diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java index 1130f302..31f8b74d 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java @@ -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 products) { + // 如果优惠券没有商品类型限制,返回所有商品总价 + if (coupon.getApplicableProducts() == null || coupon.getApplicableProducts().isEmpty()) { + return products.stream() + .map(ProductItem::getSubtotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + try { + // 解析适用商品类型列表 + List applicableProductTypes = objectMapper.readValue( + coupon.getApplicableProducts(), new TypeReference>() {}); + + // 计算适用商品的总价 + 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) { diff --git a/src/test/java/com/ycwl/basic/pricing/service/CouponOverflowTest.java b/src/test/java/com/ycwl/basic/pricing/service/CouponOverflowTest.java new file mode 100644 index 00000000..8b840a83 --- /dev/null +++ b/src/test/java/com/ycwl/basic/pricing/service/CouponOverflowTest.java @@ -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 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 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 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 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 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 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 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 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 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 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; + } +}