Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
3ff7b06f58
27 files changed
+658
-259
No files matched your search
-5
@@ -68,11 +68,6 @@ public class CanteenSpuUpdateStockReqDTO {
|
||||
@NotNull(message = "库存变化数量不能为空")
|
||||
private Integer incrCount;
|
||||
|
||||
/**
|
||||
* 本次业务开始前的库存快照,仅幂等库存接口使用。
|
||||
*/
|
||||
private Integer beforeStock;
|
||||
|
||||
/**
|
||||
* 每日菜谱用餐日;分池扣减时必填(可与同批其他行不同)。
|
||||
*/
|
||||
|
||||
+2
-2
@@ -93,7 +93,7 @@ public class DailyMenuAppVO {
|
||||
|
||||
/**
|
||||
* 当前可售库存。
|
||||
* <p>App/移动端:移动端可预订剩余;AD:总库存 − 移动端已预订。字段名保持 stock 兼容旧客户端。
|
||||
* <p>App/移动端:移动端可预订剩余;AD:剩余总库存(总库存 − 线上已订 − 线下已售)。字段名保持 stock 兼容旧客户端。
|
||||
*/
|
||||
private Integer stock;
|
||||
|
||||
@@ -112,7 +112,7 @@ public class DailyMenuAppVO {
|
||||
/** 移动端可预订剩余 */
|
||||
private Integer remainOnlineStock;
|
||||
|
||||
/** App:剩余总库存;AD:与 stock 同为 总库存−移动端已预订 */
|
||||
/** 剩余总库存(AD 与 stock 同值;管理端 remainStock 同口径) */
|
||||
private Integer remainOfflineStock;
|
||||
|
||||
/**
|
||||
|
||||
+4
-6
@@ -71,16 +71,14 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper<CanteenDailyMe
|
||||
@Param("categoryId") Long categoryId, DataScope dataScope);
|
||||
|
||||
/**
|
||||
* 条件更新线上净销量(delta 为剩余侧增量:下单负数);expected 为操作前剩余。
|
||||
* 条件更新线上净销量(delta 为剩余侧增量:下单负数);扣减自带「不扣负、不超配额」边界。
|
||||
*/
|
||||
int updateOnlineStockDelta(@Param("merchantId") Long merchantId, @Param("mealDate") java.time.LocalDate mealDate,
|
||||
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta,
|
||||
@Param("expected") Integer expected);
|
||||
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta);
|
||||
|
||||
/**
|
||||
* 条件更新线下净销量(delta 为剩余侧增量:下单负数);expected 为操作前剩余。
|
||||
* 条件更新线下净销量(delta 为剩余侧增量:下单负数);扣减自带「不扣负、不超配额」边界。
|
||||
*/
|
||||
int updateOfflineStockDelta(@Param("merchantId") Long merchantId, @Param("mealDate") java.time.LocalDate mealDate,
|
||||
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta,
|
||||
@Param("expected") Integer expected);
|
||||
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta);
|
||||
}
|
||||
-27
@@ -34,31 +34,4 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper<CanteenSpuEntity> {
|
||||
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
|
||||
return update(null, updateWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在数据库当前库存仍等于业务快照时更新库存和销量,避免不同业务号并发操作同一 SPU 时丢失更新。
|
||||
*
|
||||
* @param id 商品 SPU 编号
|
||||
* @param incrCount 库存增量,负数扣减、正数恢复
|
||||
* @param expectedStock 本次业务读取到的操作前库存
|
||||
* @return 影响行数;返回 0 表示库存已变化或扣减后会小于 0
|
||||
*/
|
||||
default int updateStockIfMatch(Long id, Integer incrCount, Integer expectedStock) {
|
||||
if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE
|
||||
|| expectedStock == null || expectedStock < 0) {
|
||||
return 0;
|
||||
}
|
||||
String sql;
|
||||
if (incrCount > 0) {
|
||||
sql = " stock = stock + " + incrCount + ", sales_count = sales_count - " + incrCount;
|
||||
} else {
|
||||
sql = " stock = stock - " + Math.abs(incrCount) + ", sales_count = sales_count + " + Math.abs(incrCount);
|
||||
}
|
||||
LambdaUpdateWrapper<CanteenSpuEntity> updateWrapper = new LambdaUpdateWrapper<CanteenSpuEntity>()
|
||||
.setSql(sql)
|
||||
.eq(CanteenSpuEntity::getId, id)
|
||||
.eq(CanteenSpuEntity::getStock, expectedStock)
|
||||
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
|
||||
return update(null, updateWrapper);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -385,7 +385,7 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
|
||||
public List<DailyMenuAppVO> getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId) {
|
||||
List<DailyMenuAppVO> list = baseMapper.getCanteenMenuList(day, hourType, categoryId, merchantId, DataScope.of());
|
||||
fillDailyMenuAppCombo(list);
|
||||
// 安卓消费机:stock = 当日线下剩余
|
||||
// 安卓消费机:stock = 剩余总库存(与管理端 remainStock、线下扣减边界同口径)
|
||||
LocalDate mealDate = null;
|
||||
try {
|
||||
mealDate = day == null ? null : LocalDate.parse(day);
|
||||
|
||||
+7
-30
@@ -30,7 +30,7 @@ import java.util.stream.Collectors;
|
||||
* <pre>
|
||||
* 剩余总库存 = max(0, 总库存 − 移动端已预订 − 消费机已销售)
|
||||
* 移动端可预订剩余 = min(移动端上限 − 已预订, 剩余总库存)
|
||||
* AD stock = max(0, 总库存 − 移动端已预订)
|
||||
* AD stock = 剩余总库存(与管理端 remainStock、线下扣减边界同口径)
|
||||
* </pre>
|
||||
* PC 管理端只展示总库存、移动端上限、移动端已订、消费机已售、剩余总库存。
|
||||
* 扣减仍受「剩余总库存」边界约束,防止超卖。
|
||||
@@ -95,7 +95,8 @@ public class CanteenDailyMenuStockSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* AD 返回的 stock:总库存 − 移动端已预订(不含消费机已售;已售由 offline_sold 单独体现)。
|
||||
* 总库存 − 移动端已预订(不含消费机已售)。
|
||||
* 仅作库存扣减流程的线下游标基线;AD 列表展示已改用 {@link #sharedRemain}。
|
||||
*/
|
||||
public static int adStockOf(Integer totalStock, Integer onlineSold) {
|
||||
return Math.max(0, totalStockOf(totalStock) - soldOrDefault(onlineSold));
|
||||
@@ -115,29 +116,6 @@ public class CanteenDailyMenuStockSupport {
|
||||
return offline ? adStockOf(menu) : onlineRemain(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将调用方 beforeStock 归一到通道 CAS 口径(与 {@link #channelRemain} / SQL expected 一致)。
|
||||
* <p>
|
||||
* 线下:标准为 AD stock(总库存−线上已订)。兼容 Hard 库存校验走 App 填充时误传的
|
||||
* 「剩余总库存」(总库存−线上已订−线下已售):该值可接受,但 CAS 仍用 AD stock。
|
||||
*
|
||||
* @return 归一后的 expected;无法识别则 null(应按库存已变化处理)
|
||||
*/
|
||||
public static Integer normalizeChannelBeforeStock(CanteenDailyMenuEntity menu, boolean offline,
|
||||
Integer beforeStock) {
|
||||
if (menu == null || beforeStock == null || beforeStock < 0) {
|
||||
return null;
|
||||
}
|
||||
int channel = channelRemain(menu, offline);
|
||||
if (beforeStock == channel) {
|
||||
return channel;
|
||||
}
|
||||
if (offline && beforeStock == sharedRemain(menu)) {
|
||||
return channel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** App:stock = 线上剩余 */
|
||||
public void fillAppOnlineRemain(List<DailyMenuAppVO> rows, Long merchantId, LocalDate mealDate) {
|
||||
if (CollUtil.isEmpty(rows)) {
|
||||
@@ -149,7 +127,7 @@ public class CanteenDailyMenuStockSupport {
|
||||
applyAppComboLinkedRemain(rows, true);
|
||||
}
|
||||
|
||||
/** 安卓消费机:stock = 总库存 − 移动端已预订 */
|
||||
/** 安卓消费机:stock = 剩余总库存(管理端 remainStock 同口径) */
|
||||
public void fillAdOfflineRemain(List<DailyMenuAppVO> rows, Long merchantId, LocalDate mealDate) {
|
||||
if (CollUtil.isEmpty(rows)) {
|
||||
return;
|
||||
@@ -229,15 +207,14 @@ public class CanteenDailyMenuStockSupport {
|
||||
int offlineSold = soldOrDefault(row.getOfflineSold());
|
||||
int shared = sharedRemain(total, onlineSold, offlineSold);
|
||||
int onlineRemain = onlineRemain(total, mobile, onlineSold, offlineSold);
|
||||
int adStock = adStockOf(total, onlineSold);
|
||||
row.setOnlineStock(mobile);
|
||||
row.setOfflineStock(total);
|
||||
row.setOnlineSold(onlineSold);
|
||||
row.setOfflineSold(offlineSold);
|
||||
row.setRemainOnlineStock(onlineRemain);
|
||||
// AD:remainOfflineStock / stock = 总库存 − 线上已订;App 侧 remainOffline 仍给剩余总库存供参考
|
||||
row.setRemainOfflineStock(stockAsOnline ? shared : adStock);
|
||||
row.setStock(stockAsOnline ? onlineRemain : adStock);
|
||||
// remainOfflineStock = 剩余总库存;stock:App=移动端可订剩余,AD=剩余总库存(与线下扣减边界一致)
|
||||
row.setRemainOfflineStock(shared);
|
||||
row.setStock(stockAsOnline ? onlineRemain : shared);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
-27
@@ -454,7 +454,7 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一业务号的扣减和补偿在食堂服务内串行执行;补偿通过当前库存与原库存快照判断扣减是否已提交。
|
||||
* 同一业务号的扣减和补偿在食堂服务内串行执行;扣减是否已提交由 Redis 状态机判断。
|
||||
*/
|
||||
@Override
|
||||
@Lock4j(keys = {"#dto.bizId"})
|
||||
@@ -464,8 +464,7 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
|
||||
boolean deductOperation = "DEDUCT".equals(operation);
|
||||
boolean rollbackOperation = "ROLLBACK".equals(operation);
|
||||
if (StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems())
|
||||
|| (!deductOperation && !rollbackOperation)
|
||||
|| dto.getItems().stream().anyMatch(item -> item.getBeforeStock() == null)) {
|
||||
|| (!deductOperation && !rollbackOperation)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_BIZ_PARAM_BAD));
|
||||
}
|
||||
String stateKey = ORDER_STOCK_BIZ_KEY_PREFIX + dto.getBizId();
|
||||
@@ -476,39 +475,25 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
|
||||
if (rollbackOperation && "ROLLED_BACK".equals(state)) {
|
||||
return R.ok();
|
||||
}
|
||||
// 充足性以服务端现值判断,不再比对调用方快照:不同业务号交替下单/退款时快照必然失配,
|
||||
// 会把正常操作误判为“库存已变化”;防超卖由 updateStock 同条 UPDATE 的 stock >= |增量| 条件保证。
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
|
||||
CanteenOnlineSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
if (spu == null) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.COMMON_PRODUCT_NOT_FOUND));
|
||||
}
|
||||
int expectedStock = item.getBeforeStock();
|
||||
int targetStock = Math.addExact(expectedStock, item.getIncrCount());
|
||||
if (deductOperation) {
|
||||
if (spu.getStock() == targetStock) {
|
||||
continue;
|
||||
}
|
||||
if (spu.getStock() != expectedStock) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED, spu.getName()));
|
||||
}
|
||||
} else {
|
||||
if (spu.getStock() == expectedStock) {
|
||||
continue;
|
||||
}
|
||||
if (spu.getStock() != targetStock) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL));
|
||||
}
|
||||
if (deductOperation && Math.addExact(spu.getStock(), item.getIncrCount()) < 0) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_INSUFFICIENT));
|
||||
}
|
||||
}
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
|
||||
CanteenOnlineSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
int expectedStock = item.getBeforeStock();
|
||||
int targetStock = Math.addExact(expectedStock, item.getIncrCount());
|
||||
int delta = deductOperation ? item.getIncrCount() : -item.getIncrCount();
|
||||
boolean needUpdate = deductOperation
|
||||
? spu.getStock() == expectedStock
|
||||
: spu.getStock() == targetStock;
|
||||
if (needUpdate && baseMapper.updateStock(item.getId(), delta) == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL);
|
||||
// 只有确认 DEDUCT 已提交的 ROLLBACK 才反向回补;状态缺失的 ROLLBACK 说明扣减从未生效,
|
||||
// 只写终态阻断延迟到达的 DEDUCT,不能按库存值猜测并恢复(与基础池协议语义一致)。
|
||||
if (deductOperation || "DEDUCTED".equals(state)) {
|
||||
if (baseMapper.updateStock(item.getId(), delta) == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL);
|
||||
}
|
||||
}
|
||||
}
|
||||
stringRedisTemplate.opsForValue().set(stateKey, deductOperation ? "DEDUCTED" : "ROLLED_BACK");
|
||||
|
||||
+36
-78
@@ -133,7 +133,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
if (dto == null || StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems())
|
||||
|| (!applyOperation && !rollbackOperation)
|
||||
|| dto.getItems().stream().anyMatch(item -> item == null || item.getId() == null
|
||||
|| item.getIncrCount() == null || item.getIncrCount() == 0 || item.getBeforeStock() == null)) {
|
||||
|| item.getIncrCount() == null || item.getIncrCount() == 0)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_BIZ_PARAM_BAD));
|
||||
}
|
||||
normalizeDailyStockChannel(dto);
|
||||
@@ -160,24 +160,13 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
if (spu == null || spu.getStock() == null) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SPU_BAD));
|
||||
}
|
||||
int expectedStock = item.getBeforeStock();
|
||||
if (expectedStock < 0) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SNAPSHOT_BAD));
|
||||
}
|
||||
int targetStock = Math.addExact(expectedStock, item.getIncrCount());
|
||||
if (targetStock < 0) {
|
||||
// 充足性以服务端现值判断,不再比对调用方快照:不同业务号交替扣减/回补时快照必然失配,
|
||||
// 曾把正常下单确定性误拒为“库存已变化”;防超卖由更新 SQL 的下界条件保证。
|
||||
if (Math.addExact(spu.getStock(), item.getIncrCount()) < 0) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
|
||||
StrUtil.blankToDefault(spu.getName(), "商品")));
|
||||
}
|
||||
if (applyOperation && item.getIncrCount() < 0) {
|
||||
// 负增量扣减必须以快照做前置校验:不得把“库存恰好等于目标值”当成本业务已经执行,
|
||||
// 该值可能由另一个业务号并发扣减得到,且并发扣减基于同一剩余量判断会超卖。
|
||||
// 正增量恢复是可交换操作,不做快照校验,见下方更新循环注释。
|
||||
if (spu.getStock() != expectedStock) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
|
||||
StrUtil.blankToDefault(spu.getName(), "商品")));
|
||||
}
|
||||
} else if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
int rollbackDelta = -item.getIncrCount();
|
||||
if (rollbackDelta < 0 && spu.getStock() < Math.abs(rollbackDelta)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
|
||||
@@ -187,10 +176,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
}
|
||||
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
|
||||
int expectedStock = item.getBeforeStock();
|
||||
int delta = applyOperation ? item.getIncrCount() : -item.getIncrCount();
|
||||
if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
// 补偿不能再要求库存等于本业务 APPLY 后的快照:期间可能已有其他订单合法改变同一 SPU。
|
||||
// 补偿是可交换操作:期间其他订单可能已合法改变同一 SPU 的库存,
|
||||
// 同一业务号由锁和 APPLIED 状态保证只反向一次,数据库条件更新只负责阻止库存扣成负数。
|
||||
if (baseMapper.updateStock(item.getId(), delta) == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL);
|
||||
@@ -199,15 +187,11 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
}
|
||||
// 状态缺失的 ROLLBACK 说明没有可确认的成功 APPLY,只写入终态阻断延迟请求,不能按库存值猜测并恢复。
|
||||
if (applyOperation) {
|
||||
// 正增量恢复库存是可交换操作:并发下单或退款改变的库存不影响本业务恢复量的正确性,
|
||||
// 直接原子累加即可,幂等仍由 bizId 分布式锁和 Redis 状态保证,
|
||||
// 避免退款恢复被其他业务号的并发库存变更误判为冲突(“库存已变化”)。
|
||||
// 负增量扣减保留快照 CAS 条件更新,防止不同业务号基于同一剩余量重复判断导致超卖。
|
||||
int updated = delta > 0 ? baseMapper.updateStock(item.getId(), delta)
|
||||
: baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock);
|
||||
if (updated == 0) {
|
||||
throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
|
||||
: ErrorCodes.CANTEEN_STOCK_CHANGED, "");
|
||||
// 库存变更统一为无条件原子累加:正负增量都是可交换操作,幂等由 bizId 分布式锁和 Redis 状态保证,
|
||||
// 防超卖由 updateStock 同条 UPDATE 的 stock >= |增量| 条件保证(返回 0 行即库存不足)。
|
||||
// 不再使用调用方快照做 CAS:不同业务号交替扣减/回补时快照必然失配,曾把正常下单误判为“库存已变化”。
|
||||
if (baseMapper.updateStock(item.getId(), delta) == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,9 +206,18 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
return "ONLINE".equals(ch) || "OFFLINE".equals(ch);
|
||||
}
|
||||
|
||||
/** 空通道默认 OFFLINE,兼容未升级的安卓消费机。 */
|
||||
/**
|
||||
* 空通道且行携带餐段信息时默认 OFFLINE,兼容未升级的安卓消费机(其请求必带 mealDate/hourType)。
|
||||
* 不带餐段的空通道请求保持原样走基础 SPU 协议:Hardware 的基础池扣减/退款不带 stockChannel 与餐段,
|
||||
* 无条件默认 OFFLINE 会把它们错误路由进分池路径并被餐段校验拒绝。
|
||||
*/
|
||||
private static void normalizeDailyStockChannel(CanteenSpuUpdateStockReqDTO dto) {
|
||||
if (dto != null && StrUtil.isBlank(dto.getStockChannel())) {
|
||||
if (dto == null || StrUtil.isNotBlank(dto.getStockChannel()) || CollUtil.isEmpty(dto.getItems())) {
|
||||
return;
|
||||
}
|
||||
boolean hasDailyRows = dto.getItems().stream()
|
||||
.anyMatch(item -> item != null && (item.getMealDate() != null || item.getHourType() != null));
|
||||
if (hasDailyRows) {
|
||||
dto.setStockChannel("OFFLINE");
|
||||
}
|
||||
}
|
||||
@@ -260,7 +253,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
}
|
||||
boolean offline = !"ONLINE".equals(StrUtil.trim(dto.getStockChannel()));
|
||||
List<CanteenSpuUpdateStockReqDTO.Item> items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline,
|
||||
dto.getItems(), false);
|
||||
dto.getItems());
|
||||
if (CollUtil.isEmpty(items)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_ITEMS_EMPTY));
|
||||
}
|
||||
@@ -289,9 +282,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
|
||||
int updated = offline
|
||||
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), item.getIncrCount(), null)
|
||||
item.getHourType(), item.getId(), item.getIncrCount())
|
||||
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), item.getIncrCount(), null);
|
||||
item.getHourType(), item.getId(), item.getIncrCount());
|
||||
if (updated == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_INSUFFICIENT);
|
||||
}
|
||||
@@ -317,7 +310,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
}
|
||||
// 套餐行展开为组成单品同日同餐段销量联动;ROLLBACK 对同一 items 再展开一次,保证对称回补
|
||||
List<CanteenSpuUpdateStockReqDTO.Item> items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline,
|
||||
dto.getItems(), true);
|
||||
dto.getItems());
|
||||
Map<String, Integer> remainCursor = new HashMap<>();
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
|
||||
CanteenDailyMenuEntity menu = findDailyMenuRow(dto.getMerchantId(), item.getMealDate(), item.getHourType(),
|
||||
@@ -328,32 +321,15 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
String key = dailyStockItemKey(item);
|
||||
int current = remainCursor.computeIfAbsent(key,
|
||||
k -> CanteenDailyMenuStockSupport.channelRemain(menu, offline));
|
||||
int expectedStock = item.getBeforeStock();
|
||||
if (expectedStock < 0) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SNAPSHOT_BAD));
|
||||
}
|
||||
// 线下:兼容误传「剩余总库存」快照,归一到 AD stock 再与 current / SQL expected 对齐
|
||||
Integer normalizedExpected = CanteenDailyMenuStockSupport.normalizeChannelBeforeStock(menu, offline,
|
||||
expectedStock);
|
||||
if (applyOperation && item.getIncrCount() < 0 && normalizedExpected == null) {
|
||||
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
|
||||
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
|
||||
}
|
||||
int casExpected = normalizedExpected != null ? normalizedExpected : expectedStock;
|
||||
item.setBeforeStock(casExpected);
|
||||
// 可售不足:线下用剩余总库存判断(真正可卖边界);线上仍用通道剩余
|
||||
// 可售不足:线下用剩余总库存判断(真正可卖边界);线上仍用通道剩余。
|
||||
// 不再比对调用方快照:不同业务号交替扣减/回补时快照必然失配,曾把消费机正常下单
|
||||
// 确定性误拒为“库存已变化”;防超卖由 delta SQL 的“不超配额、不扣负”边界保证。
|
||||
int sellable = offline ? CanteenDailyMenuStockSupport.sharedRemain(menu) : current;
|
||||
if (item.getIncrCount() < 0 && Math.abs(item.getIncrCount()) > sellable) {
|
||||
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
|
||||
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
|
||||
}
|
||||
if (applyOperation && item.getIncrCount() < 0 && current != casExpected) {
|
||||
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
|
||||
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
|
||||
}
|
||||
if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
int rollbackDelta = -item.getIncrCount();
|
||||
if (rollbackDelta < 0 && current < Math.abs(rollbackDelta)) {
|
||||
@@ -372,24 +348,23 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
updated = offline
|
||||
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), delta, null)
|
||||
item.getHourType(), item.getId(), delta)
|
||||
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), delta, null);
|
||||
item.getHourType(), item.getId(), delta);
|
||||
if (updated == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (applyOperation) {
|
||||
Integer expected = delta > 0 ? null : item.getBeforeStock();
|
||||
// delta SQL 自带“不超配额、不扣负”边界,不再携带快照 CAS 条件
|
||||
updated = offline
|
||||
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), delta, expected)
|
||||
item.getHourType(), item.getId(), delta)
|
||||
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), delta, expected);
|
||||
item.getHourType(), item.getId(), delta);
|
||||
if (updated == 0) {
|
||||
throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
|
||||
: ErrorCodes.CANTEEN_STOCK_CHANGED, "");
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,17 +379,10 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
* 单品售出会通过展示/校验侧的「可组成套餐数」压低套餐可售,此处不反向改写套餐 sold,避免与订单销量口径打架。
|
||||
*/
|
||||
private List<CanteenSpuUpdateStockReqDTO.Item> expandDailyMenuLinkedStockItems(Long merchantId, boolean offline,
|
||||
List<CanteenSpuUpdateStockReqDTO.Item> source, boolean fillMissingBeforeStock) {
|
||||
List<CanteenSpuUpdateStockReqDTO.Item> source) {
|
||||
if (CollUtil.isEmpty(source)) {
|
||||
return List.of();
|
||||
}
|
||||
Map<String, Integer> callerBefore = new HashMap<>();
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : source) {
|
||||
if (item != null && item.getId() != null && item.getMealDate() != null && item.getHourType() != null
|
||||
&& item.getBeforeStock() != null) {
|
||||
callerBefore.put(dailyStockItemKey(item), item.getBeforeStock());
|
||||
}
|
||||
}
|
||||
Set<Long> spuIds = source.stream().filter(Objects::nonNull).map(CanteenSpuUpdateStockReqDTO.Item::getId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<Long, CanteenSpuEntity> spuMap = spuIds.isEmpty() ? Map.of()
|
||||
@@ -478,15 +446,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
if (item.getIncrCount() == null || item.getIncrCount() == 0) {
|
||||
continue;
|
||||
}
|
||||
Integer before = callerBefore.get(dailyStockItemKey(item));
|
||||
if (before == null && fillMissingBeforeStock) {
|
||||
CanteenDailyMenuEntity menu = findDailyMenuRow(merchantId, item.getMealDate(), item.getHourType(),
|
||||
item.getId());
|
||||
before = menu == null ? 0 : CanteenDailyMenuStockSupport.channelRemain(menu, offline);
|
||||
}
|
||||
if (before != null) {
|
||||
item.setBeforeStock(before);
|
||||
}
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
@@ -496,7 +455,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
CanteenSpuUpdateStockReqDTO.Item copy = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
copy.setId(src.getId());
|
||||
copy.setIncrCount(src.getIncrCount());
|
||||
copy.setBeforeStock(src.getBeforeStock());
|
||||
copy.setMealDate(src.getMealDate());
|
||||
copy.setHourType(src.getHourType());
|
||||
return copy;
|
||||
|
||||
+3
-11
@@ -347,16 +347,17 @@
|
||||
<!--
|
||||
delta 与旧接口一致:下单扣减传负数、售后回传正数。
|
||||
实际改写销量:sold = sold - delta(扣减时 sold 增加)。
|
||||
expected 为操作前「剩余」快照(配额 - 销量)。
|
||||
正增量恢复不带任何边界条件:SET 已把销量钳制到 0 为止(菜谱重导或按订单重算销量后
|
||||
sold 可能小于恢复量,退款/补偿不能被统计口径阻断),配额为 0 等脏数据同样不得阻断;
|
||||
负增量扣减保留原「不扣成负销量、不超配额」边界,语义不变。
|
||||
不再携带 beforeStock 快照 CAS 条件:不同业务号交替扣减/回补时快照必然失配,
|
||||
曾把消费机正常下单确定性误拒为“库存已变化”;防超卖由上述边界条件保证。
|
||||
-->
|
||||
<!--
|
||||
共享总库存:offline_stock=总库存,online_stock=移动端上限。
|
||||
delta 为库存协议增量(扣减为负):sold = sold - delta。
|
||||
移动端剩余 = LEAST(上限−已订, 总库存−已订−已售);
|
||||
AD stock / expected = 总库存−已订;扣减边界仍为 已订+已售 ≤ 总库存。
|
||||
扣减边界为 已订+已售 ≤ 总库存。
|
||||
-->
|
||||
<update id="updateOnlineStockDelta">
|
||||
UPDATE canteen_daily_menu
|
||||
@@ -371,12 +372,6 @@
|
||||
AND IFNULL(online_sold, 0) - #{delta} <= IFNULL(online_stock, 600)
|
||||
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600)
|
||||
</if>
|
||||
<if test="expected != null">
|
||||
AND LEAST(
|
||||
GREATEST(IFNULL(online_stock, 600) - IFNULL(online_sold, 0), 0),
|
||||
GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0) - IFNULL(offline_sold, 0), 0)
|
||||
) = #{expected}
|
||||
</if>
|
||||
</update>
|
||||
|
||||
<update id="updateOfflineStockDelta">
|
||||
@@ -391,8 +386,5 @@
|
||||
AND IFNULL(offline_sold, 0) - #{delta} >= 0
|
||||
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600)
|
||||
</if>
|
||||
<if test="expected != null">
|
||||
AND GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0), 0) = #{expected}
|
||||
</if>
|
||||
</update>
|
||||
</mapper>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.bsd.pjcloud.canteen.service.impl;
|
||||
|
||||
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
|
||||
import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenCategoryMapper;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineCategoryMapper;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineComboItemMapper;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineSpuMapper;
|
||||
import com.bsd.pjcloud.canteen.service.CanteenOnlineSpuChoiceGroupService;
|
||||
import com.bsd.pjcloud.canteen.service.CanteenOnlineSpuService;
|
||||
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.core.util.SpringContextHolder;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 在线菜品旧协议(canteen_online_spu)DEDUCT/ROLLBACK 幂等状态机测试。
|
||||
* 协议已不再携带 beforeStock 快照做 CAS:充足性以服务端现值判断,防超卖由
|
||||
* updateStock 同条 UPDATE 的 stock >= |增量| 条件保证,不同业务号交替下单/退款不得误报“库存已变化”。
|
||||
*/
|
||||
class CanteenOnlineSpuServiceImplTest {
|
||||
|
||||
/** 在线菜品 Mapper,用于验证库存增量是否真实执行。 */
|
||||
private CanteenOnlineSpuMapper onlineSpuMapper;
|
||||
|
||||
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
|
||||
private ValueOperations<String, String> valueOperations;
|
||||
|
||||
/** 被测在线菜品服务。 */
|
||||
private CanteenOnlineSpuService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
onlineSpuMapper = mock(CanteenOnlineSpuMapper.class);
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
valueOperations = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
service = new CanteenOnlineSpuServiceImpl(redisTemplate, mock(CanteenCategoryMapper.class),
|
||||
mock(CanteenOnlineCategoryMapper.class), mock(CanteenDailyMenuMapper.class), onlineSpuMapper,
|
||||
mock(CanteenOnlineSpuChoiceGroupService.class), mock(CanteenOnlineComboItemMapper.class),
|
||||
mock(CanteenMerchantScopeHelper.class));
|
||||
}
|
||||
|
||||
/** 撤销最小 MessageSource 注入,避免静态 SpringContextHolder 影响后续测试类。 */
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
ReflectionTestUtils.setField(SpringContextHolder.class, "beanFactory", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* DEDUCT 不再比对快照:其他业务号已改变当前库存(交替下单/退款场景)时,
|
||||
* 只要服务端现值仍够扣就必须放行,不得误报“库存已变化”。
|
||||
*/
|
||||
@Test
|
||||
void deductShouldIgnoreConcurrentStockChange() {
|
||||
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn(null);
|
||||
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(8));
|
||||
when(onlineSpuMapper.updateStock(1L, -2)).thenReturn(1);
|
||||
|
||||
assertTrue(service.updateOnlineSpuSkuStockIdempotent(stockRequest("DEDUCT", -2)).isOk());
|
||||
|
||||
verify(onlineSpuMapper).updateStock(1L, -2);
|
||||
verify(valueOperations).set("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9", "DEDUCTED");
|
||||
}
|
||||
|
||||
/**
|
||||
* DEDUCT 防超卖兜底:服务端现值不足以覆盖扣减量时仍必须拒绝。
|
||||
*/
|
||||
@Test
|
||||
void deductShouldRejectInsufficientStock() {
|
||||
registerMinimalMessageSource();
|
||||
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn(null);
|
||||
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(1));
|
||||
|
||||
R result = service.updateOnlineSpuSkuStockIdempotent(stockRequest("DEDUCT", -2));
|
||||
|
||||
assertFalse(result.isOk());
|
||||
verify(onlineSpuMapper, never()).updateStock(anyLong(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态缺失的 ROLLBACK 说明 DEDUCT 从未生效:只写终态阻断延迟到达的 DEDUCT,
|
||||
* 不得凭空恢复库存。
|
||||
*/
|
||||
@Test
|
||||
void rollbackWithoutDeductShouldOnlyMarkTerminalState() {
|
||||
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn(null);
|
||||
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(8));
|
||||
|
||||
assertTrue(service.updateOnlineSpuSkuStockIdempotent(stockRequest("ROLLBACK", -2)).isOk());
|
||||
|
||||
verify(onlineSpuMapper, never()).updateStock(anyLong(), anyInt());
|
||||
verify(valueOperations).set("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9", "ROLLED_BACK");
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 DEDUCT 已提交的 ROLLBACK 必须反向回补库存并写入终态。
|
||||
*/
|
||||
@Test
|
||||
void rollbackAfterDeductShouldRestoreStock() {
|
||||
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn("DEDUCTED");
|
||||
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(8));
|
||||
when(onlineSpuMapper.updateStock(1L, 2)).thenReturn(1);
|
||||
|
||||
assertTrue(service.updateOnlineSpuSkuStockIdempotent(stockRequest("ROLLBACK", -2)).isOk());
|
||||
|
||||
verify(onlineSpuMapper).updateStock(1L, 2);
|
||||
verify(valueOperations).set("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9", "ROLLED_BACK");
|
||||
}
|
||||
|
||||
/**
|
||||
* 为纯 Mockito 环境注入最小 MessageSource,使 R.failed 分支的 MsgUtils 文案解析可用;
|
||||
* 文案内容不影响断言,直接回显错误码。
|
||||
*/
|
||||
private void registerMinimalMessageSource() {
|
||||
MessageSource messageSource = mock(MessageSource.class);
|
||||
when(messageSource.getMessage(anyString(), any(), any(Locale.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0, String.class));
|
||||
ConfigurableListableBeanFactory factory = mock(ConfigurableListableBeanFactory.class);
|
||||
when(factory.getBean("pjcloudMessageSource")).thenReturn(messageSource);
|
||||
new SpringContextHolder().postProcessBeanFactory(factory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造单个在线菜品的幂等库存请求。
|
||||
*
|
||||
* @param operation DEDUCT 或 ROLLBACK
|
||||
* @param increment DEDUCT 使用的库存增量(扣减为负数)
|
||||
* @return 可直接调用库存服务的请求
|
||||
*/
|
||||
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment) {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(1L);
|
||||
item.setIncrCount(increment);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
dto.setBizId("HARD_RESERVE_ORDER:9");
|
||||
dto.setOperation(operation);
|
||||
dto.setItems(List.of(item));
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造指定当前库存的在线菜品实体。
|
||||
*
|
||||
* @param stock 当前在线菜品库存
|
||||
* @return 仅包含本用例所需字段的实体
|
||||
*/
|
||||
private CanteenOnlineSpuEntity onlineSpu(int stock) {
|
||||
CanteenOnlineSpuEntity entity = new CanteenOnlineSpuEntity();
|
||||
entity.setId(1L);
|
||||
entity.setStock(stock);
|
||||
entity.setName("测试菜品");
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+90
-37
@@ -37,6 +37,8 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 每日菜谱基础 SPU 库存幂等状态机测试,重点覆盖退款任务本地回滚后的再次执行。
|
||||
* 库存协议已不再携带 beforeStock 快照做 CAS:充足性以服务端现值判断,
|
||||
* 防超卖由更新 SQL 的下界条件保证,不同业务号交替扣减/回补不得被误判为“库存已变化”。
|
||||
*/
|
||||
class CanteenSpuServiceImplTest {
|
||||
|
||||
@@ -46,7 +48,7 @@ class CanteenSpuServiceImplTest {
|
||||
/** 基础 SPU Mapper,用于验证库存增量是否真实执行。 */
|
||||
private CanteenSpuMapper spuMapper;
|
||||
|
||||
/** 每日菜谱 Mapper,用于验证分池正增量恢复的剩余匹配条件。 */
|
||||
/** 每日菜谱 Mapper,用于验证分池增量是否真实执行。 */
|
||||
private CanteenDailyMenuMapper dailyMenuMapper;
|
||||
|
||||
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
|
||||
@@ -79,11 +81,10 @@ class CanteenSpuServiceImplTest {
|
||||
|
||||
/**
|
||||
* 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。
|
||||
* 正增量恢复走无条件原子累加,不再与快照做 CAS 对比。
|
||||
*/
|
||||
@Test
|
||||
void compensatedRefundInventoryShouldAllowRetry() {
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2);
|
||||
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
|
||||
.thenReturn("COMPENSATED");
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(10));
|
||||
@@ -97,12 +98,12 @@ class CanteenSpuServiceImplTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 正增量恢复是可交换操作:快照与当前库存不一致(并发下单/退款已改变库存)时仍应直接累加成功,
|
||||
* 正增量恢复是可交换操作:并发下单/退款已改变当前库存时仍应直接累加成功,
|
||||
* 不得被误判为“库存已变化”阻断退款。
|
||||
*/
|
||||
@Test
|
||||
void restoreApplyShouldIgnoreConcurrentStockChange() {
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2);
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(8));
|
||||
when(spuMapper.updateStock(1L, 2)).thenReturn(1);
|
||||
|
||||
@@ -114,24 +115,40 @@ class CanteenSpuServiceImplTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 负增量扣减仍要求当前库存等于快照,防止不同业务号基于同一剩余量重复判断导致超卖。
|
||||
* 负增量扣减不再比对快照:当天其他业务号(消费机售出/退款回补)已改变库存时,
|
||||
* 只要服务端现值仍够扣就必须放行——曾经快照口径失配导致消费机下单被确定性误拒“库存已变化”。
|
||||
*/
|
||||
@Test
|
||||
void deductApplyShouldStillRejectChangedStock() {
|
||||
registerMinimalMessageSource();
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2, 10);
|
||||
void deductApplyShouldIgnoreConcurrentStockChange() {
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2);
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(8));
|
||||
when(spuMapper.updateStock(1L, -2)).thenReturn(1);
|
||||
|
||||
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
|
||||
|
||||
verify(spuMapper).updateStock(1L, -2);
|
||||
verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
|
||||
Duration.ofDays(7));
|
||||
}
|
||||
|
||||
/**
|
||||
* 负增量扣减防超卖兜底:服务端现值不足以覆盖扣减量时仍必须拒绝。
|
||||
*/
|
||||
@Test
|
||||
void deductApplyShouldRejectInsufficientStock() {
|
||||
registerMinimalMessageSource();
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2);
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(1));
|
||||
|
||||
R result = service.updateSkuStockIdempotent(dto);
|
||||
|
||||
assertFalse(result.isOk());
|
||||
verify(spuMapper, never()).updateStock(anyLong(), anyInt());
|
||||
verify(spuMapper, never()).updateStockIfMatch(anyLong(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日菜谱分池正增量恢复:当前剩余与快照不一致(菜谱重导/销量重算后)也不得阻断退款,
|
||||
* 且传给 SQL 的剩余匹配条件为 null(销量回退由 SQL 钳制到 0 为止)。
|
||||
* 每日菜谱分池正增量恢复:菜谱重导/销量重算导致剩余口径变化后也不得阻断退款
|
||||
* (销量回退由 SQL 钳制到 0 为止)。
|
||||
*/
|
||||
@Test
|
||||
void dailyRestoreApplyShouldSkipRemainMatch() {
|
||||
@@ -139,7 +156,6 @@ class CanteenSpuServiceImplTest {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(100L);
|
||||
item.setIncrCount(2);
|
||||
item.setBeforeStock(5);
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(1);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
@@ -149,29 +165,27 @@ class CanteenSpuServiceImplTest {
|
||||
dto.setMerchantId(1L);
|
||||
dto.setItems(List.of(item));
|
||||
|
||||
// 配额 10、已售 0:剩余 10 与快照 5 不一致,正增量恢复仍应放行
|
||||
// 配额 10、已售 0,正增量恢复应放行
|
||||
when(dailyMenuMapper.selectOne(any())).thenReturn(dailyMenu(10, 0));
|
||||
when(dailyMenuMapper.updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null)).thenReturn(1);
|
||||
when(dailyMenuMapper.updateOnlineStockDelta(1L, mealDate, 1, 100L, 2)).thenReturn(1);
|
||||
|
||||
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
|
||||
|
||||
verify(dailyMenuMapper).updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null);
|
||||
verify(dailyMenuMapper).updateOnlineStockDelta(1L, mealDate, 1, 100L, 2);
|
||||
verify(valueOperations).set("canteen:spu:order-stock:daily:ONLINE:HARD_DAILY_MENU_AFTER_SALE:9",
|
||||
"APPLIED", Duration.ofDays(7));
|
||||
}
|
||||
|
||||
/**
|
||||
* OFFLINE 扣减:调用方 beforeStock 误传「剩余总库存」时,应归一到 AD stock 再 CAS,不得误报库存已变化。
|
||||
* 例:总库存 10、线上已订 2、线下已售 3 → shared=5、AD=8;beforeStock=5 时 SQL expected 应为 8。
|
||||
* 本次消费机事故回归用例:总库存 10、线上已订 2、线下已售 3(当天已有其他订单成交痕迹),
|
||||
* OFFLINE 扣减 1 仍应放行——不得再因快照口径失配误报“库存已变化”。
|
||||
*/
|
||||
@Test
|
||||
void offlineApplyShouldNormalizeSharedRemainSnapshotToAdStock() {
|
||||
registerMinimalMessageSource();
|
||||
void offlineApplyShouldAllowDeductAfterOtherBizSoldToday() {
|
||||
LocalDate mealDate = LocalDate.of(2026, 10, 8);
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(100L);
|
||||
item.setIncrCount(-1);
|
||||
item.setBeforeStock(5);
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(1);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
@@ -190,7 +204,7 @@ class CanteenSpuServiceImplTest {
|
||||
menu.setOnlineSold(2);
|
||||
menu.setOfflineSold(3);
|
||||
when(dailyMenuMapper.selectOne(any())).thenReturn(menu);
|
||||
when(dailyMenuMapper.updateOfflineStockDelta(1L, mealDate, 1, 100L, -1, 8)).thenReturn(1);
|
||||
when(dailyMenuMapper.updateOfflineStockDelta(1L, mealDate, 1, 100L, -1)).thenReturn(1);
|
||||
CanteenSpuEntity entity = new CanteenSpuEntity();
|
||||
entity.setId(100L);
|
||||
entity.setSpuType(1);
|
||||
@@ -200,26 +214,50 @@ class CanteenSpuServiceImplTest {
|
||||
|
||||
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
|
||||
|
||||
verify(dailyMenuMapper).updateOfflineStockDelta(1L, mealDate, 1, 100L, -1, 8);
|
||||
verify(dailyMenuMapper).updateOfflineStockDelta(1L, mealDate, 1, 100L, -1);
|
||||
verify(valueOperations).set("canteen:spu:order-stock:daily:OFFLINE:HARD_DAILY_MENU_ORDER:77",
|
||||
"APPLIED", Duration.ofDays(7));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造每日菜谱菜谱行实体。
|
||||
*
|
||||
* @param onlineStock 线上配额
|
||||
* @param onlineSold 线上已售
|
||||
* @return 仅包含本用例所需字段的实体
|
||||
* 每日菜谱分池扣减防超卖兜底:共享剩余总库存(总库存−线上已订−线下已售)不足时仍必须拒绝。
|
||||
*/
|
||||
private CanteenDailyMenuEntity dailyMenu(int onlineStock, int onlineSold) {
|
||||
@Test
|
||||
void dailyOfflineDeductShouldRejectWhenSharedRemainInsufficient() {
|
||||
registerMinimalMessageSource();
|
||||
LocalDate mealDate = LocalDate.of(2026, 10, 8);
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(100L);
|
||||
item.setIncrCount(-1);
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(1);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
dto.setBizId("HARD_DAILY_MENU_ORDER:78");
|
||||
dto.setOperation("APPLY");
|
||||
dto.setStockChannel("OFFLINE");
|
||||
dto.setMerchantId(1L);
|
||||
dto.setItems(List.of(item));
|
||||
|
||||
// 总库存 10、线上已订 2、线下已售 8:共享剩余 0,扣 1 应拒绝
|
||||
CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity();
|
||||
menu.setMerchantId(1L);
|
||||
menu.setSpuId(100L);
|
||||
menu.setHourType(1);
|
||||
menu.setOnlineStock(onlineStock);
|
||||
menu.setOnlineSold(onlineSold);
|
||||
return menu;
|
||||
menu.setOfflineStock(10);
|
||||
menu.setOnlineStock(10);
|
||||
menu.setOnlineSold(2);
|
||||
menu.setOfflineSold(8);
|
||||
when(dailyMenuMapper.selectOne(any())).thenReturn(menu);
|
||||
CanteenSpuEntity entity = new CanteenSpuEntity();
|
||||
entity.setId(100L);
|
||||
entity.setSpuType(1);
|
||||
entity.setMerchantId(1L);
|
||||
entity.setName("测试菜品");
|
||||
when(spuMapper.selectBatchIds(any())).thenReturn(List.of(entity));
|
||||
|
||||
assertFalse(service.updateSkuStockIdempotent(dto).isOk());
|
||||
|
||||
verify(dailyMenuMapper, never()).updateOfflineStockDelta(anyLong(), any(), anyInt(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,7 +265,7 @@ class CanteenSpuServiceImplTest {
|
||||
*/
|
||||
@Test
|
||||
void confirmedApplyRollbackShouldBecomeCompensated() {
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("ROLLBACK", 2, 10);
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("ROLLBACK", 2);
|
||||
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
|
||||
.thenReturn("APPLIED");
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(12));
|
||||
@@ -257,15 +295,13 @@ class CanteenSpuServiceImplTest {
|
||||
* 构造单个基础 SPU 的幂等库存请求。
|
||||
*
|
||||
* @param operation APPLY 或 ROLLBACK
|
||||
* @param increment 原 APPLY 使用的库存增量
|
||||
* @param beforeStock 当前业务尝试的操作前库存快照
|
||||
* @param increment 库存增量(扣减为负数)
|
||||
* @return 可直接调用库存服务的请求
|
||||
*/
|
||||
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment, int beforeStock) {
|
||||
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment) {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(1L);
|
||||
item.setIncrCount(increment);
|
||||
item.setBeforeStock(beforeStock);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
dto.setBizId("HARD_DAILY_MENU_AFTER_SALE:9");
|
||||
dto.setOperation(operation);
|
||||
@@ -273,6 +309,23 @@ class CanteenSpuServiceImplTest {
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造每日菜谱菜谱行实体。
|
||||
*
|
||||
* @param onlineStock 线上配额
|
||||
* @param onlineSold 线上已售
|
||||
* @return 仅包含本用例所需字段的实体
|
||||
*/
|
||||
private CanteenDailyMenuEntity dailyMenu(int onlineStock, int onlineSold) {
|
||||
CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity();
|
||||
menu.setMerchantId(1L);
|
||||
menu.setSpuId(100L);
|
||||
menu.setHourType(1);
|
||||
menu.setOnlineStock(onlineStock);
|
||||
menu.setOnlineSold(onlineSold);
|
||||
return menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造当前库存快照。
|
||||
*
|
||||
|
||||
+1
@@ -1326,6 +1326,7 @@ public interface ErrorCodes {
|
||||
String APP_MSG_WALLET_DETAIL_SEP = "app.msg.wallet.detail.sep";
|
||||
String APP_MSG_WALLET_DETAIL_REMARK = "app.msg.wallet.detail.remark";
|
||||
String APP_MSG_WALLET_CONTENT = "app.msg.wallet.content";
|
||||
String APP_MSG_WALLET_SUMMARY = "app.msg.wallet.summary";
|
||||
|
||||
String APP_MSG_MEAL_DEFAULT_NAME = "app.msg.meal.default.name";
|
||||
String APP_MSG_MEAL_CONTENT = "app.msg.meal.content";
|
||||
|
||||
@@ -440,6 +440,7 @@ app.msg.wallet.detail.dec={0} decreased by {1}, balance {2}
|
||||
app.msg.wallet.detail.sep=;
|
||||
app.msg.wallet.detail.remark= (Note: {0})
|
||||
app.msg.wallet.content={0}: {1}
|
||||
app.msg.wallet.summary={0}: {1} wallet changes
|
||||
app.msg.meal.default.name=This reserved meal
|
||||
app.msg.meal.content={0} has been verified. Enjoy your meal.
|
||||
app.msg.ward.fallback=Ward
|
||||
|
||||
@@ -440,6 +440,7 @@ app.msg.wallet.detail.dec={0}减少{1}元,余额{2}元
|
||||
app.msg.wallet.detail.sep=;
|
||||
app.msg.wallet.detail.remark=(备注:{0})
|
||||
app.msg.wallet.content={0}:{1}
|
||||
app.msg.wallet.summary={0}:共{1}笔钱包变动
|
||||
app.msg.meal.default.name=本次预约用餐
|
||||
app.msg.meal.content={0}已核销,祝您用餐愉快。
|
||||
app.msg.ward.fallback=被监护人
|
||||
|
||||
@@ -440,6 +440,7 @@ app.msg.wallet.detail.dec={0}減少{1}元,餘額{2}元
|
||||
app.msg.wallet.detail.sep=;
|
||||
app.msg.wallet.detail.remark=(備註:{0})
|
||||
app.msg.wallet.content={0}:{1}
|
||||
app.msg.wallet.summary={0}:共{1}筆錢包變動
|
||||
app.msg.meal.default.name=本次預約用餐
|
||||
app.msg.meal.content={0}已核銷,祝您用餐愉快。
|
||||
app.msg.ward.fallback=被監護人
|
||||
|
||||
+2
-6
@@ -2972,7 +2972,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
item.setIncrCount(Math.multiplyExact(car.getProductCount(), factor));
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(hourType);
|
||||
item.setBeforeStock(inventorySnapshot.get(dailyStockKey(mealDate, hourType, car.getProductId())));
|
||||
items.add(item);
|
||||
}
|
||||
}
|
||||
@@ -3049,7 +3048,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(entry.getKey());
|
||||
item.setIncrCount(entry.getValue());
|
||||
item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
|
||||
return item;
|
||||
}).toList());
|
||||
R<?> result = onlineCanteenSpuService.updateSkuStockIdempotent(stockDto);
|
||||
@@ -5155,7 +5153,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(entry.getKey());
|
||||
item.setIncrCount(entry.getValue());
|
||||
item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
|
||||
return item;
|
||||
}).toList());
|
||||
R<?> result = remoteCanteenSpuService.updateSkuStockIdempotent(dto);
|
||||
@@ -5183,7 +5180,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(entry.getKey());
|
||||
item.setIncrCount(entry.getValue());
|
||||
item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(hourType);
|
||||
return item;
|
||||
@@ -5233,8 +5229,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* 线下快照与食堂 channelRemain(OFFLINE) 一致:AD stock = 总库存 − 线上已订。
|
||||
* 不用 App 填充的 remainOfflineStock(在 listForOrderValidate 里是「剩余总库存」,会导致 beforeStock 对不齐)。
|
||||
* 线下剩余(本地充足性预检口径):与食堂 channelRemain(OFFLINE) 一致,AD stock = 总库存 − 线上已订。
|
||||
* 不用 App 填充的 remainOfflineStock(在 listForOrderValidate 里是「剩余总库存」,口径偏小会误拒下单)。
|
||||
*/
|
||||
private static int dailyOfflineRemain(DailyMenuAppVO row) {
|
||||
if (row == null) {
|
||||
|
||||
+7
-12
@@ -112,7 +112,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
assertEquals("APPLY", request.getOperation());
|
||||
assertEquals("HARD_DAILY_MENU_ORDER:100", request.getBizId());
|
||||
assertEquals(-2, request.getItems().get(0).getIncrCount());
|
||||
assertEquals(5, request.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,7 +163,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
assertEquals("APPLY", request.getOperation());
|
||||
assertEquals("HARD_DAILY_MENU_AFTER_SALE:500", request.getBizId());
|
||||
assertEquals(2, request.getItems().get(0).getIncrCount());
|
||||
assertEquals(3, request.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +187,7 @@ class HardOrderServiceImplInventoryTest {
|
||||
|
||||
/**
|
||||
* 验证设备购买链路(/hard/hard-api/ad/create-order 的 BELOW+CANTEEN_SPU 购物车)对当日菜谱在册商品
|
||||
* 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE、日期餐段和线下剩余快照,不再扣 canteen_spu.stock。
|
||||
* 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE 和日期餐段,不再扣 canteen_spu.stock。
|
||||
*/
|
||||
@Test
|
||||
void deviceCreateOrderDeductsDailyMenuOfflinePoolWhenOnMenu() {
|
||||
@@ -211,7 +209,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = request.getItems().get(0);
|
||||
assertEquals(11L, item.getId());
|
||||
assertEquals(-1, item.getIncrCount());
|
||||
assertEquals(4, item.getBeforeStock());
|
||||
assertEquals(LocalDate.of(2026, 9, 13), item.getMealDate());
|
||||
assertEquals(2, item.getHourType());
|
||||
verify(remoteCanteenSpuService, never()).getInnerSpuListByIds(any());
|
||||
@@ -237,7 +234,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
CanteenSpuUpdateStockReqDTO request = captor.getValue();
|
||||
assertNull(request.getStockChannel());
|
||||
assertEquals(-1, request.getItems().get(0).getIncrCount());
|
||||
assertEquals(9996, request.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,16 +262,13 @@ class HardOrderServiceImplInventoryTest {
|
||||
assertEquals("HARD_DAILY_MENU_ORDER:100", dailyRequest.getBizId());
|
||||
assertEquals(11L, dailyRequest.getItems().get(0).getId());
|
||||
assertEquals(-1, dailyRequest.getItems().get(0).getIncrCount());
|
||||
assertEquals(4, dailyRequest.getItems().get(0).getBeforeStock());
|
||||
assertEquals("HARD_DAILY_MENU_ORDER:100", basicRequest.getBizId());
|
||||
assertEquals(12L, basicRequest.getItems().get(0).getId());
|
||||
assertEquals(-2, basicRequest.getItems().get(0).getIncrCount());
|
||||
assertEquals(5, basicRequest.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证线上预定每日菜谱订单退款恢复 ONLINE 池:下单扣的是 online_sold,退款必须还同一池,
|
||||
* 快照取线上剩余并携带日期餐段。
|
||||
* 验证线上预定每日菜谱订单退款恢复 ONLINE 池:下单扣的是 online_sold,退款必须还同一池,并携带日期餐段。
|
||||
*/
|
||||
@Test
|
||||
void reserveRefundRestoresDailyMenuOnlineStock() {
|
||||
@@ -296,13 +289,12 @@ class HardOrderServiceImplInventoryTest {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = request.getItems().get(0);
|
||||
assertEquals(11L, item.getId());
|
||||
assertEquals(2, item.getIncrCount());
|
||||
assertEquals(8, item.getBeforeStock());
|
||||
assertEquals(LocalDate.of(2026, 9, 13), item.getMealDate());
|
||||
assertEquals(2, item.getHourType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证机器收银等其他类型的每日菜谱订单退款仍恢复 OFFLINE 池,快照取线下剩余。
|
||||
* 验证机器收银等其他类型的每日菜谱订单退款仍恢复 OFFLINE 池。
|
||||
*/
|
||||
@Test
|
||||
void cashierDailyMenuRefundRestoresOfflineStock() {
|
||||
@@ -321,7 +313,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
assertEquals("OFFLINE", request.getStockChannel());
|
||||
assertEquals("HARD_DAILY_MENU_AFTER_SALE:500", request.getBizId());
|
||||
assertEquals(2, request.getItems().get(0).getIncrCount());
|
||||
assertEquals(4, request.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,6 +400,8 @@ class HardOrderServiceImplInventoryTest {
|
||||
|
||||
/**
|
||||
* 构造指定线上/线下剩余的每日菜谱行。
|
||||
* 线下剩余按 hard 预检口径(AD stock = offlineStock − onlineSold)同步设置配额字段,
|
||||
* 不能只填 remainOfflineStock(App 展示口径,预检不信任)。
|
||||
*
|
||||
* @param onlineRemain 线上池剩余
|
||||
* @param offlineRemain 线下池剩余
|
||||
@@ -420,6 +413,8 @@ class HardOrderServiceImplInventoryTest {
|
||||
row.setName("测试菜品");
|
||||
row.setRemainOnlineStock(onlineRemain);
|
||||
row.setRemainOfflineStock(offlineRemain);
|
||||
row.setOfflineStock(offlineRemain);
|
||||
row.setOnlineSold(0);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.bsd.pjcloud.thirdparty.dto.appmessage;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* PC重新推送失败终态投递记录的请求。
|
||||
*/
|
||||
@Data
|
||||
public class AppPushDeliveryRetryDto implements Serializable {
|
||||
|
||||
/** 当前操作的商户ID,用于复核管理员数据范围。 */
|
||||
@NotNull(message = "商户ID不能为空")
|
||||
private Long mchId;
|
||||
|
||||
/** 待重新推送的投递记录ID列表,仅失败终态记录会被重置。 */
|
||||
@NotEmpty(message = "请选择要重新推送的投递记录")
|
||||
@Size(max = 100, message = "单次最多重新推送100条投递记录")
|
||||
private List<Long> deliveryIds;
|
||||
}
|
||||
+3
@@ -23,6 +23,9 @@ public class AppPushDeliveryVo implements Serializable {
|
||||
/** 目标用户ID。 */
|
||||
private Long recipientUserId;
|
||||
|
||||
/** 接收用户姓名,远程用户服务不可用或用户已删除时为空。 */
|
||||
private String recipientName;
|
||||
|
||||
/** 投递状态。 */
|
||||
private String status;
|
||||
|
||||
|
||||
+12
@@ -85,6 +85,18 @@ public class AppMessageConstants {
|
||||
/** 一次投递扫描领取的最大任务数,控制数据库和供应商压力。 */
|
||||
public static final int DELIVERY_SCAN_LIMIT = 100;
|
||||
|
||||
/** 钱包事件正文逐条渲染明细的最大条数;批量补录等场景一个聚合事件可能携带上百条明细,逐条拼接必然超出正文列宽导致消息整体投递失败,超过该阈值时正文降级为“共N笔”摘要,完整明细仍保留在payload_json供App详情页展示。 */
|
||||
public static final int WALLET_DETAIL_RENDER_LIMIT = 10;
|
||||
|
||||
/** third_app_message.title 列宽(字符),所有渲染标题超出时截断加省略号兜底,防止数据库层拒绝写入。 */
|
||||
public static final int MESSAGE_TITLE_MAX_LENGTH = 128;
|
||||
|
||||
/** third_app_message.content 列宽(字符),本人与关系消息正文超出时截断加省略号兜底,防止数据库层拒绝写入。 */
|
||||
public static final int MESSAGE_CONTENT_MAX_LENGTH = 2000;
|
||||
|
||||
/** 系统Push通知正文的展示上限(字符);通知栏无法完整展示长正文,极光等供应商对报文体积也有限制,超长时截断加省略号,用户点击后经深链进入消息详情查看全文。 */
|
||||
public static final int PUSH_CONTENT_MAX_LENGTH = 100;
|
||||
|
||||
/** 第1至第8次失败后使用的重试间隔,单位分钟;最后一次达到上限后直接进入失败终态。 */
|
||||
public static final List<Long> RETRY_INTERVAL_MINUTES = List.of(1L, 5L, 15L, 60L, 180L, 360L, 720L, 720L);
|
||||
}
|
||||
+20
-2
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.log.annotation.SysLog;
|
||||
import com.bsd.pjcloud.common.security.util.SecurityUtils;
|
||||
import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushDeliveryRetryDto;
|
||||
import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushPolicySaveDto;
|
||||
import com.bsd.pjcloud.thirdparty.dto.appmessage.AppPushTestDto;
|
||||
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService;
|
||||
@@ -121,6 +122,7 @@ public class ThirdAppMessagePcController {
|
||||
* @param mchId 商户ID
|
||||
* @param status 可选投递状态
|
||||
* @param eventCode 可选事件编码
|
||||
* @param recipientUserId 可选接收用户ID
|
||||
* @return 商户投递记录分页
|
||||
*/
|
||||
@Operation(summary = "分页查询商户业务Push投递记录")
|
||||
@@ -128,9 +130,10 @@ public class ThirdAppMessagePcController {
|
||||
@GetMapping("/deliveries")
|
||||
public R<Page<AppPushDeliveryVo>> pageDeliveries(@ParameterObject Page<?> page, @RequestParam Long mchId,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String eventCode) {
|
||||
@RequestParam(required = false) String eventCode,
|
||||
@RequestParam(required = false) Long recipientUserId) {
|
||||
requireMerchantAccess(mchId);
|
||||
return R.ok(deliveryService.page(page, mchId, status, eventCode));
|
||||
return R.ok(deliveryService.page(page, mchId, status, eventCode, recipientUserId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +151,21 @@ public class ThirdAppMessagePcController {
|
||||
return R.ok(deliveryService.listAttempts(deliveryId, mchId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把失败终态的投递记录重置回待投递,由投递扫描任务在数秒内重新推送。
|
||||
*
|
||||
* @param dto 商户与投递记录ID集合
|
||||
* @return 实际重置的记录数量
|
||||
*/
|
||||
@Operation(summary = "重新推送App投递记录")
|
||||
@SysLog("重新推送App投递记录")
|
||||
@PreAuthorize("@pms.hasPermission('wallet_mchManage_setting')")
|
||||
@PostMapping("/deliveries/retry")
|
||||
public R<Integer> retryDeliveries(@Valid @RequestBody AppPushDeliveryRetryDto dto) {
|
||||
requireMerchantAccess(dto.getMchId());
|
||||
return R.ok(deliveryService.retryDeliveries(dto.getMchId(), dto.getDeliveryIds()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前部署Push供应商只读状态。
|
||||
*
|
||||
|
||||
+11
-1
@@ -35,9 +35,10 @@ public interface ThirdPushDeliveryService {
|
||||
* @param mchId 商户ID
|
||||
* @param status 可选投递状态
|
||||
* @param eventCode 可选事件编码
|
||||
* @param recipientUserId 可选接收用户ID
|
||||
* @return 商户投递记录分页
|
||||
*/
|
||||
Page<AppPushDeliveryVo> page(Page<?> page, Long mchId, String status, String eventCode);
|
||||
Page<AppPushDeliveryVo> page(Page<?> page, Long mchId, String status, String eventCode, Long recipientUserId);
|
||||
|
||||
/**
|
||||
* 查询指定商户投递任务的供应商调用详情。
|
||||
@@ -48,6 +49,15 @@ public interface ThirdPushDeliveryService {
|
||||
*/
|
||||
List<AppPushDeliveryAttemptVo> listAttempts(Long deliveryId, Long mchId);
|
||||
|
||||
/**
|
||||
* 把失败终态的投递记录重置回待投递,由既有扫描任务在数秒内重新推送。
|
||||
*
|
||||
* @param mchId 商户ID
|
||||
* @param deliveryIds 待重新推送的投递记录ID
|
||||
* @return 实际重置的记录数量,非失败终态或非本商户记录不计入
|
||||
*/
|
||||
int retryDeliveries(Long mchId, List<Long> deliveryIds);
|
||||
|
||||
/**
|
||||
* 返回供应商只读健康状态,不包含部署密钥。
|
||||
*
|
||||
|
||||
+31
-4
@@ -287,6 +287,9 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService {
|
||||
/**
|
||||
* 构造本人或关系消息的公共不可变业务快照。
|
||||
*
|
||||
* <p>标题与正文写入前按数据库列宽兜底截断:模板升级、钱包名或备注极长等任何组合都不允许
|
||||
* 触发列超长异常,否则整条消息事务(含Push投递任务)会一起回滚。</p>
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param eventId 业务事件幂等ID
|
||||
* @param eventCode 固定事件编码
|
||||
@@ -321,8 +324,8 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService {
|
||||
message.setSourceBizId(sourceBizId);
|
||||
message.setSourceMchId(sourceMchId);
|
||||
message.setSourceMchName(sourceMchName);
|
||||
message.setTitle(title);
|
||||
message.setContent(content);
|
||||
message.setTitle(clampColumn(title, AppMessageConstants.MESSAGE_TITLE_MAX_LENGTH));
|
||||
message.setContent(clampColumn(content, AppMessageConstants.MESSAGE_CONTENT_MAX_LENGTH));
|
||||
message.setPayloadJson(payloadJson);
|
||||
message.setDeepLinkPath(AppMessageConstants.MESSAGE_DETAIL_PATH);
|
||||
message.setReadStatus(AppMessageConstants.UNREAD);
|
||||
@@ -330,6 +333,23 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按数据库列宽截断渲染文案,超出时截到列宽减一并追加省略号,保证最终长度不超过列宽。
|
||||
* 兜底覆盖本人与关系两条渲染路径(关系文案会在本人文案前再拼接姓名前缀,比本人文案更长)。
|
||||
* 方法包内可见以便单测直接验证边界。
|
||||
*
|
||||
* @param value 渲染后的标题或正文,允许为空
|
||||
* @param maxLength 目标列宽,单位字符
|
||||
* @return 未超长时原样返回,超长时以省略号结尾且长度等于列宽的截断文案
|
||||
*/
|
||||
static String clampColumn(String value, int maxLength) {
|
||||
if (value == null || value.length() <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
// 预留一个字符给省略号,保证截断结果连同省略号恰好不超过列宽
|
||||
return StrUtil.sub(value, 0, maxLength - 1) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验钱包事件编码和全部明细业务类型一致。
|
||||
*
|
||||
@@ -420,11 +440,18 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService {
|
||||
/**
|
||||
* 渲染钱包多明细正文,金额和结果余额统一按分转换为两位元展示。
|
||||
*
|
||||
* <p>明细数量超过 {@link AppMessageConstants#WALLET_DETAIL_RENDER_LIMIT} 时降级为“共N笔”摘要:
|
||||
* 批量补录等大事件逐条拼接的正文会超出 third_app_message.content 列宽,导致整条消息连同Push投递一起失败;
|
||||
* 完整明细始终保留在 payload_json 中,App 详情页与按语言重渲染均不丢失信息。方法包内可见以便单测直接验证渲染边界。</p>
|
||||
*
|
||||
* @param event 钱包事件
|
||||
* @param details 同一业务操作的钱包明细
|
||||
* @return 当前语言下的模板正文
|
||||
* @return 当前语言下的逐条模板正文或超阈值摘要正文
|
||||
*/
|
||||
private String walletContent(AppMessageEventEnum event, List<WalletMessageDetailDto> details) {
|
||||
String walletContent(AppMessageEventEnum event, List<WalletMessageDetailDto> details) {
|
||||
if (details.size() > AppMessageConstants.WALLET_DETAIL_RENDER_LIMIT) {
|
||||
return MsgUtils.getMessage(ErrorCodes.APP_MSG_WALLET_SUMMARY, eventLabel(event), details.size());
|
||||
}
|
||||
String sep = MsgUtils.getMessage(ErrorCodes.APP_MSG_WALLET_DETAIL_SEP);
|
||||
StringBuilder detailsText = new StringBuilder();
|
||||
for (int index = 0; index < details.size(); index++) {
|
||||
|
||||
+84
-2
@@ -1,9 +1,14 @@
|
||||
package com.bsd.pjcloud.thirdparty.service.impl.appmessage;
|
||||
|
||||
import cn.hutool.core.collection.IterUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysUser;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
|
||||
import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants;
|
||||
import com.bsd.pjcloud.thirdparty.constant.push.ThirdPushBizTypeEnum;
|
||||
@@ -83,6 +88,9 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
/** 事务模板,保证任务领取、供应商尝试留痕与最终投递状态在同一数据库事务中提交。 */
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
/** UPMS远程用户服务,用于PC列表补齐接收用户姓名。 */
|
||||
private final RemoteUserService remoteUserService;
|
||||
|
||||
/**
|
||||
* 创建一条唯一Push决策;没有有效设备时把原本待投递任务记录为跳过。
|
||||
*
|
||||
@@ -157,23 +165,56 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
* @param mchId 商户ID
|
||||
* @param status 可选投递状态
|
||||
* @param eventCode 可选事件编码
|
||||
* @param recipientUserId 可选接收用户ID
|
||||
* @return PC投递记录分页
|
||||
*/
|
||||
@Override
|
||||
public Page<AppPushDeliveryVo> page(Page<?> page, Long mchId, String status, String eventCode) {
|
||||
public Page<AppPushDeliveryVo> page(Page<?> page, Long mchId, String status, String eventCode, Long recipientUserId) {
|
||||
Page<ThirdPushDeliveryEntity> entityPage = deliveryMapper.selectPage(
|
||||
new Page<>(page.getCurrent(), page.getSize()),
|
||||
Wrappers.<ThirdPushDeliveryEntity>lambdaQuery()
|
||||
.eq(ThirdPushDeliveryEntity::getSourceMchId, mchId)
|
||||
.eq(StrUtil.isNotBlank(status), ThirdPushDeliveryEntity::getStatus, status)
|
||||
.eq(StrUtil.isNotBlank(eventCode), ThirdPushDeliveryEntity::getEventCode, eventCode)
|
||||
.eq(recipientUserId != null, ThirdPushDeliveryEntity::getRecipientUserId, recipientUserId)
|
||||
.orderByDesc(ThirdPushDeliveryEntity::getCreateTime)
|
||||
.orderByDesc(ThirdPushDeliveryEntity::getId));
|
||||
Page<AppPushDeliveryVo> result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
result.setRecords(entityPage.getRecords().stream().map(this::toDeliveryVo).toList());
|
||||
fillRecipientNames(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量补齐投递记录接收用户姓名,远程服务异常或用户已删除不阻断诊断列表。
|
||||
*
|
||||
* @param records 当前页PC投递记录
|
||||
*/
|
||||
private void fillRecipientNames(List<AppPushDeliveryVo> records) {
|
||||
List<Long> userIdList = records.stream().map(AppPushDeliveryVo::getRecipientUserId)
|
||||
.filter(ObjectUtil::isNotEmpty).distinct().toList();
|
||||
if (ObjectUtil.isEmpty(userIdList)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
R<List<SysUser>> result = remoteUserService.getInnerUserById(userIdList);
|
||||
List<SysUser> userList = result == null || !result.isOk() ? null : result.getData();
|
||||
if (ObjectUtil.isEmpty(userList)) {
|
||||
return;
|
||||
}
|
||||
Map<Long, SysUser> userMap = IterUtil.toMap(userList, SysUser::getUserId);
|
||||
for (AppPushDeliveryVo record : records) {
|
||||
SysUser targetUser = userMap.get(record.getRecipientUserId());
|
||||
if (targetUser == null) {
|
||||
continue;
|
||||
}
|
||||
record.setRecipientName(targetUser.getName());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.warn("投递记录列表补充接收用户姓名失败,errorType:{}", exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定商户投递任务的供应商调用详情,先校验任务确实属于该商户。
|
||||
*
|
||||
@@ -196,6 +237,31 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
.stream().map(this::toAttemptVo).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把失败终态的投递记录条件重置回待投递,交由既有扫描任务重新领取处理。
|
||||
*
|
||||
* @param mchId 商户ID
|
||||
* @param deliveryIds 待重新推送的投递记录ID
|
||||
* @return 实际重置的记录数量
|
||||
*/
|
||||
@Override
|
||||
public int retryDeliveries(Long mchId, List<Long> deliveryIds) {
|
||||
if (ObjectUtil.isEmpty(deliveryIds)) {
|
||||
return 0;
|
||||
}
|
||||
return deliveryMapper.update(null, Wrappers.<ThirdPushDeliveryEntity>lambdaUpdate()
|
||||
.set(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_PENDING)
|
||||
.set(ThirdPushDeliveryEntity::getAttemptCount, 0)
|
||||
.set(ThirdPushDeliveryEntity::getNextRetryTime, null)
|
||||
.set(ThirdPushDeliveryEntity::getFinalProviderCode, null)
|
||||
.set(ThirdPushDeliveryEntity::getLastError, null)
|
||||
.set(ThirdPushDeliveryEntity::getSuccessTime, null)
|
||||
.set(ThirdPushDeliveryEntity::getUpdateTime, LocalDateTime.now())
|
||||
.in(ThirdPushDeliveryEntity::getId, deliveryIds)
|
||||
.eq(ThirdPushDeliveryEntity::getSourceMchId, mchId)
|
||||
.eq(ThirdPushDeliveryEntity::getStatus, AppMessageConstants.DELIVERY_FAILED));
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回全部供应商只读状态。
|
||||
*
|
||||
@@ -328,7 +394,7 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
}
|
||||
PushProviderRequest request = new PushProviderRequest();
|
||||
request.setTitle(message.getTitle());
|
||||
request.setContent(message.getContent());
|
||||
request.setContent(clampPushContent(message.getContent()));
|
||||
request.setUserIds(List.of(current.getRecipientUserId()));
|
||||
request.setExtras(Map.of(
|
||||
"messageId", String.valueOf(message.getId()),
|
||||
@@ -372,6 +438,22 @@ public class ThirdPushDeliveryServiceImpl implements ThirdPushDeliveryService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按系统通知展示上限截断推送正文,超长时以省略号结尾;收件箱与消息详情页仍保留完整正文,
|
||||
* 用户点击通知后经深链进入详情查看全文。通知栏本身无法完整展示长正文,
|
||||
* 且超长报文可能触发极光等供应商的体积限制导致整次投递失败。
|
||||
*
|
||||
* @param content 收件箱消息正文
|
||||
* @return 通知栏可展示的正文
|
||||
*/
|
||||
private String clampPushContent(String content) {
|
||||
if (content == null || content.length() <= AppMessageConstants.PUSH_CONTENT_MAX_LENGTH) {
|
||||
return content;
|
||||
}
|
||||
// 预留一个字符给省略号,保证截断结果连同省略号不超过通知展示上限
|
||||
return StrUtil.sub(content, 0, AppMessageConstants.PUSH_CONTENT_MAX_LENGTH - 1) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存本轮所有实际供应商调用尝试。
|
||||
*
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.bsd.pjcloud.thirdparty.service.impl.appmessage;
|
||||
|
||||
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
|
||||
import com.bsd.pjcloud.common.core.util.MsgUtils;
|
||||
import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants;
|
||||
import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageEventEnum;
|
||||
import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageDetailDto;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* App业务消息正文渲染边界测试:钱包多明细超过渲染阈值时降级为摘要,列宽兜底截断保证写入不超长。
|
||||
* 场景来源于批量补录事件曾因正文超过 third_app_message.content 列宽导致消息与Push整体投递失败。
|
||||
*/
|
||||
class ThirdAppMessageContentTest {
|
||||
|
||||
/** 被测服务实例;正文渲染方法不依赖任何注入Bean,构造参数全部传空。 */
|
||||
private final ThirdAppMessageServiceImpl service = new ThirdAppMessageServiceImpl(null, null, null, null, null);
|
||||
|
||||
/**
|
||||
* 构造一条用于渲染的钱包变动明细。
|
||||
*
|
||||
* @param walletName 钱包名称
|
||||
* @param amount 变动金额,单位分
|
||||
* @param balance 变动后余额,单位分
|
||||
* @param remark 业务备注,允许为空
|
||||
* @return 钱包变动明细
|
||||
*/
|
||||
private WalletMessageDetailDto detail(String walletName, int amount, int balance, String remark) {
|
||||
WalletMessageDetailDto dto = new WalletMessageDetailDto();
|
||||
dto.setWalletName(walletName);
|
||||
dto.setAmount(amount);
|
||||
dto.setBalance(balance);
|
||||
dto.setRemark(remark);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证明细数量超过渲染阈值时正文降级为“共N笔”摘要,不再逐条拼接。
|
||||
*/
|
||||
@Test
|
||||
void walletContentShouldSummarizeWhenDetailsExceedRenderLimit() {
|
||||
List<WalletMessageDetailDto> details = new ArrayList<>();
|
||||
for (int index = 0; index <= AppMessageConstants.WALLET_DETAIL_RENDER_LIMIT; index++) {
|
||||
details.add(detail("福利钱包", -800, 999200, "补录消费扣款"));
|
||||
}
|
||||
try (MockedStatic<MsgUtils> msgUtils = Mockito.mockStatic(MsgUtils.class)) {
|
||||
msgUtils.when(() -> MsgUtils.getMessage(Mockito.anyString()))
|
||||
.thenAnswer(invocation -> "MSG:" + invocation.getArgument(0));
|
||||
msgUtils.when(() -> MsgUtils.getMessage(Mockito.anyString(), Mockito.any(Object[].class)))
|
||||
.thenAnswer(invocation -> {
|
||||
// Mockito会把可变参数展开成独立实参,这里手动收拢第2个及之后的参数便于断言
|
||||
Object[] templateArgs = Arrays.copyOfRange(invocation.getArguments(), 1, invocation.getArguments().length);
|
||||
return "MSG:" + invocation.getArgument(0) + Arrays.toString(templateArgs);
|
||||
});
|
||||
String content = service.walletContent(AppMessageEventEnum.WALLET_UPDATE, details);
|
||||
Assertions.assertEquals("MSG:" + ErrorCodes.APP_MSG_WALLET_SUMMARY
|
||||
+ "[MSG:" + ErrorCodes.APP_MSG_EVENT_WALLET_UPDATE + ", " + details.size() + "]", content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证明细数量在渲染阈值内时仍逐条渲染收入/支出与备注模板。
|
||||
*/
|
||||
@Test
|
||||
void walletContentShouldRenderEachDetailWithinLimit() {
|
||||
List<WalletMessageDetailDto> details = List.of(
|
||||
detail("福利钱包", 10000000, 10000000, "系统批量充值"),
|
||||
detail("福利钱包", -800, 9999200, null));
|
||||
try (MockedStatic<MsgUtils> msgUtils = Mockito.mockStatic(MsgUtils.class)) {
|
||||
msgUtils.when(() -> MsgUtils.getMessage(Mockito.anyString()))
|
||||
.thenAnswer(invocation -> "MSG:" + invocation.getArgument(0));
|
||||
msgUtils.when(() -> MsgUtils.getMessage(Mockito.anyString(), Mockito.any(Object[].class)))
|
||||
.thenAnswer(invocation -> {
|
||||
// Mockito会把可变参数展开成独立实参,这里手动收拢第2个及之后的参数便于断言
|
||||
Object[] templateArgs = Arrays.copyOfRange(invocation.getArguments(), 1, invocation.getArguments().length);
|
||||
return "MSG:" + invocation.getArgument(0) + Arrays.toString(templateArgs);
|
||||
});
|
||||
String content = service.walletContent(AppMessageEventEnum.WALLET_UPDATE, details);
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertTrue(content.contains("MSG:" + ErrorCodes.APP_MSG_WALLET_DETAIL_INC)),
|
||||
() -> Assertions.assertTrue(content.contains("MSG:" + ErrorCodes.APP_MSG_WALLET_DETAIL_DEC)),
|
||||
() -> Assertions.assertTrue(content.contains("MSG:" + ErrorCodes.APP_MSG_WALLET_DETAIL_REMARK)),
|
||||
() -> Assertions.assertTrue(content.contains("MSG:" + ErrorCodes.APP_MSG_WALLET_CONTENT)),
|
||||
() -> Assertions.assertTrue(content.contains("福利钱包")),
|
||||
// 空备注不追加备注模板,避免渲染出空括号
|
||||
() -> Assertions.assertEquals(1, content.split("MSG:" + ErrorCodes.APP_MSG_WALLET_DETAIL_REMARK, -1).length - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证列宽兜底截断:空值与未超长原样返回,超长时以省略号结尾且长度恰好等于列宽。
|
||||
*/
|
||||
@Test
|
||||
void clampColumnShouldTruncateWithEllipsisAtBoundary() {
|
||||
Assertions.assertNull(ThirdAppMessageServiceImpl.clampColumn(null, AppMessageConstants.MESSAGE_CONTENT_MAX_LENGTH));
|
||||
Assertions.assertEquals("短文案", ThirdAppMessageServiceImpl.clampColumn("短文案", 2000));
|
||||
Assertions.assertEquals(2000, ThirdAppMessageServiceImpl.clampColumn("长".repeat(2000), 2000).length());
|
||||
String truncated = ThirdAppMessageServiceImpl.clampColumn("长".repeat(2001), 2000);
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertEquals(2000, truncated.length()),
|
||||
() -> Assertions.assertTrue(truncated.endsWith("…")));
|
||||
Assertions.assertEquals(128, ThirdAppMessageServiceImpl.clampColumn("题".repeat(129),
|
||||
AppMessageConstants.MESSAGE_TITLE_MAX_LENGTH).length());
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -58,9 +58,13 @@ import java.util.List;
|
||||
public class WalletActiveController {
|
||||
|
||||
private final WalletActiveService walletActiveService;
|
||||
/** 钱包开卡 Excel 导入服务,隔离默认和学生模板的校验及异步编排。 */
|
||||
/**
|
||||
* 钱包开卡 Excel 导入服务,隔离默认和学生模板的校验及异步编排。
|
||||
*/
|
||||
private final WalletActiveImportService walletActiveImportService;
|
||||
/** 钱包用户导出服务,负责开卡记录分片查询、中文二级表头和异步进度。 */
|
||||
/**
|
||||
* 钱包用户导出服务,负责开卡记录分片查询、中文二级表头和异步进度。
|
||||
*/
|
||||
private final WalletUserExportService walletUserExportService;
|
||||
private final MchManageService mchManageService;
|
||||
|
||||
@@ -103,7 +107,7 @@ public class WalletActiveController {
|
||||
UserInfo userInfo = remoteUserService.getInnerByUserId(user.getId()).getData();
|
||||
MchManageVo targetMch = mchManageService.getViewsCanteenMchByDeptId(userInfo.getSysUser().getDeptId());
|
||||
if (ObjectUtil.isNotEmpty(targetMch)) {
|
||||
dto.setDeptIds(List.of(targetMch.getDeptId()));
|
||||
dto.setMerchantId(targetMch.getId());
|
||||
} else {
|
||||
List<SysDept> deptIdList = remoteDeptService.getDescendantList(userInfo.getSysUser().getDeptId()).getData();
|
||||
if (ObjectUtil.isNotEmpty(deptIdList)) {
|
||||
@@ -234,7 +238,7 @@ public class WalletActiveController {
|
||||
* 使用中文二级表头同步分片导出开卡记录,保留旧 GET 地址供既有调用方兼容。
|
||||
*
|
||||
* @param response 当前 HTTP 下载响应
|
||||
* @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件
|
||||
* @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件
|
||||
*/
|
||||
@GetMapping("/export")
|
||||
@PreAuthorize("@pms.hasPermission('wallet_walletActive_export')")
|
||||
@@ -257,7 +261,7 @@ public class WalletActiveController {
|
||||
/**
|
||||
* 导入默认开卡模板,按用户姓名和手机号定位用户并异步覆盖饭卡卡号。
|
||||
*
|
||||
* @param excelVOList 默认开卡 Excel 行
|
||||
* @param excelVOList 默认开卡 Excel 行
|
||||
* @param bindingResult Excel 解析阶段的逐行错误
|
||||
* @return 校验失败时返回逐行错误;校验通过时返回异步任务初始进度
|
||||
*/
|
||||
@@ -271,7 +275,7 @@ public class WalletActiveController {
|
||||
/**
|
||||
* 导入学生开卡模板,按学校和学号定位学生并异步覆盖饭卡卡号。
|
||||
*
|
||||
* @param excelVOList 学生开卡 Excel 行
|
||||
* @param excelVOList 学生开卡 Excel 行
|
||||
* @param bindingResult Excel 解析阶段的逐行错误
|
||||
* @return 校验失败时返回逐行错误;校验通过时返回异步任务初始进度
|
||||
*/
|
||||
|
||||
+5
-2
@@ -32,20 +32,23 @@ public interface WalletActiveMapper extends PjcloudBaseMapper<WalletActiveEntity
|
||||
/**
|
||||
* 按当前操作者数据权限分页查询开卡记录。
|
||||
*
|
||||
* <p>查询应用卡号、激活状态、商户部门范围、用户、商户、勾选主键及排除用户等筛选条件,
|
||||
* <p>查询应用卡号、激活状态、商户部门范围、用户(单个 userId 或集合 userIds)、商户、勾选主键及排除用户等筛选条件,
|
||||
* 并按饭卡顺序、记录主键倒序返回稳定分页;未绑定及硬件激活条件沿用现有 1/0 业务语义。
|
||||
* 部门树条件已在服务层按用户表当前部门解析为用户主键,此处只按用户维度过滤,
|
||||
* {@code dto.deptIds} 仅作为数据权限圈定的商户部门快照范围。</p>
|
||||
*
|
||||
* @param page 页码、每页数量及是否统计总数的分页参数
|
||||
* @param dto 开卡页面或导出任务冻结后的筛选条件
|
||||
* @param dto 开卡页面或导出任务冻结后的筛选条件
|
||||
* @return 当前数据权限范围内的开卡记录分页,记录转换为 {@link WalletActiveVo}
|
||||
*/
|
||||
default Page<WalletActiveVo> scopePageList(Page page, WalletActiveDto dto) {
|
||||
LambdaQueryWrapper<WalletActiveEntity> wrapper = new LambdaQueryWrapper<WalletActiveEntity>()
|
||||
.like(ObjectUtil.isNotEmpty(dto.getCardNumber()), WalletActiveEntity::getCardNumber, dto.getCardNumber())
|
||||
.eq(ObjectUtil.isNotEmpty(dto.getMerchantId()), WalletActiveEntity::getMerchantId, dto.getMerchantId())
|
||||
.eq(ObjectUtil.isNotEmpty(dto.getIsActiveHardware()), WalletActiveEntity::getIsActiveHardware, dto.getIsActiveHardware())
|
||||
.eq(ObjectUtil.isNotEmpty(dto.getDeptId()), WalletActiveEntity::getDeptId, dto.getDeptId())
|
||||
// 单个用户主键条件;与 userIds 集合条件同时存在时取交集
|
||||
.eq(ObjectUtil.isNotEmpty(dto.getUserId()), WalletActiveEntity::getUserId, dto.getUserId())
|
||||
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), WalletActiveEntity::getUserId, dto.getUserIds())
|
||||
.notIn(ObjectUtil.isNotEmpty(dto.getExcludeUserIds()), WalletActiveEntity::getUserId, dto.getExcludeUserIds())
|
||||
.in(ObjectUtil.isNotEmpty(dto.getMerchantIdList()), WalletActiveEntity::getMerchantId, dto.getMerchantIdList())
|
||||
|
||||
Reference in new issue
Block a user