diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java index e10bb2ede..6e166fc78 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java @@ -164,14 +164,15 @@ public class CanteenSpuServiceImpl extends ServiceImpl 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 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, ""); } } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java index efc9b963d..6ce609ed2 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/test/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImplTest.java @@ -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 的幂等库存请求。 *