feat:消费机支付卡顿的问题

This commit is contained in:
dzc committed 2026-10-23 17:13:48 +08:00
1 parent 42e0323080
commit 710e4cfb90
29 files changed
+1219 -84

No files matched your search

@@ -78,6 +78,16 @@ public class HardCreateOrderDto {
@Schema(description = "额外价格(分)")
private Integer extraPrice;
/**
* 可选收费校验协议。旧终端不传时保留原有 extraPrice 协议;1 表示在食堂线下
* 下单前使用服务端商品快照核对当前收费规则,不一致时拒绝而不改写实付金额。
*/
@Schema(description = "收费校验协议版本,旧终端不传,新终端传1")
private Integer discountPricingVersion;
@Schema(description = "终端已确认的应付总额(分),收费校验版本1时必填,旧终端不传")
private Integer confirmedPayableAmount;
@Schema(description = "离线支付是否成功")
private Boolean offlinePaySuccess;
@@ -0,0 +1,23 @@
package com.bsd.pjcloud.hard.controller.advice;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.service.order.TerminalDiscountPricingException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** 优先于通用 500 处理器,避免明确未扣款的报价拒绝触发终端超时对账。 */
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice
public class TerminalDiscountPricingExceptionHandler {
public static final int BUSINESS_CODE = 40901;
@ExceptionHandler(TerminalDiscountPricingException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public R<Void> handle(TerminalDiscountPricingException failure) {
return R.failed(BUSINESS_CODE, failure.getMessage());
}
}
@@ -50,7 +50,9 @@ import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/**
@@ -310,6 +312,7 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
public HardOrderCartSnapshot prepareCardWithCompute(HardTypeEnum sysType, HardOrderTypeEnum orderType, List<HardCreateCarDto> list) {
List<Long> productIds = list.stream().map(HardCreateCarDto::getProductId).toList();
List<HardCarEntity> carObjectList = new ArrayList<>();
Map<Long, String> serverPriceIncreaseFlags = new HashMap<>();
StringBuffer subject = new StringBuffer();
AtomicInteger totalPrice = new AtomicInteger();
// 食堂
@@ -335,6 +338,8 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
List<CanteenSpuEntity> supList = canteenSpuService.getSpuListByIds(productIds).getData();
for (HardCreateCarDto v : list) {
supList.stream().filter(i -> ObjectUtil.equals(i.getId(), v.getProductId())).findFirst().ifPresent(spu -> {
serverPriceIncreaseFlags.put(spu.getId(), YesNoEnum.YES.getCode().equals(spu.getIsPriceIncreased())
? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode());
int itemTotalPrice = v.getProductCount() * spu.getPrice();
totalPrice.addAndGet(itemTotalPrice);
subject.append(spu.getName()).append("x").append(v.getProductCount()).append(";");
@@ -378,7 +383,8 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
if (carObjectList.size() != list.size()) {
throw new RuntimeException("部分商品不存在,请刷新商品后重新结算");
}
return new HardOrderCartSnapshot(totalPrice.get(), subject.toString(), carObjectList);
return new HardOrderCartSnapshot(totalPrice.get(), subject.toString(), carObjectList,
Map.copyOf(serverPriceIncreaseFlags));
}
/**
@@ -741,4 +747,4 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
private HardCarServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}
@@ -73,6 +73,8 @@ import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.service.order.HardOrderCartSnapshot;
import com.bsd.pjcloud.hard.service.order.TerminalDiscountPricing;
import com.bsd.pjcloud.hard.service.order.TerminalDiscountPricingException;
import com.bsd.pjcloud.hard.support.PaymentTimingTrace;
import com.bsd.pjcloud.supermarket.api.feign.RemoteMallProductService;
import com.bsd.pjcloud.supermarket.api.feign.RemoteShopSkuService;
@@ -282,6 +284,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (dto.getAndroidOrderCode() == null || dto.getAndroidOrderCode().isBlank()) {
throw new RuntimeException("安卓订单code不能为空");
}
TerminalDiscountPricing.validateVersion(dto.getDiscountPricingVersion());
if (TerminalDiscountPricing.enabled(dto) && dto.getConfirmedPayableAmount() == null) {
throw new TerminalDiscountPricingException("缺少已确认的应付金额,请更新客户端");
}
RLock lock = redissonClient.getLock(keyStrResolver.extract("hard:order:create:" + dto.getHardwareId() + ":" + dto.getAndroidOrderCode(), ":"));
if (!PaymentTimingTrace.call("redis.lock", lock::tryLock)) throw new RuntimeException("正在创建订单中,请勿重复操作");
Long orderId;
@@ -390,6 +396,28 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
() -> hardCarService.prepareCard(dto.getSysType(), dto.getOrderType(), dto.getList())));
}
int extraPrice = isExtraPrice && dto.getExtraPrice() != null ? dto.getExtraPrice() : 0;
if (TerminalDiscountPricing.enabled(dto)) {
if (sysUser == null) {
throw new TerminalDiscountPricingException("该支付方式不支持当前收费校验,请更新客户端");
}
Long userDeptId = sysUser.getDeptId();
List<WalletXfjDiscounts> rules = PaymentTimingTrace.call("prepare.discountRules",
() -> {
try {
return requireCreateOrderData(walletSettingService.getDiscountsListByDeptId(
String.valueOf(dto.getHardwareId()), userDeptId), "收费规则查询失败,请稍后重试");
} catch (RuntimeException unavailable) {
// 此时尚未进入写事务,明确拒绝并允许刷新;不能让终端把它当作扣款结果未知。
throw new TerminalDiscountPricingException("收费规则查询失败,请稍后重试", unavailable);
}
});
TerminalDiscountPricing.useServerProductFlags(cart);
int expectedExtraPrice = TerminalDiscountPricing.computeExtraPrice(cart, rules,
String.valueOf(dto.getHourType()));
TerminalDiscountPricing.requireMatchingExtraPrice(dto.getExtraPrice(), expectedExtraPrice);
TerminalDiscountPricing.requireMatchingPayableAmount(dto.getConfirmedPayableAmount(),
cart.totalPrice(), expectedExtraPrice);
}
int payPrice = Math.addExact(cart.totalPrice(), extraPrice);
if (isWalletPay) {
if (payPrice <= 0) throw new RuntimeException("支付金额为0元");
@@ -549,6 +577,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|| (contextUsesSelectedWallet(dto) && !Objects.equals(order.getWalletId(), dto.getWalletId()))) {
throw new RuntimeException("该终端订单号已存在不同内容的订单,请重新结算");
}
if (TerminalDiscountPricing.enabled(dto)) {
TerminalDiscountPricing.requireMatchingPayableAmount(dto.getConfirmedPayableAmount(),
order.getTotalPrice(), order.getExtraPrice());
}
List<HardCarEntity> savedItems = hardCarService.getByParentCarId(order.getParentCarId());
if (savedItems == null) savedItems = List.of();
Map<TerminalCartLine, Integer> saved = new HashMap<>(), requested = new HashMap<>();
@@ -557,7 +589,12 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
for (HardCreateCarDto item : dto.getList()) {
BigDecimal weight = dto.getSysType() == HardTypeEnum.CANTEEN ? BigDecimal.ZERO : item.getProductWeight();
requested.merge(terminalCartLine(item.getProductId(), item.getProductCount(), weight, Boolean.TRUE.equals(item.getIsPriceIncreased())), 1, Integer::sum);
// V1 的分组在首次建单时取自服务端;重试比较该历史快照,不能再信任终端标记或读取现价。
boolean increased = TerminalDiscountPricing.enabled(dto)
? savedItems.stream().filter(savedItem -> Objects.equals(savedItem.getProductId(), item.getProductId()))
.findFirst().map(savedItem -> YesNoEnum.YES.getCode().equals(savedItem.getIsDiscountsProduct())).orElse(false)
: Boolean.TRUE.equals(item.getIsPriceIncreased());
requested.merge(terminalCartLine(item.getProductId(), item.getProductCount(), weight, increased), 1, Integer::sum);
}
// 旧食堂票券核销链路可能只有父购物车,没有商品明细;该场景使用前面已经比对的
// 人员、支付类型和不可变核销来源号识别原结果,不能因明细缺失重新核销一次。
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.service.order;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import java.util.List;
import java.util.Map;
/**
* 事务外读取的商品快照;金额、摘要和落库明细使用同一份价格,写事务不再重新查询商品。
@@ -10,6 +11,12 @@ import java.util.List;
* @param totalPrice 商品总额,单位为分,不含额外餐费
* @param subject 沿用终端订单原有格式的商品摘要
* @param items 尚未绑定父购物车的明细,仅允许当前下单请求使用
* @param serverPriceIncreaseFlags 同次商品查询取得的服务端涨价标记,仅新收费校验协议使用
*/
public record HardOrderCartSnapshot(int totalPrice, String subject, List<HardCarEntity> items) {
public record HardOrderCartSnapshot(int totalPrice, String subject, List<HardCarEntity> items,
Map<Long, String> serverPriceIncreaseFlags) {
/** 旧调用入口不使用新收费校验,保留原有构造协议。 */
public HardOrderCartSnapshot(int totalPrice, String subject, List<HardCarEntity> items) {
this(totalPrice, subject, items, Map.of());
}
}
@@ -0,0 +1,103 @@
package com.bsd.pjcloud.hard.service.order;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.hard.HardCreateOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletXfjDiscounts;
import java.util.List;
import java.util.Objects;
/** V1 保留现有 Flutter 加价公式,仅核对报价,不代替终端悄悄更改扣款金额。 */
public final class TerminalDiscountPricing {
private TerminalDiscountPricing() {
}
public static void validateVersion(Integer version) {
if (version != null && version != 1) {
throw new TerminalDiscountPricingException("不支持的收费校验版本,请更新客户端");
}
}
public static boolean enabled(HardCreateOrderDto dto) {
return Integer.valueOf(1).equals(dto.getDiscountPricingVersion())
&& dto.getSysType() == HardTypeEnum.CANTEEN
&& dto.getOrderType() == HardOrderTypeEnum.BELOW;
}
/** 仅 V1 调用;本次落库分组与服务端同次商品查询保持一致,旧机器仍沿用请求分组。 */
public static void useServerProductFlags(HardOrderCartSnapshot cart) {
if (cart == null || cart.items() == null || cart.serverPriceIncreaseFlags() == null) {
throw new TerminalDiscountPricingException("商品收费标记不存在,请重新结算");
}
for (HardCarEntity item : cart.items()) {
if (item == null || !cart.serverPriceIncreaseFlags().containsKey(item.getProductId())) {
throw new TerminalDiscountPricingException("商品收费标记不存在,请重新结算");
}
item.setIsDiscountsProduct(YesNoEnum.YES.getCode().equals(
cart.serverPriceIncreaseFlags().get(item.getProductId()))
? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode());
}
}
/**
* 规则按餐次逐条累计;仅 extraPer>1 加价。basePrice 沿用阈值语义,触发后对
* 全部符合条件金额计算 round(total*(rate-1)),不改成“只对超出部分”计费。
*/
public static int computeExtraPrice(HardOrderCartSnapshot cart, List<WalletXfjDiscounts> rules,
String hourType) {
if (cart == null || cart.items() == null || rules == null) {
throw new TerminalDiscountPricingException("收费规则或商品快照不存在,请重新结算");
}
int extraPrice = 0;
for (WalletXfjDiscounts rule : rules) {
if (rule == null) throw new TerminalDiscountPricingException("收费规则配置异常,请联系管理员");
if (!Objects.equals(rule.getHourType(), hourType)) continue;
Double rate = rule.getExtraPer();
if (rate == null || rate <= 1.0) continue;
if (!Double.isFinite(rate)) throw new TerminalDiscountPricingException("收费倍数配置异常,请联系管理员");
boolean includeSpecialDish = YesNoEnum.YES.getCode().equals(rule.getSpecialDishEnable());
long eligiblePrice = 0;
for (HardCarEntity item : cart.items()) {
if (item == null || item.getTotalPrice() == null || item.getTotalPrice() < 0) {
throw new TerminalDiscountPricingException("商品金额异常,请重新结算");
}
if (includeSpecialDish || YesNoEnum.YES.getCode().equals(item.getIsDiscountsProduct())) {
try {
eligiblePrice = Math.addExact(eligiblePrice, item.getTotalPrice());
} catch (ArithmeticException overflow) {
throw new TerminalDiscountPricingException("商品金额超出范围", overflow);
}
}
}
if (eligiblePrice <= rule.getBasePrice()) continue;
double adjustment = eligiblePrice * (rate - 1.0);
if (!Double.isFinite(adjustment)) throw new TerminalDiscountPricingException("额外收费金额超出范围");
long rounded = Math.round(adjustment);
if (rounded < 0 || rounded > Integer.MAX_VALUE) {
throw new TerminalDiscountPricingException("额外收费金额超出范围");
}
try {
extraPrice = Math.addExact(extraPrice, (int) rounded);
} catch (ArithmeticException overflow) {
throw new TerminalDiscountPricingException("额外收费金额超出范围", overflow);
}
}
return extraPrice;
}
public static void requireMatchingExtraPrice(Integer submitted, int expected) {
if ((submitted == null ? 0 : submitted) != expected) {
throw new TerminalDiscountPricingException("收费规则或商品价格已变化,请重新确认金额后支付");
}
}
public static void requireMatchingPayableAmount(Integer submitted, int totalPrice, int expectedExtra) {
long expected = (long) totalPrice + expectedExtra;
if (submitted == null || expected < 0 || expected > Integer.MAX_VALUE || submitted.longValue() != expected) {
throw new TerminalDiscountPricingException("应付金额已变化,请重新确认金额后支付");
}
}
}
@@ -0,0 +1,12 @@
package com.bsd.pjcloud.hard.service.order;
/** 仅用于新终端收费协议在交易写入前的明确拒绝,不表示扣款结果未知。 */
public class TerminalDiscountPricingException extends IllegalArgumentException {
public TerminalDiscountPricingException(String message) {
super(message);
}
public TerminalDiscountPricingException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,47 @@
package com.bsd.pjcloud.hard.controller.advice;
import com.bsd.pjcloud.common.sentinel.handle.GlobalBizExceptionHandler;
import com.bsd.pjcloud.hard.service.order.TerminalDiscountPricingException;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class TerminalDiscountPricingExceptionHandlerTest {
private final MockMvc mvc = MockMvcBuilders.standaloneSetup(new ProbeController())
// Deliberately register the generic handler first: @Order must choose the pricing handler.
.setControllerAdvice(new GlobalBizExceptionHandler(), new TerminalDiscountPricingExceptionHandler())
.build();
@Test
void explicitPricingRejectionUses409AndStableBusinessCodeInsteadOfGeneric500() throws Exception {
mvc.perform(get("/test-pricing-rejection"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40901))
.andExpect(jsonPath("$.msg").value("收费规则或商品价格已变化,请重新确认金额后支付"));
}
@Test
void unrelatedFailuresKeepExistingGeneric500Behavior() throws Exception {
mvc.perform(get("/test-unrelated-failure"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value(1));
}
@RestController
static class ProbeController {
@GetMapping("/test-pricing-rejection")
public String pricing() {
throw new TerminalDiscountPricingException("收费规则或商品价格已变化,请重新确认金额后支付");
}
@GetMapping("/test-unrelated-failure")
public String unrelated() {
throw new IllegalStateException("original failure");
}
}
}
@@ -20,13 +20,16 @@ import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.order.HardOrderCartSnapshot;
import com.bsd.pjcloud.hard.service.order.TerminalDiscountPricingException;
import com.bsd.pjcloud.hard.support.PaymentTimingTrace;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletSettingService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.WalletXfjDiscounts;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -40,6 +43,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
@@ -57,6 +61,7 @@ class HardOrderPaymentTimingRegressionTest {
private RemoteUserService users;
private RemoteWalletService wallets;
private RemoteWalletTransactionService payments;
private RemoteWalletSettingService discountRules;
private RLock lock;
private GenericApplicationContext context;
private ApplicationContext previousContext;
@@ -72,6 +77,7 @@ class HardOrderPaymentTimingRegressionTest {
users = mock(RemoteUserService.class);
wallets = mock(RemoteWalletService.class);
payments = mock(RemoteWalletTransactionService.class);
discountRules = mock(RemoteWalletSettingService.class);
RedissonClient redis = mock(RedissonClient.class);
KeyStrResolver keys = mock(KeyStrResolver.class);
lock = mock(RLock.class);
@@ -86,6 +92,7 @@ class HardOrderPaymentTimingRegressionTest {
ReflectionTestUtils.setField(service, "userService", users);
ReflectionTestUtils.setField(service, "remoteWalletService", wallets);
ReflectionTestUtils.setField(service, "walletTransactionService", payments);
ReflectionTestUtils.setField(service, "walletSettingService", discountRules);
ReflectionTestUtils.setField(service, "redissonClient", redis);
ReflectionTestUtils.setField(service, "keyStrResolver", keys);
// Only resolve the existing getSelf() lookup; no Boot, transaction or remote connection starts.
@@ -205,9 +212,171 @@ class HardOrderPaymentTimingRegressionTest {
verify(service, times(1)).createOrderInTransaction(any(), any());
verify(service, times(1)).orderSuccess(any(), anyBoolean());
verify(service, times(1)).createOrderAfterSendMqMsg(anyLong(), any());
verifyZeroInteractions(discountRules);
assertTrue(logs.get(0).contains("outcome:success"));
}
@Test
void unknownPricingVersionFailsBeforeLockOrAnyBusinessRead() {
HardCreateOrderDto dto = request();
dto.setDiscountPricingVersion(2);
assertThrows(IllegalArgumentException.class, () -> service.createOrder(dto));
verifyZeroInteractions(lock, orders, carts, managers, merchants, users, wallets, payments, discountRules);
}
@Test
void legacyPricingStillUsesSuppliedAmountWithoutLookingUpNewRules() {
stubPreparedOrder();
stubSuccessfulWrite();
HardCreateOrderDto dto = request();
dto.setExtraPrice(150);
assertEquals(100L, service.createOrder(dto));
verify(payments).reduceWalletBalance(argThat(payment -> payment.getPrice() == 500));
verifyZeroInteractions(discountRules);
}
@Test
void newPricingMismatchStopsBeforeCartWriteStockOrDebit() {
stubPreparedOrder();
stubDiscountRules();
HardCreateOrderDto dto = request();
dto.setDiscountPricingVersion(1);
dto.setExtraPrice(174);
dto.setConfirmedPayableAmount(524);
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> service.createOrder(dto));
assertTrue(failure.getMessage().contains("重新确认金额"));
assertEquals(174, dto.getExtraPrice());
verify(discountRules).getDiscountsListByDeptId("3", 44L);
verify(users, times(1)).getInnerByUserId(4L);
verify(carts, never()).createCardFromSnapshot(any(), any(), any(), any());
verify(orders, never()).insert(any(HardOrderEntity.class));
verify(service, never()).fastenerInventory(any(), any(YesNoEnum.class));
verifyZeroInteractions(payments);
verify(lock).unlock();
}
@Test
void failedRuleReadNeverBecomesZeroAndKeepsFailureCause() {
stubPreparedOrder();
RuntimeException unavailable = new RuntimeException("rules unavailable");
when(discountRules.getDiscountsListByDeptId("3", 44L)).thenThrow(unavailable);
HardCreateOrderDto dto = request();
dto.setDiscountPricingVersion(1);
dto.setExtraPrice(0);
dto.setConfirmedPayableAmount(350);
assertSame(unavailable, assertThrows(TerminalDiscountPricingException.class,
() -> service.createOrder(dto)).getCause());
verify(orders, never()).insert(any(HardOrderEntity.class));
verifyZeroInteractions(payments);
verify(lock).unlock();
}
@Test
void businessFailureRuleResponseAlsoStopsBeforePayment() {
stubPreparedOrder();
when(discountRules.getDiscountsListByDeptId("3", 44L)).thenReturn(R.failed("rules unavailable"));
HardCreateOrderDto dto = request();
dto.setDiscountPricingVersion(1);
dto.setExtraPrice(0);
dto.setConfirmedPayableAmount(350);
assertThrows(RuntimeException.class, () -> service.createOrder(dto));
verify(orders, never()).insert(any(HardOrderEntity.class));
verifyZeroInteractions(payments);
}
@Test
void newPricingUsesServerGroupingAndOnePreparedProductSnapshot() {
stubPreparedOrder();
stubDiscountRules();
stubSuccessfulWrite();
HardCreateOrderDto dto = request();
dto.setDiscountPricingVersion(1);
dto.setExtraPrice(175);
dto.setConfirmedPayableAmount(525);
dto.getList().get(0).setIsPriceIncreased(false);
assertEquals(100L, service.createOrder(dto));
verify(carts, times(1)).prepareCardWithCompute(any(), any(), anyList());
verify(discountRules, times(1)).getDiscountsListByDeptId("3", 44L);
verify(users, times(1)).getInnerByUserId(4L);
verify(carts).createCardFromSnapshot(any(), any(), any(), argThat(cart ->
"1".equals(cart.items().get(0).getIsDiscountsProduct())));
verify(payments).reduceWalletBalance(argThat(payment -> payment.getPrice() == 525));
}
@Test
void alreadyPaidNewProtocolRetryUsesHistoricGroupingWithoutRuleReloadOrDebit() {
HardOrderEntity paid = HardOrderEntity.builder().id(100L).parentCarId(10L)
.hardId(3L).merchantId(2L).sysType(HardTypeEnum.CANTEEN.getType())
.hourType("1").orderType(HardOrderTypeEnum.BELOW.getType())
.payType(HardPayTypeEnum.FACE.getType()).userId(4L)
.totalPrice(350).extraPrice(175).orderStatus("5").build();
when(orders.selectOne(any(Wrapper.class))).thenReturn(paid);
HardCarEntity item = snapshot().items().get(0);
item.setIsDiscountsProduct("1");
when(carts.getByParentCarId(10L)).thenReturn(List.of(item));
HardCreateOrderDto dto = request();
dto.setDiscountPricingVersion(1);
dto.setExtraPrice(175);
dto.setConfirmedPayableAmount(525);
dto.getList().get(0).setIsPriceIncreased(false);
assertEquals(100L, service.createOrder(dto));
verifyZeroInteractions(discountRules, users, wallets, payments);
verify(service, never()).createOrderInTransaction(any(), any());
}
@Test
void changedBasePriceIsRejectedEvenWhenExtraPriceRemainsZero() {
stubPreparedOrder();
when(discountRules.getDiscountsListByDeptId("3", 44L)).thenReturn(R.ok(List.of()));
HardCreateOrderDto dto = request();
dto.setDiscountPricingVersion(1);
dto.setExtraPrice(0);
dto.setConfirmedPayableAmount(349);
TerminalDiscountPricingException failure = assertThrows(TerminalDiscountPricingException.class,
() -> service.createOrder(dto));
assertTrue(failure.getMessage().contains("应付金额已变化"));
assertEquals(349, dto.getConfirmedPayableAmount());
verify(orders, never()).insert(any(HardOrderEntity.class));
verifyZeroInteractions(payments);
}
@Test
void successfulEmptyRulesAllowZeroSurchargeWithMatchingConfirmedTotal() {
stubPreparedOrder();
stubSuccessfulWrite();
when(discountRules.getDiscountsListByDeptId("3", 44L)).thenReturn(R.ok(List.of()));
HardCreateOrderDto dto = request();
dto.setDiscountPricingVersion(1);
dto.setExtraPrice(0);
dto.setConfirmedPayableAmount(350);
assertEquals(100L, service.createOrder(dto));
verify(payments).reduceWalletBalance(argThat(payment -> payment.getPrice() == 350));
verify(discountRules, times(1)).getDiscountsListByDeptId("3", 44L);
}
@Test
void newVersionRequiresConfirmedTotalBeforeAnyBusinessRead() {
HardCreateOrderDto dto = request();
dto.setDiscountPricingVersion(1);
assertThrows(TerminalDiscountPricingException.class, () -> service.createOrder(dto));
verifyZeroInteractions(lock, orders, carts, merchants, users, wallets, payments, discountRules);
}
private void stubSuccessfulWrite() {
when(orders.insert(any(HardOrderEntity.class))).thenAnswer(call -> {
call.<HardOrderEntity>getArgument(0).setId(100L);
return 1;
});
when(payments.reduceWalletBalance(any())).thenReturn(R.ok(new WalletTransactionEntity()));
}
private void stubDiscountRules() {
when(discountRules.getDiscountsListByDeptId("3", 44L)).thenReturn(R.ok(List.of(
WalletXfjDiscounts.builder().hourType("1").basePrice(0).extraPer(1.5)
.specialDishEnable("0").build())));
}
private void stubPreparedOrder() {
MchManageVo merchant = new MchManageVo();
merchant.setId(2L);
@@ -215,13 +384,14 @@ class HardOrderPaymentTimingRegressionTest {
when(managers.getById(3L)).thenReturn(new HardManagerEntity());
SysUser user = new SysUser();
user.setUserId(4L);
user.setDeptId(44L);
UserInfo info = new UserInfo();
info.setSysUser(user);
when(users.getInnerByUserId(4L)).thenReturn(R.ok(info));
WalletManageEntity wallet = new WalletManageEntity();
wallet.setId(5L);
wallet.setUserId(4L);
wallet.setBalance(500);
wallet.setBalance(1000);
when(wallets.getWallet(eq(4L), any())).thenReturn(R.ok(wallet));
when(carts.prepareCardWithCompute(any(), any(), anyList())).thenReturn(snapshot());
when(carts.createCardFromSnapshot(any(), any(), any(), any())).thenReturn(10L);
@@ -262,6 +432,6 @@ class HardOrderPaymentTimingRegressionTest {
private HardOrderCartSnapshot snapshot() {
HardCarEntity item = HardCarEntity.builder().productId(6L).productCount(1).productWeight(BigDecimal.ZERO)
.productPrice(350).totalPrice(350L).isDiscountsProduct(YesNoEnum.NO.getCode()).build();
return new HardOrderCartSnapshot(350, "饭菜x1;", List.of(item));
return new HardOrderCartSnapshot(350, "饭菜x1;", List.of(item), Map.of(6L, "1"));
}
}
@@ -36,11 +36,15 @@ class HardTerminalCartSnapshotTest {
product.setId(1L);
product.setName("饭菜");
product.setPrice(350);
product.setIsPriceIncreased("1");
when(products.getSpuListByIds(any())).thenReturn(R.ok(List.of(product)));
HardCreateCarDto item = item(1, BigDecimal.ZERO);
var snapshot = service.prepareCardWithCompute(HardTypeEnum.CANTEEN, HardOrderTypeEnum.BELOW, List.of(item));
assertEquals(350, snapshot.totalPrice());
assertEquals("饭菜x1;", snapshot.subject());
// 同次查询带回权威标记;旧路径的明细分组仍沿用终端传参,不被新协议改变。
assertEquals("1", snapshot.serverPriceIncreaseFlags().get(1L));
assertEquals("0", snapshot.items().get(0).getIsDiscountsProduct());
verifyNoMoreInteractions(items, parents);
product.setPrice(999);
when(parents.insert(any(HardParentCarEntity.class))).thenAnswer(call -> {
@@ -0,0 +1,124 @@
package com.bsd.pjcloud.hard.service.order;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.hard.HardCreateOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletXfjDiscounts;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class TerminalDiscountPricingTest {
@Test
void legacyVersionStaysOptionalAndUnknownVersionsAreRejected() {
assertDoesNotThrow(() -> TerminalDiscountPricing.validateVersion(null));
assertDoesNotThrow(() -> TerminalDiscountPricing.validateVersion(1));
assertThrows(IllegalArgumentException.class, () -> TerminalDiscountPricing.validateVersion(0));
assertThrows(IllegalArgumentException.class, () -> TerminalDiscountPricing.validateVersion(2));
HardCreateOrderDto dto = HardCreateOrderDto.builder().sysType(HardTypeEnum.CANTEEN)
.orderType(HardOrderTypeEnum.BELOW).build();
assertFalse(TerminalDiscountPricing.enabled(dto));
dto.setDiscountPricingVersion(1);
assertTrue(TerminalDiscountPricing.enabled(dto));
dto.setOrderType(HardOrderTypeEnum.RESERVE);
assertFalse(TerminalDiscountPricing.enabled(dto));
dto.setOrderType(HardOrderTypeEnum.BELOW);
dto.setSysType(HardTypeEnum.SUPERMARKET);
assertFalse(TerminalDiscountPricing.enabled(dto));
}
@Test
void thresholdRetainsExistingWholeEligibleAmountFormula() {
WalletXfjDiscounts rule = rule("2", 1000, 1.5, true);
assertEquals(0, price(cart(1000, 0), rule));
// The current protocol applies rate-1 to all 1500, not just the 500 above the threshold.
assertEquals(750, price(cart(1500, 0), rule));
}
@Test
void hourTypeAndSpecialDishRulesAreAppliedAndSummedSeparately() {
HardOrderCartSnapshot cart = cart(300, 200);
assertEquals(150, price(cart, rule("2", 0, 1.5, false)));
assertEquals(250, price(cart, rule("2", 0, 1.5, true)));
assertEquals(0, price(cart, rule("1", 0, 2.0, true)));
assertEquals(400, TerminalDiscountPricing.computeExtraPrice(cart,
List.of(rule("2", 0, 1.5, false), rule("2", 0, 1.5, true)), "2"));
}
@Test
void neutralAndBelowOneRatesDoNotIntroduceDiscountsIntoSurchargeProtocol() {
assertEquals(0, TerminalDiscountPricing.computeExtraPrice(cart(300, 200), List.of(
rule("2", 0, 0.8, true), rule("2", 0, 1.0, true), rule("2", 0, null, true)), "2"));
assertEquals(0, TerminalDiscountPricing.computeExtraPrice(cart(300, 200), List.of(), "2"));
assertEquals(2, TerminalDiscountPricing.computeExtraPrice(cart(1, 0),
List.of(rule("2", 0, 1.5, true), rule("2", 0, 1.5, true)), "2"));
}
@Test
void serverProductFlagsOverrideClientGroupingWithoutChangingPriceOrQuantity() {
HardOrderCartSnapshot cart = cart(300, 200);
cart.items().get(0).setIsDiscountsProduct("0");
cart.items().get(1).setIsDiscountsProduct("1");
TerminalDiscountPricing.useServerProductFlags(cart);
assertEquals("1", cart.items().get(0).getIsDiscountsProduct());
assertEquals("0", cart.items().get(1).getIsDiscountsProduct());
assertEquals(300L, cart.items().get(0).getTotalPrice());
assertEquals(150, price(cart, rule("2", 0, 1.5, false)));
assertThrows(IllegalArgumentException.class, () -> TerminalDiscountPricing.useServerProductFlags(
new HardOrderCartSnapshot(300, "legacy", cart.items())));
}
@Test
void missingRulesNonFiniteRatesAndOverflowFailRatherThanBecomingZero() {
assertThrows(IllegalArgumentException.class,
() -> TerminalDiscountPricing.computeExtraPrice(cart(300, 0), null, "2"));
assertThrows(IllegalArgumentException.class,
() -> price(cart(300, 0), rule("2", 0, Double.NaN, true)));
assertThrows(IllegalArgumentException.class,
() -> price(cart(300, 0), rule("2", 0, Double.POSITIVE_INFINITY, true)));
assertThrows(IllegalArgumentException.class,
() -> price(cart(Integer.MAX_VALUE, 0), rule("2", 0, 3.0, true)));
}
@Test
void mismatchedOrNegativeSubmittedAmountsAreRejectedWithoutChangingThem() {
assertDoesNotThrow(() -> TerminalDiscountPricing.requireMatchingExtraPrice(null, 0));
assertDoesNotThrow(() -> TerminalDiscountPricing.requireMatchingExtraPrice(150, 150));
assertThrows(IllegalArgumentException.class,
() -> TerminalDiscountPricing.requireMatchingExtraPrice(149, 150));
assertThrows(IllegalArgumentException.class,
() -> TerminalDiscountPricing.requireMatchingExtraPrice(-1, 0));
}
@Test
void confirmedTotalProtectsBasePriceChangesEvenWithZeroSurcharge() {
assertDoesNotThrow(() -> TerminalDiscountPricing.requireMatchingPayableAmount(300, 300, 0));
assertThrows(TerminalDiscountPricingException.class,
() -> TerminalDiscountPricing.requireMatchingPayableAmount(299, 300, 0));
assertThrows(TerminalDiscountPricingException.class,
() -> TerminalDiscountPricing.requireMatchingPayableAmount(null, 300, 0));
assertThrows(TerminalDiscountPricingException.class,
() -> TerminalDiscountPricing.requireMatchingPayableAmount(Integer.MAX_VALUE,
Integer.MAX_VALUE, 1));
}
private int price(HardOrderCartSnapshot cart, WalletXfjDiscounts rule) {
return TerminalDiscountPricing.computeExtraPrice(cart, List.of(rule), "2");
}
private HardOrderCartSnapshot cart(int regular, int special) {
return new HardOrderCartSnapshot(regular + special, "test", List.of(
HardCarEntity.builder().productId(1L).totalPrice((long) regular).isDiscountsProduct("1").build(),
HardCarEntity.builder().productId(2L).totalPrice((long) special).isDiscountsProduct("0").build()),
Map.of(1L, "1", 2L, "0"));
}
private WalletXfjDiscounts rule(String hourType, int basePrice, Double rate, boolean specialDish) {
return WalletXfjDiscounts.builder().hourType(hourType).basePrice(basePrice).extraPer(rate)
.specialDishEnable(specialDish ? "1" : "0").build();
}
}
@@ -24,6 +24,7 @@ import com.bsd.pjcloud.admin.api.dto.UserDeptSimpleInfo;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.vo.UserSimpleVo;
import com.bsd.pjcloud.admin.api.vo.UserDepartmentVo;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
@@ -71,6 +72,11 @@ public interface RemoteUserService {
@GetMapping("/user/inner/get-by-user-id")
R<UserInfo> getInnerByUserId(@RequestParam("userId") Long userId);
/** 计价所需的实时部门快照,避免查询角色与菜单权限。 */
@NoToken
@GetMapping("/user/inner/get-dept-by-user-id")
R<UserDepartmentVo> getInnerDepartmentByUserId(@RequestParam("userId") Long userId);
@GetMapping("/user/get-by-user-id")
R<UserInfo> getByUserId(@RequestParam("userId") Long userId);
@@ -0,0 +1,10 @@
package com.bsd.pjcloud.admin.api.vo;
import lombok.Data;
/** 消费计价只需要人员存在性与所属部门,不返回账号、钱包或角色权限。 */
@Data
public class UserDepartmentVo {
private Long userId;
private Long deptId;
}
@@ -190,6 +190,20 @@ public class SysUserController {
return R.ok(userService.findUserInfo(user));
}
/** 仅查询消费计价所需的人员主键与部门,保留现有全量用户接口的协议。 */
@Inner
@GetMapping("/inner/get-dept-by-user-id")
public R<UserDepartmentVo> getInnerDepartmentByUserId(@RequestParam Long userId) {
SysUser user = userService.getOne(Wrappers.<SysUser>lambdaQuery()
.select(SysUser::getUserId, SysUser::getDeptId)
.eq(SysUser::getUserId, userId));
if (user == null) return R.failed(MsgUtils.getMessage(ErrorCodes.SYS_USER_USERINFO_EMPTY, userId));
UserDepartmentVo result = new UserDepartmentVo();
result.setUserId(user.getUserId());
result.setDeptId(user.getDeptId());
return R.ok(result);
}
/**
* 根据手机号查询用户
*/
@@ -23,4 +23,10 @@ public interface RemoteWalletSettingService {
@NoToken
@GetMapping("/wallet-setting/remote/get-discounts-list")
R<List<WalletXfjDiscounts>> remoteGetDiscountsList(@RequestParam String hardId, @RequestParam Long userId, @RequestParam(required = false) Long userDeptId);
/** 使用服务端已查询的人员部门,避免计价时重复获取人员资料。 */
@NoToken
@GetMapping("/wallet-setting/remote/get-discounts-list-by-dept")
R<List<WalletXfjDiscounts>> getDiscountsListByDeptId(@RequestParam("hardId") String hardId,
@RequestParam(value = "deptId", required = false) Long deptId);
}
@@ -48,6 +48,13 @@ public class WalletSettingController {
return R.ok(walletSettingService.androidGetDiscountsList(hardId, userId));
}
@Inner
@GetMapping("/remote/get-discounts-list-by-dept")
public R<List<WalletXfjDiscounts>> getDiscountsListByDeptId(@RequestParam String hardId,
@RequestParam(required = false) Long deptId) {
return R.ok(walletSettingService.getDiscountsListByDeptId(hardId, deptId));
}
/* ************************************* end 远程调用 end ************************************* */
/* ************************************* start app start ************************************* */
@@ -92,4 +99,4 @@ public class WalletSettingController {
return R.ok(true);
}
}
}
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.wallet.mapper.mch.view;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.dto.mch.WalletMchDeptViewDto;
import com.bsd.pjcloud.wallet.api.entity.mch.view.WalletMchDeptViewEntity;
@@ -16,4 +17,8 @@ public interface MchDeptViewMapper {
WalletMchDeptViewVo selectByDeptId(@Param("deptId") Long deptId);
/** SQL 显式约束递归起点、祖先部门及商户租户,避免依赖视图的隐式租户处理。 */
@InterceptorIgnore(tenantLine = "true")
boolean existsCanteenForDepartment(@Param("deptId") Long deptId, @Param("tenantId") Long tenantId);
}
@@ -49,6 +49,9 @@ public interface MchManageService extends IService<MchManageEntity> {
*/
MchManageVo getViewsCanteenMchByDeptId(Long deptId);
/** 实时判断本部门或祖先部门是否绑定自营食堂,不复用完整商户的长期缓存。 */
boolean isSelfCateringDepartment(Long deptId);
/**
* 刷新商户二维码
*/
@@ -15,7 +15,10 @@ public interface WalletSettingService extends IService<WalletSettingEntity> {
*/
List<WalletXfjDiscounts> androidGetDiscountsList(String hardId, Long userId);
/** 内部计价复用同一规则选择,部门为空仍按既有非自营口径处理。 */
List<WalletXfjDiscounts> getDiscountsListByDeptId(String hardId, Long deptId);
WalletSettingVo getSetting();
void edit(WalletSettingDto dto);
}
}
@@ -0,0 +1,79 @@
package com.bsd.pjcloud.wallet.service.impl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/** 每次优惠请求独立计时;仅记录慢请求和失败,不包含人员、金额或凭证。 */
final class WalletDiscountTiming {
static final String LOGGER_NAME = "com.bsd.pjcloud.wallet.discountTiming";
private static final Logger LOGGER = LoggerFactory.getLogger(LOGGER_NAME);
private static final long SLOW_NANOS = 1_000_000_000L;
private final String method;
private final LongSupplier nanoTime;
private final Logger logger;
private final long started;
private long userNanos;
private long settingNanos;
private long departmentNanos;
private boolean finished;
static WalletDiscountTiming start(String method) {
return new WalletDiscountTiming(method, System::nanoTime, LOGGER);
}
WalletDiscountTiming(String method, LongSupplier nanoTime, Logger logger) {
this.method = method;
this.nanoTime = nanoTime;
this.logger = logger;
this.started = nanoTime.getAsLong();
}
<T> T user(Supplier<T> action) {
long segmentStarted = nanoTime.getAsLong();
try {
return action.get();
} finally {
userNanos += nanoTime.getAsLong() - segmentStarted;
}
}
<T> T setting(Supplier<T> action) {
long segmentStarted = nanoTime.getAsLong();
try {
return action.get();
} finally {
settingNanos += nanoTime.getAsLong() - segmentStarted;
}
}
<T> T department(Supplier<T> action) {
long segmentStarted = nanoTime.getAsLong();
try {
return action.get();
} finally {
departmentNanos += nanoTime.getAsLong() - segmentStarted;
}
}
void finish(Throwable failure) {
if (finished) return;
finished = true;
long totalNanos = nanoTime.getAsLong() - started;
if (failure == null && totalNanos < SLOW_NANOS) return;
// 观测失败不能改变收费结果,也不能覆盖原始业务异常。
try {
logger.info("[终端优惠耗时] method={} totalMs={} userMs={} settingMs={} departmentMs={} outcome={} exceptionType={}",
method, millis(totalNanos), millis(userNanos), millis(settingNanos), millis(departmentNanos),
failure == null ? "success" : "failure", failure == null ? "none" : failure.getClass().getSimpleName());
} catch (RuntimeException ignored) {
// 独立异步日志队列或 appender 异常时不回写其他同步日志。
}
}
private static long millis(long nanos) {
return nanos / 1_000_000L;
}
}
@@ -3,17 +3,16 @@ package com.bsd.pjcloud.wallet.service.impl;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.vo.UserDepartmentVo;
import com.bsd.pjcloud.common.core.constant.CacheConstants;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.wallet.api.constant.WalletXfjDiscountsEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletSettingDto;
import com.bsd.pjcloud.wallet.api.entity.WalletSettingEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.WalletSettingVo;
import com.bsd.pjcloud.wallet.api.vo.WalletXfjDiscounts;
import com.bsd.pjcloud.wallet.mapper.WalletSettingMapper;
@@ -23,7 +22,6 @@ import com.bsd.pjcloud.wallet.service.WalletRuleJoinService;
import com.bsd.pjcloud.wallet.service.WalletSettingService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.ScanOptions;
@@ -36,13 +34,13 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* 支付设置
*/
@Validated
@Slf4j
@RequiredArgsConstructor
@Service
public class WalletSettingServiceImpl extends ServiceImpl<WalletSettingMapper, WalletSettingEntity> implements WalletSettingService {
@@ -57,80 +55,73 @@ public class WalletSettingServiceImpl extends ServiceImpl<WalletSettingMapper, W
*/
@Override
public List<WalletXfjDiscounts> androidGetDiscountsList(String hardId, Long userId) {
long startTime = System.currentTimeMillis();
List<WalletXfjDiscounts> discountsList = new ArrayList<>();
// 远程调用: 获取用户信息
long remoteUserStart = System.currentTimeMillis();
UserInfo userInfo = remoteUserService.getInnerByUserId(userId).getData();
long remoteUserEnd = System.currentTimeMillis();
log.info("[性能监控] androidGetDiscountsList - 远程调用获取用户信息耗时: {}ms", remoteUserEnd - remoteUserStart);
if (ObjectUtil.isEmpty(userInfo) || ObjectUtil.isEmpty(userInfo.getSysUser())) {
return List.of();
WalletDiscountTiming timing = WalletDiscountTiming.start("androidGetDiscountsList");
Throwable failure = null;
try {
requireTenant();
R<UserDepartmentVo> response = timing.user(() -> remoteUserService.getInnerDepartmentByUserId(userId));
if (response == null || !response.isOk() || response.getData() == null
|| !Objects.equals(userId, response.getData().getUserId()) || userId == null) {
throw new IllegalStateException("查询用户部门失败,请稍后重试");
}
return resolveDiscountsByDept(hardId, response.getData().getDeptId(), timing);
} catch (RuntimeException | Error exception) {
failure = exception;
throw exception;
} finally {
timing.finish(failure);
}
// SQL查询: 获取钱包设置
long sqlSettingStart = System.currentTimeMillis();
WalletSettingVo setting = getSetting();
long sqlSettingEnd = System.currentTimeMillis();
log.info("[性能监控] androidGetDiscountsList - SQL查询钱包设置耗时: {}ms", sqlSettingEnd - sqlSettingStart);
if (ObjectUtil.isEmpty(setting)) {
return List.of();
}
SysUser sysUser = userInfo.getSysUser();
}
// SQL查询: 获取用户所属食堂
long sqlMchStart = System.currentTimeMillis();
MchManageVo userMch = mchManageService.getViewsCanteenMchByDeptId(sysUser.getDeptId());
long sqlMchEnd = System.currentTimeMillis();
log.info("[性能监控] androidGetDiscountsList - SQL查询用户食堂信息耗时: {}ms", sqlMchEnd - sqlMchStart);
boolean isSelfCatering = ObjectUtil.isNotEmpty(userMch);
@Override
public List<WalletXfjDiscounts> getDiscountsListByDeptId(String hardId, Long deptId) {
WalletDiscountTiming timing = WalletDiscountTiming.start("getDiscountsListByDeptId");
Throwable failure = null;
try {
requireTenant();
return resolveDiscountsByDept(hardId, deptId, timing);
} catch (RuntimeException | Error exception) {
failure = exception;
throw exception;
} finally {
timing.finish(failure);
}
}
List<WalletSettingEntity.WalletXfjDiscounts> list = new ArrayList<>();
// 特殊食堂(二楼食堂)
if (ObjectUtil.isNotEmpty(setting.getSpecialXfjIdList()) && setting.getSpecialXfjIdList().contains(hardId)) {
// 自营食堂下用户
if (isSelfCatering) {
list = setting.getZbSpecialXfjDiscounts();
}
// 不在自营食堂下用户
else {
list = setting.getFzbSpecialXfjDiscounts();
}
/** 配置和归属各实时读取一次;不缓存收费结果,避免变更后金额仍沿用旧版本。 */
private List<WalletXfjDiscounts> resolveDiscountsByDept(String hardId, Long deptId, WalletDiscountTiming timing) {
WalletSettingVo setting = timing.setting(this::getSetting);
if (setting == null) throw new IllegalStateException("支付配置不存在,请稍后重试");
boolean special = ObjectUtil.isNotEmpty(setting.getSpecialXfjIdList())
&& setting.getSpecialXfjIdList().contains(hardId);
List<WalletSettingEntity.WalletXfjDiscounts> selfRules = special
? setting.getZbSpecialXfjDiscounts() : setting.getZbDefaultXfjDiscounts();
List<WalletSettingEntity.WalletXfjDiscounts> otherRules = special
? setting.getFzbSpecialXfjDiscounts() : setting.getFzbDefaultXfjDiscounts();
// 两组均无配置时无需查询归属;其他情况只查布尔,不读取完整商户或长期缓存。
boolean hasRules = ObjectUtil.isNotEmpty(selfRules) || ObjectUtil.isNotEmpty(otherRules);
boolean selfCatering = hasRules && timing.department(() -> mchManageService.isSelfCateringDepartment(deptId));
List<WalletSettingEntity.WalletXfjDiscounts> selected = selfCatering ? selfRules : otherRules;
if (selected == null) return List.of();
return selected.stream().filter(Objects::nonNull)
.filter(item -> YesNoEnum.YES.getCode().equals(item.getEnable()))
.map(item -> {
WalletXfjDiscounts result = new WalletXfjDiscounts();
result.setName("食堂差异化就餐收费管理办法");
result.setHourType(item.getHourType());
result.setType(WalletXfjDiscountsEnum.JZ_DISCOUNT.getCode());
result.setExtraPer(item.getExtraPer());
result.setBasePrice(item.getBasePrice());
result.setSpecialDishEnable(item.getSpecialDishEnable());
return result;
}).toList();
}
private void requireTenant() {
if (TenantContextHolder.getTenantId() == null) {
throw new IllegalStateException("租户信息缺失,无法查询消费规则");
}
// 默认食堂
else {
// 自营食堂下用户
if (isSelfCatering) {
list = setting.getZbDefaultXfjDiscounts();
}
// 不在自营食堂下用户
else {
list = setting.getFzbDefaultXfjDiscounts();
}
}
list = list.stream().filter(item -> ObjectUtil.isNotEmpty(item.getEnable()) && ObjectUtil.equals(YesNoEnum.YES.getCode(), item.getEnable())).toList();
if (ObjectUtil.isNotEmpty(list)) {
discountsList.addAll(
list.stream().map(item -> {
WalletXfjDiscounts entity = new WalletXfjDiscounts();
entity.setName("食堂差异化就餐收费管理办法");
entity.setHourType(item.getHourType());
entity.setType(WalletXfjDiscountsEnum.JZ_DISCOUNT.getCode());
entity.setExtraPer(item.getExtraPer());
entity.setBasePrice(item.getBasePrice());
entity.setSpecialDishEnable(item.getSpecialDishEnable());
return entity;
}).toList()
);
}
long endTime = System.currentTimeMillis();
log.info("[性能监控] androidGetDiscountsList - 总耗时: {}ms", endTime - startTime);
return discountsList;
}
@Override
@@ -222,4 +213,4 @@ public class WalletSettingServiceImpl extends ServiceImpl<WalletSettingMapper, W
private String afterSaleReturnReasons = "";
}
}
}
@@ -24,6 +24,7 @@ import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.QRCodeImageGenerator;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.data.resolver.ParamResolver;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService;
@@ -386,6 +387,15 @@ public class MchManageServiceImpl extends ServiceImpl<MchManageMapper, MchManage
return baseMapper.join(walletMchDeptView.getMchId());
}
/** 收费归属实时读取,部门或商户变更提交后下一次查询即生效,不需要子树缓存失效。 */
@Override
public boolean isSelfCateringDepartment(Long deptId) {
if (deptId == null) return false;
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) throw new IllegalStateException("租户信息缺失,无法查询食堂归属");
return mchDeptViewMapper.existsCanteenForDepartment(deptId, tenantId);
}
/**
* 刷新商户二维码
*/
@@ -38,6 +38,18 @@
</encoder>
</appender>
<!-- 优惠慢请求摘要:有界且队列满时丢弃,消费线程不等待磁盘写入。 -->
<appender name="walletDiscountTimingAsync" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>256</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<includeCallerData>false</includeCallerData>
<appender-ref ref="debug"/>
</appender>
<logger name="com.bsd.pjcloud.wallet.discountTiming" level="INFO" additivity="false">
<appender-ref ref="walletDiscountTimingAsync"/>
</logger>
<!-- Log file error output -->
<appender name="error" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/error.log</file>
@@ -62,4 +62,24 @@
order by a.level asc
limit 1
</select>
<!-- 从一个人员部门向上查祖先,避免构建全部食堂的部门树;UNION 去重也会终止脏数据中的环。 -->
<select id="existsCanteenForDepartment" resultType="boolean">
with recursive department_ancestors (dept_id, parent_id) as (
select d.dept_id, d.parent_id
from pjcloudx.sys_dept d
where d.dept_id = #{deptId} and d.tenant_id = #{tenantId} and d.del_flag = '0'
union distinct
select p.dept_id, p.parent_id
from pjcloudx.sys_dept p
inner join department_ancestors a on p.dept_id = a.parent_id
where p.tenant_id = #{tenantId} and p.del_flag = '0'
)
select exists (
select 1
from department_ancestors a
inner join mch_manage m on m.dept_id = a.dept_id
where m.tenant_id = #{tenantId} and m.del_flag = '0' and m.types = '1'
)
</select>
</mapper>
@@ -0,0 +1,35 @@
package com.bsd.pjcloud.wallet.service.impl;
import org.junit.jupiter.api.Test;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPathFactory;
import static org.junit.jupiter.api.Assertions.*;
class WalletDiscountTimingLogConfigTest {
@Test
void onlyDiscountSummaryUsesBoundedNonBlockingQueueAndRootOutputsStayTheSame() throws Exception {
var factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
try (var input = getClass().getClassLoader().getResourceAsStream("logback-spring.xml")) {
assertNotNull(input);
var document = factory.newDocumentBuilder().parse(input);
var xpath = XPathFactory.newInstance().newXPath();
String async = "/configuration/appender[@name='walletDiscountTimingAsync']";
assertEquals("ch.qos.logback.classic.AsyncAppender", xpath.evaluate(async + "/@class", document));
assertEquals("256", xpath.evaluate(async + "/queueSize", document));
assertEquals("true", xpath.evaluate(async + "/neverBlock", document));
assertEquals("0", xpath.evaluate(async + "/discardingThreshold", document));
assertEquals("false", xpath.evaluate(async + "/includeCallerData", document));
assertEquals("debug", xpath.evaluate(async + "/appender-ref/@ref", document));
String logger = "/configuration/logger[@name='" + WalletDiscountTiming.LOGGER_NAME + "']";
assertEquals("false", xpath.evaluate(logger + "/@additivity", document));
assertEquals("walletDiscountTimingAsync", xpath.evaluate(logger + "/appender-ref/@ref", document));
assertEquals("1", xpath.evaluate("count(" + logger + "/appender-ref)", document));
assertEquals("2", xpath.evaluate("count(/configuration/root/appender-ref)", document));
assertEquals("console", xpath.evaluate("/configuration/root/appender-ref[1]/@ref", document));
assertEquals("debug", xpath.evaluate("/configuration/root/appender-ref[2]/@ref", document));
}
}
}
@@ -0,0 +1,137 @@
package com.bsd.pjcloud.wallet.service.impl;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class WalletDiscountTimingTest {
private static final String SUMMARY = "[终端优惠耗时] method={} totalMs={} userMs={} settingMs={} departmentMs={} outcome={} exceptionType={}";
@Test
void successfulRequestBelowOneSecondDoesNotLog() {
AtomicLong clock = new AtomicLong();
Logger logger = mock(Logger.class);
WalletDiscountTiming timing = new WalletDiscountTiming("androidGetDiscountsList", clock::get, logger);
clock.set(999_999_999L);
timing.finish(null);
verifyZeroInteractions(logger);
}
@Test
void slowRequestLogsOneSummaryWithOnlyMethodDurationsAndOutcome() {
AtomicLong clock = new AtomicLong();
Logger logger = mock(Logger.class);
WalletDiscountTiming timing = new WalletDiscountTiming("androidGetDiscountsList", clock::get, logger);
assertEquals("unlogged-user", timing.user(() -> {
clock.addAndGet(1_010_000_000L);
return "unlogged-user";
}));
assertEquals(12345, timing.setting(() -> {
clock.addAndGet(20_000_000L);
return 12345;
}));
assertTrue(timing.department(() -> {
clock.addAndGet(30_000_000L);
return true;
}));
timing.finish(null);
timing.finish(null);
assertArrayEquals(new Object[]{"androidGetDiscountsList", 1060L, 1010L, 20L, 30L, "success", "none"}, summary(logger));
}
@Test
void exactlyOneSecondIsReported() {
AtomicLong clock = new AtomicLong();
Logger logger = mock(Logger.class);
WalletDiscountTiming timing = new WalletDiscountTiming("getDiscountsListByDeptId", clock::get, logger);
clock.set(1_000_000_000L);
timing.finish(null);
assertArrayEquals(new Object[]{"getDiscountsListByDeptId", 1000L, 0L, 0L, 0L, "success", "none"}, summary(logger));
}
@Test
void failedUserCallRetainsDurationAndReportsOnlyExceptionType() {
AtomicLong clock = new AtomicLong();
Logger logger = mock(Logger.class);
WalletDiscountTiming timing = new WalletDiscountTiming("androidGetDiscountsList", clock::get, logger);
IllegalStateException failure = new IllegalStateException("unlogged-user-and-amount");
assertSame(failure, assertThrows(IllegalStateException.class, () -> timing.user(() -> {
clock.addAndGet(15_000_000L);
throw failure;
})));
timing.finish(failure);
assertArrayEquals(new Object[]{"androidGetDiscountsList", 15L, 15L, 0L, 0L, "failure", "IllegalStateException"}, summary(logger));
}
@Test
void failedSettingAndDepartmentCallsRetainTheirElapsedTime() {
AtomicLong clock = new AtomicLong();
Logger logger = mock(Logger.class);
WalletDiscountTiming timing = new WalletDiscountTiming("getDiscountsListByDeptId", clock::get, logger);
IllegalArgumentException failure = new IllegalArgumentException("unlogged-payment-credential");
assertSame(failure, assertThrows(IllegalArgumentException.class, () -> timing.setting(() -> {
clock.addAndGet(7_000_000L);
throw failure;
})));
assertSame(failure, assertThrows(IllegalArgumentException.class, () -> timing.department(() -> {
clock.addAndGet(9_000_000L);
throw failure;
})));
timing.finish(failure);
assertArrayEquals(new Object[]{"getDiscountsListByDeptId", 16L, 0L, 7L, 9L, "failure", "IllegalArgumentException"}, summary(logger));
}
@Test
void loggerFailureDoesNotChangeSuccessfulResultOrOriginalFailure() {
AtomicLong clock = new AtomicLong();
Logger logger = mock(Logger.class);
AtomicInteger writes = new AtomicInteger();
doAnswer(invocation -> {
writes.incrementAndGet();
throw new IllegalStateException("appender unavailable");
}).when(logger).info(eq(SUMMARY), any(), any(), any(), any(), any(), any(), any());
WalletDiscountTiming success = new WalletDiscountTiming("getDiscountsListByDeptId", clock::get, logger);
String result = success.setting(() -> "unchanged-result");
clock.addAndGet(1_000_000_000L);
assertDoesNotThrow(() -> success.finish(null));
assertEquals("unchanged-result", result);
WalletDiscountTiming failed = new WalletDiscountTiming("androidGetDiscountsList", clock::get, logger);
IllegalArgumentException businessFailure = new IllegalArgumentException("business failure");
assertSame(businessFailure, assertThrows(IllegalArgumentException.class, () -> {
try {
failed.user(() -> { throw businessFailure; });
} finally {
failed.finish(businessFailure);
}
}));
assertEquals(2, writes.get());
}
private Object[] summary(Logger logger) {
var invocations = mockingDetails(logger).getInvocations();
assertEquals(1, invocations.size());
var invocation = invocations.iterator().next();
assertEquals("info", invocation.getMethod().getName());
Object[] arguments = invocation.getRawArguments();
assertEquals(2, arguments.length);
assertEquals(SUMMARY, arguments[0]);
return (Object[]) arguments[1];
}
}
@@ -0,0 +1,146 @@
package com.bsd.pjcloud.wallet.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.vo.UserDepartmentVo;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.wallet.api.entity.WalletSettingEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletXfjDiscounts;
import com.bsd.pjcloud.wallet.mapper.WalletSettingMapper;
import com.bsd.pjcloud.wallet.service.MchManageService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class WalletSettingDiscountsTest {
private WalletSettingServiceImpl service;
private RemoteUserService users;
private MchManageService merchants;
private WalletSettingMapper settings;
private WalletSettingEntity configuration;
private Long previousTenant;
@BeforeEach
void setup() {
previousTenant = TenantContextHolder.getTenantId();
TenantContextHolder.setTenantId(7L);
users = mock(RemoteUserService.class);
merchants = mock(MchManageService.class);
settings = mock(WalletSettingMapper.class);
service = new WalletSettingServiceImpl(merchants, mock(StringRedisTemplate.class), users);
ReflectionTestUtils.setField(service, "baseMapper", settings);
configuration = new WalletSettingEntity();
configuration.setId(1L);
when(settings.selectOne(any(Wrapper.class))).thenAnswer(invocation -> configuration);
UserDepartmentVo user = new UserDepartmentVo();
user.setUserId(4L);
user.setDeptId(12L);
when(users.getInnerDepartmentByUserId(4L)).thenReturn(R.ok(user));
}
@AfterEach
void cleanup() {
if (previousTenant == null) TenantContextHolder.clear();
else TenantContextHolder.setTenantId(previousTenant);
}
@Test
void terminalReadsOnlyLightweightDepartmentAndPreservesRuleFields() {
configuration.setZbDefaultXfjDiscounts(List.of(rule(200, 1.5)));
when(merchants.isSelfCateringDepartment(12L)).thenReturn(true);
WalletXfjDiscounts result = service.androidGetDiscountsList("device", 4L).get(0);
assertEquals(200, result.getBasePrice());
assertEquals(1.5, result.getExtraPer());
assertEquals("2", result.getHourType());
assertEquals("1", result.getSpecialDishEnable());
verify(users).getInnerDepartmentByUserId(4L);
verify(users, never()).getInnerByUserId(anyLong());
verify(merchants, never()).getViewsCanteenMchByDeptId(anyLong());
verify(settings).selectOne(any(Wrapper.class));
}
@Test
void failedMissingAndMismatchedUserResponsesCannotBecomeSuccessfulEmptyRules() {
when(users.getInnerDepartmentByUserId(4L)).thenReturn(R.failed("暂时不可用"));
assertThrows(IllegalStateException.class, () -> service.androidGetDiscountsList("device", 4L));
when(users.getInnerDepartmentByUserId(4L)).thenReturn(null);
assertThrows(IllegalStateException.class, () -> service.androidGetDiscountsList("device", 4L));
when(users.getInnerDepartmentByUserId(4L)).thenReturn(R.ok(null));
assertThrows(IllegalStateException.class, () -> service.androidGetDiscountsList("device", 4L));
UserDepartmentVo mismatched = new UserDepartmentVo();
mismatched.setUserId(99L);
when(users.getInnerDepartmentByUserId(4L)).thenReturn(R.ok(mismatched));
assertThrows(IllegalStateException.class, () -> service.androidGetDiscountsList("device", 4L));
verifyZeroInteractions(settings, merchants);
}
@Test
void nullListsAndNullOrDisabledItemsAreSafe() {
assertTrue(service.getDiscountsListByDeptId("device", 12L).isEmpty());
verifyZeroInteractions(merchants);
WalletSettingEntity.WalletXfjDiscounts disabled = rule(100, 3.0);
disabled.setEnable("0");
configuration.setFzbDefaultXfjDiscounts(Arrays.asList(null, disabled, rule(300, 1.8)));
List<WalletXfjDiscounts> result = service.getDiscountsListByDeptId("device", 12L);
assertEquals(1, result.size());
assertEquals(300, result.get(0).getBasePrice());
}
@Test
void allFourRuleGroupsUseTheExistingDeviceAndDepartmentClassification() {
configuration.setSpecialXfjIdList(List.of("special"));
configuration.setZbDefaultXfjDiscounts(List.of(rule(101, 1.1)));
configuration.setFzbDefaultXfjDiscounts(List.of(rule(102, 1.2)));
configuration.setZbSpecialXfjDiscounts(List.of(rule(103, 1.3)));
configuration.setFzbSpecialXfjDiscounts(List.of(rule(104, 1.4)));
when(merchants.isSelfCateringDepartment(12L)).thenReturn(true);
assertEquals(101, service.getDiscountsListByDeptId("default", 12L).get(0).getBasePrice());
assertEquals(103, service.getDiscountsListByDeptId("special", 12L).get(0).getBasePrice());
when(merchants.isSelfCateringDepartment(12L)).thenReturn(false);
assertEquals(102, service.getDiscountsListByDeptId("default", 12L).get(0).getBasePrice());
assertEquals(104, service.getDiscountsListByDeptId("special", 12L).get(0).getBasePrice());
verifyZeroInteractions(users);
}
@Test
void configurationAndDepartmentChangesAreVisibleOnTheNextCall() {
configuration.setZbDefaultXfjDiscounts(List.of(rule(100, 1.5)));
configuration.setFzbDefaultXfjDiscounts(List.of(rule(200, 2.0)));
when(merchants.isSelfCateringDepartment(12L)).thenReturn(true);
assertEquals(1.5, service.getDiscountsListByDeptId("device", 12L).get(0).getExtraPer());
configuration.setZbDefaultXfjDiscounts(List.of(rule(100, 1.8)));
assertEquals(1.8, service.getDiscountsListByDeptId("device", 12L).get(0).getExtraPer());
when(merchants.isSelfCateringDepartment(12L)).thenReturn(false);
assertEquals(2.0, service.getDiscountsListByDeptId("device", 12L).get(0).getExtraPer());
verify(settings, times(3)).selectOne(any(Wrapper.class));
verify(merchants, times(3)).isSelfCateringDepartment(12L);
}
@Test
void missingDepartmentKeepsNonSelfCateringRulesButMissingTenantFailsClosed() {
configuration.setFzbDefaultXfjDiscounts(List.of(rule(200, 2.0)));
assertEquals(200, service.getDiscountsListByDeptId("device", null).get(0).getBasePrice());
clearInvocations(settings, merchants, users);
TenantContextHolder.clear();
assertThrows(IllegalStateException.class, () -> service.getDiscountsListByDeptId("device", 12L));
assertThrows(IllegalStateException.class, () -> service.androidGetDiscountsList("device", 4L));
verifyZeroInteractions(settings, merchants, users);
}
private WalletSettingEntity.WalletXfjDiscounts rule(int base, double rate) {
return WalletSettingEntity.WalletXfjDiscounts.builder().hourType("2").basePrice(base)
.extraPer(rate).specialDishEnable("1").enable("1").build();
}
}
@@ -0,0 +1,37 @@
package com.bsd.pjcloud.wallet.service.impl.mch;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.wallet.mapper.mch.view.MchDeptViewMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class DiscountDepartmentScopeTest {
@Test
void everyLookupUsesCurrentTenantAndDoesNotRetainMerchantClassification() {
Long previous = TenantContextHolder.getTenantId();
try {
MchManageServiceImpl service = mock(MchManageServiceImpl.class, CALLS_REAL_METHODS);
MchDeptViewMapper mapper = mock(MchDeptViewMapper.class);
ReflectionTestUtils.setField(service, "mchDeptViewMapper", mapper);
TenantContextHolder.setTenantId(7L);
when(mapper.existsCanteenForDepartment(12L, 7L)).thenReturn(true, false);
assertTrue(service.isSelfCateringDepartment(12L));
assertFalse(service.isSelfCateringDepartment(12L));
verify(mapper, times(2)).existsCanteenForDepartment(12L, 7L);
TenantContextHolder.setTenantId(8L);
assertFalse(service.isSelfCateringDepartment(12L));
verify(mapper).existsCanteenForDepartment(12L, 8L);
clearInvocations(mapper);
assertFalse(service.isSelfCateringDepartment(null));
TenantContextHolder.clear();
assertThrows(IllegalStateException.class, () -> service.isSelfCateringDepartment(12L));
verifyZeroInteractions(mapper);
} finally {
if (previous == null) TenantContextHolder.clear();
else TenantContextHolder.setTenantId(previous);
}
}
}
@@ -0,0 +1,71 @@
package com.bsd.pjcloud.wallet.service.impl.mch;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
/** 运行 mapper 的真实 SQL,覆盖租户、祖先绑定、软删除和变更后立即生效。 */
class DiscountDepartmentSqlTest {
private JdbcTemplate jdbc;
private NamedParameterJdbcTemplate named;
private String sql;
@BeforeEach
void setup() throws Exception {
var source = new DriverManagerDataSource("jdbc:h2:mem:discount_" + UUID.randomUUID()
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(source);
named = new NamedParameterJdbcTemplate(source);
named.getJdbcTemplate().setQueryTimeout(3);
jdbc.execute("create schema pjcloudx");
jdbc.execute("create table pjcloudx.sys_dept(dept_id bigint, parent_id bigint, tenant_id bigint, del_flag varchar(1), primary key(tenant_id, dept_id))");
jdbc.execute("create table mch_manage(id bigint primary key, dept_id bigint, tenant_id bigint, types varchar(8), del_flag varchar(1))");
jdbc.update("insert into pjcloudx.sys_dept values (10,-1,7,'0'),(11,10,7,'0'),(12,11,7,'0'),(20,-1,7,'0'),(21,20,7,'0'),(30,-1,7,'1'),(31,30,7,'0'),(20,-1,8,'0'),(21,20,8,'0')");
jdbc.update("insert into mch_manage values (1,10,7,'1','0'),(2,20,7,'2','0'),(3,30,7,'1','0'),(4,20,8,'1','0')");
try (var stream = getClass().getClassLoader().getResourceAsStream("mapper/mch/view/MchDeptViewMapper.xml")) {
assertNotNull(stream);
String xml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
var match = Pattern.compile("<select id=\"existsCanteenForDepartment\"[^>]*>(.*?)</select>", Pattern.DOTALL).matcher(xml);
assertTrue(match.find());
sql = match.group(1).replace("#{deptId}", ":deptId").replace("#{tenantId}", ":tenantId");
}
}
@Test
void findsLiveAncestorCanteenWithoutCrossingTenantOrDeletedDepartments() {
assertTrue(lookup(10L, 7L));
assertTrue(lookup(12L, 7L));
assertFalse(lookup(21L, 7L));
assertTrue(lookup(21L, 8L));
assertFalse(lookup(31L, 7L));
assertFalse(lookup(null, 7L));
assertFalse(lookup(999L, 7L));
}
@Test
void merchantAndDepartmentChangesAreEffectiveOnTheNextRead() {
assertTrue(lookup(12L, 7L));
jdbc.update("update mch_manage set del_flag='1' where id=1");
assertFalse(lookup(12L, 7L));
jdbc.update("update mch_manage set del_flag='0' where id=1");
assertTrue(lookup(12L, 7L));
jdbc.update("update pjcloudx.sys_dept set parent_id=20 where dept_id=11 and tenant_id=7");
assertFalse(lookup(12L, 7L));
jdbc.update("insert into mch_manage values (5,20,7,'1','0')");
assertTrue(lookup(12L, 7L));
}
private boolean lookup(Long deptId, Long tenantId) {
return Boolean.TRUE.equals(named.queryForObject(sql,
new MapSqlParameterSource().addValue("deptId", deptId).addValue("tenantId", tenantId), Boolean.class));
}
}