fix(pricing): 修复优惠券计算逻辑,防止优惠金额溢出

- 重构优惠券折扣计算方法,确保固定金额优惠券不超过适用商品总价
- 修改百分比优惠券计算逻辑,基于适用商品总价而非购物车总价
- 新增适用商品总价计算方法,支持按商品类型过滤
- 添加防止优惠金额超出适用商品总价的保护逻辑
- 完善无商品类型限制时的全商品适用逻辑
- 增加多种边界情况和多SKU场景的单元测试
- 修复百分比优惠券最大折扣限制的计算顺序问题
This commit is contained in:
2025-11-17 17:38:46 +08:00
parent e2b450682b
commit ebf05ab189
2 changed files with 369 additions and 4 deletions

View File

@@ -70,17 +70,27 @@ public class CouponServiceImpl implements ICouponService {
if (!isCouponApplicable(coupon, products, totalAmount)) {
return BigDecimal.ZERO;
}
// 计算适用商品的总价
BigDecimal applicableProductsTotal = calculateApplicableProductsTotal(coupon, products);
BigDecimal discount;
if (coupon.getCouponType() == CouponType.PERCENTAGE) {
discount = totalAmount.multiply(coupon.getDiscountValue().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
// 百分比优惠券基于适用商品总价计算,而非购物车总价
discount = applicableProductsTotal.multiply(coupon.getDiscountValue().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
if (coupon.getMaxDiscount() != null && discount.compareTo(coupon.getMaxDiscount()) > 0) {
discount = coupon.getMaxDiscount();
}
} else {
// 固定金额优惠券
discount = coupon.getDiscountValue();
}
// 限制优惠金额不超过适用商品总价,防止优惠溢出到其他SKU
if (discount.compareTo(applicableProductsTotal) > 0) {
discount = applicableProductsTotal;
}
return discount.setScale(2, RoundingMode.HALF_UP);
}
@@ -128,7 +138,37 @@ public class CouponServiceImpl implements ICouponService {
return false;
}
}
/**
* 计算适用商品的总价
* @param coupon 优惠券配置
* @param products 商品列表
* @return 适用商品的总价
*/
private BigDecimal calculateApplicableProductsTotal(PriceCouponConfig coupon, List<ProductItem> products) {
// 如果优惠券没有商品类型限制,返回所有商品总价
if (coupon.getApplicableProducts() == null || coupon.getApplicableProducts().isEmpty()) {
return products.stream()
.map(ProductItem::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
try {
// 解析适用商品类型列表
List<String> applicableProductTypes = objectMapper.readValue(
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
// 计算适用商品的总价
return products.stream()
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
.map(ProductItem::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
} catch (Exception e) {
log.error("解析适用商品类型失败", e);
return BigDecimal.ZERO;
}
}
@Override
@Transactional
public CouponUseResult useCoupon(CouponUseRequest request) {

View File

@@ -0,0 +1,325 @@
package com.ycwl.basic.pricing.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ycwl.basic.pricing.dto.ProductItem;
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
import com.ycwl.basic.pricing.enums.CouponType;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.pricing.service.impl.CouponServiceImpl;
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.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* 优惠券溢出问题测试
* 测试多SKU场景下优惠券优惠金额不会溢出到不适用的商品
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("优惠券溢出问题测试")
class CouponOverflowTest {
@InjectMocks
private CouponServiceImpl couponService;
@Mock
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
// 使用真实的ObjectMapper,避免mock导致的问题
couponService = new CouponServiceImpl(null, null, new ObjectMapper());
}
@Test
@DisplayName("固定金额优惠券 - 多SKU场景优惠金额超过适用商品总价应被截断")
void testFixedAmountCoupon_MultiSKU_DiscountExceedsApplicableTotal() {
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(3元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("3.00"))
);
BigDecimal totalAmount = new BigDecimal("13.00");
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU
PriceCouponConfig coupon = createCoupon(
CouponType.FIXED_AMOUNT,
new BigDecimal("5.00"),
BigDecimal.ZERO,
"[\"PHOTO_PRINT_MU\"]"
);
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 优惠金额应为3元(适用商品总价),而非5元
assertTrue(discount.compareTo(new BigDecimal("3.00")) == 0, "优惠金额应被截断为适用商品总价");
}
@Test
@DisplayName("固定金额优惠券 - 优惠金额小于适用商品总价应正常优惠")
void testFixedAmountCoupon_MultiSKU_DiscountWithinApplicableTotal() {
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(8元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("8.00"))
);
BigDecimal totalAmount = new BigDecimal("18.00");
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU
PriceCouponConfig coupon = createCoupon(
CouponType.FIXED_AMOUNT,
new BigDecimal("5.00"),
BigDecimal.ZERO,
"[\"PHOTO_PRINT_MU\"]"
);
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 优惠金额应为5元(配置值)
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "优惠金额未超过适用商品总价,应为配置值");
}
@Test
@DisplayName("百分比优惠券 - 应基于适用商品总价计算而非购物车总价")
void testPercentageCoupon_MultiSKU_CalculateOnApplicableTotal() {
// 准备商品列表: PHOTO_PRINT(100元) + PHOTO_PRINT_MU(50元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("100.00")),
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("50.00"))
);
BigDecimal totalAmount = new BigDecimal("150.00");
// 优惠券配置: 9折,仅适用于PHOTO_PRINT_MU
PriceCouponConfig coupon = createCoupon(
CouponType.PERCENTAGE,
new BigDecimal("10.00"), // 10% off = 9折
BigDecimal.ZERO,
"[\"PHOTO_PRINT_MU\"]"
);
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 优惠金额应为5元(50 * 10% = 5),而非15元(150 * 10% = 15)
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "百分比优惠应基于适用商品总价计算");
}
@Test
@DisplayName("百分比优惠券 - 计算结果超过适用商品总价应被截断")
void testPercentageCoupon_MultiSKU_DiscountExceedsApplicableTotal() {
// 准备商品列表: PHOTO_PRINT(100元) + PHOTO_PRINT_MU(3元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("100.00")),
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("3.00"))
);
BigDecimal totalAmount = new BigDecimal("103.00");
// 优惠券配置: 5折,仅适用于PHOTO_PRINT_MU
PriceCouponConfig coupon = createCoupon(
CouponType.PERCENTAGE,
new BigDecimal("50.00"), // 50% off = 5折
BigDecimal.ZERO,
"[\"PHOTO_PRINT_MU\"]"
);
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 理论优惠1.5元(3 * 50% = 1.5),实际不应超过3元
assertTrue(discount.compareTo(new BigDecimal("3.00")) <= 0, "优惠金额不应超过适用商品总价");
}
@Test
@DisplayName("无商品类型限制的优惠券 - 应用于所有商品")
void testCoupon_NoProductTypeRestriction() {
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(8元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("8.00"))
);
BigDecimal totalAmount = new BigDecimal("18.00");
// 优惠券配置: 满0减5元,无商品类型限制
PriceCouponConfig coupon = createCoupon(
CouponType.FIXED_AMOUNT,
new BigDecimal("5.00"),
BigDecimal.ZERO,
null // 无商品类型限制
);
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 优惠金额应为5元
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "无商品类型限制时应应用于所有商品");
}
@Test
@DisplayName("无商品类型限制的优惠券 - 优惠金额超过总价应被截断")
void testCoupon_NoProductTypeRestriction_ExceedsTotalPrice() {
// 准备商品列表: PHOTO_PRINT(2元) + PHOTO_PRINT_MU(1元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("2.00")),
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("1.00"))
);
BigDecimal totalAmount = new BigDecimal("3.00");
// 优惠券配置: 满0减10元,无商品类型限制
PriceCouponConfig coupon = createCoupon(
CouponType.FIXED_AMOUNT,
new BigDecimal("10.00"),
BigDecimal.ZERO,
null
);
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 优惠金额应被截断为3元
assertTrue(discount.compareTo(new BigDecimal("3.00")) == 0, "优惠金额应被截断为商品总价");
}
@Test
@DisplayName("多商品类型限制的优惠券 - 计算多个适用商品的总价")
void testCoupon_MultipleProductTypeRestriction() {
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(8元) + VLOG_VIDEO(20元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("8.00")),
createProductItem(ProductType.VLOG_VIDEO, new BigDecimal("20.00"))
);
BigDecimal totalAmount = new BigDecimal("38.00");
// 优惠券配置: 满0减10元,适用于PHOTO_PRINT和PHOTO_PRINT_MU
PriceCouponConfig coupon = createCoupon(
CouponType.FIXED_AMOUNT,
new BigDecimal("10.00"),
BigDecimal.ZERO,
"[\"PHOTO_PRINT\",\"PHOTO_PRINT_MU\"]"
);
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 优惠金额应为10元(适用商品总价18元,优惠10元未超限)
assertTrue(discount.compareTo(new BigDecimal("10.00")) == 0, "多商品类型限制时应正确计算适用商品总价");
}
@Test
@DisplayName("边界情况 - 适用商品总价为0")
void testCoupon_ApplicableTotalIsZero() {
// 准备商品列表: 仅有PHOTO_PRINT(10元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00"))
);
BigDecimal totalAmount = new BigDecimal("10.00");
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU(购物车中无此商品)
PriceCouponConfig coupon = createCoupon(
CouponType.FIXED_AMOUNT,
new BigDecimal("5.00"),
BigDecimal.ZERO,
"[\"PHOTO_PRINT_MU\"]"
);
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 优惠金额应为0(适用商品总价为0,自动截断)
assertTrue(new BigDecimal("0.00").compareTo(discount) == 0, "适用商品总价为0时优惠金额应为0");
}
@Test
@DisplayName("边界情况 - 优惠金额刚好等于适用商品总价")
void testCoupon_DiscountEqualsApplicableTotal() {
// 准备商品列表: PHOTO_PRINT(10元) + PHOTO_PRINT_MU(5元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("10.00")),
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("5.00"))
);
BigDecimal totalAmount = new BigDecimal("15.00");
// 优惠券配置: 满0减5元,仅适用于PHOTO_PRINT_MU
PriceCouponConfig coupon = createCoupon(
CouponType.FIXED_AMOUNT,
new BigDecimal("5.00"),
BigDecimal.ZERO,
"[\"PHOTO_PRINT_MU\"]"
);
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 优惠金额应为5元
assertTrue(discount.compareTo(new BigDecimal("5.00")) == 0, "优惠金额刚好等于适用商品总价时应正常优惠");
}
@Test
@DisplayName("百分比优惠券 - 带最大优惠金额限制且超过适用商品总价")
void testPercentageCoupon_WithMaxDiscount_ExceedsApplicableTotal() {
// 准备商品列表: PHOTO_PRINT(100元) + PHOTO_PRINT_MU(3元)
List<ProductItem> products = Arrays.asList(
createProductItem(ProductType.PHOTO_PRINT, new BigDecimal("100.00")),
createProductItem(ProductType.PHOTO_PRINT_MU, new BigDecimal("3.00"))
);
BigDecimal totalAmount = new BigDecimal("103.00");
// 优惠券配置: 5折,最大优惠10元,仅适用于PHOTO_PRINT_MU
PriceCouponConfig coupon = createCoupon(
CouponType.PERCENTAGE,
new BigDecimal("50.00"),
BigDecimal.ZERO,
"[\"PHOTO_PRINT_MU\"]"
);
coupon.setMaxDiscount(new BigDecimal("10.00"));
// 执行计算
BigDecimal discount = couponService.calculateCouponDiscount(coupon, products, totalAmount);
// 验证: 理论优惠1.5元(3 * 50%),不受最大优惠10元限制,但仍不超过适用商品总价3元
assertTrue(discount.compareTo(new BigDecimal("3.00")) <= 0, "优惠金额不应超过适用商品总价");
assertTrue(discount.compareTo(new BigDecimal("1.50")) >= 0, "优惠金额应为计算值1.5元");
}
// ========== 辅助方法 ==========
/**
* 创建商品项
*/
private ProductItem createProductItem(ProductType productType, BigDecimal subtotal) {
ProductItem item = new ProductItem();
item.setProductType(productType);
item.setSubtotal(subtotal);
item.setQuantity(1);
item.setUnitPrice(subtotal);
return item;
}
/**
* 创建优惠券配置
*/
private PriceCouponConfig createCoupon(CouponType type, BigDecimal value,
BigDecimal minAmount, String applicableProducts) {
PriceCouponConfig coupon = new PriceCouponConfig();
coupon.setId(1L);
coupon.setCouponName("测试优惠券");
coupon.setCouponType(type);
coupon.setDiscountValue(value);
coupon.setMinAmount(minAmount);
coupon.setApplicableProducts(applicableProducts);
coupon.setIsActive(true);
return coupon;
}
}