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())