Compare commits

...

3 Commits

Author SHA1 Message Date
irving
8f89955405 fix: fix performance salary calculation
Some checks failed
Build and Push Backend / docker (push) Failing after 6s
2025-10-31 21:31:56 -04:00
irving
e7ccadaea0 refactor: 盲盒功能代码优化和完善
修复和改进:
- 修复字段映射:blind_box_gift_id -> blind_box_id
- 移除不必要的 @Version 乐观锁字段
- 优化 Mapper 方法:统一使用 listActiveEntries,简化查询逻辑
- 新增客户端接口:盲盒购买、奖励查询和兑现
- 增强权限校验:奖励兑现时验证客户身份
- 完善单元测试:增加客户身份验证测试用例
- 代码格式化:调整 import 顺序,优化代码结构

客户端 API:
- GET /wx/blind-box/config/list - 查询可用盲盒列表
- POST /wx/blind-box/order/purchase - 购买盲盒
- GET /wx/blind-box/reward/list - 查询我的盲盒奖励
- POST /wx/blind-box/reward/{id}/dispatch - 兑现盲盒奖励

其他优化:
- 增强 SQL 查询安全性,添加 deleted 字段过滤
- 优化店员提成计算逻辑
- 改进参数可选性(levelId 参数改为可选)
2025-10-31 02:48:03 -04:00
irving
422e781c60 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 字段

其他改进:
- 订单模块支持盲盒支付来源,区分余额扣款和奖励抵扣
- 优惠券校验:盲盒相关订单不支持使用优惠券
- 完善单元测试覆盖
2025-10-31 02:46:51 -04:00
56 changed files with 2743 additions and 64 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.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
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 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_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_id = #{blindBoxId}",
" AND status = 1",
" AND deleted = 0",
" 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> listActiveEntries(
@Param("tenantId") String tenantId,
@Param("blindBoxId") String blindBoxId,
@Param("now") LocalDateTime now);
@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 deleted = 0",
" 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,46 @@
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 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 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);
@Select({
"SELECT * FROM blind_box_reward",
"WHERE tenant_id = #{tenantId}",
" AND customer_id = #{customerId}",
" AND deleted = 0",
" AND (#{status} IS NULL OR status = #{status})",
"ORDER BY created_time DESC"
})
List<BlindBoxRewardEntity> listByCustomer(
@Param("tenantId") String tenantId,
@Param("customerId") String customerId,
@Param("status") String status);
}

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,44 @@
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_reward")
public class BlindBoxRewardEntity extends BaseEntity<BlindBoxRewardEntity> {
private String id;
private String tenantId;
private String customerId;
@TableField("blind_box_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;
}

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,85 @@
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.IPlayClerkGiftInfoService;
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;
private final IPlayClerkGiftInfoService clerkGiftInfoService;
@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();
OrderPlacementResult result = orderLifecycleService.placeOrder(OrderPlacementCommand.builder()
.orderContext(context)
.balanceOperationAction("盲盒奖励兑现")
.build());
if (clerkId != null) {
clerkGiftInfoService.incrementGiftCount(clerkId, giftInfo.getId(), reward.getTenantId(), 1);
}
return result;
}
}

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,391 @@
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.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,176 @@
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.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.listActiveEntries(tenantId, blindBoxId, now);
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, String customerId) {
BlindBoxRewardEntity reward = rewardMapper.lockByIdForUpdate(rewardId);
if (reward == null) {
throw new CustomException("盲盒奖励不存在");
}
if (customerId != null && !customerId.equals(reward.getCustomerId())) {
throw new CustomException("无权操作该盲盒奖励");
}
LocalDateTime now = LocalDateTime.now(clock);
if (!BlindBoxRewardStatus.UNUSED.getCode().equals(reward.getStatus())) {
throw new CustomException("盲盒奖励已使用");
}
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 java.util.List<BlindBoxRewardEntity> listRewards(String tenantId, String customerId, String status) {
return rewardMapper.listByCustomer(tenantId, customerId, status);
}
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.constant.BlindBoxConfigStatus;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import java.util.List;
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", "退款订单"), REFUND("-1", "退款订单"),
RECHARGE("0", "充值订单"), RECHARGE("0", "充值订单"),
WITHDRAWAL("1", "提现订单"), WITHDRAWAL("1", "提现订单"),
NORMAL("2", "普通订单"); NORMAL("2", "普通订单"),
GIFT("3", "礼物订单"),
BLIND_BOX_PURCHASE("4", "盲盒购买订单");
private final String code; private final String code;
private final String description; private final String description;
@@ -67,6 +69,31 @@ public class OrderConstant {
} }
} }
@Getter
public enum PaymentSource {
BALANCE("BALANCE", "余额扣款"),
WX_PAY("WX_PAY", "微信支付"),
ALI_PAY("ALI_PAY", "支付宝支付"),
BLIND_BOX("BLIND_BOX", "盲盒奖励抵扣");
private final String code;
private final String description;
PaymentSource(String code, String description) {
this.code = code;
this.description = description;
}
public static PaymentSource fromCode(String code) {
for (PaymentSource source : values()) {
if (source.code.equals(code)) {
return source;
}
}
throw new IllegalArgumentException("Unknown payment source code: " + code);
}
}
/** /**
* 下单类型枚举 * 下单类型枚举
*/ */

View File

@@ -35,6 +35,10 @@ public class OrderCreationContext {
private boolean isFirstOrder; private boolean isFirstOrder;
private OrderConstant.PaymentSource paymentSource;
private String sourceRewardId;
@Valid @Valid
@NotNull(message = "商品信息不能为空") @NotNull(message = "商品信息不能为空")
private CommodityInfo commodityInfo; private CommodityInfo commodityInfo;
@@ -75,4 +79,13 @@ public class OrderCreationContext {
public boolean isSpecifiedOrder() { public boolean isSpecifiedOrder() {
return placeType == OrderConstant.PlaceType.SPECIFIED; return placeType == OrderConstant.PlaceType.SPECIFIED;
} }
public OrderConstant.PaymentSource resolvePaymentSource() {
if (paymentSource != null) {
return paymentSource;
}
return paymentInfo != null && paymentInfo.getPaymentSource() != null
? paymentInfo.getPaymentSource()
: OrderConstant.PaymentSource.BALANCE;
}
} }

View File

@@ -1,5 +1,6 @@
package com.starry.admin.modules.order.module.dto; package com.starry.admin.modules.order.module.dto;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import lombok.Builder; import lombok.Builder;
@@ -37,4 +38,7 @@ public class PaymentInfo {
* 支付方式0余额支付,1:微信支付,2:支付宝支付 * 支付方式0余额支付,1:微信支付,2:支付宝支付
*/ */
private String payMethod; private String payMethod;
@Builder.Default
private OrderConstant.PaymentSource paymentSource = OrderConstant.PaymentSource.BALANCE;
} }

View File

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

View File

@@ -28,7 +28,7 @@ abstract class AbstractOrderPlacementStrategy implements OrderPlacementStrategy
PlayOrderInfoEntity order = service.createOrderRecord(context); PlayOrderInfoEntity order = service.createOrderRecord(context);
if (command.isDeductBalance()) { if (command.isDeductBalance() && service.shouldDeductBalance(context)) {
service.deductCustomerBalance( service.deductCustomerBalance(
context.getPurchaserBy(), context.getPurchaserBy(),
service.normalizeMoney(paymentInfo.getFinalAmount()), service.normalizeMoney(paymentInfo.getFinalAmount()),

View File

@@ -160,6 +160,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
throw new CustomException("下单类型不能为空"); throw new CustomException("下单类型不能为空");
} }
validateCouponUsage(context);
OrderConstant.RewardType rewardType = context.getRewardType() != null OrderConstant.RewardType rewardType = context.getRewardType() != null
? context.getRewardType() ? context.getRewardType()
: OrderConstant.RewardType.NOT_APPLICABLE; : OrderConstant.RewardType.NOT_APPLICABLE;
@@ -206,6 +208,14 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
return entity; return entity;
} }
boolean shouldDeductBalance(OrderCreationContext context) {
if (context == null) {
return true;
}
OrderConstant.PaymentSource paymentSource = context.resolvePaymentSource();
return paymentSource != OrderConstant.PaymentSource.BLIND_BOX;
}
OrderAmountBreakdown calculateOrderAmounts(OrderPlacementCommand.PricingInput pricingInput) { OrderAmountBreakdown calculateOrderAmounts(OrderPlacementCommand.PricingInput pricingInput) {
int quantity = pricingInput.getQuantity(); int quantity = pricingInput.getQuantity();
if (quantity <= 0) { if (quantity <= 0) {
@@ -566,6 +576,25 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
} }
} }
private void validateCouponUsage(OrderCreationContext context) {
if (context == null) {
return;
}
PaymentInfo paymentInfo = context.getPaymentInfo();
if (paymentInfo == null || CollectionUtil.isEmpty(paymentInfo.getCouponIds())) {
return;
}
boolean isBlindBoxPurchase = context.getOrderType() == OrderConstant.OrderType.BLIND_BOX_PURCHASE;
boolean isBlindBoxDispatch = context.resolvePaymentSource() == OrderConstant.PaymentSource.BLIND_BOX
|| StrUtil.isNotBlank(context.getSourceRewardId());
boolean isRewardOrder = context.getPlaceType() == OrderConstant.PlaceType.REWARD
|| context.getRewardType() == OrderConstant.RewardType.GIFT
|| context.getRewardType() == OrderConstant.RewardType.BALANCE;
if (isBlindBoxPurchase || isBlindBoxDispatch || isRewardOrder) {
throw new CustomException("盲盒/礼物订单暂不支持使用优惠券");
}
}
private PlayOrderInfoEntity buildOrderEntity(OrderCreationContext context) { private PlayOrderInfoEntity buildOrderEntity(OrderCreationContext context) {
PlayOrderInfoEntity entity = new PlayOrderInfoEntity(); PlayOrderInfoEntity entity = new PlayOrderInfoEntity();
entity.setId(context.getOrderId()); entity.setId(context.getOrderId());
@@ -597,6 +626,8 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
? YesNoFlag.YES.getCode() ? YesNoFlag.YES.getCode()
: YesNoFlag.NO.getCode()); : YesNoFlag.NO.getCode());
entity.setPayMethod(resolvePayMethod(paymentInfo.getPayMethod())); entity.setPayMethod(resolvePayMethod(paymentInfo.getPayMethod()));
entity.setPaymentSource(context.resolvePaymentSource().getCode());
entity.setSourceRewardId(context.getSourceRewardId());
entity.setPurchaserBy(context.getPurchaserBy()); entity.setPurchaserBy(context.getPurchaserBy());
entity.setPurchaserTime(LocalDateTime.now()); entity.setPurchaserTime(LocalDateTime.now());

View File

@@ -24,6 +24,7 @@ class OtherOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
.finalAmount(service.normalizeMoney(info.getFinalAmount())) .finalAmount(service.normalizeMoney(info.getFinalAmount()))
.couponIds(info.getCouponIds()) .couponIds(info.getCouponIds())
.payMethod(info.getPayMethod()) .payMethod(info.getPayMethod())
.paymentSource(info.getPaymentSource())
.build()); .build());
return null; return null;
} }

View File

@@ -45,6 +45,7 @@ import com.starry.admin.modules.shop.service.IPlayCouponDetailsService;
import com.starry.admin.modules.weichat.entity.order.*; import com.starry.admin.modules.weichat.entity.order.*;
import com.starry.admin.modules.weichat.service.WxCustomMpService; import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.IEarningsService;
import com.starry.admin.utils.DateRangeUtils;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.common.utils.ConvertUtil; import com.starry.common.utils.ConvertUtil;
import com.starry.common.utils.IdUtils; import com.starry.common.utils.IdUtils;
@@ -456,8 +457,17 @@ public class PlayOrderInfoServiceImpl extends ServiceImpl<PlayOrderInfoMapper, P
public List<PlayOrderInfoEntity> clerkSelectOrderInfoList(String clerkId, String startTime, String endTime) { public List<PlayOrderInfoEntity> clerkSelectOrderInfoList(String clerkId, String startTime, String endTime) {
LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PlayOrderInfoEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(PlayOrderInfoEntity::getAcceptBy, clerkId); lambdaQueryWrapper.eq(PlayOrderInfoEntity::getAcceptBy, clerkId);
if (StringUtils.isNotBlank(startTime) && StringUtils.isNotBlank(endTime)) { String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime);
lambdaQueryWrapper.between(PlayOrderInfoEntity::getPurchaserTime, startTime, endTime); String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime);
if (StrUtil.isNotBlank(normalizedStart) && StrUtil.isNotBlank(normalizedEnd)) {
lambdaQueryWrapper.between(PlayOrderInfoEntity::getPurchaserTime, normalizedStart, normalizedEnd);
} else {
if (StrUtil.isNotBlank(normalizedStart)) {
lambdaQueryWrapper.ge(PlayOrderInfoEntity::getPurchaserTime, normalizedStart);
}
if (StrUtil.isNotBlank(normalizedEnd)) {
lambdaQueryWrapper.le(PlayOrderInfoEntity::getPurchaserTime, normalizedEnd);
}
} }
return this.baseMapper.selectList(lambdaQueryWrapper); return this.baseMapper.selectList(lambdaQueryWrapper);
} }

View File

@@ -34,6 +34,7 @@ class RewardGiftOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
.finalAmount(service.normalizeMoney(info.getFinalAmount())) .finalAmount(service.normalizeMoney(info.getFinalAmount()))
.couponIds(info.getCouponIds()) .couponIds(info.getCouponIds())
.payMethod(info.getPayMethod()) .payMethod(info.getPayMethod())
.paymentSource(info.getPaymentSource())
.build(); .build();
context.setPaymentInfo(normalized); context.setPaymentInfo(normalized);
return normalized; return normalized;

View File

@@ -33,6 +33,7 @@ class RewardTipOrderPlacementStrategy extends AbstractOrderPlacementStrategy {
.finalAmount(service.normalizeMoney(info.getFinalAmount())) .finalAmount(service.normalizeMoney(info.getFinalAmount()))
.couponIds(info.getCouponIds()) .couponIds(info.getCouponIds())
.payMethod(info.getPayMethod()) .payMethod(info.getPayMethod())
.paymentSource(info.getPaymentSource())
.build(); .build();
context.setPaymentInfo(normalized); context.setPaymentInfo(normalized);
return normalized; return normalized;

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:普通礼物) * 礼物类型0盲盒,1:普通礼物)
*
* @deprecated 盲盒已迁移至 blind_box_config请勿再依赖该字段判断
*/ */
@Deprecated
private String type; private String type;
/** /**

View File

@@ -16,6 +16,7 @@ import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewRes
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoQueryVo; import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoQueryVo;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo; import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo;
import com.starry.admin.modules.statistics.service.IPlayClerkPerformanceService; import com.starry.admin.modules.statistics.service.IPlayClerkPerformanceService;
import com.starry.admin.utils.DateRangeUtils;
import com.starry.common.result.R; import com.starry.common.result.R;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
@@ -69,19 +70,27 @@ public class PlayClerkPerformanceController {
public R listByPage( public R listByPage(
@ApiParam(value = "查询条件", required = true) @Validated @RequestBody PlayClerkPerformanceInfoQueryVo vo) { @ApiParam(value = "查询条件", required = true) @Validated @RequestBody PlayClerkPerformanceInfoQueryVo vo) {
IPage<PlayClerkUserInfoEntity> page = clerkUserInfoService.selectByPage(vo); IPage<PlayClerkUserInfoEntity> page = clerkUserInfoService.selectByPage(vo);
IPage<PlayClerkPerformanceInfoReturnVo> voPage = page.convert(u -> { IPage<PlayClerkPerformanceInfoReturnVo> voPage = page.convert(user -> {
List<PlayClerkLevelInfoEntity> clerkLevelInfoEntity = playClerkLevelInfoService.selectAll(); List<PlayClerkLevelInfoEntity> clerkLevelInfoEntity = playClerkLevelInfoService.selectAll();
String startTime = vo.getEndOrderTime() != null ? vo.getEndOrderTime().get(0) : ""; String rawStart = vo.getEndOrderTime() != null && vo.getEndOrderTime().size() > 0
String endTime = vo.getEndOrderTime() != null ? vo.getEndOrderTime().get(1) : ""; ? vo.getEndOrderTime().get(0)
List<PlayOrderInfoEntity> orderInfoEntities = playOrderInfoService.clerkSelectOrderInfoList(u.getId(), : null;
String rawEnd = vo.getEndOrderTime() != null && vo.getEndOrderTime().size() > 1
? vo.getEndOrderTime().get(1)
: null;
String startTime = DateRangeUtils.normalizeStartOptional(rawStart);
String endTime = DateRangeUtils.normalizeEndOptional(rawEnd);
List<PlayOrderInfoEntity> orderInfoEntities = playOrderInfoService.clerkSelectOrderInfoList(user.getId(),
startTime, endTime); startTime, endTime);
List<PlayPersonnelGroupInfoEntity> groupInfoEntities = playPersonnelGroupInfoService.selectAll(); List<PlayPersonnelGroupInfoEntity> groupInfoEntities = playPersonnelGroupInfoService.selectAll();
return playClerkPerformanceService.getClerkPerformanceInfo(u, orderInfoEntities, clerkLevelInfoEntity,
groupInfoEntities); return playClerkPerformanceService.getClerkPerformanceInfo(user, orderInfoEntities, clerkLevelInfoEntity,
groupInfoEntities, startTime, endTime);
}); });
voPage.setRecords(voPage.getRecords().stream() voPage.setRecords(voPage.getRecords().stream()
.sorted(Comparator.comparing(PlayClerkPerformanceInfoReturnVo::getOrderNumber).reversed()) .sorted(Comparator.comparing(PlayClerkPerformanceInfoReturnVo::getOrderNumber).reversed())
.collect(Collectors.toList())); .collect(Collectors.toList()));
return R.ok(voPage); return R.ok(voPage);
} }

View File

@@ -32,7 +32,7 @@ public interface IPlayClerkPerformanceService {
*/ */
PlayClerkPerformanceInfoReturnVo getClerkPerformanceInfo(PlayClerkUserInfoEntity userInfo, PlayClerkPerformanceInfoReturnVo getClerkPerformanceInfo(PlayClerkUserInfoEntity userInfo,
List<PlayOrderInfoEntity> orderInfoEntities, List<PlayClerkLevelInfoEntity> clerkLevelInfoEntity, List<PlayOrderInfoEntity> orderInfoEntities, List<PlayClerkLevelInfoEntity> clerkLevelInfoEntity,
List<PlayPersonnelGroupInfoEntity> groupInfoEntities); List<PlayPersonnelGroupInfoEntity> groupInfoEntities, String startTime, String endTime);
ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo); ClerkPerformanceOverviewResponseVo queryOverview(ClerkPerformanceOverviewQueryVo vo);

View File

@@ -9,6 +9,7 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkLevelInfoEntity;
import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkLevelInfoService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IPlayOrderInfoService; import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity; import com.starry.admin.modules.personnel.module.entity.PlayPersonnelGroupInfoEntity;
@@ -26,12 +27,13 @@ import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceSnapshotVo;
import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceTrendPointVo; import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceTrendPointVo;
import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo; import com.starry.admin.modules.statistics.module.vo.PlayClerkPerformanceInfoReturnVo;
import com.starry.admin.modules.statistics.service.IPlayClerkPerformanceService; import com.starry.admin.modules.statistics.service.IPlayClerkPerformanceService;
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
import com.starry.admin.utils.DateRangeUtils;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
@@ -51,9 +53,6 @@ import org.springframework.stereotype.Service;
@Service @Service
public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceService { public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Resource @Resource
private IPlayClerkUserInfoService clerkUserInfoService; private IPlayClerkUserInfoService clerkUserInfoService;
@@ -66,10 +65,14 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
@Resource @Resource
private IPlayPersonnelGroupInfoService playPersonnelGroupInfoService; private IPlayPersonnelGroupInfoService playPersonnelGroupInfoService;
@Resource
private EarningsLineMapper earningsLineMapper;
@Override @Override
public PlayClerkPerformanceInfoReturnVo getClerkPerformanceInfo(PlayClerkUserInfoEntity userInfo, public PlayClerkPerformanceInfoReturnVo getClerkPerformanceInfo(PlayClerkUserInfoEntity userInfo,
List<PlayOrderInfoEntity> orderInfoEntities, List<PlayClerkLevelInfoEntity> clerkLevelInfoEntities, List<PlayOrderInfoEntity> orderInfoEntities, List<PlayClerkLevelInfoEntity> clerkLevelInfoEntities,
List<PlayPersonnelGroupInfoEntity> groupInfoEntities) { List<PlayPersonnelGroupInfoEntity> groupInfoEntities, String startTime, String endTime) {
Set<String> customIds = new HashSet<>(); Set<String> customIds = new HashSet<>();
int orderContinueNumber = 0; int orderContinueNumber = 0;
int orderRefundNumber = 0; int orderRefundNumber = 0;
@@ -79,28 +82,31 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
BigDecimal orderTotalAmount = BigDecimal.ZERO; BigDecimal orderTotalAmount = BigDecimal.ZERO;
BigDecimal orderRewardAmount = BigDecimal.ZERO; BigDecimal orderRewardAmount = BigDecimal.ZERO;
BigDecimal orderRefundAmount = BigDecimal.ZERO; BigDecimal orderRefundAmount = BigDecimal.ZERO;
BigDecimal estimatedRevenue = BigDecimal.ZERO;
for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) { for (PlayOrderInfoEntity orderInfoEntity : orderInfoEntities) {
customIds.add(orderInfoEntity.getPurchaserBy()); customIds.add(orderInfoEntity.getPurchaserBy());
finalAmount = finalAmount.add(defaultZero(orderInfoEntity.getFinalAmount())); finalAmount = finalAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
if ("1".equals(orderInfoEntity.getFirstOrder())) { if (OrderConstant.YesNoFlag.YES.getCode().equals(orderInfoEntity.getFirstOrder())) {
orderFirstAmount = orderFirstAmount.add(defaultZero(orderInfoEntity.getFinalAmount())); orderFirstAmount = orderFirstAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
} else { } else {
orderContinueNumber++; orderContinueNumber++;
orderTotalAmount = orderTotalAmount.add(defaultZero(orderInfoEntity.getFinalAmount())); orderTotalAmount = orderTotalAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
} }
if ("2".equals(orderInfoEntity.getPlaceType())) { if (OrderConstant.PlaceType.REWARD.getCode().equals(orderInfoEntity.getPlaceType())) {
orderRewardAmount = orderRewardAmount.add(defaultZero(orderInfoEntity.getFinalAmount())); orderRewardAmount = orderRewardAmount.add(defaultZero(orderInfoEntity.getFinalAmount()));
} }
if ("1".equals(orderInfoEntity.getRefundType())) { if (OrderConstant.OrderRefundFlag.REFUNDED.getCode().equals(orderInfoEntity.getRefundType())) {
orderRefundNumber++; orderRefundNumber++;
orderRefundAmount = orderRefundAmount.add(defaultZero(orderInfoEntity.getRefundAmount())); orderRefundAmount = orderRefundAmount.add(defaultZero(orderInfoEntity.getRefundAmount()));
} }
if ("1".equals(orderInfoEntity.getOrdersExpiredState())) { if (OrderConstant.OrdersExpiredState.EXPIRED.getCode().equals(orderInfoEntity.getOrdersExpiredState())) {
ordersExpiredNumber++; ordersExpiredNumber++;
} }
estimatedRevenue = estimatedRevenue.add(defaultZero(orderInfoEntity.getEstimatedRevenue()));
} }
BigDecimal estimatedRevenue =
calculateEarningsAmount(userInfo.getId(), orderInfoEntities, startTime, endTime);
PlayClerkPerformanceInfoReturnVo returnVo = new PlayClerkPerformanceInfoReturnVo(); PlayClerkPerformanceInfoReturnVo returnVo = new PlayClerkPerformanceInfoReturnVo();
returnVo.setClerkId(userInfo.getId()); returnVo.setClerkId(userInfo.getId());
returnVo.setClerkNickname(userInfo.getNickname()); returnVo.setClerkNickname(userInfo.getNickname());
@@ -149,7 +155,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
for (PlayClerkUserInfoEntity clerk : clerks) { for (PlayClerkUserInfoEntity clerk : clerks) {
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(), List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
range.startTime, range.endTime); range.startTime, range.endTime);
snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap)); snapshots.add(buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime));
} }
int total = snapshots.size(); int total = snapshots.size();
ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots); ClerkPerformanceOverviewSummaryVo summary = aggregateSummary(snapshots);
@@ -184,7 +190,8 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
(a, b) -> a)); (a, b) -> a));
List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(), List<PlayOrderInfoEntity> orders = playOrderInfoService.clerkSelectOrderInfoList(clerk.getId(),
range.startTime, range.endTime); range.startTime, range.endTime);
ClerkPerformanceSnapshotVo snapshot = buildSnapshot(clerk, orders, levelNameMap, groupNameMap); ClerkPerformanceSnapshotVo snapshot =
buildSnapshot(clerk, orders, levelNameMap, groupNameMap, range.startTime, range.endTime);
ClerkPerformanceDetailResponseVo responseVo = new ClerkPerformanceDetailResponseVo(); ClerkPerformanceDetailResponseVo responseVo = new ClerkPerformanceDetailResponseVo();
responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap)); responseVo.setProfile(buildProfile(clerk, levelNameMap, groupNameMap));
responseVo.setSnapshot(snapshot); responseVo.setSnapshot(snapshot);
@@ -262,7 +269,7 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
for (PlayOrderInfoEntity order : orders) { for (PlayOrderInfoEntity order : orders) {
BigDecimal finalAmount = defaultZero(order.getFinalAmount()); BigDecimal finalAmount = defaultZero(order.getFinalAmount());
gmv = gmv.add(finalAmount); gmv = gmv.add(finalAmount);
if ("1".equals(order.getFirstOrder())) { if (OrderConstant.YesNoFlag.YES.getCode().equals(order.getFirstOrder())) {
firstAmount = firstAmount.add(finalAmount); firstAmount = firstAmount.add(finalAmount);
} else { } else {
continuedAmount = continuedAmount.add(finalAmount); continuedAmount = continuedAmount.add(finalAmount);
@@ -408,8 +415,27 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
return summary; return summary;
} }
private BigDecimal calculateEarningsAmount(String clerkId, List<PlayOrderInfoEntity> orders, String startTime,
String endTime) {
if (StrUtil.isBlank(clerkId) || CollectionUtil.isEmpty(orders)) {
return BigDecimal.ZERO;
}
List<String> orderIds = orders.stream()
.map(PlayOrderInfoEntity::getId)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toList());
if (CollectionUtil.isEmpty(orderIds)) {
return BigDecimal.ZERO;
}
String normalizedStart = DateRangeUtils.normalizeStartOptional(startTime);
String normalizedEnd = DateRangeUtils.normalizeEndOptional(endTime);
BigDecimal sum = earningsLineMapper.sumAmountByClerkAndOrderIds(clerkId, orderIds, normalizedStart,
normalizedEnd);
return defaultZero(sum);
}
private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List<PlayOrderInfoEntity> orders, private ClerkPerformanceSnapshotVo buildSnapshot(PlayClerkUserInfoEntity clerk, List<PlayOrderInfoEntity> orders,
Map<String, String> levelNameMap, Map<String, String> groupNameMap) { Map<String, String> levelNameMap, Map<String, String> groupNameMap, String startTime, String endTime) {
ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo(); ClerkPerformanceSnapshotVo snapshot = new ClerkPerformanceSnapshotVo();
snapshot.setClerkId(clerk.getId()); snapshot.setClerkId(clerk.getId());
snapshot.setClerkNickname(clerk.getNickname()); snapshot.setClerkNickname(clerk.getNickname());
@@ -422,7 +448,6 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
BigDecimal continuedAmount = BigDecimal.ZERO; BigDecimal continuedAmount = BigDecimal.ZERO;
BigDecimal rewardAmount = BigDecimal.ZERO; BigDecimal rewardAmount = BigDecimal.ZERO;
BigDecimal refundAmount = BigDecimal.ZERO; BigDecimal refundAmount = BigDecimal.ZERO;
BigDecimal estimatedRevenue = BigDecimal.ZERO;
int firstCount = 0; int firstCount = 0;
int continuedCount = 0; int continuedCount = 0;
int refundCount = 0; int refundCount = 0;
@@ -432,28 +457,28 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
BigDecimal finalAmount = defaultZero(order.getFinalAmount()); BigDecimal finalAmount = defaultZero(order.getFinalAmount());
gmv = gmv.add(finalAmount); gmv = gmv.add(finalAmount);
userOrderMap.merge(order.getPurchaserBy(), 1, Integer::sum); userOrderMap.merge(order.getPurchaserBy(), 1, Integer::sum);
if ("1".equals(order.getFirstOrder())) { if (OrderConstant.YesNoFlag.YES.getCode().equals(order.getFirstOrder())) {
firstCount++; firstCount++;
firstAmount = firstAmount.add(finalAmount); firstAmount = firstAmount.add(finalAmount);
} else { } else {
continuedCount++; continuedCount++;
continuedAmount = continuedAmount.add(finalAmount); continuedAmount = continuedAmount.add(finalAmount);
} }
if ("2".equals(order.getPlaceType())) { if (OrderConstant.PlaceType.REWARD.getCode().equals(order.getPlaceType())) {
rewardAmount = rewardAmount.add(finalAmount); rewardAmount = rewardAmount.add(finalAmount);
} }
if ("1".equals(order.getRefundType())) { if (OrderConstant.OrderRefundFlag.REFUNDED.getCode().equals(order.getRefundType())) {
refundCount++; refundCount++;
refundAmount = refundAmount.add(defaultZero(order.getRefundAmount())); refundAmount = refundAmount.add(defaultZero(order.getRefundAmount()));
} }
if ("1".equals(order.getOrdersExpiredState())) { if (OrderConstant.OrdersExpiredState.EXPIRED.getCode().equals(order.getOrdersExpiredState())) {
expiredCount++; expiredCount++;
} }
estimatedRevenue = estimatedRevenue.add(defaultZero(order.getEstimatedRevenue()));
} }
int orderCount = orders.size(); int orderCount = orders.size();
int userCount = userOrderMap.size(); int userCount = userOrderMap.size();
int continuedUserCount = (int) userOrderMap.values().stream().filter(cnt -> cnt > 1).count(); int continuedUserCount = (int) userOrderMap.values().stream().filter(cnt -> cnt > 1).count();
BigDecimal estimatedRevenue = calculateEarningsAmount(clerk.getId(), orders, startTime, endTime);
snapshot.setGmv(gmv); snapshot.setGmv(gmv);
snapshot.setFirstOrderAmount(firstAmount); snapshot.setFirstOrderAmount(firstAmount);
snapshot.setContinuedOrderAmount(continuedAmount); snapshot.setContinuedOrderAmount(continuedAmount);
@@ -504,41 +529,19 @@ public class PlayClerkPerformanceServiceImpl implements IPlayClerkPerformanceSer
String startStr; String startStr;
String endStr; String endStr;
if (CollectionUtil.isNotEmpty(endOrderTime) && endOrderTime.size() >= 2) { if (CollectionUtil.isNotEmpty(endOrderTime) && endOrderTime.size() >= 2) {
startStr = normalizeStart(endOrderTime.get(0)); startStr = DateRangeUtils.normalizeStart(endOrderTime.get(0));
endStr = normalizeEnd(endOrderTime.get(1)); endStr = DateRangeUtils.normalizeEnd(endOrderTime.get(1));
} else { } else {
LocalDate end = LocalDate.now(); LocalDate end = LocalDate.now();
LocalDate start = end.minusDays(6); LocalDate start = end.minusDays(6);
startStr = start.format(DATE_FORMATTER) + " 00:00:00"; startStr = start.format(DateRangeUtils.DATE_FORMATTER) + " 00:00:00";
endStr = end.format(DATE_FORMATTER) + " 23:59:59"; endStr = end.format(DateRangeUtils.DATE_FORMATTER) + " 23:59:59";
} }
LocalDate startDate = LocalDate.parse(startStr.substring(0, 10), DATE_FORMATTER); LocalDate startDate = LocalDate.parse(startStr.substring(0, 10), DateRangeUtils.DATE_FORMATTER);
LocalDate endDate = LocalDate.parse(endStr.substring(0, 10), DATE_FORMATTER); LocalDate endDate = LocalDate.parse(endStr.substring(0, 10), DateRangeUtils.DATE_FORMATTER);
return new DateRange(startStr, endStr, startDate, endDate); return new DateRange(startStr, endStr, startDate, endDate);
} }
private String normalizeStart(String raw) {
if (StrUtil.isBlank(raw)) {
return LocalDate.now().minusDays(6).format(DATE_FORMATTER) + " 00:00:00";
}
if (raw.length() > 10) {
LocalDateTime.parse(raw, DATE_TIME_FORMATTER);
return raw;
}
return raw + " 00:00:00";
}
private String normalizeEnd(String raw) {
if (StrUtil.isBlank(raw)) {
return LocalDate.now().format(DATE_FORMATTER) + " 23:59:59";
}
if (raw.length() > 10) {
LocalDateTime.parse(raw, DATE_TIME_FORMATTER);
return raw;
}
return raw + " 23:59:59";
}
private BigDecimal calcPercentage(int numerator, int denominator) { private BigDecimal calcPercentage(int numerator, int denominator) {
if (denominator <= 0) { if (denominator <= 0) {
return BigDecimal.ZERO; return BigDecimal.ZERO;

View File

@@ -0,0 +1,89 @@
package com.starry.admin.modules.weichat.controller;
import cn.hutool.core.collection.CollUtil;
import com.starry.admin.common.aspect.CustomUserLogin;
import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.weichat.entity.blindbox.BlindBoxConfigView;
import com.starry.admin.modules.weichat.entity.blindbox.BlindBoxPurchaseRequest;
import com.starry.admin.modules.weichat.entity.blindbox.BlindBoxPurchaseResult;
import com.starry.admin.modules.weichat.entity.blindbox.BlindBoxRewardDispatchRequest;
import com.starry.admin.modules.weichat.entity.blindbox.BlindBoxRewardView;
import com.starry.admin.modules.weichat.service.WxBlindBoxOrderService;
import com.starry.common.result.R;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import javax.validation.Valid;
import org.springframework.validation.annotation.Validated;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/wx/blind-box")
@Validated
public class WxBlindBoxController {
@Resource
private BlindBoxConfigService blindBoxConfigService;
@Resource
private WxBlindBoxOrderService wxBlindBoxOrderService;
@CustomUserLogin
@GetMapping("/config/list")
public R listConfigs() {
PlayCustomUserInfoEntity user = ThreadLocalRequestDetail.getCustomUserInfo();
if (user == null) {
throw new CustomException("用户未登录");
}
List<BlindBoxConfigEntity> configs = blindBoxConfigService.listActiveByTenant(user.getTenantId());
List<BlindBoxConfigView> views = CollUtil.emptyIfNull(configs).stream()
.map(this::toConfigView)
.collect(Collectors.toList());
return R.ok(views);
}
@CustomUserLogin
@PostMapping("/order/purchase")
public R purchase(@Valid @RequestBody BlindBoxPurchaseRequest request) {
BlindBoxPurchaseResult result = wxBlindBoxOrderService.purchase(request);
return R.ok(result);
}
@CustomUserLogin
@PostMapping("/reward/{id}/dispatch")
public R dispatch(@PathVariable("id") String rewardId, @Valid @RequestBody BlindBoxRewardDispatchRequest body) {
BlindBoxRewardView view = wxBlindBoxOrderService.dispatchReward(rewardId, body.getClerkId());
return R.ok(view);
}
@CustomUserLogin
@GetMapping("/reward/list")
public R listRewards(@RequestParam(value = "status", required = false) String status) {
PlayCustomUserInfoEntity user = ThreadLocalRequestDetail.getCustomUserInfo();
if (user == null) {
throw new CustomException("用户未登录");
}
List<BlindBoxRewardView> rewards = wxBlindBoxOrderService.listRewards(user.getTenantId(), user.getId(), status);
return R.ok(rewards);
}
private BlindBoxConfigView toConfigView(BlindBoxConfigEntity entity) {
BlindBoxConfigView view = new BlindBoxConfigView();
view.setId(entity.getId());
view.setName(entity.getName());
view.setCoverUrl(entity.getCoverUrl());
view.setDescription(entity.getDescription());
view.setPrice(entity.getPrice());
return view;
}
}

View File

@@ -57,7 +57,7 @@ public class WxClerkCommodityController {
@ApiResponses({ @ApiResponses({
@ApiResponse(code = 200, message = "操作成功", response = PlayCommodityReturnVo.class, responseContainer = "List")}) @ApiResponse(code = 200, message = "操作成功", response = PlayCommodityReturnVo.class, responseContainer = "List")})
@GetMapping("/custom/queryClerkAllCommodityByLevel") @GetMapping("/custom/queryClerkAllCommodityByLevel")
public R queryClerkAllCommodityByLevel(@RequestParam("id") String levelId) { public R queryClerkAllCommodityByLevel(@RequestParam(value = "id", required = false) String levelId) {
List<PlayCommodityAndLevelInfoEntity> levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll(); List<PlayCommodityAndLevelInfoEntity> levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll();
List<PlayCommodityReturnVo> tree = playCommodityInfoService.selectTree(); List<PlayCommodityReturnVo> tree = playCommodityInfoService.selectTree();
if (levelId == null || levelId.isEmpty()) { if (levelId == null || levelId.isEmpty()) {

View File

@@ -33,6 +33,7 @@ import com.starry.admin.modules.weichat.entity.order.PlayClerkOrderInfoQueryVo;
import com.starry.admin.modules.weichat.entity.order.PlayClerkOrderListReturnVo; import com.starry.admin.modules.weichat.entity.order.PlayClerkOrderListReturnVo;
import com.starry.admin.modules.weichat.service.WxCustomMpService; import com.starry.admin.modules.weichat.service.WxCustomMpService;
import com.starry.admin.modules.weichat.service.WxCustomUserService; import com.starry.admin.modules.weichat.service.WxCustomUserService;
import com.starry.admin.utils.DateRangeUtils;
import com.starry.admin.utils.SecurityUtils; import com.starry.admin.utils.SecurityUtils;
import com.starry.admin.utils.SmsUtils; import com.starry.admin.utils.SmsUtils;
import com.starry.common.redis.RedisCache; import com.starry.common.redis.RedisCache;
@@ -126,11 +127,13 @@ public class WxClerkController {
PlayClerkUserInfoEntity entity = clerkUserInfoService PlayClerkUserInfoEntity entity = clerkUserInfoService
.selectById(ThreadLocalRequestDetail.getClerkUserInfo().getId()); .selectById(ThreadLocalRequestDetail.getClerkUserInfo().getId());
List<PlayClerkLevelInfoEntity> clerkLevelInfoEntity = playClerkLevelInfoService.selectAll(); List<PlayClerkLevelInfoEntity> clerkLevelInfoEntity = playClerkLevelInfoService.selectAll();
String startTime = DateRangeUtils.normalizeStartOptional(vo.getStartTime());
String endTime = DateRangeUtils.normalizeEndOptional(vo.getEndTime());
List<PlayOrderInfoEntity> orderInfoEntities = playOrderInfoService.clerkSelectOrderInfoList(entity.getId(), List<PlayOrderInfoEntity> orderInfoEntities = playOrderInfoService.clerkSelectOrderInfoList(entity.getId(),
vo.getStartTime(), vo.getEndTime()); startTime, endTime);
List<PlayPersonnelGroupInfoEntity> groupInfoEntities = playPersonnelGroupInfoService.selectAll(); List<PlayPersonnelGroupInfoEntity> groupInfoEntities = playPersonnelGroupInfoService.selectAll();
return R.ok(playClerkPerformanceService.getClerkPerformanceInfo(entity, orderInfoEntities, clerkLevelInfoEntity, return R.ok(playClerkPerformanceService.getClerkPerformanceInfo(entity, orderInfoEntities, clerkLevelInfoEntity,
groupInfoEntities)); groupInfoEntities, startTime, endTime));
} }
/** /**

View File

@@ -0,0 +1,21 @@
package com.starry.admin.modules.weichat.entity.blindbox;
import java.math.BigDecimal;
import lombok.Data;
/**
* 盲盒配置前端视图。
*/
@Data
public class BlindBoxConfigView {
private String id;
private String name;
private String coverUrl;
private String description;
private BigDecimal price;
}

View File

@@ -0,0 +1,18 @@
package com.starry.admin.modules.weichat.entity.blindbox;
import javax.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class BlindBoxPurchaseRequest {
@NotBlank(message = "盲盒ID不能为空")
private String blindBoxId;
@NotBlank(message = "店员ID不能为空")
private String clerkId;
private String weiChatCode;
private String remark;
}

View File

@@ -0,0 +1,42 @@
package com.starry.admin.modules.weichat.entity.blindbox;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class BlindBoxPurchaseResult {
private String orderId;
private BlindBoxRewardInfo reward;
@Data
@Builder
public static class BlindBoxRewardInfo {
private String rewardId;
private String blindBoxId;
private String blindBoxName;
private String blindBoxCover;
private String rewardGiftId;
private String rewardGiftName;
private String rewardGiftImage;
private BigDecimal rewardGiftPrice;
private BigDecimal boxPrice;
private LocalDateTime expiresAt;
private String status;
}
}

View File

@@ -0,0 +1,11 @@
package com.starry.admin.modules.weichat.entity.blindbox;
import javax.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class BlindBoxRewardDispatchRequest {
@NotBlank(message = "店员ID不能为空")
private String clerkId;
}

View File

@@ -0,0 +1,33 @@
package com.starry.admin.modules.weichat.entity.blindbox;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class BlindBoxRewardView {
private String id;
private String blindBoxId;
private String blindBoxName;
private String blindBoxCover;
private String rewardGiftId;
private String rewardGiftName;
private String rewardGiftImage;
private BigDecimal rewardGiftPrice;
private BigDecimal boxPrice;
private LocalDateTime expiresAt;
private String status;
}

View File

@@ -0,0 +1,207 @@
package com.starry.admin.modules.weichat.service;
import com.starry.admin.common.conf.ThreadLocalRequestDetail;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
import com.starry.admin.modules.blindbox.service.BlindBoxConfigService;
import com.starry.admin.modules.blindbox.service.BlindBoxService;
import com.starry.admin.modules.custom.module.entity.PlayCustomUserInfoEntity;
import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService;
import com.starry.admin.modules.order.module.constant.OrderConstant;
import com.starry.admin.modules.order.module.constant.OrderConstant.OrderActor;
import com.starry.admin.modules.order.module.constant.OrderConstant.RewardType;
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.module.entity.PlayOrderInfoEntity;
import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderInfoService;
import com.starry.admin.modules.shop.mapper.PlayGiftInfoMapper;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.modules.weichat.entity.blindbox.BlindBoxPurchaseRequest;
import com.starry.admin.modules.weichat.entity.blindbox.BlindBoxPurchaseResult;
import com.starry.admin.modules.weichat.entity.blindbox.BlindBoxPurchaseResult.BlindBoxRewardInfo;
import com.starry.admin.modules.weichat.entity.blindbox.BlindBoxRewardView;
import com.starry.common.utils.IdUtils;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class WxBlindBoxOrderService {
@Resource
private BlindBoxConfigService blindBoxConfigService;
@Resource
private BlindBoxService blindBoxService;
@Resource
private IOrderLifecycleService orderLifecycleService;
@Resource
private IPlayOrderInfoService playOrderInfoService;
@Resource
private IPlayCustomUserInfoService customUserInfoService;
@Resource
private PlayGiftInfoMapper playGiftInfoMapper;
@Resource
private BlindBoxRewardMapper blindBoxRewardMapper;
@Transactional(rollbackFor = Exception.class)
public BlindBoxPurchaseResult purchase(BlindBoxPurchaseRequest request) {
PlayCustomUserInfoEntity sessionUser = ThreadLocalRequestDetail.getCustomUserInfo();
if (sessionUser == null || sessionUser.getId() == null) {
throw new CustomException("用户未登录");
}
PlayCustomUserInfoEntity customer = customUserInfoService.selectById(sessionUser.getId());
if (customer == null) {
throw new CustomException("用户不存在");
}
BlindBoxConfigEntity config = blindBoxConfigService.requireById(request.getBlindBoxId());
if (!Objects.equals(config.getTenantId(), customer.getTenantId())) {
throw new CustomException("盲盒不存在或已下架");
}
if (!Objects.equals(config.getStatus(), 1)) {
throw new CustomException("盲盒已下架");
}
BigDecimal boxPrice = Objects.requireNonNull(config.getPrice(), "盲盒价格未配置");
if (boxPrice.compareTo(BigDecimal.ZERO) <= 0) {
throw new CustomException("盲盒价格异常");
}
String orderId = IdUtils.getUuid();
OrderCreationContext orderRequest = buildOrderRequest(orderId, request, customer, config, boxPrice);
OrderPlacementResult result = orderLifecycleService.placeOrder(OrderPlacementCommand.builder()
.orderContext(orderRequest)
.balanceOperationAction("购买盲盒")
.build());
PlayOrderInfoEntity order = result.getOrder();
BlindBoxRewardEntity reward = blindBoxService.drawReward(
customer.getTenantId(),
orderId,
customer.getId(),
config.getId(),
UUID.randomUUID().toString());
PlayGiftInfoEntity giftInfo = playGiftInfoMapper.selectById(reward.getRewardGiftId());
return BlindBoxPurchaseResult.builder()
.orderId(order.getId())
.reward(buildRewardInfo(reward, config, giftInfo))
.build();
}
public List<BlindBoxRewardView> listRewards(String tenantId, String customerId, String status) {
return blindBoxService.listRewards(tenantId, customerId, status).stream()
.map(this::toRewardView)
.collect(Collectors.toList());
}
@Transactional(rollbackFor = Exception.class)
public BlindBoxRewardView dispatchReward(String rewardId, String clerkId) {
PlayCustomUserInfoEntity sessionUser = ThreadLocalRequestDetail.getCustomUserInfo();
if (sessionUser == null || sessionUser.getId() == null) {
throw new CustomException("用户未登录");
}
blindBoxService.dispatchReward(rewardId, clerkId, sessionUser.getId());
BlindBoxRewardEntity updated = blindBoxRewardMapper.selectById(rewardId);
if (updated == null) {
throw new CustomException("盲盒奖励不存在");
}
return toRewardView(updated);
}
public BlindBoxRewardView toRewardView(BlindBoxRewardEntity reward) {
BlindBoxConfigEntity config = blindBoxConfigService.getById(reward.getBlindBoxId());
PlayGiftInfoEntity gift = playGiftInfoMapper.selectById(reward.getRewardGiftId());
return BlindBoxRewardView.builder()
.id(reward.getId())
.blindBoxId(reward.getBlindBoxId())
.blindBoxName(config != null ? config.getName() : null)
.blindBoxCover(config != null ? config.getCoverUrl() : null)
.rewardGiftId(reward.getRewardGiftId())
.rewardGiftName(gift != null ? gift.getName() : reward.getRewardGiftId())
.rewardGiftImage(gift != null ? gift.getUrl() : null)
.rewardGiftPrice(reward.getRewardPrice())
.boxPrice(reward.getBoxPrice())
.expiresAt(reward.getExpiresAt())
.status(reward.getStatus())
.build();
}
private BlindBoxRewardInfo buildRewardInfo(
BlindBoxRewardEntity reward,
BlindBoxConfigEntity config,
PlayGiftInfoEntity giftInfo) {
return BlindBoxRewardInfo.builder()
.rewardId(reward.getId())
.blindBoxId(reward.getBlindBoxId())
.blindBoxName(config != null ? config.getName() : null)
.blindBoxCover(config != null ? config.getCoverUrl() : null)
.rewardGiftId(reward.getRewardGiftId())
.rewardGiftName(giftInfo != null ? giftInfo.getName() : reward.getRewardGiftId())
.rewardGiftImage(giftInfo != null ? giftInfo.getUrl() : null)
.rewardGiftPrice(reward.getRewardPrice())
.boxPrice(reward.getBoxPrice())
.expiresAt(reward.getExpiresAt())
.status(reward.getStatus())
.build();
}
private OrderCreationContext buildOrderRequest(
String orderId,
BlindBoxPurchaseRequest request,
PlayCustomUserInfoEntity customer,
BlindBoxConfigEntity config,
BigDecimal boxPrice) {
return OrderCreationContext.builder()
.orderId(orderId)
.orderNo(playOrderInfoService.getOrderNo())
.orderStatus(OrderConstant.OrderStatus.COMPLETED)
.orderType(OrderConstant.OrderType.BLIND_BOX_PURCHASE)
.placeType(OrderConstant.PlaceType.REWARD)
.rewardType(RewardType.GIFT)
.isFirstOrder(false)
.creatorActor(OrderActor.CUSTOMER)
.creatorId(customer.getId())
.commodityInfo(CommodityInfo.builder()
.commodityId(config.getId())
.commodityType(OrderConstant.CommodityType.GIFT)
.commodityPrice(config.getPrice())
.commodityName(config.getName())
.commodityNumber("1")
.serviceDuration("")
.build())
.paymentInfo(PaymentInfo.builder()
.orderMoney(boxPrice)
.finalAmount(boxPrice)
.discountAmount(BigDecimal.ZERO)
.couponIds(Collections.emptyList())
.payMethod(OrderConstant.PayMethod.BALANCE.getCode())
.paymentSource(OrderConstant.PaymentSource.BALANCE)
.build())
.purchaserBy(customer.getId())
.acceptBy(request.getClerkId())
.weiChatCode(request.getWeiChatCode())
.remark(request.getRemark())
.build();
}
}

View File

@@ -178,10 +178,16 @@ public class WxCustomMpService {
data.add(new WxMpTemplateData("thing11", commodityName)); data.add(new WxMpTemplateData("thing11", commodityName));
data.add(new WxMpTemplateData("amount8", money)); data.add(new WxMpTemplateData("amount8", money));
templateMessage.setData(data); templateMessage.setData(data);
PlayClerkUserInfoEntity clerkUserInfo =
StringUtils.isBlank(openId) ? null : clerkUserInfoService.selectByOpenid(openId);
String clerkId = clerkUserInfo == null ? null : clerkUserInfo.getId();
String clerkNickname = clerkUserInfo == null ? null : clerkUserInfo.getNickname();
try { try {
proxyWxMpService(tenantId).getTemplateMsgService().sendTemplateMsg(templateMessage); proxyWxMpService(tenantId).getTemplateMsgService().sendTemplateMsg(templateMessage);
} catch (WxErrorException e) { } catch (WxErrorException e) {
log.error("接单成功发送消息异常", e); log.error(
"接单成功发送消息异常, tenantId={}, tenantName={}, orderId={}, orderNo={}, recipientType=clerk, clerkId={}, openId={}, nickname={}",
tenantId, tenant.getTenantName(), orderId, orderNo, clerkId, openId, clerkNickname, e);
} }
} }

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@@ -34,4 +35,28 @@ public interface EarningsLineMapper extends BaseMapper<EarningsLineEntity> {
" AND (status = 'available' OR (status = 'frozen' AND unlock_time <= #{now})) " + " AND (status = 'available' OR (status = 'frozen' AND unlock_time <= #{now})) " +
"ORDER BY unlock_time ASC") "ORDER BY unlock_time ASC")
List<EarningsLineEntity> selectWithdrawableLines(@Param("clerkId") String clerkId, @Param("now") LocalDateTime now); List<EarningsLineEntity> selectWithdrawableLines(@Param("clerkId") String clerkId, @Param("now") LocalDateTime now);
@Select("<script>" +
"SELECT COALESCE(SUM(el.amount), 0) " +
"FROM play_earnings_line el " +
"JOIN play_order_info oi ON oi.id = el.order_id " +
"WHERE el.deleted = 0 " +
" AND oi.deleted = 0 " +
" AND el.clerk_id = #{clerkId} " +
" AND el.status &lt;&gt; 'reversed' " +
"<if test='orderIds != null and orderIds.size &gt; 0'>" +
" AND el.order_id IN " +
" <foreach item='id' collection='orderIds' open='(' separator=',' close=')'>#{id}</foreach>" +
"</if>" +
"<if test='startTime != null'>" +
" AND oi.purchaser_time &gt;= #{startTime}" +
"</if>" +
"<if test='endTime != null'>" +
" AND oi.purchaser_time &lt;= #{endTime}" +
"</if>" +
"</script>")
BigDecimal sumAmountByClerkAndOrderIds(@Param("clerkId") String clerkId,
@Param("orderIds") Collection<String> orderIds,
@Param("startTime") String startTime,
@Param("endTime") String endTime);
} }

View File

@@ -0,0 +1,69 @@
package com.starry.admin.utils;
import cn.hutool.core.util.StrUtil;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/**
* Utilities for normalizing date/time range inputs.
*/
public final class DateRangeUtils {
public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private DateRangeUtils() {}
public static String normalizeStart(String raw) {
if (StrUtil.isBlank(raw)) {
return LocalDate.now().minusDays(6).format(DATE_FORMATTER) + " 00:00:00";
}
if (raw.length() > 10) {
LocalDateTime.parse(raw, DATE_TIME_FORMATTER);
return raw;
}
return raw + " 00:00:00";
}
public static String normalizeEnd(String raw) {
if (StrUtil.isBlank(raw)) {
return LocalDate.now().format(DATE_FORMATTER) + " 23:59:59";
}
if (raw.length() > 10) {
LocalDateTime.parse(raw, DATE_TIME_FORMATTER);
return raw;
}
return raw + " 23:59:59";
}
public static String normalizeStartOptional(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
return normalizeFlexible(raw, false);
}
public static String normalizeEndOptional(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
return normalizeFlexible(raw, true);
}
private static String normalizeFlexible(String raw, boolean isEnd) {
String candidate = raw.trim().replace('T', ' ').replace("Z", "");
if (candidate.length() <= 10) {
candidate = candidate + (isEnd ? " 23:59:59" : " 00:00:00");
} else if (candidate.length() > 19) {
candidate = candidate.substring(0, 19);
}
try {
LocalDateTime.parse(candidate, DATE_TIME_FORMATTER);
return candidate;
} catch (DateTimeParseException ex) {
return candidate;
}
}
}

View File

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

View File

@@ -0,0 +1,43 @@
package com.starry.admin.modules.blindbox.service;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class BlindBoxInventoryServiceTest {
@Mock
private BlindBoxPoolMapper blindBoxPoolMapper;
@InjectMocks
private BlindBoxInventoryService blindBoxInventoryService;
@Test
void shouldReserveStockWhenDraw() {
when(blindBoxPoolMapper.consumeRewardStock(eq("tenant-1"), eq(5L), eq("gift-1"))).thenReturn(1);
blindBoxInventoryService.reserveRewardStock("tenant-1", 5L, "gift-1");
verify(blindBoxPoolMapper).consumeRewardStock("tenant-1", 5L, "gift-1");
}
@Test
void shouldThrowWhenStockInsufficient() {
when(blindBoxPoolMapper.consumeRewardStock(eq("tenant-1"), eq(6L), eq("gift-1"))).thenReturn(0);
CustomException ex = assertThrows(CustomException.class,
() -> blindBoxInventoryService.reserveRewardStock("tenant-1", 6L, "gift-1"));
assertTrue(ex.getMessage().contains("库存不足"));
}
}

View File

@@ -0,0 +1,274 @@
package com.starry.admin.modules.blindbox.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxGiftOption;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolImportRow;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolUpsertRequest;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxPoolView;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxPoolEntity;
import com.starry.admin.modules.shop.mapper.PlayGiftInfoMapper;
import com.starry.admin.modules.shop.module.entity.PlayGiftInfoEntity;
import com.starry.admin.utils.SecurityUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class BlindBoxPoolAdminServiceTest {
@Mock
private BlindBoxPoolMapper blindBoxPoolMapper;
@Mock
private PlayGiftInfoMapper playGiftInfoMapper;
@Mock
private BlindBoxConfigService blindBoxConfigService;
@InjectMocks
private BlindBoxPoolAdminService blindBoxPoolAdminService;
@BeforeEach
void setupContext() {
SecurityUtils.setTenantId("tenant-1");
}
@Test
void shouldListPoolsWithGiftNames() {
BlindBoxPoolEntity entity = new BlindBoxPoolEntity();
entity.setId(10L);
entity.setTenantId("tenant-1");
entity.setBlindBoxId("blind-1");
entity.setRewardGiftId("gift-2");
entity.setRewardPrice(BigDecimal.valueOf(9.9));
entity.setWeight(50);
entity.setRemainingStock(100);
entity.setValidFrom(LocalDateTime.of(2024, 8, 1, 0, 0));
entity.setStatus(1);
when(blindBoxPoolMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(entity));
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setName("幸运盲盒");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
when(playGiftInfoMapper.selectBatchIds(any(Collection.class)))
.thenAnswer(invocation -> Arrays.asList(reward));
List<BlindBoxPoolView> views = blindBoxPoolAdminService.list("blind-1");
assertEquals(1, views.size());
BlindBoxPoolView view = views.get(0);
assertEquals("幸运盲盒", view.getBlindBoxName());
assertEquals("超值娃娃", view.getRewardGiftName());
assertEquals(50, view.getWeight());
}
@Test
void shouldReplacePoolAndInsertRows() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
reward.setType("1");
reward.setPrice(BigDecimal.valueOf(9.9));
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Collections.singletonList(reward));
when(blindBoxPoolMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(1);
AtomicInteger insertCount = new AtomicInteger();
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class)))
.thenAnswer(invocation -> {
insertCount.incrementAndGet();
return 1;
});
BlindBoxPoolImportRow row = new BlindBoxPoolImportRow();
row.setRewardGiftName("超值娃娃");
row.setWeight(80);
row.setRemainingStock(10);
row.setStatus(1);
blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(row));
assertEquals(1, insertCount.get());
ArgumentCaptor<BlindBoxPoolEntity> captor = ArgumentCaptor.forClass(BlindBoxPoolEntity.class);
verify(blindBoxPoolMapper).insert(captor.capture());
BlindBoxPoolEntity saved = captor.getValue();
assertEquals("gift-2", saved.getRewardGiftId());
assertEquals(Integer.valueOf(80), saved.getWeight());
assertEquals(BigDecimal.valueOf(9.9), saved.getRewardPrice());
}
@Test
void shouldThrowWhenRewardGiftMissing() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
BlindBoxPoolImportRow row = new BlindBoxPoolImportRow();
row.setRewardGiftName("未知礼物");
row.setWeight(10);
assertThrows(CustomException.class,
() -> blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(row)));
}
@Test
void shouldThrowForInvalidWeight() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity reward = new PlayGiftInfoEntity();
reward.setId("gift-2");
reward.setName("超值娃娃");
reward.setType("1");
reward.setPrice(BigDecimal.ONE);
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Collections.singletonList(reward));
BlindBoxPoolImportRow row = new BlindBoxPoolImportRow();
row.setRewardGiftName("超值娃娃");
row.setWeight(0);
assertThrows(CustomException.class,
() -> blindBoxPoolAdminService.replacePool("blind-1", Collections.singletonList(row)));
}
@Test
void shouldListGiftOptions() {
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-1");
gift.setTenantId("tenant-1");
gift.setHistory("0");
gift.setState("0");
gift.setType("1");
gift.setPrice(BigDecimal.valueOf(19.9));
gift.setName("超值娃娃");
gift.setUrl("https://image");
when(playGiftInfoMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(gift));
List<BlindBoxGiftOption> options = blindBoxPoolAdminService.listGiftOptions("");
assertEquals(1, options.size());
assertEquals("gift-1", options.get(0).getId());
assertEquals("超值娃娃", options.get(0).getName());
}
@Test
void shouldCreatePoolEntry() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setName("幸运盲盒");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-2");
gift.setTenantId("tenant-1");
gift.setHistory("0");
gift.setState("0");
gift.setType("1");
gift.setPrice(BigDecimal.valueOf(9.9));
gift.setName("超值娃娃");
when(playGiftInfoMapper.selectById("gift-2")).thenReturn(gift);
when(blindBoxPoolMapper.insert(any(BlindBoxPoolEntity.class))).thenAnswer(invocation -> {
BlindBoxPoolEntity entity = invocation.getArgument(0);
entity.setId(100L);
return 1;
});
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
request.setBlindBoxId("blind-1");
request.setRewardGiftId("gift-2");
request.setRewardPrice(BigDecimal.valueOf(12.5));
request.setWeight(80);
request.setRemainingStock(5);
request.setStatus(1);
request.setValidFrom(LocalDateTime.of(2024, 8, 1, 0, 0));
request.setValidTo(LocalDateTime.of(2024, 8, 31, 23, 59, 59));
BlindBoxPoolView view = blindBoxPoolAdminService.create("blind-1", request);
assertEquals("gift-2", view.getRewardGiftId());
assertEquals(Integer.valueOf(80), view.getWeight());
verify(blindBoxPoolMapper).insert(any(BlindBoxPoolEntity.class));
}
@Test
void shouldUpdatePoolEntry() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setName("幸运盲盒");
when(blindBoxConfigService.requireById("blind-1")).thenReturn(config);
PlayGiftInfoEntity gift = new PlayGiftInfoEntity();
gift.setId("gift-3");
gift.setTenantId("tenant-1");
gift.setHistory("0");
gift.setState("0");
gift.setType("1");
gift.setPrice(BigDecimal.valueOf(19.9));
gift.setName("超级公仔");
when(playGiftInfoMapper.selectById("gift-3")).thenReturn(gift);
BlindBoxPoolEntity existing = new BlindBoxPoolEntity();
existing.setId(200L);
existing.setTenantId("tenant-1");
existing.setBlindBoxId("blind-1");
existing.setRewardGiftId("gift-1");
existing.setStatus(1);
when(blindBoxPoolMapper.selectById(200L)).thenReturn(existing);
when(blindBoxPoolMapper.updateById(any(BlindBoxPoolEntity.class))).thenReturn(1);
BlindBoxPoolUpsertRequest request = new BlindBoxPoolUpsertRequest();
request.setBlindBoxId("blind-1");
request.setRewardGiftId("gift-3");
request.setWeight(60);
request.setRemainingStock(null);
request.setStatus(0);
request.setRewardPrice(null);
request.setValidFrom(LocalDateTime.of(2024, 9, 1, 0, 0));
request.setValidTo(LocalDateTime.of(2024, 9, 30, 23, 59, 59));
BlindBoxPoolView view = blindBoxPoolAdminService.update(200L, request);
assertEquals("gift-3", existing.getRewardGiftId());
assertEquals(Integer.valueOf(60), existing.getWeight());
assertEquals(Integer.valueOf(0), existing.getStatus());
assertEquals("超级公仔", view.getRewardGiftName());
verify(blindBoxPoolMapper).updateById(existing);
}
}

View File

@@ -0,0 +1,202 @@
package com.starry.admin.modules.blindbox.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.starry.admin.common.exception.CustomException;
import com.starry.admin.modules.blindbox.mapper.BlindBoxPoolMapper;
import com.starry.admin.modules.blindbox.mapper.BlindBoxRewardMapper;
import com.starry.admin.modules.blindbox.module.constant.BlindBoxRewardStatus;
import com.starry.admin.modules.blindbox.module.dto.BlindBoxCandidate;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxConfigEntity;
import com.starry.admin.modules.blindbox.module.entity.BlindBoxRewardEntity;
import com.starry.admin.modules.order.module.dto.OrderPlacementResult;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class BlindBoxServiceTest {
@Mock
private BlindBoxPoolMapper poolMapper;
@Mock
private BlindBoxRewardMapper rewardMapper;
@Mock
private BlindBoxInventoryService inventoryService;
@Mock
private BlindBoxDispatchService dispatchService;
@Mock
private BlindBoxConfigService configService;
private Clock clock;
private TestRandomAdapter randomAdapter;
private BlindBoxService blindBoxService;
@BeforeEach
void setUp() {
clock = Clock.fixed(Instant.parse("2024-08-01T10:15:30Z"), ZoneOffset.UTC);
randomAdapter = new TestRandomAdapter();
blindBoxService = new BlindBoxService(poolMapper, rewardMapper, inventoryService, dispatchService, configService,
clock, Duration.ofDays(7), randomAdapter);
lenient().when(rewardMapper.insert(any())).thenAnswer(invocation -> {
BlindBoxRewardEntity entity = invocation.getArgument(0);
if (entity.getId() == null) {
entity.setId(UUID.randomUUID().toString());
}
return 1;
});
}
@Test
void shouldCreateRewardRecordWhenOrderCompleted() {
randomAdapter.nextDoubleToReturn = 0.95;
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setPrice(BigDecimal.valueOf(49));
when(configService.requireById("blind-1")).thenReturn(config);
List<BlindBoxCandidate> candidates = Arrays.asList(
BlindBoxCandidate.of(1L, "tenant-1", "blind-1", "gift-low", BigDecimal.valueOf(10), 10, 5),
BlindBoxCandidate.of(2L, "tenant-1", "blind-1", "gift-high", BigDecimal.valueOf(99), 90, 3)
);
when(poolMapper.listActiveEntries(eq("tenant-1"), eq("blind-1"), any(LocalDateTime.class))).thenReturn(candidates);
BlindBoxRewardEntity entity = blindBoxService.drawReward("tenant-1", "order-1", "customer-1", "blind-1",
"seed-123");
ArgumentCaptor<BlindBoxRewardEntity> captor = ArgumentCaptor.forClass(BlindBoxRewardEntity.class);
verify(rewardMapper).insert(captor.capture());
BlindBoxRewardEntity persisted = captor.getValue();
assertEquals("gift-high", persisted.getRewardGiftId());
assertEquals(BigDecimal.valueOf(99).setScale(2), persisted.getRewardPrice());
assertEquals(BigDecimal.valueOf(49).setScale(2), persisted.getBoxPrice());
assertEquals(BlindBoxRewardStatus.UNUSED.getCode(), persisted.getStatus());
assertEquals(LocalDateTime.ofInstant(clock.instant(), clock.getZone()).plusDays(7), persisted.getExpiresAt());
verify(inventoryService).reserveRewardStock("tenant-1", 2L, "gift-high");
assertEquals(entity.getRewardGiftId(), persisted.getRewardGiftId());
}
@Test
void shouldHandleUnlimitedStock() {
BlindBoxConfigEntity config = new BlindBoxConfigEntity();
config.setId("blind-1");
config.setTenantId("tenant-1");
config.setPrice(BigDecimal.valueOf(29));
when(configService.requireById("blind-1")).thenReturn(config);
List<BlindBoxCandidate> candidates = Collections.singletonList(
BlindBoxCandidate.of(7L, "tenant-1", "blind-1", "gift-unlimited", BigDecimal.valueOf(59), 100, null)
);
when(poolMapper.listActiveEntries(eq("tenant-1"), eq("blind-1"), any(LocalDateTime.class))).thenReturn(candidates);
blindBoxService.drawReward("tenant-1", "order-2", "customer-9", "blind-1", "seed-unlimited");
ArgumentCaptor<BlindBoxRewardEntity> captor = ArgumentCaptor.forClass(BlindBoxRewardEntity.class);
verify(rewardMapper).insert(captor.capture());
BlindBoxRewardEntity persisted = captor.getValue();
assertEquals("gift-unlimited", persisted.getRewardGiftId());
assertEquals(BigDecimal.valueOf(29).setScale(2), persisted.getBoxPrice());
assertEquals(BigDecimal.valueOf(59).setScale(2), persisted.getRewardPrice());
assertEquals(BlindBoxRewardStatus.UNUSED.getCode(), persisted.getStatus());
assertNull(persisted.getRewardStockSnapshot());
verify(inventoryService).reserveRewardStock("tenant-1", 7L, "gift-unlimited");
}
@Test
void shouldPreventDoubleDispatch() {
BlindBoxRewardEntity reward = buildRewardEntity();
when(rewardMapper.lockByIdForUpdate("reward-1")).thenReturn(reward);
when(dispatchService.dispatchRewardOrder(eq(reward), eq("clerk-1"))).thenReturn(mock(OrderPlacementResult.class));
when(rewardMapper.markUsed(eq("reward-1"), eq("clerk-1"), any(), any())).thenReturn(1);
blindBoxService.dispatchReward("reward-1", "clerk-1", "customer-1");
reward.setStatus(BlindBoxRewardStatus.USED.getCode());
CustomException ex = assertThrows(CustomException.class, () ->
blindBoxService.dispatchReward("reward-1", "clerk-1", "customer-1"));
assertTrue(ex.getMessage().contains("已使用"));
verify(rewardMapper, times(1)).markUsed(eq("reward-1"), eq("clerk-1"), any(), any());
}
@Test
void shouldRejectExpiredReward() {
BlindBoxRewardEntity reward = buildRewardEntity();
reward.setExpiresAt(LocalDateTime.ofInstant(clock.instant(), clock.getZone()).minusHours(1));
when(rewardMapper.lockByIdForUpdate("reward-1")).thenReturn(reward);
CustomException ex = assertThrows(CustomException.class, () ->
blindBoxService.dispatchReward("reward-1", "clerk-1", "customer-1"));
assertTrue(ex.getMessage().contains("已过期"));
verify(dispatchService, times(0)).dispatchRewardOrder(any(), any());
}
@Test
void shouldRejectDispatchWhenCustomerMismatch() {
BlindBoxRewardEntity reward = buildRewardEntity();
when(rewardMapper.lockByIdForUpdate("reward-1")).thenReturn(reward);
CustomException ex = assertThrows(CustomException.class, () ->
blindBoxService.dispatchReward("reward-1", "clerk-1", "someone-else"));
assertTrue(ex.getMessage().contains("无权"));
verify(dispatchService, times(0)).dispatchRewardOrder(any(), any());
verify(rewardMapper, times(0)).markUsed(any(), any(), any(), any());
}
private BlindBoxRewardEntity buildRewardEntity() {
BlindBoxRewardEntity reward = new BlindBoxRewardEntity();
reward.setId("reward-1");
reward.setTenantId("tenant-1");
reward.setCustomerId("customer-1");
reward.setBlindBoxId("blind-1");
reward.setRewardGiftId("gift-high");
reward.setRewardPrice(BigDecimal.valueOf(99));
reward.setBoxPrice(BigDecimal.valueOf(49));
reward.setRewardStockSnapshot(3);
reward.setSeed("seed-123");
reward.setStatus(BlindBoxRewardStatus.UNUSED.getCode());
reward.setCreatedByOrder("order-1");
reward.setCreatedTime(java.sql.Timestamp.from(clock.instant()));
reward.setUpdatedTime(java.sql.Timestamp.from(clock.instant()));
reward.setExpiresAt(LocalDateTime.ofInstant(clock.instant(), clock.getZone()).plusHours(1));
return reward;
}
private static class TestRandomAdapter implements BlindBoxService.RandomAdapter {
double nextDoubleToReturn = 0.5;
@Override
public double nextDouble() {
return nextDoubleToReturn;
}
}
}

View File

@@ -22,15 +22,18 @@ import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewRes
import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewSummaryVo; import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceOverviewSummaryVo;
import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceSnapshotVo; import com.starry.admin.modules.statistics.module.vo.ClerkPerformanceSnapshotVo;
import com.starry.admin.modules.statistics.service.impl.PlayClerkPerformanceServiceImpl; import com.starry.admin.modules.statistics.service.impl.PlayClerkPerformanceServiceImpl;
import com.starry.admin.modules.withdraw.mapper.EarningsLineMapper;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.Month; import java.time.Month;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
@@ -55,6 +58,9 @@ class PlayClerkPerformanceServiceImplTest {
@Mock @Mock
private IPlayPersonnelGroupInfoService playPersonnelGroupInfoService; private IPlayPersonnelGroupInfoService playPersonnelGroupInfoService;
@Mock
private EarningsLineMapper earningsLineMapper;
@InjectMocks @InjectMocks
private PlayClerkPerformanceServiceImpl service; private PlayClerkPerformanceServiceImpl service;
@@ -89,6 +95,10 @@ class PlayClerkPerformanceServiceImplTest {
when(playOrderInfoService.clerkSelectOrderInfoList(eq("c1"), anyString(), anyString())).thenReturn(ordersA); when(playOrderInfoService.clerkSelectOrderInfoList(eq("c1"), anyString(), anyString())).thenReturn(ordersA);
when(playOrderInfoService.clerkSelectOrderInfoList(eq("c2"), anyString(), anyString())).thenReturn(ordersB); when(playOrderInfoService.clerkSelectOrderInfoList(eq("c2"), anyString(), anyString())).thenReturn(ordersB);
when(earningsLineMapper.sumAmountByClerkAndOrderIds(eq("c1"), anyCollection(), any(), any()))
.thenReturn(new BigDecimal("170.00"));
when(earningsLineMapper.sumAmountByClerkAndOrderIds(eq("c2"), anyCollection(), any(), any()))
.thenReturn(new BigDecimal("55.00"));
setAuthentication(); setAuthentication();
try { try {
@@ -109,6 +119,8 @@ class PlayClerkPerformanceServiceImplTest {
assertEquals(2, top.getContinuedOrderCount()); assertEquals(2, top.getContinuedOrderCount());
assertEquals(new BigDecimal("66.67"), top.getContinuedRate()); assertEquals(new BigDecimal("66.67"), top.getContinuedRate());
assertEquals(new BigDecimal("100.00"), top.getAverageTicketPrice()); assertEquals(new BigDecimal("100.00"), top.getAverageTicketPrice());
assertEquals(new BigDecimal("170.00"), top.getEstimatedRevenue());
assertEquals(new BigDecimal("55.00"), response.getRankings().get(1).getEstimatedRevenue());
ClerkPerformanceOverviewSummaryVo summary = response.getSummary(); ClerkPerformanceOverviewSummaryVo summary = response.getSummary();
assertEquals(new BigDecimal("380.00"), summary.getTotalGmv()); assertEquals(new BigDecimal("380.00"), summary.getTotalGmv());
@@ -117,6 +129,7 @@ class PlayClerkPerformanceServiceImplTest {
assertEquals(2, summary.getTotalContinuedOrderCount()); assertEquals(2, summary.getTotalContinuedOrderCount());
assertEquals(new BigDecimal("50.00"), summary.getContinuedRate()); assertEquals(new BigDecimal("50.00"), summary.getContinuedRate());
assertEquals(new BigDecimal("95.00"), summary.getAverageTicketPrice()); assertEquals(new BigDecimal("95.00"), summary.getAverageTicketPrice());
assertEquals(new BigDecimal("225.00"), summary.getTotalEstimatedRevenue());
} finally { } finally {
clearAuthentication(); clearAuthentication();
} }
@@ -146,6 +159,8 @@ class PlayClerkPerformanceServiceImplTest {
withRefund(order("c1", "userB", "0", "2", "1", new BigDecimal("60.00"), new BigDecimal("30.00"), withRefund(order("c1", "userB", "0", "2", "1", new BigDecimal("60.00"), new BigDecimal("30.00"),
LocalDateTime.of(2024, Month.AUGUST, 2, 18, 0)), new BigDecimal("20.00"))); LocalDateTime.of(2024, Month.AUGUST, 2, 18, 0)), new BigDecimal("20.00")));
when(playOrderInfoService.clerkSelectOrderInfoList(eq("c1"), anyString(), anyString())).thenReturn(orders); when(playOrderInfoService.clerkSelectOrderInfoList(eq("c1"), anyString(), anyString())).thenReturn(orders);
when(earningsLineMapper.sumAmountByClerkAndOrderIds(eq("c1"), anyCollection(), any(), any()))
.thenReturn(new BigDecimal("110.00"));
setAuthentication(); setAuthentication();
try { try {
@@ -158,6 +173,7 @@ class PlayClerkPerformanceServiceImplTest {
assertEquals(3, response.getSnapshot().getOrderCount()); assertEquals(3, response.getSnapshot().getOrderCount());
assertEquals(new BigDecimal("66.67"), response.getSnapshot().getContinuedRate()); assertEquals(new BigDecimal("66.67"), response.getSnapshot().getContinuedRate());
assertEquals(new BigDecimal("86.67"), response.getSnapshot().getAverageTicketPrice()); assertEquals(new BigDecimal("86.67"), response.getSnapshot().getAverageTicketPrice());
assertEquals(new BigDecimal("110.00"), response.getSnapshot().getEstimatedRevenue());
assertNotNull(response.getComposition()); assertNotNull(response.getComposition());
assertEquals(4, response.getComposition().getOrderComposition().size()); assertEquals(4, response.getComposition().getOrderComposition().size());
@@ -191,6 +207,30 @@ class PlayClerkPerformanceServiceImplTest {
} }
} }
@Test
@DisplayName("getClerkPerformanceInfo aligns earnings lookup with purchaser time")
void getClerkPerformanceInfoAlignsEarningsLookupWithPurchaserTime() {
PlayClerkUserInfoEntity clerk = buildClerk("c3", "Carol", "g3", "l3");
List<PlayOrderInfoEntity> orders = Collections.singletonList(
order("c3", "userX", "1", "0", "0", new BigDecimal("99.00"), new BigDecimal("50.00"),
LocalDateTime.of(2024, Month.AUGUST, 1, 15, 30)));
when(earningsLineMapper.sumAmountByClerkAndOrderIds(eq("c3"), anyCollection(), any(), any()))
.thenReturn(new BigDecimal("80.00"));
service.getClerkPerformanceInfo(clerk, orders, Collections.singletonList(level("l3", "钻石")),
Collections.singletonList(group("g3", "三组")), "2024-08-01", "2024-08-02");
ArgumentCaptor<Collection<String>> orderIdsCaptor = ArgumentCaptor.forClass(Collection.class);
ArgumentCaptor<String> startCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> endCaptor = ArgumentCaptor.forClass(String.class);
verify(earningsLineMapper).sumAmountByClerkAndOrderIds(eq("c3"), orderIdsCaptor.capture(),
startCaptor.capture(), endCaptor.capture());
assertTrue(orderIdsCaptor.getValue().contains(orders.get(0).getId()));
assertEquals("2024-08-01 00:00:00", startCaptor.getValue());
assertEquals("2024-08-02 23:59:59", endCaptor.getValue());
}
private PlayClerkUserInfoEntity buildClerk(String id, String name, String groupId, String levelId) { private PlayClerkUserInfoEntity buildClerk(String id, String name, String groupId, String levelId) {
PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity(); PlayClerkUserInfoEntity entity = new PlayClerkUserInfoEntity();
entity.setId(id); entity.setId(id);
@@ -229,6 +269,7 @@ class PlayClerkPerformanceServiceImplTest {
order.setEstimatedRevenue(estimatedRevenue); order.setEstimatedRevenue(estimatedRevenue);
order.setOrdersExpiredState("1".equals(refundType) ? "1" : "0"); order.setOrdersExpiredState("1".equals(refundType) ? "1" : "0");
order.setPurchaserTime(purchaserTime); order.setPurchaserTime(purchaserTime);
order.setId(clerkId + "-" + purchaser + "-" + purchaserTime.toString());
return order; return order;
} }