PAYATHON 2026

订阅过期机制的最佳设计模式是什么?

支付老李

明确结论

订阅是否过期、用户能否继续访问,应由明确的时间边界决定,例如:

current_time < access_until

不要依赖 Cronjob 是否及时把 status 从 active 更新为 expired。

在给出的两个方案中,Model 1 更适合用于授权判断,但字段含义需要调整。不要简单地用固定的 signup_date + subscription_length 计算到期时间,而应保存当前订阅周期的实际结束时间,例如 current_period_end 或 access_until。每次续费、退款、取消或补偿时,再按照业务规则更新这些字段。

推荐使用以下判断方式:

是否允许访问 =
status 允许提供服务
AND
current_time < access_until

Cronjob 可以整理状态、发送提醒和修复数据,但不能成为订阅按时失效的唯一保障。

两种模式的对比

维度Model 1:按 expiry_date 判断Model 2:按 Cronjob 更新 status
准确性时间边界明确,适合授权判断取决于任务的执行频率和成功情况
实时性请求到达时即时计算最多会延迟一个调度周期
可靠性不依赖后台任务及时运行Cronjob 延迟或失败可能造成越权访问
维护成本核心逻辑简单,但要处理续费后的日期更新需要维护调度、重试、监控和并发控制
查询性能需要比较状态和时间,通常成本很低只查询状态比较简单,但状态可能已经过时
审计能力可以直接看到访问权何时结束只能看到当前状态,无法单独说明时间边界

Model 1 的优点

授权系统直接比较当前时间和订阅结束时间,不必等待异步任务:

current_time < access_until

即使 Cronjob 暂停运行,已经到期的订阅也无法继续访问。

这种方式也更容易处理以下情况:

  • 按月、按年或自定义周期订阅;
  • 免费试用;
  • 续费后延长服务期;
  • 取消自动续费,但允许使用到当前周期结束;
  • 人工赠送额外使用时间;
  • 支付失败后的宽限期。

Model 1 的问题

原始公式:

expiry_date = signup_date + subscription_length

只适用于一次性且不会续费的简单订阅。对于周期订阅,注册时间通常不是当前服务周期的起点。

更合理的字段包括:

current_period_start
current_period_end
access_until

其中:

  • current_period_end 表示当前计费周期的结束时间;
  • access_until 表示用户实际可以访问到什么时间;
  • 在普通订阅中,两者可以相同,但遇到宽限期、退款或人工补偿时,两者可能不同。

Model 2 的优点

授权时只检查 status,代码比较直观:

status == "active"

如果系统需要频繁筛选活跃用户,持久化状态也便于查询和统计。

Model 2 的问题

这种模式把授权的正确性建立在 Cronjob 正常运行的基础上。假设任务每小时执行一次,订阅在 10:05 到期,任务要到 11:00 才执行,用户就可能多获得将近一小时的访问权限。

实际运行中还可能遇到这些故障:

  • Cronjob 没有启动或意外退出;
  • 某一批数据处理失败;
  • 数据库暂时不可用;
  • 多个任务实例并发更新;
  • 任务只处理了部分分页数据;
  • 用户续费和过期任务同时执行,产生竞态条件;
  • 服务器的时区或夏令时配置不一致。

即使将任务频率提高到每分钟一次,也只是缩短了错误窗口,并没有消除对异步任务的依赖。

推荐的数据模型

计费状态和访问权可以分开建模:

CREATE TABLE subscriptions (
    id                  BIGINT PRIMARY KEY,
    user_id             BIGINT NOT NULL,
    status              VARCHAR(32) NOT NULL,
    current_period_start TIMESTAMP NOT NULL,
    current_period_end   TIMESTAMP NOT NULL,
    access_until         TIMESTAMP NOT NULL,
    cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
    updated_at           TIMESTAMP NOT NULL
);

status 用于表示订阅所处的生命周期阶段,例如:

trialing
active
past_due
canceled
expired
suspended

每个状态都要有清楚的业务定义。特别需要注意,canceled 不一定表示立即失去访问权。用户关闭自动续费后,通常仍然可以使用到已付费周期结束。

授权逻辑

授权时同时检查状态和时间:

from datetime import datetime, timezone

ACCESSIBLE_STATUSES = {"trialing", "active", "past_due"}

def can_access(subscription) -> bool:
    now = datetime.now(timezone.utc)

    return (
        subscription.status in ACCESSIBLE_STATUSES
        and now < subscription.access_until
    )

是否允许处于 past_due 状态的用户继续访问,要看产品是否提供支付宽限期。如果没有宽限期,就不要把它加入 ACCESSIBLE_STATUSES。

遇到立即封禁、退款撤销或风控冻结等情况,可以先通过状态拒绝访问:

DENIED_STATUSES = {"expired", "suspended", "revoked"}

def can_access(subscription) -> bool:
    if subscription.status in DENIED_STATUSES:
        return False

    now = datetime.now(timezone.utc)
    return now < subscription.access_until

这样既能按照时间实时判断,也支持需要立即生效的管理操作。

续费和支付失败的处理

支付成功后,应根据支付结果更新当前订阅周期,不要再次使用最初的 signup_date:

def apply_successful_renewal(subscription, new_period_start, new_period_end):
    subscription.status = "active"
    subscription.current_period_start = new_period_start
    subscription.current_period_end = new_period_end
    subscription.access_until = new_period_end

支付失败时,可以按照业务规则设置宽限期:

from datetime import timedelta

def apply_payment_failure(subscription):
    subscription.status = "past_due"
    subscription.access_until = (
        subscription.current_period_end + timedelta(days=3)
    )

这里的三天只是示例,并非通用标准。宽限期应根据实际的计费和风控规则确定。

Cronjob 应该承担什么职责

Cronjob 仍有作用,但更适合用于状态同步和兜底:

UPDATE subscriptions
SET status = 'expired',
    updated_at = CURRENT_TIMESTAMP
WHERE status IN ('trialing', 'active', 'past_due')
  AND access_until <= CURRENT_TIMESTAMP;

它可以负责:

  • 将超过 access_until 的记录标记为 expired;
  • 发送即将到期或已经到期的通知;
  • 生成运营报表;
  • 清理缓存和关联资源;
  • 修复支付回调遗漏造成的数据不一致。

即使任务暂时失败,授权系统仍会根据 access_until 拒绝已经过期的访问。

Cronjob 的更新条件必须包含当前状态和时间边界,确保重复执行时得到一致结果。批量处理数据时,还需要加入分页、失败重试、执行监控和告警。

处理并发与乱序事件

支付回调、用户取消操作和过期任务可能同时发生。例如,Cronjob 读到订阅已经过期,准备将其更新为 expired;与此同时,支付回调已经完成续期。如果 Cronjob 随后无条件写入,就可能覆盖新的有效状态。

因此,更新时需要附带条件:

UPDATE subscriptions
SET status = 'expired',
    updated_at = CURRENT_TIMESTAMP
WHERE id = :subscription_id
  AND status IN ('trialing', 'active', 'past_due')
  AND access_until <= CURRENT_TIMESTAMP;

也可以通过版本号实现乐观锁:

UPDATE subscriptions
SET status = :new_status,
    access_until = :new_access_until,
    version = version + 1
WHERE id = :subscription_id
  AND version = :expected_version;

支付平台发送的事件还可能重复或乱序。系统应保存事件 ID,保证同一个事件只处理一次,并根据事件时间或订阅版本,阻止旧事件覆盖新状态。

时间处理注意事项

时间字段建议统一保存为 UTC,并使用带有时区语义的类型。界面展示时,再转换为用户所在的时区。

结束时间的边界规则也要明确定义。建议使用半开区间:

period_start <= current_time < period_end

这表示当 current_time == period_end 时,订阅已经失效。与混合使用 <= 和 >= 相比,这种规则不容易在相邻周期的交界处产生重叠。

授权判断依赖服务器时间,因此还要确保应用服务器和数据库服务器的时钟可靠同步。

最终建议

将 status 用于描述订阅的生命周期,将 access_until 用作访问授权的时间边界:

status:说明订阅处于什么业务阶段
access_until:说明访问权持续到什么时候

每次请求到达时实时检查这两个字段。支付成功、取消、退款或人工调整时更新它们,Cronjob 则负责状态归档、通知和异常修复。

这种组合既能覆盖比单独使用 Model 1 更复杂的订阅业务,也能避免 Model 2 因 Cronjob 延迟或失败而产生授权漏洞。

备注:内容仅供参考。