mirror of
https://gitee.com/zhijiantianya/ruoyi-vue-pro.git
synced 2026-06-05 18:25:41 +08:00
fix: 修复拼团开团与库存边界校验
- 允许拼团购买数量等于 SKU 库存,仅购买数量大于库存时提示库存不足 - 补充 CombinationRecordServiceImpl 的 DB 单测,覆盖 headId=0 开团、参团、父团不存在、库存边界、重复参团、总限购和过期取消订单等场景 - 补充 promotion_combination_record 单测表结构和清理 SQL 帖子:https://t.zsxq.com/Brapi
This commit is contained in:
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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<CombinationActivityDO, CombinationProductDO> 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<CombinationActivityDO, CombinationProductDO> 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<CombinationActivityDO, CombinationProductDO> 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<CombinationActivityDO, CombinationProductDO> 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<CombinationRecordDO> 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<CombinationRecordDO>... 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<CombinationRecordDO> consumer : consumers) {
|
||||
consumer.accept(o);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@ -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";
|
||||
DELETE FROM "promotion_diy_page";
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user