如何在数据库中管理订阅付款状态?
结论
用“订阅有效期”判断访问权限是合理的,但系统不能只保存一个到期日期,也不能只在用户登录时检查权限。
一套可靠的设计通常包括:
- 用独立且不可随意修改的流水表保存付款记录。
- 在订阅表中保存当前状态、计费周期和有效期。
- 以支付平台的服务端通知(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,让权限保留到已付款周期结束。
支付成功后的处理流程
建议按以下流程处理:
- 服务端创建订单或订阅。
- 客户端跳转到支付平台完成付款。
- 支付平台向服务端发送 Webhook。
- 服务端验证 Webhook 的签名、事件来源和金额。
- 使用支付平台的事件 ID 进行幂等检查。
- 在同一个数据库事务中写入支付流水并更新订阅周期。
- 返回成功响应。
- 由定时对账任务补偿丢失或处理失败的通知。
不能因为浏览器跳转到了“支付成功页面”就开通权限。跳转请求可能被伪造,用户也可能在付款成功后直接关闭页面。客户端提交的“支付成功”状态同样不能作为可信依据。
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 等敏感支付数据,支付信息应交给合规的支付服务商处理。
一个实用的最小方案
业务规模不大时,可以先实现以下闭环:
- 用
subscriptions表保存订阅状态和当前周期。 - 用
payments表保存每笔付款,并为平台付款 ID 建立唯一约束。 - 通过经过签名验证的 Webhook 确认付款。
- 在数据库事务中幂等地写入付款记录并延长订阅周期。
- 所有受限 API 都在服务端检查
now < current_period_end。 - 每日或以更高频率更新过期状态。
- 定期与支付平台对账,并对处理失败的情况发出告警。
- 为月末、闰年、重复通知、乱序通知、退款和续费失败编写自动化测试。
这套方案保留了按到期日期判断权限的简单性,也能处理真实支付环境中常见的问题。
备注:内容仅供参考。
