feat: 实现盲盒功能模块
新增功能: - 盲盒配置管理:支持盲盒的创建、编辑、上下架 - 盲盒奖池管理:支持奖池配置、Excel导入、权重抽奖、库存管理 - 盲盒购买流程:客户购买盲盒并抽取奖励 - 奖励兑现流程:客户可将盲盒奖励兑现为实际礼物订单 - 店员提成:奖励兑现时自动增加店员礼物提成 核心实现: - BlindBoxService: 抽奖核心逻辑,支持权重算法和库存扣减 - BlindBoxDispatchService: 奖励兑现订单创建 - BlindBoxInventoryService: 奖池库存管理 - BlindBoxPoolAdminService: 奖池配置管理,支持批量导入 API接口: - /play/blind-box/config: 盲盒配置CRUD - /play/blind-box/pool: 奖池配置管理和导入 - /wx/blind-box: 客户端盲盒购买和奖励查询 数据库变更: - blind_box_config: 盲盒配置表 - blind_box_pool: 盲盒奖池表 - blind_box_reward: 盲盒奖励记录表 - play_order_info: 新增 payment_source 和 source_reward_id 字段 其他改进: - 订单模块支持盲盒支付来源,区分余额扣款和奖励抵扣 - 优惠券校验:盲盒相关订单不支持使用优惠券 - 完善单元测试覆盖
This commit is contained in:
@@ -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<BlindBoxConfigEntity> 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<BlindBoxConfigEntity> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BlindBoxPoolImportRow> importRows = (List<BlindBoxPoolImportRow>) 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BlindBoxConfigEntity> {
|
||||||
|
}
|
||||||
@@ -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<BlindBoxPoolEntity> {
|
||||||
|
|
||||||
|
@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<BlindBoxCandidate> 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);
|
||||||
|
}
|
||||||
@@ -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<BlindBoxRewardEntity> {
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<BlindBoxConfigEntity> {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
private String tenantId;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String coverUrl;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private BigDecimal price;
|
||||||
|
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -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<BlindBoxPoolEntity> {
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<BlindBoxRewardEntity> {
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|
||||||
|
BlindBoxConfigEntity requireById(String id);
|
||||||
|
|
||||||
|
List<BlindBoxConfigEntity> listActiveByTenant(String tenantId);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("盲盒奖池库存不足,请稍后再试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BlindBoxPoolView> 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<BlindBoxPoolEntity> query = Wrappers.lambdaQuery(BlindBoxPoolEntity.class)
|
||||||
|
.eq(BlindBoxPoolEntity::getTenantId, tenantId)
|
||||||
|
.eq(BlindBoxPoolEntity::getBlindBoxId, blindBoxId)
|
||||||
|
.eq(BlindBoxPoolEntity::getDeleted, Boolean.FALSE)
|
||||||
|
.orderByAsc(BlindBoxPoolEntity::getId);
|
||||||
|
List<BlindBoxPoolEntity> entities = blindBoxPoolMapper.selectList(query);
|
||||||
|
if (CollUtil.isEmpty(entities)) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
Set<String> rewardIds = entities.stream()
|
||||||
|
.map(BlindBoxPoolEntity::getRewardGiftId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
List<PlayGiftInfoEntity> gifts = playGiftInfoMapper.selectBatchIds(rewardIds);
|
||||||
|
Map<String, PlayGiftInfoEntity> 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<BlindBoxGiftOption> listGiftOptions(String keyword) {
|
||||||
|
String tenantId = SecurityUtils.getTenantId();
|
||||||
|
if (StrUtil.isBlank(tenantId)) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
LambdaQueryWrapper<PlayGiftInfoEntity> 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<PlayGiftInfoEntity> 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<BlindBoxPoolImportRow> 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<String> rewardNames = rows.stream()
|
||||||
|
.map(BlindBoxPoolImportRow::getRewardGiftName)
|
||||||
|
.filter(StrUtil::isNotBlank)
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (CollUtil.isEmpty(rewardNames)) {
|
||||||
|
throw new CustomException("导入数据缺少中奖礼物名称");
|
||||||
|
}
|
||||||
|
LambdaQueryWrapper<PlayGiftInfoEntity> 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<PlayGiftInfoEntity> rewardGifts = playGiftInfoMapper.selectList(rewardQuery);
|
||||||
|
Map<String, List<PlayGiftInfoEntity>> rewardsByName = rewardGifts.stream()
|
||||||
|
.collect(Collectors.groupingBy(PlayGiftInfoEntity::getName));
|
||||||
|
List<String> 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<String, PlayGiftInfoEntity> rewardMap = rewardsByName.entrySet().stream()
|
||||||
|
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().get(0)));
|
||||||
|
String operatorId = currentUserIdSafely();
|
||||||
|
List<BlindBoxPoolEntity> 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<BlindBoxPoolEntity> 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<BlindBoxPoolEntity> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BlindBoxCandidate> 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<BlindBoxCandidate> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BlindBoxConfigMapper, BlindBoxConfigEntity>
|
||||||
|
implements BlindBoxConfigService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BlindBoxConfigEntity requireById(String id) {
|
||||||
|
BlindBoxConfigEntity entity = getById(id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new CustomException("盲盒不存在");
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BlindBoxConfigEntity> listActiveByTenant(String tenantId) {
|
||||||
|
return lambdaQuery()
|
||||||
|
.eq(BlindBoxConfigEntity::getTenantId, tenantId)
|
||||||
|
.eq(BlindBoxConfigEntity::getStatus, BlindBoxConfigStatus.ENABLED.getCode())
|
||||||
|
.eq(BlindBoxConfigEntity::getDeleted, Boolean.FALSE)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,9 @@ public class OrderConstant {
|
|||||||
REFUND("-1", "退款订单"),
|
REFUND("-1", "退款订单"),
|
||||||
RECHARGE("0", "充值订单"),
|
RECHARGE("0", "充值订单"),
|
||||||
WITHDRAWAL("1", "提现订单"),
|
WITHDRAWAL("1", "提现订单"),
|
||||||
NORMAL("2", "普通订单");
|
NORMAL("2", "普通订单"),
|
||||||
|
GIFT("3", "礼物订单"),
|
||||||
|
BLIND_BOX_PURCHASE("4", "盲盒购买订单");
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
private final String description;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下单类型枚举
|
* 下单类型枚举
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ public class OrderCreationContext {
|
|||||||
|
|
||||||
private boolean isFirstOrder;
|
private boolean isFirstOrder;
|
||||||
|
|
||||||
|
private OrderConstant.PaymentSource paymentSource;
|
||||||
|
|
||||||
|
private String sourceRewardId;
|
||||||
|
|
||||||
@Valid
|
@Valid
|
||||||
@NotNull(message = "商品信息不能为空")
|
@NotNull(message = "商品信息不能为空")
|
||||||
private CommodityInfo commodityInfo;
|
private CommodityInfo commodityInfo;
|
||||||
@@ -75,4 +79,13 @@ public class OrderCreationContext {
|
|||||||
public boolean isSpecifiedOrder() {
|
public boolean isSpecifiedOrder() {
|
||||||
return placeType == OrderConstant.PlaceType.SPECIFIED;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.starry.admin.modules.order.module.dto;
|
package com.starry.admin.modules.order.module.dto;
|
||||||
|
|
||||||
|
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -37,4 +38,7 @@ public class PaymentInfo {
|
|||||||
* 支付方式,0:余额支付,1:微信支付,2:支付宝支付
|
* 支付方式,0:余额支付,1:微信支付,2:支付宝支付
|
||||||
*/
|
*/
|
||||||
private String payMethod;
|
private String payMethod;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private OrderConstant.PaymentSource paymentSource = OrderConstant.PaymentSource.BALANCE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ public class PlayOrderInfoEntity extends BaseEntity<PlayOrderInfoEntity> {
|
|||||||
*/
|
*/
|
||||||
private String backendEntry;
|
private String backendEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付来源(区分余额、三方、盲盒奖励等)。
|
||||||
|
*/
|
||||||
|
private String paymentSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 盲盒奖励引用ID。
|
||||||
|
*/
|
||||||
|
private String sourceRewardId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 支付方式,0:余额支付,1:微信支付,2:支付宝支付
|
* 支付方式,0:余额支付,1:微信支付,2:支付宝支付
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy
|
|||||||
|
|
||||||
PlayOrderInfoEntity order = service.createOrderRecord(context);
|
PlayOrderInfoEntity order = service.createOrderRecord(context);
|
||||||
|
|
||||||
if (command.isDeductBalance()) {
|
if (command.isDeductBalance() && service.shouldDeductBalance(context)) {
|
||||||
service.deductCustomerBalance(
|
service.deductCustomerBalance(
|
||||||
context.getPurchaserBy(),
|
context.getPurchaserBy(),
|
||||||
service.normalizeMoney(paymentInfo.getFinalAmount()),
|
service.normalizeMoney(paymentInfo.getFinalAmount()),
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
|
|||||||
throw new CustomException("下单类型不能为空");
|
throw new CustomException("下单类型不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateCouponUsage(context);
|
||||||
|
|
||||||
OrderConstant.RewardType rewardType = context.getRewardType() != null
|
OrderConstant.RewardType rewardType = context.getRewardType() != null
|
||||||
? context.getRewardType()
|
? context.getRewardType()
|
||||||
: OrderConstant.RewardType.NOT_APPLICABLE;
|
: OrderConstant.RewardType.NOT_APPLICABLE;
|
||||||
@@ -206,6 +208,14 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
|
|||||||
return entity;
|
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) {
|
OrderAmountBreakdown calculateOrderAmounts(OrderPlacementCommand.PricingInput pricingInput) {
|
||||||
int quantity = pricingInput.getQuantity();
|
int quantity = pricingInput.getQuantity();
|
||||||
if (quantity <= 0) {
|
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) {
|
private PlayOrderInfoEntity buildOrderEntity(OrderCreationContext context) {
|
||||||
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
|
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
|
||||||
entity.setId(context.getOrderId());
|
entity.setId(context.getOrderId());
|
||||||
@@ -597,6 +626,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
|
|||||||
? YesNoFlag.YES.getCode()
|
? YesNoFlag.YES.getCode()
|
||||||
: YesNoFlag.NO.getCode());
|
: YesNoFlag.NO.getCode());
|
||||||
entity.setPayMethod(resolvePayMethod(paymentInfo.getPayMethod()));
|
entity.setPayMethod(resolvePayMethod(paymentInfo.getPayMethod()));
|
||||||
|
entity.setPaymentSource(context.resolvePaymentSource().getCode());
|
||||||
|
entity.setSourceRewardId(context.getSourceRewardId());
|
||||||
|
|
||||||
entity.setPurchaserBy(context.getPurchaserBy());
|
entity.setPurchaserBy(context.getPurchaserBy());
|
||||||
entity.setPurchaserTime(LocalDateTime.now());
|
entity.setPurchaserTime(LocalDateTime.now());
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class OtherOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
|
|||||||
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
|
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
|
||||||
.couponIds(info.getCouponIds())
|
.couponIds(info.getCouponIds())
|
||||||
.payMethod(info.getPayMethod())
|
.payMethod(info.getPayMethod())
|
||||||
|
.paymentSource(info.getPaymentSource())
|
||||||
.build());
|
.build());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class RewardGiftOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
|
|||||||
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
|
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
|
||||||
.couponIds(info.getCouponIds())
|
.couponIds(info.getCouponIds())
|
||||||
.payMethod(info.getPayMethod())
|
.payMethod(info.getPayMethod())
|
||||||
|
.paymentSource(info.getPaymentSource())
|
||||||
.build();
|
.build();
|
||||||
context.setPaymentInfo(normalized);
|
context.setPaymentInfo(normalized);
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class RewardTipOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
|
|||||||
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
|
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
|
||||||
.couponIds(info.getCouponIds())
|
.couponIds(info.getCouponIds())
|
||||||
.payMethod(info.getPayMethod())
|
.payMethod(info.getPayMethod())
|
||||||
|
.paymentSource(info.getPaymentSource())
|
||||||
.build();
|
.build();
|
||||||
context.setPaymentInfo(normalized);
|
context.setPaymentInfo(normalized);
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,10 @@ public class PlayGiftInfoEntity extends BaseEntity<PlayGiftInfoEntity> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 礼物类型(0:盲盒,1:普通礼物)
|
* 礼物类型(0:盲盒,1:普通礼物)
|
||||||
|
*
|
||||||
|
* @deprecated 盲盒已迁移至 blind_box_config,请勿再依赖该字段判断
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
private String type;
|
private String type;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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("库存不足"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package com.starry.admin.modules.blindbox.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.starry.admin.common.exception.CustomException;
|
||||||
|
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
|
||||||
|
import com.starry.admin.modules.blindbox.module.dto.BlindBoxGiftOption;
|
||||||
|
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolImportRow;
|
||||||
|
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolUpsertRequest;
|
||||||
|
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolView;
|
||||||
|
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
|
||||||
|
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
|
||||||
|
import com.starry.admin.modules.shop.mapper.PlayGiftInfoMapper;
|
||||||
|
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
|
||||||
|
import com.starry.admin.utils.SecurityUtils;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class BlindBoxPoolAdminServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private BlindBoxPoolMapper blindBoxPoolMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PlayGiftInfoMapper playGiftInfoMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private BlindBoxConfigService blindBoxConfigService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private BlindBoxPoolAdminService blindBoxPoolAdminService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setupContext() {
|
||||||
|
SecurityUtils.setTenantId("tenant-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldListPoolsWithGiftNames() {
|
||||||
|
BlindBoxPoolEntity entity = new BlindBoxPoolEntity();
|
||||||
|
entity.setId(10L);
|
||||||
|
entity.setTenantId("tenant-1");
|
||||||
|
entity.setBlindBoxId("blind-1");
|
||||||
|
entity.setRewardGiftId("gift-2");
|
||||||
|
entity.setRewardPrice(BigDecimal.valueOf(9.9));
|
||||||
|
entity.setWeight(50);
|
||||||
|
entity.setRemainingStock(100);
|
||||||
|
entity.setValidFrom(LocalDateTime.of(2024, 8, 1, 0, 0));
|
||||||
|
entity.setStatus(1);
|
||||||
|
|
||||||
|
when(blindBoxPoolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(entity));
|
||||||
|
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||||
|
config.setId("blind-1");
|
||||||
|
config.setTenantId("tenant-1");
|
||||||
|
config.setName("幸运盲盒");
|
||||||
|
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
|
||||||
|
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
|
||||||
|
reward.setId("gift-2");
|
||||||
|
reward.setName("超值娃娃");
|
||||||
|
when(playGiftInfoMapper.selectBatchIds(any(Collection.class)))
|
||||||
|
.thenAnswer(invocation -> Arrays.asList(reward));
|
||||||
|
|
||||||
|
List<BlindBoxPoolView> views = blindBoxPoolAdminService.list("blind-1");
|
||||||
|
|
||||||
|
assertEquals(1, views.size());
|
||||||
|
BlindBoxPoolView view = views.get(0);
|
||||||
|
assertEquals("幸运盲盒", view.getBlindBoxName());
|
||||||
|
assertEquals("超值娃娃", view.getRewardGiftName());
|
||||||
|
assertEquals(50, view.getWeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReplacePoolAndInsertRows() {
|
||||||
|
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||||
|
config.setId("blind-1");
|
||||||
|
config.setTenantId("tenant-1");
|
||||||
|
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
|
||||||
|
|
||||||
|
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
|
||||||
|
reward.setId("gift-2");
|
||||||
|
reward.setName("超值娃娃");
|
||||||
|
reward.setType("1");
|
||||||
|
reward.setPrice(BigDecimal.valueOf(9.9));
|
||||||
|
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||||
|
.thenReturn(Collections.singletonList(reward));
|
||||||
|
|
||||||
|
when(blindBoxPoolMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(1);
|
||||||
|
AtomicInteger insertCount = new AtomicInteger();
|
||||||
|
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
insertCount.incrementAndGet();
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
BlindBoxPoolImportRow row = new BlindBoxPoolImportRow();
|
||||||
|
row.setRewardGiftName("超值娃娃");
|
||||||
|
row.setWeight(80);
|
||||||
|
row.setRemainingStock(10);
|
||||||
|
row.setStatus(1);
|
||||||
|
|
||||||
|
blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(row));
|
||||||
|
|
||||||
|
assertEquals(1, insertCount.get());
|
||||||
|
ArgumentCaptor<BlindBoxPoolEntity> captor = ArgumentCaptor.forClass(BlindBoxPoolEntity.class);
|
||||||
|
verify(blindBoxPoolMapper).insert(captor.capture());
|
||||||
|
BlindBoxPoolEntity saved = captor.getValue();
|
||||||
|
assertEquals("gift-2", saved.getRewardGiftId());
|
||||||
|
assertEquals(Integer.valueOf(80), saved.getWeight());
|
||||||
|
assertEquals(BigDecimal.valueOf(9.9), saved.getRewardPrice());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowWhenRewardGiftMissing() {
|
||||||
|
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||||
|
config.setId("blind-1");
|
||||||
|
config.setTenantId("tenant-1");
|
||||||
|
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
|
||||||
|
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
BlindBoxPoolImportRow row = new BlindBoxPoolImportRow();
|
||||||
|
row.setRewardGiftName("未知礼物");
|
||||||
|
row.setWeight(10);
|
||||||
|
|
||||||
|
assertThrows(CustomException.class,
|
||||||
|
() -> blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowForInvalidWeight() {
|
||||||
|
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||||
|
config.setId("blind-1");
|
||||||
|
config.setTenantId("tenant-1");
|
||||||
|
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
|
||||||
|
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
|
||||||
|
reward.setId("gift-2");
|
||||||
|
reward.setName("超值娃娃");
|
||||||
|
reward.setType("1");
|
||||||
|
reward.setPrice(BigDecimal.ONE);
|
||||||
|
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||||
|
.thenReturn(Collections.singletonList(reward));
|
||||||
|
|
||||||
|
BlindBoxPoolImportRow row = new BlindBoxPoolImportRow();
|
||||||
|
row.setRewardGiftName("超值娃娃");
|
||||||
|
row.setWeight(0);
|
||||||
|
|
||||||
|
assertThrows(CustomException.class,
|
||||||
|
() -> blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldListGiftOptions() {
|
||||||
|
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
|
||||||
|
gift.setId("gift-1");
|
||||||
|
gift.setTenantId("tenant-1");
|
||||||
|
gift.setHistory("0");
|
||||||
|
gift.setState("0");
|
||||||
|
gift.setType("1");
|
||||||
|
gift.setPrice(BigDecimal.valueOf(19.9));
|
||||||
|
gift.setName("超值娃娃");
|
||||||
|
gift.setUrl("https://image");
|
||||||
|
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(gift));
|
||||||
|
|
||||||
|
List<BlindBoxGiftOption> options = blindBoxPoolAdminService.listGiftOptions("娃");
|
||||||
|
|
||||||
|
assertEquals(1, options.size());
|
||||||
|
assertEquals("gift-1", options.get(0).getId());
|
||||||
|
assertEquals("超值娃娃", options.get(0).getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreatePoolEntry() {
|
||||||
|
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||||
|
config.setId("blind-1");
|
||||||
|
config.setTenantId("tenant-1");
|
||||||
|
config.setName("幸运盲盒");
|
||||||
|
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
|
||||||
|
|
||||||
|
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
|
||||||
|
gift.setId("gift-2");
|
||||||
|
gift.setTenantId("tenant-1");
|
||||||
|
gift.setHistory("0");
|
||||||
|
gift.setState("0");
|
||||||
|
gift.setType("1");
|
||||||
|
gift.setPrice(BigDecimal.valueOf(9.9));
|
||||||
|
gift.setName("超值娃娃");
|
||||||
|
when(playGiftInfoMapper.selectById("gift-2")).thenReturn(gift);
|
||||||
|
|
||||||
|
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class))).thenAnswer(invocation -> {
|
||||||
|
BlindBoxPoolEntity entity = invocation.getArgument(0);
|
||||||
|
entity.setId(100L);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
|
||||||
|
request.setBlindBoxId("blind-1");
|
||||||
|
request.setRewardGiftId("gift-2");
|
||||||
|
request.setRewardPrice(BigDecimal.valueOf(12.5));
|
||||||
|
request.setWeight(80);
|
||||||
|
request.setRemainingStock(5);
|
||||||
|
request.setStatus(1);
|
||||||
|
request.setValidFrom(LocalDateTime.of(2024, 8, 1, 0, 0));
|
||||||
|
request.setValidTo(LocalDateTime.of(2024, 8, 31, 23, 59, 59));
|
||||||
|
|
||||||
|
BlindBoxPoolView view = blindBoxPoolAdminService.create("blind-1", request);
|
||||||
|
|
||||||
|
assertEquals("gift-2", view.getRewardGiftId());
|
||||||
|
assertEquals(Integer.valueOf(80), view.getWeight());
|
||||||
|
verify(blindBoxPoolMapper).insert(any(BlindBoxPoolEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUpdatePoolEntry() {
|
||||||
|
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||||
|
config.setId("blind-1");
|
||||||
|
config.setTenantId("tenant-1");
|
||||||
|
config.setName("幸运盲盒");
|
||||||
|
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
|
||||||
|
|
||||||
|
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
|
||||||
|
gift.setId("gift-3");
|
||||||
|
gift.setTenantId("tenant-1");
|
||||||
|
gift.setHistory("0");
|
||||||
|
gift.setState("0");
|
||||||
|
gift.setType("1");
|
||||||
|
gift.setPrice(BigDecimal.valueOf(19.9));
|
||||||
|
gift.setName("超级公仔");
|
||||||
|
when(playGiftInfoMapper.selectById("gift-3")).thenReturn(gift);
|
||||||
|
|
||||||
|
BlindBoxPoolEntity existing = new BlindBoxPoolEntity();
|
||||||
|
existing.setId(200L);
|
||||||
|
existing.setTenantId("tenant-1");
|
||||||
|
existing.setBlindBoxId("blind-1");
|
||||||
|
existing.setRewardGiftId("gift-1");
|
||||||
|
existing.setStatus(1);
|
||||||
|
when(blindBoxPoolMapper.selectById(200L)).thenReturn(existing);
|
||||||
|
when(blindBoxPoolMapper.updateById(any(BlindBoxPoolEntity.class))).thenReturn(1);
|
||||||
|
|
||||||
|
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
|
||||||
|
request.setBlindBoxId("blind-1");
|
||||||
|
request.setRewardGiftId("gift-3");
|
||||||
|
request.setWeight(60);
|
||||||
|
request.setRemainingStock(null);
|
||||||
|
request.setStatus(0);
|
||||||
|
request.setRewardPrice(null);
|
||||||
|
request.setValidFrom(LocalDateTime.of(2024, 9, 1, 0, 0));
|
||||||
|
request.setValidTo(LocalDateTime.of(2024, 9, 30, 23, 59, 59));
|
||||||
|
|
||||||
|
BlindBoxPoolView view = blindBoxPoolAdminService.update(200L, request);
|
||||||
|
|
||||||
|
assertEquals("gift-3", existing.getRewardGiftId());
|
||||||
|
assertEquals(Integer.valueOf(60), existing.getWeight());
|
||||||
|
assertEquals(Integer.valueOf(0), existing.getStatus());
|
||||||
|
assertEquals("超级公仔", view.getRewardGiftName());
|
||||||
|
verify(blindBoxPoolMapper).updateById(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.starry.admin.modules.blindbox.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.starry.admin.common.exception.CustomException;
|
||||||
|
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
|
||||||
|
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
|
||||||
|
import com.starry.admin.modules.blindbox.module.constant.BlindBoxRewardStatus;
|
||||||
|
import com.starry.admin.modules.blindbox.module.dto.BlindBoxCandidate;
|
||||||
|
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
|
||||||
|
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
|
||||||
|
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class BlindBoxServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private BlindBoxPoolMapper poolMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private BlindBoxRewardMapper rewardMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private BlindBoxInventoryService inventoryService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private BlindBoxDispatchService dispatchService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private BlindBoxConfigService configService;
|
||||||
|
|
||||||
|
private Clock clock;
|
||||||
|
private TestRandomAdapter randomAdapter;
|
||||||
|
private BlindBoxService blindBoxService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
clock = Clock.fixed(Instant.parse("2024-08-01T10:15:30Z"), ZoneOffset.UTC);
|
||||||
|
randomAdapter = new TestRandomAdapter();
|
||||||
|
blindBoxService = new BlindBoxService(poolMapper, rewardMapper, inventoryService, dispatchService, configService,
|
||||||
|
clock, Duration.ofDays(7), randomAdapter);
|
||||||
|
|
||||||
|
lenient().when(rewardMapper.insert(any())).thenAnswer(invocation -> {
|
||||||
|
BlindBoxRewardEntity entity = invocation.getArgument(0);
|
||||||
|
if (entity.getId() == null) {
|
||||||
|
entity.setId(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreateRewardRecordWhenOrderCompleted() {
|
||||||
|
randomAdapter.nextDoubleToReturn = 0.95;
|
||||||
|
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||||
|
config.setId("blind-1");
|
||||||
|
config.setTenantId("tenant-1");
|
||||||
|
config.setPrice(BigDecimal.valueOf(49));
|
||||||
|
when(configService.requireById("blind-1")).thenReturn(config);
|
||||||
|
List<BlindBoxCandidate> candidates = Arrays.asList(
|
||||||
|
BlindBoxCandidate.of(1L, "tenant-1", "blind-1", "gift-low", BigDecimal.valueOf(10), 10, 5),
|
||||||
|
BlindBoxCandidate.of(2L, "tenant-1", "blind-1", "gift-high", BigDecimal.valueOf(99), 90, 3)
|
||||||
|
);
|
||||||
|
when(poolMapper.listActiveEntries(eq("tenant-1"), eq("blind-1"), any())).thenReturn(candidates);
|
||||||
|
|
||||||
|
BlindBoxRewardEntity entity = blindBoxService.drawReward("tenant-1", "order-1", "customer-1", "blind-1",
|
||||||
|
"seed-123");
|
||||||
|
|
||||||
|
ArgumentCaptor<BlindBoxRewardEntity> captor = ArgumentCaptor.forClass(BlindBoxRewardEntity.class);
|
||||||
|
verify(rewardMapper).insert(captor.capture());
|
||||||
|
BlindBoxRewardEntity persisted = captor.getValue();
|
||||||
|
|
||||||
|
assertEquals("gift-high", persisted.getRewardGiftId());
|
||||||
|
assertEquals(BigDecimal.valueOf(99).setScale(2), persisted.getRewardPrice());
|
||||||
|
assertEquals(BigDecimal.valueOf(49).setScale(2), persisted.getBoxPrice());
|
||||||
|
assertEquals(BlindBoxRewardStatus.UNUSED.getCode(), persisted.getStatus());
|
||||||
|
assertEquals(LocalDateTime.ofInstant(clock.instant(), clock.getZone()).plusDays(7), persisted.getExpiresAt());
|
||||||
|
verify(inventoryService).reserveRewardStock("tenant-1", 2L, "gift-high");
|
||||||
|
assertEquals(entity.getRewardGiftId(), persisted.getRewardGiftId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHandleUnlimitedStock() {
|
||||||
|
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
|
||||||
|
config.setId("blind-1");
|
||||||
|
config.setTenantId("tenant-1");
|
||||||
|
config.setPrice(BigDecimal.valueOf(29));
|
||||||
|
when(configService.requireById("blind-1")).thenReturn(config);
|
||||||
|
List<BlindBoxCandidate> candidates = Collections.singletonList(
|
||||||
|
BlindBoxCandidate.of(7L, "tenant-1", "blind-1", "gift-unlimited", BigDecimal.valueOf(59), 100, null)
|
||||||
|
);
|
||||||
|
when(poolMapper.listActiveEntries(eq("tenant-1"), eq("blind-1"), any())).thenReturn(candidates);
|
||||||
|
|
||||||
|
blindBoxService.drawReward("tenant-1", "order-2", "customer-9", "blind-1", "seed-unlimited");
|
||||||
|
|
||||||
|
ArgumentCaptor<BlindBoxRewardEntity> captor = ArgumentCaptor.forClass(BlindBoxRewardEntity.class);
|
||||||
|
verify(rewardMapper).insert(captor.capture());
|
||||||
|
BlindBoxRewardEntity persisted = captor.getValue();
|
||||||
|
assertEquals("gift-unlimited", persisted.getRewardGiftId());
|
||||||
|
assertEquals(BigDecimal.valueOf(29).setScale(2), persisted.getBoxPrice());
|
||||||
|
assertEquals(BigDecimal.valueOf(59).setScale(2), persisted.getRewardPrice());
|
||||||
|
assertEquals(BlindBoxRewardStatus.UNUSED.getCode(), persisted.getStatus());
|
||||||
|
assertNull(persisted.getRewardStockSnapshot());
|
||||||
|
verify(inventoryService).reserveRewardStock("tenant-1", 7L, "gift-unlimited");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldPreventDoubleDispatch() {
|
||||||
|
BlindBoxRewardEntity reward = buildRewardEntity();
|
||||||
|
when(rewardMapper.lockByIdForUpdate("reward-1")).thenReturn(reward);
|
||||||
|
when(dispatchService.dispatchRewardOrder(eq(reward), eq("clerk-1"))).thenReturn(mock(OrderPlacementResult.class));
|
||||||
|
when(rewardMapper.markUsed(eq("reward-1"), eq("clerk-1"), any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
blindBoxService.dispatchReward("reward-1", "clerk-1");
|
||||||
|
|
||||||
|
reward.setStatus(BlindBoxRewardStatus.USED.getCode());
|
||||||
|
CustomException ex = assertThrows(CustomException.class, () ->
|
||||||
|
blindBoxService.dispatchReward("reward-1", "clerk-1"));
|
||||||
|
assertTrue(ex.getMessage().contains("已使用"));
|
||||||
|
|
||||||
|
verify(rewardMapper, times(1)).markUsed(eq("reward-1"), eq("clerk-1"), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectExpiredReward() {
|
||||||
|
BlindBoxRewardEntity reward = buildRewardEntity();
|
||||||
|
reward.setExpiresAt(LocalDateTime.ofInstant(clock.instant(), clock.getZone()).minusHours(1));
|
||||||
|
when(rewardMapper.lockByIdForUpdate("reward-1")).thenReturn(reward);
|
||||||
|
|
||||||
|
CustomException ex = assertThrows(CustomException.class, () ->
|
||||||
|
blindBoxService.dispatchReward("reward-1", "clerk-1"));
|
||||||
|
assertTrue(ex.getMessage().contains("已过期"));
|
||||||
|
verify(dispatchService, times(0)).dispatchRewardOrder(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private BlindBoxRewardEntity buildRewardEntity() {
|
||||||
|
BlindBoxRewardEntity reward = new BlindBoxRewardEntity();
|
||||||
|
reward.setId("reward-1");
|
||||||
|
reward.setTenantId("tenant-1");
|
||||||
|
reward.setCustomerId("customer-1");
|
||||||
|
reward.setBlindBoxId("blind-1");
|
||||||
|
reward.setRewardGiftId("gift-high");
|
||||||
|
reward.setRewardPrice(BigDecimal.valueOf(99));
|
||||||
|
reward.setBoxPrice(BigDecimal.valueOf(49));
|
||||||
|
reward.setRewardStockSnapshot(3);
|
||||||
|
reward.setSeed("seed-123");
|
||||||
|
reward.setStatus(BlindBoxRewardStatus.UNUSED.getCode());
|
||||||
|
reward.setCreatedByOrder("order-1");
|
||||||
|
reward.setCreatedTime(java.sql.Timestamp.from(clock.instant()));
|
||||||
|
reward.setUpdatedTime(java.sql.Timestamp.from(clock.instant()));
|
||||||
|
reward.setExpiresAt(LocalDateTime.ofInstant(clock.instant(), clock.getZone()).plusHours(1));
|
||||||
|
reward.setVersion(0);
|
||||||
|
return reward;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TestRandomAdapter implements BlindBoxService.RandomAdapter {
|
||||||
|
|
||||||
|
double nextDoubleToReturn = 0.5;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double nextDouble() {
|
||||||
|
return nextDoubleToReturn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user