业务背景
兑换优惠券之后,我们可以在订单结算时使用优惠券,这个时候优惠券状态就会变成锁定中;如果用户支付了订单,优惠券状态变更为已使用;如果订单退款,用户优惠券回退到用户账户里,优惠券状态回退到未使用状态。
-
兑换优惠券(未使用):优惠券被领取到用户账户中,初始状态为
未使用
。
-
订单结算(锁定中):当用户在订单中使用优惠券时,优惠券状态应变更为
锁定中
。此时优惠券不可被其他订单再次使用,直到订单完成或取消。
-
支付成功(已使用):用户支付订单后,优惠券状态变更为
已使用
,表示优惠券使用成功,不可再被使用。
-
订单退款(未使用):如果订单取消或发生退款,优惠券状态回退至
未使用
,重新回到用户账户中,可被再次使用。
锁定优惠券
用户在订单结算时使用优惠券,创建优惠券结算单,并将用户优惠券的状态从“未使用”变更为“锁定中”,确保优惠券在订单支付过程中被锁定,避免并发情况下同一优惠券被重复使用。
场景:
美团上点外卖会有很多优惠券
- 商家优惠券:在当前店铺里的商品都可用
- 指定商品优惠券:在订单中如果存在指定商品则可以使用。
猜想:在购买页面点击确认按钮后前端将相关信息整合为入参传入接口,执行完成业务逻辑后优惠券进入锁定状态,前端显示待支付页面。
- 创建优惠券结算单设置初始状态为“锁定中”
- 用户优惠券状态从“未使用”变更为“锁定中”
1. 获取分布式锁
代码如下:
RLock lock = redissonClient.getLock(String.format(EngineRedisConstant.LOCK_COUPON_SETTLEMENT_KEY, requestParam.getCouponId()));
boolean tryLock = lock.tryLock();
if (!tryLock) {
throw new ClientException("正在创建优惠券结算单,请稍候再试");
}
-
获取分布式锁:使用
Redisson
获取基于Redis
的分布式锁,防止并发情况下同一优惠券被多个线程同时使用。
-
锁的 Key:锁的 Key 为
LOCK_COUPON_SETTLEMENT_KEY + couponId
,表示锁定某个具体优惠券的结算操作。
-
tryLock 判断:如果获取不到锁,则表示当前优惠券正在创建结算单,抛出异常,提示稍后再试。
2. 检查优惠券状态
代码如下所示:
LambdaQueryWrapper<CouponSettlementDO> queryWrapper = Wrappers.lambdaQuery(CouponSettlementDO.class)
.eq(CouponSettlementDO::getCouponId, requestParam.getCouponId())
.eq(CouponSettlementDO::getUserId, Long.parseLong(UserContext.getUserId()))
.in(CouponSettlementDO::getStatus, 0, 2);
if (couponSettlementMapper.selectOne(queryWrapper) != null) {
throw new ClientException("请检查优惠券是否已使用");
}
-
检查优惠券状态:通过
CouponSettlementDO
查询当前用户的优惠券是否已经有结算记录。-
状态为
0
表示“锁定中”,状态为2
表示“已使用”。
-
-
避免重复使用:如果查询结果不为空,说明优惠券正在使用或已使用,抛出异常提示“优惠券已使用”。
3. 用户优惠券的有效性和状态
代码如下所示:
UserCouponDO userCouponDO = userCouponMapper.selectOne(Wrappers.lambdaQuery(UserCouponDO.class)
.eq(UserCouponDO::getId, requestParam.getCouponId())
.eq(UserCouponDO::getUserId, Long.parseLong(UserContext.getUserId())));
if (Objects.isNull(userCouponDO)) {
throw new ClientException("优惠券不存在");
}
if (userCouponDO.getValidEndTime().before(new Date())) {
throw new ClientException("优惠券已过期");
}
if (userCouponDO.getStatus() != 0) {
throw new ClientException("优惠券使用状态异常");
}
-
检查优惠券是否存在:根据用户 ID 和优惠券 ID 查询用户的优惠券数据,验证优惠券的存在性。
-
检查优惠券有效期:判断优惠券是否过期,如果过期则抛出异常。
-
检查优惠券状态:验证优惠券是否处于“未使用”状态(
status = 0
),如果不是,抛出“优惠券使用状态异常”。
4. 获取优惠券模板和消费规则,计算折扣金额
-
查询优惠券模板:根据优惠券模板 ID 和店铺编号(
shopNumber
)查询优惠券模板信息。
-
解析优惠券的消费规则:使用
JSONObject
解析消费规则(例如满减条件、折扣比例等)。
5. 根据不同的优惠券类型计算折扣金额
商品专属优惠券:如果 couponTemplate.getTarget()
为 0
,表示优惠券是商品专属券。
-
检查商品编号是否匹配,并计算商品的折扣金额。
-
如果商品金额和折扣金额不一致,则抛出异常。
店铺专属优惠券:如果 couponTemplate.getTarget()
为 1
,表示优惠券是店铺专属券。
-
检查店铺编号是否匹配,并根据优惠券的类型(立减、满减、折扣)计算折扣金额。
计算折扣后金额并检查,代码如下所示:
BigDecimal actualPayableAmount = requestParam.getOrderAmount().subtract(discountAmount);
if (actualPayableAmount.compareTo(requestParam.getPayableAmount()) != 0) {
throw new ClientException("折扣后金额不一致");
}
-
计算实际应付金额:使用订单金额减去折扣金额来计算实际应付金额,并与请求参数中的
payableAmount
进行比较。
-
验证一致性:如果计算的金额和请求金额不一致,抛出异常。
6. 创建优惠券结算单,并更新优惠券状态
使用 Spring 的 TransactionTemplate
控制事务范围,确保在同一个事务中创建结算单和更新用户优惠券状态。
结算单状态:
-
0:锁定
-
1:已取消
-
2:已支付
-
3:已退款
代码如下所示:
transactionTemplate.executeWithoutResult(status -> {
try {
// 创建优惠券结算单记录
CouponSettlementDO couponSettlementDO = CouponSettlementDO.builder()
.orderId(requestParam.getOrderId())
.couponId(requestParam.getCouponId())
.userId(Long.parseLong(UserContext.getUserId()))
.status(0) // 状态 0 表示“锁定中”
.build();
couponSettlementMapper.insert(couponSettlementDO);
// 变更用户优惠券状态
LambdaUpdateWrapper<UserCouponDO> userCouponUpdateWrapper = Wrappers.lambdaUpdate(UserCouponDO.class)
.eq(UserCouponDO::getId, requestParam.getCouponId())
.eq(UserCouponDO::getUserId, Long.parseLong(UserContext.getUserId()))
.eq(UserCouponDO::getStatus, UserCouponStatusEnum.UNUSED.getCode());
UserCouponDO updateUserCouponDO = UserCouponDO.builder()
.status(UserCouponStatusEnum.LOCKING.getCode()) // 将状态更新为“锁定中”
.build();
userCouponMapper.update(updateUserCouponDO, userCouponUpdateWrapper);
} catch (Exception ex) {
log.error("创建优惠券结算单失败", ex);
status.setRollbackOnly(); // 事务回滚
throw ex;
}
});
-
创建结算单记录:在数据库中插入一条优惠券结算单记录。
-
更新优惠券状态:将用户的优惠券状态从“未使用”更新为“锁定中”。
-
事务控制:确保以上操作在同一个事务中执行,如果发生异常,则进行事务回滚。
7. 删除缓存中的用户优惠券数据
从 Redis 中删除该优惠券,防止用户再次看到并使用。代码如下所示:
String userCouponItemCacheKey = StrUtil.builder()
.append(userCouponDO.getCouponTemplateId())
.append("_")
.append(userCouponDO.getId())
.toString();
stringRedisTemplate.opsForZSet().remove(String.format(USER_COUPON_TEMPLATE_LIST_KEY, UserContext.getUserId()), userCouponItemCacheKey);
流程图:
核销优惠券
在优惠券结算过程中,核销优惠券结算单,并且将对应的优惠券状态从“锁定中”变更为“已使用”,保证结算单和用户优惠券状态的一致性。
场景:
在待支付界面确认支付后,优惠券需要进行核销业务,执行完成后,优惠券状态从锁定更改为已使用。最后前端显示支付成功提醒。
- 优惠券结算单状态从”锁定中“变更为“已支付”。
- 用户优惠券状态从“锁定中”变更为“已使用”
通过获取分布式锁(Redisson
)来确保同一优惠券结算单在多线程或并发操作时不被重复核销,并使用编程式事务控制(TransactionTemplate
)来确保数据操作的原子性。
1. 获取分布式锁防并发
使用 Redisson
创建一个基于 Redis
的分布式锁 RLock
,如果当前结算单已经在被其他线程或其他操作进行核销,锁获取失败(tryLock
返回 false
),则抛出异常,提示“正在核销优惠券结算单,请稍候再试”。
代码如下所示:
RLock lock = redissonClient.getLock(String.format(EngineRedisConstant.LOCK_COUPON_SETTLEMENT_KEY, requestParam.getCouponId()));
boolean tryLock = lock.tryLock();
if (!tryLock) {
throw new ClientException("正在核销优惠券结算单,请稍候再试");
}
2. 修改优惠券结算单,并更新优惠券状态
使用 Spring 的 TransactionTemplate
来手动控制事务,确保操作的原子性和一致性。
核销优惠券结算单,更新状态为“已支付”,状态 0
表示优惠券结算单处于“锁定中”,防止重复核销已支付的结算单。将 CouponSettlementDO
对象的状态更新为 2
(表示“已支付”),并执行更新操作。如果 update
操作返回结果为 0
(表示未更新任何记录),则抛出异常。
将用户优惠券的状态更新为 USED
(表示“已使用”),更新后检查是否成功,如果失败,记录日志并抛出异常。
代码如下所示:
// 通过编程式事务减小事务范围
transactionTemplate.executeWithoutResult(status -> {
try {
// 变更优惠券结算单状态为已支付
LambdaUpdateWrapper<CouponSettlementDO> couponSettlementUpdateWrapper = Wrappers.lambdaUpdate(CouponSettlementDO.class)
.eq(CouponSettlementDO::getCouponId, requestParam.getCouponId())
.eq(CouponSettlementDO::getUserId, Long.parseLong(UserContext.getUserId()))
.eq(CouponSettlementDO::getStatus, 0);
CouponSettlementDO couponSettlementDO = CouponSettlementDO.builder()
.status(2)
.build();
int couponSettlementUpdated = couponSettlementMapper.update(couponSettlementDO, couponSettlementUpdateWrapper);
if (!SqlHelper.retBool(couponSettlementUpdated)) {
log.error("核销优惠券结算单异常,请求参数:{}", com.alibaba.fastjson.JSON.toJSONString(requestParam));
throw new ServiceException("核销优惠券结算单异常");
}
// 变更用户优惠券状态
LambdaUpdateWrapper<UserCouponDO> userCouponUpdateWrapper = Wrappers.lambdaUpdate(UserCouponDO.class)
.eq(UserCouponDO::getId, requestParam.getCouponId())
.eq(UserCouponDO::getUserId, Long.parseLong(UserContext.getUserId()))
.eq(UserCouponDO::getStatus, UserCouponStatusEnum.LOCKING.getCode());
UserCouponDO userCouponDO = UserCouponDO.builder()
.status(UserCouponStatusEnum.USED.getCode())
.build();
int userCouponUpdated = userCouponMapper.update(userCouponDO, userCouponUpdateWrapper);
if (!SqlHelper.retBool(userCouponUpdated)) {
log.error("修改用户优惠券记录状态已使用异常,请求参数:{}", com.alibaba.fastjson.JSON.toJSONString(requestParam));
throw new ServiceException("修改用户优惠券记录状态异常");
}
} catch (Exception ex) {
log.error("核销优惠券结算单失败", ex);
status.setRollbackOnly();
throw ex;
} finally {
lock.unlock();
}
});
流程图:
-
获取分布式锁:防止并发情况下同一优惠券结算单被重复核销。
-
事务管理:通过
TransactionTemplate
控制结算单状态和用户优惠券状态的原子性变更。
-
核销优惠券结算单:将优惠券结算单的状态更新为“已支付”。
-
更新用户优惠券状态:将用户优惠券状态从“锁定中”更新为“已使用”。
-
异常处理和日志记录:在任何操作出现异常时,进行事务回滚,并记录详细的异常信息。
退款优惠券
将用户已经使用的优惠券恢复为“未使用”状态,并将对应的优惠券结算单状态变更为“已退款”。在优惠券状态变更完成后,还将优惠券重新放回 Redis 缓存中,便于用户后续继续使用该优惠券。
场景:
支付成功后,用户可以进行退款操作,点击确认退款按钮后入参传给接口执行业务逻辑,
- 优惠券结算单状态从”已支付“变更为“已退款”。
- 用户优惠券状态从“已使用”变更为“未使用”
1. 获取分布式锁防止并发
如果当前优惠券已经在被其他线程或操作进行退款处理,锁获取失败(tryLock
返回 false
),则抛出异常,提示“正在执行优惠券退款,请稍候再试”。
代码如下所示:
RLock lock = redissonClient.getLock(String.format(EngineRedisConstant.LOCK_COUPON_SETTLEMENT_KEY, requestParam.getCouponId()));
boolean tryLock = lock.tryLock();
if (!tryLock) {
throw new ClientException("正在执行优惠券退款,请稍候再试");
}
2. 更新结算单和优惠券状态
使用 Spring 的 TransactionTemplate
来手动控制事务,确保操作的原子性和一致性。
将 CouponSettlementDO
对象的状态更新为 3
(“已退款”),并执行更新操作。如果 update
操作返回结果为 0
(表示未更新任何记录),则抛出异常。
将用户优惠券的状态更新为 UNUSED
(“未使用”),检查更新是否成功,如果失败,记录日志并抛出异常。
代码如下所示:
// 通过编程式事务减小事务范围
transactionTemplate.executeWithoutResult(status -> {
try {
// 变更优惠券结算单状态为已退款
LambdaUpdateWrapper<CouponSettlementDO> couponSettlementUpdateWrapper = Wrappers.lambdaUpdate(CouponSettlementDO.class)
.eq(CouponSettlementDO::getCouponId, requestParam.getCouponId())
.eq(CouponSettlementDO::getUserId, Long.parseLong(UserContext.getUserId()))
.eq(CouponSettlementDO::getStatus, 2);
CouponSettlementDO couponSettlementDO = CouponSettlementDO.builder()
.status(3)
.build();
int couponSettlementUpdated = couponSettlementMapper.update(couponSettlementDO, couponSettlementUpdateWrapper);
if (!SqlHelper.retBool(couponSettlementUpdated)) {
log.error("优惠券结算单退款异常,请求参数:{}", com.alibaba.fastjson.JSON.toJSONString(requestParam));
throw new ServiceException("核销优惠券结算单异常");
}
// 变更用户优惠券状态
LambdaUpdateWrapper<UserCouponDO> userCouponUpdateWrapper = Wrappers.lambdaUpdate(UserCouponDO.class)
.eq(UserCouponDO::getId, requestParam.getCouponId())
.eq(UserCouponDO::getUserId, Long.parseLong(UserContext.getUserId()))
.eq(UserCouponDO::getStatus, UserCouponStatusEnum.USED.getCode());
UserCouponDO userCouponDO = UserCouponDO.builder()
.status(UserCouponStatusEnum.UNUSED.getCode())
.build();
int userCouponUpdated = userCouponMapper.update(userCouponDO, userCouponUpdateWrapper);
if (!SqlHelper.retBool(userCouponUpdated)) {
log.error("修改用户优惠券记录状态未使用异常,请求参数:{}", com.alibaba.fastjson.JSON.toJSONString(requestParam));
throw new ServiceException("修改用户优惠券记录状态异常");
}
} catch (Exception ex) {
log.error("执行优惠券结算单退款失败", ex);
status.setRollbackOnly();
throw ex;
}
});
查询用户优惠券记录,构建缓存 Key,使用 ZSet
数据结构将优惠券信息放回 Redis 中,确保用户可以在后续继续使用该优惠券。
代码如下所示:
// 查询出来优惠券再放回缓存
UserCouponDO userCouponDO = userCouponMapper.selectOne(Wrappers.lambdaQuery(UserCouponDO.class)
.eq(UserCouponDO::getUserId, Long.parseLong(UserContext.getUserId()))
.eq(UserCouponDO::getId, requestParam.getCouponId())
);
String userCouponItemCacheKey = StrUtil.builder()
.append(userCouponDO.getCouponTemplateId())
.append("_")
.append(userCouponDO.getId())
.toString();
stringRedisTemplate.opsForZSet().add(String.format(USER_COUPON_TEMPLATE_LIST_KEY, UserContext.getUserId()), userCouponItemCacheKey, userCouponDO.getReceiveTime().getTime());
流程图:
代码逻辑概述:
-
获取分布式锁:通过分布式锁确保同一时间只有一个线程能够对相同的优惠券进行退款处理,防止并发操作引发数据不一致。
-
编程式事务控制:使用
TransactionTemplate
控制事务范围,保证结算单和用户优惠券状态的原子性变更。
-
更新优惠券结算单状态:将优惠券结算单状态从“已使用”变更为“已退款”。
-
恢复用户优惠券状态:将用户优惠券状态从“已使用”恢复为“未使用”。
-
将优惠券放回 Redis 缓存:查询用户优惠券记录,将优惠券重新放回 Redis 中,便于用户后续继续使用。
-
异常处理和锁释放:处理过程中出现任何异常时,记录日志并回滚事务,同时保证分布式锁能够被正确释放。
部分图片和内容引用知识星球《拿个offer》牛券项目-https://nageoffer.com/onecoupon/