Commit abee9ff48d5b98acd605bd4419a50b5df564839a

Authored by YunaiV
1 parent eaaeb86a

fix: 修复拼团开团与库存边界校验

- 允许拼团购买数量等于 SKU 库存,仅购买数量大于库存时提示库存不足
- 补充 CombinationRecordServiceImpl 的 DB 单测,覆盖 headId=0 开团、参团、父团不存在、库存边界、重复参团、总限购和过期取消订单等场景
- 补充 promotion_combination_record 单测表结构和清理 SQL

帖子:https://t.zsxq.com/Brapi
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 {
128 128 throw exception(COMBINATION_JOIN_ACTIVITY_PRODUCT_NOT_EXISTS);
129 129 }
130 130 // 4.3 校验库存是否充足
131   - if (count >= sku.getStock()) {
  131 + if (count > sku.getStock()) {
132 132 throw exception(COMBINATION_ACTIVITY_UPDATE_STOCK_FAIL);
133 133 }
134 134  
... ...
yudao-module-mall/yudao-module-promotion/src/test/java/cn/iocoder/yudao/module/promotion/service/combination/CombinationRecordServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.promotion.service.combination;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.KeyValue;
  4 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  6 +import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
  7 +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
  8 +import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi;
  9 +import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO;
  10 +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi;
  11 +import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO;
  12 +import cn.iocoder.yudao.module.promotion.api.combination.dto.CombinationRecordCreateReqDTO;
  13 +import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationActivityDO;
  14 +import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationProductDO;
  15 +import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationRecordDO;
  16 +import cn.iocoder.yudao.module.promotion.dal.mysql.combination.CombinationRecordMapper;
  17 +import cn.iocoder.yudao.module.promotion.enums.combination.CombinationRecordStatusEnum;
  18 +import cn.iocoder.yudao.module.system.api.social.SocialClientApi;
  19 +import cn.iocoder.yudao.module.trade.api.order.TradeOrderApi;
  20 +import cn.iocoder.yudao.module.trade.enums.order.TradeOrderCancelTypeEnum;
  21 +import jakarta.annotation.Resource;
  22 +import org.junit.jupiter.api.Test;
  23 +import org.springframework.context.annotation.Import;
  24 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
  25 +
  26 +import java.time.LocalDateTime;
  27 +import java.util.List;
  28 +import java.util.function.Consumer;
  29 +
  30 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  31 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
  32 +import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.*;
  33 +import static org.junit.jupiter.api.Assertions.*;
  34 +import static org.mockito.Mockito.verify;
  35 +import static org.mockito.Mockito.when;
  36 +
  37 +/**
  38 + * {@link CombinationRecordServiceImpl} 的单元测试类
  39 + *
  40 + * @author 芋道源码
  41 + */
  42 +@Import(CombinationRecordServiceImpl.class)
  43 +public class CombinationRecordServiceImplTest extends BaseDbUnitTest {
  44 +
  45 + private static final Long USER_ID = 100L;
  46 + private static final Long ACTIVITY_ID = 200L;
  47 + private static final Long SPU_ID = 300L;
  48 + private static final Long SKU_ID = 400L;
  49 + private static final Long HEAD_ID = 500L;
  50 + private static final Long ORDER_ID = 600L;
  51 +
  52 + @Resource
  53 + private CombinationRecordServiceImpl combinationRecordService;
  54 + @Resource
  55 + private CombinationRecordMapper combinationRecordMapper;
  56 +
  57 + @MockitoBean
  58 + private CombinationActivityService combinationActivityService;
  59 + @MockitoBean
  60 + private MemberUserApi memberUserApi;
  61 + @MockitoBean
  62 + private ProductSpuApi productSpuApi;
  63 + @MockitoBean
  64 + private ProductSkuApi productSkuApi;
  65 + @MockitoBean
  66 + private TradeOrderApi tradeOrderApi;
  67 + @MockitoBean
  68 + private SocialClientApi socialClientApi;
  69 +
  70 + @Test
  71 + public void testValidateCombinationRecord_headIdNullAsNewGroup() {
  72 + // mock 数据
  73 + CombinationActivityDO activity = mockValidateContext(10);
  74 +
  75 + // 调用
  76 + KeyValue<CombinationActivityDO, CombinationProductDO> result = combinationRecordService
  77 + .validateCombinationRecord(USER_ID, ACTIVITY_ID, null, SKU_ID, 1);
  78 +
  79 + // 断言
  80 + assertSame(activity, result.getKey());
  81 + assertEquals(SKU_ID, result.getValue().getSkuId());
  82 + }
  83 +
  84 + @Test
  85 + public void testValidateCombinationRecord_headIdGroupAsNewGroup() {
  86 + // mock 数据:headId 为 0 时,也代表新开团
  87 + CombinationActivityDO activity = mockValidateContext(10);
  88 +
  89 + // 调用
  90 + KeyValue<CombinationActivityDO, CombinationProductDO> result = combinationRecordService
  91 + .validateCombinationRecord(USER_ID, ACTIVITY_ID, CombinationRecordDO.HEAD_ID_GROUP, SKU_ID, 1);
  92 +
  93 + // 断言
  94 + assertSame(activity, result.getKey());
  95 + assertEquals(SKU_ID, result.getValue().getSkuId());
  96 + }
  97 +
  98 + @Test
  99 + public void testValidateCombinationRecord_realHeadIdAsJoinGroup() {
  100 + // mock 数据:真实 headId 时,按参团校验父拼团
  101 + CombinationActivityDO activity = mockValidateContext(10);
  102 + combinationRecordMapper.insert(randomRecord(o -> {
  103 + o.setId(HEAD_ID);
  104 + o.setHeadId(CombinationRecordDO.HEAD_ID_GROUP);
  105 + o.setUserId(101L);
  106 + o.setOrderId(601L);
  107 + }));
  108 +
  109 + // 调用
  110 + KeyValue<CombinationActivityDO, CombinationProductDO> result = combinationRecordService
  111 + .validateCombinationRecord(USER_ID, ACTIVITY_ID, HEAD_ID, SKU_ID, 1);
  112 +
  113 + // 断言
  114 + assertSame(activity, result.getKey());
  115 + assertEquals(SKU_ID, result.getValue().getSkuId());
  116 + }
  117 +
  118 + @Test
  119 + public void testValidateCombinationRecord_realHeadIdNotExists() {
  120 + // mock 数据:拼团活动正常,但父拼团不存在
  121 + mockActivity();
  122 +
  123 + // 调用,并断言
  124 + assertServiceException(() -> combinationRecordService.validateCombinationRecord(
  125 + USER_ID, ACTIVITY_ID, HEAD_ID, SKU_ID, 1), COMBINATION_RECORD_HEAD_NOT_EXISTS);
  126 + }
  127 +
  128 + @Test
  129 + public void testValidateCombinationRecord_countEqualsStock() {
  130 + // mock 数据:库存刚好等于购买数量
  131 + mockValidateContext(10);
  132 +
  133 + // 调用
  134 + KeyValue<CombinationActivityDO, CombinationProductDO> result = combinationRecordService
  135 + .validateCombinationRecord(USER_ID, ACTIVITY_ID, null, SKU_ID, 10);
  136 +
  137 + // 断言
  138 + assertNotNull(result);
  139 + }
  140 +
  141 + @Test
  142 + public void testValidateCombinationRecord_countGreaterThanStock() {
  143 + // mock 数据:库存不足
  144 + mockValidateContext(10);
  145 +
  146 + // 调用,并断言
  147 + assertServiceException(() -> combinationRecordService.validateCombinationRecord(
  148 + USER_ID, ACTIVITY_ID, null, SKU_ID, 11), COMBINATION_ACTIVITY_UPDATE_STOCK_FAIL);
  149 + }
  150 +
  151 + @Test
  152 + public void testValidateCombinationRecord_haveJoined() {
  153 + // mock 数据:用户存在进行中的拼团记录
  154 + mockValidateContext(10);
  155 + combinationRecordMapper.insert(randomRecord(o -> {
  156 + o.setStatus(CombinationRecordStatusEnum.IN_PROGRESS.getStatus());
  157 + o.setCount(1);
  158 + }));
  159 +
  160 + // 调用,并断言
  161 + assertServiceException(() -> combinationRecordService.validateCombinationRecord(
  162 + USER_ID, ACTIVITY_ID, null, SKU_ID, 1), COMBINATION_RECORD_FAILED_HAVE_JOINED);
  163 + }
  164 +
  165 + @Test
  166 + public void testValidateCombinationRecord_totalLimitCountExceed() {
  167 + // mock 数据:用户历史购买数量加本次数量超过总限购
  168 + CombinationActivityDO activity = mockValidateContext(10);
  169 + activity.setTotalLimitCount(5);
  170 + combinationRecordMapper.insert(randomRecord(o -> {
  171 + o.setStatus(CombinationRecordStatusEnum.SUCCESS.getStatus());
  172 + o.setCount(3);
  173 + }));
  174 +
  175 + // 调用,并断言
  176 + assertServiceException(() -> combinationRecordService.validateCombinationRecord(
  177 + USER_ID, ACTIVITY_ID, null, SKU_ID, 3), COMBINATION_RECORD_FAILED_TOTAL_LIMIT_COUNT_EXCEED);
  178 + }
  179 +
  180 + @Test
  181 + public void testCreateCombinationRecord_headIdGroupAsNewGroup() {
  182 + // mock 数据:开团参数中 headId 为 0
  183 + mockValidateContext(10);
  184 + mockCreateContext();
  185 + CombinationRecordCreateReqDTO reqDTO = randomCreateReqDTO(CombinationRecordDO.HEAD_ID_GROUP);
  186 +
  187 + // 调用
  188 + CombinationRecordDO record = combinationRecordService.createCombinationRecord(reqDTO);
  189 +
  190 + // 断言:仍然按开团处理,不更新其它团记录
  191 + assertNotNull(record.getId());
  192 + assertEquals(CombinationRecordDO.HEAD_ID_GROUP, record.getHeadId());
  193 + assertEquals(CombinationRecordStatusEnum.IN_PROGRESS.getStatus(), record.getStatus());
  194 + assertEquals(1, record.getUserCount());
  195 + assertFalse(record.getVirtualGroup());
  196 + assertNotNull(record.getStartTime());
  197 + assertNotNull(record.getExpireTime());
  198 + assertEquals(1L, combinationRecordMapper.selectCount());
  199 + assertEquals(CombinationRecordDO.HEAD_ID_GROUP,
  200 + combinationRecordMapper.selectById(record.getId()).getHeadId());
  201 + }
  202 +
  203 + @Test
  204 + public void testHandleExpireRecord_cancelOrders() {
  205 + // mock 数据:过期团长和一个团员
  206 + CombinationRecordDO headRecord = randomRecord(o -> {
  207 + o.setId(HEAD_ID);
  208 + o.setHeadId(CombinationRecordDO.HEAD_ID_GROUP);
  209 + o.setUserId(USER_ID);
  210 + o.setOrderId(ORDER_ID);
  211 + });
  212 + CombinationRecordDO memberRecord = randomRecord(o -> {
  213 + o.setId(501L);
  214 + o.setHeadId(HEAD_ID);
  215 + o.setUserId(101L);
  216 + o.setOrderId(601L);
  217 + });
  218 + combinationRecordMapper.insert(headRecord);
  219 + combinationRecordMapper.insert(memberRecord);
  220 +
  221 + // 调用
  222 + combinationRecordService.handleExpireRecord(headRecord);
  223 +
  224 + // 断言:整团记录标记为失败,并取消已支付订单
  225 + List<CombinationRecordDO> records = combinationRecordMapper.selectList();
  226 + assertEquals(2, records.size());
  227 + assertTrue(records.stream().allMatch(item ->
  228 + CombinationRecordStatusEnum.FAILED.getStatus().equals(item.getStatus())));
  229 + assertTrue(records.stream().allMatch(item -> item.getEndTime() != null));
  230 + verify(tradeOrderApi).cancelPaidOrder(USER_ID, ORDER_ID, TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType());
  231 + verify(tradeOrderApi).cancelPaidOrder(101L, 601L, TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType());
  232 + }
  233 +
  234 + private CombinationActivityDO mockValidateContext(Integer stock) {
  235 + CombinationActivityDO activity = mockActivity();
  236 + CombinationProductDO product = randomPojo(CombinationProductDO.class, o -> {
  237 + o.setActivityId(ACTIVITY_ID);
  238 + o.setSpuId(SPU_ID);
  239 + o.setSkuId(SKU_ID);
  240 + o.setCombinationPrice(100);
  241 + });
  242 + ProductSkuRespDTO sku = randomPojo(ProductSkuRespDTO.class, o -> {
  243 + o.setId(SKU_ID);
  244 + o.setSpuId(SPU_ID);
  245 + o.setStock(stock);
  246 + o.setPicUrl("https://www.iocoder.cn/sku.png");
  247 + });
  248 + when(combinationActivityService.selectByActivityIdAndSkuId(ACTIVITY_ID, SKU_ID)).thenReturn(product);
  249 + when(productSkuApi.getSku(SKU_ID)).thenReturn(sku);
  250 + return activity;
  251 + }
  252 +
  253 + private CombinationActivityDO mockActivity() {
  254 + CombinationActivityDO activity = randomPojo(CombinationActivityDO.class, o -> {
  255 + o.setId(ACTIVITY_ID);
  256 + o.setStatus(CommonStatusEnum.ENABLE.getStatus());
  257 + o.setStartTime(LocalDateTime.now().minusHours(1));
  258 + o.setEndTime(LocalDateTime.now().plusHours(1));
  259 + o.setSingleLimitCount(100);
  260 + o.setTotalLimitCount(100);
  261 + o.setUserSize(3);
  262 + o.setVirtualGroup(false);
  263 + o.setLimitDuration(24);
  264 + });
  265 + when(combinationActivityService.validateCombinationActivityExists(ACTIVITY_ID)).thenReturn(activity);
  266 + return activity;
  267 + }
  268 +
  269 + private void mockCreateContext() {
  270 + MemberUserRespDTO user = randomPojo(MemberUserRespDTO.class, o -> {
  271 + o.setId(USER_ID);
  272 + o.setNickname("芋道源码");
  273 + o.setAvatar("https://www.iocoder.cn/avatar.png");
  274 + });
  275 + when(memberUserApi.getUser(USER_ID)).thenReturn(user);
  276 + ProductSpuRespDTO spu = randomPojo(ProductSpuRespDTO.class, o -> {
  277 + o.setId(SPU_ID);
  278 + o.setName("测试商品");
  279 + o.setPicUrl("https://www.iocoder.cn/spu.png");
  280 + });
  281 + when(productSpuApi.getSpu(SPU_ID)).thenReturn(spu);
  282 + }
  283 +
  284 + private static CombinationRecordCreateReqDTO randomCreateReqDTO(Long headId) {
  285 + return randomPojo(CombinationRecordCreateReqDTO.class, o -> {
  286 + o.setActivityId(ACTIVITY_ID);
  287 + o.setSpuId(SPU_ID);
  288 + o.setSkuId(SKU_ID);
  289 + o.setCount(1);
  290 + o.setOrderId(ORDER_ID);
  291 + o.setUserId(USER_ID);
  292 + o.setHeadId(headId);
  293 + o.setCombinationPrice(100);
  294 + });
  295 + }
  296 +
  297 + @SafeVarargs
  298 + private static CombinationRecordDO randomRecord(Consumer<CombinationRecordDO>... consumers) {
  299 + return randomPojo(CombinationRecordDO.class, o -> {
  300 + o.setActivityId(ACTIVITY_ID);
  301 + o.setCombinationPrice(100);
  302 + o.setSpuId(SPU_ID);
  303 + o.setSpuName("测试商品");
  304 + o.setPicUrl("https://www.iocoder.cn/spu.png");
  305 + o.setSkuId(SKU_ID);
  306 + o.setCount(1);
  307 + o.setUserId(USER_ID);
  308 + o.setNickname("芋道源码");
  309 + o.setAvatar("https://www.iocoder.cn/avatar.png");
  310 + o.setHeadId(CombinationRecordDO.HEAD_ID_GROUP);
  311 + o.setStatus(CombinationRecordStatusEnum.IN_PROGRESS.getStatus());
  312 + o.setOrderId(ORDER_ID);
  313 + o.setUserSize(3);
  314 + o.setUserCount(1);
  315 + o.setVirtualGroup(false);
  316 + o.setStartTime(LocalDateTime.now().minusMinutes(10));
  317 + o.setExpireTime(LocalDateTime.now().plusHours(1));
  318 + o.setEndTime(null);
  319 + for (Consumer<CombinationRecordDO> consumer : consumers) {
  320 + consumer.accept(o);
  321 + }
  322 + });
  323 + }
  324 +
  325 +}
... ...
yudao-module-mall/yudao-module-promotion/src/test/resources/sql/clean.sql
... ... @@ -5,8 +5,9 @@ DELETE FROM &quot;promotion_reward_activity&quot;;
5 5 DELETE FROM "promotion_discount_activity";
6 6 DELETE FROM "promotion_discount_product";
7 7 DELETE FROM "promotion_seckill_config";
  8 +DELETE FROM "promotion_combination_record";
8 9 DELETE FROM "promotion_combination_activity";
9 10 DELETE FROM "promotion_article_category";
10 11 DELETE FROM "promotion_article";
11 12 DELETE FROM "promotion_diy_template";
12   -DELETE FROM "promotion_diy_page";
13 13 \ No newline at end of file
  14 +DELETE FROM "promotion_diy_page";
... ...
yudao-module-mall/yudao-module-promotion/src/test/resources/sql/create_tables.sql
... ... @@ -203,6 +203,36 @@ CREATE TABLE IF NOT EXISTS &quot;promotion_combination_activity&quot;
203 203 PRIMARY KEY ("id")
204 204 ) COMMENT '拼团活动';
205 205  
  206 +CREATE TABLE IF NOT EXISTS "promotion_combination_record"
  207 +(
  208 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  209 + "activity_id" bigint NOT NULL,
  210 + "combination_price" int NOT NULL,
  211 + "spu_id" bigint NOT NULL,
  212 + "spu_name" varchar NOT NULL,
  213 + "pic_url" varchar,
  214 + "sku_id" bigint NOT NULL,
  215 + "count" int NOT NULL,
  216 + "user_id" bigint NOT NULL,
  217 + "nickname" varchar,
  218 + "avatar" varchar,
  219 + "head_id" bigint NOT NULL,
  220 + "status" int NOT NULL,
  221 + "order_id" bigint NOT NULL,
  222 + "user_size" int NOT NULL,
  223 + "user_count" int NOT NULL,
  224 + "virtual_group" bit NOT NULL,
  225 + "expire_time" datetime,
  226 + "start_time" datetime,
  227 + "end_time" datetime,
  228 + "creator" varchar DEFAULT '',
  229 + "create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  230 + "updater" varchar DEFAULT '',
  231 + "update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  232 + "deleted" bit NOT NULL DEFAULT FALSE,
  233 + PRIMARY KEY ("id")
  234 +) COMMENT '拼团记录';
  235 +
206 236 CREATE TABLE IF NOT EXISTS "promotion_article_category"
207 237 (
208 238 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
... ...