From 7e157eaba959ffaf53b76bba5e6f222d1ea4b77f Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 17 Dec 2025 23:49:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(pricing):=20=E6=96=B0=E5=A2=9E=E4=BC=98?= =?UTF-8?q?=E6=83=A0=E5=88=B8=E5=B1=9E=E6=80=A7=E9=97=A8=E6=A7=9B=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在PriceCouponConfig实体中新增requiredAttributeKeys字段,用于配置优惠券使用门槛 - 修改MyBatis Mapper SQL语句,支持新字段的插入和更新操作 - 在CouponManagementServiceImpl中增加对requiredAttributeKeys的格式校验逻辑 - 更新CouponServiceImpl的优惠券适用性检查逻辑,增加属性门槛判断 - 在PriceCalculationServiceImpl中实现商品属性Key的自动计算与填充 - 优化价格计算服务中的能力缓存与属性Key构建逻辑 - 更新CLAUDE.md文档,补充属性门槛特性的说明 --- .../java/com/ycwl/basic/pricing/CLAUDE.md | 1 + .../ycwl/basic/pricing/dto/ProductItem.java | 8 +- .../pricing/entity/PriceCouponConfig.java | 8 +- .../mapper/PriceCouponConfigMapper.java | 6 +- .../impl/CouponManagementServiceImpl.java | 37 +++++++- .../service/impl/CouponServiceImpl.java | 60 ++++++++++--- .../impl/PriceCalculationServiceImpl.java | 85 +++++++++++++++++-- 7 files changed, 183 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/ycwl/basic/pricing/CLAUDE.md b/src/main/java/com/ycwl/basic/pricing/CLAUDE.md index 0742dcdb..aad8c04c 100644 --- a/src/main/java/com/ycwl/basic/pricing/CLAUDE.md +++ b/src/main/java/com/ycwl/basic/pricing/CLAUDE.md @@ -120,6 +120,7 @@ public enum CouponStatus { CLAIMED("claimed", ...), USED("used", ...), EXPIRED(" #### 关键特性 - 商品类型限制:通过 JSON 字段(结合 `ProductTypeListTypeHandler`)控制适用商品 +- 属性门槛:通过 `requiredAttributeKeys`(JSON) 配置,要求在可折扣商品范围内任一商品出现任一属性Key(属性Key为后端与运营约定的字符串);商品属性由服务端根据商品能力配置(`ProductTypeCapability.metadata.pricingAttributeKeys`)计算写入 `ProductItem.attributeKeys` - 消费限制:支持最小消费金额、最大折扣限制 - 时效性:基于时间的有效期控制 - **用户领取数量限制**:通过 `userClaimLimit` 字段控制单个用户可领取优惠券的最大数量(v1.0.0新增) diff --git a/src/main/java/com/ycwl/basic/pricing/dto/ProductItem.java b/src/main/java/com/ycwl/basic/pricing/dto/ProductItem.java index c5d2160b..533294df 100644 --- a/src/main/java/com/ycwl/basic/pricing/dto/ProductItem.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/ProductItem.java @@ -4,6 +4,7 @@ import com.ycwl.basic.pricing.enums.ProductType; import lombok.Data; import java.math.BigDecimal; +import java.util.List; /** * 商品项DTO @@ -50,4 +51,9 @@ public class ProductItem { * 景区ID */ private String scenicId; -} \ No newline at end of file + + /** + * 商品属性Key列表(服务端计算填充,客户端传入会被忽略) + */ + private List attributeKeys; +} diff --git a/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponConfig.java b/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponConfig.java index 824bc248..09a58060 100644 --- a/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponConfig.java +++ b/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponConfig.java @@ -50,6 +50,12 @@ public class PriceCouponConfig { * 适用商品类型(JSON) */ private String applicableProducts; + + /** + * 优惠券使用门槛:要求在可折扣商品范围内出现指定属性Key(JSON) + * 为空表示不限制 + */ + private String requiredAttributeKeys; /** * 发行总量 @@ -104,4 +110,4 @@ public class PriceCouponConfig { private Integer deleted; private Date deletedAt; -} \ No newline at end of file +} diff --git a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java index 71496c2e..fd354880 100644 --- a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java +++ b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java @@ -52,9 +52,9 @@ public interface PriceCouponConfigMapper extends BaseMapper { */ @Insert("INSERT INTO price_coupon_config (coupon_name, coupon_type, discount_value, min_amount, " + "max_discount, applicable_products, total_quantity, used_quantity, valid_from, valid_until, " + - "is_active, scenic_id, create_time, update_time) VALUES " + + "required_attribute_keys, is_active, scenic_id, create_time, update_time) VALUES " + "(#{couponName}, #{couponType}, #{discountValue}, #{minAmount}, #{maxDiscount}, " + - "#{applicableProducts}, #{totalQuantity}, #{usedQuantity}, #{validFrom}, #{validUntil}, " + + "#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, #{validFrom}, #{validUntil}, " + "#{isActive}, #{scenicId}, NOW(), NOW())") int insertCoupon(PriceCouponConfig coupon); @@ -63,7 +63,7 @@ public interface PriceCouponConfigMapper extends BaseMapper { */ @Update("UPDATE price_coupon_config SET coupon_name = #{couponName}, coupon_type = #{couponType}, " + "discount_value = #{discountValue}, min_amount = #{minAmount}, max_discount = #{maxDiscount}, " + - "applicable_products = #{applicableProducts}, total_quantity = #{totalQuantity}, " + + "applicable_products = #{applicableProducts}, required_attribute_keys = #{requiredAttributeKeys}, total_quantity = #{totalQuantity}, " + "valid_from = #{validFrom}, valid_until = #{validUntil}, is_active = #{isActive}, " + "scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}") int updateCoupon(PriceCouponConfig coupon); diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponManagementServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponManagementServiceImpl.java index 4342fdf2..50b6c428 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponManagementServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponManagementServiceImpl.java @@ -1,5 +1,7 @@ package com.ycwl.basic.pricing.service.impl; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord; @@ -26,9 +28,12 @@ import java.util.Map; @Service @RequiredArgsConstructor public class CouponManagementServiceImpl implements ICouponManagementService { + + private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; private final PriceCouponConfigMapper couponConfigMapper; private final PriceCouponClaimRecordMapper claimRecordMapper; + private final ObjectMapper objectMapper; // ==================== 优惠券配置管理 ==================== @@ -36,6 +41,8 @@ public class CouponManagementServiceImpl implements ICouponManagementService { @Transactional public Long createCouponConfig(PriceCouponConfig config) { log.info("创建优惠券配置: {}", config.getCouponName()); + + validateCouponConfig(config); // 设置默认值 if (config.getUsedQuantity() == null) { @@ -59,6 +66,8 @@ public class CouponManagementServiceImpl implements ICouponManagementService { @Transactional public boolean updateCouponConfig(PriceCouponConfig config) { log.info("更新优惠券配置,ID: {}", config.getId()); + + validateCouponConfig(config); PriceCouponConfig existing = couponConfigMapper.selectById(config.getId()); if (existing == null) { @@ -75,6 +84,32 @@ public class CouponManagementServiceImpl implements ICouponManagementService { return false; } } + + private void validateCouponConfig(PriceCouponConfig config) { + validateRequiredAttributeKeys(config.getRequiredAttributeKeys()); + } + + private void validateRequiredAttributeKeys(String requiredAttributeKeys) { + if (requiredAttributeKeys == null || requiredAttributeKeys.isBlank()) { + return; + } + + List keys; + try { + keys = objectMapper.readValue(requiredAttributeKeys, STRING_LIST_TYPE); + } catch (Exception e) { + throw new IllegalArgumentException("requiredAttributeKeys格式错误,必须是JSON数组字符串,例如 [\"TYPE_3\"]"); + } + + if (keys == null || keys.isEmpty()) { + return; + } + + boolean hasBlankKey = keys.stream().anyMatch(key -> key == null || key.trim().isEmpty()); + if (hasBlankKey) { + throw new IllegalArgumentException("requiredAttributeKeys不能包含空值"); + } + } @Override @Transactional @@ -250,4 +285,4 @@ public class CouponManagementServiceImpl implements ICouponManagementService { return result; } -} \ No newline at end of file +} 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 31f8b74d..75250121 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 @@ -118,23 +118,63 @@ public class CouponServiceImpl implements ICouponService { } } - // 3. 检查商品类型限制 - if (coupon.getApplicableProducts() == null || coupon.getApplicableProducts().isEmpty()) { + // 3. 检查商品类型限制(用于确定可折扣商品范围) + List discountableProducts = products; + if (coupon.getApplicableProducts() != null && !coupon.getApplicableProducts().isEmpty()) { + try { + List applicableProductTypes = objectMapper.readValue( + coupon.getApplicableProducts(), new TypeReference>() {}); + + discountableProducts = products.stream() + .filter(product -> applicableProductTypes.contains(product.getProductType().getCode())) + .toList(); + + if (discountableProducts.isEmpty()) { + return false; + } + } catch (Exception e) { + log.error("解析适用商品类型失败", e); + return false; + } + } + + // 4. 检查属性门槛:要求在可折扣商品范围内,任一商品出现任一属性Key + if (coupon.getRequiredAttributeKeys() == null || coupon.getRequiredAttributeKeys().isEmpty()) { return true; } - + try { - List applicableProductTypes = objectMapper.readValue( - coupon.getApplicableProducts(), new TypeReference>() {}); - - for (ProductItem product : products) { - if (applicableProductTypes.contains(product.getProductType().getCode())) { - return true; + List requiredAttributeKeys = objectMapper.readValue( + coupon.getRequiredAttributeKeys(), new TypeReference>() {}); + if (requiredAttributeKeys == null || requiredAttributeKeys.isEmpty()) { + return true; + } + + for (ProductItem product : discountableProducts) { + List attributeKeys = product.getAttributeKeys(); + if (attributeKeys == null || attributeKeys.isEmpty()) { + continue; + } + + for (String requiredKey : requiredAttributeKeys) { + if (requiredKey == null) { + continue; + } + + String key = requiredKey.trim(); + if (key.isEmpty()) { + continue; + } + + if (attributeKeys.contains(key)) { + return true; + } } } + return false; } catch (Exception e) { - log.error("解析适用商品类型失败", e); + log.error("解析优惠券属性门槛失败", e); return false; } } diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java index 6bcf7532..b174231c 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/PriceCalculationServiceImpl.java @@ -17,7 +17,11 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; /** * 价格计算服务实现 @@ -26,6 +30,8 @@ import java.util.List; @Service("pricingCalculationServiceImpl") @RequiredArgsConstructor public class PriceCalculationServiceImpl implements IPriceCalculationService { + + private static final String CAPABILITY_METADATA_ATTRIBUTE_KEYS = "pricingAttributeKeys"; private final IProductConfigService productConfigService; private final ICouponService couponService; @@ -46,6 +52,13 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { return capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED; } + private boolean isQuantityBasedPricing(ProductTypeCapability capability) { + if (capability == null) { + return false; + } + return capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED; + } + @Override public PriceCalculationResult calculatePrice(PriceCalculationRequest request) { if (request.getProducts() == null || request.getProducts().isEmpty()) { @@ -166,9 +179,16 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { BigDecimal totalAmount = BigDecimal.ZERO; BigDecimal originalTotalAmount = BigDecimal.ZERO; + Map capabilityCache = new HashMap<>(); + Map> attributeKeysCache = new HashMap<>(); + for (ProductItem product : products) { + String productTypeCode = product.getProductType().getCode(); + ProductTypeCapability capability = capabilityCache.computeIfAbsent( + productTypeCode, productTypeCapabilityService::getCapability); + // 计算实际价格和原价(传入景区ID) - ProductPriceInfo priceInfo = calculateSingleProductPriceWithOriginal(product, scenicId); + ProductPriceInfo priceInfo = calculateSingleProductPriceWithOriginal(product, scenicId, capability); product.setUnitPrice(priceInfo.getActualPrice()); product.setOriginalPrice(priceInfo.getOriginalPrice()); @@ -176,6 +196,13 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { BigDecimal subtotal = priceInfo.getActualPrice().multiply(BigDecimal.valueOf(product.getPurchaseCount())); BigDecimal originalSubtotal = priceInfo.getOriginalPrice().multiply(BigDecimal.valueOf(product.getPurchaseCount())); + List attributeKeys = attributeKeysCache.get(productTypeCode); + if (attributeKeys == null) { + attributeKeys = buildProductAttributeKeys(capability); + attributeKeysCache.put(productTypeCode, attributeKeys); + } + product.setAttributeKeys(attributeKeys); + product.setSubtotal(subtotal); totalAmount = totalAmount.add(subtotal); @@ -187,6 +214,51 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { originalTotalAmount.setScale(2, RoundingMode.HALF_UP) ); } + + private List buildProductAttributeKeys(ProductTypeCapability capability) { + if (capability == null || capability.getMetadata() == null) { + return List.of(); + } + + Object rawValue = capability.getMetadata().get(CAPABILITY_METADATA_ATTRIBUTE_KEYS); + if (rawValue == null) { + return List.of(); + } + + Set result = new LinkedHashSet<>(); + if (rawValue instanceof List rawList) { + for (Object item : rawList) { + if (item instanceof String rawKey) { + addAttributeKey(result, rawKey); + } + } + } else if (rawValue instanceof String rawString) { + String[] parts = rawString.split(","); + for (String part : parts) { + addAttributeKey(result, part); + } + } else { + log.warn("商品类型能力metadata中{}字段类型不支持: productType={}, valueType={}", + CAPABILITY_METADATA_ATTRIBUTE_KEYS, + capability.getProductType(), + rawValue.getClass().getName()); + } + + return result.isEmpty() ? List.of() : List.copyOf(result); + } + + private void addAttributeKey(Set target, String rawKey) { + if (rawKey == null) { + return; + } + + String key = rawKey.trim(); + if (key.isEmpty()) { + return; + } + + target.add(key); + } private BigDecimal calculateSingleProductPrice(ProductItem product) { ProductType productType = product.getProductType(); @@ -245,7 +317,8 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { throw new PriceCalculationException("无法计算商品价格: " + productType.getDescription() + ", productId: " + productId); } - private ProductPriceInfo calculateSingleProductPriceWithOriginal(ProductItem product, Long scenicId) { + private ProductPriceInfo calculateSingleProductPriceWithOriginal(ProductItem product, Long scenicId, + ProductTypeCapability capability) { ProductType productType = product.getProductType(); String productId = product.getProductId() != null ? product.getProductId() : "default"; @@ -269,7 +342,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { actualPrice = baseConfig.getBasePrice(); originalPrice = baseConfig.getOriginalPrice(); - if (isQuantityBasedPricing(productType.getCode())) { + if (isQuantityBasedPricing(capability)) { actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity())); if (originalPrice != null) { originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity())); @@ -289,7 +362,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { actualPrice = defaultConfig.getBasePrice(); originalPrice = defaultConfig.getOriginalPrice(); - if (isQuantityBasedPricing(productType.getCode())) { + if (isQuantityBasedPricing(capability)) { actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity())); if (originalPrice != null) { originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity())); @@ -308,7 +381,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { actualPrice = baseConfig.getBasePrice(); originalPrice = baseConfig.getOriginalPrice(); - if (isQuantityBasedPricing(productType.getCode())) { + if (isQuantityBasedPricing(capability)) { actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity())); if (originalPrice != null) { originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity())); @@ -426,4 +499,4 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService { // 不抛出异常,避免影响主流程 } } -} \ No newline at end of file +}