fix:解决退款报错[400 Bad Request] during [POST] to [http://pjcloud-canteen-biz/canteenSpu/inner/updateSkuStockIdempotent] [RemoteCanteenSpuService#updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO)]: [{"code":1,"msg":"库存已变化,请重新操作","data":null,"ok":false}]

This commit is contained in:
dzc committed 2026-09-14 22:39:36 +08:00
1 parent 0308701998
commit 53c1ac7cb2
2 files changed
+89 -12

No files matched your search

@@ -164,14 +164,15 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
StrUtil.blankToDefault(spu.getName(), "商品")));
}
if (applyOperation) {
// 不得把“库存恰好等于目标值”当成本业务已经执行;该值可能由另一个业务号并发扣减得到。
// 幂等成功只能依赖上方读取到的 APPLIED 状态,状态缺失时必须从调用方快照原子更新。
if (applyOperation && item.getIncrCount() < 0) {
// 负增量扣减必须以快照做前置校验:不得把“库存恰好等于目标值”当成本业务已经执行,
// 该值可能由另一个业务号并发扣减得到,且并发扣减基于同一剩余量判断会超卖。
// 正增量恢复是可交换操作,不做快照校验,见下方更新循环注释。
if (spu.getStock() != expectedStock) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
StrUtil.blankToDefault(spu.getName(), "商品")));
}
} else if ("APPLIED".equals(state)) {
} else 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,
@@ -192,8 +193,17 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
continue;
}
// 状态缺失的 ROLLBACK 说明没有可确认的成功 APPLY,只写入终态阻断延迟请求,不能按库存值猜测并恢复。
if (applyOperation && baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock) == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_CHANGED, "");
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, "");
}
}
}
String targetState = applyOperation ? "APPLIED"
@@ -277,7 +287,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
}
if (applyOperation && current != expectedStock) {
// 正增量恢复是可交换操作,跳过剩余量快照校验,避免退款恢复被并发下单等无关变更误拒;
// 负增量扣减保留快照校验,防止不同业务号基于同一剩余量重复判断导致超卖。
if (applyOperation && item.getIncrCount() < 0 && current != expectedStock) {
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
@@ -306,13 +318,18 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
continue;
}
if (applyOperation) {
// 正增量恢复直接原子累加:expected 传 null 跳过剩余量匹配条件(SQL 仍保留不退成负销量、
// 不超配额的边界保护),并发变更不影响恢复正确性,幂等由 bizId 锁和 Redis 状态保证;
// 负增量扣减保留快照匹配条件更新,防止不同业务号基于同一剩余量重复判断导致超卖。
Integer expected = delta > 0 ? null : item.getBeforeStock();
updated = offline
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), delta, item.getBeforeStock())
item.getHourType(), item.getId(), delta, expected)
: canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), delta, item.getBeforeStock());
item.getHourType(), item.getId(), delta, expected);
if (updated == 0) {
throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_CHANGED, "");
throw new I18nBizException(delta > 0 ? ErrorCodes.CANTEEN_STOCK_UPDATE_FAIL
: ErrorCodes.CANTEEN_STOCK_CHANGED, "");
}
}
}
@@ -5,9 +5,13 @@ import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
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;
@@ -15,9 +19,16 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.Duration;
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;
@@ -55,10 +66,13 @@ class CanteenSpuServiceImplTest {
@AfterEach
void tearDown() {
TransactionSynchronizationManager.clearSynchronization();
// 撤销最小 MessageSource 注入,避免静态 SpringContextHolder 影响后续测试类。
ReflectionTestUtils.setField(SpringContextHolder.class, "beanFactory", null);
}
/**
* 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。
* 正增量恢复走无条件原子累加,不再与快照做 CAS 对比。
*/
@Test
void compensatedRefundInventoryShouldAllowRetry() {
@@ -66,15 +80,48 @@ class CanteenSpuServiceImplTest {
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
.thenReturn("COMPENSATED");
when(spuMapper.selectById(1L)).thenReturn(spu(10));
when(spuMapper.updateStockIfMatch(1L, 2, 10)).thenReturn(1);
when(spuMapper.updateStock(1L, 2)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(spuMapper).updateStockIfMatch(1L, 2, 10);
verify(spuMapper).updateStock(1L, 2);
verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
Duration.ofDays(7));
}
/**
* 正增量恢复是可交换操作:快照与当前库存不一致(并发下单/退款已改变库存)时仍应直接累加成功,
* 不得被误判为“库存已变化”阻断退款。
*/
@Test
void restoreApplyShouldIgnoreConcurrentStockChange() {
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
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 deductApplyShouldStillRejectChangedStock() {
registerMinimalMessageSource();
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", -2, 10);
when(spuMapper.selectById(1L)).thenReturn(spu(8));
R result = service.updateSkuStockIdempotent(dto);
assertFalse(result.isOk());
verify(spuMapper, never()).updateStock(anyLong(), anyInt());
verify(spuMapper, never()).updateStockIfMatch(anyLong(), anyInt(), anyInt());
}
/**
* 已执行 APPLY 的回滚必须标记为可重试补偿态,区别于阻断网络延迟 APPLY 的未确认回滚态。
*/
@@ -93,6 +140,19 @@ class CanteenSpuServiceImplTest {
Duration.ofDays(7));
}
/**
* 为纯 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);
}
/**
* 构造单个基础 SPU 的幂等库存请求。
*