fix:库存更新失败,请重试

This commit is contained in:
dzc committed 2026-09-14 22:56:22 +08:00
1 parent 4ad183d55e
commit 6470e222d1
3 files changed
+78 -6

No files matched your search

@@ -318,8 +318,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
continue;
}
if (applyOperation) {
// 正增量恢复直接原子累加:expected 传 null 跳过剩余量匹配条件(SQL 仍保留不退成负销量、
// 不超配额的边界保护),并发变更不影响恢复正确性,幂等由 bizId 锁和 Redis 状态保证;
// 正增量恢复直接原子累加:expected 传 null 跳过剩余量匹配条件。SQL 将销量回退钳制到 0 为止
// (菜谱重导或按订单重算销量后 sold 可能小于恢复量,退款/补偿不能被统计口径阻断),
// 并发变更不影响恢复正确性,幂等由 bizId 锁和 Redis 状态保证;
// 负增量扣减保留快照匹配条件更新,防止不同业务号基于同一剩余量重复判断导致超卖。
Integer expected = delta > 0 ? null : item.getBeforeStock();
updated = offline
@@ -324,17 +324,26 @@
delta 与旧接口一致:下单扣减传负数、售后回传正数。
实际改写销量:sold = sold - delta(扣减时 sold 增加)。
expected 为操作前「剩余」快照(配额 - 销量)。
正增量恢复把销量钳制到 0 为止:菜谱重导或按订单重算销量后 sold 可能小于恢复量,
退款/补偿不能被该统计口径阻断;负增量扣减方向恒增,GREATEST 不改变原语义。
-->
<update id="updateOnlineStockDelta">
UPDATE canteen_daily_menu
SET online_sold = IFNULL(online_sold, 0) - #{delta}
SET online_sold = GREATEST(IFNULL(online_sold, 0) - #{delta}, 0)
WHERE del_flag = '0'
AND merchant_id = #{merchantId}
AND start_time = #{mealDate}
AND hour_type = #{hourType}
AND spu_id = #{spuId}
<choose>
<when test="delta &gt; 0">
AND GREATEST(IFNULL(online_sold, 0) - #{delta}, 0) &lt;= IFNULL(online_stock, 600)
</when>
<otherwise>
AND IFNULL(online_sold, 0) - #{delta} &gt;= 0
AND IFNULL(online_sold, 0) - #{delta} &lt;= IFNULL(online_stock, 600)
</otherwise>
</choose>
<if test="expected != null">
AND (IFNULL(online_stock, 600) - IFNULL(online_sold, 0)) = #{expected}
</if>
@@ -342,14 +351,21 @@
<update id="updateOfflineStockDelta">
UPDATE canteen_daily_menu
SET offline_sold = IFNULL(offline_sold, 0) - #{delta}
SET offline_sold = GREATEST(IFNULL(offline_sold, 0) - #{delta}, 0)
WHERE del_flag = '0'
AND merchant_id = #{merchantId}
AND start_time = #{mealDate}
AND hour_type = #{hourType}
AND spu_id = #{spuId}
<choose>
<when test="delta &gt; 0">
AND GREATEST(IFNULL(offline_sold, 0) - #{delta}, 0) &lt;= IFNULL(offline_stock, 600)
</when>
<otherwise>
AND IFNULL(offline_sold, 0) - #{delta} &gt;= 0
AND IFNULL(offline_sold, 0) - #{delta} &lt;= IFNULL(offline_stock, 600)
</otherwise>
</choose>
<if test="expected != null">
AND (IFNULL(offline_stock, 600) - IFNULL(offline_sold, 0)) = #{expected}
</if>
@@ -1,7 +1,9 @@
package com.bsd.pjcloud.canteen.service.impl;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
@@ -18,6 +20,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
@@ -43,6 +46,9 @@ class CanteenSpuServiceImplTest {
/** 基础 SPU Mapper,用于验证库存增量是否真实执行。 */
private CanteenSpuMapper spuMapper;
/** 每日菜谱 Mapper,用于验证分池正增量恢复的剩余匹配条件。 */
private CanteenDailyMenuMapper dailyMenuMapper;
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
private ValueOperations<String, String> valueOperations;
@@ -52,11 +58,12 @@ class CanteenSpuServiceImplTest {
@BeforeEach
void setUp() {
spuMapper = mock(CanteenSpuMapper.class);
dailyMenuMapper = mock(CanteenDailyMenuMapper.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, null, null, null, null, null,
mock(CanteenMerchantScopeHelper.class), mock(CanteenDailyMenuStockSupport.class));
CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, dailyMenuMapper, null, null, null,
null, mock(CanteenMerchantScopeHelper.class), mock(CanteenDailyMenuStockSupport.class));
ReflectionTestUtils.setField(target, "baseMapper", spuMapper);
service = target;
TransactionSynchronizationManager.initSynchronization();
@@ -122,6 +129,54 @@ class CanteenSpuServiceImplTest {
verify(spuMapper, never()).updateStockIfMatch(anyLong(), anyInt(), anyInt());
}
/**
* 每日菜谱分池正增量恢复:当前剩余与快照不一致(菜谱重导/销量重算后)也不得阻断退款,
* 且传给 SQL 的剩余匹配条件为 null(销量回退由 SQL 钳制到 0 为止)。
*/
@Test
void dailyRestoreApplyShouldSkipRemainMatch() {
LocalDate mealDate = LocalDate.of(2026, 9, 14);
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();
dto.setBizId("HARD_DAILY_MENU_AFTER_SALE:9");
dto.setOperation("APPLY");
dto.setStockChannel("ONLINE");
dto.setMerchantId(1L);
dto.setItems(List.of(item));
// 配额 10、已售 0:剩余 10 与快照 5 不一致,正增量恢复仍应放行
when(dailyMenuMapper.selectOne(any())).thenReturn(dailyMenu(10, 0));
when(dailyMenuMapper.updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(dailyMenuMapper).updateOnlineStockDelta(1L, mealDate, 1, 100L, 2, null);
verify(valueOperations).set("canteen:spu:order-stock:daily:ONLINE:HARD_DAILY_MENU_AFTER_SALE:9",
"APPLIED", Duration.ofDays(7));
}
/**
* 构造每日菜谱菜谱行实体。
*
* @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;
}
/**
* 已执行 APPLY 的回滚必须标记为可重试补偿态,区别于阻断网络延迟 APPLY 的未确认回滚态。
*/