You've already forked FrameTour-BE
Merge branch 'order_update'
# Conflicts: # src/main/java/com/ycwl/basic/pricing/CLAUDE.md
This commit is contained in:
@@ -82,6 +82,7 @@ com.ycwl.basic.pricing/
|
||||
#### API端点
|
||||
- `POST /api/pricing/calculate` — 执行价格计算(预览模式默认开启)
|
||||
- `GET /api/pricing/coupons/my-coupons` — 查询用户可用优惠券
|
||||
- `POST /api/pricing/upgrade-check` — 升单检测:综合已购与待购商品,判断是否命中一口价或打包优惠
|
||||
|
||||
#### 计算流程
|
||||
```java
|
||||
@@ -588,22 +589,146 @@ public class PriceCalculationResult {
|
||||
- `GET /api/pricing/admin/one-price/scenic/{scenicId}` — 按景区查询启用配置
|
||||
- `GET /api/pricing/admin/one-price/check/{scenicId}` — 景区是否适用一口价
|
||||
|
||||
## 升单检测功能 (Upgrade Detection)
|
||||
|
||||
### 1. 功能概述
|
||||
|
||||
升单检测功能是最新新增的功能,用于综合已购商品与待购商品,判断是否满足一口价或打包购买优惠条件,为用户提供购买建议。
|
||||
|
||||
### 2. 核心接口
|
||||
|
||||
#### IPriceCalculationService 升单检测方法
|
||||
```java
|
||||
/**
|
||||
* 升单检测:综合已购与待购商品,判断是否命中一口价或打包优惠
|
||||
* @param request 升单检测请求
|
||||
* @return 升单检测结果
|
||||
*/
|
||||
UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request);
|
||||
```
|
||||
|
||||
#### API 端点
|
||||
- `POST /api/pricing/upgrade-check` — 升单检测接口
|
||||
|
||||
### 3. 检测逻辑
|
||||
|
||||
#### 请求参数 (UpgradeCheckRequest)
|
||||
- `scenicId`: 景区ID
|
||||
- `purchasedProducts`: 已购商品列表
|
||||
- `intendingProducts`: 待购商品列表
|
||||
|
||||
#### 检测流程
|
||||
1. **商品规范化**: 对已购和待购商品进行规范化处理
|
||||
2. **价格汇总**: 分别计算已购和待购商品的总价格
|
||||
3. **一口价评估**: 判断合并商品是否满足一口价条件
|
||||
4. **打包优惠评估**: 检测是否满足打包购买优惠条件
|
||||
5. **结果汇总**: 生成升单检测结果和建议
|
||||
|
||||
#### 响应结果 (UpgradeCheckResult)
|
||||
- `summary`: 价格汇总信息(原价、优惠价、最终价)
|
||||
- `onePriceResult`: 一口价检测结果(如适用)
|
||||
- `bundleResult`: 打包优惠检测结果(如适用)
|
||||
- `upgradeAvailable`: 是否可升单(布尔值)
|
||||
- `savingsAmount`: 升单可节省金额
|
||||
|
||||
### 4. 业务价值
|
||||
|
||||
#### 用户体验提升
|
||||
- 为用户提供购买建议,提高客单价
|
||||
- 自动检测最优购买组合
|
||||
- 清晰展示升单优惠金额
|
||||
|
||||
#### 销售支持
|
||||
- 促进多商品销售
|
||||
- 提高打包购买和一口价利用率
|
||||
- 增加用户购买决策信心
|
||||
|
||||
### 5. 使用场景
|
||||
|
||||
#### 典型场景
|
||||
- 用户已购买照片,建议加购视频享受打包优惠
|
||||
- 用户购买多件商品,建议升级为一口价套餐
|
||||
- 用户购买数量接近打包优惠门槛,建议增加数量
|
||||
|
||||
#### 实现细节
|
||||
```java
|
||||
// 升单检测核心逻辑
|
||||
@Override
|
||||
public UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request) {
|
||||
// 1. 参数验证
|
||||
if (request == null) {
|
||||
throw new PriceCalculationException("升单检测请求不能为空");
|
||||
}
|
||||
|
||||
// 2. 商品规范化
|
||||
List<ProductItem> purchased = normalizeProducts(request.getPurchasedProducts());
|
||||
List<ProductItem> intending = normalizeProducts(request.getIntendingProducts());
|
||||
|
||||
// 3. 合并商品列表
|
||||
List<ProductItem> allProducts = new ArrayList<>();
|
||||
allProducts.addAll(purchased);
|
||||
allProducts.addAll(intending);
|
||||
|
||||
// 4. 价格计算
|
||||
PriceDetails purchasedPrice = calculateProductsPriceWithOriginal(purchased);
|
||||
PriceDetails intendingPrice = calculateProductsPriceWithOriginal(intending);
|
||||
PriceDetails totalPrice = calculateProductsPrice(allProducts);
|
||||
|
||||
// 5. 优惠评估
|
||||
UpgradeOnePriceResult onePriceResult = evaluateOnePrice(request.getScenicId(), allProducts, totalPrice);
|
||||
UpgradeBundleDiscountResult bundleResult = evaluateBundleDiscount(request.getScenicId(), allProducts, totalPrice);
|
||||
|
||||
// 6. 结果汇总
|
||||
return buildUpgradeResult(purchasedPrice, intendingPrice, onePriceResult, bundleResult);
|
||||
}
|
||||
```
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 1. 单元测试
|
||||
建议覆盖:
|
||||
- 价格计算核心流程与边界
|
||||
- 优惠券/券码/一口价适用性与叠加规则
|
||||
- 异常场景与异常处理器
|
||||
### 单元测试类型
|
||||
- **服务层测试**:每个服务类都有对应测试类
|
||||
- `PriceBundleServiceTest` - 套餐价格计算测试
|
||||
- `ReusableVoucherServiceTest` - 可重复使用券码测试
|
||||
- `VoucherTimeRangeTest` - 券码时间范围功能测试
|
||||
- `VoucherPrintServiceCodeGenerationTest` - 券码生成测试
|
||||
- **实体映射测试**:验证数据库映射和JSON序列化
|
||||
- `PriceBundleConfigStructureTest` - 实体结构测试
|
||||
- `PriceBundleConfigJsonTest` - JSON序列化测试
|
||||
- `CouponSwitchFieldsMappingTest` - 字段映射测试
|
||||
- **类型处理器测试**:验证自定义TypeHandler
|
||||
- `BundleProductListTypeHandlerTest` - 套餐商品列表序列化测试
|
||||
- **配置验证测试**:验证系统配置完整性
|
||||
- `DefaultConfigValidationTest` - 验证所有ProductType的default配置
|
||||
- `CodeGenerationStandaloneTest` - 独立代码生成测试
|
||||
|
||||
### 2. 集成测试
|
||||
- 数据库读写与分页
|
||||
- JSON 序列化/反序列化(TypeHandler)
|
||||
- API 端点的入参/出参校验
|
||||
### 测试执行命令
|
||||
```bash
|
||||
# 运行单个测试类
|
||||
mvn test -Dtest=VoucherTimeRangeTest
|
||||
mvn test -Dtest=ReusableVoucherServiceTest
|
||||
mvn test -Dtest=BundleProductListTypeHandlerTest
|
||||
|
||||
### 3. 配置校验
|
||||
- 校验各 ProductType 的默认配置完整性
|
||||
- 关键枚举与配置代码路径的兼容性
|
||||
# 运行整个pricing模块测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.*Test"
|
||||
|
||||
# 运行特定分类的测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.service.*Test" # 服务层测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.handler.*Test" # TypeHandler测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.entity.*Test" # 实体测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.mapper.*Test" # Mapper测试
|
||||
|
||||
# 运行带详细报告的测试
|
||||
mvn test -Dtest="com.ycwl.basic.pricing.*Test" -Dsurefire.printSummary=true
|
||||
```
|
||||
|
||||
### 重点测试场景
|
||||
- **价格计算核心流程**:验证统一优惠检测和组合逻辑
|
||||
- **可重复使用券码**:验证多次使用、时间间隔、用户限制逻辑
|
||||
- **时间范围控制**:验证券码有效期开始和结束时间
|
||||
- **优惠叠加规则**:验证券码、优惠券、一口价的叠加逻辑
|
||||
- **JSON序列化**:验证复杂对象在数据库中的存储和读取
|
||||
- **分页功能**:验证PageHelper和MyBatis-Plus分页集成
|
||||
- **异常处理**:验证业务异常和全局异常处理器
|
||||
|
||||
## 数据库设计
|
||||
|
||||
@@ -665,11 +790,20 @@ CREATE INDEX idx_print_face_scenic ON voucher_print_record(face_id, scenic_id);
|
||||
- 使用数据完整性检查 SQL 验证统计数据准确性
|
||||
- **优惠券领取记录表查询优化** (v1.0.0): 为 `(user_id, coupon_id)` 添加复合索引以加速用户领取次数统计
|
||||
|
||||
### 关键架构变更
|
||||
|
||||
#### 最近重要更新 (2025-09-18)
|
||||
1. **新增升单检测功能** - 添加了`/api/pricing/upgrade-check`接口,支持已购和待购商品的优惠组合检测
|
||||
2. **新增打包购买优惠功能** - 实现了多商品组合优惠策略,优先级100(仅次于一口价)
|
||||
3. **优惠优先级调整** - 确立了"一口价 > 打包购买 > 券码 > 优惠券"的优先级顺序
|
||||
4. **PrinterServiceImpl重构** - 移除对PriceRepository的依赖,统一使用IPriceCalculationService
|
||||
|
||||
## 兼容性与注意事项
|
||||
|
||||
- 本模块使用 PageHelper(优惠券相关)与 MyBatis‑Plus(券码/一口价等)并存,请根据对应 Service/Mapper 选择分页与查询方式。
|
||||
- 优惠优先级及叠加规则以各 Provider 与业务配置为准,避免在外层重复实现优先级判断逻辑。
|
||||
- 若扩展新的优惠类型,务必实现 `IDiscountProvider` 并在 `IDiscountDetectionService` 中完成注册(当前实现通过组件扫描自动注册并排序)。
|
||||
- 升单检测功能依赖完整的价格计算和优惠检测服务,确保相关依赖正常注入。
|
||||
- **优惠券数量管理** (v1.0.0): 现有代码已调整为领取时更新 `claimedQuantity`,使用时更新 `usedQuantity`。如业务需求不同,请调整 `CouponServiceImpl.claimCoupon()` 和 `CouponServiceImpl.useCoupon()` 逻辑。
|
||||
|
||||
## 版本更新记录
|
||||
|
||||
@@ -38,6 +38,20 @@ public class PriceCalculationController {
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 升单检测:判断是否命中一口价或打包优惠
|
||||
*/
|
||||
@PostMapping("/upgrade-check")
|
||||
public ApiResponse<UpgradeCheckResult> upgradeCheck(@RequestBody UpgradeCheckRequest request) {
|
||||
log.info("升单检测请求: scenicId={}, purchased={}, intending={}",
|
||||
request.getScenicId(),
|
||||
request.getPurchasedProducts() != null ? request.getPurchasedProducts().size() : 0,
|
||||
request.getIntendingProducts() != null ? request.getIntendingProducts().size() : 0);
|
||||
|
||||
UpgradeCheckResult result = priceCalculationService.checkUpgrade(request);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户可用优惠券
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 升单检测打包优惠结果
|
||||
*/
|
||||
@Data
|
||||
public class UpgradeBundleDiscountResult {
|
||||
|
||||
/**
|
||||
* 是否命中打包优惠
|
||||
*/
|
||||
private boolean applicable;
|
||||
|
||||
/**
|
||||
* 打包配置ID
|
||||
*/
|
||||
private Long bundleConfigId;
|
||||
|
||||
/**
|
||||
* 打包优惠名称
|
||||
*/
|
||||
private String bundleName;
|
||||
|
||||
/**
|
||||
* 打包优惠描述
|
||||
*/
|
||||
private String bundleDescription;
|
||||
|
||||
/**
|
||||
* 优惠类型
|
||||
*/
|
||||
private String discountType;
|
||||
|
||||
/**
|
||||
* 优惠值
|
||||
*/
|
||||
private BigDecimal discountValue;
|
||||
|
||||
/**
|
||||
* 实际优惠金额
|
||||
*/
|
||||
private BigDecimal discountAmount;
|
||||
|
||||
/**
|
||||
* 满足条件的最少数量
|
||||
*/
|
||||
private Integer minQuantity;
|
||||
|
||||
/**
|
||||
* 满足条件的最少金额
|
||||
*/
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/**
|
||||
* 使用优惠后的预计应付金额
|
||||
*/
|
||||
private BigDecimal estimatedFinalAmount;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 升单检测请求
|
||||
*/
|
||||
@Data
|
||||
public class UpgradeCheckRequest {
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 用户faceId
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 已购买商品列表
|
||||
*/
|
||||
private List<ProductItem> purchasedProducts;
|
||||
|
||||
/**
|
||||
* 准备购买的商品列表
|
||||
*/
|
||||
private List<ProductItem> intendingProducts;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 升单检测结果
|
||||
*/
|
||||
@Data
|
||||
public class UpgradeCheckResult {
|
||||
|
||||
/**
|
||||
* 景区ID
|
||||
*/
|
||||
private Long scenicId;
|
||||
|
||||
/**
|
||||
* 用户faceId
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 价格汇总信息
|
||||
*/
|
||||
private UpgradePriceSummary priceSummary;
|
||||
|
||||
/**
|
||||
* 一口价检测结果
|
||||
*/
|
||||
private UpgradeOnePriceResult onePriceResult;
|
||||
|
||||
/**
|
||||
* 打包优惠检测结果
|
||||
*/
|
||||
private UpgradeBundleDiscountResult bundleDiscountResult;
|
||||
|
||||
/**
|
||||
* 已购买商品明细(带计算价)
|
||||
*/
|
||||
private List<ProductItem> purchasedProducts;
|
||||
|
||||
/**
|
||||
* 计划购买商品明细(带计算价)
|
||||
*/
|
||||
private List<ProductItem> intendingProducts;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 升单检测一口价结果
|
||||
*/
|
||||
@Data
|
||||
public class UpgradeOnePriceResult {
|
||||
|
||||
/**
|
||||
* 是否命中一口价规则
|
||||
*/
|
||||
private boolean applicable;
|
||||
|
||||
/**
|
||||
* 一口价配置ID
|
||||
*/
|
||||
private Long bundleConfigId;
|
||||
|
||||
/**
|
||||
* 一口价名称
|
||||
*/
|
||||
private String bundleName;
|
||||
|
||||
/**
|
||||
* 一口价描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 适用景区ID
|
||||
*/
|
||||
private String scenicId;
|
||||
|
||||
/**
|
||||
* 一口价金额
|
||||
*/
|
||||
private BigDecimal bundlePrice;
|
||||
|
||||
/**
|
||||
* 优惠金额(合并小计 - 一口价金额)
|
||||
*/
|
||||
private BigDecimal discountAmount;
|
||||
|
||||
/**
|
||||
* 使用一口价后的预计应付金额
|
||||
*/
|
||||
private BigDecimal estimatedFinalAmount;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.ycwl.basic.pricing.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 升单检测价格汇总
|
||||
*/
|
||||
@Data
|
||||
public class UpgradePriceSummary {
|
||||
|
||||
/**
|
||||
* 已购买原价合计
|
||||
*/
|
||||
private BigDecimal purchasedOriginalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 已购买小计金额
|
||||
*/
|
||||
private BigDecimal purchasedSubtotalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 计划购买原价合计
|
||||
*/
|
||||
private BigDecimal intendingOriginalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 计划购买小计金额
|
||||
*/
|
||||
private BigDecimal intendingSubtotalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 合并后的原价合计
|
||||
*/
|
||||
private BigDecimal combinedOriginalAmount = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 合并后的小计金额
|
||||
*/
|
||||
private BigDecimal combinedSubtotalAmount = BigDecimal.ZERO;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
|
||||
import com.ycwl.basic.pricing.dto.UpgradeCheckRequest;
|
||||
import com.ycwl.basic.pricing.dto.UpgradeCheckResult;
|
||||
|
||||
/**
|
||||
* 价格计算服务接口
|
||||
@@ -15,4 +17,12 @@ public interface IPriceCalculationService {
|
||||
* @return 价格计算结果
|
||||
*/
|
||||
PriceCalculationResult calculatePrice(PriceCalculationRequest request);
|
||||
|
||||
/**
|
||||
* 升单检测:综合已购与待购商品,判断是否命中一口价或打包优惠
|
||||
*
|
||||
* @param request 升单检测请求
|
||||
* @return 检测结果
|
||||
*/
|
||||
UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ycwl.basic.pricing.service.impl;
|
||||
|
||||
import com.ycwl.basic.pricing.dto.*;
|
||||
import com.ycwl.basic.pricing.entity.PriceBundleConfig;
|
||||
import com.ycwl.basic.pricing.entity.PriceProductConfig;
|
||||
import com.ycwl.basic.pricing.entity.PriceTierConfig;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
@@ -36,6 +37,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
|
||||
private final IProductConfigService productConfigService;
|
||||
private final ICouponService couponService;
|
||||
private final IPriceBundleService bundleService;
|
||||
private final IBundleDiscountService bundleDiscountService;
|
||||
private final IDiscountDetectionService discountDetectionService;
|
||||
private final IVoucherService voucherService;
|
||||
private final IProductTypeCapabilityService productTypeCapabilityService;
|
||||
@@ -159,6 +161,49 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UpgradeCheckResult checkUpgrade(UpgradeCheckRequest request) {
|
||||
if (request == null) {
|
||||
throw new PriceCalculationException("升单检测请求不能为空");
|
||||
}
|
||||
|
||||
List<ProductItem> purchasedProducts = cloneProducts(request.getPurchasedProducts());
|
||||
List<ProductItem> intendingProducts = cloneProducts(request.getIntendingProducts());
|
||||
|
||||
if (purchasedProducts.isEmpty() && intendingProducts.isEmpty()) {
|
||||
throw new PriceCalculationException("已购和待购商品列表不能同时为空");
|
||||
}
|
||||
|
||||
normalizeProducts(purchasedProducts);
|
||||
normalizeProducts(intendingProducts);
|
||||
|
||||
PriceDetails purchasedDetails = purchasedProducts.isEmpty()
|
||||
? new PriceDetails(BigDecimal.ZERO, BigDecimal.ZERO)
|
||||
: calculateProductsPriceWithOriginal(purchasedProducts);
|
||||
PriceDetails intendingDetails = intendingProducts.isEmpty()
|
||||
? new PriceDetails(BigDecimal.ZERO, BigDecimal.ZERO)
|
||||
: calculateProductsPriceWithOriginal(intendingProducts);
|
||||
|
||||
List<ProductItem> combinedProducts = new ArrayList<>();
|
||||
combinedProducts.addAll(purchasedProducts);
|
||||
combinedProducts.addAll(intendingProducts);
|
||||
PriceDetails combinedDetails = calculateProductsPriceWithOriginal(combinedProducts);
|
||||
|
||||
UpgradePriceSummary priceSummary = buildPriceSummary(purchasedDetails, intendingDetails, combinedDetails);
|
||||
UpgradeOnePriceResult onePriceResult = evaluateOnePrice(request.getScenicId(), combinedProducts, combinedDetails);
|
||||
UpgradeBundleDiscountResult bundleDiscountResult = evaluateBundleDiscount(request.getScenicId(), combinedProducts, combinedDetails);
|
||||
|
||||
UpgradeCheckResult result = new UpgradeCheckResult();
|
||||
result.setScenicId(request.getScenicId());
|
||||
result.setFaceId(request.getFaceId());
|
||||
result.setPriceSummary(priceSummary);
|
||||
result.setOnePriceResult(onePriceResult);
|
||||
result.setBundleDiscountResult(bundleDiscountResult);
|
||||
result.setPurchasedProducts(purchasedProducts);
|
||||
result.setIntendingProducts(intendingProducts);
|
||||
return result;
|
||||
}
|
||||
|
||||
private BigDecimal calculateProductsPrice(List<ProductItem> products) {
|
||||
BigDecimal totalAmount = BigDecimal.ZERO;
|
||||
|
||||
@@ -390,6 +435,134 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
|
||||
return new ProductPriceInfo(actualPrice, originalPrice);
|
||||
}
|
||||
|
||||
private UpgradePriceSummary buildPriceSummary(PriceDetails purchased, PriceDetails intending, PriceDetails combined) {
|
||||
UpgradePriceSummary summary = new UpgradePriceSummary();
|
||||
summary.setPurchasedOriginalAmount(purchased.getOriginalTotalAmount());
|
||||
summary.setPurchasedSubtotalAmount(purchased.getTotalAmount());
|
||||
summary.setIntendingOriginalAmount(intending.getOriginalTotalAmount());
|
||||
summary.setIntendingSubtotalAmount(intending.getTotalAmount());
|
||||
summary.setCombinedOriginalAmount(combined.getOriginalTotalAmount());
|
||||
summary.setCombinedSubtotalAmount(combined.getTotalAmount());
|
||||
return summary;
|
||||
}
|
||||
|
||||
private UpgradeOnePriceResult evaluateOnePrice(Long scenicId, List<ProductItem> combinedProducts, PriceDetails combinedDetails) {
|
||||
UpgradeOnePriceResult result = new UpgradeOnePriceResult();
|
||||
result.setApplicable(false);
|
||||
|
||||
PriceBundleConfig bundleConfig = bundleService.getBundleConfig(combinedProducts);
|
||||
if (bundleConfig == null || !matchesScenic(bundleConfig.getScenicId(), scenicId)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
BigDecimal bundlePrice = bundleConfig.getBundlePrice() != null
|
||||
? bundleConfig.getBundlePrice()
|
||||
: combinedDetails.getTotalAmount();
|
||||
BigDecimal discountAmount = combinedDetails.getTotalAmount().subtract(bundlePrice);
|
||||
if (discountAmount.compareTo(BigDecimal.ZERO) < 0) {
|
||||
discountAmount = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
result.setApplicable(true);
|
||||
result.setBundleConfigId(bundleConfig.getId());
|
||||
result.setBundleName(bundleConfig.getBundleName());
|
||||
result.setDescription(bundleConfig.getDescription());
|
||||
result.setScenicId(bundleConfig.getScenicId());
|
||||
result.setBundlePrice(bundlePrice);
|
||||
result.setDiscountAmount(discountAmount);
|
||||
result.setEstimatedFinalAmount(bundlePrice);
|
||||
return result;
|
||||
}
|
||||
|
||||
private UpgradeBundleDiscountResult evaluateBundleDiscount(Long scenicId, List<ProductItem> combinedProducts, PriceDetails combinedDetails) {
|
||||
UpgradeBundleDiscountResult result = new UpgradeBundleDiscountResult();
|
||||
result.setApplicable(false);
|
||||
|
||||
BundleDiscountInfo bestDiscount = bundleDiscountService.getBestBundleDiscount(combinedProducts, scenicId);
|
||||
if (bestDiscount == null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
BigDecimal discountAmount = bestDiscount.getActualDiscountAmount();
|
||||
if (discountAmount == null || discountAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
discountAmount = bundleDiscountService.calculateBundleDiscount(bestDiscount, combinedProducts);
|
||||
}
|
||||
if (discountAmount == null || discountAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.setApplicable(true);
|
||||
result.setBundleConfigId(bestDiscount.getBundleConfigId());
|
||||
result.setBundleName(bestDiscount.getBundleName());
|
||||
result.setBundleDescription(bestDiscount.getBundleDescription());
|
||||
result.setDiscountType(bestDiscount.getDiscountType());
|
||||
result.setDiscountValue(bestDiscount.getDiscountValue());
|
||||
result.setDiscountAmount(discountAmount);
|
||||
result.setMinQuantity(bestDiscount.getMinQuantity());
|
||||
result.setMinAmount(bestDiscount.getMinAmount());
|
||||
|
||||
BigDecimal finalAmount = combinedDetails.getTotalAmount().subtract(discountAmount);
|
||||
if (finalAmount.compareTo(BigDecimal.ZERO) < 0) {
|
||||
finalAmount = BigDecimal.ZERO;
|
||||
}
|
||||
result.setEstimatedFinalAmount(finalAmount);
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ProductItem> cloneProducts(List<ProductItem> source) {
|
||||
List<ProductItem> copies = new ArrayList<>();
|
||||
if (source == null) {
|
||||
return copies;
|
||||
}
|
||||
for (ProductItem item : source) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
copies.add(cloneProductItem(item));
|
||||
}
|
||||
return copies;
|
||||
}
|
||||
|
||||
private ProductItem cloneProductItem(ProductItem source) {
|
||||
ProductItem copy = new ProductItem();
|
||||
copy.setProductType(source.getProductType());
|
||||
copy.setProductId(source.getProductId());
|
||||
copy.setQuantity(source.getQuantity());
|
||||
copy.setPurchaseCount(source.getPurchaseCount());
|
||||
copy.setOriginalPrice(source.getOriginalPrice());
|
||||
copy.setUnitPrice(source.getUnitPrice());
|
||||
copy.setSubtotal(source.getSubtotal());
|
||||
copy.setScenicId(source.getScenicId());
|
||||
return copy;
|
||||
}
|
||||
|
||||
private void normalizeProducts(List<ProductItem> products) {
|
||||
for (ProductItem product : products) {
|
||||
if (product.getProductType() == null) {
|
||||
throw new PriceCalculationException("商品类型不能为空");
|
||||
}
|
||||
if (product.getProductId() == null) {
|
||||
throw new PriceCalculationException("商品ID不能为空");
|
||||
}
|
||||
if (product.getPurchaseCount() == null) {
|
||||
product.setPurchaseCount(1);
|
||||
}
|
||||
if (product.getQuantity() == null) {
|
||||
product.setQuantity(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean matchesScenic(String configScenicId, Long scenicId) {
|
||||
if (scenicId == null) {
|
||||
return true;
|
||||
}
|
||||
if (configScenicId == null || configScenicId.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return configScenicId.equals(String.valueOf(scenicId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算优惠(券码 + 优惠券)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ycwl.basic.pricing.entity;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ycwl.basic.pricing.dto.BundleProductItem;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 验证PriceBundleConfig新数据结构的测试
|
||||
*/
|
||||
class PriceBundleConfigStructureTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void testNewDataStructure() throws JsonProcessingException {
|
||||
// 创建测试数据
|
||||
BundleProductItem includedItem = new BundleProductItem();
|
||||
includedItem.setType("PHOTO_PRINT");
|
||||
includedItem.setSubType("6寸照片");
|
||||
includedItem.setQuantity(20);
|
||||
|
||||
BundleProductItem excludedItem = new BundleProductItem();
|
||||
excludedItem.setType("VLOG_VIDEO");
|
||||
excludedItem.setProductId("video_001");
|
||||
excludedItem.setQuantity(1);
|
||||
|
||||
List<BundleProductItem> includedProducts = List.of(includedItem);
|
||||
List<BundleProductItem> excludedProducts = List.of(excludedItem);
|
||||
|
||||
// 创建实体
|
||||
PriceBundleConfig config = new PriceBundleConfig();
|
||||
config.setBundleName("全家福套餐");
|
||||
config.setBundlePrice(new BigDecimal("99.00"));
|
||||
config.setIncludedProducts(includedProducts);
|
||||
config.setExcludedProducts(excludedProducts);
|
||||
config.setDescription("包含20张6寸照片打印");
|
||||
config.setIsActive(true);
|
||||
|
||||
// 验证数据结构
|
||||
assertNotNull(config.getIncludedProducts());
|
||||
assertNotNull(config.getExcludedProducts());
|
||||
assertEquals(1, config.getIncludedProducts().size());
|
||||
assertEquals(1, config.getExcludedProducts().size());
|
||||
|
||||
// 验证包含商品
|
||||
BundleProductItem included = config.getIncludedProducts().get(0);
|
||||
assertEquals("PHOTO_PRINT", included.getType());
|
||||
assertEquals("6寸照片", included.getSubType());
|
||||
assertEquals(20, included.getQuantity());
|
||||
|
||||
// 验证排除商品
|
||||
BundleProductItem excluded = config.getExcludedProducts().get(0);
|
||||
assertEquals("VLOG_VIDEO", excluded.getType());
|
||||
assertEquals("video_001", excluded.getProductId());
|
||||
assertEquals(1, excluded.getQuantity());
|
||||
|
||||
// 验证JSON序列化
|
||||
String includedJson = objectMapper.writeValueAsString(config.getIncludedProducts());
|
||||
String excludedJson = objectMapper.writeValueAsString(config.getExcludedProducts());
|
||||
|
||||
System.out.println("Included Products JSON: " + includedJson);
|
||||
System.out.println("Excluded Products JSON: " + excludedJson);
|
||||
|
||||
// 验证能正确反序列化
|
||||
List<BundleProductItem> deserializedIncluded = objectMapper.readValue(includedJson,
|
||||
objectMapper.getTypeFactory().constructCollectionType(List.class, BundleProductItem.class));
|
||||
assertEquals(1, deserializedIncluded.size());
|
||||
assertEquals("PHOTO_PRINT", deserializedIncluded.get(0).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFrontendExpectedFormat() throws JsonProcessingException {
|
||||
// 测试前端期望的JSON格式
|
||||
String expectedJson = "[{\"type\":\"PHOTO_PRINT\",\"subType\":\"6寸照片\",\"quantity\":20}]";
|
||||
|
||||
List<BundleProductItem> items = objectMapper.readValue(expectedJson,
|
||||
objectMapper.getTypeFactory().constructCollectionType(List.class, BundleProductItem.class));
|
||||
|
||||
assertEquals(1, items.size());
|
||||
assertEquals("PHOTO_PRINT", items.get(0).getType());
|
||||
assertEquals("6寸照片", items.get(0).getSubType());
|
||||
assertEquals(20, items.get(0).getQuantity());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ycwl.basic.pricing.handler;
|
||||
|
||||
import com.ycwl.basic.pricing.dto.BundleProductItem;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 自定义TypeHandler测试
|
||||
*/
|
||||
class BundleProductListTypeHandlerTest {
|
||||
|
||||
private final BundleProductListTypeHandler handler = new BundleProductListTypeHandler();
|
||||
|
||||
@Test
|
||||
void testSetNonNullParameter() throws SQLException {
|
||||
// 创建测试数据
|
||||
BundleProductItem item1 = new BundleProductItem();
|
||||
item1.setType("PHOTO_PRINT");
|
||||
item1.setSubType("6寸照片");
|
||||
item1.setQuantity(20);
|
||||
|
||||
BundleProductItem item2 = new BundleProductItem();
|
||||
item2.setType("VLOG_VIDEO");
|
||||
item2.setProductId("video_001");
|
||||
item2.setQuantity(1);
|
||||
|
||||
List<BundleProductItem> items = Arrays.asList(item1, item2);
|
||||
|
||||
// Mock PreparedStatement
|
||||
PreparedStatement ps = Mockito.mock(PreparedStatement.class);
|
||||
|
||||
// 测试序列化
|
||||
assertDoesNotThrow(() -> handler.setNonNullParameter(ps, 1, items, null));
|
||||
|
||||
// 验证setString被调用
|
||||
verify(ps, times(1)).setString(eq(1), any(String.class));
|
||||
|
||||
System.out.println("序列化测试通过");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetNullableResultFromResultSet() throws SQLException {
|
||||
// Mock ResultSet
|
||||
ResultSet rs = Mockito.mock(ResultSet.class);
|
||||
|
||||
// 测试正常JSON
|
||||
String json = "[{\"type\":\"PHOTO_PRINT\",\"subType\":\"6寸照片\",\"quantity\":20}]";
|
||||
when(rs.getString("included_products")).thenReturn(json);
|
||||
|
||||
List<BundleProductItem> result = handler.getNullableResult(rs, "included_products");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("PHOTO_PRINT", result.get(0).getType());
|
||||
assertEquals("6寸照片", result.get(0).getSubType());
|
||||
assertEquals(20, result.get(0).getQuantity());
|
||||
|
||||
System.out.println("反序列化测试通过: " + result.get(0).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetNullableResultWithNullJson() throws SQLException {
|
||||
// Mock ResultSet返回null
|
||||
ResultSet rs = Mockito.mock(ResultSet.class);
|
||||
when(rs.getString("included_products")).thenReturn(null);
|
||||
|
||||
List<BundleProductItem> result = handler.getNullableResult(rs, "included_products");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(0, result.size());
|
||||
|
||||
System.out.println("Null JSON处理测试通过");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetNullableResultWithEmptyJson() throws SQLException {
|
||||
// Mock ResultSet返回空字符串
|
||||
ResultSet rs = Mockito.mock(ResultSet.class);
|
||||
when(rs.getString("included_products")).thenReturn("");
|
||||
|
||||
List<BundleProductItem> result = handler.getNullableResult(rs, "included_products");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(0, result.size());
|
||||
|
||||
System.out.println("空JSON处理测试通过");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetNullableResultWithInvalidJson() throws SQLException {
|
||||
// Mock ResultSet返回无效JSON
|
||||
ResultSet rs = Mockito.mock(ResultSet.class);
|
||||
when(rs.getString("included_products")).thenReturn("invalid json");
|
||||
|
||||
List<BundleProductItem> result = handler.getNullableResult(rs, "included_products");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(0, result.size());
|
||||
|
||||
System.out.println("无效JSON处理测试通过");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.ycwl.basic.pricing.mapper;
|
||||
|
||||
import com.ycwl.basic.pricing.entity.PriceBundleConfig;
|
||||
import com.ycwl.basic.pricing.entity.PriceProductConfig;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 优惠券开关字段映射测试
|
||||
* 验证新添加的canUseCoupon和canUseVoucher字段是否正确映射
|
||||
*/
|
||||
class CouponSwitchFieldsMappingTest {
|
||||
|
||||
@Test
|
||||
void testProductConfigSwitchFields() {
|
||||
// 测试商品配置的优惠券开关字段
|
||||
PriceProductConfig config = new PriceProductConfig();
|
||||
config.setProductType(ProductType.PHOTO_PRINT.getCode());
|
||||
config.setProductId("test_001");
|
||||
config.setProductName("测试商品");
|
||||
config.setBasePrice(new BigDecimal("10.00"));
|
||||
config.setOriginalPrice(new BigDecimal("15.00"));
|
||||
config.setUnit("元/个");
|
||||
config.setIsActive(true);
|
||||
|
||||
// 测试新增的优惠券开关字段
|
||||
config.setCanUseCoupon(true);
|
||||
config.setCanUseVoucher(false);
|
||||
|
||||
// 验证字段设置
|
||||
assertTrue(config.getCanUseCoupon());
|
||||
assertFalse(config.getCanUseVoucher());
|
||||
|
||||
System.out.println("商品配置开关字段测试通过:");
|
||||
System.out.println("- canUseCoupon: " + config.getCanUseCoupon());
|
||||
System.out.println("- canUseVoucher: " + config.getCanUseVoucher());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBundleConfigSwitchFields() {
|
||||
// 测试打包配置的优惠券开关字段
|
||||
PriceBundleConfig config = new PriceBundleConfig();
|
||||
config.setBundleName("测试套餐");
|
||||
config.setScenicId(1L);
|
||||
config.setBundlePrice(new BigDecimal("99.00"));
|
||||
config.setDescription("测试描述");
|
||||
config.setIsActive(true);
|
||||
|
||||
// 测试新增的优惠券开关字段
|
||||
config.setCanUseCoupon(false);
|
||||
config.setCanUseVoucher(true);
|
||||
|
||||
// 验证字段设置
|
||||
assertFalse(config.getCanUseCoupon());
|
||||
assertTrue(config.getCanUseVoucher());
|
||||
|
||||
System.out.println("打包配置开关字段测试通过:");
|
||||
System.out.println("- canUseCoupon: " + config.getCanUseCoupon());
|
||||
System.out.println("- canUseVoucher: " + config.getCanUseVoucher());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDefaultBooleanValues() {
|
||||
// 测试默认值逻辑
|
||||
PriceProductConfig config = new PriceProductConfig();
|
||||
|
||||
// 未设置时应为null
|
||||
assertNull(config.getCanUseCoupon());
|
||||
assertNull(config.getCanUseVoucher());
|
||||
|
||||
// 测试Boolean.TRUE.equals的逻辑
|
||||
assertFalse(Boolean.TRUE.equals(config.getCanUseCoupon())); // null case
|
||||
assertFalse(Boolean.TRUE.equals(config.getCanUseVoucher())); // null case
|
||||
|
||||
// 设置为false
|
||||
config.setCanUseCoupon(false);
|
||||
config.setCanUseVoucher(false);
|
||||
assertFalse(Boolean.TRUE.equals(config.getCanUseCoupon())); // false case
|
||||
assertFalse(Boolean.TRUE.equals(config.getCanUseVoucher())); // false case
|
||||
|
||||
// 设置为true
|
||||
config.setCanUseCoupon(true);
|
||||
config.setCanUseVoucher(true);
|
||||
assertTrue(Boolean.TRUE.equals(config.getCanUseCoupon())); // true case
|
||||
assertTrue(Boolean.TRUE.equals(config.getCanUseVoucher())); // true case
|
||||
|
||||
System.out.println("布尔值判断逻辑测试通过");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.ycwl.basic.pricing.mapper;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ycwl.basic.pricing.dto.BundleProductItem;
|
||||
import com.ycwl.basic.pricing.entity.PriceBundleConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 一口价配置JSON序列化测试
|
||||
*/
|
||||
class PriceBundleConfigJsonTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void testBundleProductItemSerialization() throws JsonProcessingException {
|
||||
// 创建测试数据
|
||||
BundleProductItem item1 = new BundleProductItem();
|
||||
item1.setType("PHOTO_PRINT");
|
||||
item1.setSubType("6寸照片");
|
||||
item1.setQuantity(20);
|
||||
|
||||
BundleProductItem item2 = new BundleProductItem();
|
||||
item2.setType("VLOG_VIDEO");
|
||||
item2.setProductId("video_001");
|
||||
item2.setQuantity(1);
|
||||
|
||||
List<BundleProductItem> items = Arrays.asList(item1, item2);
|
||||
|
||||
// 测试序列化
|
||||
String json = objectMapper.writeValueAsString(items);
|
||||
System.out.println("序列化结果: " + json);
|
||||
|
||||
assertNotNull(json);
|
||||
assertTrue(json.contains("PHOTO_PRINT"));
|
||||
assertTrue(json.contains("6寸照片"));
|
||||
assertTrue(json.contains("VLOG_VIDEO"));
|
||||
|
||||
// 测试反序列化
|
||||
List<BundleProductItem> deserializedItems = objectMapper.readValue(json,
|
||||
objectMapper.getTypeFactory().constructCollectionType(List.class, BundleProductItem.class));
|
||||
|
||||
assertNotNull(deserializedItems);
|
||||
assertEquals(2, deserializedItems.size());
|
||||
assertEquals("PHOTO_PRINT", deserializedItems.get(0).getType());
|
||||
assertEquals("6寸照片", deserializedItems.get(0).getSubType());
|
||||
assertEquals(20, deserializedItems.get(0).getQuantity());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPriceBundleConfigFieldsNotNull() {
|
||||
// 创建测试配置
|
||||
PriceBundleConfig config = new PriceBundleConfig();
|
||||
config.setBundleName("测试套餐");
|
||||
config.setBundlePrice(new BigDecimal("99.00"));
|
||||
|
||||
// 创建包含商品
|
||||
BundleProductItem includedItem = new BundleProductItem();
|
||||
includedItem.setType("PHOTO_PRINT");
|
||||
includedItem.setSubType("6寸照片");
|
||||
includedItem.setQuantity(20);
|
||||
|
||||
// 创建排除商品
|
||||
BundleProductItem excludedItem = new BundleProductItem();
|
||||
excludedItem.setType("VLOG_VIDEO");
|
||||
excludedItem.setProductId("video_001");
|
||||
excludedItem.setQuantity(1);
|
||||
|
||||
config.setIncludedProducts(List.of(includedItem));
|
||||
config.setExcludedProducts(List.of(excludedItem));
|
||||
config.setIsActive(true);
|
||||
|
||||
// 验证字段不为null
|
||||
assertNotNull(config.getIncludedProducts());
|
||||
assertNotNull(config.getExcludedProducts());
|
||||
assertEquals(1, config.getIncludedProducts().size());
|
||||
assertEquals(1, config.getExcludedProducts().size());
|
||||
|
||||
System.out.println("包含商品数量: " + config.getIncludedProducts().size());
|
||||
System.out.println("排除商品数量: " + config.getExcludedProducts().size());
|
||||
System.out.println("包含商品: " + config.getIncludedProducts().get(0).getType());
|
||||
System.out.println("排除商品: " + config.getExcludedProducts().get(0).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEmptyListSerialization() throws JsonProcessingException {
|
||||
// 测试空列表的序列化
|
||||
PriceBundleConfig config = new PriceBundleConfig();
|
||||
config.setBundleName("测试套餐");
|
||||
config.setBundlePrice(new BigDecimal("99.00"));
|
||||
config.setIncludedProducts(List.of()); // 空列表
|
||||
config.setExcludedProducts(null); // null值
|
||||
|
||||
assertNotNull(config.getIncludedProducts());
|
||||
assertEquals(0, config.getIncludedProducts().size());
|
||||
assertNull(config.getExcludedProducts());
|
||||
|
||||
System.out.println("空列表测试通过");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 独立的流水号生成测试(不依赖Spring)
|
||||
* 专门测试generateCode方法的重复率和性能
|
||||
*/
|
||||
public class CodeGenerationStandaloneTest {
|
||||
|
||||
private static final String CODE_PREFIX = "VT";
|
||||
|
||||
/**
|
||||
* 模拟当前的generateCode方法实现
|
||||
*/
|
||||
private String generateCode() {
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("ss");
|
||||
String timestamp = sdf.format(new Date());
|
||||
String randomSuffix = String.valueOf((int)(Math.random() * 100000)).formatted("%05d");
|
||||
return CODE_PREFIX + timestamp + randomSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单线程环境下快速生成10个流水号的重复率
|
||||
*/
|
||||
@Test
|
||||
public void testSingleThreadDuplication() {
|
||||
System.out.println("=== 开始单线程重复率测试 ===");
|
||||
|
||||
int totalRounds = 1000; // 测试1000轮
|
||||
int codesPerRound = 10; // 每轮生成10个流水号
|
||||
int totalDuplicates = 0;
|
||||
int totalCodes = 0;
|
||||
|
||||
for (int round = 0; round < totalRounds; round++) {
|
||||
Set<String> codes = new HashSet<>();
|
||||
List<String> codeList = new ArrayList<>();
|
||||
|
||||
// 快速生成10个流水号
|
||||
for (int i = 0; i < codesPerRound; i++) {
|
||||
String code = generateCode();
|
||||
codes.add(code);
|
||||
codeList.add(code);
|
||||
}
|
||||
|
||||
int duplicates = codeList.size() - codes.size();
|
||||
if (duplicates > 0) {
|
||||
totalDuplicates += duplicates;
|
||||
System.out.printf("第%d轮发现%d个重复: %s%n", round + 1, duplicates, codeList);
|
||||
}
|
||||
|
||||
totalCodes += codesPerRound;
|
||||
|
||||
// 稍微休息一下
|
||||
try {
|
||||
Thread.sleep(1);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
double duplicationRate = (double) totalDuplicates / totalCodes * 100;
|
||||
System.out.println("=== 单线程测试结果 ===");
|
||||
System.out.printf("总轮数: %d%n", totalRounds);
|
||||
System.out.printf("每轮生成数: %d%n", codesPerRound);
|
||||
System.out.printf("总生成数: %d%n", totalCodes);
|
||||
System.out.printf("总重复数: %d%n", totalDuplicates);
|
||||
System.out.printf("重复率: %.4f%%%n", duplicationRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 高并发多线程测试
|
||||
*/
|
||||
@Test
|
||||
public void testHighConcurrency() throws InterruptedException {
|
||||
System.out.println("=== 开始高并发测试 ===");
|
||||
|
||||
int threadCount = 10; // 10个并发线程
|
||||
int codesPerThread = 10; // 每个线程生成10个流水号
|
||||
int totalExpectedCodes = threadCount * codesPerThread;
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch latch = new CountDownLatch(threadCount);
|
||||
List<String> allCodesList = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
// 启动所有线程
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
final int threadId = i;
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
// 每个线程快速生成流水号
|
||||
for (int j = 0; j < codesPerThread; j++) {
|
||||
String code = generateCode();
|
||||
allCodesList.add(code);
|
||||
}
|
||||
|
||||
System.out.printf("线程%d完成%n", threadId);
|
||||
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 等待所有线程完成
|
||||
boolean finished = latch.await(10, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
|
||||
if (!finished) {
|
||||
System.err.println("测试超时!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 分析结果
|
||||
Set<String> uniqueCodes = new HashSet<>(allCodesList);
|
||||
int duplicates = totalExpectedCodes - uniqueCodes.size();
|
||||
double duplicationRate = (double) duplicates / totalExpectedCodes * 100;
|
||||
|
||||
// 找出重复的流水号
|
||||
Map<String, Long> codeCount = allCodesList.stream()
|
||||
.collect(Collectors.groupingBy(code -> code, Collectors.counting()));
|
||||
|
||||
List<Map.Entry<String, Long>> duplicatedCodes = codeCount.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() > 1)
|
||||
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
|
||||
.limit(10)
|
||||
.toList();
|
||||
|
||||
System.out.println("=== 高并发测试结果 ===");
|
||||
System.out.printf("并发线程数: %d%n", threadCount);
|
||||
System.out.printf("每线程生成数: %d%n", codesPerThread);
|
||||
System.out.printf("预期总数: %d%n", totalExpectedCodes);
|
||||
System.out.printf("实际总数: %d%n", allCodesList.size());
|
||||
System.out.printf("唯一流水号数: %d%n", uniqueCodes.size());
|
||||
System.out.printf("重复数: %d%n", duplicates);
|
||||
System.out.printf("重复率: %.4f%%%n", duplicationRate);
|
||||
|
||||
if (!duplicatedCodes.isEmpty()) {
|
||||
System.out.println("=== 发现重复流水号 ===");
|
||||
duplicatedCodes.forEach(entry ->
|
||||
System.out.printf("流水号: %s 重复了 %d 次%n", entry.getKey(), entry.getValue()));
|
||||
}
|
||||
|
||||
if (duplicationRate > 1.0) {
|
||||
System.err.println("严重警告:高并发下重复率超过1.0%,必须优化generateCode方法!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格1秒内生成测试
|
||||
*/
|
||||
@Test
|
||||
public void testOneSecondGeneration() {
|
||||
System.out.println("=== 开始1秒内生成测试 ===");
|
||||
|
||||
Set<String> codes = new HashSet<>();
|
||||
List<String> codeList = new ArrayList<>();
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 在1秒内尽可能多地生成流水号
|
||||
while (System.currentTimeMillis() - startTime < 1000) {
|
||||
String code = generateCode();
|
||||
codes.add(code);
|
||||
codeList.add(code);
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
int duplicates = codeList.size() - codes.size();
|
||||
double duplicationRate = (double) duplicates / codeList.size() * 100;
|
||||
|
||||
System.out.println("=== 1秒内生成测试结果 ===");
|
||||
System.out.printf("测试时长: %d ms%n", duration);
|
||||
System.out.printf("总生成数: %d%n", codeList.size());
|
||||
System.out.printf("唯一数: %d%n", codes.size());
|
||||
System.out.printf("重复数: %d%n", duplicates);
|
||||
System.out.printf("重复率: %.4f%%%n", duplicationRate);
|
||||
System.out.printf("生成速率: %.1f codes/sec%n", (double) codeList.size() / duration * 1000);
|
||||
|
||||
if (duplicates > 0) {
|
||||
// 找出重复的流水号
|
||||
Map<String, Long> codeCount = codeList.stream()
|
||||
.collect(Collectors.groupingBy(code -> code, Collectors.counting()));
|
||||
|
||||
codeCount.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() > 1)
|
||||
.limit(10)
|
||||
.forEach(entry ->
|
||||
System.out.printf("重复流水号: %s (出现%d次)%n", entry.getKey(), entry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合评估报告
|
||||
*/
|
||||
@Test
|
||||
public void generateReport() {
|
||||
System.out.println("=== generateCode方法综合评估报告 ===");
|
||||
|
||||
// 基础性能测试
|
||||
long startTime = System.nanoTime();
|
||||
List<String> sample = new ArrayList<>();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
sample.add(generateCode());
|
||||
}
|
||||
long duration = System.nanoTime() - startTime;
|
||||
double avgTimePerCode = duration / 1000.0 / 1_000_000; // 毫秒
|
||||
|
||||
// 唯一性分析
|
||||
Set<String> uniqueSample = new HashSet<>(sample);
|
||||
double sampleDuplicationRate = (double) (sample.size() - uniqueSample.size()) / sample.size() * 100;
|
||||
|
||||
// 长度和格式分析
|
||||
String sampleCode = generateCode();
|
||||
int codeLength = sampleCode.length();
|
||||
boolean hasCorrectPrefix = sampleCode.startsWith(CODE_PREFIX);
|
||||
|
||||
// 理论分析
|
||||
double theoreticalCollisionProbability = calculateBirthdayParadoxProbability(10, 100000);
|
||||
|
||||
System.out.println("基础信息:");
|
||||
System.out.printf(" - 代码前缀: %s%n", CODE_PREFIX);
|
||||
System.out.printf(" - 流水号长度: %d%n", codeLength);
|
||||
System.out.printf(" - 格式正确: %b%n", hasCorrectPrefix);
|
||||
System.out.printf(" - 示例流水号: %s%n", sampleCode);
|
||||
|
||||
System.out.println("性能指标:");
|
||||
System.out.printf(" - 平均生成时间: %.3f ms%n", avgTimePerCode);
|
||||
System.out.printf(" - 理论最大生成速率: %.0f codes/sec%n", 1000.0 / avgTimePerCode);
|
||||
|
||||
System.out.println("唯一性分析:");
|
||||
System.out.printf(" - 样本重复率: %.4f%% (1000个样本)%n", sampleDuplicationRate);
|
||||
System.out.printf(" - 理论冲突概率: %.4f%% (1秒内10个)%n", theoreticalCollisionProbability * 100);
|
||||
System.out.println(" - 随机数范围: 100,000 (00000-99999)");
|
||||
|
||||
System.out.println("风险评估:");
|
||||
if (sampleDuplicationRate > 0.5) {
|
||||
System.err.println(" - 高风险:样本重复率过高,不适合生产环境");
|
||||
} else if (sampleDuplicationRate > 0.1) {
|
||||
System.out.println(" - 中风险:存在一定重复概率,建议优化");
|
||||
} else {
|
||||
System.out.println(" - 低风险:重复概率较低,基本可用");
|
||||
}
|
||||
|
||||
System.out.println("优化建议:");
|
||||
System.out.println(" - 建议1:使用毫秒级时间戳替代秒级");
|
||||
System.out.println(" - 建议2:增加机器标识或进程ID");
|
||||
System.out.println(" - 建议3:使用原子递增计数器");
|
||||
System.out.println(" - 建议4:采用UUID算法确保全局唯一性");
|
||||
|
||||
// 显示一些示例流水号
|
||||
System.out.println("示例流水号:");
|
||||
for (int i = 0; i < 10; i++) {
|
||||
System.out.printf(" %s%n", generateCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算生日悖论概率
|
||||
*/
|
||||
private double calculateBirthdayParadoxProbability(int n, int d) {
|
||||
if (n > d) return 1.0;
|
||||
|
||||
double probability = 1.0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
probability *= (double) (d - i) / d;
|
||||
}
|
||||
return 1.0 - probability;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimRequest;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimResult;
|
||||
import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord;
|
||||
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
|
||||
import com.ycwl.basic.pricing.mapper.PriceCouponClaimRecordMapper;
|
||||
import com.ycwl.basic.pricing.mapper.PriceCouponConfigMapper;
|
||||
import com.ycwl.basic.pricing.service.impl.CouponServiceImpl;
|
||||
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;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CouponServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private PriceCouponConfigMapper couponConfigMapper;
|
||||
|
||||
@Mock
|
||||
private PriceCouponClaimRecordMapper couponClaimRecordMapper;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private CouponServiceImpl couponService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
couponService = new CouponServiceImpl(couponConfigMapper, couponClaimRecordMapper, objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailWhenUserClaimLimitReached() {
|
||||
PriceCouponConfig coupon = baseCoupon();
|
||||
coupon.setUserClaimLimit(1);
|
||||
when(couponConfigMapper.selectById(1L)).thenReturn(coupon);
|
||||
when(couponClaimRecordMapper.countUserCouponClaims(10L, 1L)).thenReturn(1);
|
||||
|
||||
CouponClaimResult result = couponService.claimCoupon(buildRequest());
|
||||
|
||||
assertFalse(result.isSuccess());
|
||||
assertEquals(CouponClaimResult.ERROR_CLAIM_LIMIT_REACHED, result.getErrorCode());
|
||||
verify(couponClaimRecordMapper, never()).insert(any());
|
||||
verify(couponConfigMapper, never()).incrementClaimedQuantityIfAvailable(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSucceedWhenClaimWithinLimitAndStockAvailable() {
|
||||
PriceCouponConfig coupon = baseCoupon();
|
||||
coupon.setUserClaimLimit(3);
|
||||
coupon.setClaimedQuantity(1);
|
||||
when(couponConfigMapper.selectById(1L)).thenReturn(coupon);
|
||||
when(couponClaimRecordMapper.countUserCouponClaims(10L, 1L)).thenReturn(0);
|
||||
when(couponClaimRecordMapper.insert(any())).thenAnswer(invocation -> {
|
||||
PriceCouponClaimRecord record = invocation.getArgument(0);
|
||||
record.setId(99L);
|
||||
return 1;
|
||||
});
|
||||
when(couponConfigMapper.incrementClaimedQuantityIfAvailable(1L)).thenReturn(1);
|
||||
|
||||
CouponClaimResult result = couponService.claimCoupon(buildRequest());
|
||||
|
||||
assertTrue(result.isSuccess());
|
||||
assertEquals(99L, result.getClaimRecordId());
|
||||
assertEquals(2, coupon.getClaimedQuantity());
|
||||
ArgumentCaptor<PriceCouponClaimRecord> captor = ArgumentCaptor.forClass(PriceCouponClaimRecord.class);
|
||||
verify(couponClaimRecordMapper).insert(captor.capture());
|
||||
assertEquals(10L, captor.getValue().getUserId());
|
||||
verify(couponConfigMapper).incrementClaimedQuantityIfAvailable(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnOutOfStockWhenInventoryUpdateFails() {
|
||||
PriceCouponConfig coupon = baseCoupon();
|
||||
coupon.setClaimedQuantity(9);
|
||||
when(couponConfigMapper.selectById(1L)).thenReturn(coupon);
|
||||
when(couponClaimRecordMapper.countUserCouponClaims(10L, 1L)).thenReturn(0);
|
||||
when(couponClaimRecordMapper.insert(any())).thenReturn(1);
|
||||
when(couponConfigMapper.incrementClaimedQuantityIfAvailable(1L)).thenReturn(0);
|
||||
|
||||
CouponClaimResult result = couponService.claimCoupon(buildRequest());
|
||||
|
||||
assertFalse(result.isSuccess());
|
||||
assertEquals(CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK, result.getErrorCode());
|
||||
}
|
||||
|
||||
private CouponClaimRequest buildRequest() {
|
||||
CouponClaimRequest request = new CouponClaimRequest();
|
||||
request.setUserId(10L);
|
||||
request.setCouponId(1L);
|
||||
request.setScenicId("SCENIC-1");
|
||||
return request;
|
||||
}
|
||||
|
||||
private PriceCouponConfig baseCoupon() {
|
||||
PriceCouponConfig coupon = new PriceCouponConfig();
|
||||
coupon.setId(1L);
|
||||
coupon.setCouponName("新客券");
|
||||
coupon.setIsActive(true);
|
||||
coupon.setValidFrom(LocalDateTime.now().minusDays(1));
|
||||
coupon.setValidUntil(LocalDateTime.now().plusDays(1));
|
||||
coupon.setTotalQuantity(100);
|
||||
coupon.setClaimedQuantity(0);
|
||||
return coupon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.ycwl.basic.pricing.entity.PriceProductConfig;
|
||||
import com.ycwl.basic.pricing.entity.PriceTierConfig;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Default配置校验测试
|
||||
*/
|
||||
class DefaultConfigValidationTest {
|
||||
|
||||
@Test
|
||||
void testDefaultProductConfigValidation() {
|
||||
// 测试default商品配置的创建校验逻辑
|
||||
PriceProductConfig config = new PriceProductConfig();
|
||||
config.setProductType(ProductType.PHOTO_PRINT.getCode());
|
||||
config.setProductId("default");
|
||||
config.setProductName("默认打印配置");
|
||||
config.setBasePrice(new BigDecimal("5.00"));
|
||||
config.setOriginalPrice(new BigDecimal("8.00"));
|
||||
config.setUnit("元/张");
|
||||
config.setIsActive(true);
|
||||
|
||||
// 验证productId为default
|
||||
assertEquals("default", config.getProductId());
|
||||
assertEquals(ProductType.PHOTO_PRINT.getCode(), config.getProductType());
|
||||
|
||||
System.out.println("Default商品配置创建成功: " + config.getProductName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDefaultTierConfigCreation() {
|
||||
// 测试default阶梯配置的创建
|
||||
PriceTierConfig config = new PriceTierConfig();
|
||||
config.setProductType(ProductType.PHOTO_PRINT.getCode());
|
||||
config.setProductId("default");
|
||||
config.setMinQuantity(1);
|
||||
config.setMaxQuantity(10);
|
||||
config.setPrice(new BigDecimal("5.00"));
|
||||
config.setOriginalPrice(new BigDecimal("8.00"));
|
||||
config.setUnit("元/张");
|
||||
config.setSortOrder(1);
|
||||
config.setIsActive(true);
|
||||
|
||||
// 验证productId为default
|
||||
assertEquals("default", config.getProductId());
|
||||
assertEquals(ProductType.PHOTO_PRINT.getCode(), config.getProductType());
|
||||
|
||||
System.out.println("Default阶梯配置创建成功: " + config.getProductType() +
|
||||
", 数量区间: " + config.getMinQuantity() + "-" + config.getMaxQuantity());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testProductIdFallbackLogic() {
|
||||
// 测试兜底逻辑的概念验证
|
||||
String specificProductId = "video_001";
|
||||
String fallbackProductId = "default";
|
||||
|
||||
// 模拟查询特定商品失败,需要使用default配置
|
||||
boolean specificFound = false; // 假设没有找到特定配置
|
||||
String actualProductId = specificFound ? specificProductId : fallbackProductId;
|
||||
|
||||
assertEquals("default", actualProductId);
|
||||
System.out.println("使用兜底配置: productId=" + actualProductId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
|
||||
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
|
||||
import com.ycwl.basic.pricing.dto.ProductItem;
|
||||
import com.ycwl.basic.pricing.dto.ProductPriceInfo;
|
||||
import com.ycwl.basic.pricing.entity.PriceProductConfig;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
import com.ycwl.basic.pricing.service.impl.PriceCalculationServiceImpl;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
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;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 测试新增的照片打印 SKU(PHOTO_PRINT_MU 和 PHOTO_PRINT_FX)的价格计算逻辑
|
||||
* <p>
|
||||
* 测试范围:
|
||||
* 1. 枚举定义正确性
|
||||
* 2. 价格计算逻辑(单价×数量)
|
||||
* 3. 价格回退机制(阶梯→具体→default)
|
||||
* 4. 与现有 PHOTO_PRINT 行为一致性
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("新照片打印 SKU 价格计算测试")
|
||||
class NewPhotoPrintSkuTest {
|
||||
|
||||
@Mock
|
||||
private IProductConfigService productConfigService;
|
||||
|
||||
@Mock
|
||||
private ICouponService couponService;
|
||||
|
||||
@Mock
|
||||
private IPriceBundleService bundleService;
|
||||
|
||||
@Mock
|
||||
private IDiscountDetectionService discountDetectionService;
|
||||
|
||||
@Mock
|
||||
private IVoucherService voucherService;
|
||||
|
||||
@InjectMocks
|
||||
private PriceCalculationServiceImpl priceCalculationService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 初始化设置(如有需要)
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试1:验证新枚举类型定义正确
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("验证 PHOTO_PRINT_MU 和 PHOTO_PRINT_FX 枚举定义")
|
||||
void testNewProductTypeEnumDefinition() {
|
||||
// 验证枚举值存在
|
||||
assertNotNull(ProductType.PHOTO_PRINT_MU);
|
||||
assertNotNull(ProductType.PHOTO_PRINT_FX);
|
||||
|
||||
// 验证枚举属性
|
||||
assertEquals("PHOTO_PRINT_MU", ProductType.PHOTO_PRINT_MU.getCode());
|
||||
assertEquals("手机照片打印", ProductType.PHOTO_PRINT_MU.getDescription());
|
||||
|
||||
assertEquals("PHOTO_PRINT_FX", ProductType.PHOTO_PRINT_FX.getCode());
|
||||
assertEquals("特效照片打印", ProductType.PHOTO_PRINT_FX.getDescription());
|
||||
|
||||
// 验证 fromCode 方法
|
||||
assertEquals(ProductType.PHOTO_PRINT_MU, ProductType.fromCode("PHOTO_PRINT_MU"));
|
||||
assertEquals(ProductType.PHOTO_PRINT_FX, ProductType.fromCode("PHOTO_PRINT_FX"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试2:手机照片打印 - 基础价格计算(单价×数量)
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("PHOTO_PRINT_MU - 基础价格计算(单价×数量)")
|
||||
void testPhotoPrintMuPriceCalculation() {
|
||||
// 准备测试数据
|
||||
BigDecimal unitPrice = new BigDecimal("3.00");
|
||||
BigDecimal originalPrice = new BigDecimal("5.00");
|
||||
int quantity = 20;
|
||||
|
||||
PriceProductConfig config = new PriceProductConfig();
|
||||
config.setProductType("PHOTO_PRINT_MU");
|
||||
config.setProductId("default");
|
||||
config.setBasePrice(unitPrice);
|
||||
config.setOriginalPrice(originalPrice);
|
||||
|
||||
// Mock 服务行为
|
||||
when(productConfigService.getTierConfig(anyString(), anyString(), anyInt()))
|
||||
.thenReturn(null); // 不使用阶梯定价
|
||||
when(productConfigService.getProductConfig("PHOTO_PRINT_MU", "default"))
|
||||
.thenReturn(config);
|
||||
|
||||
// 构建请求
|
||||
ProductItem item = new ProductItem();
|
||||
item.setProductType(ProductType.PHOTO_PRINT_MU);
|
||||
item.setProductId("default");
|
||||
item.setQuantity(quantity);
|
||||
item.setPurchaseCount(1);
|
||||
|
||||
PriceCalculationRequest request = new PriceCalculationRequest();
|
||||
request.setProducts(Collections.singletonList(item));
|
||||
request.setPreviewOnly(true);
|
||||
|
||||
// 执行计算
|
||||
PriceCalculationResult result = priceCalculationService.calculatePrice(request);
|
||||
|
||||
// 验证结果
|
||||
assertNotNull(result);
|
||||
assertEquals(0, unitPrice.multiply(BigDecimal.valueOf(quantity))
|
||||
.compareTo(result.getFinalAmount())); // 3.00 * 20 = 60.00
|
||||
|
||||
// 验证服务调用
|
||||
verify(productConfigService, atLeastOnce())
|
||||
.getProductConfig("PHOTO_PRINT_MU", "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试3:特效照片打印 - 基础价格计算
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("PHOTO_PRINT_FX - 基础价格计算(单价×数量)")
|
||||
void testPhotoPrintFxPriceCalculation() {
|
||||
// 准备测试数据
|
||||
BigDecimal unitPrice = new BigDecimal("5.00");
|
||||
BigDecimal originalPrice = new BigDecimal("8.00");
|
||||
int quantity = 15;
|
||||
|
||||
PriceProductConfig config = new PriceProductConfig();
|
||||
config.setProductType("PHOTO_PRINT_FX");
|
||||
config.setProductId("default");
|
||||
config.setBasePrice(unitPrice);
|
||||
config.setOriginalPrice(originalPrice);
|
||||
|
||||
// Mock 服务行为
|
||||
when(productConfigService.getTierConfig(anyString(), anyString(), anyInt()))
|
||||
.thenReturn(null);
|
||||
when(productConfigService.getProductConfig("PHOTO_PRINT_FX", "default"))
|
||||
.thenReturn(config);
|
||||
|
||||
// 构建请求
|
||||
ProductItem item = new ProductItem();
|
||||
item.setProductType(ProductType.PHOTO_PRINT_FX);
|
||||
item.setProductId("default");
|
||||
item.setQuantity(quantity);
|
||||
item.setPurchaseCount(1);
|
||||
|
||||
PriceCalculationRequest request = new PriceCalculationRequest();
|
||||
request.setProducts(Collections.singletonList(item));
|
||||
request.setPreviewOnly(true);
|
||||
|
||||
// 执行计算
|
||||
PriceCalculationResult result = priceCalculationService.calculatePrice(request);
|
||||
|
||||
// 验证结果
|
||||
assertNotNull(result);
|
||||
assertEquals(0, unitPrice.multiply(BigDecimal.valueOf(quantity))
|
||||
.compareTo(result.getFinalAmount())); // 5.00 * 15 = 75.00
|
||||
|
||||
verify(productConfigService, atLeastOnce())
|
||||
.getProductConfig("PHOTO_PRINT_FX", "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试4:景区特定配置价格计算
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("PHOTO_PRINT_MU - 景区特定配置")
|
||||
void testPhotoPrintMuWithScenicSpecificConfig() {
|
||||
String scenicId = "100";
|
||||
BigDecimal scenicPrice = new BigDecimal("2.50");
|
||||
int quantity = 30;
|
||||
|
||||
PriceProductConfig scenicConfig = new PriceProductConfig();
|
||||
scenicConfig.setProductType("PHOTO_PRINT_MU");
|
||||
scenicConfig.setProductId(scenicId);
|
||||
scenicConfig.setBasePrice(scenicPrice);
|
||||
|
||||
when(productConfigService.getTierConfig(anyString(), anyString(), anyInt()))
|
||||
.thenReturn(null);
|
||||
when(productConfigService.getProductConfig("PHOTO_PRINT_MU", scenicId))
|
||||
.thenReturn(scenicConfig);
|
||||
|
||||
ProductItem item = new ProductItem();
|
||||
item.setProductType(ProductType.PHOTO_PRINT_MU);
|
||||
item.setProductId(scenicId);
|
||||
item.setQuantity(quantity);
|
||||
item.setPurchaseCount(1);
|
||||
|
||||
PriceCalculationRequest request = new PriceCalculationRequest();
|
||||
request.setProducts(Collections.singletonList(item));
|
||||
request.setPreviewOnly(true);
|
||||
|
||||
PriceCalculationResult result = priceCalculationService.calculatePrice(request);
|
||||
|
||||
assertNotNull(result);
|
||||
// 验证使用景区特定价格:2.50 * 30 = 75.00
|
||||
assertEquals(0, scenicPrice.multiply(BigDecimal.valueOf(quantity))
|
||||
.compareTo(result.getFinalAmount()));
|
||||
|
||||
verify(productConfigService).getProductConfig("PHOTO_PRINT_MU", scenicId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试5:价格回退机制 - 从景区配置回退到 default
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("价格回退机制 - 景区配置不存在时回退到 default")
|
||||
void testPriceFallbackMechanism() {
|
||||
String scenicId = "999";
|
||||
BigDecimal defaultPrice = new BigDecimal("3.00");
|
||||
int quantity = 10;
|
||||
|
||||
PriceProductConfig defaultConfig = new PriceProductConfig();
|
||||
defaultConfig.setProductType("PHOTO_PRINT_MU");
|
||||
defaultConfig.setProductId("default");
|
||||
defaultConfig.setBasePrice(defaultPrice);
|
||||
|
||||
// 景区配置不存在,抛出异常
|
||||
when(productConfigService.getTierConfig(anyString(), anyString(), anyInt()))
|
||||
.thenReturn(null);
|
||||
when(productConfigService.getProductConfig("PHOTO_PRINT_MU", scenicId))
|
||||
.thenThrow(new RuntimeException("Not found"));
|
||||
// 回退到 default 配置
|
||||
when(productConfigService.getProductConfig("PHOTO_PRINT_MU", "default"))
|
||||
.thenReturn(defaultConfig);
|
||||
|
||||
ProductItem item = new ProductItem();
|
||||
item.setProductType(ProductType.PHOTO_PRINT_MU);
|
||||
item.setProductId(scenicId);
|
||||
item.setQuantity(quantity);
|
||||
item.setPurchaseCount(1);
|
||||
|
||||
PriceCalculationRequest request = new PriceCalculationRequest();
|
||||
request.setProducts(Collections.singletonList(item));
|
||||
request.setPreviewOnly(true);
|
||||
|
||||
PriceCalculationResult result = priceCalculationService.calculatePrice(request);
|
||||
|
||||
assertNotNull(result);
|
||||
// 验证使用 default 价格:3.00 * 10 = 30.00
|
||||
assertEquals(0, defaultPrice.multiply(BigDecimal.valueOf(quantity))
|
||||
.compareTo(result.getFinalAmount()));
|
||||
|
||||
// 验证回退逻辑被调用
|
||||
verify(productConfigService).getProductConfig("PHOTO_PRINT_MU", scenicId);
|
||||
verify(productConfigService, atLeastOnce())
|
||||
.getProductConfig("PHOTO_PRINT_MU", "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试6:与现有 PHOTO_PRINT 行为一致性
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("新 SKU 与 PHOTO_PRINT 行为一致性")
|
||||
void testConsistencyWithExistingPhotoPrint() {
|
||||
BigDecimal unitPrice = new BigDecimal("2.00");
|
||||
int quantity = 25;
|
||||
BigDecimal expectedTotal = unitPrice.multiply(BigDecimal.valueOf(quantity));
|
||||
|
||||
// 准备三种类型的配置
|
||||
PriceProductConfig photoPrintConfig = createConfig("PHOTO_PRINT", unitPrice);
|
||||
PriceProductConfig photoPrintMuConfig = createConfig("PHOTO_PRINT_MU", unitPrice);
|
||||
PriceProductConfig photoPrintFxConfig = createConfig("PHOTO_PRINT_FX", unitPrice);
|
||||
|
||||
when(productConfigService.getTierConfig(anyString(), anyString(), anyInt()))
|
||||
.thenReturn(null);
|
||||
when(productConfigService.getProductConfig("PHOTO_PRINT", "default"))
|
||||
.thenReturn(photoPrintConfig);
|
||||
when(productConfigService.getProductConfig("PHOTO_PRINT_MU", "default"))
|
||||
.thenReturn(photoPrintMuConfig);
|
||||
when(productConfigService.getProductConfig("PHOTO_PRINT_FX", "default"))
|
||||
.thenReturn(photoPrintFxConfig);
|
||||
|
||||
// 测试 PHOTO_PRINT
|
||||
PriceCalculationResult result1 = calculatePrice(ProductType.PHOTO_PRINT, quantity);
|
||||
// 测试 PHOTO_PRINT_MU
|
||||
PriceCalculationResult result2 = calculatePrice(ProductType.PHOTO_PRINT_MU, quantity);
|
||||
// 测试 PHOTO_PRINT_FX
|
||||
PriceCalculationResult result3 = calculatePrice(ProductType.PHOTO_PRINT_FX, quantity);
|
||||
|
||||
// 验证三者计算逻辑一致(都是单价×数量)
|
||||
assertEquals(0, expectedTotal.compareTo(result1.getFinalAmount()));
|
||||
assertEquals(0, expectedTotal.compareTo(result2.getFinalAmount()));
|
||||
assertEquals(0, expectedTotal.compareTo(result3.getFinalAmount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试7:数量为0或负数的边界情况
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("边界测试 - 数量为0或负数")
|
||||
void testEdgeCasesWithZeroOrNegativeQuantity() {
|
||||
BigDecimal unitPrice = new BigDecimal("3.00");
|
||||
|
||||
PriceProductConfig config = new PriceProductConfig();
|
||||
config.setProductType("PHOTO_PRINT_MU");
|
||||
config.setProductId("default");
|
||||
config.setBasePrice(unitPrice);
|
||||
|
||||
when(productConfigService.getTierConfig(anyString(), anyString(), anyInt()))
|
||||
.thenReturn(null);
|
||||
when(productConfigService.getProductConfig("PHOTO_PRINT_MU", "default"))
|
||||
.thenReturn(config);
|
||||
|
||||
// 测试数量为0
|
||||
PriceCalculationResult result0 = calculatePrice(ProductType.PHOTO_PRINT_MU, 0);
|
||||
assertEquals(0, BigDecimal.ZERO.compareTo(result0.getFinalAmount()));
|
||||
|
||||
// 测试数量为1(边界)
|
||||
PriceCalculationResult result1 = calculatePrice(ProductType.PHOTO_PRINT_MU, 1);
|
||||
assertEquals(0, unitPrice.compareTo(result1.getFinalAmount()));
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 创建价格配置对象
|
||||
*/
|
||||
private PriceProductConfig createConfig(String productType, BigDecimal basePrice) {
|
||||
PriceProductConfig config = new PriceProductConfig();
|
||||
config.setProductType(productType);
|
||||
config.setProductId("default");
|
||||
config.setBasePrice(basePrice);
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行价格计算的辅助方法
|
||||
*/
|
||||
private PriceCalculationResult calculatePrice(ProductType productType, int quantity) {
|
||||
ProductItem item = new ProductItem();
|
||||
item.setProductType(productType);
|
||||
item.setProductId("default");
|
||||
item.setQuantity(quantity);
|
||||
item.setPurchaseCount(1);
|
||||
|
||||
PriceCalculationRequest request = new PriceCalculationRequest();
|
||||
request.setProducts(Collections.singletonList(item));
|
||||
request.setPreviewOnly(true);
|
||||
|
||||
return priceCalculationService.calculatePrice(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.ycwl.basic.pricing.dto.BundleProductItem;
|
||||
import com.ycwl.basic.pricing.entity.PriceBundleConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 一口价服务测试
|
||||
*/
|
||||
class PriceBundleServiceTest {
|
||||
|
||||
@Test
|
||||
void testBundleProductItemSerialization() {
|
||||
// 测试BundleProductItem对象的创建和序列化
|
||||
BundleProductItem item = new BundleProductItem();
|
||||
item.setType("PHOTO_PRINT");
|
||||
item.setSubType("6寸照片");
|
||||
item.setQuantity(20);
|
||||
|
||||
assertNotNull(item);
|
||||
assertEquals("PHOTO_PRINT", item.getType());
|
||||
assertEquals("6寸照片", item.getSubType());
|
||||
assertEquals(20, item.getQuantity());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPriceBundleConfigWithNewStructure() {
|
||||
// 测试PriceBundleConfig实体类的新结构
|
||||
PriceBundleConfig config = new PriceBundleConfig();
|
||||
config.setBundleName("全家福套餐");
|
||||
config.setBundlePrice(new BigDecimal("99.00"));
|
||||
|
||||
BundleProductItem includedItem = new BundleProductItem();
|
||||
includedItem.setType("PHOTO_PRINT");
|
||||
includedItem.setSubType("6寸照片");
|
||||
includedItem.setQuantity(20);
|
||||
|
||||
List<BundleProductItem> includedProducts = List.of(includedItem);
|
||||
config.setIncludedProducts(includedProducts);
|
||||
|
||||
assertNotNull(config.getIncludedProducts());
|
||||
assertEquals(1, config.getIncludedProducts().size());
|
||||
assertEquals("PHOTO_PRINT", config.getIncludedProducts().get(0).getType());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.ycwl.basic.pricing.dto.VoucherInfo;
|
||||
import com.ycwl.basic.pricing.entity.PriceVoucherBatchConfig;
|
||||
import com.ycwl.basic.pricing.entity.PriceVoucherCode;
|
||||
import com.ycwl.basic.pricing.entity.PriceVoucherUsageRecord;
|
||||
import com.ycwl.basic.pricing.enums.VoucherCodeStatus;
|
||||
import com.ycwl.basic.pricing.enums.VoucherDiscountType;
|
||||
import com.ycwl.basic.pricing.mapper.PriceVoucherBatchConfigMapper;
|
||||
import com.ycwl.basic.pricing.mapper.PriceVoucherCodeMapper;
|
||||
import com.ycwl.basic.pricing.mapper.PriceVoucherUsageRecordMapper;
|
||||
import com.ycwl.basic.pricing.service.impl.VoucherServiceImpl;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 可重复使用券码服务测试
|
||||
*/
|
||||
public class ReusableVoucherServiceTest {
|
||||
|
||||
@Mock
|
||||
private PriceVoucherCodeMapper voucherCodeMapper;
|
||||
|
||||
@Mock
|
||||
private PriceVoucherBatchConfigMapper voucherBatchConfigMapper;
|
||||
|
||||
@Mock
|
||||
private PriceVoucherUsageRecordMapper usageRecordMapper;
|
||||
|
||||
@InjectMocks
|
||||
private VoucherServiceImpl voucherService;
|
||||
|
||||
private PriceVoucherCode testVoucherCode;
|
||||
private PriceVoucherBatchConfig testBatchConfig;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
|
||||
// 创建测试数据
|
||||
testVoucherCode = new PriceVoucherCode();
|
||||
testVoucherCode.setId(1L);
|
||||
testVoucherCode.setCode("TEST123");
|
||||
testVoucherCode.setBatchId(1L);
|
||||
testVoucherCode.setScenicId(1L);
|
||||
testVoucherCode.setFaceId(1001L);
|
||||
testVoucherCode.setStatus(VoucherCodeStatus.CLAIMED_AVAILABLE.getCode());
|
||||
testVoucherCode.setCurrentUseCount(1);
|
||||
testVoucherCode.setLastUsedTime(new Date());
|
||||
testVoucherCode.setDeleted(0);
|
||||
|
||||
testBatchConfig = new PriceVoucherBatchConfig();
|
||||
testBatchConfig.setId(1L);
|
||||
testBatchConfig.setBatchName("测试批次");
|
||||
testBatchConfig.setDiscountType(VoucherDiscountType.REDUCE_PRICE.getCode());
|
||||
testBatchConfig.setDiscountValue(new BigDecimal("10.00"));
|
||||
testBatchConfig.setMaxUseCount(3); // 可使用3次
|
||||
testBatchConfig.setMaxUsePerUser(2); // 每用户最多2次
|
||||
testBatchConfig.setUseIntervalHours(24); // 间隔24小时
|
||||
testBatchConfig.setStatus(1);
|
||||
testBatchConfig.setDeleted(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateReusableVoucherCode_Success() {
|
||||
// Given
|
||||
when(voucherCodeMapper.selectByCode("TEST123")).thenReturn(testVoucherCode);
|
||||
when(voucherBatchConfigMapper.selectById(1L)).thenReturn(testBatchConfig);
|
||||
when(usageRecordMapper.countByFaceIdAndVoucherCodeId(1001L, 1L)).thenReturn(1); // 用户已使用1次
|
||||
|
||||
// 模拟距离上次使用已超过24小时
|
||||
Date lastUseTime = new Date(System.currentTimeMillis() - 25 * 60 * 60 * 1000); // 25小时前
|
||||
when(usageRecordMapper.getLastUseTimeByFaceIdAndVoucherCodeId(1001L, 1L)).thenReturn(lastUseTime);
|
||||
|
||||
// When
|
||||
VoucherInfo result = voucherService.validateAndGetVoucherInfo("TEST123", 1001L, 1L);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertEquals("TEST123", result.getVoucherCode());
|
||||
assertTrue(result.getAvailable());
|
||||
assertEquals(Integer.valueOf(1), result.getCurrentUseCount());
|
||||
assertEquals(Integer.valueOf(3), result.getMaxUseCount());
|
||||
assertEquals(Integer.valueOf(2), result.getRemainingUseCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateVoucherCode_ReachedMaxUseCount() {
|
||||
// Given - 券码已达到最大使用次数
|
||||
testVoucherCode.setCurrentUseCount(3);
|
||||
testVoucherCode.setStatus(VoucherCodeStatus.CLAIMED_EXHAUSTED.getCode());
|
||||
|
||||
when(voucherCodeMapper.selectByCode("TEST123")).thenReturn(testVoucherCode);
|
||||
when(voucherBatchConfigMapper.selectById(1L)).thenReturn(testBatchConfig);
|
||||
|
||||
// When
|
||||
VoucherInfo result = voucherService.validateAndGetVoucherInfo("TEST123", 1001L, 1L);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertFalse(result.getAvailable());
|
||||
assertEquals("券码已用完", result.getUnavailableReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateVoucherCode_UserReachedMaxUsePerUser() {
|
||||
// Given - 用户已达到个人使用上限
|
||||
when(voucherCodeMapper.selectByCode("TEST123")).thenReturn(testVoucherCode);
|
||||
when(voucherBatchConfigMapper.selectById(1L)).thenReturn(testBatchConfig);
|
||||
when(usageRecordMapper.countByFaceIdAndVoucherCodeId(1001L, 1L)).thenReturn(2); // 用户已使用2次
|
||||
|
||||
// When
|
||||
VoucherInfo result = voucherService.validateAndGetVoucherInfo("TEST123", 1001L, 1L);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertFalse(result.getAvailable());
|
||||
assertEquals("您使用该券码的次数已达上限", result.getUnavailableReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateVoucherCode_WithinInterval() {
|
||||
// Given - 距离上次使用不足24小时
|
||||
when(voucherCodeMapper.selectByCode("TEST123")).thenReturn(testVoucherCode);
|
||||
when(voucherBatchConfigMapper.selectById(1L)).thenReturn(testBatchConfig);
|
||||
when(usageRecordMapper.countByFaceIdAndVoucherCodeId(1001L, 1L)).thenReturn(1);
|
||||
|
||||
// 模拟距离上次使用仅10小时
|
||||
Date lastUseTime = new Date(System.currentTimeMillis() - 10 * 60 * 60 * 1000);
|
||||
when(usageRecordMapper.getLastUseTimeByFaceIdAndVoucherCodeId(1001L, 1L)).thenReturn(lastUseTime);
|
||||
|
||||
// When
|
||||
VoucherInfo result = voucherService.validateAndGetVoucherInfo("TEST123", 1001L, 1L);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertFalse(result.getAvailable());
|
||||
assertTrue(result.getUnavailableReason().contains("请等待"));
|
||||
assertTrue(result.getUnavailableReason().contains("小时后再次使用"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMarkVoucherAsUsed_UpdateCountAndStatus() {
|
||||
// Given
|
||||
when(voucherCodeMapper.selectByCode("TEST123")).thenReturn(testVoucherCode);
|
||||
when(voucherBatchConfigMapper.selectById(1L)).thenReturn(testBatchConfig);
|
||||
|
||||
// When
|
||||
voucherService.markVoucherAsUsed("TEST123", "测试使用", "ORDER001", new BigDecimal("10.00"), 1L);
|
||||
|
||||
// Then
|
||||
verify(usageRecordMapper, times(1)).insert(any(PriceVoucherUsageRecord.class));
|
||||
verify(voucherCodeMapper, times(1)).updateById(any(PriceVoucherCode.class));
|
||||
verify(voucherBatchConfigMapper, times(1)).updateUsedCount(eq(1L), eq(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMarkVoucherAsUsed_ReachMaxUseCount() {
|
||||
// Given - 使用后将达到最大使用次数
|
||||
testVoucherCode.setCurrentUseCount(2); // 当前已使用2次,再使用1次将达到上限3次
|
||||
|
||||
when(voucherCodeMapper.selectByCode("TEST123")).thenReturn(testVoucherCode);
|
||||
when(voucherBatchConfigMapper.selectById(1L)).thenReturn(testBatchConfig);
|
||||
|
||||
// When
|
||||
voucherService.markVoucherAsUsed("TEST123", "测试使用", "ORDER001", new BigDecimal("10.00"), 1L);
|
||||
|
||||
// Then
|
||||
verify(usageRecordMapper, times(1)).insert(any(PriceVoucherUsageRecord.class));
|
||||
verify(voucherCodeMapper, times(1)).updateById(argThat(voucherCode ->
|
||||
voucherCode.getCurrentUseCount() == 3 &&
|
||||
voucherCode.getStatus().equals(VoucherCodeStatus.CLAIMED_EXHAUSTED.getCode())
|
||||
));
|
||||
verify(voucherBatchConfigMapper, times(1)).updateUsedCount(eq(1L), eq(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMarkVoucherAsUsed_SingleUseCompatibility() {
|
||||
// Given - 测试单次使用兼容性(maxUseCount = 1)
|
||||
testBatchConfig.setMaxUseCount(1);
|
||||
testVoucherCode.setCurrentUseCount(0);
|
||||
|
||||
when(voucherCodeMapper.selectByCode("TEST123")).thenReturn(testVoucherCode);
|
||||
when(voucherBatchConfigMapper.selectById(1L)).thenReturn(testBatchConfig);
|
||||
|
||||
// When
|
||||
voucherService.markVoucherAsUsed("TEST123", "测试使用", "ORDER001", new BigDecimal("10.00"), 1L);
|
||||
|
||||
// Then - 应该设置为USED状态以兼容原有逻辑
|
||||
verify(voucherCodeMapper, times(1)).updateById(argThat(voucherCode ->
|
||||
voucherCode.getCurrentUseCount() == 1 &&
|
||||
voucherCode.getStatus().equals(VoucherCodeStatus.USED.getCode()) &&
|
||||
voucherCode.getUsedTime() != null
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.ycwl.basic.pricing.service.impl.VoucherPrintServiceImpl;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 优惠券打印服务流水号生成测试
|
||||
* 专门测试generateCode方法的重复率和性能
|
||||
*/
|
||||
@Slf4j
|
||||
@SpringBootTest
|
||||
public class VoucherPrintServiceCodeGenerationTest {
|
||||
|
||||
private static final String CODE_PREFIX = "VT";
|
||||
|
||||
/**
|
||||
* 模拟当前的generateCode方法实现
|
||||
*/
|
||||
private String generateCode() {
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("ss");
|
||||
String timestamp = sdf.format(new Date());
|
||||
String randomSuffix = String.valueOf((int)(Math.random() * 100000)).formatted("%05d");
|
||||
return CODE_PREFIX + timestamp + randomSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单线程环境下1秒内生成10个流水号的重复率
|
||||
*/
|
||||
@Test
|
||||
public void testGenerateCodeDuplicationRateInOneSecond() {
|
||||
log.info("=== 开始测试1秒内生成10个流水号的重复率 ===");
|
||||
|
||||
int totalRounds = 1000; // 测试1000轮
|
||||
int codesPerRound = 10; // 每轮生成10个流水号
|
||||
int totalDuplicates = 0;
|
||||
int totalCodes = 0;
|
||||
|
||||
for (int round = 0; round < totalRounds; round++) {
|
||||
Set<String> codes = new HashSet<>();
|
||||
List<String> codeList = new ArrayList<>();
|
||||
|
||||
// 在很短时间内生成10个流水号
|
||||
for (int i = 0; i < codesPerRound; i++) {
|
||||
String code = generateCode();
|
||||
codes.add(code);
|
||||
codeList.add(code);
|
||||
}
|
||||
|
||||
int duplicates = codeList.size() - codes.size();
|
||||
if (duplicates > 0) {
|
||||
totalDuplicates += duplicates;
|
||||
log.warn("第{}轮发现{}个重复: {}", round + 1, duplicates, codeList);
|
||||
}
|
||||
|
||||
totalCodes += codesPerRound;
|
||||
|
||||
// 稍微休息一下,避免在完全同一时间生成
|
||||
try {
|
||||
Thread.sleep(10);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
double duplicationRate = (double) totalDuplicates / totalCodes * 100;
|
||||
log.info("=== 单线程测试结果 ===");
|
||||
log.info("总轮数: {}", totalRounds);
|
||||
log.info("每轮生成数: {}", codesPerRound);
|
||||
log.info("总生成数: {}", totalCodes);
|
||||
log.info("总重复数: {}", totalDuplicates);
|
||||
log.info("重复率: {:.4f}%", duplicationRate);
|
||||
|
||||
// 记录一些示例生成的流水号
|
||||
log.info("=== 示例流水号 ===");
|
||||
for (int i = 0; i < 20; i++) {
|
||||
log.info("示例{}: {}", i + 1, generateCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试严格在1秒内生成流水号的重复率
|
||||
*/
|
||||
@Test
|
||||
public void testStrictOneSecondGeneration() {
|
||||
log.info("=== 开始测试严格1秒内生成流水号重复率 ===");
|
||||
|
||||
int rounds = 100;
|
||||
int totalDuplicates = 0;
|
||||
int totalCodes = 0;
|
||||
|
||||
for (int round = 0; round < rounds; round++) {
|
||||
Set<String> codes = new HashSet<>();
|
||||
List<String> codeList = new ArrayList<>();
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 在1秒内尽可能多地生成流水号
|
||||
while (System.currentTimeMillis() - startTime < 1000) {
|
||||
String code = generateCode();
|
||||
codes.add(code);
|
||||
codeList.add(code);
|
||||
}
|
||||
|
||||
int duplicates = codeList.size() - codes.size();
|
||||
totalDuplicates += duplicates;
|
||||
totalCodes += codeList.size();
|
||||
|
||||
if (duplicates > 0) {
|
||||
log.warn("第{}轮: 生成{}个,重复{}个,重复率{:.2f}%",
|
||||
round + 1, codeList.size(), duplicates,
|
||||
(double) duplicates / codeList.size() * 100);
|
||||
}
|
||||
|
||||
// 等待下一秒开始
|
||||
try {
|
||||
Thread.sleep(1100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
double overallDuplicationRate = (double) totalDuplicates / totalCodes * 100;
|
||||
log.info("=== 严格1秒测试结果 ===");
|
||||
log.info("测试轮数: {}", rounds);
|
||||
log.info("总生成数: {}", totalCodes);
|
||||
log.info("总重复数: {}", totalDuplicates);
|
||||
log.info("总体重复率: {:.4f}%", overallDuplicationRate);
|
||||
log.info("平均每轮生成: {:.1f}个", (double) totalCodes / rounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析流水号的分布特征
|
||||
*/
|
||||
@Test
|
||||
public void testCodeDistributionAnalysis() {
|
||||
log.info("=== 开始分析流水号分布特征 ===");
|
||||
|
||||
int sampleSize = 10000;
|
||||
List<String> codes = new ArrayList<>();
|
||||
Map<String, Integer> prefixCount = new HashMap<>(); // 时间前缀统计
|
||||
Map<String, Integer> suffixCount = new HashMap<>(); // 随机后缀统计
|
||||
|
||||
// 生成样本
|
||||
for (int i = 0; i < sampleSize; i++) {
|
||||
String code = generateCode();
|
||||
codes.add(code);
|
||||
|
||||
// 提取时间前缀 (VTxx)
|
||||
String prefix = code.substring(0, 4);
|
||||
prefixCount.put(prefix, prefixCount.getOrDefault(prefix, 0) + 1);
|
||||
|
||||
// 提取随机后缀 (最后5位)
|
||||
String suffix = code.substring(4);
|
||||
suffixCount.put(suffix, suffixCount.getOrDefault(suffix, 0) + 1);
|
||||
|
||||
// 稍微间隔一下,避免全在同一秒
|
||||
if (i % 100 == 0) {
|
||||
try {
|
||||
Thread.sleep(1);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算去重率
|
||||
Set<String> uniqueCodes = new HashSet<>(codes);
|
||||
double uniqueRate = (double) uniqueCodes.size() / sampleSize * 100;
|
||||
|
||||
// 分析时间前缀分布
|
||||
log.info("=== 时间前缀分布 (前10个) ===");
|
||||
prefixCount.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
|
||||
.limit(10)
|
||||
.forEach(entry -> log.info("前缀 {}: {}次 ({:.2f}%)",
|
||||
entry.getKey(), entry.getValue(),
|
||||
(double) entry.getValue() / sampleSize * 100));
|
||||
|
||||
// 检查随机后缀的重复情况
|
||||
long duplicatedSuffixes = suffixCount.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() > 1)
|
||||
.count();
|
||||
|
||||
log.info("=== 分布分析结果 ===");
|
||||
log.info("样本总数: {}", sampleSize);
|
||||
log.info("唯一流水号数: {}", uniqueCodes.size());
|
||||
log.info("去重率: {:.4f}%", uniqueRate);
|
||||
log.info("时间前缀种类: {}", prefixCount.size());
|
||||
log.info("随机后缀种类: {}", suffixCount.size());
|
||||
log.info("重复的随机后缀数: {}", duplicatedSuffixes);
|
||||
log.info("随机后缀重复率: {:.4f}%", (double) duplicatedSuffixes / suffixCount.size() * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟真实业务场景:快速连续请求
|
||||
*/
|
||||
@Test
|
||||
public void testRealBusinessScenario() {
|
||||
log.info("=== 开始模拟真实业务场景测试 ===");
|
||||
|
||||
// 模拟场景:10个用户几乎同时请求打印小票
|
||||
int simultaneousUsers = 10;
|
||||
int testsPerUser = 5; // 每个用户发送5次请求
|
||||
int totalTests = 50; // 总共进行50次这样的场景测试
|
||||
|
||||
int totalDuplicates = 0;
|
||||
int totalCodes = 0;
|
||||
|
||||
for (int test = 0; test < totalTests; test++) {
|
||||
Set<String> allCodes = new HashSet<>();
|
||||
List<String> allCodesList = new ArrayList<>();
|
||||
|
||||
// 模拟同一时刻多个用户的请求
|
||||
for (int user = 0; user < simultaneousUsers; user++) {
|
||||
for (int request = 0; request < testsPerUser; request++) {
|
||||
String code = generateCode();
|
||||
allCodes.add(code);
|
||||
allCodesList.add(code);
|
||||
}
|
||||
}
|
||||
|
||||
int duplicates = allCodesList.size() - allCodes.size();
|
||||
if (duplicates > 0) {
|
||||
totalDuplicates += duplicates;
|
||||
log.warn("第{}次场景测试发现{}个重复流水号", test + 1, duplicates);
|
||||
|
||||
// 找出重复的流水号
|
||||
Map<String, Integer> codeCount = new HashMap<>();
|
||||
for (String code : allCodesList) {
|
||||
codeCount.put(code, codeCount.getOrDefault(code, 0) + 1);
|
||||
}
|
||||
|
||||
codeCount.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() > 1)
|
||||
.forEach(entry -> log.warn("重复流水号: {} (出现{}次)",
|
||||
entry.getKey(), entry.getValue()));
|
||||
}
|
||||
|
||||
totalCodes += allCodesList.size();
|
||||
|
||||
// 模拟请求间隔
|
||||
try {
|
||||
Thread.sleep(50);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
double duplicationRate = (double) totalDuplicates / totalCodes * 100;
|
||||
log.info("=== 真实业务场景测试结果 ===");
|
||||
log.info("场景测试次数: {}", totalTests);
|
||||
log.info("每次场景用户数: {}", simultaneousUsers);
|
||||
log.info("每用户请求数: {}", testsPerUser);
|
||||
log.info("总生成流水号数: {}", totalCodes);
|
||||
log.info("总重复数: {}", totalDuplicates);
|
||||
log.info("重复率: {:.4f}%", duplicationRate);
|
||||
|
||||
if (duplicationRate > 0.1) {
|
||||
log.warn("警告:重复率超过0.1%,建议优化generateCode方法!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 高并发多线程测试
|
||||
*/
|
||||
@Test
|
||||
public void testHighConcurrencyGeneration() throws InterruptedException {
|
||||
log.info("=== 开始高并发多线程测试 ===");
|
||||
|
||||
int threadCount = 20; // 20个并发线程
|
||||
int codesPerThread = 50; // 每个线程生成50个流水号
|
||||
int totalExpectedCodes = threadCount * codesPerThread;
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch latch = new CountDownLatch(threadCount);
|
||||
ConcurrentHashMap<String, Integer> allCodes = new ConcurrentHashMap<>();
|
||||
List<String> allCodesList = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
// 启动所有线程
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
final int threadId = i;
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
List<String> threadCodes = new ArrayList<>();
|
||||
|
||||
// 每个线程快速生成流水号
|
||||
for (int j = 0; j < codesPerThread; j++) {
|
||||
String code = generateCode();
|
||||
threadCodes.add(code);
|
||||
allCodesList.add(code);
|
||||
|
||||
// 统计重复
|
||||
Integer count = allCodes.put(code, 1);
|
||||
if (count != null) {
|
||||
allCodes.put(code, count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("线程{}完成,生成{}个流水号", threadId, threadCodes.size());
|
||||
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 等待所有线程完成
|
||||
boolean finished = latch.await(30, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
|
||||
if (!finished) {
|
||||
log.error("测试超时!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 分析结果
|
||||
Set<String> uniqueCodes = new HashSet<>(allCodesList);
|
||||
int duplicates = totalExpectedCodes - uniqueCodes.size();
|
||||
double duplicationRate = (double) duplicates / totalExpectedCodes * 100;
|
||||
|
||||
// 找出重复的流水号
|
||||
List<Map.Entry<String, Integer>> duplicatedCodes = allCodes.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() > 1)
|
||||
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
|
||||
.limit(20) // 只显示前20个最多重复的
|
||||
.toList();
|
||||
|
||||
log.info("=== 高并发测试结果 ===");
|
||||
log.info("并发线程数: {}", threadCount);
|
||||
log.info("每线程生成数: {}", codesPerThread);
|
||||
log.info("预期总数: {}", totalExpectedCodes);
|
||||
log.info("实际总数: {}", allCodesList.size());
|
||||
log.info("唯一流水号数: {}", uniqueCodes.size());
|
||||
log.info("重复数: {}", duplicates);
|
||||
log.info("重复率: {:.4f}%", duplicationRate);
|
||||
|
||||
if (!duplicatedCodes.isEmpty()) {
|
||||
log.warn("=== 发现重复流水号 ===");
|
||||
duplicatedCodes.forEach(entry ->
|
||||
log.warn("流水号: {} 重复了 {} 次", entry.getKey(), entry.getValue()));
|
||||
}
|
||||
|
||||
if (duplicationRate > 1.0) {
|
||||
log.error("严重警告:高并发下重复率超过1.0%,必须优化generateCode方法!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟极端高压场景:短时间内大量请求
|
||||
*/
|
||||
@Test
|
||||
public void testExtremeHighPressure() throws InterruptedException {
|
||||
log.info("=== 开始极端高压测试 ===");
|
||||
|
||||
int threadCount = 50; // 50个并发线程
|
||||
int codesPerThread = 20; // 每个线程生成20个
|
||||
long timeWindowMs = 1000; // 在1秒内完成
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch endLatch = new CountDownLatch(threadCount);
|
||||
|
||||
ConcurrentHashMap<String, List<Integer>> codeToThreads = new ConcurrentHashMap<>();
|
||||
List<String> allCodes = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
// 准备所有线程
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
final int threadId = i;
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
// 等待开始信号
|
||||
startLatch.await();
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
List<String> threadCodes = new ArrayList<>();
|
||||
|
||||
// 在时间窗口内尽可能快地生成
|
||||
while (System.currentTimeMillis() - startTime < timeWindowMs &&
|
||||
threadCodes.size() < codesPerThread) {
|
||||
String code = generateCode();
|
||||
threadCodes.add(code);
|
||||
allCodes.add(code);
|
||||
|
||||
// 记录哪个线程生成了这个流水号
|
||||
codeToThreads.computeIfAbsent(code, k ->
|
||||
Collections.synchronizedList(new ArrayList<>())).add(threadId);
|
||||
}
|
||||
|
||||
log.debug("线程{}在{}ms内生成{}个流水号",
|
||||
threadId, System.currentTimeMillis() - startTime, threadCodes.size());
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
endLatch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 开始测试
|
||||
long testStartTime = System.currentTimeMillis();
|
||||
startLatch.countDown();
|
||||
|
||||
// 等待完成
|
||||
boolean finished = endLatch.await(timeWindowMs + 5000, TimeUnit.MILLISECONDS);
|
||||
executor.shutdown();
|
||||
long testDuration = System.currentTimeMillis() - testStartTime;
|
||||
|
||||
if (!finished) {
|
||||
log.error("极端高压测试超时!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 分析结果
|
||||
Set<String> uniqueCodes = new HashSet<>(allCodes);
|
||||
int totalGenerated = allCodes.size();
|
||||
int duplicates = totalGenerated - uniqueCodes.size();
|
||||
double duplicationRate = (double) duplicates / totalGenerated * 100;
|
||||
double generationRate = (double) totalGenerated / testDuration * 1000; // 每秒生成数
|
||||
|
||||
// 分析重复模式
|
||||
Map<String, List<Integer>> duplicatedCodes = codeToThreads.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().size() > 1)
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
Map.Entry::getValue
|
||||
));
|
||||
|
||||
log.info("=== 极端高压测试结果 ===");
|
||||
log.info("并发线程数: {}", threadCount);
|
||||
log.info("预期每线程生成数: {}", codesPerThread);
|
||||
log.info("测试持续时间: {}ms", testDuration);
|
||||
log.info("实际总生成数: {}", totalGenerated);
|
||||
log.info("唯一流水号数: {}", uniqueCodes.size());
|
||||
log.info("重复数: {}", duplicates);
|
||||
log.info("重复率: {:.4f}%", duplicationRate);
|
||||
log.info("生成速率: {:.1f} codes/sec", generationRate);
|
||||
|
||||
if (!duplicatedCodes.isEmpty()) {
|
||||
log.warn("=== 极端高压下的重复情况 ===");
|
||||
duplicatedCodes.entrySet().stream()
|
||||
.limit(10) // 只显示前10个
|
||||
.forEach(entry -> {
|
||||
String code = entry.getKey();
|
||||
List<Integer> threads = entry.getValue();
|
||||
log.warn("流水号: {} 被线程 {} 重复生成", code, threads);
|
||||
});
|
||||
}
|
||||
|
||||
// 评估结果
|
||||
if (duplicationRate > 5.0) {
|
||||
log.error("极严重警告:极端高压下重复率超过5.0%,generateCode方法不适合高并发场景!");
|
||||
} else if (duplicationRate > 1.0) {
|
||||
log.warn("警告:极端高压下重复率超过1.0%,建议优化generateCode方法");
|
||||
}
|
||||
|
||||
if (generationRate > 10000) {
|
||||
log.info("性能良好:生成速率超过10,000 codes/sec");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合测试报告
|
||||
*/
|
||||
@Test
|
||||
public void generateComprehensiveReport() {
|
||||
log.info("=== 生成综合测试报告 ===");
|
||||
|
||||
// 基础性能测试
|
||||
long startTime = System.nanoTime();
|
||||
List<String> sample = new ArrayList<>();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
sample.add(generateCode());
|
||||
}
|
||||
long duration = System.nanoTime() - startTime;
|
||||
double avgTimePerCode = duration / 1000.0 / 1_000_000; // 毫秒
|
||||
|
||||
// 唯一性分析
|
||||
Set<String> uniqueSample = new HashSet<>(sample);
|
||||
double sampleDuplicationRate = (double) (sample.size() - uniqueSample.size()) / sample.size() * 100;
|
||||
|
||||
// 长度和格式分析
|
||||
String sampleCode = generateCode();
|
||||
int codeLength = sampleCode.length();
|
||||
boolean hasCorrectPrefix = sampleCode.startsWith(CODE_PREFIX);
|
||||
|
||||
// 理论分析
|
||||
double theoreticalCollisionProbability = calculateBirthdayParadoxProbability(10, 100000);
|
||||
|
||||
log.info("=== generateCode方法综合评估报告 ===");
|
||||
log.info("基础信息:");
|
||||
log.info(" - 代码前缀: {}", CODE_PREFIX);
|
||||
log.info(" - 流水号长度: {}", codeLength);
|
||||
log.info(" - 格式正确: {}", hasCorrectPrefix);
|
||||
log.info(" - 示例流水号: {}", sampleCode);
|
||||
|
||||
log.info("性能指标:");
|
||||
log.info(" - 平均生成时间: {:.3f}ms", avgTimePerCode);
|
||||
log.info(" - 理论最大生成速率: {:.0f} codes/sec", 1000.0 / avgTimePerCode);
|
||||
|
||||
log.info("唯一性分析:");
|
||||
log.info(" - 样本重复率: {:.4f}% (1000个样本)", sampleDuplicationRate);
|
||||
log.info(" - 理论冲突概率: {:.4f}% (1秒内10个)", theoreticalCollisionProbability * 100);
|
||||
log.info(" - 随机数范围: 100,000 (00000-99999)");
|
||||
|
||||
log.info("风险评估:");
|
||||
if (sampleDuplicationRate > 0.5) {
|
||||
log.error(" - 高风险:样本重复率过高,不适合生产环境");
|
||||
} else if (sampleDuplicationRate > 0.1) {
|
||||
log.warn(" - 中风险:存在一定重复概率,建议优化");
|
||||
} else {
|
||||
log.info(" - 低风险:重复概率较低,基本可用");
|
||||
}
|
||||
|
||||
log.info("优化建议:");
|
||||
log.info(" - 建议1:使用毫秒级时间戳替代秒级");
|
||||
log.info(" - 建议2:增加机器标识或进程ID");
|
||||
log.info(" - 建议3:使用原子递增计数器");
|
||||
log.info(" - 建议4:采用UUID算法确保全局唯一性");
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算生日悖论概率
|
||||
*/
|
||||
private double calculateBirthdayParadoxProbability(int n, int d) {
|
||||
if (n > d) return 1.0;
|
||||
|
||||
double probability = 1.0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
probability *= (double) (d - i) / d;
|
||||
}
|
||||
return 1.0 - probability;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.ycwl.basic.pricing.service;
|
||||
|
||||
import com.ycwl.basic.pricing.dto.VoucherInfo;
|
||||
import com.ycwl.basic.pricing.dto.req.VoucherBatchCreateReqV2;
|
||||
import com.ycwl.basic.pricing.entity.PriceVoucherBatchConfig;
|
||||
import com.ycwl.basic.pricing.enums.ProductType;
|
||||
import com.ycwl.basic.pricing.enums.VoucherDiscountType;
|
||||
import com.ycwl.basic.pricing.service.impl.VoucherServiceImpl;
|
||||
import com.ycwl.basic.pricing.service.impl.VoucherBatchServiceImpl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 券码时间范围功能单元测试
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
public class VoucherTimeRangeTest {
|
||||
|
||||
private VoucherServiceImpl voucherService;
|
||||
private VoucherBatchServiceImpl voucherBatchService;
|
||||
|
||||
private Long testScenicId = 1001L;
|
||||
private Long testBrokerId = 2001L;
|
||||
private Long testFaceId = 3001L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 这里应该注入真实的服务实例,或者使用Mock
|
||||
// 为了演示,这里只是创建测试结构
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试券码批次时间范围验证 - 正常时间范围")
|
||||
void testValidTimeRange() {
|
||||
// 创建时间范围:当前时间前1小时到后1小时
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, -1);
|
||||
Date validStartTime = cal.getTime();
|
||||
|
||||
cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, 1);
|
||||
Date validEndTime = cal.getTime();
|
||||
|
||||
PriceVoucherBatchConfig batchConfig = createTestBatchConfig(validStartTime, validEndTime);
|
||||
|
||||
// 测试当前时间在有效期内
|
||||
assertTrue(batchConfig.isWithinValidTimeRange(), "当前时间应该在有效期内");
|
||||
|
||||
// 测试指定时间在有效期内
|
||||
assertTrue(batchConfig.isWithinValidTimeRange(new Date()), "指定时间应该在有效期内");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试券码批次时间范围验证 - 尚未生效")
|
||||
void testTimeRangeNotYetValid() {
|
||||
// 创建时间范围:未来1小时到未来2小时
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, 1);
|
||||
Date validStartTime = cal.getTime();
|
||||
|
||||
cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, 2);
|
||||
Date validEndTime = cal.getTime();
|
||||
|
||||
PriceVoucherBatchConfig batchConfig = createTestBatchConfig(validStartTime, validEndTime);
|
||||
|
||||
// 测试当前时间不在有效期内(尚未生效)
|
||||
assertFalse(batchConfig.isWithinValidTimeRange(), "当前时间不应该在有效期内(尚未生效)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试券码批次时间范围验证 - 已过期")
|
||||
void testTimeRangeExpired() {
|
||||
// 创建时间范围:过去2小时到过去1小时
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, -2);
|
||||
Date validStartTime = cal.getTime();
|
||||
|
||||
cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, -1);
|
||||
Date validEndTime = cal.getTime();
|
||||
|
||||
PriceVoucherBatchConfig batchConfig = createTestBatchConfig(validStartTime, validEndTime);
|
||||
|
||||
// 测试当前时间不在有效期内(已过期)
|
||||
assertFalse(batchConfig.isWithinValidTimeRange(), "当前时间不应该在有效期内(已过期)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试券码批次时间范围验证 - 无时间限制")
|
||||
void testNoTimeRestriction() {
|
||||
// 创建无时间限制的批次配置
|
||||
PriceVoucherBatchConfig batchConfig = createTestBatchConfig(null, null);
|
||||
|
||||
// 测试无时间限制时应该总是有效
|
||||
assertTrue(batchConfig.isWithinValidTimeRange(), "无时间限制的券码应该总是有效");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试券码批次时间范围验证 - 只有开始时间")
|
||||
void testOnlyStartTime() {
|
||||
// 创建只有开始时间的批次配置(过去1小时开始,无结束时间)
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, -1);
|
||||
Date validStartTime = cal.getTime();
|
||||
|
||||
PriceVoucherBatchConfig batchConfig = createTestBatchConfig(validStartTime, null);
|
||||
|
||||
// 测试应该有效(已过开始时间且无结束时间限制)
|
||||
assertTrue(batchConfig.isWithinValidTimeRange(), "只有开始时间的券码在开始时间后应该有效");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试券码批次时间范围验证 - 只有结束时间")
|
||||
void testOnlyEndTime() {
|
||||
// 创建只有结束时间的批次配置(无开始时间,1小时后结束)
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, 1);
|
||||
Date validEndTime = cal.getTime();
|
||||
|
||||
PriceVoucherBatchConfig batchConfig = createTestBatchConfig(null, validEndTime);
|
||||
|
||||
// 测试应该有效(无开始时间限制且未到结束时间)
|
||||
assertTrue(batchConfig.isWithinValidTimeRange(), "只有结束时间的券码在结束时间前应该有效");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试VoucherInfo时间范围方法")
|
||||
void testVoucherInfoTimeRangeMethods() {
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, -1);
|
||||
Date validStartTime = cal.getTime();
|
||||
|
||||
cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, 1);
|
||||
Date validEndTime = cal.getTime();
|
||||
|
||||
VoucherInfo voucherInfo = new VoucherInfo();
|
||||
voucherInfo.setValidStartTime(validStartTime);
|
||||
voucherInfo.setValidEndTime(validEndTime);
|
||||
|
||||
// 测试当前时间在有效期内
|
||||
assertTrue(voucherInfo.isWithinValidTimeRange(), "VoucherInfo当前时间应该在有效期内");
|
||||
|
||||
// 测试指定时间在有效期内
|
||||
assertTrue(voucherInfo.isWithinValidTimeRange(new Date()), "VoucherInfo指定时间应该在有效期内");
|
||||
|
||||
// 测试过期时间
|
||||
cal = Calendar.getInstance();
|
||||
cal.add(Calendar.HOUR_OF_DAY, 2);
|
||||
Date futureTime = cal.getTime();
|
||||
assertFalse(voucherInfo.isWithinValidTimeRange(futureTime), "VoucherInfo未来时间不应该在有效期内");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试批次创建请求时间范围验证")
|
||||
void testBatchCreateRequestTimeValidation() {
|
||||
VoucherBatchCreateReqV2 request = new VoucherBatchCreateReqV2();
|
||||
request.setBatchName("时间范围测试批次");
|
||||
request.setScenicId(testScenicId);
|
||||
request.setBrokerId(testBrokerId);
|
||||
request.setDiscountType(VoucherDiscountType.DISCOUNT.getCode());
|
||||
request.setDiscountValue(new BigDecimal("10.00"));
|
||||
request.setTotalCount(100);
|
||||
request.setApplicableProducts(Arrays.asList(ProductType.VLOG_VIDEO, ProductType.PHOTO_SET));
|
||||
|
||||
// 测试正常时间范围
|
||||
Calendar cal = Calendar.getInstance();
|
||||
Date validStartTime = cal.getTime();
|
||||
cal.add(Calendar.HOUR_OF_DAY, 24);
|
||||
Date validEndTime = cal.getTime();
|
||||
|
||||
request.setValidStartTime(validStartTime);
|
||||
request.setValidEndTime(validEndTime);
|
||||
|
||||
// 这里应该调用实际的批次创建服务进行验证
|
||||
// 正常情况下不应该抛出异常
|
||||
assertDoesNotThrow(() -> {
|
||||
validateTimeRange(request.getValidStartTime(), request.getValidEndTime());
|
||||
}, "正常时间范围不应该抛出异常");
|
||||
|
||||
// 测试无效时间范围(开始时间晚于结束时间)
|
||||
request.setValidStartTime(validEndTime); // 交换开始和结束时间
|
||||
request.setValidEndTime(validStartTime);
|
||||
|
||||
// 应该抛出验证异常
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
validateTimeRange(request.getValidStartTime(), request.getValidEndTime());
|
||||
}, "无效时间范围应该抛出异常");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试边界条件 - 开始时间等于结束时间")
|
||||
void testBoundaryCondition_StartEqualsEnd() {
|
||||
Date sameTime = new Date();
|
||||
|
||||
PriceVoucherBatchConfig batchConfig = createTestBatchConfig(sameTime, sameTime);
|
||||
|
||||
// 测试开始时间等于结束时间时的行为
|
||||
assertTrue(batchConfig.isWithinValidTimeRange(sameTime),
|
||||
"开始时间等于结束时间时,该时间点应该被认为是有效的");
|
||||
}
|
||||
|
||||
// 辅助方法:创建测试用的批次配置
|
||||
private PriceVoucherBatchConfig createTestBatchConfig(Date validStartTime, Date validEndTime) {
|
||||
PriceVoucherBatchConfig config = new PriceVoucherBatchConfig();
|
||||
config.setId(1L);
|
||||
config.setBatchName("测试批次");
|
||||
config.setScenicId(testScenicId);
|
||||
config.setBrokerId(testBrokerId);
|
||||
config.setDiscountType(VoucherDiscountType.DISCOUNT.getCode());
|
||||
config.setDiscountValue(new BigDecimal("10.00"));
|
||||
config.setTotalCount(100);
|
||||
config.setUsedCount(0);
|
||||
config.setClaimedCount(0);
|
||||
config.setStatus(1);
|
||||
config.setValidStartTime(validStartTime);
|
||||
config.setValidEndTime(validEndTime);
|
||||
config.setDeleted(0);
|
||||
config.setCreateTime(new Date());
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// 辅助方法:验证时间范围
|
||||
private void validateTimeRange(Date validStartTime, Date validEndTime) {
|
||||
if (validStartTime != null && validEndTime != null) {
|
||||
if (validStartTime.after(validEndTime)) {
|
||||
throw new IllegalArgumentException("有效期开始时间不能晚于结束时间");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user