You've already forked FrameTour-BE
feat(pricing): 优化优惠券领取逻辑与并发控制
- 为 CouponInvalidException 添加错误码支持 - 在 countUserCouponClaims 查询中添加 FOR UPDATE 锁 - 新增 incrementClaimedQuantityIfAvailable 方法用于原子性增加已领取数量 - 移除重复的用户优惠券领取检查逻辑 - 调整领取流程步骤编号并优化事务回滚处理 - 增加对优惠券库存耗尽情况的业务异常处理 - 使用
This commit is contained in:
@@ -5,11 +5,26 @@ package com.ycwl.basic.pricing.exception;
|
|||||||
*/
|
*/
|
||||||
public class CouponInvalidException extends RuntimeException {
|
public class CouponInvalidException extends RuntimeException {
|
||||||
|
|
||||||
|
private final String errorCode;
|
||||||
|
|
||||||
public CouponInvalidException(String message) {
|
public CouponInvalidException(String message) {
|
||||||
super(message);
|
this(null, message, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CouponInvalidException(String message, Throwable cause) {
|
public CouponInvalidException(String message, Throwable cause) {
|
||||||
|
this(null, message, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CouponInvalidException(String errorCode, String message) {
|
||||||
|
this(errorCode, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CouponInvalidException(String errorCode, String message, Throwable cause) {
|
||||||
super(message, cause);
|
super(message, cause);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorCode() {
|
||||||
|
return errorCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper<PriceCouponClai
|
|||||||
* 统计用户领取某优惠券的次数(所有状态)
|
* 统计用户领取某优惠券的次数(所有状态)
|
||||||
*/
|
*/
|
||||||
@Select("SELECT COUNT(*) FROM price_coupon_claim_record " +
|
@Select("SELECT COUNT(*) FROM price_coupon_claim_record " +
|
||||||
"WHERE user_id = #{userId} AND coupon_id = #{couponId} AND deleted = 0")
|
"WHERE user_id = #{userId} AND coupon_id = #{couponId} AND deleted = 0 FOR UPDATE")
|
||||||
int countUserCouponClaims(@Param("userId") Long userId, @Param("couponId") Long couponId);
|
int countUserCouponClaims(@Param("userId") Long userId, @Param("couponId") Long couponId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
|
|||||||
"update_time = NOW() WHERE id = #{couponId} AND used_quantity < total_quantity")
|
"update_time = NOW() WHERE id = #{couponId} AND used_quantity < total_quantity")
|
||||||
int incrementUsedQuantity(Long couponId);
|
int incrementUsedQuantity(Long couponId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子性增加已领取数量(仅对有限库存的优惠券生效)
|
||||||
|
*/
|
||||||
|
@Update("UPDATE price_coupon_config SET claimed_quantity = COALESCE(claimed_quantity, 0) + 1, " +
|
||||||
|
"update_time = NOW() WHERE id = #{couponId} AND total_quantity IS NOT NULL AND total_quantity > 0 " +
|
||||||
|
"AND COALESCE(claimed_quantity, 0) < total_quantity")
|
||||||
|
int incrementClaimedQuantityIfAvailable(@Param("couponId") Long couponId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 插入优惠券配置
|
* 插入优惠券配置
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.util.Date;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.interceptor.TransactionAspectSupport;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
@@ -243,6 +244,7 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
if (coupon.getUserClaimLimit() != null && coupon.getUserClaimLimit() > 0) {
|
if (coupon.getUserClaimLimit() != null && coupon.getUserClaimLimit() > 0) {
|
||||||
int userClaimCount = couponClaimRecordMapper.countUserCouponClaims(
|
int userClaimCount = couponClaimRecordMapper.countUserCouponClaims(
|
||||||
request.getUserId(), request.getCouponId());
|
request.getUserId(), request.getCouponId());
|
||||||
|
// countUserCouponClaims 使用 FOR UPDATE + 复合索引,确保并发下的计数准确
|
||||||
if (userClaimCount >= coupon.getUserClaimLimit()) {
|
if (userClaimCount >= coupon.getUserClaimLimit()) {
|
||||||
return CouponClaimResult.failure(
|
return CouponClaimResult.failure(
|
||||||
CouponClaimResult.ERROR_CLAIM_LIMIT_REACHED,
|
CouponClaimResult.ERROR_CLAIM_LIMIT_REACHED,
|
||||||
@@ -250,14 +252,7 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 检查用户是否已经领取过该优惠券(兼容旧逻辑,双重保护)
|
// 7. 创建领取记录
|
||||||
PriceCouponClaimRecord existingRecord = couponClaimRecordMapper.selectUserCouponRecord(
|
|
||||||
request.getUserId(), request.getCouponId());
|
|
||||||
if (existingRecord != null) {
|
|
||||||
return CouponClaimResult.failure(CouponClaimResult.ERROR_ALREADY_CLAIMED, "您已经领取过该优惠券");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 创建领取记录
|
|
||||||
Date claimTime = new Date();
|
Date claimTime = new Date();
|
||||||
PriceCouponClaimRecord claimRecord = new PriceCouponClaimRecord();
|
PriceCouponClaimRecord claimRecord = new PriceCouponClaimRecord();
|
||||||
claimRecord.setCouponId(request.getCouponId());
|
claimRecord.setCouponId(request.getCouponId());
|
||||||
@@ -269,7 +264,7 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
claimRecord.setUpdateTime(claimTime);
|
claimRecord.setUpdateTime(claimTime);
|
||||||
claimRecord.setDeleted(0);
|
claimRecord.setDeleted(0);
|
||||||
|
|
||||||
// 9. 插入领取记录
|
// 8. 插入领取记录
|
||||||
int insertResult = couponClaimRecordMapper.insert(claimRecord);
|
int insertResult = couponClaimRecordMapper.insert(claimRecord);
|
||||||
if (insertResult <= 0) {
|
if (insertResult <= 0) {
|
||||||
log.error("插入优惠券领取记录失败: userId={}, couponId={}",
|
log.error("插入优惠券领取记录失败: userId={}, couponId={}",
|
||||||
@@ -277,21 +272,35 @@ public class CouponServiceImpl implements ICouponService {
|
|||||||
return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "领取失败,请稍后重试");
|
return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "领取失败,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. 更新优惠券已领取数量(区分于已使用数量)
|
// 9. 更新优惠券已领取数量(区分于已使用数量)
|
||||||
// 仅在有总量限制时才更新claimedQuantity(totalQuantity为正整数)
|
// 仅在有总量限制时才更新claimedQuantity(totalQuantity为正整数)
|
||||||
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
|
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
|
||||||
|
int affected = couponConfigMapper.incrementClaimedQuantityIfAvailable(coupon.getId());
|
||||||
|
if (affected == 0) {
|
||||||
|
throw new CouponInvalidException(
|
||||||
|
CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK,
|
||||||
|
"优惠券已被领取完,请稍后重试");
|
||||||
|
}
|
||||||
int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1;
|
int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1;
|
||||||
coupon.setClaimedQuantity(updatedClaimedQuantity);
|
coupon.setClaimedQuantity(updatedClaimedQuantity);
|
||||||
couponConfigMapper.updateById(coupon);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}",
|
log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}",
|
||||||
request.getUserId(), request.getCouponId(), claimRecord.getId());
|
request.getUserId(), request.getCouponId(), claimRecord.getId());
|
||||||
|
|
||||||
// 11. 返回成功结果
|
// 10. 返回成功结果
|
||||||
return CouponClaimResult.success(claimRecord, coupon);
|
return CouponClaimResult.success(claimRecord, coupon);
|
||||||
|
|
||||||
|
} catch (CouponInvalidException e) {
|
||||||
|
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||||
|
log.warn("领取优惠券失败(业务校验不通过): userId={}, couponId={}, reason={}",
|
||||||
|
request.getUserId(), request.getCouponId(), e.getMessage());
|
||||||
|
String errorCode = e.getErrorCode() == null
|
||||||
|
? CouponClaimResult.ERROR_SYSTEM_ERROR
|
||||||
|
: e.getErrorCode();
|
||||||
|
return CouponClaimResult.failure(errorCode, e.getMessage());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||||
log.error("领取优惠券失败: userId={}, couponId={}",
|
log.error("领取优惠券失败: userId={}, couponId={}",
|
||||||
request.getUserId(), request.getCouponId(), e);
|
request.getUserId(), request.getCouponId(), e);
|
||||||
return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "系统错误,领取失败:" + e.getMessage());
|
return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "系统错误,领取失败:" + e.getMessage());
|
||||||
|
|||||||
Reference in New Issue
Block a user