Merge branch 'order_update'

# Conflicts:
#	src/main/java/com/ycwl/basic/pricing/CLAUDE.md
This commit is contained in:
2026-01-19 19:54:59 +08:00
21 changed files with 2847 additions and 12 deletions

View File

@@ -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()` 逻辑。
## 版本更新记录

View File

@@ -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);
}
/**
* 查询用户可用优惠券
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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));
}
/**
* 计算优惠(券码 + 优惠券)
*/

View File

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

View File

@@ -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处理测试通过");
}
}

View File

@@ -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("布尔值判断逻辑测试通过");
}
}

View File

@@ -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("空列表测试通过");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("有效期开始时间不能晚于结束时间");
}
}
}
}