fix:库存更新失败,请重试
This commit is contained in:
1 parent
4ad183d55e
commit
6470e222d1
3 files changed
+78
-6
No files matched your search
+3
-2
@@ -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
|
||||
|
||||
+18
-2
@@ -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 > 0">
|
||||
AND GREATEST(IFNULL(online_sold, 0) - #{delta}, 0) <= IFNULL(online_stock, 600)
|
||||
</when>
|
||||
<otherwise>
|
||||
AND IFNULL(online_sold, 0) - #{delta} >= 0
|
||||
AND IFNULL(online_sold, 0) - #{delta} <= 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 > 0">
|
||||
AND GREATEST(IFNULL(offline_sold, 0) - #{delta}, 0) <= IFNULL(offline_stock, 600)
|
||||
</when>
|
||||
<otherwise>
|
||||
AND IFNULL(offline_sold, 0) - #{delta} >= 0
|
||||
AND IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600)
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test="expected != null">
|
||||
AND (IFNULL(offline_stock, 600) - IFNULL(offline_sold, 0)) = #{expected}
|
||||
</if>
|
||||
|
||||
+57
-2
@@ -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 的未确认回滚态。
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user