feat(pricing): 新增优惠券属性门槛校验功能

- 在PriceCouponConfig实体中新增requiredAttributeKeys字段,用于配置优惠券使用门槛
- 修改MyBatis Mapper SQL语句,支持新字段的插入和更新操作
- 在CouponManagementServiceImpl中增加对requiredAttributeKeys的格式校验逻辑
- 更新CouponServiceImpl的优惠券适用性检查逻辑,增加属性门槛判断
- 在PriceCalculationServiceImpl中实现商品属性Key的自动计算与填充
- 优化价格计算服务中的能力缓存与属性Key构建逻辑
- 更新CLAUDE.md文档,补充属性门槛特性的说明
This commit is contained in:
2025-12-17 23:49:20 +08:00
parent 00dd6a16a3
commit 7e157eaba9
7 changed files with 183 additions and 22 deletions

View File

@@ -120,6 +120,7 @@ public enum CouponStatus { CLAIMED("claimed", ...), USED("used", ...), EXPIRED("
#### 关键特性 #### 关键特性
- 商品类型限制:通过 JSON 字段(结合 `ProductTypeListTypeHandler`)控制适用商品 - 商品类型限制:通过 JSON 字段(结合 `ProductTypeListTypeHandler`)控制适用商品
- 属性门槛:通过 `requiredAttributeKeys`(JSON) 配置,要求在可折扣商品范围内任一商品出现任一属性Key(属性Key为后端与运营约定的字符串);商品属性由服务端根据商品能力配置(`ProductTypeCapability.metadata.pricingAttributeKeys`)计算写入 `ProductItem.attributeKeys`
- 消费限制:支持最小消费金额、最大折扣限制 - 消费限制:支持最小消费金额、最大折扣限制
- 时效性:基于时间的有效期控制 - 时效性:基于时间的有效期控制
- **用户领取数量限制**:通过 `userClaimLimit` 字段控制单个用户可领取优惠券的最大数量(v1.0.0新增) - **用户领取数量限制**:通过 `userClaimLimit` 字段控制单个用户可领取优惠券的最大数量(v1.0.0新增)

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.pricing.enums.ProductType;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List;
/** /**
* 商品项DTO * 商品项DTO
@@ -50,4 +51,9 @@ public class ProductItem {
* 景区ID * 景区ID
*/ */
private String scenicId; private String scenicId;
}
/**
* 商品属性Key列表(服务端计算填充,客户端传入会被忽略)
*/
private List<String> attributeKeys;
}

View File

@@ -50,6 +50,12 @@ public class PriceCouponConfig {
* 适用商品类型(JSON) * 适用商品类型(JSON)
*/ */
private String applicableProducts; private String applicableProducts;
/**
* 优惠券使用门槛:要求在可折扣商品范围内出现指定属性Key(JSON)
* 为空表示不限制
*/
private String requiredAttributeKeys;
/** /**
* 发行总量 * 发行总量
@@ -104,4 +110,4 @@ public class PriceCouponConfig {
private Integer deleted; private Integer deleted;
private Date deletedAt; private Date deletedAt;
} }

View File

@@ -52,9 +52,9 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
*/ */
@Insert("INSERT INTO price_coupon_config (coupon_name, coupon_type, discount_value, min_amount, " + @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, " + "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}, " + "(#{couponName}, #{couponType}, #{discountValue}, #{minAmount}, #{maxDiscount}, " +
"#{applicableProducts}, #{totalQuantity}, #{usedQuantity}, #{validFrom}, #{validUntil}, " + "#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, #{validFrom}, #{validUntil}, " +
"#{isActive}, #{scenicId}, NOW(), NOW())") "#{isActive}, #{scenicId}, NOW(), NOW())")
int insertCoupon(PriceCouponConfig coupon); int insertCoupon(PriceCouponConfig coupon);
@@ -63,7 +63,7 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
*/ */
@Update("UPDATE price_coupon_config SET coupon_name = #{couponName}, coupon_type = #{couponType}, " + @Update("UPDATE price_coupon_config SET coupon_name = #{couponName}, coupon_type = #{couponType}, " +
"discount_value = #{discountValue}, min_amount = #{minAmount}, max_discount = #{maxDiscount}, " + "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}, " + "valid_from = #{validFrom}, valid_until = #{validUntil}, is_active = #{isActive}, " +
"scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}") "scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}")
int updateCoupon(PriceCouponConfig coupon); int updateCoupon(PriceCouponConfig coupon);

View File

@@ -1,5 +1,7 @@
package com.ycwl.basic.pricing.service.impl; 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.PageHelper;
import com.github.pagehelper.PageInfo; import com.github.pagehelper.PageInfo;
import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord; import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord;
@@ -26,9 +28,12 @@ import java.util.Map;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class CouponManagementServiceImpl implements ICouponManagementService { public class CouponManagementServiceImpl implements ICouponManagementService {
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private final PriceCouponConfigMapper couponConfigMapper; private final PriceCouponConfigMapper couponConfigMapper;
private final PriceCouponClaimRecordMapper claimRecordMapper; private final PriceCouponClaimRecordMapper claimRecordMapper;
private final ObjectMapper objectMapper;
// ==================== 优惠券配置管理 ==================== // ==================== 优惠券配置管理 ====================
@@ -36,6 +41,8 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
@Transactional @Transactional
public Long createCouponConfig(PriceCouponConfig config) { public Long createCouponConfig(PriceCouponConfig config) {
log.info("创建优惠券配置: {}", config.getCouponName()); log.info("创建优惠券配置: {}", config.getCouponName());
validateCouponConfig(config);
// 设置默认值 // 设置默认值
if (config.getUsedQuantity() == null) { if (config.getUsedQuantity() == null) {
@@ -59,6 +66,8 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
@Transactional @Transactional
public boolean updateCouponConfig(PriceCouponConfig config) { public boolean updateCouponConfig(PriceCouponConfig config) {
log.info("更新优惠券配置,ID: {}", config.getId()); log.info("更新优惠券配置,ID: {}", config.getId());
validateCouponConfig(config);
PriceCouponConfig existing = couponConfigMapper.selectById(config.getId()); PriceCouponConfig existing = couponConfigMapper.selectById(config.getId());
if (existing == null) { if (existing == null) {
@@ -75,6 +84,32 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
return false; return false;
} }
} }
private void validateCouponConfig(PriceCouponConfig config) {
validateRequiredAttributeKeys(config.getRequiredAttributeKeys());
}
private void validateRequiredAttributeKeys(String requiredAttributeKeys) {
if (requiredAttributeKeys == null || requiredAttributeKeys.isBlank()) {
return;
}
List<String> 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 @Override
@Transactional @Transactional
@@ -250,4 +285,4 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
return result; return result;
} }
} }

View File

@@ -118,23 +118,63 @@ public class CouponServiceImpl implements ICouponService {
} }
} }
// 3. 检查商品类型限制 // 3. 检查商品类型限制(用于确定可折扣商品范围)
if (coupon.getApplicableProducts() == null || coupon.getApplicableProducts().isEmpty()) { List<ProductItem> discountableProducts = products;
if (coupon.getApplicableProducts() != null && !coupon.getApplicableProducts().isEmpty()) {
try {
List<String> applicableProductTypes = objectMapper.readValue(
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
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; return true;
} }
try { try {
List<String> applicableProductTypes = objectMapper.readValue( List<String> requiredAttributeKeys = objectMapper.readValue(
coupon.getApplicableProducts(), new TypeReference<List<String>>() {}); coupon.getRequiredAttributeKeys(), new TypeReference<List<String>>() {});
if (requiredAttributeKeys == null || requiredAttributeKeys.isEmpty()) {
for (ProductItem product : products) { return true;
if (applicableProductTypes.contains(product.getProductType().getCode())) { }
return true;
for (ProductItem product : discountableProducts) {
List<String> 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; return false;
} catch (Exception e) { } catch (Exception e) {
log.error("解析适用商品类型失败", e); log.error("解析优惠券属性门槛失败", e);
return false; return false;
} }
} }

View File

@@ -17,7 +17,11 @@ import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
/** /**
* 价格计算服务实现 * 价格计算服务实现
@@ -26,6 +30,8 @@ import java.util.List;
@Service("pricingCalculationServiceImpl") @Service("pricingCalculationServiceImpl")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PriceCalculationServiceImpl implements IPriceCalculationService { public class PriceCalculationServiceImpl implements IPriceCalculationService {
private static final String CAPABILITY_METADATA_ATTRIBUTE_KEYS = "pricingAttributeKeys";
private final IProductConfigService productConfigService; private final IProductConfigService productConfigService;
private final ICouponService couponService; private final ICouponService couponService;
@@ -46,6 +52,13 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
return capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED; return capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED;
} }
private boolean isQuantityBasedPricing(ProductTypeCapability capability) {
if (capability == null) {
return false;
}
return capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED;
}
@Override @Override
public PriceCalculationResult calculatePrice(PriceCalculationRequest request) { public PriceCalculationResult calculatePrice(PriceCalculationRequest request) {
if (request.getProducts() == null || request.getProducts().isEmpty()) { if (request.getProducts() == null || request.getProducts().isEmpty()) {
@@ -166,9 +179,16 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
BigDecimal totalAmount = BigDecimal.ZERO; BigDecimal totalAmount = BigDecimal.ZERO;
BigDecimal originalTotalAmount = BigDecimal.ZERO; BigDecimal originalTotalAmount = BigDecimal.ZERO;
Map<String, ProductTypeCapability> capabilityCache = new HashMap<>();
Map<String, List<String>> attributeKeysCache = new HashMap<>();
for (ProductItem product : products) { for (ProductItem product : products) {
String productTypeCode = product.getProductType().getCode();
ProductTypeCapability capability = capabilityCache.computeIfAbsent(
productTypeCode, productTypeCapabilityService::getCapability);
// 计算实际价格和原价(传入景区ID) // 计算实际价格和原价(传入景区ID)
ProductPriceInfo priceInfo = calculateSingleProductPriceWithOriginal(product, scenicId); ProductPriceInfo priceInfo = calculateSingleProductPriceWithOriginal(product, scenicId, capability);
product.setUnitPrice(priceInfo.getActualPrice()); product.setUnitPrice(priceInfo.getActualPrice());
product.setOriginalPrice(priceInfo.getOriginalPrice()); product.setOriginalPrice(priceInfo.getOriginalPrice());
@@ -176,6 +196,13 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
BigDecimal subtotal = priceInfo.getActualPrice().multiply(BigDecimal.valueOf(product.getPurchaseCount())); BigDecimal subtotal = priceInfo.getActualPrice().multiply(BigDecimal.valueOf(product.getPurchaseCount()));
BigDecimal originalSubtotal = priceInfo.getOriginalPrice().multiply(BigDecimal.valueOf(product.getPurchaseCount())); BigDecimal originalSubtotal = priceInfo.getOriginalPrice().multiply(BigDecimal.valueOf(product.getPurchaseCount()));
List<String> attributeKeys = attributeKeysCache.get(productTypeCode);
if (attributeKeys == null) {
attributeKeys = buildProductAttributeKeys(capability);
attributeKeysCache.put(productTypeCode, attributeKeys);
}
product.setAttributeKeys(attributeKeys);
product.setSubtotal(subtotal); product.setSubtotal(subtotal);
totalAmount = totalAmount.add(subtotal); totalAmount = totalAmount.add(subtotal);
@@ -187,6 +214,51 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
originalTotalAmount.setScale(2, RoundingMode.HALF_UP) originalTotalAmount.setScale(2, RoundingMode.HALF_UP)
); );
} }
private List<String> 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<String> 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<String> target, String rawKey) {
if (rawKey == null) {
return;
}
String key = rawKey.trim();
if (key.isEmpty()) {
return;
}
target.add(key);
}
private BigDecimal calculateSingleProductPrice(ProductItem product) { private BigDecimal calculateSingleProductPrice(ProductItem product) {
ProductType productType = product.getProductType(); ProductType productType = product.getProductType();
@@ -245,7 +317,8 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
throw new PriceCalculationException("无法计算商品价格: " + productType.getDescription() + ", productId: " + productId); 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(); ProductType productType = product.getProductType();
String productId = product.getProductId() != null ? product.getProductId() : "default"; String productId = product.getProductId() != null ? product.getProductId() : "default";
@@ -269,7 +342,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = baseConfig.getBasePrice(); actualPrice = baseConfig.getBasePrice();
originalPrice = baseConfig.getOriginalPrice(); originalPrice = baseConfig.getOriginalPrice();
if (isQuantityBasedPricing(productType.getCode())) { if (isQuantityBasedPricing(capability)) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity())); actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) { if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity())); originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -289,7 +362,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = defaultConfig.getBasePrice(); actualPrice = defaultConfig.getBasePrice();
originalPrice = defaultConfig.getOriginalPrice(); originalPrice = defaultConfig.getOriginalPrice();
if (isQuantityBasedPricing(productType.getCode())) { if (isQuantityBasedPricing(capability)) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity())); actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) { if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity())); originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -308,7 +381,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = baseConfig.getBasePrice(); actualPrice = baseConfig.getBasePrice();
originalPrice = baseConfig.getOriginalPrice(); originalPrice = baseConfig.getOriginalPrice();
if (isQuantityBasedPricing(productType.getCode())) { if (isQuantityBasedPricing(capability)) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity())); actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) { if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity())); originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -426,4 +499,4 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
// 不抛出异常,避免影响主流程 // 不抛出异常,避免影响主流程
} }
} }
} }