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`)控制适用商品
- 属性门槛:通过 `requiredAttributeKeys`(JSON) 配置,要求在可折扣商品范围内任一商品出现任一属性Key(属性Key为后端与运营约定的字符串);商品属性由服务端根据商品能力配置(`ProductTypeCapability.metadata.pricingAttributeKeys`)计算写入 `ProductItem.attributeKeys`
- 消费限制:支持最小消费金额、最大折扣限制
- 时效性:基于时间的有效期控制
- **用户领取数量限制**:通过 `userClaimLimit` 字段控制单个用户可领取优惠券的最大数量(v1.0.0新增)

View File

@@ -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;
/**
* 商品属性Key列表(服务端计算填充,客户端传入会被忽略)
*/
private List<String> attributeKeys;
}

View File

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

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, " +
"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<PriceCouponConfig> {
*/
@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);

View File

@@ -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;
@@ -27,8 +29,11 @@ import java.util.Map;
@RequiredArgsConstructor
public class CouponManagementServiceImpl implements ICouponManagementService {
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private final PriceCouponConfigMapper couponConfigMapper;
private final PriceCouponClaimRecordMapper claimRecordMapper;
private final ObjectMapper objectMapper;
// ==================== 优惠券配置管理 ====================
@@ -37,6 +42,8 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
public Long createCouponConfig(PriceCouponConfig config) {
log.info("创建优惠券配置: {}", config.getCouponName());
validateCouponConfig(config);
// 设置默认值
if (config.getUsedQuantity() == null) {
config.setUsedQuantity(0);
@@ -60,6 +67,8 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
public boolean updateCouponConfig(PriceCouponConfig config) {
log.info("更新优惠券配置,ID: {}", config.getId());
validateCouponConfig(config);
PriceCouponConfig existing = couponConfigMapper.selectById(config.getId());
if (existing == null) {
log.error("优惠券配置不存在,ID: {}", config.getId());
@@ -76,6 +85,32 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
}
}
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
@Transactional
public boolean deleteCouponConfig(Long id) {

View File

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

View File

@@ -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;
/**
* 价格计算服务实现
@@ -27,6 +31,8 @@ import java.util.List;
@RequiredArgsConstructor
public class PriceCalculationServiceImpl implements IPriceCalculationService {
private static final String CAPABILITY_METADATA_ATTRIBUTE_KEYS = "pricingAttributeKeys";
private final IProductConfigService productConfigService;
private final ICouponService couponService;
private final IPriceBundleService bundleService;
@@ -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<String, ProductTypeCapability> capabilityCache = new HashMap<>();
Map<String, List<String>> 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<String> attributeKeys = attributeKeysCache.get(productTypeCode);
if (attributeKeys == null) {
attributeKeys = buildProductAttributeKeys(capability);
attributeKeysCache.put(productTypeCode, attributeKeys);
}
product.setAttributeKeys(attributeKeys);
product.setSubtotal(subtotal);
totalAmount = totalAmount.add(subtotal);
@@ -188,6 +215,51 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
);
}
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) {
ProductType productType = product.getProductType();
String productId = product.getProductId() != null ? product.getProductId() : "default";
@@ -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()));