diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/controller/BlindBoxConfigController.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/controller/BlindBoxConfigController.java new file mode 100644 index 0000000..4127cfa --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/controller/BlindBoxConfigController.java @@ -0,0 +1,108 @@ +package com.starry.admin.modules.blindbox.controller; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity; +import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus; +import com.starry.admin.modules.blindbox.service.BlindBoxConfigService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.result.R; +import com.starry.common.utils.IdUtils; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import java.math.BigDecimal; +import java.util.List; +import javax.annotation.Resource; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Api(tags = "盲盒配置管理") +@RestController +@RequestMapping("/play/blind-box/config") +public class BlindBoxConfigController { + + @Resource + private BlindBoxConfigService blindBoxConfigService; + + @ApiOperation("查询盲盒列表") + @GetMapping + public R list(@RequestParam(required = false) Integer status) { + String tenantId = SecurityUtils.getTenantId(); + LambdaQueryWrapper query = Wrappers.lambdaQuery(BlindBoxConfigEntity.class) + .eq(BlindBoxConfigEntity::getTenantId, tenantId) + .eq(BlindBoxConfigEntity::getDeleted, Boolean.FALSE) + .orderByDesc(BlindBoxConfigEntity::getCreatedTime); + if (status != null) { + query.eq(BlindBoxConfigEntity::getStatus, status); + } + List configs = blindBoxConfigService.list(query); + return R.ok(configs); + } + + @ApiOperation("盲盒详情") + @GetMapping("/{id}") + public R detail(@PathVariable String id) { + BlindBoxConfigEntity entity = blindBoxConfigService.requireById(id); + return R.ok(entity); + } + + @ApiOperation("新增盲盒") + @PostMapping + public R create(@RequestBody BlindBoxConfigEntity body) { + if (StrUtil.isBlank(body.getName())) { + throw new CustomException("盲盒名称不能为空"); + } + validatePrice(body.getPrice()); + body.setId(IdUtils.getUuid()); + body.setTenantId(SecurityUtils.getTenantId()); + body.setDeleted(Boolean.FALSE); + if (body.getStatus() == null) { + body.setStatus(BlindBoxConfigStatus.ENABLED.getCode()); + } + blindBoxConfigService.save(body); + return R.ok(body.getId()); + } + + @ApiOperation("更新盲盒") + @PutMapping("/{id}") + public R update(@PathVariable String id, @RequestBody BlindBoxConfigEntity body) { + validatePrice(body.getPrice()); + BlindBoxConfigEntity existing = blindBoxConfigService.requireById(id); + if (!SecurityUtils.getTenantId().equals(existing.getTenantId())) { + throw new CustomException("无权操作该盲盒"); + } + existing.setName(body.getName()); + existing.setCoverUrl(body.getCoverUrl()); + existing.setDescription(body.getDescription()); + existing.setPrice(body.getPrice()); + existing.setStatus(body.getStatus()); + blindBoxConfigService.updateById(existing); + return R.ok(); + } + + @ApiOperation("删除盲盒") + @DeleteMapping("/{id}") + public R delete(@PathVariable String id) { + BlindBoxConfigEntity existing = blindBoxConfigService.requireById(id); + if (!SecurityUtils.getTenantId().equals(existing.getTenantId())) { + throw new CustomException("无权操作该盲盒"); + } + blindBoxConfigService.removeById(id); + return R.ok(); + } + + private void validatePrice(BigDecimal price) { + if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) { + throw new CustomException("盲盒价格必须大于0"); + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/controller/BlindBoxPoolController.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/controller/BlindBoxPoolController.java new file mode 100644 index 0000000..8e0fcfd --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/controller/BlindBoxPoolController.java @@ -0,0 +1,77 @@ +package com.starry.admin.modules.blindbox.controller; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolImportRow; +import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolUpsertRequest; +import com.starry.admin.modules.blindbox.service.BlindBoxPoolAdminService; +import com.starry.admin.utils.ExcelUtils; +import com.starry.common.result.R; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import java.io.IOException; +import java.util.List; +import javax.annotation.Resource; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Api(tags = "盲盒奖池管理") +@RestController +@RequestMapping("/play/blind-box/pool") +public class BlindBoxPoolController { + + @Resource + private BlindBoxPoolAdminService blindBoxPoolAdminService; + + @ApiOperation("查询盲盒奖池列表") + @GetMapping + public R list(@RequestParam String blindBoxId) { + return R.ok(blindBoxPoolAdminService.list(blindBoxId)); + } + + @ApiOperation("导入盲盒奖池配置") + @PostMapping("/{blindBoxId}/import") + public R importPool(@PathVariable String blindBoxId, @RequestParam("file") MultipartFile file) + throws IOException { + if (file == null || file.isEmpty()) { + throw new CustomException("上传文件不能为空"); + } + List rows = ExcelUtils.importEasyExcel(file.getInputStream(), BlindBoxPoolImportRow.class); + @SuppressWarnings("unchecked") + List importRows = (List) rows; + blindBoxPoolAdminService.replacePool(blindBoxId, importRows); + return R.ok(); + } + + @ApiOperation("删除盲盒奖池项") + @DeleteMapping("/{id}") + public R remove(@PathVariable Long id) { + blindBoxPoolAdminService.removeById(id); + return R.ok(); + } + + @ApiOperation("新增盲盒奖池项") + @PostMapping + public R create(@RequestBody BlindBoxPoolUpsertRequest body) { + return R.ok(blindBoxPoolAdminService.create(body != null ? body.getBlindBoxId() : null, body)); + } + + @ApiOperation("更新盲盒奖池项") + @PutMapping("/{id}") + public R update(@PathVariable Long id, @RequestBody BlindBoxPoolUpsertRequest body) { + return R.ok(blindBoxPoolAdminService.update(id, body)); + } + + @ApiOperation("查询可用中奖礼物") + @GetMapping("/gifts") + public R giftOptions(@RequestParam(required = false) String keyword) { + return R.ok(blindBoxPoolAdminService.listGiftOptions(keyword)); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/mapper/BlindBoxConfigMapper.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/mapper/BlindBoxConfigMapper.java new file mode 100644 index 0000000..b2292d1 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/mapper/BlindBoxConfigMapper.java @@ -0,0 +1,9 @@ +package com.starry.admin.modules.blindbox.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface BlindBoxConfigMapper extends BaseMapper { +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/mapper/BlindBoxPoolMapper.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/mapper/BlindBoxPoolMapper.java new file mode 100644 index 0000000..e21a2d8 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/mapper/BlindBoxPoolMapper.java @@ -0,0 +1,54 @@ +package com.starry.admin.modules.blindbox.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.blindbox.module.dto.BlindBoxCandidate; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity; +import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface BlindBoxPoolMapper extends BaseMapper { + + @Select({ + "SELECT id AS poolId,", + " tenant_id AS tenantId,", + " blind_box_gift_id AS blindBoxId,", + " reward_gift_id AS rewardGiftId,", + " reward_price AS rewardPrice,", + " weight,", + " remaining_stock AS remainingStock", + "FROM blind_box_pool", + "WHERE tenant_id = #{tenantId}", + " AND blind_box_gift_id = #{blindBoxId}", + " AND status = #{enabledStatus}", + " AND (valid_from IS NULL OR valid_from <= #{now})", + " AND (valid_to IS NULL OR valid_to >= #{now})", + " AND (remaining_stock IS NULL OR remaining_stock > 0)" + }) + List listEntries( + @Param("tenantId") String tenantId, + @Param("blindBoxId") String blindBoxId, + @Param("now") LocalDateTime now, + @Param("enabledStatus") int enabledStatus); + + @Update({ + "UPDATE blind_box_pool", + "SET remaining_stock = CASE", + " WHEN remaining_stock IS NULL THEN NULL", + " ELSE remaining_stock - 1", + " END", + "WHERE tenant_id = #{tenantId}", + " AND id = #{poolId}", + " AND reward_gift_id = #{rewardGiftId}", + " AND (remaining_stock IS NULL OR remaining_stock > 0)" + }) + int consumeRewardStock( + @Param("tenantId") String tenantId, + @Param("poolId") Long poolId, + @Param("rewardGiftId") String rewardGiftId); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/mapper/BlindBoxRewardMapper.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/mapper/BlindBoxRewardMapper.java new file mode 100644 index 0000000..a4e547b --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/mapper/BlindBoxRewardMapper.java @@ -0,0 +1,32 @@ +package com.starry.admin.modules.blindbox.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity; +import java.time.LocalDateTime; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface BlindBoxRewardMapper extends BaseMapper { + + @Select("SELECT * FROM blind_box_reward WHERE id = #{id} FOR UPDATE") + BlindBoxRewardEntity lockByIdForUpdate(@Param("id") String id); + + @Update({ + "UPDATE blind_box_reward", + "SET status = 'USED',", + " used_order_id = #{orderId},", + " used_clerk_id = #{clerkId},", + " used_time = #{usedTime},", + " version = version + 1", + "WHERE id = #{id}", + " AND status = 'UNUSED'" + }) + int markUsed( + @Param("id") String id, + @Param("clerkId") String clerkId, + @Param("orderId") String orderId, + @Param("usedTime") LocalDateTime usedTime); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/constant/BlindBoxConfigStatus.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/constant/BlindBoxConfigStatus.java new file mode 100644 index 0000000..99b72d2 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/constant/BlindBoxConfigStatus.java @@ -0,0 +1,19 @@ +package com.starry.admin.modules.blindbox.module.constant; + +/** + * 盲盒(配置)上下架状态。 + */ +public enum BlindBoxConfigStatus { + ENABLED(1), + DISABLED(0); + + private final int code; + + BlindBoxConfigStatus(int code) { + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/constant/BlindBoxPoolStatus.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/constant/BlindBoxPoolStatus.java new file mode 100644 index 0000000..8cc165d --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/constant/BlindBoxPoolStatus.java @@ -0,0 +1,19 @@ +package com.starry.admin.modules.blindbox.module.constant; + +/** + * 盲盒奖池项启停状态。 + */ +public enum BlindBoxPoolStatus { + ENABLED(1), + DISABLED(0); + + private final int code; + + BlindBoxPoolStatus(int code) { + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/constant/BlindBoxRewardStatus.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/constant/BlindBoxRewardStatus.java new file mode 100644 index 0000000..4dfc053 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/constant/BlindBoxRewardStatus.java @@ -0,0 +1,17 @@ +package com.starry.admin.modules.blindbox.module.constant; + +public enum BlindBoxRewardStatus { + UNUSED("UNUSED"), + USED("USED"), + REFUNDED("REFUNDED"); + + private final String code; + + BlindBoxRewardStatus(String code) { + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxCandidate.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxCandidate.java new file mode 100644 index 0000000..07a816e --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxCandidate.java @@ -0,0 +1,46 @@ +package com.starry.admin.modules.blindbox.module.dto; + +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 盲盒抽奖候选项,封装奖池记录必要信息。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BlindBoxCandidate { + + /** + * 奖池记录主键。 + */ + private Long poolId; + + private String tenantId; + + private String blindBoxId; + + private String rewardGiftId; + + private BigDecimal rewardPrice; + + private int weight; + + /** + * 剩余库存,null 表示不限量。 + */ + private Integer remainingStock; + + public static BlindBoxCandidate of( + Long poolId, + String tenantId, + String blindBoxId, + String rewardGiftId, + BigDecimal rewardPrice, + int weight, + Integer remainingStock) { + return new BlindBoxCandidate(poolId, tenantId, blindBoxId, rewardGiftId, rewardPrice, weight, remainingStock); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxGiftOption.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxGiftOption.java new file mode 100644 index 0000000..5e013c0 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxGiftOption.java @@ -0,0 +1,23 @@ +package com.starry.admin.modules.blindbox.module.dto; + +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 盲盒奖池可选礼物选项。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BlindBoxGiftOption { + + private String id; + + private String name; + + private BigDecimal price; + + private String imageUrl; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxPoolImportRow.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxPoolImportRow.java new file mode 100644 index 0000000..7f4f8d0 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxPoolImportRow.java @@ -0,0 +1,33 @@ +package com.starry.admin.modules.blindbox.module.dto; + +import com.alibaba.excel.annotation.ExcelProperty; +import java.math.BigDecimal; +import lombok.Data; + +/** + * 盲盒奖池导入模板行。 + */ +@Data +public class BlindBoxPoolImportRow { + + @ExcelProperty("中奖礼物名称") + private String rewardGiftName; + + @ExcelProperty("权重") + private Integer weight; + + @ExcelProperty("初始库存") + private Integer remainingStock; + + @ExcelProperty("生效时间") + private String validFrom; + + @ExcelProperty("失效时间") + private String validTo; + + @ExcelProperty("状态") + private Integer status; + + @ExcelProperty("奖品售价(可选)") + private BigDecimal overrideRewardPrice; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxPoolUpsertRequest.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxPoolUpsertRequest.java new file mode 100644 index 0000000..ca4e9ed --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxPoolUpsertRequest.java @@ -0,0 +1,28 @@ +package com.starry.admin.modules.blindbox.module.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +public class BlindBoxPoolUpsertRequest { + + private String blindBoxId; + + private String rewardGiftId; + + private BigDecimal rewardPrice; + + private Integer weight; + + private Integer remainingStock; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime validFrom; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime validTo; + + private Integer status; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxPoolView.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxPoolView.java new file mode 100644 index 0000000..87617c4 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/dto/BlindBoxPoolView.java @@ -0,0 +1,34 @@ +package com.starry.admin.modules.blindbox.module.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.Data; + +/** + * 盲盒奖池前端展示对象。 + */ +@Data +public class BlindBoxPoolView { + + private Long id; + + private String blindBoxId; + + private String blindBoxName; + + private String rewardGiftId; + + private String rewardGiftName; + + private BigDecimal rewardPrice; + + private Integer weight; + + private Integer remainingStock; + + private LocalDateTime validFrom; + + private LocalDateTime validTo; + + private Integer status; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/entity/BlindBoxConfigEntity.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/entity/BlindBoxConfigEntity.java new file mode 100644 index 0000000..a5bd4c3 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/entity/BlindBoxConfigEntity.java @@ -0,0 +1,30 @@ +package com.starry.admin.modules.blindbox.module.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.starry.common.domain.BaseEntity; +import java.math.BigDecimal; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 盲盒配置实体。 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("blind_box_config") +public class BlindBoxConfigEntity extends BaseEntity { + + private String id; + + private String tenantId; + + private String name; + + private String coverUrl; + + private String description; + + private BigDecimal price; + + private Integer status; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/entity/BlindBoxPoolEntity.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/entity/BlindBoxPoolEntity.java new file mode 100644 index 0000000..47bcf06 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/entity/BlindBoxPoolEntity.java @@ -0,0 +1,45 @@ +package com.starry.admin.modules.blindbox.module.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.starry.common.domain.BaseEntity; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +/** + * 盲盒奖池配置实体。 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("blind_box_pool") +public class BlindBoxPoolEntity extends BaseEntity { + + private Long id; + + private String tenantId; + + @TableField("blind_box_id") + private String blindBoxId; + + private String rewardGiftId; + + private BigDecimal rewardPrice; + + private Integer weight; + + private Integer remainingStock; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime validFrom; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime validTo; + + private Integer status; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/entity/BlindBoxRewardEntity.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/entity/BlindBoxRewardEntity.java new file mode 100644 index 0000000..5496aba --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/module/entity/BlindBoxRewardEntity.java @@ -0,0 +1,47 @@ +package com.starry.admin.modules.blindbox.module.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.starry.common.domain.BaseEntity; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("blind_box_reward") +public class BlindBoxRewardEntity extends BaseEntity { + + private String id; + private String tenantId; + private String customerId; + + @TableField("blind_box_gift_id") + private String blindBoxId; + private String rewardGiftId; + private BigDecimal rewardPrice; + private BigDecimal boxPrice; + private BigDecimal subsidyAmount; + private Integer rewardStockSnapshot; + private String seed; + private String status; + private String createdByOrder; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime expiresAt; + + private String usedOrderId; + private String usedClerkId; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private LocalDateTime usedTime; + + @Version + private Integer version; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxConfigService.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxConfigService.java new file mode 100644 index 0000000..bf5315d --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxConfigService.java @@ -0,0 +1,12 @@ +package com.starry.admin.modules.blindbox.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity; +import java.util.List; + +public interface BlindBoxConfigService extends IService { + + BlindBoxConfigEntity requireById(String id); + + List listActiveByTenant(String tenantId); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxDispatchService.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxDispatchService.java new file mode 100644 index 0000000..8797c64 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxDispatchService.java @@ -0,0 +1,79 @@ +package com.starry.admin.modules.blindbox.service; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.dto.CommodityInfo; +import com.starry.admin.modules.order.module.dto.OrderCreationContext; +import com.starry.admin.modules.order.module.dto.OrderPlacementCommand; +import com.starry.admin.modules.order.module.dto.OrderPlacementResult; +import com.starry.admin.modules.order.module.dto.PaymentInfo; +import com.starry.admin.modules.order.service.IOrderLifecycleService; +import com.starry.admin.modules.order.service.IPlayOrderInfoService; +import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity; +import com.starry.admin.modules.shop.service.IPlayGiftInfoService; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BlindBoxDispatchService { + + private final IOrderLifecycleService orderLifecycleService; + private final IPlayOrderInfoService orderInfoService; + private final IPlayGiftInfoService giftInfoService; + + @Transactional(rollbackFor = Exception.class) + public OrderPlacementResult dispatchRewardOrder(BlindBoxRewardEntity reward, String clerkId) { + PlayGiftInfoEntity giftInfo = giftInfoService.selectPlayGiftInfoById(reward.getRewardGiftId()); + if (giftInfo == null) { + throw new CustomException("奖励礼物不存在或已下架"); + } + + BigDecimal rewardPrice = reward.getRewardPrice(); + PaymentInfo paymentInfo = PaymentInfo.builder() + .orderMoney(rewardPrice) + .finalAmount(rewardPrice) + .discountAmount(BigDecimal.ZERO) + .couponIds(Collections.emptyList()) + .payMethod(OrderConstant.PayMethod.BALANCE.getCode()) + .paymentSource(OrderConstant.PaymentSource.BLIND_BOX) + .build(); + + CommodityInfo commodityInfo = CommodityInfo.builder() + .commodityId(giftInfo.getId()) + .commodityType(OrderConstant.CommodityType.GIFT) + .commodityPrice(giftInfo.getPrice()) + .commodityName(giftInfo.getName()) + .commodityNumber("1") + .serviceDuration("") + .build(); + + OrderCreationContext context = OrderCreationContext.builder() + .orderId(IdUtils.getUuid()) + .orderNo(orderInfoService.getOrderNo()) + .orderStatus(OrderConstant.OrderStatus.COMPLETED) + .orderType(OrderConstant.OrderType.GIFT) + .placeType(OrderConstant.PlaceType.REWARD) + .rewardType(OrderConstant.RewardType.GIFT) + .paymentSource(OrderConstant.PaymentSource.BLIND_BOX) + .sourceRewardId(reward.getId()) + .paymentInfo(paymentInfo) + .commodityInfo(commodityInfo) + .purchaserBy(reward.getCustomerId()) + .acceptBy(clerkId) + .creatorActor(OrderConstant.OrderActor.CUSTOMER) + .creatorId(reward.getCustomerId()) + .remark("盲盒奖励兑现") + .build(); + + return orderLifecycleService.placeOrder(OrderPlacementCommand.builder() + .orderContext(context) + .balanceOperationAction("盲盒奖励兑现") + .build()); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxInventoryService.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxInventoryService.java new file mode 100644 index 0000000..160dbf3 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxInventoryService.java @@ -0,0 +1,22 @@ +package com.starry.admin.modules.blindbox.service; + +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BlindBoxInventoryService { + + private final BlindBoxPoolMapper blindBoxPoolMapper; + + @Transactional(rollbackFor = Exception.class) + public void reserveRewardStock(String tenantId, Long poolId, String rewardGiftId) { + int affected = blindBoxPoolMapper.consumeRewardStock(tenantId, poolId, rewardGiftId); + if (affected <= 0) { + throw new CustomException("盲盒奖池库存不足,请稍后再试"); + } + } +} 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 new file mode 100644 index 0000000..9cdf54f --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminService.java @@ -0,0 +1,392 @@ +package com.starry.admin.modules.blindbox.service; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper; +import com.starry.admin.modules.blindbox.module.constant.BlindBoxPoolStatus; +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.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.utils.SecurityUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class BlindBoxPoolAdminService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final BlindBoxPoolMapper blindBoxPoolMapper; + private final BlindBoxConfigService blindBoxConfigService; + private final PlayGiftInfoMapper playGiftInfoMapper; + + public List list(String blindBoxId) { + String tenantId = SecurityUtils.getTenantId(); + if (StrUtil.isBlank(tenantId) || StrUtil.isBlank(blindBoxId)) { + return Collections.emptyList(); + } + BlindBoxConfigEntity config = blindBoxConfigService.requireById(blindBoxId); + if (!tenantId.equals(config.getTenantId())) { + throw new CustomException("盲盒不存在或已被移除"); + } + LambdaQueryWrapper query = Wrappers.lambdaQuery(BlindBoxPoolEntity.class) + .eq(BlindBoxPoolEntity::getTenantId, tenantId) + .eq(BlindBoxPoolEntity::getBlindBoxId, blindBoxId) + .eq(BlindBoxPoolEntity::getDeleted, Boolean.FALSE) + .orderByAsc(BlindBoxPoolEntity::getId); + List entities = blindBoxPoolMapper.selectList(query); + if (CollUtil.isEmpty(entities)) { + return Collections.emptyList(); + } + Set rewardIds = entities.stream() + .map(BlindBoxPoolEntity::getRewardGiftId) + .collect(Collectors.toSet()); + List gifts = playGiftInfoMapper.selectBatchIds(rewardIds); + Map giftMap = gifts.stream() + .collect(Collectors.toMap(PlayGiftInfoEntity::getId, Function.identity())); + return entities.stream() + .map(entity -> toView(entity, config, giftMap.get(entity.getRewardGiftId()))) + .collect(Collectors.toList()); + } + + public List listGiftOptions(String keyword) { + String tenantId = SecurityUtils.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + return Collections.emptyList(); + } + LambdaQueryWrapper query = Wrappers.lambdaQuery(PlayGiftInfoEntity.class) + .eq(PlayGiftInfoEntity::getTenantId, tenantId) + .eq(PlayGiftInfoEntity::getHistory, GiftHistory.CURRENT.getCode()) + .eq(PlayGiftInfoEntity::getState, GiftState.ACTIVE.getCode()) + .eq(PlayGiftInfoEntity::getType, GiftType.NORMAL.getCode()) + .eq(PlayGiftInfoEntity::getDeleted, Boolean.FALSE); + if (StrUtil.isNotBlank(keyword)) { + query.like(PlayGiftInfoEntity::getName, keyword.trim()); + } + query.orderByAsc(PlayGiftInfoEntity::getName); + List gifts = playGiftInfoMapper.selectList(query); + if (CollUtil.isEmpty(gifts)) { + return Collections.emptyList(); + } + return gifts.stream() + .map(gift -> new BlindBoxGiftOption(gift.getId(), gift.getName(), gift.getPrice(), gift.getUrl())) + .collect(Collectors.toList()); + } + + @Transactional(rollbackFor = Exception.class) + public BlindBoxPoolView create(String blindBoxId, BlindBoxPoolUpsertRequest request) { + if (request == null) { + throw new CustomException("参数不能为空"); + } + String tenantId = requireTenantId(); + String targetBlindBoxId = StrUtil.isNotBlank(blindBoxId) ? blindBoxId : request.getBlindBoxId(); + if (StrUtil.isBlank(targetBlindBoxId)) { + throw new CustomException("盲盒ID不能为空"); + } + BlindBoxConfigEntity config = blindBoxConfigService.requireById(targetBlindBoxId); + if (!tenantId.equals(config.getTenantId())) { + throw new CustomException("盲盒不存在或已被移除"); + } + PlayGiftInfoEntity rewardGift = requireRewardGift(tenantId, request.getRewardGiftId()); + validateTimeRange(request.getValidFrom(), request.getValidTo()); + Integer weight = requirePositiveWeight(request.getWeight(), rewardGift.getName()); + Integer remainingStock = normalizeRemainingStock(request.getRemainingStock(), rewardGift.getName()); + Integer status = resolveStatus(request.getStatus()); + BigDecimal rewardPrice = resolveRewardPrice(request.getRewardPrice(), rewardGift.getPrice()); + String operatorId = currentUserIdSafely(); + + BlindBoxPoolEntity entity = new BlindBoxPoolEntity(); + entity.setTenantId(tenantId); + entity.setBlindBoxId(targetBlindBoxId); + entity.setRewardGiftId(rewardGift.getId()); + entity.setRewardPrice(rewardPrice); + entity.setWeight(weight); + entity.setRemainingStock(remainingStock); + entity.setValidFrom(request.getValidFrom()); + entity.setValidTo(request.getValidTo()); + entity.setStatus(status); + entity.setDeleted(Boolean.FALSE); + entity.setCreatedBy(operatorId); + entity.setUpdatedBy(operatorId); + + blindBoxPoolMapper.insert(entity); + return toView(entity, config, rewardGift); + } + + @Transactional(rollbackFor = Exception.class) + public BlindBoxPoolView update(Long id, BlindBoxPoolUpsertRequest request) { + if (id == null) { + throw new CustomException("记录ID不能为空"); + } + if (request == null) { + throw new CustomException("参数不能为空"); + } + String tenantId = requireTenantId(); + BlindBoxPoolEntity existing = blindBoxPoolMapper.selectById(id); + if (existing == null || Boolean.TRUE.equals(existing.getDeleted())) { + throw new CustomException("记录不存在或已删除"); + } + if (!tenantId.equals(existing.getTenantId())) { + throw new CustomException("无权操作该记录"); + } + String targetBlindBoxId = StrUtil.isNotBlank(request.getBlindBoxId()) + ? request.getBlindBoxId() + : existing.getBlindBoxId(); + BlindBoxConfigEntity config = blindBoxConfigService.requireById(targetBlindBoxId); + if (!tenantId.equals(config.getTenantId())) { + throw new CustomException("盲盒不存在或已被移除"); + } + PlayGiftInfoEntity rewardGift = requireRewardGift(tenantId, request.getRewardGiftId()); + validateTimeRange(request.getValidFrom(), request.getValidTo()); + Integer weight = requirePositiveWeight(request.getWeight(), rewardGift.getName()); + Integer remainingStock = normalizeRemainingStock(request.getRemainingStock(), rewardGift.getName()); + Integer status = resolveStatus(request.getStatus()); + BigDecimal rewardPrice = resolveRewardPrice(request.getRewardPrice(), rewardGift.getPrice()); + String operatorId = currentUserIdSafely(); + + existing.setBlindBoxId(targetBlindBoxId); + existing.setRewardGiftId(rewardGift.getId()); + existing.setRewardPrice(rewardPrice); + existing.setWeight(weight); + existing.setRemainingStock(remainingStock); + existing.setValidFrom(request.getValidFrom()); + existing.setValidTo(request.getValidTo()); + existing.setStatus(status); + existing.setUpdatedBy(operatorId); + blindBoxPoolMapper.updateById(existing); + return toView(existing, config, rewardGift); + } + + @Transactional(rollbackFor = Exception.class) + public void replacePool(String blindBoxId, List rows) { + if (StrUtil.isBlank(blindBoxId)) { + throw new CustomException("盲盒ID不能为空"); + } + if (CollUtil.isEmpty(rows)) { + throw new CustomException("导入数据不能为空"); + } + String tenantId = SecurityUtils.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + throw new CustomException("租户信息缺失"); + } + BlindBoxConfigEntity config = blindBoxConfigService.requireById(blindBoxId); + if (!tenantId.equals(config.getTenantId())) { + throw new CustomException("盲盒不存在或已被移除"); + } + List rewardNames = rows.stream() + .map(BlindBoxPoolImportRow::getRewardGiftName) + .filter(StrUtil::isNotBlank) + .distinct() + .collect(Collectors.toList()); + if (CollUtil.isEmpty(rewardNames)) { + throw new CustomException("导入数据缺少中奖礼物名称"); + } + LambdaQueryWrapper rewardQuery = Wrappers.lambdaQuery(PlayGiftInfoEntity.class) + .eq(PlayGiftInfoEntity::getTenantId, tenantId) + .eq(PlayGiftInfoEntity::getHistory, GiftHistory.CURRENT.getCode()) + .eq(PlayGiftInfoEntity::getState, GiftState.ACTIVE.getCode()) + .eq(PlayGiftInfoEntity::getType, GiftType.NORMAL.getCode()) + .in(PlayGiftInfoEntity::getName, rewardNames); + List rewardGifts = playGiftInfoMapper.selectList(rewardQuery); + Map> rewardsByName = rewardGifts.stream() + .collect(Collectors.groupingBy(PlayGiftInfoEntity::getName)); + List duplicateNames = rewardsByName.entrySet().stream() + .filter(entry -> entry.getValue().size() > 1) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + if (CollUtil.isNotEmpty(duplicateNames)) { + throw new CustomException("存在同名礼物,无法区分:" + String.join("、", duplicateNames)); + } + Map rewardMap = rewardsByName.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().get(0))); + String operatorId = currentUserIdSafely(); + List toInsert = new ArrayList<>(rows.size()); + for (BlindBoxPoolImportRow row : rows) { + if (StrUtil.isBlank(row.getRewardGiftName())) { + continue; + } + PlayGiftInfoEntity rewardGift = rewardMap.get(row.getRewardGiftName()); + if (rewardGift == null) { + throw new CustomException("中奖礼物不存在: " + row.getRewardGiftName()); + } + Integer weight = row.getWeight(); + if (weight == null || weight <= 0) { + throw new CustomException("礼物 " + row.getRewardGiftName() + " 权重必须为正整数"); + } + Integer remainingStock = row.getRemainingStock(); + if (remainingStock != null && remainingStock < 0) { + throw new CustomException("礼物 " + row.getRewardGiftName() + " 库存不能为负数"); + } + Integer status = row.getStatus() == null + ? BlindBoxPoolStatus.ENABLED.getCode() + : row.getStatus(); + BlindBoxPoolEntity entity = new BlindBoxPoolEntity(); + entity.setTenantId(tenantId); + entity.setBlindBoxId(blindBoxId); + entity.setRewardGiftId(rewardGift.getId()); + entity.setRewardPrice(resolveRewardPrice(row.getOverrideRewardPrice(), rewardGift.getPrice())); + entity.setWeight(weight); + entity.setRemainingStock(remainingStock); + entity.setValidFrom(parseDateTime(row.getValidFrom())); + entity.setValidTo(parseDateTime(row.getValidTo())); + entity.setStatus(status); + entity.setDeleted(Boolean.FALSE); + entity.setCreatedBy(operatorId); + entity.setUpdatedBy(operatorId); + toInsert.add(entity); + } + if (CollUtil.isEmpty(toInsert)) { + throw new CustomException("有效导入数据为空"); + } + LambdaQueryWrapper deleteWrapper = Wrappers.lambdaQuery(BlindBoxPoolEntity.class) + .eq(BlindBoxPoolEntity::getTenantId, tenantId) + .eq(BlindBoxPoolEntity::getBlindBoxId, blindBoxId); + blindBoxPoolMapper.delete(deleteWrapper); + for (BlindBoxPoolEntity entity : toInsert) { + blindBoxPoolMapper.insert(entity); + } + } + + @Transactional(rollbackFor = Exception.class) + public void removeById(Long id) { + String tenantId = SecurityUtils.getTenantId(); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(BlindBoxPoolEntity.class) + .eq(BlindBoxPoolEntity::getTenantId, tenantId) + .eq(BlindBoxPoolEntity::getId, id); + int deleted = blindBoxPoolMapper.delete(wrapper); + if (deleted == 0) { + throw new CustomException("记录不存在或已删除"); + } + } + + private BlindBoxPoolView toView(BlindBoxPoolEntity entity, BlindBoxConfigEntity config, + PlayGiftInfoEntity rewardGift) { + BlindBoxPoolView view = new BlindBoxPoolView(); + view.setId(entity.getId()); + view.setBlindBoxId(entity.getBlindBoxId()); + view.setBlindBoxName(config.getName()); + view.setRewardGiftId(entity.getRewardGiftId()); + view.setRewardGiftName(rewardGift != null ? rewardGift.getName() : entity.getRewardGiftId()); + view.setRewardPrice(entity.getRewardPrice()); + view.setWeight(entity.getWeight()); + view.setRemainingStock(entity.getRemainingStock()); + view.setValidFrom(entity.getValidFrom()); + view.setValidTo(entity.getValidTo()); + view.setStatus(entity.getStatus()); + return view; + } + + private LocalDateTime parseDateTime(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return LocalDateTime.parse(value.trim(), DATE_TIME_FORMATTER); + } catch (DateTimeParseException ex) { + throw new CustomException("日期格式应为 yyyy-MM-dd HH:mm:ss: " + value); + } + } + + private BigDecimal resolveRewardPrice(BigDecimal overrideRewardPrice, BigDecimal defaultPrice) { + return overrideRewardPrice != null ? overrideRewardPrice : defaultPrice; + } + + private String requireTenantId() { + String tenantId = SecurityUtils.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + throw new CustomException("租户信息缺失"); + } + return tenantId; + } + + private PlayGiftInfoEntity requireRewardGift(String tenantId, String rewardGiftId) { + 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("中奖礼物不存在或已下架"); + } + return gift; + } + + private Integer requirePositiveWeight(Integer weight, String giftName) { + if (weight == null || weight <= 0) { + String name = StrUtil.isBlank(giftName) ? "" : giftName; + throw new CustomException(StrUtil.isBlank(name) + ? "奖池权重必须为正整数" + : "礼物 " + name + " 权重必须为正整数"); + } + return weight; + } + + private Integer normalizeRemainingStock(Integer remainingStock, String giftName) { + if (remainingStock == null) { + return null; + } + if (remainingStock < 0) { + String name = StrUtil.isBlank(giftName) ? "" : giftName; + throw new CustomException(StrUtil.isBlank(name) + ? "库存不能为负数" + : "礼物 " + name + " 库存不能为负数"); + } + return remainingStock; + } + + private Integer resolveStatus(Integer status) { + if (status == null) { + return BlindBoxPoolStatus.ENABLED.getCode(); + } + if (status.equals(BlindBoxPoolStatus.ENABLED.getCode()) + || status.equals(BlindBoxPoolStatus.DISABLED.getCode())) { + return status; + } + throw new CustomException("状态参数非法"); + } + + private void validateTimeRange(LocalDateTime validFrom, LocalDateTime validTo) { + if (validFrom != null && validTo != null && validFrom.isAfter(validTo)) { + throw new CustomException("生效时间不能晚于失效时间"); + } + } + + private String currentUserIdSafely() { + try { + return SecurityUtils.getUserId(); + } catch (RuntimeException ex) { + return null; + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxService.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxService.java new file mode 100644 index 0000000..866a574 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/BlindBoxService.java @@ -0,0 +1,174 @@ +package com.starry.admin.modules.blindbox.service; + +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.BlindBoxPoolStatus; +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 com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +@Service +public class BlindBoxService { + + private final BlindBoxPoolMapper poolMapper; + private final BlindBoxRewardMapper rewardMapper; + private final BlindBoxInventoryService inventoryService; + private final BlindBoxDispatchService dispatchService; + private final BlindBoxConfigService configService; + private final Clock clock; + private final Duration rewardValidity; + private final RandomAdapter randomAdapter; + + @Autowired + public BlindBoxService( + BlindBoxPoolMapper poolMapper, + BlindBoxRewardMapper rewardMapper, + BlindBoxInventoryService inventoryService, + BlindBoxDispatchService dispatchService, + BlindBoxConfigService configService) { + this(poolMapper, rewardMapper, inventoryService, dispatchService, configService, Clock.systemDefaultZone(), Duration.ofDays(30), new DefaultRandomAdapter()); + } + + public BlindBoxService( + BlindBoxPoolMapper poolMapper, + BlindBoxRewardMapper rewardMapper, + BlindBoxInventoryService inventoryService, + BlindBoxDispatchService dispatchService, + BlindBoxConfigService configService, + Clock clock, + Duration rewardValidity, + RandomAdapter randomAdapter) { + this.poolMapper = Objects.requireNonNull(poolMapper, "poolMapper"); + this.rewardMapper = Objects.requireNonNull(rewardMapper, "rewardMapper"); + this.inventoryService = Objects.requireNonNull(inventoryService, "inventoryService"); + this.dispatchService = Objects.requireNonNull(dispatchService, "dispatchService"); + this.configService = Objects.requireNonNull(configService, "configService"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.rewardValidity = Objects.requireNonNull(rewardValidity, "rewardValidity"); + this.randomAdapter = Objects.requireNonNull(randomAdapter, "randomAdapter"); + } + + @Transactional(rollbackFor = Exception.class) + public BlindBoxRewardEntity drawReward( + String tenantId, + String orderId, + String customerId, + String blindBoxId, + String seed) { + LocalDateTime now = LocalDateTime.now(clock); + BlindBoxConfigEntity config = configService.requireById(blindBoxId); + if (!tenantId.equals(config.getTenantId())) { + throw new CustomException("盲盒不存在或已下架"); + } + List candidates = poolMapper.listEntries( + tenantId, + blindBoxId, + now, + BlindBoxPoolStatus.ENABLED.getCode()); + if (CollectionUtils.isEmpty(candidates)) { + throw new CustomException("盲盒奖池暂无可用奖励"); + } + + BlindBoxCandidate selected = selectCandidate(candidates); + inventoryService.reserveRewardStock(tenantId, selected.getPoolId(), selected.getRewardGiftId()); + + BlindBoxRewardEntity entity = new BlindBoxRewardEntity(); + entity.setId(IdUtils.getUuid()); + entity.setTenantId(tenantId); + entity.setCustomerId(customerId); + entity.setBlindBoxId(blindBoxId); + entity.setRewardGiftId(selected.getRewardGiftId()); + entity.setRewardPrice(normalizeMoney(selected.getRewardPrice())); + entity.setBoxPrice(normalizeMoney(config.getPrice())); + entity.setSubsidyAmount(entity.getRewardPrice().subtract(entity.getBoxPrice())); + Integer remainingStock = selected.getRemainingStock(); + entity.setRewardStockSnapshot(remainingStock == null ? null : Math.max(remainingStock - 1, 0)); + entity.setSeed(seed); + entity.setStatus(BlindBoxRewardStatus.UNUSED.getCode()); + entity.setCreatedByOrder(orderId); + entity.setExpiresAt(now.plus(rewardValidity)); + entity.setCreatedTime(java.sql.Timestamp.valueOf(now)); + entity.setUpdatedTime(java.sql.Timestamp.valueOf(now)); + + rewardMapper.insert(entity); + return entity; + } + + @Transactional(rollbackFor = Exception.class) + public OrderPlacementResult dispatchReward(String rewardId, String clerkId) { + BlindBoxRewardEntity reward = rewardMapper.lockByIdForUpdate(rewardId); + if (reward == null) { + throw new CustomException("盲盒奖励不存在"); + } + if (!BlindBoxRewardStatus.UNUSED.getCode().equals(reward.getStatus())) { + throw new CustomException("盲盒奖励已使用"); + } + LocalDateTime now = LocalDateTime.now(clock); + if (reward.getExpiresAt() != null && reward.getExpiresAt().isBefore(now)) { + throw new CustomException("盲盒奖励已过期"); + } + + OrderPlacementResult result = dispatchService.dispatchRewardOrder(reward, clerkId); + String orderId = result != null && result.getOrder() != null ? result.getOrder().getId() : null; + int affected = rewardMapper.markUsed(rewardId, clerkId, orderId, now); + if (affected <= 0) { + throw new CustomException("盲盒奖励已使用"); + } + reward.setStatus(BlindBoxRewardStatus.USED.getCode()); + reward.setUsedClerkId(clerkId); + reward.setUsedOrderId(orderId); + reward.setUsedTime(now); + return result; + } + + private BlindBoxCandidate selectCandidate(List candidates) { + int totalWeight = candidates.stream() + .mapToInt(BlindBoxCandidate::getWeight) + .sum(); + if (totalWeight <= 0) { + throw new CustomException("盲盒奖池权重配置异常"); + } + double threshold = randomAdapter.nextDouble() * totalWeight; + int cumulative = 0; + for (BlindBoxCandidate candidate : candidates) { + cumulative += Math.max(candidate.getWeight(), 0); + if (threshold < cumulative) { + return candidate; + } + } + return candidates.get(candidates.size() - 1); + } + + private BigDecimal normalizeMoney(BigDecimal value) { + return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) + : value.setScale(2, RoundingMode.HALF_UP); + } + + public interface RandomAdapter { + double nextDouble(); + } + + private static class DefaultRandomAdapter implements RandomAdapter { + + @Override + public double nextDouble() { + return ThreadLocalRandom.current().nextDouble(); + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/impl/BlindBoxConfigServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/impl/BlindBoxConfigServiceImpl.java new file mode 100644 index 0000000..0fa13c9 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/blindbox/service/impl/BlindBoxConfigServiceImpl.java @@ -0,0 +1,34 @@ +package com.starry.admin.modules.blindbox.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.blindbox.mapper.BlindBoxConfigMapper; +import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity; +import com.starry.admin.modules.blindbox.service.BlindBoxConfigService; +import java.util.List; +import com.starry.admin.modules.blindbox.module.constant.BlindBoxConfigStatus; +import org.springframework.stereotype.Service; + +@Service +public class BlindBoxConfigServiceImpl + extends ServiceImpl + implements BlindBoxConfigService { + + @Override + public BlindBoxConfigEntity requireById(String id) { + BlindBoxConfigEntity entity = getById(id); + if (entity == null) { + throw new CustomException("盲盒不存在"); + } + return entity; + } + + @Override + public List listActiveByTenant(String tenantId) { + return lambdaQuery() + .eq(BlindBoxConfigEntity::getTenantId, tenantId) + .eq(BlindBoxConfigEntity::getStatus, BlindBoxConfigStatus.ENABLED.getCode()) + .eq(BlindBoxConfigEntity::getDeleted, Boolean.FALSE) + .list(); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java index 5cc1e0f..580af5c 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/constant/OrderConstant.java @@ -47,7 +47,9 @@ public class OrderConstant { REFUND("-1", "退款订单"), RECHARGE("0", "充值订单"), WITHDRAWAL("1", "提现订单"), - NORMAL("2", "普通订单"); + NORMAL("2", "普通订单"), + GIFT("3", "礼物订单"), + BLIND_BOX_PURCHASE("4", "盲盒购买订单"); private final String code; private final String description; @@ -67,6 +69,31 @@ public class OrderConstant { } } + @Getter + public enum PaymentSource { + BALANCE("BALANCE", "余额扣款"), + WX_PAY("WX_PAY", "微信支付"), + ALI_PAY("ALI_PAY", "支付宝支付"), + BLIND_BOX("BLIND_BOX", "盲盒奖励抵扣"); + + private final String code; + private final String description; + + PaymentSource(String code, String description) { + this.code = code; + this.description = description; + } + + public static PaymentSource fromCode(String code) { + for (PaymentSource source : values()) { + if (source.code.equals(code)) { + return source; + } + } + throw new IllegalArgumentException("Unknown payment source code: " + code); + } + } + /** * 下单类型枚举 */ diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationContext.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationContext.java index 6d2ba93..9dc9315 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationContext.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/OrderCreationContext.java @@ -35,6 +35,10 @@ public class OrderCreationContext { private boolean isFirstOrder; + private OrderConstant.PaymentSource paymentSource; + + private String sourceRewardId; + @Valid @NotNull(message = "商品信息不能为空") private CommodityInfo commodityInfo; @@ -75,4 +79,13 @@ public class OrderCreationContext { public boolean isSpecifiedOrder() { return placeType == OrderConstant.PlaceType.SPECIFIED; } + + public OrderConstant.PaymentSource resolvePaymentSource() { + if (paymentSource != null) { + return paymentSource; + } + return paymentInfo != null && paymentInfo.getPaymentSource() != null + ? paymentInfo.getPaymentSource() + : OrderConstant.PaymentSource.BALANCE; + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/PaymentInfo.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/PaymentInfo.java index d2497a3..d9d76f8 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/PaymentInfo.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/dto/PaymentInfo.java @@ -1,5 +1,6 @@ package com.starry.admin.modules.order.module.dto; +import com.starry.admin.modules.order.module.constant.OrderConstant; import java.math.BigDecimal; import java.util.List; import lombok.Builder; @@ -37,4 +38,7 @@ public class PaymentInfo { * 支付方式,0:余额支付,1:微信支付,2:支付宝支付 */ private String payMethod; + + @Builder.Default + private OrderConstant.PaymentSource paymentSource = OrderConstant.PaymentSource.BALANCE; } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java b/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java index bfcfdda..fb7dd3e 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/module/entity/PlayOrderInfoEntity.java @@ -132,6 +132,16 @@ public class PlayOrderInfoEntity extends BaseEntity { */ private String backendEntry; + /** + * 支付来源(区分余额、三方、盲盒奖励等)。 + */ + private String paymentSource; + + /** + * 盲盒奖励引用ID。 + */ + private String sourceRewardId; + /** * 支付方式,0:余额支付,1:微信支付,2:支付宝支付 */ diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/AbstractOrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/AbstractOrderPlacementStrategy.java index b907209..8143397 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/AbstractOrderPlacementStrategy.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/AbstractOrderPlacementStrategy.java @@ -28,7 +28,7 @@ abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy PlayOrderInfoEntity order = service.createOrderRecord(context); - if (command.isDeductBalance()) { + if (command.isDeductBalance() && service.shouldDeductBalance(context)) { service.deductCustomerBalance( context.getPurchaserBy(), service.normalizeMoney(paymentInfo.getFinalAmount()), diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java index bdac190..841897e 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java @@ -160,6 +160,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { throw new CustomException("下单类型不能为空"); } + validateCouponUsage(context); + OrderConstant.RewardType rewardType = context.getRewardType() != null ? context.getRewardType() : OrderConstant.RewardType.NOT_APPLICABLE; @@ -206,6 +208,14 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { return entity; } + boolean shouldDeductBalance(OrderCreationContext context) { + if (context == null) { + return true; + } + OrderConstant.PaymentSource paymentSource = context.resolvePaymentSource(); + return paymentSource != OrderConstant.PaymentSource.BLIND_BOX; + } + OrderAmountBreakdown calculateOrderAmounts(OrderPlacementCommand.PricingInput pricingInput) { int quantity = pricingInput.getQuantity(); if (quantity <= 0) { @@ -566,6 +576,25 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { } } + private void validateCouponUsage(OrderCreationContext context) { + if (context == null) { + return; + } + PaymentInfo paymentInfo = context.getPaymentInfo(); + if (paymentInfo == null || CollectionUtil.isEmpty(paymentInfo.getCouponIds())) { + return; + } + boolean isBlindBoxPurchase = context.getOrderType() == OrderConstant.OrderType.BLIND_BOX_PURCHASE; + boolean isBlindBoxDispatch = context.resolvePaymentSource() == OrderConstant.PaymentSource.BLIND_BOX + || StrUtil.isNotBlank(context.getSourceRewardId()); + boolean isRewardOrder = context.getPlaceType() == OrderConstant.PlaceType.REWARD + || context.getRewardType() == OrderConstant.RewardType.GIFT + || context.getRewardType() == OrderConstant.RewardType.BALANCE; + if (isBlindBoxPurchase || isBlindBoxDispatch || isRewardOrder) { + throw new CustomException("盲盒/礼物订单暂不支持使用优惠券"); + } + } + private PlayOrderInfoEntity buildOrderEntity(OrderCreationContext context) { PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); entity.setId(context.getOrderId()); @@ -597,6 +626,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService { ? YesNoFlag.YES.getCode() : YesNoFlag.NO.getCode()); entity.setPayMethod(resolvePayMethod(paymentInfo.getPayMethod())); + entity.setPaymentSource(context.resolvePaymentSource().getCode()); + entity.setSourceRewardId(context.getSourceRewardId()); entity.setPurchaserBy(context.getPurchaserBy()); entity.setPurchaserTime(LocalDateTime.now()); diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OtherOrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OtherOrderPlacementStrategy.java index 2015653..16c722b 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OtherOrderPlacementStrategy.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OtherOrderPlacementStrategy.java @@ -24,6 +24,7 @@ class OtherOrderPlacementStrategy extends AbstractOrderPlacementStrategy { .finalAmount(service.normalizeMoney(info.getFinalAmount())) .couponIds(info.getCouponIds()) .payMethod(info.getPayMethod()) + .paymentSource(info.getPaymentSource()) .build()); return null; } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardGiftOrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardGiftOrderPlacementStrategy.java index bacd398..289e8fe 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardGiftOrderPlacementStrategy.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardGiftOrderPlacementStrategy.java @@ -34,6 +34,7 @@ class RewardGiftOrderPlacementStrategy extends AbstractOrderPlacementStrategy { .finalAmount(service.normalizeMoney(info.getFinalAmount())) .couponIds(info.getCouponIds()) .payMethod(info.getPayMethod()) + .paymentSource(info.getPaymentSource()) .build(); context.setPaymentInfo(normalized); return normalized; diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardTipOrderPlacementStrategy.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardTipOrderPlacementStrategy.java index 32e7bb5..bfbe3c9 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardTipOrderPlacementStrategy.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/RewardTipOrderPlacementStrategy.java @@ -33,6 +33,7 @@ class RewardTipOrderPlacementStrategy extends AbstractOrderPlacementStrategy { .finalAmount(service.normalizeMoney(info.getFinalAmount())) .couponIds(info.getCouponIds()) .payMethod(info.getPayMethod()) + .paymentSource(info.getPaymentSource()) .build(); context.setPaymentInfo(normalized); return normalized; diff --git a/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/GiftHistory.java b/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/GiftHistory.java new file mode 100644 index 0000000..d16ba37 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/GiftHistory.java @@ -0,0 +1,19 @@ +package com.starry.admin.modules.shop.module.constant; + +/** + * 是否历史礼物标记。 + */ +public enum GiftHistory { + CURRENT("0"), + HISTORY("1"); + + private final String code; + + GiftHistory(String code) { + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/GiftState.java b/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/GiftState.java new file mode 100644 index 0000000..2363d4b --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/GiftState.java @@ -0,0 +1,19 @@ +package com.starry.admin.modules.shop.module.constant; + +/** + * 礼物上下架状态。 + */ +public enum GiftState { + ACTIVE("0"), + OFF_SHELF("1"); + + private final String code; + + GiftState(String code) { + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/GiftType.java b/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/GiftType.java new file mode 100644 index 0000000..b9de979 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/shop/module/constant/GiftType.java @@ -0,0 +1,19 @@ +package com.starry.admin.modules.shop.module.constant; + +/** + * 礼物类型(字段已标注废弃,但仍有遗留使用)。 + */ +public enum GiftType { + BLIND_BOX("0"), + NORMAL("1"); + + private final String code; + + GiftType(String code) { + this.code = code; + } + + public String getCode() { + return code; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayGiftInfoEntity.java b/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayGiftInfoEntity.java index 6dc087d..02289c8 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayGiftInfoEntity.java +++ b/play-admin/src/main/java/com/starry/admin/modules/shop/module/entity/PlayGiftInfoEntity.java @@ -42,7 +42,10 @@ public class PlayGiftInfoEntity extends BaseEntity { /** * 礼物类型(0:盲盒,1:普通礼物) + * + * @deprecated 盲盒已迁移至 blind_box_config,请勿再依赖该字段判断 */ + @Deprecated private String type; /** diff --git a/play-admin/src/main/resources/db/migration/V13__blind_box_reward_table.sql b/play-admin/src/main/resources/db/migration/V13__blind_box_reward_table.sql new file mode 100644 index 0000000..1746a77 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V13__blind_box_reward_table.sql @@ -0,0 +1,73 @@ +CREATE TABLE IF NOT EXISTS blind_box_config ( + id varchar(36) NOT NULL COMMENT '盲盒ID', + tenant_id varchar(36) NOT NULL COMMENT '租户ID', + name varchar(64) NOT NULL COMMENT '盲盒名称', + cover_url varchar(255) DEFAULT NULL COMMENT '封面图', + description text DEFAULT NULL COMMENT '盲盒描述', + price decimal(10,2) NOT NULL COMMENT '盲盒售价', + status tinyint NOT NULL DEFAULT 1 COMMENT '状态:1-上架;0-下架', + created_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + created_by varchar(32) DEFAULT NULL COMMENT '创建人', + updated_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + updated_by varchar(32) DEFAULT NULL COMMENT '更新人', + deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1已删除 0未删除', + version int NOT NULL DEFAULT 1 COMMENT '数据版本', + PRIMARY KEY (id), + KEY idx_box_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='盲盒配置'; + +CREATE TABLE IF NOT EXISTS blind_box_pool ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id varchar(36) NOT NULL COMMENT '租户ID', + blind_box_id varchar(36) NOT NULL COMMENT '盲盒ID', + reward_gift_id varchar(36) NOT NULL COMMENT '中奖礼物ID', + reward_price decimal(10,2) NOT NULL COMMENT '盲盒赠送价快照', + weight int NOT NULL COMMENT '抽奖权重', + remaining_stock int DEFAULT NULL COMMENT '剩余库存(NULL 表示不限量)', + valid_from datetime DEFAULT NULL COMMENT '生效时间', + valid_to datetime DEFAULT NULL COMMENT '失效时间', + status tinyint NOT NULL DEFAULT 1 COMMENT '状态:1-启用;0-停用', + created_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + created_by varchar(32) DEFAULT NULL COMMENT '创建人', + updated_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + updated_by varchar(32) DEFAULT NULL COMMENT '更新人', + deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1已删除 0未删除', + version int NOT NULL DEFAULT 1 COMMENT '数据版本', + PRIMARY KEY (id), + KEY idx_pool_tenant_box (tenant_id, blind_box_id, status), + KEY idx_pool_reward (tenant_id, reward_gift_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='盲盒奖池配置'; + +CREATE TABLE IF NOT EXISTS blind_box_reward ( + id varchar(36) NOT NULL COMMENT '奖励ID', + tenant_id varchar(36) NOT NULL COMMENT '租户ID', + customer_id varchar(36) NOT NULL COMMENT '顾客ID', + blind_box_id varchar(36) NOT NULL COMMENT '盲盒ID', + reward_gift_id varchar(36) NOT NULL COMMENT '中奖礼物ID', + reward_price decimal(10,2) NOT NULL COMMENT '中奖礼物价格', + box_price decimal(10,2) NOT NULL COMMENT '盲盒价格', + subsidy_amount decimal(10,2) NOT NULL DEFAULT 0 COMMENT '补贴金额', + reward_stock_snapshot int DEFAULT NULL COMMENT '抽奖时库存快照', + seed varchar(128) NOT NULL COMMENT '随机种子', + status varchar(16) NOT NULL DEFAULT 'UNUSED' COMMENT 'UNUSED / USED / REFUNDED', + created_by_order varchar(36) NOT NULL COMMENT '来源订单ID', + expires_at datetime NOT NULL COMMENT '到期时间', + used_order_id varchar(36) DEFAULT NULL COMMENT '兑现订单ID', + used_clerk_id varchar(36) DEFAULT NULL COMMENT '兑现店员ID', + used_time datetime DEFAULT NULL COMMENT '兑现时间', + created_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + created_by varchar(32) DEFAULT NULL COMMENT '创建人', + updated_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + updated_by varchar(32) DEFAULT NULL COMMENT '更新人', + deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除 1已删除 0未删除', + version int NOT NULL DEFAULT 1 COMMENT '数据版本', + PRIMARY KEY (id), + KEY idx_reward_customer_status (customer_id, status), + KEY idx_reward_tenant_blind (tenant_id, blind_box_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='盲盒奖励明细'; + +ALTER TABLE play_order_info +ADD COLUMN payment_source varchar(32) NOT NULL DEFAULT 'BALANCE' COMMENT '支付来源(余额、第三方、盲盒奖励等)' AFTER pay_method, +ADD COLUMN source_reward_id varchar(36) DEFAULT NULL COMMENT '盲盒奖励引用ID' AFTER payment_source; + +UPDATE play_order_info SET payment_source = 'BALANCE' WHERE payment_source IS NULL; diff --git a/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxInventoryServiceTest.java b/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxInventoryServiceTest.java new file mode 100644 index 0000000..e663423 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxInventoryServiceTest.java @@ -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("库存不足")); + } +} 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 new file mode 100644 index 0000000..035c677 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxPoolAdminServiceTest.java @@ -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 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 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 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); + } +} 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 new file mode 100644 index 0000000..82dd7ed --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/blindbox/service/BlindBoxServiceTest.java @@ -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 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 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 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 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; + } + } +}