feat: 实现盲盒功能模块

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

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

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

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

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

View File

@@ -0,0 +1,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");
}
}
}

View File

@@ -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));
}
}

View File

@@ -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> {
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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());
}
}

View File

@@ -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("盲盒奖池库存不足,请稍后再试");
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
/**
* 下单类型枚举
*/

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -132,6 +132,16 @@ public class PlayOrderInfoEntity extends BaseEntity<PlayOrderInfoEntity> {
*/
private String backendEntry;
/**
* 支付来源(区分余额、三方、盲盒奖励等)。
*/
private String paymentSource;
/**
* 盲盒奖励引用ID。
*/
private String sourceRewardId;
/**
* 支付方式0余额支付,1:微信支付,2:支付宝支付
*/

View File

@@ -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()),

View File

@@ -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());

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -42,7 +42,10 @@ public class PlayGiftInfoEntity extends BaseEntity<PlayGiftInfoEntity> {
/**
* 礼物类型0盲盒,1:普通礼物)
*
* @deprecated 盲盒已迁移至 blind_box_config请勿再依赖该字段判断
*/
@Deprecated
private String type;
/**