diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
index 6ef408ff2..440cbc22c 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
@@ -68,11 +68,6 @@ public class CanteenSpuUpdateStockReqDTO {
@NotNull(message = "库存变化数量不能为空")
private Integer incrCount;
- /**
- * 本次业务开始前的库存快照,仅幂等库存接口使用。
- */
- private Integer beforeStock;
-
/**
* 每日菜谱用餐日;分池扣减时必填(可与同批其他行不同)。
*/
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java
index fe00ceef7..f900a2c01 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java
@@ -93,7 +93,7 @@ public class DailyMenuAppVO {
/**
* 当前可售库存。
- *
App/移动端:移动端可预订剩余;AD:总库存 − 移动端已预订。字段名保持 stock 兼容旧客户端。
+ *
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;
/**
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java
index 4815d0e2a..697aff68f 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java
@@ -71,16 +71,14 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper {
.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 updateWrapper = new LambdaUpdateWrapper()
- .setSql(sql)
- .eq(CanteenSpuEntity::getId, id)
- .eq(CanteenSpuEntity::getStock, expectedStock)
- .ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
- return update(null, updateWrapper);
- }
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java
index d8b411975..8049b9af8 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java
@@ -385,7 +385,7 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId) {
List 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);
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuStockSupport.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuStockSupport.java
index b5f9deefb..5588b7e89 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuStockSupport.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuStockSupport.java
@@ -30,7 +30,7 @@ import java.util.stream.Collectors;
*
* 剩余总库存 = max(0, 总库存 − 移动端已预订 − 消费机已销售)
* 移动端可预订剩余 = min(移动端上限 − 已预订, 剩余总库存)
- * AD stock = max(0, 总库存 − 移动端已预订)
+ * AD stock = 剩余总库存(与管理端 remainStock、线下扣减边界同口径)
*
* 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 一致)。
- *
- * 线下:标准为 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 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 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);
}
/**
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java
index cb3229c9e..f050ac6ef 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java
@@ -454,7 +454,7 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl 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= |增量| 条件保证。
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");
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
index 805b6983b..a05c6029f 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
@@ -133,7 +133,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl 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 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 item != null && (item.getMealDate() != null || item.getHourType() != null));
+ if (hasDailyRows) {
dto.setStockChannel("OFFLINE");
}
}
@@ -260,7 +253,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl 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 items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline,
- dto.getItems(), true);
+ dto.getItems());
Map 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 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 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 expandDailyMenuLinkedStockItems(Long merchantId, boolean offline,
- List source, boolean fillMissingBeforeStock) {
+ List source) {
if (CollUtil.isEmpty(source)) {
return List.of();
}
- Map 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 spuIds = source.stream().filter(Objects::nonNull).map(CanteenSpuUpdateStockReqDTO.Item::getId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map spuMap = spuIds.isEmpty() ? Map.of()
@@ -478,15 +446,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl
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)
-
- 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}
-
@@ -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)
-
- AND GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0), 0) = #{expected}
-
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImplTest.java b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImplTest.java
new file mode 100644
index 000000000..19c86c2d5
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImplTest.java
@@ -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 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;
+ }
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java
index d089ae1ec..95e8f7a54 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java
@@ -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;
+ }
+
/**
* 构造当前库存快照。
*
diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java
index 0fcb12690..13825af5d 100644
--- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java
+++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java
@@ -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";
diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties
index 6cab779de..c488c0af3 100644
--- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties
+++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties
@@ -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
diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties
index facbd50fa..76eee9fed 100644
--- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties
+++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties
@@ -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=被监护人
diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties
index aefc8f2d7..81ed6fa76 100644
--- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties
+++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties
@@ -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=被監護人
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
index d7e6ae4b6..1cec454c3 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
@@ -2972,7 +2972,6 @@ public class HardOrderServiceImpl extends ServiceImpl result = onlineCanteenSpuService.updateSkuStockIdempotent(stockDto);
@@ -5155,7 +5153,6 @@ public class HardOrderServiceImpl extends ServiceImpl result = remoteCanteenSpuService.updateSkuStockIdempotent(dto);
@@ -5183,7 +5180,6 @@ public class HardOrderServiceImpl extends ServiceImpl deliveryIds;
+}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryVo.java
index f8c34ca5a..e9c7e9780 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryVo.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/appmessage/AppPushDeliveryVo.java
@@ -23,6 +23,9 @@ public class AppPushDeliveryVo implements Serializable {
/** 目标用户ID。 */
private Long recipientUserId;
+ /** 接收用户姓名,远程用户服务不可用或用户已删除时为空。 */
+ private String recipientName;
+
/** 投递状态。 */
private String status;
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java
index bd2e04e6d..45a63c9c4 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/constant/appmessage/AppMessageConstants.java
@@ -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 RETRY_INTERVAL_MINUTES = List.of(1L, 5L, 15L, 60L, 180L, 360L, 720L, 720L);
}
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java
index dd24ba9f7..f9b589a03 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/appmessage/ThirdAppMessagePcController.java
@@ -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> 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 retryDeliveries(@Valid @RequestBody AppPushDeliveryRetryDto dto) {
+ requireMerchantAccess(dto.getMchId());
+ return R.ok(deliveryService.retryDeliveries(dto.getMchId(), dto.getDeliveryIds()));
+ }
+
/**
* 查询当前部署Push供应商只读状态。
*
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java
index 0bc601a08..85690c424 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/appmessage/ThirdPushDeliveryService.java
@@ -35,9 +35,10 @@ public interface ThirdPushDeliveryService {
* @param mchId 商户ID
* @param status 可选投递状态
* @param eventCode 可选事件编码
+ * @param recipientUserId 可选接收用户ID
* @return 商户投递记录分页
*/
- Page page(Page> page, Long mchId, String status, String eventCode);
+ Page page(Page> page, Long mchId, String status, String eventCode, Long recipientUserId);
/**
* 查询指定商户投递任务的供应商调用详情。
@@ -48,6 +49,15 @@ public interface ThirdPushDeliveryService {
*/
List listAttempts(Long deliveryId, Long mchId);
+ /**
+ * 把失败终态的投递记录重置回待投递,由既有扫描任务在数秒内重新推送。
+ *
+ * @param mchId 商户ID
+ * @param deliveryIds 待重新推送的投递记录ID
+ * @return 实际重置的记录数量,非失败终态或非本商户记录不计入
+ */
+ int retryDeliveries(Long mchId, List deliveryIds);
+
/**
* 返回供应商只读健康状态,不包含部署密钥。
*
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java
index 6e401b094..dccc14ea9 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageServiceImpl.java
@@ -287,6 +287,9 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService {
/**
* 构造本人或关系消息的公共不可变业务快照。
*
+ * 标题与正文写入前按数据库列宽兜底截断:模板升级、钱包名或备注极长等任何组合都不允许
+ * 触发列超长异常,否则整条消息事务(含Push投递任务)会一起回滚。
+ *
* @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 {
/**
* 渲染钱包多明细正文,金额和结果余额统一按分转换为两位元展示。
*
+ * 明细数量超过 {@link AppMessageConstants#WALLET_DETAIL_RENDER_LIMIT} 时降级为“共N笔”摘要:
+ * 批量补录等大事件逐条拼接的正文会超出 third_app_message.content 列宽,导致整条消息连同Push投递一起失败;
+ * 完整明细始终保留在 payload_json 中,App 详情页与按语言重渲染均不丢失信息。方法包内可见以便单测直接验证渲染边界。
+ *
* @param event 钱包事件
* @param details 同一业务操作的钱包明细
- * @return 当前语言下的模板正文
+ * @return 当前语言下的逐条模板正文或超阈值摘要正文
*/
- private String walletContent(AppMessageEventEnum event, List details) {
+ String walletContent(AppMessageEventEnum event, List 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++) {
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java
index 2cd47554a..628defba2 100644
--- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdPushDeliveryServiceImpl.java
@@ -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 page(Page> page, Long mchId, String status, String eventCode) {
+ public Page page(Page> page, Long mchId, String status, String eventCode, Long recipientUserId) {
Page entityPage = deliveryMapper.selectPage(
new Page<>(page.getCurrent(), page.getSize()),
Wrappers.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 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 records) {
+ List userIdList = records.stream().map(AppPushDeliveryVo::getRecipientUserId)
+ .filter(ObjectUtil::isNotEmpty).distinct().toList();
+ if (ObjectUtil.isEmpty(userIdList)) {
+ return;
+ }
+ try {
+ R> result = remoteUserService.getInnerUserById(userIdList);
+ List userList = result == null || !result.isOk() ? null : result.getData();
+ if (ObjectUtil.isEmpty(userList)) {
+ return;
+ }
+ Map 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 deliveryIds) {
+ if (ObjectUtil.isEmpty(deliveryIds)) {
+ return 0;
+ }
+ return deliveryMapper.update(null, Wrappers.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) + "…";
+ }
+
/**
* 保存本轮所有实际供应商调用尝试。
*
diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageContentTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageContentTest.java
new file mode 100644
index 000000000..f8de569f8
--- /dev/null
+++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appmessage/ThirdAppMessageContentTest.java
@@ -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 details = new ArrayList<>();
+ for (int index = 0; index <= AppMessageConstants.WALLET_DETAIL_RENDER_LIMIT; index++) {
+ details.add(detail("福利钱包", -800, 999200, "补录消费扣款"));
+ }
+ try (MockedStatic 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 details = List.of(
+ detail("福利钱包", 10000000, 10000000, "系统批量充值"),
+ detail("福利钱包", -800, 9999200, null));
+ try (MockedStatic 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());
+ }
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java
index 38c3ce2d4..92d18c515 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java
@@ -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 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 校验失败时返回逐行错误;校验通过时返回异步任务初始进度
*/
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java
index 368a2e4eb..9c1e4fb29 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java
@@ -32,20 +32,23 @@ public interface WalletActiveMapper extends PjcloudBaseMapper查询应用卡号、激活状态、商户部门范围、用户、商户、勾选主键及排除用户等筛选条件,
+ * 查询应用卡号、激活状态、商户部门范围、用户(单个 userId 或集合 userIds)、商户、勾选主键及排除用户等筛选条件,
* 并按饭卡顺序、记录主键倒序返回稳定分页;未绑定及硬件激活条件沿用现有 1/0 业务语义。
* 部门树条件已在服务层按用户表当前部门解析为用户主键,此处只按用户维度过滤,
* {@code dto.deptIds} 仅作为数据权限圈定的商户部门快照范围。
*
* @param page 页码、每页数量及是否统计总数的分页参数
- * @param dto 开卡页面或导出任务冻结后的筛选条件
+ * @param dto 开卡页面或导出任务冻结后的筛选条件
* @return 当前数据权限范围内的开卡记录分页,记录转换为 {@link WalletActiveVo}
*/
default Page scopePageList(Page page, WalletActiveDto dto) {
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
.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())