写在前面
使用LocalDateTime的同学需要注意下,这东西的plusMonth可能会有点点超出你的认知,如果不慎掉坑里,希望这篇笔记可以给你提供思路
业务背景
此业务场景非常简单,自动续费业务,需要在用户会员到期前24小时执行扣款服务,为用户开通续费会员
踩坑描述
这是一个组合坑,续费job无任何问题,会员管理端也没有任何问题,但是他们组合在一起,就是问题。
某日自动续费用户突然为0,相关负责同事让我排查问题,经排查发现,续费日后一天权益到期的用户数为0,因此导致不能扣费,具体排查过程简介如下
业务实现逻辑
续费端
定时任务
使用 elastic-job 配置定时器,在每日固定时间7点执行扣费任务
业务步骤
- 扫描全部具有签约协议的用户
- 查询每个用户的会员到期时间,与当前时间做对比,判断是否在24小时内,如果在24小时内,则进行扣费
- 扣费记录入库,预订单入库,预开通权限入库,通知扣款服务商进行扣费,等待异步扣费结果通知
续费端业务逻辑并不复杂,按照每天扫描一次的方式执行,有到期的用户就进行续费,
会员管理端(踩坑点)
开通权限流程
- 接受异步扣款通知
- 验证通知中的签名等基本安全认证信息
- 根据通知中的订单记录id,查找订单和预开通权限
- 根据预开通权限的内容,为当前订单用户开通相关权益
- 更新记录,订单完毕,续费表记录完毕
踩坑点细节
主要问题在开通权益这里,权益表存储数据包括了续费周期(1),和续费单位(MONTH),因此需要为用户开通一个月权益,考虑到用户权益可能未到期,因此需要把用户的最大到期时间,和当前时间做对比,取最大值,然后根据最大值开通一个月会员即可,伪代码如下
// 查询用户到期时间
LocalDateTime dbEndTime = fromDbEndTime(userId);
// 当前时间
LocalDateTime now = LocalDateTime.now();
// 二者取最大值作为权限的开始时间
LocalDateTime startTime = maxTime(now,dbEndTime);
// 计算用户应该开通的最大到期时间(异常点在这里)
LocalDateTime endTime = startTime.plusMonths(duration).minusDays(1).toLocalDate().atTime(LocalTime.MAX)
// 数据入库
save(userId,startTime,endTime);
异常说明
LocalDateTime的plusMonths这个api,他会计算本月延后一个月的天数,是否在下个月存在,如果不存在,他会保留下个月的最大值,比如
你是在1.31日任意时间点开通权限,向后延长一个月,保留时间为2.27 23:59:59
如果你在1.30 开通,也是这个时间
也就是说:大月份(31天的月份)衔接小月份(不足31天的月份),对于自动续费业务来说,会在某一天的用户总量会翻倍
你以为这就完了?没,小月份衔接大月份,某一天会没用户
比如:
4.30日开通权益的用户,到期时间是 5.29. 23:59:59
5.1日开通权益的用户,到期时间是 5.31 23:59:59
看出问题没?5.30那天,没人能到期,因此5.29日的扣费程序就不会有任何人被扣款
查看LocalDateTime的plusMonths 说明,并没有说明小月份衔接大月份的问题
总结
这个不能算JDK的bug,没有踩过这个坑的人,可能也想不到这个问题,如果使用这个api,你的程序大概也会这样,希望这个笔记能对你有帮助,在有人找你排查问题的时候,能快速定位并解决问题