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", "退款订单"),
|
||||
RECHARGE("0", "充值订单"),
|
||||
WITHDRAWAL("1", "提现订单"),
|
||||
NORMAL("2", "普通订单");
|
||||
NORMAL("2", "普通订单"),
|
||||
GIFT("3", "礼物订单"),
|
||||
BLIND_BOX_PURCHASE("4", "盲盒购买订单");
|
||||
|
||||
private final String code;
|
||||
private final String description;
|
||||
@@ -67,6 +69,31 @@ public class OrderConstant {
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
public enum PaymentSource {
|
||||
BALANCE("BALANCE", "余额扣款"),
|
||||
WX_PAY("WX_PAY", "微信支付"),
|
||||
ALI_PAY("ALI_PAY", "支付宝支付"),
|
||||
BLIND_BOX("BLIND_BOX", "盲盒奖励抵扣");
|
||||
|
||||
private final String code;
|
||||
private final String description;
|
||||
|
||||
PaymentSource(String code, String description) {
|
||||
this.code = code;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public static PaymentSource fromCode(String code) {
|
||||
for (PaymentSource source : values()) {
|
||||
if (source.code.equals(code)) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown payment source code: " + code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单类型枚举
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,10 @@ public class OrderCreationContext {
|
||||
|
||||
private boolean isFirstOrder;
|
||||
|
||||
private OrderConstant.PaymentSource paymentSource;
|
||||
|
||||
private String sourceRewardId;
|
||||
|
||||
@Valid
|
||||
@NotNull(message = "商品信息不能为空")
|
||||
private CommodityInfo commodityInfo;
|
||||
@@ -75,4 +79,13 @@ public class OrderCreationContext {
|
||||
public boolean isSpecifiedOrder() {
|
||||
return placeType == OrderConstant.PlaceType.SPECIFIED;
|
||||
}
|
||||
|
||||
public OrderConstant.PaymentSource resolvePaymentSource() {
|
||||
if (paymentSource != null) {
|
||||
return paymentSource;
|
||||
}
|
||||
return paymentInfo != null && paymentInfo.getPaymentSource() != null
|
||||
? paymentInfo.getPaymentSource()
|
||||
: OrderConstant.PaymentSource.BALANCE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.starry.admin.modules.order.module.dto;
|
||||
|
||||
import com.starry.admin.modules.order.module.constant.OrderConstant;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
@@ -37,4 +38,7 @@ public class PaymentInfo {
|
||||
* 支付方式,0:余额支付,1:微信支付,2:支付宝支付
|
||||
*/
|
||||
private String payMethod;
|
||||
|
||||
@Builder.Default
|
||||
private OrderConstant.PaymentSource paymentSource = OrderConstant.PaymentSource.BALANCE;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,16 @@ public class PlayOrderInfoEntity extends BaseEntity<PlayOrderInfoEntity> {
|
||||
*/
|
||||
private String backendEntry;
|
||||
|
||||
/**
|
||||
* 支付来源(区分余额、三方、盲盒奖励等)。
|
||||
*/
|
||||
private String paymentSource;
|
||||
|
||||
/**
|
||||
* 盲盒奖励引用ID。
|
||||
*/
|
||||
private String sourceRewardId;
|
||||
|
||||
/**
|
||||
* 支付方式,0:余额支付,1:微信支付,2:支付宝支付
|
||||
*/
|
||||
|
||||
@@ -28,7 +28,7 @@ abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy
|
||||
|
||||
PlayOrderInfoEntity order = service.createOrderRecord(context);
|
||||
|
||||
if (command.isDeductBalance()) {
|
||||
if (command.isDeductBalance() && service.shouldDeductBalance(context)) {
|
||||
service.deductCustomerBalance(
|
||||
context.getPurchaserBy(),
|
||||
service.normalizeMoney(paymentInfo.getFinalAmount()),
|
||||
|
||||
@@ -160,6 +160,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
|
||||
throw new CustomException("下单类型不能为空");
|
||||
}
|
||||
|
||||
validateCouponUsage(context);
|
||||
|
||||
OrderConstant.RewardType rewardType = context.getRewardType() != null
|
||||
? context.getRewardType()
|
||||
: OrderConstant.RewardType.NOT_APPLICABLE;
|
||||
@@ -206,6 +208,14 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
boolean shouldDeductBalance(OrderCreationContext context) {
|
||||
if (context == null) {
|
||||
return true;
|
||||
}
|
||||
OrderConstant.PaymentSource paymentSource = context.resolvePaymentSource();
|
||||
return paymentSource != OrderConstant.PaymentSource.BLIND_BOX;
|
||||
}
|
||||
|
||||
OrderAmountBreakdown calculateOrderAmounts(OrderPlacementCommand.PricingInput pricingInput) {
|
||||
int quantity = pricingInput.getQuantity();
|
||||
if (quantity <= 0) {
|
||||
@@ -566,6 +576,25 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCouponUsage(OrderCreationContext context) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
PaymentInfo paymentInfo = context.getPaymentInfo();
|
||||
if (paymentInfo == null || CollectionUtil.isEmpty(paymentInfo.getCouponIds())) {
|
||||
return;
|
||||
}
|
||||
boolean isBlindBoxPurchase = context.getOrderType() == OrderConstant.OrderType.BLIND_BOX_PURCHASE;
|
||||
boolean isBlindBoxDispatch = context.resolvePaymentSource() == OrderConstant.PaymentSource.BLIND_BOX
|
||||
|| StrUtil.isNotBlank(context.getSourceRewardId());
|
||||
boolean isRewardOrder = context.getPlaceType() == OrderConstant.PlaceType.REWARD
|
||||
|| context.getRewardType() == OrderConstant.RewardType.GIFT
|
||||
|| context.getRewardType() == OrderConstant.RewardType.BALANCE;
|
||||
if (isBlindBoxPurchase || isBlindBoxDispatch || isRewardOrder) {
|
||||
throw new CustomException("盲盒/礼物订单暂不支持使用优惠券");
|
||||
}
|
||||
}
|
||||
|
||||
private PlayOrderInfoEntity buildOrderEntity(OrderCreationContext context) {
|
||||
PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
|
||||
entity.setId(context.getOrderId());
|
||||
@@ -597,6 +626,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
|
||||
? YesNoFlag.YES.getCode()
|
||||
: YesNoFlag.NO.getCode());
|
||||
entity.setPayMethod(resolvePayMethod(paymentInfo.getPayMethod()));
|
||||
entity.setPaymentSource(context.resolvePaymentSource().getCode());
|
||||
entity.setSourceRewardId(context.getSourceRewardId());
|
||||
|
||||
entity.setPurchaserBy(context.getPurchaserBy());
|
||||
entity.setPurchaserTime(LocalDateTime.now());
|
||||
|
||||
@@ -24,6 +24,7 @@ class OtherOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
|
||||
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
|
||||
.couponIds(info.getCouponIds())
|
||||
.payMethod(info.getPayMethod())
|
||||
.paymentSource(info.getPaymentSource())
|
||||
.build());
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class RewardGiftOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
|
||||
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
|
||||
.couponIds(info.getCouponIds())
|
||||
.payMethod(info.getPayMethod())
|
||||
.paymentSource(info.getPaymentSource())
|
||||
.build();
|
||||
context.setPaymentInfo(normalized);
|
||||
return normalized;
|
||||
|
||||
@@ -33,6 +33,7 @@ class RewardTipOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
|
||||
.finalAmount(service.normalizeMoney(info.getFinalAmount()))
|
||||
.couponIds(info.getCouponIds())
|
||||
.payMethod(info.getPayMethod())
|
||||
.paymentSource(info.getPaymentSource())
|
||||
.build();
|
||||
context.setPaymentInfo(normalized);
|
||||
return normalized;
|
||||
|
||||
@@ -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:普通礼物)
|
||||
*
|
||||
* @deprecated 盲盒已迁移至 blind_box_config,请勿再依赖该字段判断
|
||||
*/
|
||||
@Deprecated
|
||||
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;
|
||||
Reference in New Issue
Block a user