feat:库存bug
This commit is contained in:
1 parent
80b4d1e9cc
commit
b4109f55d2
11 files changed
+331
-232
No files matched your search
-5
@@ -68,11 +68,6 @@ public class CanteenSpuUpdateStockReqDTO {
|
||||
@NotNull(message = "库存变化数量不能为空")
|
||||
private Integer incrCount;
|
||||
|
||||
/**
|
||||
* 本次业务开始前的库存快照,仅幂等库存接口使用。
|
||||
*/
|
||||
private Integer beforeStock;
|
||||
|
||||
/**
|
||||
* 每日菜谱用餐日;分池扣减时必填(可与同批其他行不同)。
|
||||
*/
|
||||
|
||||
+4
-6
@@ -71,16 +71,14 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper<CanteenDailyMe
|
||||
@Param("categoryId") Long categoryId, DataScope dataScope);
|
||||
|
||||
/**
|
||||
* 条件更新线上净销量(delta 为剩余侧增量:下单负数);expected 为操作前剩余。
|
||||
* 条件更新线上净销量(delta 为剩余侧增量:下单负数);扣减自带「不扣负、不超配额」边界。
|
||||
*/
|
||||
int updateOnlineStockDelta(@Param("merchantId") Long merchantId, @Param("mealDate") java.time.LocalDate mealDate,
|
||||
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta,
|
||||
@Param("expected") Integer expected);
|
||||
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta);
|
||||
|
||||
/**
|
||||
* 条件更新线下净销量(delta 为剩余侧增量:下单负数);expected 为操作前剩余。
|
||||
* 条件更新线下净销量(delta 为剩余侧增量:下单负数);扣减自带「不扣负、不超配额」边界。
|
||||
*/
|
||||
int updateOfflineStockDelta(@Param("merchantId") Long merchantId, @Param("mealDate") java.time.LocalDate mealDate,
|
||||
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta,
|
||||
@Param("expected") Integer expected);
|
||||
@Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta);
|
||||
}
|
||||
-27
@@ -34,31 +34,4 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper<CanteenSpuEntity> {
|
||||
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
|
||||
return update(null, updateWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在数据库当前库存仍等于业务快照时更新库存和销量,避免不同业务号并发操作同一 SPU 时丢失更新。
|
||||
*
|
||||
* @param id 商品 SPU 编号
|
||||
* @param incrCount 库存增量,负数扣减、正数恢复
|
||||
* @param expectedStock 本次业务读取到的操作前库存
|
||||
* @return 影响行数;返回 0 表示库存已变化或扣减后会小于 0
|
||||
*/
|
||||
default int updateStockIfMatch(Long id, Integer incrCount, Integer expectedStock) {
|
||||
if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE
|
||||
|| expectedStock == null || expectedStock < 0) {
|
||||
return 0;
|
||||
}
|
||||
String sql;
|
||||
if (incrCount > 0) {
|
||||
sql = " stock = stock + " + incrCount + ", sales_count = sales_count - " + incrCount;
|
||||
} else {
|
||||
sql = " stock = stock - " + Math.abs(incrCount) + ", sales_count = sales_count + " + Math.abs(incrCount);
|
||||
}
|
||||
LambdaUpdateWrapper<CanteenSpuEntity> updateWrapper = new LambdaUpdateWrapper<CanteenSpuEntity>()
|
||||
.setSql(sql)
|
||||
.eq(CanteenSpuEntity::getId, id)
|
||||
.eq(CanteenSpuEntity::getStock, expectedStock)
|
||||
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
|
||||
return update(null, updateWrapper);
|
||||
}
|
||||
}
|
||||
-23
@@ -115,29 +115,6 @@ public class CanteenDailyMenuStockSupport {
|
||||
return offline ? adStockOf(menu) : onlineRemain(menu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将调用方 beforeStock 归一到通道 CAS 口径(与 {@link #channelRemain} / SQL expected 一致)。
|
||||
* <p>
|
||||
* 线下:标准为 AD stock(总库存−线上已订)。兼容 Hard 库存校验走 App 填充时误传的
|
||||
* 「剩余总库存」(总库存−线上已订−线下已售):该值可接受,但 CAS 仍用 AD stock。
|
||||
*
|
||||
* @return 归一后的 expected;无法识别则 null(应按库存已变化处理)
|
||||
*/
|
||||
public static Integer normalizeChannelBeforeStock(CanteenDailyMenuEntity menu, boolean offline,
|
||||
Integer beforeStock) {
|
||||
if (menu == null || beforeStock == null || beforeStock < 0) {
|
||||
return null;
|
||||
}
|
||||
int channel = channelRemain(menu, offline);
|
||||
if (beforeStock == channel) {
|
||||
return channel;
|
||||
}
|
||||
if (offline && beforeStock == sharedRemain(menu)) {
|
||||
return channel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** App:stock = 线上剩余 */
|
||||
public void fillAppOnlineRemain(List<DailyMenuAppVO> rows, Long merchantId, LocalDate mealDate) {
|
||||
if (CollUtil.isEmpty(rows)) {
|
||||
|
||||
+12
-27
@@ -454,7 +454,7 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一业务号的扣减和补偿在食堂服务内串行执行;补偿通过当前库存与原库存快照判断扣减是否已提交。
|
||||
* 同一业务号的扣减和补偿在食堂服务内串行执行;扣减是否已提交由 Redis 状态机判断。
|
||||
*/
|
||||
@Override
|
||||
@Lock4j(keys = {"#dto.bizId"})
|
||||
@@ -464,8 +464,7 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
|
||||
boolean deductOperation = "DEDUCT".equals(operation);
|
||||
boolean rollbackOperation = "ROLLBACK".equals(operation);
|
||||
if (StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems())
|
||||
|| (!deductOperation && !rollbackOperation)
|
||||
|| dto.getItems().stream().anyMatch(item -> item.getBeforeStock() == null)) {
|
||||
|| (!deductOperation && !rollbackOperation)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_BIZ_PARAM_BAD));
|
||||
}
|
||||
String stateKey = ORDER_STOCK_BIZ_KEY_PREFIX + dto.getBizId();
|
||||
@@ -476,39 +475,25 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
|
||||
if (rollbackOperation && "ROLLED_BACK".equals(state)) {
|
||||
return R.ok();
|
||||
}
|
||||
// 充足性以服务端现值判断,不再比对调用方快照:不同业务号交替下单/退款时快照必然失配,
|
||||
// 会把正常操作误判为“库存已变化”;防超卖由 updateStock 同条 UPDATE 的 stock >= |增量| 条件保证。
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
|
||||
CanteenOnlineSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
if (spu == null) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.COMMON_PRODUCT_NOT_FOUND));
|
||||
}
|
||||
int expectedStock = item.getBeforeStock();
|
||||
int targetStock = Math.addExact(expectedStock, item.getIncrCount());
|
||||
if (deductOperation) {
|
||||
if (spu.getStock() == targetStock) {
|
||||
continue;
|
||||
}
|
||||
if (spu.getStock() != expectedStock) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED, spu.getName()));
|
||||
}
|
||||
} else {
|
||||
if (spu.getStock() == expectedStock) {
|
||||
continue;
|
||||
}
|
||||
if (spu.getStock() != targetStock) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL));
|
||||
}
|
||||
if (deductOperation && Math.addExact(spu.getStock(), item.getIncrCount()) < 0) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_INSUFFICIENT));
|
||||
}
|
||||
}
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
|
||||
CanteenOnlineSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
int expectedStock = item.getBeforeStock();
|
||||
int targetStock = Math.addExact(expectedStock, item.getIncrCount());
|
||||
int delta = deductOperation ? item.getIncrCount() : -item.getIncrCount();
|
||||
boolean needUpdate = deductOperation
|
||||
? spu.getStock() == expectedStock
|
||||
: spu.getStock() == targetStock;
|
||||
if (needUpdate && baseMapper.updateStock(item.getId(), delta) == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL);
|
||||
// 只有确认 DEDUCT 已提交的 ROLLBACK 才反向回补;状态缺失的 ROLLBACK 说明扣减从未生效,
|
||||
// 只写终态阻断延迟到达的 DEDUCT,不能按库存值猜测并恢复(与基础池协议语义一致)。
|
||||
if (deductOperation || "DEDUCTED".equals(state)) {
|
||||
if (baseMapper.updateStock(item.getId(), delta) == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL);
|
||||
}
|
||||
}
|
||||
}
|
||||
stringRedisTemplate.opsForValue().set(stateKey, deductOperation ? "DEDUCTED" : "ROLLED_BACK");
|
||||
|
||||
+36
-78
@@ -133,7 +133,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
if (dto == null || StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems())
|
||||
|| (!applyOperation && !rollbackOperation)
|
||||
|| dto.getItems().stream().anyMatch(item -> item == null || item.getId() == null
|
||||
|| item.getIncrCount() == null || item.getIncrCount() == 0 || item.getBeforeStock() == null)) {
|
||||
|| item.getIncrCount() == null || item.getIncrCount() == 0)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_BIZ_PARAM_BAD));
|
||||
}
|
||||
normalizeDailyStockChannel(dto);
|
||||
@@ -160,24 +160,13 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
if (spu == null || spu.getStock() == null) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SPU_BAD));
|
||||
}
|
||||
int expectedStock = item.getBeforeStock();
|
||||
if (expectedStock < 0) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SNAPSHOT_BAD));
|
||||
}
|
||||
int targetStock = Math.addExact(expectedStock, item.getIncrCount());
|
||||
if (targetStock < 0) {
|
||||
// 充足性以服务端现值判断,不再比对调用方快照:不同业务号交替扣减/回补时快照必然失配,
|
||||
// 曾把正常下单确定性误拒为“库存已变化”;防超卖由更新 SQL 的下界条件保证。
|
||||
if (Math.addExact(spu.getStock(), item.getIncrCount()) < 0) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
|
||||
StrUtil.blankToDefault(spu.getName(), "商品")));
|
||||
}
|
||||
if (applyOperation && item.getIncrCount() < 0) {
|
||||
// 负增量扣减必须以快照做前置校验:不得把“库存恰好等于目标值”当成本业务已经执行,
|
||||
// 该值可能由另一个业务号并发扣减得到,且并发扣减基于同一剩余量判断会超卖。
|
||||
// 正增量恢复是可交换操作,不做快照校验,见下方更新循环注释。
|
||||
if (spu.getStock() != expectedStock) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
|
||||
StrUtil.blankToDefault(spu.getName(), "商品")));
|
||||
}
|
||||
} else if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
int rollbackDelta = -item.getIncrCount();
|
||||
if (rollbackDelta < 0 && spu.getStock() < Math.abs(rollbackDelta)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
|
||||
@@ -187,10 +176,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
}
|
||||
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
|
||||
int expectedStock = item.getBeforeStock();
|
||||
int delta = applyOperation ? item.getIncrCount() : -item.getIncrCount();
|
||||
if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
// 补偿不能再要求库存等于本业务 APPLY 后的快照:期间可能已有其他订单合法改变同一 SPU。
|
||||
// 补偿是可交换操作:期间其他订单可能已合法改变同一 SPU 的库存,
|
||||
// 同一业务号由锁和 APPLIED 状态保证只反向一次,数据库条件更新只负责阻止库存扣成负数。
|
||||
if (baseMapper.updateStock(item.getId(), delta) == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL);
|
||||
@@ -199,15 +187,11 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
}
|
||||
// 状态缺失的 ROLLBACK 说明没有可确认的成功 APPLY,只写入终态阻断延迟请求,不能按库存值猜测并恢复。
|
||||
if (applyOperation) {
|
||||
// 正增量恢复库存是可交换操作:并发下单或退款改变的库存不影响本业务恢复量的正确性,
|
||||
// 直接原子累加即可,幂等仍由 bizId 分布式锁和 Redis 状态保证,
|
||||
// 避免退款恢复被其他业务号的并发库存变更误判为冲突(“库存已变化”)。
|
||||
// 负增量扣减保留快照 CAS 条件更新,防止不同业务号基于同一剩余量重复判断导致超卖。
|
||||
int updated = delta > 0 ? baseMapper.updateStock(item.getId(), delta)
|
||||
: baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock);
|
||||
if (updated == 0) {
|
||||
throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
|
||||
: ErrorCodes.CANTEEN_STOCK_CHANGED, "");
|
||||
// 库存变更统一为无条件原子累加:正负增量都是可交换操作,幂等由 bizId 分布式锁和 Redis 状态保证,
|
||||
// 防超卖由 updateStock 同条 UPDATE 的 stock >= |增量| 条件保证(返回 0 行即库存不足)。
|
||||
// 不再使用调用方快照做 CAS:不同业务号交替扣减/回补时快照必然失配,曾把正常下单误判为“库存已变化”。
|
||||
if (baseMapper.updateStock(item.getId(), delta) == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,9 +206,18 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
return "ONLINE".equals(ch) || "OFFLINE".equals(ch);
|
||||
}
|
||||
|
||||
/** 空通道默认 OFFLINE,兼容未升级的安卓消费机。 */
|
||||
/**
|
||||
* 空通道且行携带餐段信息时默认 OFFLINE,兼容未升级的安卓消费机(其请求必带 mealDate/hourType)。
|
||||
* 不带餐段的空通道请求保持原样走基础 SPU 协议:Hardware 的基础池扣减/退款不带 stockChannel 与餐段,
|
||||
* 无条件默认 OFFLINE 会把它们错误路由进分池路径并被餐段校验拒绝。
|
||||
*/
|
||||
private static void normalizeDailyStockChannel(CanteenSpuUpdateStockReqDTO dto) {
|
||||
if (dto != null && StrUtil.isBlank(dto.getStockChannel())) {
|
||||
if (dto == null || StrUtil.isNotBlank(dto.getStockChannel()) || CollUtil.isEmpty(dto.getItems())) {
|
||||
return;
|
||||
}
|
||||
boolean hasDailyRows = dto.getItems().stream()
|
||||
.anyMatch(item -> item != null && (item.getMealDate() != null || item.getHourType() != null));
|
||||
if (hasDailyRows) {
|
||||
dto.setStockChannel("OFFLINE");
|
||||
}
|
||||
}
|
||||
@@ -260,7 +253,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
}
|
||||
boolean offline = !"ONLINE".equals(StrUtil.trim(dto.getStockChannel()));
|
||||
List<CanteenSpuUpdateStockReqDTO.Item> items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline,
|
||||
dto.getItems(), false);
|
||||
dto.getItems());
|
||||
if (CollUtil.isEmpty(items)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_ITEMS_EMPTY));
|
||||
}
|
||||
@@ -289,9 +282,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
|
||||
int updated = offline
|
||||
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), item.getIncrCount(), null)
|
||||
item.getHourType(), item.getId(), item.getIncrCount())
|
||||
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), item.getIncrCount(), null);
|
||||
item.getHourType(), item.getId(), item.getIncrCount());
|
||||
if (updated == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_INSUFFICIENT);
|
||||
}
|
||||
@@ -317,7 +310,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
}
|
||||
// 套餐行展开为组成单品同日同餐段销量联动;ROLLBACK 对同一 items 再展开一次,保证对称回补
|
||||
List<CanteenSpuUpdateStockReqDTO.Item> items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline,
|
||||
dto.getItems(), true);
|
||||
dto.getItems());
|
||||
Map<String, Integer> remainCursor = new HashMap<>();
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
|
||||
CanteenDailyMenuEntity menu = findDailyMenuRow(dto.getMerchantId(), item.getMealDate(), item.getHourType(),
|
||||
@@ -328,32 +321,15 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
String key = dailyStockItemKey(item);
|
||||
int current = remainCursor.computeIfAbsent(key,
|
||||
k -> CanteenDailyMenuStockSupport.channelRemain(menu, offline));
|
||||
int expectedStock = item.getBeforeStock();
|
||||
if (expectedStock < 0) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SNAPSHOT_BAD));
|
||||
}
|
||||
// 线下:兼容误传「剩余总库存」快照,归一到 AD stock 再与 current / SQL expected 对齐
|
||||
Integer normalizedExpected = CanteenDailyMenuStockSupport.normalizeChannelBeforeStock(menu, offline,
|
||||
expectedStock);
|
||||
if (applyOperation && item.getIncrCount() < 0 && normalizedExpected == null) {
|
||||
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
|
||||
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
|
||||
}
|
||||
int casExpected = normalizedExpected != null ? normalizedExpected : expectedStock;
|
||||
item.setBeforeStock(casExpected);
|
||||
// 可售不足:线下用剩余总库存判断(真正可卖边界);线上仍用通道剩余
|
||||
// 可售不足:线下用剩余总库存判断(真正可卖边界);线上仍用通道剩余。
|
||||
// 不再比对调用方快照:不同业务号交替扣减/回补时快照必然失配,曾把消费机正常下单
|
||||
// 确定性误拒为“库存已变化”;防超卖由 delta SQL 的“不超配额、不扣负”边界保证。
|
||||
int sellable = offline ? CanteenDailyMenuStockSupport.sharedRemain(menu) : current;
|
||||
if (item.getIncrCount() < 0 && Math.abs(item.getIncrCount()) > sellable) {
|
||||
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
|
||||
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
|
||||
}
|
||||
if (applyOperation && item.getIncrCount() < 0 && current != casExpected) {
|
||||
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
|
||||
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
|
||||
}
|
||||
if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
int rollbackDelta = -item.getIncrCount();
|
||||
if (rollbackDelta < 0 && current < Math.abs(rollbackDelta)) {
|
||||
@@ -372,24 +348,23 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
if (rollbackOperation && "APPLIED".equals(state)) {
|
||||
updated = offline
|
||||
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), delta, null)
|
||||
item.getHourType(), item.getId(), delta)
|
||||
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), delta, null);
|
||||
item.getHourType(), item.getId(), delta);
|
||||
if (updated == 0) {
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (applyOperation) {
|
||||
Integer expected = delta > 0 ? null : item.getBeforeStock();
|
||||
// delta SQL 自带“不超配额、不扣负”边界,不再携带快照 CAS 条件
|
||||
updated = offline
|
||||
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), delta, expected)
|
||||
item.getHourType(), item.getId(), delta)
|
||||
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
|
||||
item.getHourType(), item.getId(), delta, expected);
|
||||
item.getHourType(), item.getId(), delta);
|
||||
if (updated == 0) {
|
||||
throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
|
||||
: ErrorCodes.CANTEEN_STOCK_CHANGED, "");
|
||||
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,17 +379,10 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
* 单品售出会通过展示/校验侧的「可组成套餐数」压低套餐可售,此处不反向改写套餐 sold,避免与订单销量口径打架。
|
||||
*/
|
||||
private List<CanteenSpuUpdateStockReqDTO.Item> expandDailyMenuLinkedStockItems(Long merchantId, boolean offline,
|
||||
List<CanteenSpuUpdateStockReqDTO.Item> source, boolean fillMissingBeforeStock) {
|
||||
List<CanteenSpuUpdateStockReqDTO.Item> source) {
|
||||
if (CollUtil.isEmpty(source)) {
|
||||
return List.of();
|
||||
}
|
||||
Map<String, Integer> callerBefore = new HashMap<>();
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : source) {
|
||||
if (item != null && item.getId() != null && item.getMealDate() != null && item.getHourType() != null
|
||||
&& item.getBeforeStock() != null) {
|
||||
callerBefore.put(dailyStockItemKey(item), item.getBeforeStock());
|
||||
}
|
||||
}
|
||||
Set<Long> spuIds = source.stream().filter(Objects::nonNull).map(CanteenSpuUpdateStockReqDTO.Item::getId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<Long, CanteenSpuEntity> spuMap = spuIds.isEmpty() ? Map.of()
|
||||
@@ -478,15 +446,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
if (item.getIncrCount() == null || item.getIncrCount() == 0) {
|
||||
continue;
|
||||
}
|
||||
Integer before = callerBefore.get(dailyStockItemKey(item));
|
||||
if (before == null && fillMissingBeforeStock) {
|
||||
CanteenDailyMenuEntity menu = findDailyMenuRow(merchantId, item.getMealDate(), item.getHourType(),
|
||||
item.getId());
|
||||
before = menu == null ? 0 : CanteenDailyMenuStockSupport.channelRemain(menu, offline);
|
||||
}
|
||||
if (before != null) {
|
||||
item.setBeforeStock(before);
|
||||
}
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
@@ -496,7 +455,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
CanteenSpuUpdateStockReqDTO.Item copy = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
copy.setId(src.getId());
|
||||
copy.setIncrCount(src.getIncrCount());
|
||||
copy.setBeforeStock(src.getBeforeStock());
|
||||
copy.setMealDate(src.getMealDate());
|
||||
copy.setHourType(src.getHourType());
|
||||
return copy;
|
||||
|
||||
+3
-11
@@ -347,16 +347,17 @@
|
||||
<!--
|
||||
delta 与旧接口一致:下单扣减传负数、售后回传正数。
|
||||
实际改写销量:sold = sold - delta(扣减时 sold 增加)。
|
||||
expected 为操作前「剩余」快照(配额 - 销量)。
|
||||
正增量恢复不带任何边界条件:SET 已把销量钳制到 0 为止(菜谱重导或按订单重算销量后
|
||||
sold 可能小于恢复量,退款/补偿不能被统计口径阻断),配额为 0 等脏数据同样不得阻断;
|
||||
负增量扣减保留原「不扣成负销量、不超配额」边界,语义不变。
|
||||
不再携带 beforeStock 快照 CAS 条件:不同业务号交替扣减/回补时快照必然失配,
|
||||
曾把消费机正常下单确定性误拒为“库存已变化”;防超卖由上述边界条件保证。
|
||||
-->
|
||||
<!--
|
||||
共享总库存:offline_stock=总库存,online_stock=移动端上限。
|
||||
delta 为库存协议增量(扣减为负):sold = sold - delta。
|
||||
移动端剩余 = LEAST(上限−已订, 总库存−已订−已售);
|
||||
AD stock / expected = 总库存−已订;扣减边界仍为 已订+已售 ≤ 总库存。
|
||||
扣减边界为 已订+已售 ≤ 总库存。
|
||||
-->
|
||||
<update id="updateOnlineStockDelta">
|
||||
UPDATE canteen_daily_menu
|
||||
@@ -371,12 +372,6 @@
|
||||
AND IFNULL(online_sold, 0) - #{delta} <= IFNULL(online_stock, 600)
|
||||
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600)
|
||||
</if>
|
||||
<if test="expected != null">
|
||||
AND LEAST(
|
||||
GREATEST(IFNULL(online_stock, 600) - IFNULL(online_sold, 0), 0),
|
||||
GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0) - IFNULL(offline_sold, 0), 0)
|
||||
) = #{expected}
|
||||
</if>
|
||||
</update>
|
||||
|
||||
<update id="updateOfflineStockDelta">
|
||||
@@ -391,8 +386,5 @@
|
||||
AND IFNULL(offline_sold, 0) - #{delta} >= 0
|
||||
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600)
|
||||
</if>
|
||||
<if test="expected != null">
|
||||
AND GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0), 0) = #{expected}
|
||||
</if>
|
||||
</update>
|
||||
</mapper>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.bsd.pjcloud.canteen.service.impl;
|
||||
|
||||
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
|
||||
import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenCategoryMapper;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineCategoryMapper;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineComboItemMapper;
|
||||
import com.bsd.pjcloud.canteen.mapper.CanteenOnlineSpuMapper;
|
||||
import com.bsd.pjcloud.canteen.service.CanteenOnlineSpuChoiceGroupService;
|
||||
import com.bsd.pjcloud.canteen.service.CanteenOnlineSpuService;
|
||||
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.core.util.SpringContextHolder;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 在线菜品旧协议(canteen_online_spu)DEDUCT/ROLLBACK 幂等状态机测试。
|
||||
* 协议已不再携带 beforeStock 快照做 CAS:充足性以服务端现值判断,防超卖由
|
||||
* updateStock 同条 UPDATE 的 stock >= |增量| 条件保证,不同业务号交替下单/退款不得误报“库存已变化”。
|
||||
*/
|
||||
class CanteenOnlineSpuServiceImplTest {
|
||||
|
||||
/** 在线菜品 Mapper,用于验证库存增量是否真实执行。 */
|
||||
private CanteenOnlineSpuMapper onlineSpuMapper;
|
||||
|
||||
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
|
||||
private ValueOperations<String, String> valueOperations;
|
||||
|
||||
/** 被测在线菜品服务。 */
|
||||
private CanteenOnlineSpuService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
onlineSpuMapper = mock(CanteenOnlineSpuMapper.class);
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
valueOperations = mock(ValueOperations.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
service = new CanteenOnlineSpuServiceImpl(redisTemplate, mock(CanteenCategoryMapper.class),
|
||||
mock(CanteenOnlineCategoryMapper.class), mock(CanteenDailyMenuMapper.class), onlineSpuMapper,
|
||||
mock(CanteenOnlineSpuChoiceGroupService.class), mock(CanteenOnlineComboItemMapper.class),
|
||||
mock(CanteenMerchantScopeHelper.class));
|
||||
}
|
||||
|
||||
/** 撤销最小 MessageSource 注入,避免静态 SpringContextHolder 影响后续测试类。 */
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
ReflectionTestUtils.setField(SpringContextHolder.class, "beanFactory", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* DEDUCT 不再比对快照:其他业务号已改变当前库存(交替下单/退款场景)时,
|
||||
* 只要服务端现值仍够扣就必须放行,不得误报“库存已变化”。
|
||||
*/
|
||||
@Test
|
||||
void deductShouldIgnoreConcurrentStockChange() {
|
||||
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn(null);
|
||||
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(8));
|
||||
when(onlineSpuMapper.updateStock(1L, -2)).thenReturn(1);
|
||||
|
||||
assertTrue(service.updateOnlineSpuSkuStockIdempotent(stockRequest("DEDUCT", -2)).isOk());
|
||||
|
||||
verify(onlineSpuMapper).updateStock(1L, -2);
|
||||
verify(valueOperations).set("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9", "DEDUCTED");
|
||||
}
|
||||
|
||||
/**
|
||||
* DEDUCT 防超卖兜底:服务端现值不足以覆盖扣减量时仍必须拒绝。
|
||||
*/
|
||||
@Test
|
||||
void deductShouldRejectInsufficientStock() {
|
||||
registerMinimalMessageSource();
|
||||
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn(null);
|
||||
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(1));
|
||||
|
||||
R result = service.updateOnlineSpuSkuStockIdempotent(stockRequest("DEDUCT", -2));
|
||||
|
||||
assertFalse(result.isOk());
|
||||
verify(onlineSpuMapper, never()).updateStock(anyLong(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态缺失的 ROLLBACK 说明 DEDUCT 从未生效:只写终态阻断延迟到达的 DEDUCT,
|
||||
* 不得凭空恢复库存。
|
||||
*/
|
||||
@Test
|
||||
void rollbackWithoutDeductShouldOnlyMarkTerminalState() {
|
||||
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn(null);
|
||||
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(8));
|
||||
|
||||
assertTrue(service.updateOnlineSpuSkuStockIdempotent(stockRequest("ROLLBACK", -2)).isOk());
|
||||
|
||||
verify(onlineSpuMapper, never()).updateStock(anyLong(), anyInt());
|
||||
verify(valueOperations).set("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9", "ROLLED_BACK");
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 DEDUCT 已提交的 ROLLBACK 必须反向回补库存并写入终态。
|
||||
*/
|
||||
@Test
|
||||
void rollbackAfterDeductShouldRestoreStock() {
|
||||
when(valueOperations.get("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9")).thenReturn("DEDUCTED");
|
||||
when(onlineSpuMapper.selectById(1L)).thenReturn(onlineSpu(8));
|
||||
when(onlineSpuMapper.updateStock(1L, 2)).thenReturn(1);
|
||||
|
||||
assertTrue(service.updateOnlineSpuSkuStockIdempotent(stockRequest("ROLLBACK", -2)).isOk());
|
||||
|
||||
verify(onlineSpuMapper).updateStock(1L, 2);
|
||||
verify(valueOperations).set("canteen:online-spu:order-stock:HARD_RESERVE_ORDER:9", "ROLLED_BACK");
|
||||
}
|
||||
|
||||
/**
|
||||
* 为纯 Mockito 环境注入最小 MessageSource,使 R.failed 分支的 MsgUtils 文案解析可用;
|
||||
* 文案内容不影响断言,直接回显错误码。
|
||||
*/
|
||||
private void registerMinimalMessageSource() {
|
||||
MessageSource messageSource = mock(MessageSource.class);
|
||||
when(messageSource.getMessage(anyString(), any(), any(Locale.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0, String.class));
|
||||
ConfigurableListableBeanFactory factory = mock(ConfigurableListableBeanFactory.class);
|
||||
when(factory.getBean("pjcloudMessageSource")).thenReturn(messageSource);
|
||||
new SpringContextHolder().postProcessBeanFactory(factory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造单个在线菜品的幂等库存请求。
|
||||
*
|
||||
* @param operation DEDUCT 或 ROLLBACK
|
||||
* @param increment DEDUCT 使用的库存增量(扣减为负数)
|
||||
* @return 可直接调用库存服务的请求
|
||||
*/
|
||||
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment) {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(1L);
|
||||
item.setIncrCount(increment);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
dto.setBizId("HARD_RESERVE_ORDER:9");
|
||||
dto.setOperation(operation);
|
||||
dto.setItems(List.of(item));
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造指定当前库存的在线菜品实体。
|
||||
*
|
||||
* @param stock 当前在线菜品库存
|
||||
* @return 仅包含本用例所需字段的实体
|
||||
*/
|
||||
private CanteenOnlineSpuEntity onlineSpu(int stock) {
|
||||
CanteenOnlineSpuEntity entity = new CanteenOnlineSpuEntity();
|
||||
entity.setId(1L);
|
||||
entity.setStock(stock);
|
||||
entity.setName("测试菜品");
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+90
-37
@@ -37,6 +37,8 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 每日菜谱基础 SPU 库存幂等状态机测试,重点覆盖退款任务本地回滚后的再次执行。
|
||||
* 库存协议已不再携带 beforeStock 快照做 CAS:充足性以服务端现值判断,
|
||||
* 防超卖由更新 SQL 的下界条件保证,不同业务号交替扣减/回补不得被误判为“库存已变化”。
|
||||
*/
|
||||
class CanteenSpuServiceImplTest {
|
||||
|
||||
@@ -46,7 +48,7 @@ class CanteenSpuServiceImplTest {
|
||||
/** 基础 SPU Mapper,用于验证库存增量是否真实执行。 */
|
||||
private CanteenSpuMapper spuMapper;
|
||||
|
||||
/** 每日菜谱 Mapper,用于验证分池正增量恢复的剩余匹配条件。 */
|
||||
/** 每日菜谱 Mapper,用于验证分池增量是否真实执行。 */
|
||||
private CanteenDailyMenuMapper dailyMenuMapper;
|
||||
|
||||
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
|
||||
@@ -79,11 +81,10 @@ class CanteenSpuServiceImplTest {
|
||||
|
||||
/**
|
||||
* 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。
|
||||
* 正增量恢复走无条件原子累加,不再与快照做 CAS 对比。
|
||||
*/
|
||||
@Test
|
||||
void compensatedRefundInventoryShouldAllowRetry() {
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2);
|
||||
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
|
||||
.thenReturn("COMPENSATED");
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(10));
|
||||
@@ -97,12 +98,12 @@ class CanteenSpuServiceImplTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 正增量恢复是可交换操作:快照与当前库存不一致(并发下单/退款已改变库存)时仍应直接累加成功,
|
||||
* 正增量恢复是可交换操作:并发下单/退款已改变当前库存时仍应直接累加成功,
|
||||
* 不得被误判为“库存已变化”阻断退款。
|
||||
*/
|
||||
@Test
|
||||
void restoreApplyShouldIgnoreConcurrentStockChange() {
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2);
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(8));
|
||||
when(spuMapper.updateStock(1L, 2)).thenReturn(1);
|
||||
|
||||
@@ -114,24 +115,40 @@ class CanteenSpuServiceImplTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 负增量扣减仍要求当前库存等于快照,防止不同业务号基于同一剩余量重复判断导致超卖。
|
||||
* 负增量扣减不再比对快照:当天其他业务号(消费机售出/退款回补)已改变库存时,
|
||||
* 只要服务端现值仍够扣就必须放行——曾经快照口径失配导致消费机下单被确定性误拒“库存已变化”。
|
||||
*/
|
||||
@Test
|
||||
void deductApplyShouldStillRejectChangedStock() {
|
||||
registerMinimalMessageSource();
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2, 10);
|
||||
void deductApplyShouldIgnoreConcurrentStockChange() {
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2);
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(8));
|
||||
when(spuMapper.updateStock(1L, -2)).thenReturn(1);
|
||||
|
||||
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
|
||||
|
||||
verify(spuMapper).updateStock(1L, -2);
|
||||
verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
|
||||
Duration.ofDays(7));
|
||||
}
|
||||
|
||||
/**
|
||||
* 负增量扣减防超卖兜底:服务端现值不足以覆盖扣减量时仍必须拒绝。
|
||||
*/
|
||||
@Test
|
||||
void deductApplyShouldRejectInsufficientStock() {
|
||||
registerMinimalMessageSource();
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2);
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(1));
|
||||
|
||||
R result = service.updateSkuStockIdempotent(dto);
|
||||
|
||||
assertFalse(result.isOk());
|
||||
verify(spuMapper, never()).updateStock(anyLong(), anyInt());
|
||||
verify(spuMapper, never()).updateStockIfMatch(anyLong(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日菜谱分池正增量恢复:当前剩余与快照不一致(菜谱重导/销量重算后)也不得阻断退款,
|
||||
* 且传给 SQL 的剩余匹配条件为 null(销量回退由 SQL 钳制到 0 为止)。
|
||||
* 每日菜谱分池正增量恢复:菜谱重导/销量重算导致剩余口径变化后也不得阻断退款
|
||||
* (销量回退由 SQL 钳制到 0 为止)。
|
||||
*/
|
||||
@Test
|
||||
void dailyRestoreApplyShouldSkipRemainMatch() {
|
||||
@@ -139,7 +156,6 @@ class CanteenSpuServiceImplTest {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(100L);
|
||||
item.setIncrCount(2);
|
||||
item.setBeforeStock(5);
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(1);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
@@ -149,29 +165,27 @@ class CanteenSpuServiceImplTest {
|
||||
dto.setMerchantId(1L);
|
||||
dto.setItems(List.of(item));
|
||||
|
||||
// 配额 10、已售 0:剩余 10 与快照 5 不一致,正增量恢复仍应放行
|
||||
// 配额 10、已售 0,正增量恢复应放行
|
||||
when(dailyMenuMapper.selectOne(any())).thenReturn(dailyMenu(10, 0));
|
||||
when(dailyMenuMapper.updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null)).thenReturn(1);
|
||||
when(dailyMenuMapper.updateOnlineStockDelta(1L, mealDate, 1, 100L, 2)).thenReturn(1);
|
||||
|
||||
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
|
||||
|
||||
verify(dailyMenuMapper).updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null);
|
||||
verify(dailyMenuMapper).updateOnlineStockDelta(1L, mealDate, 1, 100L, 2);
|
||||
verify(valueOperations).set("canteen:spu:order-stock:daily:ONLINE:HARD_DAILY_MENU_AFTER_SALE:9",
|
||||
"APPLIED", Duration.ofDays(7));
|
||||
}
|
||||
|
||||
/**
|
||||
* OFFLINE 扣减:调用方 beforeStock 误传「剩余总库存」时,应归一到 AD stock 再 CAS,不得误报库存已变化。
|
||||
* 例:总库存 10、线上已订 2、线下已售 3 → shared=5、AD=8;beforeStock=5 时 SQL expected 应为 8。
|
||||
* 本次消费机事故回归用例:总库存 10、线上已订 2、线下已售 3(当天已有其他订单成交痕迹),
|
||||
* OFFLINE 扣减 1 仍应放行——不得再因快照口径失配误报“库存已变化”。
|
||||
*/
|
||||
@Test
|
||||
void offlineApplyShouldNormalizeSharedRemainSnapshotToAdStock() {
|
||||
registerMinimalMessageSource();
|
||||
void offlineApplyShouldAllowDeductAfterOtherBizSoldToday() {
|
||||
LocalDate mealDate = LocalDate.of(2026, 10, 8);
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(100L);
|
||||
item.setIncrCount(-1);
|
||||
item.setBeforeStock(5);
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(1);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
@@ -190,7 +204,7 @@ class CanteenSpuServiceImplTest {
|
||||
menu.setOnlineSold(2);
|
||||
menu.setOfflineSold(3);
|
||||
when(dailyMenuMapper.selectOne(any())).thenReturn(menu);
|
||||
when(dailyMenuMapper.updateOfflineStockDelta(1L, mealDate, 1, 100L, -1, 8)).thenReturn(1);
|
||||
when(dailyMenuMapper.updateOfflineStockDelta(1L, mealDate, 1, 100L, -1)).thenReturn(1);
|
||||
CanteenSpuEntity entity = new CanteenSpuEntity();
|
||||
entity.setId(100L);
|
||||
entity.setSpuType(1);
|
||||
@@ -200,26 +214,50 @@ class CanteenSpuServiceImplTest {
|
||||
|
||||
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
|
||||
|
||||
verify(dailyMenuMapper).updateOfflineStockDelta(1L, mealDate, 1, 100L, -1, 8);
|
||||
verify(dailyMenuMapper).updateOfflineStockDelta(1L, mealDate, 1, 100L, -1);
|
||||
verify(valueOperations).set("canteen:spu:order-stock:daily:OFFLINE:HARD_DAILY_MENU_ORDER:77",
|
||||
"APPLIED", Duration.ofDays(7));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造每日菜谱菜谱行实体。
|
||||
*
|
||||
* @param onlineStock 线上配额
|
||||
* @param onlineSold 线上已售
|
||||
* @return 仅包含本用例所需字段的实体
|
||||
* 每日菜谱分池扣减防超卖兜底:共享剩余总库存(总库存−线上已订−线下已售)不足时仍必须拒绝。
|
||||
*/
|
||||
private CanteenDailyMenuEntity dailyMenu(int onlineStock, int onlineSold) {
|
||||
@Test
|
||||
void dailyOfflineDeductShouldRejectWhenSharedRemainInsufficient() {
|
||||
registerMinimalMessageSource();
|
||||
LocalDate mealDate = LocalDate.of(2026, 10, 8);
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(100L);
|
||||
item.setIncrCount(-1);
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(1);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
dto.setBizId("HARD_DAILY_MENU_ORDER:78");
|
||||
dto.setOperation("APPLY");
|
||||
dto.setStockChannel("OFFLINE");
|
||||
dto.setMerchantId(1L);
|
||||
dto.setItems(List.of(item));
|
||||
|
||||
// 总库存 10、线上已订 2、线下已售 8:共享剩余 0,扣 1 应拒绝
|
||||
CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity();
|
||||
menu.setMerchantId(1L);
|
||||
menu.setSpuId(100L);
|
||||
menu.setHourType(1);
|
||||
menu.setOnlineStock(onlineStock);
|
||||
menu.setOnlineSold(onlineSold);
|
||||
return menu;
|
||||
menu.setOfflineStock(10);
|
||||
menu.setOnlineStock(10);
|
||||
menu.setOnlineSold(2);
|
||||
menu.setOfflineSold(8);
|
||||
when(dailyMenuMapper.selectOne(any())).thenReturn(menu);
|
||||
CanteenSpuEntity entity = new CanteenSpuEntity();
|
||||
entity.setId(100L);
|
||||
entity.setSpuType(1);
|
||||
entity.setMerchantId(1L);
|
||||
entity.setName("测试菜品");
|
||||
when(spuMapper.selectBatchIds(any())).thenReturn(List.of(entity));
|
||||
|
||||
assertFalse(service.updateSkuStockIdempotent(dto).isOk());
|
||||
|
||||
verify(dailyMenuMapper, never()).updateOfflineStockDelta(anyLong(), any(), anyInt(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,7 +265,7 @@ class CanteenSpuServiceImplTest {
|
||||
*/
|
||||
@Test
|
||||
void confirmedApplyRollbackShouldBecomeCompensated() {
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("ROLLBACK", 2, 10);
|
||||
CanteenSpuUpdateStockReqDTO dto = stockRequest("ROLLBACK", 2);
|
||||
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
|
||||
.thenReturn("APPLIED");
|
||||
when(spuMapper.selectById(1L)).thenReturn(spu(12));
|
||||
@@ -257,15 +295,13 @@ class CanteenSpuServiceImplTest {
|
||||
* 构造单个基础 SPU 的幂等库存请求。
|
||||
*
|
||||
* @param operation APPLY 或 ROLLBACK
|
||||
* @param increment 原 APPLY 使用的库存增量
|
||||
* @param beforeStock 当前业务尝试的操作前库存快照
|
||||
* @param increment 库存增量(扣减为负数)
|
||||
* @return 可直接调用库存服务的请求
|
||||
*/
|
||||
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment, int beforeStock) {
|
||||
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment) {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(1L);
|
||||
item.setIncrCount(increment);
|
||||
item.setBeforeStock(beforeStock);
|
||||
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
|
||||
dto.setBizId("HARD_DAILY_MENU_AFTER_SALE:9");
|
||||
dto.setOperation(operation);
|
||||
@@ -273,6 +309,23 @@ class CanteenSpuServiceImplTest {
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造每日菜谱菜谱行实体。
|
||||
*
|
||||
* @param onlineStock 线上配额
|
||||
* @param onlineSold 线上已售
|
||||
* @return 仅包含本用例所需字段的实体
|
||||
*/
|
||||
private CanteenDailyMenuEntity dailyMenu(int onlineStock, int onlineSold) {
|
||||
CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity();
|
||||
menu.setMerchantId(1L);
|
||||
menu.setSpuId(100L);
|
||||
menu.setHourType(1);
|
||||
menu.setOnlineStock(onlineStock);
|
||||
menu.setOnlineSold(onlineSold);
|
||||
return menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造当前库存快照。
|
||||
*
|
||||
|
||||
+2
-6
@@ -2972,7 +2972,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
item.setIncrCount(Math.multiplyExact(car.getProductCount(), factor));
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(hourType);
|
||||
item.setBeforeStock(inventorySnapshot.get(dailyStockKey(mealDate, hourType, car.getProductId())));
|
||||
items.add(item);
|
||||
}
|
||||
}
|
||||
@@ -3049,7 +3048,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(entry.getKey());
|
||||
item.setIncrCount(entry.getValue());
|
||||
item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
|
||||
return item;
|
||||
}).toList());
|
||||
R<?> result = onlineCanteenSpuService.updateSkuStockIdempotent(stockDto);
|
||||
@@ -5155,7 +5153,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(entry.getKey());
|
||||
item.setIncrCount(entry.getValue());
|
||||
item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
|
||||
return item;
|
||||
}).toList());
|
||||
R<?> result = remoteCanteenSpuService.updateSkuStockIdempotent(dto);
|
||||
@@ -5183,7 +5180,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
|
||||
item.setId(entry.getKey());
|
||||
item.setIncrCount(entry.getValue());
|
||||
item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
|
||||
item.setMealDate(mealDate);
|
||||
item.setHourType(hourType);
|
||||
return item;
|
||||
@@ -5233,8 +5229,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* 线下快照与食堂 channelRemain(OFFLINE) 一致:AD stock = 总库存 − 线上已订。
|
||||
* 不用 App 填充的 remainOfflineStock(在 listForOrderValidate 里是「剩余总库存」,会导致 beforeStock 对不齐)。
|
||||
* 线下剩余(本地充足性预检口径):与食堂 channelRemain(OFFLINE) 一致,AD stock = 总库存 − 线上已订。
|
||||
* 不用 App 填充的 remainOfflineStock(在 listForOrderValidate 里是「剩余总库存」,口径偏小会误拒下单)。
|
||||
*/
|
||||
private static int dailyOfflineRemain(DailyMenuAppVO row) {
|
||||
if (row == null) {
|
||||
|
||||
+7
-12
@@ -112,7 +112,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
assertEquals("APPLY", request.getOperation());
|
||||
assertEquals("HARD_DAILY_MENU_ORDER:100", request.getBizId());
|
||||
assertEquals(-2, request.getItems().get(0).getIncrCount());
|
||||
assertEquals(5, request.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,7 +163,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
assertEquals("APPLY", request.getOperation());
|
||||
assertEquals("HARD_DAILY_MENU_AFTER_SALE:500", request.getBizId());
|
||||
assertEquals(2, request.getItems().get(0).getIncrCount());
|
||||
assertEquals(3, request.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +187,7 @@ class HardOrderServiceImplInventoryTest {
|
||||
|
||||
/**
|
||||
* 验证设备购买链路(/hard/hard-api/ad/create-order 的 BELOW+CANTEEN_SPU 购物车)对当日菜谱在册商品
|
||||
* 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE、日期餐段和线下剩余快照,不再扣 canteen_spu.stock。
|
||||
* 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE 和日期餐段,不再扣 canteen_spu.stock。
|
||||
*/
|
||||
@Test
|
||||
void deviceCreateOrderDeductsDailyMenuOfflinePoolWhenOnMenu() {
|
||||
@@ -211,7 +209,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = request.getItems().get(0);
|
||||
assertEquals(11L, item.getId());
|
||||
assertEquals(-1, item.getIncrCount());
|
||||
assertEquals(4, item.getBeforeStock());
|
||||
assertEquals(LocalDate.of(2026, 9, 13), item.getMealDate());
|
||||
assertEquals(2, item.getHourType());
|
||||
verify(remoteCanteenSpuService, never()).getInnerSpuListByIds(any());
|
||||
@@ -237,7 +234,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
CanteenSpuUpdateStockReqDTO request = captor.getValue();
|
||||
assertNull(request.getStockChannel());
|
||||
assertEquals(-1, request.getItems().get(0).getIncrCount());
|
||||
assertEquals(9996, request.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,16 +262,13 @@ class HardOrderServiceImplInventoryTest {
|
||||
assertEquals("HARD_DAILY_MENU_ORDER:100", dailyRequest.getBizId());
|
||||
assertEquals(11L, dailyRequest.getItems().get(0).getId());
|
||||
assertEquals(-1, dailyRequest.getItems().get(0).getIncrCount());
|
||||
assertEquals(4, dailyRequest.getItems().get(0).getBeforeStock());
|
||||
assertEquals("HARD_DAILY_MENU_ORDER:100", basicRequest.getBizId());
|
||||
assertEquals(12L, basicRequest.getItems().get(0).getId());
|
||||
assertEquals(-2, basicRequest.getItems().get(0).getIncrCount());
|
||||
assertEquals(5, basicRequest.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证线上预定每日菜谱订单退款恢复 ONLINE 池:下单扣的是 online_sold,退款必须还同一池,
|
||||
* 快照取线上剩余并携带日期餐段。
|
||||
* 验证线上预定每日菜谱订单退款恢复 ONLINE 池:下单扣的是 online_sold,退款必须还同一池,并携带日期餐段。
|
||||
*/
|
||||
@Test
|
||||
void reserveRefundRestoresDailyMenuOnlineStock() {
|
||||
@@ -296,13 +289,12 @@ class HardOrderServiceImplInventoryTest {
|
||||
CanteenSpuUpdateStockReqDTO.Item item = request.getItems().get(0);
|
||||
assertEquals(11L, item.getId());
|
||||
assertEquals(2, item.getIncrCount());
|
||||
assertEquals(8, item.getBeforeStock());
|
||||
assertEquals(LocalDate.of(2026, 9, 13), item.getMealDate());
|
||||
assertEquals(2, item.getHourType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证机器收银等其他类型的每日菜谱订单退款仍恢复 OFFLINE 池,快照取线下剩余。
|
||||
* 验证机器收银等其他类型的每日菜谱订单退款仍恢复 OFFLINE 池。
|
||||
*/
|
||||
@Test
|
||||
void cashierDailyMenuRefundRestoresOfflineStock() {
|
||||
@@ -321,7 +313,6 @@ class HardOrderServiceImplInventoryTest {
|
||||
assertEquals("OFFLINE", request.getStockChannel());
|
||||
assertEquals("HARD_DAILY_MENU_AFTER_SALE:500", request.getBizId());
|
||||
assertEquals(2, request.getItems().get(0).getIncrCount());
|
||||
assertEquals(4, request.getItems().get(0).getBeforeStock());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,6 +400,8 @@ class HardOrderServiceImplInventoryTest {
|
||||
|
||||
/**
|
||||
* 构造指定线上/线下剩余的每日菜谱行。
|
||||
* 线下剩余按 hard 预检口径(AD stock = offlineStock − onlineSold)同步设置配额字段,
|
||||
* 不能只填 remainOfflineStock(App 展示口径,预检不信任)。
|
||||
*
|
||||
* @param onlineRemain 线上池剩余
|
||||
* @param offlineRemain 线下池剩余
|
||||
@@ -420,6 +413,8 @@ class HardOrderServiceImplInventoryTest {
|
||||
row.setName("测试菜品");
|
||||
row.setRemainOnlineStock(onlineRemain);
|
||||
row.setRemainOfflineStock(offlineRemain);
|
||||
row.setOfflineStock(offlineRemain);
|
||||
row.setOnlineSold(0);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user