You've already forked FrameTour-BE
feat(pricing): 新增优惠券属性门槛校验功能
- 在PriceCouponConfig实体中新增requiredAttributeKeys字段,用于配置优惠券使用门槛 - 修改MyBatis Mapper SQL语句,支持新字段的插入和更新操作 - 在CouponManagementServiceImpl中增加对requiredAttributeKeys的格式校验逻辑 - 更新CouponServiceImpl的优惠券适用性检查逻辑,增加属性门槛判断 - 在PriceCalculationServiceImpl中实现商品属性Key的自动计算与填充 - 优化价格计算服务中的能力缓存与属性Key构建逻辑 - 更新CLAUDE.md文档,补充属性门槛特性的说明
This commit is contained in:
@@ -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新增)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -51,6 +51,12 @@ public class PriceCouponConfig {
|
||||
*/
|
||||
private String applicableProducts;
|
||||
|
||||
/**
|
||||
* 优惠券使用门槛:要求在可折扣商品范围内出现指定属性Key(JSON)
|
||||
* 为空表示不限制
|
||||
*/
|
||||
private String requiredAttributeKeys;
|
||||
|
||||
/**
|
||||
* 发行总量
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user