Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-10-09 15:39:37 +08:00
commit 3ff7b06f58
27 files changed
+658 -259

No files matched your search

@@ -68,11 +68,6 @@ public class CanteenSpuUpdateStockReqDTO {
@NotNull(message = "库存变化数量不能为空")
private Integer incrCount;
/**
* 本次业务开始前的库存快照,仅幂等库存接口使用。
*/
private Integer beforeStock;
/**
* 每日菜谱用餐日;分池扣减时必填(可与同批其他行不同)。
*/
@@ -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;
/**
@@ -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);
}
@@ -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);
}
}
@@ -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);
@@ -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);
}
/**
@@ -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");
@@ -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;
@@ -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} &lt;= IFNULL(online_stock, 600)
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} &lt;= 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} &gt;= 0
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} &lt;= IFNULL(offline_stock, 600)
</if>
<if test="expected != null">
AND GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0), 0) = #{expected}
</if>
</update>
</mapper>
@@ -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;
}
}
@@ -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;
}
/**
* 构造当前库存快照。
*
@@ -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=被監護人
@@ -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) {
@@ -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;
}
@@ -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;
}
@@ -23,6 +23,9 @@ public class AppPushDeliveryVo implements Serializable {
/** 目标用户ID。 */
private Long recipientUserId;
/** 接收用户姓名,远程用户服务不可用或用户已删除时为空。 */
private String recipientName;
/** 投递状态。 */
private String status;
@@ -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);
}
@@ -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供应商只读状态。
*
@@ -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);
/**
* 返回供应商只读健康状态,不包含部署密钥。
*
@@ -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++) {
@@ -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) + "…";
}
/**
* 保存本轮所有实际供应商调用尝试。
*
@@ -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());
}
}
@@ -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 校验失败时返回逐行错误;校验通过时返回异步任务初始进度
*/
@@ -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())