From 7b6943d39197dc0be6c1055eb6bbf76f39f5bb6f Mon Sep 17 00:00:00 2001 From: irving Date: Mon, 10 Nov 2025 21:59:59 -0500 Subject: [PATCH] fix: allow editing blind box pools referencing inactive gifts --- .../service/BlindBoxPoolAdminService.java | 18 +- .../api/BlindBoxPoolControllerApiTest.java | 257 ++++++++++++++++++ .../admin/api/BlindBoxServiceWeightTest.java | 154 +++++++++-- .../admin/api/WxBlindBoxOrderApiTest.java | 135 +++++++++ .../service/BlindBoxPoolAdminServiceTest.java | 146 ++++++++++ .../blindbox/service/BlindBoxServiceTest.java | 15 + 6 files changed, 698 insertions(+), 27 deletions(-) create mode 100644 play-admin/src/test/java/com/starry/admin/api/BlindBoxPoolControllerApiTest.java diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminService.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminService.java index 8d043d7..9316a1a 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminService.java @@ -162,7 +162,7 @@ public class BlindBoxPoolAdminService { if (!tenantId.equals(config.getTenantId())) { throw new CustomException("盲盒不存在或已被移除"); } - PlayGiftInfoEntity rewardGift = requireRewardGift(tenantId, request.getRewardGiftId()); + PlayGiftInfoEntity rewardGift = requireRewardGiftForUpdate(tenantId, request.getRewardGiftId()); validateTimeRange(request.getValidFrom(), request.getValidTo()); Integer weight = requirePositiveWeight(request.getWeight(), rewardGift.getName()); Integer remainingStock = normalizeRemainingStock(request.getRemainingStock(), rewardGift.getName()); @@ -326,18 +326,30 @@ public class BlindBoxPoolAdminService { } private PlayGiftInfoEntity requireRewardGift(String tenantId, String rewardGiftId) { + return requireRewardGift(tenantId, rewardGiftId, true); + } + + private PlayGiftInfoEntity requireRewardGiftForUpdate(String tenantId, String rewardGiftId) { + return requireRewardGift(tenantId, rewardGiftId, false); + } + + private PlayGiftInfoEntity requireRewardGift(String tenantId, String rewardGiftId, boolean strictAvailability) { if (StrUtil.isBlank(rewardGiftId)) { throw new CustomException("请选择中奖礼物"); } PlayGiftInfoEntity gift = playGiftInfoMapper.selectById(rewardGiftId); if (gift == null || !tenantId.equals(gift.getTenantId()) - || !GiftHistory.CURRENT.getCode().equals(gift.getHistory()) - || !GiftState.ACTIVE.getCode().equals(gift.getState()) || !GiftType.NORMAL.getCode().equals(gift.getType()) || Boolean.TRUE.equals(gift.getDeleted())) { throw new CustomException("中奖礼物不存在或已下架"); } + if (strictAvailability) { + if (!GiftHistory.CURRENT.getCode().equals(gift.getHistory()) + || !GiftState.ACTIVE.getCode().equals(gift.getState())) { + throw new CustomException("中奖礼物不存在或已下架"); + } + } return gift; } diff --git a/play-admin/src/test/java/com/starry/admin/api/BlindBoxPoolControllerApiTest.java b/play-admin/src/test/java/com/starry/admin/api/BlindBoxPoolControllerApiTest.java new file mode 100644 index 0000000..cafd0ab --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/api/BlindBoxPoolControllerApiTest.java @@ -0,0 +1,257 @@ +package com.starry.admin.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper; +import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus; +import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity; +import com.starry.admin.modules.blindbox.service.BlindBoxConfigService; +import com.starry.admin.modules.shop.module.constant.GiftHistory; +import com.starry.admin.modules.shop.module.constant.GiftState; +import com.starry.admin.modules.shop.module.constant.GiftType; +import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity; +import com.starry.admin.modules.shop.service.IPlayGiftInfoService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +class BlindBoxPoolControllerApiTest extends AbstractApiTest { + + private static final String TEST_BLIND_BOX_ID = "blindbox-admin-api"; + private static final DateTimeFormatter DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Autowired + private BlindBoxConfigService blindBoxConfigService; + + @Autowired + private IPlayGiftInfoService giftInfoService; + + @Autowired + private BlindBoxPoolMapper blindBoxPoolMapper; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final List poolIdsToCleanup = new ArrayList<>(); + private final List giftIdsToCleanup = new ArrayList<>(); + + @BeforeEach + void setUp() { + ensureTenantContext(); + ensureBlindBoxConfig(); + } + + @AfterEach + void tearDown() { + ensureTenantContext(); + if (!poolIdsToCleanup.isEmpty()) { + blindBoxPoolMapper.deleteBatchIds(poolIdsToCleanup); + poolIdsToCleanup.clear(); + } + if (!giftIdsToCleanup.isEmpty()) { + giftInfoService.removeByIds(giftIdsToCleanup); + giftIdsToCleanup.clear(); + } + } + + @Test + // 测试用例:校验奖池管理 API 的新增、更新(禁用/修改权重时间库存)、删除以及礼物选项查询功能, + // 确保后台盲盒奖池的所有按钮都能正常调用并持久化。 + void adminCanCreateUpdateToggleAndDeletePoolEntries() throws Exception { + PlayGiftInfoEntity freshlyAddedGift = seedGift("API盲盒新礼物"); + giftIdsToCleanup.add(freshlyAddedGift.getId()); + + mockMvc.perform(get("/play/blind-box/pool/gifts") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .param("keyword", "盲盒新礼物")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data[*].id", hasItem(freshlyAddedGift.getId()))); + + LocalDateTime now = LocalDateTime.now().withNano(0); + long configurableEntryId = createPoolEntryViaApi( + ApiTestDataSeeder.DEFAULT_GIFT_ID, + new BigDecimal("25.88"), + 40, + 8, + BlindBoxPoolStatus.ENABLED.getCode(), + now.minusDays(1), + now.plusDays(5)); + + LocalDateTime updatedFrom = now.minusHours(2); + LocalDateTime updatedTo = now.plusDays(10); + ObjectNode updatePayload = objectMapper.createObjectNode(); + updatePayload.put("blindBoxId", TEST_BLIND_BOX_ID); + updatePayload.put("rewardGiftId", ApiTestDataSeeder.DEFAULT_GIFT_ID); + updatePayload.put("rewardPrice", "35.66"); + updatePayload.put("weight", 75); + updatePayload.put("remainingStock", 3); + updatePayload.put("status", BlindBoxPoolStatus.DISABLED.getCode()); + updatePayload.put("validFrom", updatedFrom.format(DATE_TIME_FORMATTER)); + updatePayload.put("validTo", updatedTo.format(DATE_TIME_FORMATTER)); + + mockMvc.perform(put("/play/blind-box/pool/" + configurableEntryId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content(updatePayload.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value(BlindBoxPoolStatus.DISABLED.getCode())) + .andExpect(jsonPath("$.data.weight").value(75)) + .andExpect(jsonPath("$.data.remainingStock").value(3)); + + ensureTenantContext(); + BlindBoxPoolEntity updated = blindBoxPoolMapper.selectById(configurableEntryId); + assertThat(updated).isNotNull(); + assertThat(updated.getStatus()).isEqualTo(BlindBoxPoolStatus.DISABLED.getCode()); + assertThat(updated.getWeight()).isEqualTo(75); + assertThat(updated.getRemainingStock()).isEqualTo(3); + assertThat(updated.getValidFrom()).isEqualTo(updatedFrom); + assertThat(updated.getValidTo()).isEqualTo(updatedTo); + + long reusableEntryId = createPoolEntryViaApi( + freshlyAddedGift.getId(), + new BigDecimal("18.50"), + 10, + 1, + BlindBoxPoolStatus.ENABLED.getCode(), + now.minusDays(2), + now.plusDays(3)); + + updateGiftState(freshlyAddedGift.getId(), GiftState.OFF_SHELF); + + ObjectNode inactiveUpdate = objectMapper.createObjectNode(); + inactiveUpdate.put("blindBoxId", TEST_BLIND_BOX_ID); + inactiveUpdate.put("rewardGiftId", freshlyAddedGift.getId()); + inactiveUpdate.put("weight", 15); + inactiveUpdate.put("status", BlindBoxPoolStatus.ENABLED.getCode()); + inactiveUpdate.putNull("remainingStock"); + + mockMvc.perform(put("/play/blind-box/pool/" + reusableEntryId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content(inactiveUpdate.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.weight").value(15)); + + mockMvc.perform(delete("/play/blind-box/pool/" + reusableEntryId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + ensureTenantContext(); + BlindBoxPoolEntity deleted = blindBoxPoolMapper.selectById(reusableEntryId); + assertThat(deleted).isNull(); + poolIdsToCleanup.remove(reusableEntryId); + } + + private long createPoolEntryViaApi( + String giftId, + BigDecimal rewardPrice, + int weight, + Integer remainingStock, + int status, + LocalDateTime validFrom, + LocalDateTime validTo) throws Exception { + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("blindBoxId", TEST_BLIND_BOX_ID); + payload.put("rewardGiftId", giftId); + payload.put("rewardPrice", rewardPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()); + payload.put("weight", weight); + if (remainingStock != null) { + payload.put("remainingStock", remainingStock); + } else { + payload.putNull("remainingStock"); + } + payload.put("status", status); + payload.put("validFrom", validFrom.format(DATE_TIME_FORMATTER)); + payload.put("validTo", validTo.format(DATE_TIME_FORMATTER)); + + MvcResult result = mockMvc.perform(post("/play/blind-box/pool") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content(payload.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.id").isNumber()) + .andReturn(); + + JsonNode response = objectMapper.readTree(result.getResponse().getContentAsString()); + long id = response.path("data").path("id").asLong(); + poolIdsToCleanup.add(id); + return id; + } + + private void ensureBlindBoxConfig() { + BlindBoxConfigEntity existing = blindBoxConfigService.getById(TEST_BLIND_BOX_ID); + if (existing != null) { + return; + } + BlindBoxConfigEntity entity = new BlindBoxConfigEntity(); + entity.setId(TEST_BLIND_BOX_ID); + entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entity.setName("API盲盒(Admin)"); + entity.setPrice(new BigDecimal("19.90")); + entity.setStatus(BlindBoxConfigStatus.ENABLED.getCode()); + blindBoxConfigService.save(entity); + } + + private PlayGiftInfoEntity seedGift(String name) { + PlayGiftInfoEntity gift = new PlayGiftInfoEntity(); + gift.setId("gift-admin-" + IdUtils.getUuid().substring(0, 12)); + gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + gift.setHistory(GiftHistory.CURRENT.getCode()); + gift.setName(name); + gift.setType(GiftType.NORMAL.getCode()); + gift.setUrl("https://example.com/assets/" + gift.getId() + ".png"); + gift.setPrice(new BigDecimal("58.80")); + gift.setUnit("CNY"); + gift.setState(GiftState.ACTIVE.getCode()); + gift.setListingTime(LocalDateTime.now().minusDays(1)); + gift.setRemark("Seeded for blind box pool admin test"); + giftInfoService.save(gift); + return gift; + } + + private void updateGiftState(String giftId, GiftState state) { + ensureTenantContext(); + PlayGiftInfoEntity gift = giftInfoService.getById(giftId); + if (gift != null) { + gift.setState(state.getCode()); + giftInfoService.updateById(gift); + } + } + + protected void ensureTenantContext() { + SecurityUtils.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java b/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java index 1874685..6b8d16d 100644 --- a/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/BlindBoxServiceWeightTest.java @@ -7,6 +7,7 @@ import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper; import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper; import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus; import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus; +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.BlindBoxPoolEntity; import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity; @@ -34,6 +35,11 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport { private static final String TEST_BLIND_BOX_ID = "blindbox-apitest"; private static final String PRIMARY_GIFT_ID = ApiTestDataSeeder.DEFAULT_GIFT_ID; private static final String SECONDARY_GIFT_ID = "gift-blindbox-secondary"; + private static final String MIXED_GIFT_A_ID = "gift-blindbox-mixed-a"; + private static final String MIXED_GIFT_B_ID = "gift-blindbox-mixed-b"; + private static final String MIXED_GIFT_C_ID = "gift-blindbox-mixed-c"; + private static final String MIXED_GIFT_D_ID = "gift-blindbox-mixed-d"; + private static final String MIXED_GIFT_E_ID = "gift-blindbox-mixed-e"; private static final int DRAW_ATTEMPT_COUNT = 1_000; private static final int PRIMARY_WEIGHT = 80; private static final int SECONDARY_WEIGHT = 20; @@ -41,6 +47,11 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport { private static final double PRIMARY_RATIO_MAX = 0.88; private static final double SECONDARY_RATIO_MIN = 0.12; private static final double SECONDARY_RATIO_MAX = 0.32; + private static final int MIXED_TOTAL_DRAWS = 400; + private static final int MIXED_GIFT_A_STOCK = 10; + private static final int MIXED_GIFT_B_STOCK = 5; + private static final int MIXED_GIFT_C_STOCK = 0; + private static final int MIXED_GIFT_E_STOCK = 2; @Autowired private BlindBoxService blindBoxService; @@ -112,6 +123,66 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport { purgePool(); } + @Test + // 测试用例:混合 5 种礼物(部分限量、部分不限量),验证限量礼物被抽满后不再出现, + // 不限量礼物可继续抽取,且库存为 0 的礼物永远不会返回。 + void blindBoxDrawHandlesMixedInventory() { + ensureTenantContext(); + ensureBlindBoxConfig(); + ensureMixedGifts(); + resetCustomerBalance(); + + purgeRewards(); + purgePool(); + insertPoolEntry(MIXED_GIFT_A_ID, 40, MIXED_GIFT_A_STOCK); + insertPoolEntry(MIXED_GIFT_B_ID, 25, MIXED_GIFT_B_STOCK); + insertPoolEntry(MIXED_GIFT_C_ID, 15, MIXED_GIFT_C_STOCK); + insertPoolEntry(MIXED_GIFT_D_ID, 10, null); + insertPoolEntry(MIXED_GIFT_E_ID, 10, MIXED_GIFT_E_STOCK); + + Map frequency = new HashMap<>(); + for (int i = 0; i < MIXED_TOTAL_DRAWS; i++) { + BlindBoxRewardEntity reward = blindBoxService.drawReward( + ApiTestDataSeeder.DEFAULT_TENANT_ID, + "mixed-order-" + i, + ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, + TEST_BLIND_BOX_ID, + "mixed-seed-" + i); + frequency.merge(reward.getRewardGiftId(), 1, Integer::sum); + } + + Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_C_ID, 0)) + .as("库存为 0 的礼物永远不应被抽中") + .isEqualTo(0); + Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_A_ID, 0)) + .as("限量礼物 A 应被精确抽完") + .isEqualTo(MIXED_GIFT_A_STOCK); + Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_B_ID, 0)) + .as("限量礼物 B 应被精确抽完") + .isEqualTo(MIXED_GIFT_B_STOCK); + Assertions.assertThat(frequency.getOrDefault(MIXED_GIFT_E_ID, 0)) + .as("限量礼物 E 应被精确抽完") + .isEqualTo(MIXED_GIFT_E_STOCK); + + int unlimitedCount = frequency.getOrDefault(MIXED_GIFT_D_ID, 0); + int finiteTotal = MIXED_GIFT_A_STOCK + MIXED_GIFT_B_STOCK + MIXED_GIFT_E_STOCK; + Assertions.assertThat(unlimitedCount) + .as("不限量礼物承担剩余抽奖次数") + .isEqualTo(MIXED_TOTAL_DRAWS - finiteTotal) + .isGreaterThan(0); + + // 抽完后,奖池中仅剩不限量礼物 + java.util.List remaining = blindBoxPoolMapper.listActiveEntries( + ApiTestDataSeeder.DEFAULT_TENANT_ID, TEST_BLIND_BOX_ID, LocalDateTime.now()); + Assertions.assertThat(remaining) + .as("只剩不限量礼物可用") + .hasSize(1); + Assertions.assertThat(remaining.get(0).getRewardGiftId()).isEqualTo(MIXED_GIFT_D_ID); + + purgeRewards(); + purgePool(); + } + private void ensureBlindBoxConfig() { BlindBoxConfigEntity config = blindBoxConfigService.getById(TEST_BLIND_BOX_ID); if (config != null) { @@ -129,46 +200,81 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport { } private void ensureSecondaryGift() { - PlayGiftInfoEntity existing = findGift(SECONDARY_GIFT_ID); - if (existing != null) { - return; - } - PlayGiftInfoEntity entity = new PlayGiftInfoEntity(); - entity.setId(SECONDARY_GIFT_ID); - entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); - entity.setHistory(GiftHistory.CURRENT.getCode()); - entity.setName("API盲盒奖励"); - entity.setType(GiftType.NORMAL.getCode()); - entity.setUrl("https://example.com/apitest/blindbox.png"); - entity.setPrice(new BigDecimal("9.99")); - entity.setUnit("CNY"); - entity.setState(GiftState.ACTIVE.getCode()); - entity.setListingTime(LocalDateTime.now()); - entity.setRemark("Seeded secondary gift for blind box tests"); - giftInfoService.save(entity); + ensureGift( + SECONDARY_GIFT_ID, + "API盲盒奖励", + new BigDecimal("9.99"), + "https://example.com/apitest/blindbox.png", + "Seeded secondary gift for blind box tests"); } private void ensurePrimaryGift() { - PlayGiftInfoEntity existing = findGift(PRIMARY_GIFT_ID); + ensureGift( + PRIMARY_GIFT_ID, + ApiTestDataSeeder.DEFAULT_GIFT_NAME, + new BigDecimal("15.00"), + "https://example.com/apitest/gift-basic.png", + "Seeded default gift for blind box tests"); + } + + private void ensureMixedGifts() { + ensureGift( + MIXED_GIFT_A_ID, + "API盲盒混合A", + new BigDecimal("11.11"), + "https://example.com/apitest/mixed-a.png", + "Mixed blind box gift A"); + ensureGift( + MIXED_GIFT_B_ID, + "API盲盒混合B", + new BigDecimal("22.22"), + "https://example.com/apitest/mixed-b.png", + "Mixed blind box gift B"); + ensureGift( + MIXED_GIFT_C_ID, + "API盲盒混合C", + new BigDecimal("33.33"), + "https://example.com/apitest/mixed-c.png", + "Mixed blind box gift C"); + ensureGift( + MIXED_GIFT_D_ID, + "API盲盒混合D", + new BigDecimal("44.44"), + "https://example.com/apitest/mixed-d.png", + "Mixed blind box gift D"); + ensureGift( + MIXED_GIFT_E_ID, + "API盲盒混合E", + new BigDecimal("55.55"), + "https://example.com/apitest/mixed-e.png", + "Mixed blind box gift E"); + } + + private void ensureGift(String giftId, String name, BigDecimal price, String imageUrl, String remark) { + PlayGiftInfoEntity existing = findGift(giftId); if (existing != null) { return; } PlayGiftInfoEntity entity = new PlayGiftInfoEntity(); - entity.setId(PRIMARY_GIFT_ID); + entity.setId(giftId); entity.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); entity.setHistory(GiftHistory.CURRENT.getCode()); - entity.setName(ApiTestDataSeeder.DEFAULT_GIFT_NAME); + entity.setName(name); entity.setType(GiftType.NORMAL.getCode()); - entity.setUrl("https://example.com/apitest/gift-basic.png"); - entity.setPrice(new BigDecimal("15.00")); + entity.setUrl(imageUrl); + entity.setPrice(price); entity.setUnit("CNY"); entity.setState(GiftState.ACTIVE.getCode()); entity.setListingTime(LocalDateTime.now()); - entity.setRemark("Seeded default gift for blind box tests"); + entity.setRemark(remark); giftInfoService.save(entity); } private void insertPoolEntry(String giftId, int weight) { + insertPoolEntry(giftId, weight, null); + } + + private void insertPoolEntry(String giftId, int weight, Integer remainingStock) { PlayGiftInfoEntity gift = findGift(giftId); if (gift == null) { throw new IllegalStateException("Expected gift to be seeded: " + giftId); @@ -179,7 +285,7 @@ class BlindBoxServiceWeightTest extends WxCustomOrderApiTestSupport { entry.setRewardGiftId(giftId); entry.setRewardPrice(gift.getPrice()); entry.setWeight(weight); - entry.setRemainingStock(null); + entry.setRemainingStock(remainingStock); entry.setValidFrom(null); entry.setValidTo(null); entry.setStatus(BlindBoxPoolStatus.ENABLED.getCode()); diff --git a/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java b/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java index 5fc2a7a..a6c03e4 100644 --- a/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java +++ b/play-admin/src/test/java/com/starry/admin/api/WxBlindBoxOrderApiTest.java @@ -4,17 +4,32 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.JsonNode; import com.starry.admin.common.apitest.ApiTestDataSeeder; +import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper; +import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper; import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity; import com.starry.admin.modules.blindbox.service.BlindBoxConfigService; import com.starry.admin.modules.order.module.constant.OrderConstant; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.shop.module.constant.GiftHistory; +import com.starry.admin.modules.shop.module.constant.GiftState; +import com.starry.admin.modules.shop.module.constant.GiftType; +import com.starry.admin.modules.shop.module.entity.PlayClerkGiftInfoEntity; +import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity; +import com.starry.admin.modules.shop.service.IPlayClerkGiftInfoService; +import com.starry.admin.modules.shop.service.IPlayGiftInfoService; import com.starry.admin.utils.SecurityUtils; import com.starry.common.constant.Constants; import com.starry.common.context.CustomSecurityContextHolder; import com.starry.common.utils.IdUtils; import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; import org.assertj.core.api.Assertions; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -23,6 +38,14 @@ class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport { @Autowired private BlindBoxConfigService blindBoxConfigService; + @Autowired + private BlindBoxPoolMapper blindBoxPoolMapper; + @Autowired + private IPlayGiftInfoService giftInfoService; + @Autowired + private BlindBoxRewardMapper blindBoxRewardMapper; + @Autowired + private IPlayClerkGiftInfoService clerkGiftInfoService; @Test void blindBoxPurchaseFailsWhenBalanceInsufficient() throws Exception { @@ -73,4 +96,116 @@ class WxBlindBoxOrderApiTest extends WxCustomOrderApiTestSupport { CustomSecurityContextHolder.remove(); } } + + @Test + void blindBoxPurchaseAndDispatchSucceedWhenGiftInactive() throws Exception { + String configId = "blind-inactive-" + IdUtils.getUuid().substring(0, 6); + String giftId = "gift-inactive-" + IdUtils.getUuid().substring(0, 6); + Long poolId = null; + String rewardId = null; + try { + ensureTenantContext(); + BlindBoxConfigEntity config = new BlindBoxConfigEntity(); + config.setId(configId); + config.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + config.setName("下架礼物测试盲盒"); + config.setPrice(new BigDecimal("19.90")); + config.setStatus(1); + blindBoxConfigService.save(config); + + PlayGiftInfoEntity gift = new PlayGiftInfoEntity(); + gift.setId(giftId); + gift.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + gift.setName("盲盒下架礼物"); + gift.setHistory(GiftHistory.CURRENT.getCode()); + gift.setState(GiftState.ACTIVE.getCode()); + gift.setType(GiftType.NORMAL.getCode()); + gift.setUrl("https://example.com/apitest/blindbox-off.png"); + gift.setPrice(new BigDecimal("9.99")); + giftInfoService.save(gift); + + BlindBoxPoolEntity entry = new BlindBoxPoolEntity(); + entry.setTenantId(ApiTestDataSeeder.DEFAULT_TENANT_ID); + entry.setBlindBoxId(configId); + entry.setRewardGiftId(giftId); + entry.setRewardPrice(gift.getPrice()); + entry.setWeight(100); + entry.setRemainingStock(1); + entry.setStatus(1); + entry.setValidFrom(LocalDateTime.now().minusDays(1)); + entry.setValidTo(LocalDateTime.now().plusDays(1)); + blindBoxPoolMapper.insert(entry); + poolId = entry.getId(); + + gift.setState(GiftState.OFF_SHELF.getCode()); + giftInfoService.updateById(gift); + + resetCustomerBalance(); + String customerToken = wxTokenService.createWxUserToken(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID); + customUserInfoService.updateTokenById(ApiTestDataSeeder.DEFAULT_CUSTOMER_ID, customerToken); + + String payload = objectMapper.createObjectNode() + .put("blindBoxId", configId) + .put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID) + .put("weiChatCode", "apitest-customer-wx") + .toString(); + + MvcResult purchaseResult = mockMvc.perform(post("/wx/blind-box/order/purchase") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.reward.rewardId").isString()) + .andReturn(); + + JsonNode rewardNode = objectMapper.readTree(purchaseResult.getResponse().getContentAsString()) + .path("data").path("reward"); + rewardId = rewardNode.path("rewardId").asText(); + Assertions.assertThat(rewardId).isNotBlank(); + + SoftAssertions softly = new SoftAssertions(); + softly.assertThat(rewardNode.path("rewardGiftId").asText()).isEqualTo(giftId); + softly.assertThat(rewardNode.path("status").asText()).isEqualTo("UNUSED"); + softly.assertAll(); + + PlayClerkGiftInfoEntity before = clerkGiftInfoService.selectByGiftIdAndClerkId(giftId, ApiTestDataSeeder.DEFAULT_CLERK_ID); + long beforeCount = before != null && before.getGiffNumber() != null ? before.getGiffNumber() : 0L; + + mockMvc.perform(post("/wx/blind-box/reward/" + rewardId + "/dispatch") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT) + .header(Constants.CUSTOM_USER_LOGIN_TOKEN, Constants.TOKEN_PREFIX + customerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.createObjectNode() + .put("clerkId", ApiTestDataSeeder.DEFAULT_CLERK_ID) + .toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.status").value("USED")); + + ensureTenantContext(); + BlindBoxRewardEntity storedReward = blindBoxRewardMapper.selectById(rewardId); + Assertions.assertThat(storedReward).isNotNull(); + Assertions.assertThat(storedReward.getStatus()).isEqualTo("USED"); + Assertions.assertThat(storedReward.getUsedClerkId()).isEqualTo(ApiTestDataSeeder.DEFAULT_CLERK_ID); + + PlayClerkGiftInfoEntity after = clerkGiftInfoService.selectByGiftIdAndClerkId(giftId, ApiTestDataSeeder.DEFAULT_CLERK_ID); + long afterCount = after != null && after.getGiffNumber() != null ? after.getGiffNumber() : 0L; + Assertions.assertThat(afterCount).isEqualTo(beforeCount + 1); + } finally { + ensureTenantContext(); + if (Objects.nonNull(poolId)) { + blindBoxPoolMapper.deleteById(poolId); + } + blindBoxConfigService.removeById(configId); + giftInfoService.removeById(giftId); + if (rewardId != null) { + blindBoxRewardMapper.deleteById(rewardId); + } + CustomSecurityContextHolder.remove(); + } + } } diff --git a/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminServiceTest.java index 035c677..a648335 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminServiceTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminServiceTest.java @@ -1,8 +1,11 @@ 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.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -20,6 +23,7 @@ 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.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -185,6 +189,84 @@ class BlindBoxPoolAdminServiceTest { assertEquals("超值娃娃", options.get(0).getName()); } + @Test + void shouldOverwriteRemainingStockOnReimport() { + 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); + + List inserted = new ArrayList<>(); + when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class))).thenAnswer(invocation -> { + BlindBoxPoolEntity entity = invocation.getArgument(0); + BlindBoxPoolEntity snapshot = new BlindBoxPoolEntity(); + snapshot.setBlindBoxId(entity.getBlindBoxId()); + snapshot.setRewardGiftId(entity.getRewardGiftId()); + snapshot.setRemainingStock(entity.getRemainingStock()); + inserted.add(snapshot); + return 1; + }); + + BlindBoxPoolImportRow first = new BlindBoxPoolImportRow(); + first.setRewardGiftName("超值娃娃"); + first.setWeight(50); + first.setRemainingStock(5); + first.setStatus(1); + + BlindBoxPoolImportRow second = new BlindBoxPoolImportRow(); + second.setRewardGiftName("超值娃娃"); + second.setWeight(60); + second.setRemainingStock(1); + second.setStatus(1); + + blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(first)); + blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(second)); + + assertEquals(2, inserted.size()); + assertEquals(Integer.valueOf(5), inserted.get(0).getRemainingStock()); + assertEquals(Integer.valueOf(1), inserted.get(1).getRemainingStock()); + } + + @Test + void shouldKeepUnlimitedStockWhenRemainingStockBlank() { + 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); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BlindBoxPoolEntity.class); + when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class))).thenReturn(1); + + BlindBoxPoolImportRow importRow = new BlindBoxPoolImportRow(); + importRow.setRewardGiftName("超值娃娃"); + importRow.setWeight(30); + importRow.setStatus(1); + + blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(importRow)); + + verify(blindBoxPoolMapper).insert(captor.capture()); + BlindBoxPoolEntity saved = captor.getValue(); + assertNull(saved.getRemainingStock()); + } + @Test void shouldCreatePoolEntry() { BlindBoxConfigEntity config = new BlindBoxConfigEntity(); @@ -271,4 +353,68 @@ class BlindBoxPoolAdminServiceTest { assertEquals("超级公仔", view.getRewardGiftName()); verify(blindBoxPoolMapper).updateById(existing); } + + @Test + void shouldAllowUpdateWhenGiftInactiveOrHistorical() { + BlindBoxConfigEntity config = new BlindBoxConfigEntity(); + config.setId("blind-1"); + config.setTenantId("tenant-1"); + when(blindBoxConfigService.requireById("blind-1")).thenReturn(config); + + PlayGiftInfoEntity inactiveGift = new PlayGiftInfoEntity(); + inactiveGift.setId("gift-offline"); + inactiveGift.setTenantId("tenant-1"); + inactiveGift.setHistory("1"); + inactiveGift.setState("1"); + inactiveGift.setType("1"); + inactiveGift.setPrice(BigDecimal.valueOf(66.6)); + inactiveGift.setName("下架礼物"); + when(playGiftInfoMapper.selectById("gift-offline")).thenReturn(inactiveGift); + + BlindBoxPoolEntity existing = new BlindBoxPoolEntity(); + existing.setId(500L); + existing.setTenantId("tenant-1"); + existing.setBlindBoxId("blind-1"); + existing.setRewardGiftId("gift-on"); + existing.setStatus(1); + when(blindBoxPoolMapper.selectById(500L)).thenReturn(existing); + when(blindBoxPoolMapper.updateById(existing)).thenReturn(1); + + BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest(); + request.setBlindBoxId("blind-1"); + request.setRewardGiftId("gift-offline"); + request.setWeight(10); + request.setStatus(1); + + blindBoxPoolAdminService.update(500L, request); + + verify(blindBoxPoolMapper).updateById(existing); + assertEquals("gift-offline", existing.getRewardGiftId()); + } + + @Test + void shouldRejectCreateWhenGiftInactive() { + BlindBoxConfigEntity config = new BlindBoxConfigEntity(); + config.setId("blind-1"); + config.setTenantId("tenant-1"); + when(blindBoxConfigService.requireById("blind-1")).thenReturn(config); + + PlayGiftInfoEntity inactiveGift = new PlayGiftInfoEntity(); + inactiveGift.setId("gift-off"); + inactiveGift.setTenantId("tenant-1"); + inactiveGift.setHistory("1"); + inactiveGift.setState("1"); + inactiveGift.setType("1"); + when(playGiftInfoMapper.selectById("gift-off")).thenReturn(inactiveGift); + + BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest(); + request.setBlindBoxId("blind-1"); + request.setRewardGiftId("gift-off"); + request.setWeight(10); + + CustomException ex = assertThrows(CustomException.class, + () -> blindBoxPoolAdminService.create("blind-1", request)); + assertTrue(ex.getMessage().contains("中奖礼物不存在或已下架")); + verify(blindBoxPoolMapper, times(0)).insert(any()); + } } diff --git a/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxServiceTest.java index fed5264..e7af83d 100644 --- a/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxServiceTest.java +++ b/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxServiceTest.java @@ -171,6 +171,21 @@ class BlindBoxServiceTest { verify(rewardMapper, times(0)).markUsed(any(), any(), any(), any()); } + @Test + void shouldRejectWhenNoEligibleRewardExists() { + BlindBoxConfigEntity config = new BlindBoxConfigEntity(); + config.setId("blind-1"); + config.setTenantId("tenant-1"); + when(configService.requireById("blind-1")).thenReturn(config); + when(poolMapper.listActiveEntries(eq("tenant-1"), eq("blind-1"), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + CustomException ex = assertThrows(CustomException.class, + () -> blindBoxService.drawReward("tenant-1", "order-404", "customer-9", "blind-1", "seed-out")); + assertTrue(ex.getMessage().contains("奖池暂无可用奖励")); + verify(inventoryService, times(0)).reserveRewardStock(any(), any(), any()); + } + private BlindBoxRewardEntity buildRewardEntity() { BlindBoxRewardEntity reward = new BlindBoxRewardEntity(); reward.setId("reward-1");