diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java index 8a4b1ee8b..052a6a068 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java @@ -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; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/advice/TerminalDiscountPricingExceptionHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/advice/TerminalDiscountPricingExceptionHandler.java new file mode 100644 index 000000000..5b451d55c --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/advice/TerminalDiscountPricingExceptionHandler.java @@ -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 handle(TerminalDiscountPricingException failure) { + return R.failed(BUSINESS_CODE, failure.getMessage()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java index 414d719ed..ecca00324 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java @@ -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 list) { List productIds = list.stream().map(HardCreateCarDto::getProductId).toList(); List carObjectList = new ArrayList<>(); + Map serverPriceIncreaseFlags = new HashMap<>(); StringBuffer subject = new StringBuffer(); AtomicInteger totalPrice = new AtomicInteger(); // 食堂 @@ -335,6 +338,8 @@ public class HardCarServiceImpl extends ServiceImpl 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 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 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 savedItems = hardCarService.getByParentCarId(order.getParentCarId()); if (savedItems == null) savedItems = List.of(); Map saved = new HashMap<>(), requested = new HashMap<>(); @@ -557,7 +589,12 @@ public class HardOrderServiceImpl extends ServiceImpl 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); } // 旧食堂票券核销链路可能只有父购物车,没有商品明细;该场景使用前面已经比对的 // 人员、支付类型和不可变核销来源号识别原结果,不能因明细缺失重新核销一次。 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderCartSnapshot.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderCartSnapshot.java index 424c05dd7..e350aa0bf 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderCartSnapshot.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderCartSnapshot.java @@ -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 items) { +public record HardOrderCartSnapshot(int totalPrice, String subject, List items, + Map serverPriceIncreaseFlags) { + /** 旧调用入口不使用新收费校验,保留原有构造协议。 */ + public HardOrderCartSnapshot(int totalPrice, String subject, List items) { + this(totalPrice, subject, items, Map.of()); + } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/TerminalDiscountPricing.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/TerminalDiscountPricing.java new file mode 100644 index 000000000..854463fde --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/TerminalDiscountPricing.java @@ -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 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("应付金额已变化,请重新确认金额后支付"); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/TerminalDiscountPricingException.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/TerminalDiscountPricingException.java new file mode 100644 index 000000000..5675172eb --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/TerminalDiscountPricingException.java @@ -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); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/advice/TerminalDiscountPricingExceptionHandlerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/advice/TerminalDiscountPricingExceptionHandlerTest.java new file mode 100644 index 000000000..2d3b0eab3 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/controller/advice/TerminalDiscountPricingExceptionHandlerTest.java @@ -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"); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderPaymentTimingRegressionTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderPaymentTimingRegressionTest.java index eacff2cde..6c24d9a99 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderPaymentTimingRegressionTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderPaymentTimingRegressionTest.java @@ -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.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")); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardTerminalCartSnapshotTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardTerminalCartSnapshotTest.java index 7ada205a2..f25a1f56f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardTerminalCartSnapshotTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardTerminalCartSnapshotTest.java @@ -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 -> { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/TerminalDiscountPricingTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/TerminalDiscountPricingTest.java new file mode 100644 index 000000000..ba4569b89 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/TerminalDiscountPricingTest.java @@ -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(); + } +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index 65d29b438..93d6c7181 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -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 getInnerByUserId(@RequestParam("userId") Long userId); + /** 计价所需的实时部门快照,避免查询角色与菜单权限。 */ + @NoToken + @GetMapping("/user/inner/get-dept-by-user-id") + R getInnerDepartmentByUserId(@RequestParam("userId") Long userId); + @GetMapping("/user/get-by-user-id") R getByUserId(@RequestParam("userId") Long userId); diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserDepartmentVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserDepartmentVo.java new file mode 100644 index 000000000..f959a5975 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/UserDepartmentVo.java @@ -0,0 +1,10 @@ +package com.bsd.pjcloud.admin.api.vo; + +import lombok.Data; + +/** 消费计价只需要人员存在性与所属部门,不返回账号、钱包或角色权限。 */ +@Data +public class UserDepartmentVo { + private Long userId; + private Long deptId; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 7a94a8dc3..b5b8d5683 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -190,6 +190,20 @@ public class SysUserController { return R.ok(userService.findUserInfo(user)); } + /** 仅查询消费计价所需的人员主键与部门,保留现有全量用户接口的协议。 */ + @Inner + @GetMapping("/inner/get-dept-by-user-id") + public R getInnerDepartmentByUserId(@RequestParam Long userId) { + SysUser user = userService.getOne(Wrappers.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); + } + /** * 根据手机号查询用户 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletSettingService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletSettingService.java index 8c3e09189..d2162dc39 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletSettingService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletSettingService.java @@ -23,4 +23,10 @@ public interface RemoteWalletSettingService { @NoToken @GetMapping("/wallet-setting/remote/get-discounts-list") R> remoteGetDiscountsList(@RequestParam String hardId, @RequestParam Long userId, @RequestParam(required = false) Long userDeptId); + + /** 使用服务端已查询的人员部门,避免计价时重复获取人员资料。 */ + @NoToken + @GetMapping("/wallet-setting/remote/get-discounts-list-by-dept") + R> getDiscountsListByDeptId(@RequestParam("hardId") String hardId, + @RequestParam(value = "deptId", required = false) Long deptId); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletSettingController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletSettingController.java index 41bc38f4a..3d54c5ba1 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletSettingController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletSettingController.java @@ -48,6 +48,13 @@ public class WalletSettingController { return R.ok(walletSettingService.androidGetDiscountsList(hardId, userId)); } + @Inner + @GetMapping("/remote/get-discounts-list-by-dept") + public R> 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); } -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/view/MchDeptViewMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/view/MchDeptViewMapper.java index d1d0ecee9..b1f0f36ca 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/view/MchDeptViewMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/view/MchDeptViewMapper.java @@ -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); + } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java index 8c77c4c70..3ac4b4d48 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java @@ -49,6 +49,9 @@ public interface MchManageService extends IService { */ MchManageVo getViewsCanteenMchByDeptId(Long deptId); + /** 实时判断本部门或祖先部门是否绑定自营食堂,不复用完整商户的长期缓存。 */ + boolean isSelfCateringDepartment(Long deptId); + /** * 刷新商户二维码 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletSettingService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletSettingService.java index f29ff36c6..cbffbdf60 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletSettingService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletSettingService.java @@ -15,7 +15,10 @@ public interface WalletSettingService extends IService { */ List androidGetDiscountsList(String hardId, Long userId); + /** 内部计价复用同一规则选择,部门为空仍按既有非自营口径处理。 */ + List getDiscountsListByDeptId(String hardId, Long deptId); + WalletSettingVo getSetting(); void edit(WalletSettingDto dto); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletDiscountTiming.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletDiscountTiming.java new file mode 100644 index 000000000..44de1e73f --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletDiscountTiming.java @@ -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 user(Supplier action) { + long segmentStarted = nanoTime.getAsLong(); + try { + return action.get(); + } finally { + userNanos += nanoTime.getAsLong() - segmentStarted; + } + } + + T setting(Supplier action) { + long segmentStarted = nanoTime.getAsLong(); + try { + return action.get(); + } finally { + settingNanos += nanoTime.getAsLong() - segmentStarted; + } + } + + T department(Supplier 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; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletSettingServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletSettingServiceImpl.java index 604def966..abe36966e 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletSettingServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletSettingServiceImpl.java @@ -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 implements WalletSettingService { @@ -57,80 +55,73 @@ public class WalletSettingServiceImpl extends ServiceImpl androidGetDiscountsList(String hardId, Long userId) { - long startTime = System.currentTimeMillis(); - List 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 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 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 list = new ArrayList<>(); - // 特殊食堂(二楼食堂) - if (ObjectUtil.isNotEmpty(setting.getSpecialXfjIdList()) && setting.getSpecialXfjIdList().contains(hardId)) { - // 自营食堂下用户 - if (isSelfCatering) { - list = setting.getZbSpecialXfjDiscounts(); - } - // 不在自营食堂下用户 - else { - list = setting.getFzbSpecialXfjDiscounts(); - } + /** 配置和归属各实时读取一次;不缓存收费结果,避免变更后金额仍沿用旧版本。 */ + private List 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 selfRules = special + ? setting.getZbSpecialXfjDiscounts() : setting.getZbDefaultXfjDiscounts(); + List otherRules = special + ? setting.getFzbSpecialXfjDiscounts() : setting.getFzbDefaultXfjDiscounts(); + // 两组均无配置时无需查询归属;其他情况只查布尔,不读取完整商户或长期缓存。 + boolean hasRules = ObjectUtil.isNotEmpty(selfRules) || ObjectUtil.isNotEmpty(otherRules); + boolean selfCatering = hasRules && timing.department(() -> mchManageService.isSelfCateringDepartment(deptId)); + List 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 + + + 256 + 0 + true + false + + + + + + ${log.path}/error.log diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/view/MchDeptViewMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/view/MchDeptViewMapper.xml index 95384c6d7..dbd04b0c3 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/view/MchDeptViewMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/view/MchDeptViewMapper.xml @@ -62,4 +62,24 @@ order by a.level asc limit 1 + + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletDiscountTimingLogConfigTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletDiscountTimingLogConfigTest.java new file mode 100644 index 000000000..fdca85d90 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletDiscountTimingLogConfigTest.java @@ -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)); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletDiscountTimingTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletDiscountTimingTest.java new file mode 100644 index 000000000..27b6eb185 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletDiscountTimingTest.java @@ -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]; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletSettingDiscountsTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletSettingDiscountsTest.java new file mode 100644 index 000000000..13e3ede4b --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletSettingDiscountsTest.java @@ -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 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(); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/DiscountDepartmentScopeTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/DiscountDepartmentScopeTest.java new file mode 100644 index 000000000..70a1cb7db --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/DiscountDepartmentScopeTest.java @@ -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); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/DiscountDepartmentSqlTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/DiscountDepartmentSqlTest.java new file mode 100644 index 000000000..7e175be00 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/DiscountDepartmentSqlTest.java @@ -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("", 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)); + } +}