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:
1 parent
0308701998
commit
53c1ac7cb2
2 files changed
+89
-12
No files matched your search
+27
-10
@@ -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, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
-2
@@ -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 的幂等库存请求。
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user