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-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/CanteenDailyMenuStockSupport.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuStockSupport.java index b5f9deefb..63e7eb8d9 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 @@ -115,29 +115,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)) { 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-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