feat: 实现盲盒功能模块

新增功能:
- 盲盒配置管理:支持盲盒的创建、编辑、上下架
- 盲盒奖池管理:支持奖池配置、Excel导入、权重抽奖、库存管理
- 盲盒购买流程:客户购买盲盒并抽取奖励
- 奖励兑现流程:客户可将盲盒奖励兑现为实际礼物订单
- 店员提成:奖励兑现时自动增加店员礼物提成

核心实现:
- BlindBoxService: 抽奖核心逻辑,支持权重算法和库存扣减
- BlindBoxDispatchService: 奖励兑现订单创建
- BlindBoxInventoryService: 奖池库存管理
- BlindBoxPoolAdminService: 奖池配置管理,支持批量导入

API接口:
- /play/blind-box/config: 盲盒配置CRUD
- /play/blind-box/pool: 奖池配置管理和导入
- /wx/blind-box: 客户端盲盒购买和奖励查询

数据库变更:
- blind_box_config: 盲盒配置表
- blind_box_pool: 盲盒奖池表
- blind_box_reward: 盲盒奖励记录表
- play_order_info: 新增 payment_source 和 source_reward_id 字段

其他改进:
- 订单模块支持盲盒支付来源,区分余额扣款和奖励抵扣
- 优惠券校验:盲盒相关订单不支持使用优惠券
- 完善单元测试覆盖
This commit is contained in:
irving
2025-10-31 02:46:51 -04:00
parent c9439e1021
commit 422e781c60
39 changed files with 2065 additions and 2 deletions

View File

@@ -0,0 +1,43 @@
package com.starry.admin.modules.blindbox.service;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class BlindBoxInventoryServiceTest {
@Mock
private BlindBoxPoolMapper blindBoxPoolMapper;
@InjectMocks
private BlindBoxInventoryService blindBoxInventoryService;
@Test
void shouldReserveStockWhenDraw() {
when(blindBoxPoolMapper.consumeRewardStock(eq("tenant-1"), eq(5L), eq("gift-1"))).thenReturn(1);
blindBoxInventoryService.reserveRewardStock("tenant-1", 5L, "gift-1");
verify(blindBoxPoolMapper).consumeRewardStock("tenant-1", 5L, "gift-1");
}
@Test
void shouldThrowWhenStockInsufficient() {
when(blindBoxPoolMapper.consumeRewardStock(eq("tenant-1"), eq(6L), eq("gift-1"))).thenReturn(0);
CustomException ex = assertThrows(CustomException.class,
() -> blindBoxInventoryService.reserveRewardStock("tenant-1", 6L, "gift-1"));
assertTrue(ex.getMessage().contains("库存不足"));
}
}

View File

@@ -0,0 +1,274 @@
package com.starry.admin.modules.blindbox.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxGiftOption;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolImportRow;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolUpsertRequest;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolView;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.shop.mapper.PlayGiftInfoMapper;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.utils.SecurityUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class BlindBoxPoolAdminServiceTest {
@Mock
private BlindBoxPoolMapper blindBoxPoolMapper;
@Mock
private PlayGiftInfoMapper playGiftInfoMapper;
@Mock
private BlindBoxConfigService blindBoxConfigService;
@InjectMocks
private BlindBoxPoolAdminService blindBoxPoolAdminService;
@BeforeEach
void setupContext() {
SecurityUtils.setTenantId("tenant-1");
}
@Test
void shouldListPoolsWithGiftNames() {
BlindBoxPoolEntity entity = new BlindBoxPoolEntity();
entity.setId(10L);
entity.setTenantId("tenant-1");
entity.setBlindBoxId("blind-1");
entity.setRewardGiftId("gift-2");
entity.setRewardPrice(BigDecimal.valueOf(9.9));
entity.setWeight(50);
entity.setRemainingStock(100);
entity.setValidFrom(LocalDateTime.of(2024, 8, 1, 0, 0));
entity.setStatus(1);
when(blindBoxPoolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(entity));
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setName("幸运盲盒");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
when(playGiftInfoMapper.selectBatchIds(any(Collection.class)))
.thenAnswer(invocation -> Arrays.asList(reward));
List<BlindBoxPoolView> views = blindBoxPoolAdminService.list("blind-1");
assertEquals(1, views.size());
BlindBoxPoolView view = views.get(0);
assertEquals("幸运盲盒", view.getBlindBoxName());
assertEquals("超值娃娃", view.getRewardGiftName());
assertEquals(50, view.getWeight());
}
@Test
void shouldReplacePoolAndInsertRows() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
reward.setType("1");
reward.setPrice(BigDecimal.valueOf(9.9));
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Collections.singletonList(reward));
when(blindBoxPoolMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(1);
AtomicInteger insertCount = new AtomicInteger();
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class)))
.thenAnswer(invocation -> {
insertCount.incrementAndGet();
return 1;
});
BlindBoxPoolImportRow row = new BlindBoxPoolImportRow();
row.setRewardGiftName("超值娃娃");
row.setWeight(80);
row.setRemainingStock(10);
row.setStatus(1);
blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(row));
assertEquals(1, insertCount.get());
ArgumentCaptor<BlindBoxPoolEntity> captor = ArgumentCaptor.forClass(BlindBoxPoolEntity.class);
verify(blindBoxPoolMapper).insert(captor.capture());
BlindBoxPoolEntity saved = captor.getValue();
assertEquals("gift-2", saved.getRewardGiftId());
assertEquals(Integer.valueOf(80), saved.getWeight());
assertEquals(BigDecimal.valueOf(9.9), saved.getRewardPrice());
}
@Test
void shouldThrowWhenRewardGiftMissing() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
BlindBoxPoolImportRow row = new BlindBoxPoolImportRow();
row.setRewardGiftName("未知礼物");
row.setWeight(10);
assertThrows(CustomException.class,
() -> blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(row)));
}
@Test
void shouldThrowForInvalidWeight() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
reward.setType("1");
reward.setPrice(BigDecimal.ONE);
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Collections.singletonList(reward));
BlindBoxPoolImportRow row = new BlindBoxPoolImportRow();
row.setRewardGiftName("超值娃娃");
row.setWeight(0);
assertThrows(CustomException.class,
() -> blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(row)));
}
@Test
void shouldListGiftOptions() {
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-1");
gift.setTenantId("tenant-1");
gift.setHistory("0");
gift.setState("0");
gift.setType("1");
gift.setPrice(BigDecimal.valueOf(19.9));
gift.setName("超值娃娃");
gift.setUrl("https://image");
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(gift));
List<BlindBoxGiftOption> options = blindBoxPoolAdminService.listGiftOptions("");
assertEquals(1, options.size());
assertEquals("gift-1", options.get(0).getId());
assertEquals("超值娃娃", options.get(0).getName());
}
@Test
void shouldCreatePoolEntry() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setName("幸运盲盒");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-2");
gift.setTenantId("tenant-1");
gift.setHistory("0");
gift.setState("0");
gift.setType("1");
gift.setPrice(BigDecimal.valueOf(9.9));
gift.setName("超值娃娃");
when(playGiftInfoMapper.selectById("gift-2")).thenReturn(gift);
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class))).thenAnswer(invocation -> {
BlindBoxPoolEntity entity = invocation.getArgument(0);
entity.setId(100L);
return 1;
});
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
request.setBlindBoxId("blind-1");
request.setRewardGiftId("gift-2");
request.setRewardPrice(BigDecimal.valueOf(12.5));
request.setWeight(80);
request.setRemainingStock(5);
request.setStatus(1);
request.setValidFrom(LocalDateTime.of(2024, 8, 1, 0, 0));
request.setValidTo(LocalDateTime.of(2024, 8, 31, 23, 59, 59));
BlindBoxPoolView view = blindBoxPoolAdminService.create("blind-1", request);
assertEquals("gift-2", view.getRewardGiftId());
assertEquals(Integer.valueOf(80), view.getWeight());
verify(blindBoxPoolMapper).insert(any(BlindBoxPoolEntity.class));
}
@Test
void shouldUpdatePoolEntry() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setName("幸运盲盒");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-3");
gift.setTenantId("tenant-1");
gift.setHistory("0");
gift.setState("0");
gift.setType("1");
gift.setPrice(BigDecimal.valueOf(19.9));
gift.setName("超级公仔");
when(playGiftInfoMapper.selectById("gift-3")).thenReturn(gift);
BlindBoxPoolEntity existing = new BlindBoxPoolEntity();
existing.setId(200L);
existing.setTenantId("tenant-1");
existing.setBlindBoxId("blind-1");
existing.setRewardGiftId("gift-1");
existing.setStatus(1);
when(blindBoxPoolMapper.selectById(200L)).thenReturn(existing);
when(blindBoxPoolMapper.updateById(any(BlindBoxPoolEntity.class))).thenReturn(1);
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
request.setBlindBoxId("blind-1");
request.setRewardGiftId("gift-3");
request.setWeight(60);
request.setRemainingStock(null);
request.setStatus(0);
request.setRewardPrice(null);
request.setValidFrom(LocalDateTime.of(2024, 9, 1, 0, 0));
request.setValidTo(LocalDateTime.of(2024, 9, 30, 23, 59, 59));
BlindBoxPoolView view = blindBoxPoolAdminService.update(200L, request);
assertEquals("gift-3", existing.getRewardGiftId());
assertEquals(Integer.valueOf(60), existing.getWeight());
assertEquals(Integer.valueOf(0), existing.getStatus());
assertEquals("超级公仔", view.getRewardGiftName());
verify(blindBoxPoolMapper).updateById(existing);
}
}

View File

@@ -0,0 +1,191 @@
package com.starry.admin.modules.blindbox.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxRewardStatus;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxCandidate;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class BlindBoxServiceTest {
@Mock
private BlindBoxPoolMapper poolMapper;
@Mock
private BlindBoxRewardMapper rewardMapper;
@Mock
private BlindBoxInventoryService inventoryService;
@Mock
private BlindBoxDispatchService dispatchService;
@Mock
private BlindBoxConfigService configService;
private Clock clock;
private TestRandomAdapter randomAdapter;
private BlindBoxService blindBoxService;
@BeforeEach
void setUp() {
clock = Clock.fixed(Instant.parse("2024-08-01T10:15:30Z"), ZoneOffset.UTC);
randomAdapter = new TestRandomAdapter();
blindBoxService = new BlindBoxService(poolMapper, rewardMapper, inventoryService, dispatchService, configService,
clock, Duration.ofDays(7), randomAdapter);
lenient().when(rewardMapper.insert(any())).thenAnswer(invocation -> {
BlindBoxRewardEntity entity = invocation.getArgument(0);
if (entity.getId() == null) {
entity.setId(UUID.randomUUID().toString());
}
return 1;
});
}
@Test
void shouldCreateRewardRecordWhenOrderCompleted() {
randomAdapter.nextDoubleToReturn = 0.95;
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setPrice(BigDecimal.valueOf(49));
when(configService.requireById("blind-1")).thenReturn(config);
List<BlindBoxCandidate> candidates = Arrays.asList(
BlindBoxCandidate.of(1L, "tenant-1", "blind-1", "gift-low", BigDecimal.valueOf(10), 10, 5),
BlindBoxCandidate.of(2L, "tenant-1", "blind-1", "gift-high", BigDecimal.valueOf(99), 90, 3)
);
when(poolMapper.listActiveEntries(eq("tenant-1"), eq("blind-1"), any())).thenReturn(candidates);
BlindBoxRewardEntity entity = blindBoxService.drawReward("tenant-1", "order-1", "customer-1", "blind-1",
"seed-123");
ArgumentCaptor<BlindBoxRewardEntity> captor = ArgumentCaptor.forClass(BlindBoxRewardEntity.class);
verify(rewardMapper).insert(captor.capture());
BlindBoxRewardEntity persisted = captor.getValue();
assertEquals("gift-high", persisted.getRewardGiftId());
assertEquals(BigDecimal.valueOf(99).setScale(2), persisted.getRewardPrice());
assertEquals(BigDecimal.valueOf(49).setScale(2), persisted.getBoxPrice());
assertEquals(BlindBoxRewardStatus.UNUSED.getCode(), persisted.getStatus());
assertEquals(LocalDateTime.ofInstant(clock.instant(), clock.getZone()).plusDays(7), persisted.getExpiresAt());
verify(inventoryService).reserveRewardStock("tenant-1", 2L, "gift-high");
assertEquals(entity.getRewardGiftId(), persisted.getRewardGiftId());
}
@Test
void shouldHandleUnlimitedStock() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setPrice(BigDecimal.valueOf(29));
when(configService.requireById("blind-1")).thenReturn(config);
List<BlindBoxCandidate> candidates = Collections.singletonList(
BlindBoxCandidate.of(7L, "tenant-1", "blind-1", "gift-unlimited", BigDecimal.valueOf(59), 100, null)
);
when(poolMapper.listActiveEntries(eq("tenant-1"), eq("blind-1"), any())).thenReturn(candidates);
blindBoxService.drawReward("tenant-1", "order-2", "customer-9", "blind-1", "seed-unlimited");
ArgumentCaptor<BlindBoxRewardEntity> captor = ArgumentCaptor.forClass(BlindBoxRewardEntity.class);
verify(rewardMapper).insert(captor.capture());
BlindBoxRewardEntity persisted = captor.getValue();
assertEquals("gift-unlimited", persisted.getRewardGiftId());
assertEquals(BigDecimal.valueOf(29).setScale(2), persisted.getBoxPrice());
assertEquals(BigDecimal.valueOf(59).setScale(2), persisted.getRewardPrice());
assertEquals(BlindBoxRewardStatus.UNUSED.getCode(), persisted.getStatus());
assertNull(persisted.getRewardStockSnapshot());
verify(inventoryService).reserveRewardStock("tenant-1", 7L, "gift-unlimited");
}
@Test
void shouldPreventDoubleDispatch() {
BlindBoxRewardEntity reward = buildRewardEntity();
when(rewardMapper.lockByIdForUpdate("reward-1")).thenReturn(reward);
when(dispatchService.dispatchRewardOrder(eq(reward), eq("clerk-1"))).thenReturn(mock(OrderPlacementResult.class));
when(rewardMapper.markUsed(eq("reward-1"), eq("clerk-1"), any(), any())).thenReturn(1);
blindBoxService.dispatchReward("reward-1", "clerk-1");
reward.setStatus(BlindBoxRewardStatus.USED.getCode());
CustomException ex = assertThrows(CustomException.class, () ->
blindBoxService.dispatchReward("reward-1", "clerk-1"));
assertTrue(ex.getMessage().contains("已使用"));
verify(rewardMapper, times(1)).markUsed(eq("reward-1"), eq("clerk-1"), any(), any());
}
@Test
void shouldRejectExpiredReward() {
BlindBoxRewardEntity reward = buildRewardEntity();
reward.setExpiresAt(LocalDateTime.ofInstant(clock.instant(), clock.getZone()).minusHours(1));
when(rewardMapper.lockByIdForUpdate("reward-1")).thenReturn(reward);
CustomException ex = assertThrows(CustomException.class, () ->
blindBoxService.dispatchReward("reward-1", "clerk-1"));
assertTrue(ex.getMessage().contains("已过期"));
verify(dispatchService, times(0)).dispatchRewardOrder(any(), any());
}
private BlindBoxRewardEntity buildRewardEntity() {
BlindBoxRewardEntity reward = new BlindBoxRewardEntity();
reward.setId("reward-1");
reward.setTenantId("tenant-1");
reward.setCustomerId("customer-1");
reward.setBlindBoxId("blind-1");
reward.setRewardGiftId("gift-high");
reward.setRewardPrice(BigDecimal.valueOf(99));
reward.setBoxPrice(BigDecimal.valueOf(49));
reward.setRewardStockSnapshot(3);
reward.setSeed("seed-123");
reward.setStatus(BlindBoxRewardStatus.UNUSED.getCode());
reward.setCreatedByOrder("order-1");
reward.setCreatedTime(java.sql.Timestamp.from(clock.instant()));
reward.setUpdatedTime(java.sql.Timestamp.from(clock.instant()));
reward.setExpiresAt(LocalDateTime.ofInstant(clock.instant(), clock.getZone()).plusHours(1));
reward.setVersion(0);
return reward;
}
private static class TestRandomAdapter implements BlindBoxService.RandomAdapter {
double nextDoubleToReturn = 0.5;
@Override
public double nextDouble() {
return nextDoubleToReturn;
}
}
}