PAYATHON 2026

如何在数据库中管理订阅付款状态?

支付阿杰

结论

用“订阅有效期”判断访问权限是合理的,但系统不能只保存一个到期日期,也不能只在用户登录时检查权限。

一套可靠的设计通常包括:

  • 用独立且不可随意修改的流水表保存付款记录。
  • 在订阅表中保存当前状态、计费周期和有效期。
  • 以支付平台的服务端通知(Webhook)为支付结果依据,并用主动查询补偿遗漏。
  • 用户每次访问受限功能时,都由服务端判断订阅是否有效。
  • 通过后台定时任务处理过期、续费失败、通知遗漏和状态对账。
  • 所有时间统一使用 UTC,支付处理必须具备幂等性。

到期日期是授权判断的重要依据,但只是订阅系统的一部分。

推荐的数据模型

至少要将用户订阅、支付流水和支付事件分开保存。

CREATE TABLE subscriptions (
    id                  BIGINT PRIMARY KEY,
    user_id             BIGINT NOT NULL,
    plan_id             BIGINT NOT NULL,

    status              VARCHAR(32) NOT NULL,
    current_period_start TIMESTAMP NOT NULL,
    current_period_end   TIMESTAMP NOT NULL,

    cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
    grace_period_end     TIMESTAMP NULL,

    provider             VARCHAR(32) NOT NULL,
    provider_customer_id VARCHAR(128) NULL,
    provider_subscription_id VARCHAR(128) NULL,

    created_at           TIMESTAMP NOT NULL,
    updated_at           TIMESTAMP NOT NULL,

    UNIQUE (provider, provider_subscription_id)
);

CREATE INDEX idx_subscriptions_user
    ON subscriptions(user_id);

CREATE INDEX idx_subscriptions_expiration
    ON subscriptions(status, current_period_end);

支付流水应单独保存:

CREATE TABLE payments (
    id                   BIGINT PRIMARY KEY,
    subscription_id      BIGINT NOT NULL,
    provider_payment_id  VARCHAR(128) NOT NULL,
    amount_minor         BIGINT NOT NULL,
    currency             CHAR(3) NOT NULL,
    status               VARCHAR(32) NOT NULL,
    paid_at              TIMESTAMP NULL,
    period_start         TIMESTAMP NULL,
    period_end           TIMESTAMP NULL,
    created_at           TIMESTAMP NOT NULL,
    updated_at           TIMESTAMP NOT NULL,

    UNIQUE (provider_payment_id),
    FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);

amount_minor 表示货币的最小单位,例如人民币的分。金额不要使用浮点数保存。

收到的 Webhook 事件也需要记录:

CREATE TABLE payment_events (
    id                   BIGINT PRIMARY KEY,
    provider             VARCHAR(32) NOT NULL,
    provider_event_id    VARCHAR(128) NOT NULL,
    event_type           VARCHAR(64) NOT NULL,
    payload              TEXT NOT NULL,
    received_at          TIMESTAMP NOT NULL,
    processed_at         TIMESTAMP NULL,
    processing_error     TEXT NULL,

    UNIQUE (provider, provider_event_id)
);

事件表既可用于幂等处理,也便于排查“支付平台显示成功,但应用没有开通权限”这类问题。原始载荷可能包含敏感数据,保存前应按照支付平台的规则进行脱敏和访问控制。

如何判断订阅是否有效

不能只检查 current_period_end,通常需要同时判断状态、有效期和宽限期。

有效 =
    status = 'active'
    AND now < current_period_end

或者:

有效 =
    status = 'past_due'
    AND grace_period_end IS NOT NULL
    AND now < grace_period_end

示例:

boolean hasAccess(Subscription subscription, Instant now) {
    if ("active".equals(subscription.getStatus())) {
        return now.isBefore(subscription.getCurrentPeriodEnd());
    }

    if ("past_due".equals(subscription.getStatus())
            && subscription.getGracePeriodEnd() != null) {
        return now.isBefore(subscription.getGracePeriodEnd());
    }

    return false;
}

状态名称可以根据具体业务调整,例如:

  • pending:订单已经创建,但付款尚未确认。
  • active:当前周期已经付款。
  • past_due:续费失败,但订阅仍在宽限期内。
  • canceled:订阅已经取消。
  • expired:有效期已经结束。
  • suspended:订阅因退款、拒付或风控被暂停。

取消订阅通常不等于立即失效。如果用户在周期中途关闭自动续费,可以设置 cancel_at_period_end = true,让权限保留到已付款周期结束。

支付成功后的处理流程

建议按以下流程处理:

  1. 服务端创建订单或订阅。
  2. 客户端跳转到支付平台完成付款。
  3. 支付平台向服务端发送 Webhook。
  4. 服务端验证 Webhook 的签名、事件来源和金额。
  5. 使用支付平台的事件 ID 进行幂等检查。
  6. 在同一个数据库事务中写入支付流水并更新订阅周期。
  7. 返回成功响应。
  8. 由定时对账任务补偿丢失或处理失败的通知。

不能因为浏览器跳转到了“支付成功页面”就开通权限。跳转请求可能被伪造,用户也可能在付款成功后直接关闭页面。客户端提交的“支付成功”状态同样不能作为可信依据。

Webhook 处理可以采用类似的结构:

function handleWebhook(request):
    event = verifySignatureAndParse(request)

    begin transaction

    if payment_events.exists(event.provider, event.id):
        commit
        return HTTP 200

    insert payment_events(
        provider = event.provider,
        provider_event_id = event.id,
        event_type = event.type,
        payload = redact(event.payload)
    )

    if event.type == "payment_succeeded":
        payment = fetchAndVerifyPayment(event.payment_id)

        assert payment.amount == expectedAmount
        assert payment.currency == expectedCurrency
        assert payment.order_id == expectedOrderId

        upsertPayment(payment)
        extendSubscription(payment)

    if event.type == "payment_failed":
        markSubscriptionPastDue(event.subscription_id)

    if event.type in ["refunded", "chargeback"]:
        applyRefundOrChargebackPolicy(event)

    markEventProcessed(event.id)

    commit
    return HTTP 200

如果处理失败,应让支付平台重试,或者把事件放入内部重试队列。数据库事务提交之前不要返回成功。

有效期应该如何计算

首次付款

首次付款可以从支付确认时间或约定的服务开始时间起算:

current_period_start = paid_at
current_period_end   = add_months(paid_at, 1)

年付可以这样计算:

current_period_end = add_years(paid_at, 1)

正常续费

正常续费一般应从原周期的结束时间继续延长,而不是直接以收到付款的当前时间为起点:

new_period_start = old_period_end
new_period_end   = add_months(old_period_end, 1)

否则,用户提前续费时可能损失尚未用完的时间。

如果订阅已经过期一段时间,就要明确产品规则。常见做法是从实际付款时间重新开始:

base = max(old_period_end, paid_at)
new_period_start = base
new_period_end = add_months(base, 1)

示例 SQL:

UPDATE subscriptions
SET current_period_start =
        CASE
            WHEN current_period_end > :paid_at
                THEN current_period_end
            ELSE :paid_at
        END,
    current_period_end =
        CASE
            WHEN current_period_end > :paid_at
                THEN current_period_end + INTERVAL '1 month'
            ELSE :paid_at + INTERVAL '1 month'
        END,
    status = 'active',
    grace_period_end = NULL,
    updated_at = :now
WHERE id = :subscription_id;

不同数据库的 INTERVAL 语法和月份计算行为并不相同,需要根据实际使用的数据库调整上述示例。

月份不是固定天数

不要用 30 * 24 * 60 * 60 秒表示一个月。月份可能有 28、29、30 或 31 天,闰年也会影响年付周期。

月末续费规则也要提前确定。例如,用户在 1 月 31 日订阅,一个月后的日期可以按照业务规则处理为:

  • 2 月的最后一天;
  • 3 月 3 日一类的日期溢出结果;
  • 固定在每个月的最后一天续费。

应使用日期库或支付平台提供的计费周期规则,并通过测试覆盖月末和闰年:

2024-01-31 + 1 month
2024-02-29 + 1 year
2025-08-31 + 6 months

如果计费周期由支付平台管理,应优先采用平台返回的 current_period_start 和 current_period_end,以免应用和平台分别计算后出现偏差。

不要只在登录时检查

只在登录时检查权限会留下明显漏洞。用户登录后的会话可能持续数周,即使订阅已经过期,仍有可能继续调用接口。

服务端应在受保护请求的入口检查权限:

function requireActiveSubscription(request):
    subscription = loadSubscription(request.user.id)

    if not hasAccess(subscription, utcNow()):
        return HTTP 403

    continueRequest()

检查结果可以短暂缓存,但缓存时间不能超过业务能够接受的权限撤销延迟。发生退款、拒付或管理员暂停订阅时,还需要主动清除缓存。

前端显示订阅状态只能改善用户体验,不能代替服务端授权。

并发与重复通知

支付平台可能出现以下情况:

  • 重复发送同一个事件;
  • 事件到达顺序与实际发生顺序不一致;
  • 请求超时后重新发送;
  • 同时发送付款成功事件和订阅状态变化事件;
  • 应用已经处理成功,但响应丢失,导致平台再次通知。

因此,系统需要做好这些处理:

  • 为 provider_event_id 和 provider_payment_id 建立唯一约束。
  • 在同一个事务中更新付款记录和订阅状态。
  • 使用行锁、乐观锁或条件更新,避免并发续费相互覆盖。
  • 处理乱序事件时比较平台事件的版本或时间,但不能盲目信任客户端时间。
  • 同一笔付款不能重复延长有效期。

例如:

BEGIN;

SELECT *
FROM subscriptions
WHERE id = :subscription_id
FOR UPDATE;

INSERT INTO payments (...)
VALUES (...)
ON CONFLICT (provider_payment_id) DO NOTHING;

-- 只有确认刚插入了新付款记录时,才延长订阅周期。

COMMIT;

具体的冲突处理语法取决于所用数据库。

必要的后台维护

订阅系统至少需要以下周期性任务。

过期状态更新

定期将已经超过有效期且不在宽限期内的订阅标记为 expired:

UPDATE subscriptions
SET status = 'expired',
    updated_at = CURRENT_TIMESTAMP
WHERE status IN ('active', 'past_due')
  AND current_period_end <= CURRENT_TIMESTAMP
  AND (
      grace_period_end IS NULL
      OR grace_period_end <= CURRENT_TIMESTAMP
  );

即使这个任务没有及时执行,实时授权检查仍应根据时间拒绝访问。status 字段不能作为唯一依据,否则定时任务一旦延迟,系统就可能错误放行用户。

支付状态对账

定期向支付平台查询近期订单和订阅,重点检查:

  • 本地状态仍为 pending 的订单;
  • Webhook 处理失败的事件;
  • 平台已经付款但本地尚未入账的交易;
  • 本地显示有效,但平台已经取消或退款的订阅;
  • 续费失败或发生拒付的订阅;
  • 金额、币种或周期不一致的记录。

失败重试和告警

需要监控以下指标和异常:

  • Webhook 签名验证失败次数;
  • 事件处理积压量;
  • 连续重试失败的事件;
  • 对账差异数量;
  • 续费失败率;
  • 即将过期但尚未续费的订阅;
  • 异常的重复付款或重复延长。

无法自动修复的异常应进入人工处理队列,同时保留完整的审计记录。

时间处理的常见陷阱

数据库和服务端应统一保存 UTC 时间,可以使用 TIMESTAMP WITH TIME ZONE,也可以明确约定相关时间字段均为 UTC。用户所在时区只用于展示。

判断时间边界时,建议使用半开区间:

current_period_start <= now < current_period_end

这样一来,权限会在到达 current_period_end 时立即失效,也能避免相邻两个周期在边界处重叠。

还要注意:

  • 不要使用用户设备的时间判断权限。
  • 服务器应配置可靠的时间同步。
  • 明确宽限期是否包含结束时刻。
  • 不要按照服务器本地时区隐式解析日期字符串。
  • 夏令时只影响本地时间的展示,不应改变已经确定的 UTC 到期时刻。

退款、拒付和套餐变更

一个到期日期无法完整表达退款、拒付和套餐变更等情况,因此必须制定相应的业务规则。

退款可以立即停止服务,也可以按照退款覆盖的周期缩短有效期。发生拒付时,通常需要立即暂停权限并转入人工审查。套餐升级或降级则要明确:

  • 立即生效还是在下个周期生效;
  • 是否按剩余时间折算;
  • 是否补收或退还差额;
  • 新周期从什么时间开始。

这些操作应产生单独的账务记录,不要直接修改旧付款流水来覆盖历史。

安全和审计要求

订阅相关接口应满足以下要求:

  • Webhook 必须验证签名并防止重放攻击。
  • 服务端应自行查询订单价格,不能信任客户端提交的金额。
  • 核对币种、金额、用户、订单和支付平台账户。
  • 支付密钥不能写入客户端或日志。
  • 管理员手工延长订阅时,应记录操作者、原因、原值和新值。
  • 支付流水应尽量只追加,不直接删除或改写历史记录。
  • 不要保存完整银行卡号、CVV 等敏感支付数据,支付信息应交给合规的支付服务商处理。

一个实用的最小方案

业务规模不大时,可以先实现以下闭环:

  1. 用 subscriptions 表保存订阅状态和当前周期。
  2. 用 payments 表保存每笔付款,并为平台付款 ID 建立唯一约束。
  3. 通过经过签名验证的 Webhook 确认付款。
  4. 在数据库事务中幂等地写入付款记录并延长订阅周期。
  5. 所有受限 API 都在服务端检查 now < current_period_end。
  6. 每日或以更高频率更新过期状态。
  7. 定期与支付平台对账,并对处理失败的情况发出告警。
  8. 为月末、闰年、重复通知、乱序通知、退款和续费失败编写自动化测试。

这套方案保留了按到期日期判断权限的简单性,也能处理真实支付环境中常见的问题。

备注:内容仅供参考。