Administrator
Administrator
发布于 2025-12-26 / 9 阅读
0
0

构建高灵活度的商品权益订阅与计费系统

构建高灵活度的商品权益订阅与计费系统


前言: 在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 为什么选择自研?

经过对比,我们发现通用方案存在两个核心矛盾:

  1. 复杂度过剩:我们要解决的是“商品-权益”的灵活组合,而不是要做一个通用的全球税务计算引擎。
  2. 集成成本:为了接入第三方计费,往往需要改造现有的用户体系和订单体系,这个工作量不比自研少。

因此,我们决定基于 Spring Boot 生态,量身定制一套**“轻量级、高扩展”**的订阅计费模块。

1.3 技术栈一览

核心技术栈

  • 框架:Spring Boot 3.x, MyBatis-Plus
  • 数据库:MySQL 8.0 (利用 JSON 字段存储快照,利用乐观锁处理并发)
  • 切面编程:Spring AOP + SpEL (实现无感鉴权)
  • 工具链:Lombok, Hutool
graph TB subgraph 外部交互 [External World] User((用户/Client)) Payment((支付网关回调)) end subgraph 接入层 [API Layer] API[业务接口 API<br>PDF/AI面试] WebHook[支付回调接口] end subgraph 切面层 [Aspect Layer] AOP[AOP 鉴权切面<br>@CheckEntitlement] SpEL[SpEL 参数解析] end subgraph 核心服务层 [Core Service Layer] GrantService[权益发放<br>GrantEntitlement] ConsumeService[权益扣减<br>Reserve/Confirm/Cancel] RefundService[退款计算<br>Reverse/Weighted] end subgraph 兜底保障层 [Reliability Layer] TaskGrant[补单任务<br>GrantTask] TaskClean[死锁清理任务<br>CleanupTask] end subgraph 数据层 [Data Layer] DB[(MySQL 8.0)] end %% 连线关系 User --> API Payment --> WebHook API --> AOP AOP -- 1.预扣减 --> ConsumeService API -- 2.业务逻辑 --> ConsumeService WebHook --> GrantService GrantService --> DB ConsumeService --> DB RefundService --> DB TaskGrant -- 扫描漏单 --> GrantService TaskClean -- 扫描超时 --> ConsumeService

2. 业务背景与设计挑战

在项目初期,需求往往很简单:“用户购买月卡,获得30天会员”。但随着运营策略的精细化,我们面临了以下典型挑战:

2.1 复杂的销售组合(Bundle Sales)

运营人员不再满足于单一维度的销售。他们需要灵活的“打包”策略,例如:

  • 买一送一:购买“高级会员(30天)”,赠送“AI模拟面试券(5次)”。
  • 混合计费:一个商品同时包含“时间维度”的权益(如全站去广告)和“数量维度”的权益(如简历下载次数)。

如果采用传统的“商品表增加字段”的方式(例如在 products 表里加 vip_daysdownload_counts),一旦新增一种权益类型(如“查看联系方式”),就需要修改表结构和业务代码,扩展性极差。

2.2 多样的生命周期管理

系统需要同时支持三种截然不同的权益形态,且能够在一个订单中混合处理:

  1. 订阅制(Subscription):按时间付费,支持多次购买自动叠加有效期(如 Netflix、Spotify)。
  2. 限时资源包(Time-limited Usage):在特定时间内有效,过期作废(如“7天内有效的10次简历诊断卡”)。
  3. 永久点数(Permanent Usage):无时间限制,用完为止(如“永久简历下载券”)。

2.3 核心设计目标

基于上述痛点,我们确定了系统的核心设计目标:

  • 完全解耦:将“售卖单元(Product)”与“履约单元(Entitlement)”彻底分离。
  • 高扩展性:新增权益类型只需配置数据,无需修改代码。
  • 数据一致性:确保支付、发货、消耗、退款全链路的资金与权益账本绝对准确。

3. 核心领域模型设计 (The Model)

为了实现上述目标,我们设计了一个以 “授权规则(Product Grant)” 为核心的领域模型。

3.1 实体关系图 (ER Diagram)

我们通过引入中间层,打破了商品与权益的强绑定关系:

image.png

3.2 定义层:原子化的权益

我们首先将系统中的所有能力抽象为原子权益,存储在 t_entitlement_definitions 表中。

  • 设计意图:这是所有计费逻辑的基石。无论业务如何变,权益的定义必须是稳定的。
  • 关键字段
    • type_code:业务代码中引用的唯一标识(如 RESUME_CREATE, MOCK_INTERVIEW)。

3.3 关联层:灵活的授权规则 (The Magic Glue)

t_product_grants 是整个模型中最核心的部分。它通过 duration_daysgrant_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_daysgrant_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);
}

三种发放逻辑的差异

  1. 订阅制发放 (handleSubscriptionGrant)
    • 难点:有效期叠加与溯源。
    • 逻辑(双写模式)
      1. 记录流水:先在 t_user_order_entitlement 插入一条记录。虽然订阅本身不限次,但我们需要记录该订单贡献了多少天(duration_days),这对后续的退款计算至关重要。
      2. 更新聚合:查询或创建 t_user_subscription 聚合记录。如果已过期,新有效期 = Now + Days;如果未过期,新有效期 = CurrentExpiry + Days
    • 代码细节:利用数据库原子更新保证聚合表有效期的准确性,同时保留流水表作为“账本明细”。
  2. 限时资源包发放 (handleTimeLimitedCountGrant)
    • 难点:独立生命周期。
    • 逻辑:直接插入一条 t_user_order_entitlement 记录,其 expiry_time 计算方式为 Now + Days
    • 关键点:即使是购买同一个商品,每次购买生成的记录也是独立的,互不干扰。
  3. 永久资源包发放 (handlePermanentCountGrant)
    • 逻辑:插入 t_user_order_entitlement 记录,但 expiry_time 置为 NULL

4.2 多级权限扣减链:用户利益最大化

当用户点击“生成简历”按钮时,系统面临一个抉择:用户手里可能有“会员月卡”,也有“赠送的免费次数”,还有“刚买的加油包”。到底该扣哪一个?

如果乱扣(例如先扣了永久点数,而让限时点数过期),用户一定会投诉。因此,我们设计了一条严格的 优先级扣减链 (Priority Chain),核心原则是:优先消耗即将失效或免费的权益

优先级链顺序

  1. Lv1 特殊规则 (Special Rules):业务特例,最高优先级(如:该简历之前已经付费过,再次修改免费)。
  2. Lv2 订阅覆盖 (Subscription):如果用户是会员且在有效期内,直接通行,不扣减任何次数
  3. Lv3 限时资源包 (Time-Limited):优先消耗,且遵循 FIFO(先进先出) 原则,先扣快过期的。
  4. Lv4 永久资源包 (Permanent):最后兜底,遵循 Oldest First 原则,先扣买得最早的。

核心代码实现 (reserve 方法)

这是整个计费系统最核心的方法,负责“预扣减”操作。

graph TD Start([用户发起业务请求]) --> Lv1{"Lv1. 特殊规则?"} %% Lv1 分支 Lv1 -- "命中 (如:简历已付费)" --> Pass["✅ 直接放行 (无需扣费)"] %% Lv2 分支 Lv1 -- 未命中 --> Lv2{"Lv2. 有效订阅?"} Lv2 -- 在有效期内 --> Pass %% Lv3 分支 Lv2 -- "无订阅/过期" --> Lv3{"Lv3. 限时计次权益?"} Lv3 -- "有剩余 (FIFO)" --> DeductTime["⚡ 预扣减: 最早过期的一条"] %% Lv4 分支 Lv3 -- 无可用记录 --> Lv4{"Lv4. 永久计次权益?"} Lv4 -- "有剩余 (Oldest)" --> DeductPerm["⚡ 预扣减: 最早购买的一条"] %% 结果 Lv4 -- 无权益 --> Reject(["⛔ 拦截: 权益不足"]) DeductTime --> Log[记录 PENDING 日志] DeductPerm --> Log Pass --> Log Log --> Success([🚀 执行业务逻辑]) style Pass fill:#d4edda,stroke:#28a745,color:#155724 style Reject fill:#f8d7da,stroke:#dc3545,color:#721c24 style DeductTime fill:#fff3cd,stroke:#ffc107 style DeductPerm fill:#fff3cd,stroke:#ffc107
@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;
}

小结

通过 工厂模式优先级链,我们成功解决了两个问题:

  1. 进销存的“进”:无论运营配置出多么奇葩的商品组合,系统都能自动将其拆解为原子的权益记录并入库。
  2. 进销存的“出”:无论用户持有多少种权益,系统总能智能地找到最划算的那一种进行扣减,极大降低了客诉风险。

5. 资金安全与退款算法 (Financial Accuracy)

[!NOTE]

⚠️ 生产环境注记: 值得一提的是,下文所述的复杂退款算法,在目前我们的生产环境中主要用于 “系统试算 + 人工确认” 的半自动流程,尚未开放给用户端自动操作。 我们实现这套逻辑的核心目的,是为了验证领域模型的完备性——即证明在这套模型下,即使面对复杂的“时序叠加”和“混合计费”场景,系统依然具备精确计算剩余价值的底层能力。

[!WARNING]

💡 关键细节:交易快照 (The Snapshot)

运营人员可能会随时调整商品的权益组合或权重(例如将权重大调为 50:50)。如果不保存快照,直接查询最新的 ProductGrant 表,会导致历史订单的退款金额计算错误

因此,我们在 t_orders 表设计了一个 snapshot 字段(JSON 类型),在下单时刻将该商品当时所有的权益规则、权重完整序列化并持久化。退款计算必须且只能依赖这个快照数据。

在简单的电商系统中,退款往往只是 Status = REFUNDED。但在权益系统中,退款意味着**“权益回收”“价值折算”**。

我们需要解决两个核心难题:

  1. 时序叠加难题:订阅有效期是不断叠加的,如何剥离出中间某一个订单贡献的时长?
  2. 组合商品难题:一个商品包含“会员”和“积分”,如何计算单个权益的退款价值?

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天,而不是全额退款。

算法实现: 我们采用 “倒推法”,即:不关心过去发生了什么,只看未来还剩什么

  1. 计算后续贡献:查询该订单(A)之后所有同类权益订单(B)的总贡献时长(60天)。
  2. 确定理论终点当前总过期时间 (Now+80) - 后续贡献 (60) = 订单A的理论结束时间 (Now+20)
  3. 计算剩余价值理论结束时间 - 当前时间 = 订单A的真实剩余时长 (20天)
  4. 计算退款比例20天 / 30天 = 66.67%
gantt title 倒推法退款逻辑演示 dateFormat X axisFormat %d section 用户行为 购买月卡A (30天) :done, orderA, 0, 30 续费季卡B (90天) :active, orderB, 30, 120 当前发起退款A :crit, now, 10, 10 section 系统计算 当前总有效期 (120天) :active, total, 0, 120 剔除后续贡献B (90天) : deduct, 30, 120 理论剩余终点 (第30天) :crit, milestone, 30, 0 订单A真实剩余价值 :active, refund, 10, 30 section 结论 应退款比例 = (30-10)/30 = 66.7% : done, result, 0, 0
// 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> 以实现“打字机”效果。切面必须能处理这种异步流的生命周期。

sequenceDiagram autonumber participant User as 用户/客户端 participant Controller as 业务接口 participant Aspect as @CheckEntitlement切面 participant Service as 权益Service participant DB as 数据库 Note over User, Controller: 场景:生成简历 (WebFlux流) User->>Controller: POST /generate/{id} Controller->>Aspect: 进入切面拦截 rect rgb(240, 248, 255) Note right of Aspect: Phase 1: Try (预留) Aspect->>Service: reserve(userId, type) Service->>DB: 扣减次数, Insert Log(PENDING) Service-->>Aspect: 返回 LogID end Aspect->>Controller: proceed() 执行业务 Controller-->>User: 返回 Flux<String> 流 rect rgb(255, 250, 240) Note right of Aspect: Phase 2: Confirm/Cancel loop 流数据传输中 User-->Controller: 持续接收数据... end alt 流正常结束 (onComplete) Controller->>Aspect: 触发 doOnComplete Aspect->>Service: confirm(logId) Service->>DB: Update Log -> CONFIRMED else 发生异常/断开 (onError/onCancel) Controller->>Aspect: 触发 onError Aspect->>Service: cancel(logId) Service->>DB: 回滚次数, Update Log -> CANCELLED end end
// 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。如果未来为了高可用部署了多实例,定时任务会在多台机器上同时触发,导致重复发货的风险。

未来的演进路线

  1. 引入缓存 (Redis Caching):对于“订阅权益”这种读多写少的数据,可以将其缓存到 Redis 中。鉴权时优先查缓存,极大减轻数据库压力。
  2. 分布式锁 (Distributed Locks):在多实例部署前,必须引入 Redis (Redisson) 分布式锁,确保 EntitlementGrantTask 在同一时刻只有一个实例在运行。
  3. 异步化 (Asynchronous Processing):将权益消费日志的写入操作放入消息队列(如 RabbitMQ),实现“削峰填谷”。

9. 总结

至此,我们完成了一个商品权益系统的构建:

  1. 模型层:通过 ProductGrant 解耦,支持了无限的运营组合。
  2. 服务层:通过 PriorityChain 实现了智能扣减,通过 ReverseDeduction 解决了退款难题。
  3. 接入层:通过 AOP + SpEL,对业务开发的侵入性降到最低。
  4. 保障层:通过 GrantTaskCleanupTask 双重兜底,确保了在极端异常情况下的数据最终一致性。

评论