Skip to main content

12小节:开发查询/取消优惠券预约提醒

作者:程序员马丁

在线博客:https://nageoffer.com

note

热门项目实战社群,收获国内众多知名公司面试青睐,近千名同学面试成功!助力你在校招或社招上拿个offer。

用户查询/取消优惠券预约提醒功能,元数据信息:

©版权所有 - 拿个offer-开源&项目实战星球专属学习项目,依据《中华人民共和国著作权法实施条例》《知识星球产权保护》,严禁未经本项目原作者明确书面授权擅自分享至 GitHub、Gitee 等任何开放平台。违者将面临法律追究。


内容摘要:本章节将继续围绕用户优惠券预约提醒功能的开发展开,支持用户取消预约提醒。取消后,RocketMQ 消费者可以感知到取消操作,不再发送通知。同时,通过各种位运算,系统能够解析出用户的优惠券模板预约列表,让用户方便地管理其预约信息。

课程目录如下所示:

  • 业务背景
  • Git 分支
  • 取消预约提醒
  • 查询预约提醒列表
  • 文末总结

业务背景

当用户预约了一个或多个优惠券抢购提醒后,如果不再需要提醒,可以取消预约通知。不过,虽然用户可以取消提醒,但已经发送到 MQ 的消息不会被撤回,消费者在时间点到达时依然会收到消息。此时,我们不应该再向用户发出提醒。因此,我们需要开发一个方法来判断用户是否取消了预约。同时,还需支持用户查询其已预约的优惠券列表信息,以便用户管理其预约状态。

Git 分支

20240920_dev_coupon-remind-v3_rocketmq-bitmap_youya

本章节预约提醒优惠券核心代码由优雅同学贡献,感谢优雅提供的优秀代码设计。

取消预约提醒

1. 取消用户预约优惠券提醒

有这样一种情况,用户预约了优惠券提醒后不想再预约场景,那我们就需要把这个提醒删除。

代码如下所示:

@Service
@RequiredArgsConstructor
public class CouponTemplateServiceRemindImpl extends ServiceImpl<CouponTemplateRemindMapper, CouponTemplateRemindDO> implements CouponTemplateRemindService {
// ......

@Override
public void cancelCouponRemind(CouponTemplateRemindCancelReqDTO requestParam) {
// 验证优惠券是否存在,避免缓存穿透问题并获取优惠券开抢时间
CouponTemplateQueryRespDTO couponTemplate = couponTemplateService
.findCouponTemplate(new CouponTemplateQueryReqDTO(requestParam.getShopNumber(), requestParam.getCouponTemplateId()));
if (couponTemplate.getValidStartTime().before(new Date())) {
throw new ClientException("无法取消已开始领取的优惠券预约");
}

LambdaQueryWrapper<CouponTemplateRemindDO> queryWrapper = Wrappers.lambdaQuery(CouponTemplateRemindDO.class)
.eq(CouponTemplateRemindDO::getUserId, UserContext.getUserId())
.eq(CouponTemplateRemindDO::getCouponTemplateId, requestParam.getCouponTemplateId());
CouponTemplateRemindDO couponTemplateRemindDO = couponTemplateRemindMapper.selectOne(queryWrapper);
if (couponTemplateRemindDO == null) {
throw new ClientException("优惠券模板预约信息不存在");
}
// 计算 BitMap 信息
Long bitMap = CouponTemplateRemindUtil.calculateBitMap(requestParam.getRemindTime(), requestParam.getType());
if ((bitMap & couponTemplateRemindDO.getInformation()) == 0L) {
throw new ClientException("您没有预约该时间点的提醒");
}
bitMap ^= couponTemplateRemindDO.getInformation();
queryWrapper.eq(CouponTemplateRemindDO::getInformation, couponTemplateRemindDO.getInformation());
if (bitMap.equals(0L)) {
// 如果新 BitMap 信息是 0,说明已经没有预约提醒了,可以直接删除
if (couponTemplateRemindMapper.delete(queryWrapper) == 0) {
// MySQL 乐观锁进行删除,如果删除失败,说明用户可能同时正在进行删除、新增提醒操作
throw new ClientException("取消提醒失败,请刷新页面后重试");
}
} else {
// 虽然删除了这个预约提醒,但还有其它提醒,那就更新数据库
couponTemplateRemindDO.setInformation(bitMap);
if (couponTemplateRemindMapper.update(couponTemplateRemindDO, queryWrapper) == 0) {
// MySQL 乐观锁进行更新,如果更新失败,说明用户可能同时正在进行删除、新增提醒操作
throw new ClientException("取消提醒失败,请刷新页面后重试");
}
}
}
}

业务流程如下所示:

  1. 验证优惠券:根据查询优惠券模板方法避免缓存击穿和穿透,并且获取到优惠券模板详情后判断优惠券是否已开始领取,如果是的话抛出异常。
  2. 查询预约提醒记录:系统使用 userIdcouponTemplateId 在数据库中查找对应的提醒记录。如果找不到该记录,则抛出异常,提示“优惠券模板预约信息不存在”。如果找到记录,继续执行后续操作。
  3. 计算用户想要取消的提醒对应的 BitMap:使用 CouponTemplateRemindUtil.calculateBitMap() 方法,根据用户的 remindTimetype 计算出该提醒对应的 bitMap(位图)。
  4. 检查用户是否已经预约该提醒:通过 bitMap & couponTemplateRemindDO.getInformation() 检查数据库中的预约提醒信息是否包含该时间点的提醒。如果结果为 0,说明用户没有预约该时间点的提醒,抛出异常提示“您没有预约该时间点的提醒”。
  5. 更新 BitMap 信息:使用异或操作 bitMap ^= couponTemplateRemindDO.getInformation() 取消该时间点的提醒位。此时,bitMap 会去除用户想要取消的提醒对应的位。
  6. 判断更新后的 BitMap:如果 bitMap0,说明用户取消了所有提醒,删除该预约提醒记录。如果 bitMap 不为 0:说明用户取消了部分提醒,仍有其他提醒存在。系统更新数据库中的 information 字段,保存剩余的提醒信息。

我们通过创建优惠券预约提醒功能,添加一个 0 类型,提前 15 分钟的预约提醒:

{
"couponTemplateId": "1836986189085790209",
"shopNumber": "1810714735922956666",
"type": 0,
"remindTime": 15
}

需要注意,在后台服务创建优惠券模板时,validStartTime 不能小于当前时间。创建完成后,模板信息会被复制到创建优惠券预约提醒的入参中,并在 t_coupon_template_remind 表中生成一条预约提醒记录。

当用户通过取消预约提醒接口进行操作时,传入的参数依然是上述的模板信息。执行取消操作后,查看数据库时,可以发现对应的预约提醒记录会被逐步修改。如果用户创建了多个时间段的提醒,每次取消会修改记录中的提醒信息,直到最后一个预约时间被取消,才最终删除该记录。

2. 消息队列判断是否已取消预约

虽然用户可以取消提醒,但已经发送到 MQ 的消息不会被撤回,消费者在时间点到达时依然会收到消息。这时我们不应该再向用户发出提醒。所以,我们需要开发一个方法,那就是判断用户是否取消了预约。

代码如下所示:

@Service
@RequiredArgsConstructor
public class CouponTemplateServiceRemindImpl extends ServiceImpl<CouponTemplateRemindMapper, CouponTemplateRemindDO> implements CouponTemplateRemindService {

// ......

@Override
public boolean isCancelRemind(CouponTemplateRemindDTO requestParam) {
LambdaQueryWrapper<CouponTemplateRemindDO> queryWrapper = Wrappers.lambdaQuery(CouponTemplateRemindDO.class)
.eq(CouponTemplateRemindDO::getUserId, requestParam.getUserId())
.eq(CouponTemplateRemindDO::getCouponTemplateId, requestParam.getCouponTemplateId());
CouponTemplateRemindDO couponTemplateRemindDO = couponTemplateRemindMapper.selectOne(queryWrapper);
if (couponTemplateRemindDO == null) {
// 数据库中没该条预约提醒,说明被取消
return true;
}

// 即使存在数据,也要检查该类型的该时间点是否有提醒
Long information = couponTemplateRemindDO.getInformation();
Long bitMap = CouponTemplateRemindUtil.calculateBitMap(requestParam.getRemindTime(), requestParam.getType());

// 按位与等于 0 说明用户取消了预约
return (bitMap & information) == 0L;
}
}

通过该方法,我们在消息队列的消费者执行前加入判断,如果已取消则打印一行日志即可。

代码如下所示:

@Slf4j
@Component
@RequiredArgsConstructor
public class CouponTemplateRemindExecutor {

private final CouponTemplateRemindService couponTemplateRemindService;

/**
* 执行提醒
*
* @param couponTemplateRemindDTO 用户预约提醒请求信息
*/
public void executeRemindCouponTemplate(CouponTemplateRemindDTO couponTemplateRemindDTO) {
// 用户没取消预约,则发出提醒
if (couponTemplateRemindService.isCancelRemind(couponTemplateRemindDTO)) {
log.info("用户已取消优惠券预约提醒,参数:{}", JSON.toJSONString(couponTemplateRemindDTO));
return;
}

// ......
}
}

3. 布隆过滤器优化性能

是否需要每次消息消费时都查询数据库来检查用户是否取消了提醒呢?如果对每条消息都进行数据库查询,消息消费的效率就会受到数据库的瓶颈影响。

为了解决这个问题,可以使用布隆过滤器进行初步判断。当用户取消提醒时,我们根据(用户ID、券ID、提醒时间点、提醒类型)的四元组计算哈希值,并将其存入布隆过滤器。消息消费时,如果布隆过滤器中不存在该哈希值,则说明用户没有取消提醒,可以直接发送提醒。如果存在该哈希值,则有两种可能:

  1. 用户确实取消了提醒。
  2. 布隆过滤器发生了误判。

由于存在误判的可能性,我们必须进一步查询数据库,确认用户是否真的取消了提醒。不过这种情况很少出现,大部分请求已经被布隆过滤器过滤,剩下需要查询数据库的请求量很小。

解锁付费内容,👉 戳