构建高灵活度的商品权益订阅与计费系统
前言: 在SaaS或内容付费系统的演进过程中,计费模块往往是业务复杂度的“重灾区”。从最初简单的“付钱 -> 改状态”,到后期复杂的“混合计费”、“权益组合”、“自动续费”与“精细化退款”,传统的硬编码模式很难支撑业务的快速迭代。
当然,这大多也是被产品需求朝令夕改逼出来的。鼠鼠我啊,实在不想他们随口改下需求,我就得苦哈哈地去把代码改来改去。所以,对于这种肉眼可见、百分百要经常变化的需求,我决定直接一步到位,彻底解耦!做一套灵活的配置后台,让他们自己去玩排列组合吧,哈哈哈哈!
这也是我第一次设计这种相对复杂的模块,过程中借助了哈基米(Gemini 2.5 Pro)的帮助,虽然反复修改确认了好几版,但想必定有不足之处,还请各位大佬指正。
1. 技术选型与市场调研 (Build vs. Buy)
在决定自研之前,我们首先调研了市场上成熟的订阅计费解决方案。
1.1 现有开源/商业方案调研
| 方案 | 类型 | 优势 | 劣势 |
|---|---|---|---|
| Kill Bill | 开源 (Java) | 功能极强,支持极其复杂的计费场景;生态完善。 | 太重了。架构庞大,部署维护成本高,学习曲线陡峭。对于我们这种中小型 SaaS 来说简直是“杀鸡用牛刀”。 |
| Lago | 开源 (Ruby/Go) | 专为“计量计费”设计,API 友好,界面现代。 | 技术栈不匹配(我们是纯 Java/Spring 体系),且对我们需要的“混合权益组合”支持不够灵活。 |
| Stripe Billing | 商业 SaaS | 体验极佳,文档完美,开箱即用。 | 贵,且数据不在自己手里。国内支付对接(微信/支付宝)不如自研灵活。 |
1.2 为什么选择自研?
经过对比,我们发现通用方案存在两个核心矛盾:
- 复杂度过剩:我们要解决的是“商品-权益”的灵活组合,而不是要做一个通用的全球税务计算引擎。
- 集成成本:为了接入第三方计费,往往需要改造现有的用户体系和订单体系,这个工作量不比自研少。
因此,我们决定基于 Spring Boot 生态,量身定制一套**“轻量级、高扩展”**的订阅计费模块。
1.3 技术栈一览
核心技术栈:
- 框架:Spring Boot 3.x, MyBatis-Plus
- 数据库:MySQL 8.0 (利用 JSON 字段存储快照,利用乐观锁处理并发)
- 切面编程:Spring AOP + SpEL (实现无感鉴权)
- 工具链:Lombok, Hutool
2. 业务背景与设计挑战
在项目初期,需求往往很简单:“用户购买月卡,获得30天会员”。但随着运营策略的精细化,我们面临了以下典型挑战:
2.1 复杂的销售组合(Bundle Sales)
运营人员不再满足于单一维度的销售。他们需要灵活的“打包”策略,例如:
- 买一送一:购买“高级会员(30天)”,赠送“AI模拟面试券(5次)”。
- 混合计费:一个商品同时包含“时间维度”的权益(如全站去广告)和“数量维度”的权益(如简历下载次数)。
如果采用传统的“商品表增加字段”的方式(例如在 products 表里加 vip_days、download_counts),一旦新增一种权益类型(如“查看联系方式”),就需要修改表结构和业务代码,扩展性极差。
2.2 多样的生命周期管理
系统需要同时支持三种截然不同的权益形态,且能够在一个订单中混合处理:
- 订阅制(Subscription):按时间付费,支持多次购买自动叠加有效期(如 Netflix、Spotify)。
- 限时资源包(Time-limited Usage):在特定时间内有效,过期作废(如“7天内有效的10次简历诊断卡”)。
- 永久点数(Permanent Usage):无时间限制,用完为止(如“永久简历下载券”)。
2.3 核心设计目标
基于上述痛点,我们确定了系统的核心设计目标:
- 完全解耦:将“售卖单元(Product)”与“履约单元(Entitlement)”彻底分离。
- 高扩展性:新增权益类型只需配置数据,无需修改代码。
- 数据一致性:确保支付、发货、消耗、退款全链路的资金与权益账本绝对准确。
3. 核心领域模型设计 (The Model)
为了实现上述目标,我们设计了一个以 “授权规则(Product Grant)” 为核心的领域模型。
3.1 实体关系图 (ER Diagram)
我们通过引入中间层,打破了商品与权益的强绑定关系:

3.2 定义层:原子化的权益
我们首先将系统中的所有能力抽象为原子权益,存储在 t_entitlement_definitions 表中。
- 设计意图:这是所有计费逻辑的基石。无论业务如何变,权益的定义必须是稳定的。
- 关键字段:
type_code:业务代码中引用的唯一标识(如RESUME_CREATE,MOCK_INTERVIEW)。
3.3 关联层:灵活的授权规则 (The Magic Glue)
t_product_grants 是整个模型中最核心的部分。它通过 duration_days 和 grant_count 两个字段的排列组合,覆盖了所有的计费形态。
| 业务形态 | duration_days | grant_count | 含义 | 存储策略 |
|---|---|---|---|---|
| 订阅制 | > 0 | NULL / <=0 | 有效期内无限次使用 | 聚合存储 (Subscription) |
| 限时资源包 | > 0 | > 0 | N天内可用M次 | 独立流水 (Order Entitlement) |
| 永久点数 | NULL / 0 | > 0 | 永久有效,限M次 | 独立流水 (Order Entitlement) |
此外,我们在该表中引入了 price_weight(价格权重)字段。
- 痛点解决:当一个 100 元的商品包含了“会员”和“积分”两种权益时,如果用户使用了积分但想退款会员,该退多少钱?
- 方案:通过权重配置(如会员权重80,积分权重20),系统可以自动计算出每个权益的“隐含价值”,从而实现精确的按比例退款。
3.4 实例层:用户权益的两种存储形态
为了应对“有效期叠加”和“独立过期”的矛盾,我们将用户的权益数据拆分为两张表存储。
A. 聚合型存储:t_user_subscription
专门用于处理纯时间维度的权益(如会员有效期)。
create table t_user_subscription (
user_id bigint unsigned not null,
entitlement_id bigint unsigned not null,
expiry_time timestamp not null comment '聚合后的总服务截止日期',
unique (user_id, entitlement_id) -- 核心约束:同一种订阅权益,用户只有一条记录
);
- 设计逻辑:当用户多次购买会员时,我们不需要保留多条记录,只需要不断推后
expiry_time。这极大地简化了权限校验逻辑——只需判断now() < expiry_time。
B. 流水型存储:t_user_order_entitlement
专门用于处理计次维度(无论是否限时)的权益。
create table t_user_order_entitlement (
id bigint unsigned auto_increment primary key,
order_id bigint unsigned not null comment '溯源:来自哪个订单',
entitlement_id bigint unsigned not null,
total_counts int comment '初始总次数',
remaining_counts int comment '当前剩余次数',
expiry_time timestamp null comment '独立过期时间',
status varchar(20) default 'ACTIVE'
);
- 设计逻辑:
- 独立性:用户今天买了一个“7天包”,明天又买了一个“3天包”。这两个包的过期时间不同,不能聚合,必须作为两条独立的资产记录存在。
- 溯源能力:字段
order_id保证了每笔资产都能追溯到具体的交易,这是实现“精细化退款”和“部分退款”的基础。 - 扣减原理:在消费时,系统会基于 FIFO(先进先出)原则,动态选择最合适的一条记录进行扣减(后续章节将详细展开该算法)。
4. 核心业务逻辑实现 (The Core Service)
我们的核心服务类 EntitlementServiceImpl 承担了这两个职责。为了应对复杂的业务形态,我们分别采用了 工厂模式(Factory Pattern) 和 优先级链(Priority Chain) 的设计思想。
**4.1 权益发放策略:**基于规则的动态分发
支付成功是权益生命周期的起点。系统需要扫描该商品关联的所有 ProductGrant 记录,并根据其配置(duration_days 和 grant_count 的组合)动态选择不同的发放策略。
这是一个典型的基于规则的逻辑分发:
/**
* 权益发放核心入口
* 业务场景:监听支付成功事件后调用
*/
@Transactional
public void grantEntitlement(Long orderId) {
// 1. 基础校验:确保订单已支付且未发放过
Order order = orderMapper.selectById(orderId);
if (order == null || !ORDER_STATUS_PAID.equals(order.getStatus())) {
return;
}
// 2. 获取该商品关联的所有“授权规则”
// 这一步体现了解耦的优势:代码不需要知道商品里有什么,全靠查表
List<ProductGrant> grants = productGrantMapper.selectList(
new LambdaQueryWrapper<ProductGrant>().eq(ProductGrant::getProductId, order.getProductId())
);
// 3. 遍历规则,按类型分发
for (ProductGrant grant : grants) {
// 策略A:订阅制(有天数,无次数限制) -> 类似于 "月卡"
if (grant.getDurationDays() != null && grant.getDurationDays() > 0 && (grant.getGrantCount() == null || grant.getGrantCount() < 0)) {
handleSubscriptionGrant(order, grant);
}
// 策略B:限时资源包(有天数,有次数) -> 类似于 "7天有效10次卡"
else if (grant.getDurationDays() != null && grant.getDurationDays() > 0 && grant.getGrantCount() != null && grant.getGrantCount() > 0) {
handleTimeLimitedCountGrant(order, grant);
}
// 策略C:永久资源包(无天数,有次数) -> 类似于 "永久点数"
else if ((grant.getDurationDays() == null || grant.getDurationDays() == 0) && grant.getGrantCount() != null && grant.getGrantCount() > 0) {
handlePermanentCountGrant(order, grant);
}
}
// 4. 更新订单状态为“已发放”
order.setStatus(ORDER_STATUS_GRANTED);
orderMapper.updateById(order);
}
三种发放逻辑的差异
- 订阅制发放 (
handleSubscriptionGrant):- 难点:有效期叠加与溯源。
- 逻辑(双写模式):
- 记录流水:先在
t_user_order_entitlement插入一条记录。虽然订阅本身不限次,但我们需要记录该订单贡献了多少天(duration_days),这对后续的退款计算至关重要。 - 更新聚合:查询或创建
t_user_subscription聚合记录。如果已过期,新有效期 = Now + Days;如果未过期,新有效期 = CurrentExpiry + Days。
- 记录流水:先在
- 代码细节:利用数据库原子更新保证聚合表有效期的准确性,同时保留流水表作为“账本明细”。
- 限时资源包发放 (
handleTimeLimitedCountGrant):- 难点:独立生命周期。
- 逻辑:直接插入一条
t_user_order_entitlement记录,其expiry_time计算方式为Now + Days。 - 关键点:即使是购买同一个商品,每次购买生成的记录也是独立的,互不干扰。
- 永久资源包发放 (
handlePermanentCountGrant):- 逻辑:插入
t_user_order_entitlement记录,但expiry_time置为NULL。
- 逻辑:插入
4.2 多级权限扣减链:用户利益最大化
当用户点击“生成简历”按钮时,系统面临一个抉择:用户手里可能有“会员月卡”,也有“赠送的免费次数”,还有“刚买的加油包”。到底该扣哪一个?
如果乱扣(例如先扣了永久点数,而让限时点数过期),用户一定会投诉。因此,我们设计了一条严格的 优先级扣减链 (Priority Chain),核心原则是:优先消耗即将失效或免费的权益。
优先级链顺序
- Lv1 特殊规则 (Special Rules):业务特例,最高优先级(如:该简历之前已经付费过,再次修改免费)。
- Lv2 订阅覆盖 (Subscription):如果用户是会员且在有效期内,直接通行,不扣减任何次数。
- Lv3 限时资源包 (Time-Limited):优先消耗,且遵循 FIFO(先进先出) 原则,先扣快过期的。
- Lv4 永久资源包 (Permanent):最后兜底,遵循 Oldest First 原则,先扣买得最早的。
核心代码实现 (reserve 方法)
这是整个计费系统最核心的方法,负责“预扣减”操作。
@Transactional
public UsageLog reserve(Long userId, Long entitlementId, Long resumeId) throws EntitlementException {
// --- 优先级 1: 检查特殊规则 (Special Permission) ---
// 场景:用户修改一份已经下载过的简历,通常是免费的
if (resumeId != null) {
boolean hasSpecialPermission = resumeModificationPermissionMapper.exists(
new LambdaQueryWrapper<ResumeModificationPermission>()
.eq(ResumeModificationPermission::getResumeId, resumeId)
.eq(ResumeModificationPermission::getUserId, userId));
if (hasSpecialPermission) {
// 直接生成一条“已确认”的日志,消耗为0
return usageLogService.createLog(userId, 0L, entitlementId, 0, USAGE_LOG_STATUS_CONFIRMED, "Special permission.");
}
}
// --- 优先级 2: 检查订阅权益 (Subscription) ---
// 场景:用户开了月卡,有效期内无限次使用
UserSubscription subscription = userSubscriptionMapper.selectOne(
new LambdaQueryWrapper<UserSubscription>()
.eq(UserSubscription::getUserId, userId)
.eq(UserSubscription::getEntitlementId, entitlementId));
// 关键判断:订阅存在 且 expireTime > Now
if (subscription != null && subscription.getExpiryTime().after(Timestamp.from(Instant.now()))) {
return usageLogService.createLog(userId, 0L, entitlementId, 0, USAGE_LOG_STATUS_CONFIRMED, "Subscription.");
}
// --- 优先级 3: 预扣减限时计次权益 (Time-Limited Count) ---
// 场景:消耗“7天有效10次卡”,优先扣快过期的
UsageLog toConsume = consumeTimeLimitedCount(userId, entitlementId);
if(toConsume != null){
return toConsume;
}
// --- 优先级 4: 预扣减永久计次权益 (Permanent Count) ---
// 场景:消耗“永久点数”
toConsume = consumePermanentCount(userId, entitlementId);
if(toConsume != null){
return toConsume;
}
// --- 兜底: 没有任何可用权益 ---
throw new EntitlementException("您的相关权益不足,请购买后再试。");
}
算法细节:FIFO 扣减逻辑
在 consumeTimeLimitedCount 中,我们如何保证优先扣减“快过期”的那个包?答案全在 SQL 的 ORDER BY 里。
private UsageLog consumeTimeLimitedCount(Long userId, Long entitlementId) {
QueryWrapper<UserOrderEntitlement> query = new QueryWrapper<>();
query.lambda()
.eq(UserOrderEntitlement::getUserId, userId)
.eq(UserOrderEntitlement::getEntitlementId, entitlementId)
.eq(UserOrderEntitlement::getStatus, "ACTIVE")
.gt(UserOrderEntitlement::getRemainingCounts, 0)
.gt(UserOrderEntitlement::getExpiryTime, Timestamp.from(Instant.now())) // 必须未过期
// 核心逻辑:按过期时间升序排列,这样 list.get(0) 就是最紧迫的那条记录
.orderByAsc(UserOrderEntitlement::getExpiryTime);
List<UserOrderEntitlement> availableEntitlements = userOrderEntitlementMapper.selectList(query);
if (!CollectionUtils.isEmpty(availableEntitlements)) {
UserOrderEntitlement toConsume = availableEntitlements.get(0);
// 乐观锁/原子更新:remaining_counts = remaining_counts - 1
int updatedRows = userOrderEntitlementMapper.decrementRemainingCounts(toConsume.getId(), 1);
if (updatedRows > 0) {
// 生成 PENDING 状态的日志,记录 uoeId 以便后续回滚
return usageLogService.createLog(userId, toConsume.getOrderId(), entitlementId, 1,
USAGE_LOG_STATUS_PENDING, "uoeId:" + toConsume.getId());
}
}
return null;
}
小结
通过 工厂模式 和 优先级链,我们成功解决了两个问题:
- 进销存的“进”:无论运营配置出多么奇葩的商品组合,系统都能自动将其拆解为原子的权益记录并入库。
- 进销存的“出”:无论用户持有多少种权益,系统总能智能地找到最划算的那一种进行扣减,极大降低了客诉风险。
5. 资金安全与退款算法 (Financial Accuracy)
[!NOTE]
⚠️ 生产环境注记: 值得一提的是,下文所述的复杂退款算法,在目前我们的生产环境中主要用于 “系统试算 + 人工确认” 的半自动流程,尚未开放给用户端自动操作。 我们实现这套逻辑的核心目的,是为了验证领域模型的完备性——即证明在这套模型下,即使面对复杂的“时序叠加”和“混合计费”场景,系统依然具备精确计算剩余价值的底层能力。
[!WARNING]
💡 关键细节:交易快照 (The Snapshot)
运营人员可能会随时调整商品的权益组合或权重(例如将权重大调为 50:50)。如果不保存快照,直接查询最新的
ProductGrant表,会导致历史订单的退款金额计算错误。因此,我们在
t_orders表设计了一个snapshot字段(JSON 类型),在下单时刻将该商品当时所有的权益规则、权重完整序列化并持久化。退款计算必须且只能依赖这个快照数据。
在简单的电商系统中,退款往往只是 Status = REFUNDED。但在权益系统中,退款意味着**“权益回收”与“价值折算”**。
我们需要解决两个核心难题:
- 时序叠加难题:订阅有效期是不断叠加的,如何剥离出中间某一个订单贡献的时长?
- 组合商品难题:一个商品包含“会员”和“积分”,如何计算单个权益的退款价值?
5.1 订阅制权益退款:倒推法 (Reverse Deduction)
场景: 用户先购买了 30天会员(订单A),使用了10天后,觉得不错,又续费了 60天会员(订单B)。 此时,用户突然对订单A发起退款。
挑战: 此时用户的总过期时间是 Now + 20天(A剩余) + 60天(B) = Now + 80天。 如果直接把订单A的30天扣除,用户有效期变更为 Now + 50天。这看起来没问题? 错! 用户实际上已经消耗了订单A的10天,应该退款的是 订单A剩余的20天,而不是全额退款。
算法实现: 我们采用 “倒推法”,即:不关心过去发生了什么,只看未来还剩什么。
- 计算后续贡献:查询该订单(A)之后所有同类权益订单(B)的总贡献时长(60天)。
- 确定理论终点:
当前总过期时间 (Now+80)-后续贡献 (60)=订单A的理论结束时间 (Now+20)。 - 计算剩余价值:
理论结束时间-当前时间=订单A的真实剩余时长 (20天)。 - 计算退款比例:
20天 / 30天 = 66.67%。
// EntitlementServiceImpl.java
private void refundSubscription(UserOrderEntitlement uoe) {
// 1. 查询该订单之后购买的所有同类权益订单
List<UserOrderEntitlement> subsequentUoes = userOrderEntitlementMapper.selectList(...);
// 2. 计算后续订单贡献的总天数
int subsequentDays = subsequentUoes.stream().mapToInt(UserOrderEntitlement::getDurationDays).sum();
// 3. 获取当前聚合的总过期时间
UserSubscription subscription = userSubscriptionMapper.selectOne(...);
Timestamp totalExpiryTime = subscription.getExpiryTime();
// 4. 核心算法:倒推理论终点
// 理论终点 = 总过期时间 - 后续所有订单贡献的天数
Instant theoreticalEndpoint = totalExpiryTime.toInstant().minus(subsequentDays, ChronoUnit.DAYS);
// 5. 计算该订单的真实剩余时长
long remainingSeconds = ChronoUnit.SECONDS.between(Instant.now(), theoreticalEndpoint);
if (remainingSeconds > 0) {
// 按比例退款逻辑...
BigDecimal refundRatio = BigDecimal.valueOf(remainingSeconds)
.divide(BigDecimal.valueOf(totalSeconds), 4, RoundingMode.HALF_UP);
// 6. 权益回收:从总有效期中扣除该订单原本贡献的天数
subscription.setExpiryTime(Timestamp.from(totalExpiryTime.toInstant().minus(uoe.getDurationDays(), ChronoUnit.DAYS)));
userSubscriptionMapper.updateById(subscription);
}
}
5.2 计次/组合权益退款:加权按比例 (Weighted Pro-rata)
场景: 运营搞活动,推出了一个“面试加油包”商品(价格100元),包含:
- 权益A:7天高级会员(权重 30)
- 权益B:10次AI模拟面试(权重 70)
用户用掉了5次面试,然后要求退款。
算法实现: 这里引入了我们在模型定义提到的 price_weight(价格权重)字段。
// EntitlementServiceImpl.java
private void refundCountBased(UserOrderEntitlement uoe, Order order) {
// 1. 计算剩余次数比例 (5/10 = 0.5)
BigDecimal refundRatio = BigDecimal.valueOf(uoe.getRemainingCounts())
.divide(BigDecimal.valueOf(uoe.getTotalCounts()), 4, RoundingMode.HALF_UP);
// 2. 获取该权益在商品中的价格权重
ProductGrant grant = productGrantMapper.selectOne(...);
if (grant != null && grant.getPriceWeight() > 0) {
// 3. 计算权益单价
// 权益价格 = 订单总价(100) * (权重(70) / 100) = 70元
BigDecimal weightRatio = BigDecimal.valueOf(grant.getPriceWeight()).divide(BigDecimal.valueOf(100), ...);
BigDecimal entitlementPrice = order.getAmount().multiply(weightRatio);
// 4. 计算最终退款
// 退款 = 70元 * 0.5 = 35元
BigDecimal refundAmount = entitlementPrice.multiply(refundRatio);
}
// 5. 状态流转
uoe.setStatus("REFUNDED");
userOrderEntitlementMapper.updateById(uoe);
}
6. 开发体验与系统集成 (Developer Experience)
对于业务开发同学(比如负责写 PDF 生成的同事,也是我),他们最讨厌的就是在业务代码里写一堆 if (balance < 10) return error。
我们的目标是:业务代码纯净无暇,计费逻辑像空气一样存在。
6.1 声明式鉴权:@CheckEntitlement
我们利用 Spring AOP 和 自定义注解,将计费逻辑完全抽离。
// PDFController.java
@RestController
@RequestMapping("/api/pdf")
public class PDFController {
// 只需要加这一行注解!
@CheckEntitlement(
value = EntitlementType.RESUME_CREATE,
errorMsg = "您的简历导出额度已用尽,请购买次数或升级会员"
)
@PostMapping("/generate/{resumeId}")
public ResponseEntity<byte[]> generatePdf(...) {
// 下面全是纯粹的业务逻辑,完全不用管扣费的事
byte[] pdfBytes = playwrightManager.generatePdf(htmlContent);
return ...;
}
}
6.2 AOP 切面编排:两阶段提交 (TCC)
切面不仅要拦截请求,还要负责协调 预扣减(Reserve)、确认(Confirm) 和 取消(Cancel) 的完整事务流程。
特别是,我们的 AI 模拟面试 或 简历润色 服务,往往需要调用大模型进行流式输出(Server-Sent Events),采用 WebFlux 返回 Flux<String> 以实现“打字机”效果。切面必须能处理这种异步流的生命周期。
// EntitlementCheckAspect.java
@Around("@annotation(org.dztyykxx.back.aspect.CheckEntitlement)")
public Object check(ProceedingJoinPoint joinPoint) throws Throwable {
// Step 1: 预留权益 (Reserve)
// 这一步会扣减数据库次数,并生成 PENDING 状态的日志
UsageLog usageLog = entitlementService.reserve(userId, entitlementId, resumeId);
// 快速通道:如果是包月会员,reserve 返回的是 CONFIRMED,直接放行
if ("CONFIRMED".equals(usageLog.getStatus())) {
return joinPoint.proceed();
}
// Step 2: 执行业务
Object result;
try {
result = joinPoint.proceed();
} catch (Throwable e) {
// 同步异常:立即回滚
entitlementService.cancel(usageLog.getId());
throw e;
}
// Step 3: 处理结果 (支持响应式流 Flux/Mono)
if (result instanceof Flux) {
return ((Flux<?>) result)
// 流成功完成 -> 确认扣费
.doOnComplete(() -> entitlementService.confirm(usageLog.getId()))
// 流发生错误 -> 回滚扣费
.doOnError(error -> entitlementService.cancel(usageLog.getId()))
// 客户端取消(断开连接) -> 回滚扣费
.doOnCancel(() -> entitlementService.cancel(usageLog.getId()));
} else {
// 普通同步方法 -> 确认扣费
entitlementService.confirm(usageLog.getId());
return result;
}
}
6.3 动态参数解析 (SpEL)
有些特殊权益规则依赖于具体的业务参数。例如:“修改简历”通常需要付费,但如果这份简历 ID 是 1001,且用户昨天已经付过费了,今天应该免费。
为了在 AOP 中获取到 resumeId,我们支持了 SpEL 表达式:
@CheckEntitlement(value = EntitlementType.RESUME_MODIFY, resumeId = "#resumeId")
public void modifyResume(Long resumeId, ...) { ... }
AOP 内部会解析 #resumeId,提取参数值,传递给 entitlementService.reserve 进行特殊规则判定。
7. 高可用与兜底机制 (Reliability)
在分布式环境中,即使代码逻辑再完美,也无法避免网络抖动、服务宕机等不可抗力。为了保证“钱、权、账”的一致性,仅仅依靠 AOP 拦截是不够的,我们引入了两层兜底机制。
7.1 支付回调的一致性保障:EntitlementGrantTask
- 问题:用户付了钱,微信/支付宝也扣款成功了,但回调通知因为网络波动丢失了,或者回调时我们的服务器正好重启,导致用户状态一直是“已支付”但“未发货”(权益未到账)。这会引发严重的客诉。
- 对策:实现一个定时任务,扫描所有“已支付”但超过缓冲时间(如2分钟)仍未发放权益的订单,进行幂等补发。
// EntitlementGrantTask.java
@Scheduled(fixedDelayString = "120000") // 每2分钟执行一次
public void handlePendingEntitlementGrants() {
// 1. 设定缓冲区:只处理2分钟前支付的订单,避免与正常回调冲突
Instant bufferTime = Instant.now().minus(2, ChronoUnit.MINUTES);
// 2. 查找漏单:状态为 PAID 且 completed_at < bufferTime
List<Order> pendingOrders = orderMapper.selectList(...);
// 3. 补发权益
for (Order order : pendingOrders) {
try {
// 调用核心发放逻辑(内部有事务和幂等校验)
entitlementService.grantEntitlement(order.getId());
} catch (Exception e) {
log.error("补发失败: {}", order.getId(), e);
}
}
}
7.2 异常状态的自我修复:EntitlementCleanupTask
- 问题:AOP 中的
reserve成功了(数据库扣减了次数,日志状态为PENDING),但随后业务服务宕机,导致既没有执行confirm也没有执行cancel。此时,用户的权益被“锁死”在中间状态,无法使用也未返还。 - 对策:实现一个“清道夫”任务,定期巡检长时间(如10分钟)处于
PENDING状态的流水,将其视为失败,强制执行回滚。
// EntitlementCleanupTask.java
@Scheduled(cron = "0 */5 * * * ?") // 每5分钟执行一次
public void cleanupPendingEntitlements() {
// 1. 设定超时阈值:10分钟前创建的任务
Instant timeoutThreshold = Instant.now().minus(10, ChronoUnit.MINUTES);
// 2. 查找僵尸记录:状态为 PENDING 且 createdAt < timeoutThreshold
List<UsageLog> staleLogs = usageLogMapper.selectList(...);
// 3. 强制回滚
for (UsageLog log : staleLogs) {
// 调用核心取消逻辑,将锁定的次数返还给用户
entitlementService.cancel(log.getId());
}
}
8. 未来展望
8.1 逻辑编排:从过程式到责任链
在当前的 EntitlementServiceImpl.reserve 实现中,为了追求交付速度,我们采用了直观的“流水线”式写法:先查特殊规则,再查订阅,最后查计次。
// 当前的实现方式:简单直观,但耦合度高
if (checkSpecial(...)) return;
if (checkSubscription(...)) return;
if (consumeTimeLimited(...)) return;
if (consumePermanent(...)) return;
这种写法的优点是逻辑一目了然,维护成本低。但随着业务发展,如果我们引入了新的权益类型(比如“家庭共享额度”、“企业团队池”),或者需要动态调整扣减顺序(比如“优先扣永久,再扣限时”),改动成本就会变高。
下一步的优化方向是引入责任链模式 (Chain of Responsibility Pattern):
我们将每一个扣减逻辑封装为一个独立的 Handler(如 SpecialRuleHandler, SubscriptionHandler),通过 Spring 将其编排成一条链。请求(Request)在链条上传递,哪个 Handler 能处理就处理。
这样一来,核心代码将完全符合开闭原则 (OCP):新增一种权益扣减规则,只需要新增一个 Class,而无需修改原有的 Service 代码。虽然这次受限于工期(其实是太久没用设计模式手生了)选择了硬编码,但这绝对是系统演进的必经之路。
8.2 架构演进:从单体到分布式
目前,我们的系统是一个标准的单体应用,这在业务初期是一个务实的选择。然而,随着用户量和并发量的增长,当前架构的一些局限性将逐渐暴露:
- 数据库压力:目前所有的鉴权查询(如
hasActiveEntitlement)都直接打到 MySQL。在高频访问下(如用户频繁翻页查看资源),数据库 IO 将成为瓶颈。 - 任务调度冲突:我们的兜底任务使用简单的
@Scheduled。如果未来为了高可用部署了多实例,定时任务会在多台机器上同时触发,导致重复发货的风险。
未来的演进路线:
- 引入缓存 (Redis Caching):对于“订阅权益”这种读多写少的数据,可以将其缓存到 Redis 中。鉴权时优先查缓存,极大减轻数据库压力。
- 分布式锁 (Distributed Locks):在多实例部署前,必须引入 Redis (Redisson) 分布式锁,确保
EntitlementGrantTask在同一时刻只有一个实例在运行。 - 异步化 (Asynchronous Processing):将权益消费日志的写入操作放入消息队列(如 RabbitMQ),实现“削峰填谷”。
9. 总结
至此,我们完成了一个商品权益系统的构建:
- 模型层:通过
ProductGrant解耦,支持了无限的运营组合。 - 服务层:通过
PriorityChain实现了智能扣减,通过ReverseDeduction解决了退款难题。 - 接入层:通过
AOP+SpEL,对业务开发的侵入性降到最低。 - 保障层:通过
GrantTask和CleanupTask双重兜底,确保了在极端异常情况下的数据最终一致性。