From abee9ff48d5b98acd605bd4419a50b5df564839a Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 31 May 2026 17:11:56 +0800 Subject: [PATCH] fix: 修复拼团开团与库存边界校验 --- yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImpl.java | 2 +- yudao-module-mall/yudao-module-promotion/src/test/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImplTest.java | 325 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-mall/yudao-module-promotion/src/test/resources/sql/clean.sql | 3 ++- yudao-module-mall/yudao-module-promotion/src/test/resources/sql/create_tables.sql | 30 ++++++++++++++++++++++++++++++ 4 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 yudao-module-mall/yudao-module-promotion/src/test/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImplTest.java diff --git a/yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImpl.java b/yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImpl.java index 46929ca..ab215f6 100644 --- a/yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImpl.java +++ b/yudao-module-mall/yudao-module-promotion/src/main/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImpl.java @@ -128,7 +128,7 @@ public class CombinationRecordServiceImpl implements CombinationRecordService { throw exception(COMBINATION_JOIN_ACTIVITY_PRODUCT_NOT_EXISTS); } // 4.3 校验库存是否充足 - if (count >= sku.getStock()) { + if (count > sku.getStock()) { throw exception(COMBINATION_ACTIVITY_UPDATE_STOCK_FAIL); } diff --git a/yudao-module-mall/yudao-module-promotion/src/test/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImplTest.java b/yudao-module-mall/yudao-module-promotion/src/test/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImplTest.java new file mode 100644 index 0000000..5a07773 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion/src/test/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImplTest.java @@ -0,0 +1,325 @@ +package cn.iocoder.yudao.module.promotion.service.combination; + +import cn.iocoder.yudao.framework.common.core.KeyValue; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi; +import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO; +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; +import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO; +import cn.iocoder.yudao.module.promotion.api.combination.dto.CombinationRecordCreateReqDTO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationActivityDO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationProductDO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationRecordDO; +import cn.iocoder.yudao.module.promotion.dal.mysql.combination.CombinationRecordMapper; +import cn.iocoder.yudao.module.promotion.enums.combination.CombinationRecordStatusEnum; +import cn.iocoder.yudao.module.system.api.social.SocialClientApi; +import cn.iocoder.yudao.module.trade.api.order.TradeOrderApi; +import cn.iocoder.yudao.module.trade.enums.order.TradeOrderCancelTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.function.Consumer; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link CombinationRecordServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(CombinationRecordServiceImpl.class) +public class CombinationRecordServiceImplTest extends BaseDbUnitTest { + + private static final Long USER_ID = 100L; + private static final Long ACTIVITY_ID = 200L; + private static final Long SPU_ID = 300L; + private static final Long SKU_ID = 400L; + private static final Long HEAD_ID = 500L; + private static final Long ORDER_ID = 600L; + + @Resource + private CombinationRecordServiceImpl combinationRecordService; + @Resource + private CombinationRecordMapper combinationRecordMapper; + + @MockitoBean + private CombinationActivityService combinationActivityService; + @MockitoBean + private MemberUserApi memberUserApi; + @MockitoBean + private ProductSpuApi productSpuApi; + @MockitoBean + private ProductSkuApi productSkuApi; + @MockitoBean + private TradeOrderApi tradeOrderApi; + @MockitoBean + private SocialClientApi socialClientApi; + + @Test + public void testValidateCombinationRecord_headIdNullAsNewGroup() { + // mock 数据 + CombinationActivityDO activity = mockValidateContext(10); + + // 调用 + KeyValue result = combinationRecordService + .validateCombinationRecord(USER_ID, ACTIVITY_ID, null, SKU_ID, 1); + + // 断言 + assertSame(activity, result.getKey()); + assertEquals(SKU_ID, result.getValue().getSkuId()); + } + + @Test + public void testValidateCombinationRecord_headIdGroupAsNewGroup() { + // mock 数据:headId 为 0 时,也代表新开团 + CombinationActivityDO activity = mockValidateContext(10); + + // 调用 + KeyValue result = combinationRecordService + .validateCombinationRecord(USER_ID, ACTIVITY_ID, CombinationRecordDO.HEAD_ID_GROUP, SKU_ID, 1); + + // 断言 + assertSame(activity, result.getKey()); + assertEquals(SKU_ID, result.getValue().getSkuId()); + } + + @Test + public void testValidateCombinationRecord_realHeadIdAsJoinGroup() { + // mock 数据:真实 headId 时,按参团校验父拼团 + CombinationActivityDO activity = mockValidateContext(10); + combinationRecordMapper.insert(randomRecord(o -> { + o.setId(HEAD_ID); + o.setHeadId(CombinationRecordDO.HEAD_ID_GROUP); + o.setUserId(101L); + o.setOrderId(601L); + })); + + // 调用 + KeyValue result = combinationRecordService + .validateCombinationRecord(USER_ID, ACTIVITY_ID, HEAD_ID, SKU_ID, 1); + + // 断言 + assertSame(activity, result.getKey()); + assertEquals(SKU_ID, result.getValue().getSkuId()); + } + + @Test + public void testValidateCombinationRecord_realHeadIdNotExists() { + // mock 数据:拼团活动正常,但父拼团不存在 + mockActivity(); + + // 调用,并断言 + assertServiceException(() -> combinationRecordService.validateCombinationRecord( + USER_ID, ACTIVITY_ID, HEAD_ID, SKU_ID, 1), COMBINATION_RECORD_HEAD_NOT_EXISTS); + } + + @Test + public void testValidateCombinationRecord_countEqualsStock() { + // mock 数据:库存刚好等于购买数量 + mockValidateContext(10); + + // 调用 + KeyValue result = combinationRecordService + .validateCombinationRecord(USER_ID, ACTIVITY_ID, null, SKU_ID, 10); + + // 断言 + assertNotNull(result); + } + + @Test + public void testValidateCombinationRecord_countGreaterThanStock() { + // mock 数据:库存不足 + mockValidateContext(10); + + // 调用,并断言 + assertServiceException(() -> combinationRecordService.validateCombinationRecord( + USER_ID, ACTIVITY_ID, null, SKU_ID, 11), COMBINATION_ACTIVITY_UPDATE_STOCK_FAIL); + } + + @Test + public void testValidateCombinationRecord_haveJoined() { + // mock 数据:用户存在进行中的拼团记录 + mockValidateContext(10); + combinationRecordMapper.insert(randomRecord(o -> { + o.setStatus(CombinationRecordStatusEnum.IN_PROGRESS.getStatus()); + o.setCount(1); + })); + + // 调用,并断言 + assertServiceException(() -> combinationRecordService.validateCombinationRecord( + USER_ID, ACTIVITY_ID, null, SKU_ID, 1), COMBINATION_RECORD_FAILED_HAVE_JOINED); + } + + @Test + public void testValidateCombinationRecord_totalLimitCountExceed() { + // mock 数据:用户历史购买数量加本次数量超过总限购 + CombinationActivityDO activity = mockValidateContext(10); + activity.setTotalLimitCount(5); + combinationRecordMapper.insert(randomRecord(o -> { + o.setStatus(CombinationRecordStatusEnum.SUCCESS.getStatus()); + o.setCount(3); + })); + + // 调用,并断言 + assertServiceException(() -> combinationRecordService.validateCombinationRecord( + USER_ID, ACTIVITY_ID, null, SKU_ID, 3), COMBINATION_RECORD_FAILED_TOTAL_LIMIT_COUNT_EXCEED); + } + + @Test + public void testCreateCombinationRecord_headIdGroupAsNewGroup() { + // mock 数据:开团参数中 headId 为 0 + mockValidateContext(10); + mockCreateContext(); + CombinationRecordCreateReqDTO reqDTO = randomCreateReqDTO(CombinationRecordDO.HEAD_ID_GROUP); + + // 调用 + CombinationRecordDO record = combinationRecordService.createCombinationRecord(reqDTO); + + // 断言:仍然按开团处理,不更新其它团记录 + assertNotNull(record.getId()); + assertEquals(CombinationRecordDO.HEAD_ID_GROUP, record.getHeadId()); + assertEquals(CombinationRecordStatusEnum.IN_PROGRESS.getStatus(), record.getStatus()); + assertEquals(1, record.getUserCount()); + assertFalse(record.getVirtualGroup()); + assertNotNull(record.getStartTime()); + assertNotNull(record.getExpireTime()); + assertEquals(1L, combinationRecordMapper.selectCount()); + assertEquals(CombinationRecordDO.HEAD_ID_GROUP, + combinationRecordMapper.selectById(record.getId()).getHeadId()); + } + + @Test + public void testHandleExpireRecord_cancelOrders() { + // mock 数据:过期团长和一个团员 + CombinationRecordDO headRecord = randomRecord(o -> { + o.setId(HEAD_ID); + o.setHeadId(CombinationRecordDO.HEAD_ID_GROUP); + o.setUserId(USER_ID); + o.setOrderId(ORDER_ID); + }); + CombinationRecordDO memberRecord = randomRecord(o -> { + o.setId(501L); + o.setHeadId(HEAD_ID); + o.setUserId(101L); + o.setOrderId(601L); + }); + combinationRecordMapper.insert(headRecord); + combinationRecordMapper.insert(memberRecord); + + // 调用 + combinationRecordService.handleExpireRecord(headRecord); + + // 断言:整团记录标记为失败,并取消已支付订单 + List records = combinationRecordMapper.selectList(); + assertEquals(2, records.size()); + assertTrue(records.stream().allMatch(item -> + CombinationRecordStatusEnum.FAILED.getStatus().equals(item.getStatus()))); + assertTrue(records.stream().allMatch(item -> item.getEndTime() != null)); + verify(tradeOrderApi).cancelPaidOrder(USER_ID, ORDER_ID, TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType()); + verify(tradeOrderApi).cancelPaidOrder(101L, 601L, TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType()); + } + + private CombinationActivityDO mockValidateContext(Integer stock) { + CombinationActivityDO activity = mockActivity(); + CombinationProductDO product = randomPojo(CombinationProductDO.class, o -> { + o.setActivityId(ACTIVITY_ID); + o.setSpuId(SPU_ID); + o.setSkuId(SKU_ID); + o.setCombinationPrice(100); + }); + ProductSkuRespDTO sku = randomPojo(ProductSkuRespDTO.class, o -> { + o.setId(SKU_ID); + o.setSpuId(SPU_ID); + o.setStock(stock); + o.setPicUrl("https://www.iocoder.cn/sku.png"); + }); + when(combinationActivityService.selectByActivityIdAndSkuId(ACTIVITY_ID, SKU_ID)).thenReturn(product); + when(productSkuApi.getSku(SKU_ID)).thenReturn(sku); + return activity; + } + + private CombinationActivityDO mockActivity() { + CombinationActivityDO activity = randomPojo(CombinationActivityDO.class, o -> { + o.setId(ACTIVITY_ID); + o.setStatus(CommonStatusEnum.ENABLE.getStatus()); + o.setStartTime(LocalDateTime.now().minusHours(1)); + o.setEndTime(LocalDateTime.now().plusHours(1)); + o.setSingleLimitCount(100); + o.setTotalLimitCount(100); + o.setUserSize(3); + o.setVirtualGroup(false); + o.setLimitDuration(24); + }); + when(combinationActivityService.validateCombinationActivityExists(ACTIVITY_ID)).thenReturn(activity); + return activity; + } + + private void mockCreateContext() { + MemberUserRespDTO user = randomPojo(MemberUserRespDTO.class, o -> { + o.setId(USER_ID); + o.setNickname("芋道源码"); + o.setAvatar("https://www.iocoder.cn/avatar.png"); + }); + when(memberUserApi.getUser(USER_ID)).thenReturn(user); + ProductSpuRespDTO spu = randomPojo(ProductSpuRespDTO.class, o -> { + o.setId(SPU_ID); + o.setName("测试商品"); + o.setPicUrl("https://www.iocoder.cn/spu.png"); + }); + when(productSpuApi.getSpu(SPU_ID)).thenReturn(spu); + } + + private static CombinationRecordCreateReqDTO randomCreateReqDTO(Long headId) { + return randomPojo(CombinationRecordCreateReqDTO.class, o -> { + o.setActivityId(ACTIVITY_ID); + o.setSpuId(SPU_ID); + o.setSkuId(SKU_ID); + o.setCount(1); + o.setOrderId(ORDER_ID); + o.setUserId(USER_ID); + o.setHeadId(headId); + o.setCombinationPrice(100); + }); + } + + @SafeVarargs + private static CombinationRecordDO randomRecord(Consumer... consumers) { + return randomPojo(CombinationRecordDO.class, o -> { + o.setActivityId(ACTIVITY_ID); + o.setCombinationPrice(100); + o.setSpuId(SPU_ID); + o.setSpuName("测试商品"); + o.setPicUrl("https://www.iocoder.cn/spu.png"); + o.setSkuId(SKU_ID); + o.setCount(1); + o.setUserId(USER_ID); + o.setNickname("芋道源码"); + o.setAvatar("https://www.iocoder.cn/avatar.png"); + o.setHeadId(CombinationRecordDO.HEAD_ID_GROUP); + o.setStatus(CombinationRecordStatusEnum.IN_PROGRESS.getStatus()); + o.setOrderId(ORDER_ID); + o.setUserSize(3); + o.setUserCount(1); + o.setVirtualGroup(false); + o.setStartTime(LocalDateTime.now().minusMinutes(10)); + o.setExpireTime(LocalDateTime.now().plusHours(1)); + o.setEndTime(null); + for (Consumer consumer : consumers) { + consumer.accept(o); + } + }); + } + +} diff --git a/yudao-module-mall/yudao-module-promotion/src/test/resources/sql/clean.sql b/yudao-module-mall/yudao-module-promotion/src/test/resources/sql/clean.sql index 6a1a242..54ec043 100644 --- a/yudao-module-mall/yudao-module-promotion/src/test/resources/sql/clean.sql +++ b/yudao-module-mall/yudao-module-promotion/src/test/resources/sql/clean.sql @@ -5,8 +5,9 @@ DELETE FROM "promotion_reward_activity"; DELETE FROM "promotion_discount_activity"; DELETE FROM "promotion_discount_product"; DELETE FROM "promotion_seckill_config"; +DELETE FROM "promotion_combination_record"; DELETE FROM "promotion_combination_activity"; DELETE FROM "promotion_article_category"; DELETE FROM "promotion_article"; DELETE FROM "promotion_diy_template"; -DELETE FROM "promotion_diy_page"; \ No newline at end of file +DELETE FROM "promotion_diy_page"; diff --git a/yudao-module-mall/yudao-module-promotion/src/test/resources/sql/create_tables.sql b/yudao-module-mall/yudao-module-promotion/src/test/resources/sql/create_tables.sql index 5b566ce..fae2e52 100644 --- a/yudao-module-mall/yudao-module-promotion/src/test/resources/sql/create_tables.sql +++ b/yudao-module-mall/yudao-module-promotion/src/test/resources/sql/create_tables.sql @@ -203,6 +203,36 @@ CREATE TABLE IF NOT EXISTS "promotion_combination_activity" PRIMARY KEY ("id") ) COMMENT '拼团活动'; +CREATE TABLE IF NOT EXISTS "promotion_combination_record" +( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "activity_id" bigint NOT NULL, + "combination_price" int NOT NULL, + "spu_id" bigint NOT NULL, + "spu_name" varchar NOT NULL, + "pic_url" varchar, + "sku_id" bigint NOT NULL, + "count" int NOT NULL, + "user_id" bigint NOT NULL, + "nickname" varchar, + "avatar" varchar, + "head_id" bigint NOT NULL, + "status" int NOT NULL, + "order_id" bigint NOT NULL, + "user_size" int NOT NULL, + "user_count" int NOT NULL, + "virtual_group" bit NOT NULL, + "expire_time" datetime, + "start_time" datetime, + "end_time" datetime, + "creator" varchar DEFAULT '', + "create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar DEFAULT '', + "update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + PRIMARY KEY ("id") +) COMMENT '拼团记录'; + CREATE TABLE IF NOT EXISTS "promotion_article_category" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, -- libgit2 0.21.4