feat:消费机支付卡顿的问题
This commit is contained in:
1 parent
42e0323080
commit
710e4cfb90
29 files changed
+1219
-84
No files matched your search
+10
@@ -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;
|
||||
|
||||
|
||||
+23
@@ -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());
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -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);
|
||||
}
|
||||
// 旧食堂票券核销链路可能只有父购物车,没有商品明细;该场景使用前面已经比对的
|
||||
// 人员、支付类型和不可变核销来源号识别原结果,不能因明细缺失重新核销一次。
|
||||
|
||||
+8
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
+103
@@ -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("应付金额已变化,请重新确认金额后支付");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
-2
@@ -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"));
|
||||
}
|
||||
}
|
||||
+4
@@ -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 -> {
|
||||
|
||||
+124
@@ -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();
|
||||
}
|
||||
}
|
||||
+6
@@ -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);
|
||||
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.bsd.pjcloud.admin.api.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 消费计价只需要人员存在性与所属部门,不返回账号、钱包或角色权限。 */
|
||||
@Data
|
||||
public class UserDepartmentVo {
|
||||
private Long userId;
|
||||
private Long deptId;
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号查询用户
|
||||
*/
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
+8
-1
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+5
@@ -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);
|
||||
|
||||
}
|
||||
+3
@@ -49,6 +49,9 @@ public interface MchManageService extends IService<MchManageEntity> {
|
||||
*/
|
||||
MchManageVo getViewsCanteenMchByDeptId(Long deptId);
|
||||
|
||||
/** 实时判断本部门或祖先部门是否绑定自营食堂,不复用完整商户的长期缓存。 */
|
||||
boolean isSelfCateringDepartment(Long deptId);
|
||||
|
||||
/**
|
||||
* 刷新商户二维码
|
||||
*/
|
||||
|
||||
+4
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+79
@@ -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;
|
||||
}
|
||||
}
|
||||
+67
-76
@@ -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 = "";
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+10
@@ -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>
|
||||
|
||||
+20
@@ -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>
|
||||
+35
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -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];
|
||||
}
|
||||
}
|
||||
+146
@@ -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();
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user