如何验证 freemium 产品的 subscription service 有效性
结论
不要只靠 paid_up = "y" 判断订阅是否有效,也不要用定时批处理来更新订阅状态。
更稳妥的做法是:
- 由支付平台管理 recurring billing。
- 系统通过 webhook 接收付款成功、付款失败、续订、取消和退款等事件,并验证签名。
- 在本地保存订阅状态和当前付费周期的结束时间,例如
subscription_status和current_period_end。 - 用户登录时正常创建 session,访问付费功能时再检查 entitlement(功能权限)是否有效。
- 定时任务用于对账和补偿遗漏的 webhook,不用于推测付款结果。
“最近一次成功扣款日期”可以作为审计信息,但不能单独用来判断订阅是否有效。订阅还可能涉及试用期、宽限期、退款、取消后到期、人工赠送时长或账单周期调整,单看扣款日期无法覆盖这些情况。
推荐的数据模型
不要使用只能表示”是或否”的 paid_up 字段。至少应保存以下信息:
CREATE TABLE subscriptions (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
provider_customer_id VARCHAR(255) NOT NULL,
provider_subscription_id VARCHAR(255) NOT NULL UNIQUE,
subscription_status VARCHAR(32) NOT NULL,
current_period_start TIMESTAMP NULL,
current_period_end TIMESTAMP NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
last_payment_succeeded_at TIMESTAMP NULL,
last_provider_event_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
subscription_status 可以使用系统内部定义的有限状态,例如:
trialing:试用中active:订阅有效past_due:扣款失败,处于催收或宽限期canceled:已取消expired:付费权益已到期unpaid:账单未支付refunded:已退款,需要按退款规则处理权益
具体名称应根据支付平台的实际 API 进行映射。不同平台即使使用相同或相似的状态名,含义也未必完全一致。
如何判断订阅是否有效
可以将”订阅记录”与”用户实际拥有的功能权限”分开处理。以后增加试用、优惠券、企业授权或人工赠送权限时,就不需要改动登录逻辑。
基础判断可以这样写:
function hasPremiumAccess(subscription, now = new Date()) {
if (!subscription) {
return false;
}
const periodEnd = subscription.current_period_end
? new Date(subscription.current_period_end)
: null;
if (!periodEnd || periodEnd <= now) {
return false;
}
return ["trialing", "active"].includes(subscription.subscription_status);
}
如果业务允许用户在扣款失败后继续使用一段时间,可以明确加入宽限期规则:
function hasPremiumAccess(subscription, now = new Date()) {
if (!subscription) {
return false;
}
const periodEnd = new Date(subscription.current_period_end);
if (["trialing", "active"].includes(subscription.subscription_status)) {
return periodEnd > now;
}
if (
subscription.subscription_status === "past_due" &&
subscription.grace_period_end
) {
return new Date(subscription.grace_period_end) > now;
}
return false;
}
宽限期属于产品策略,并不是支付平台状态自带的权限规则。比如,past_due 用户能否继续使用付费功能,需要由业务明确规定。
通过 webhook 更新状态
支付结果通常异步产生。此时用户可能没有登录,也可能已经关闭浏览器回调页面,所以不能依赖支付完成页面更新订阅。
下面是一段不绑定具体支付平台的示意代码:
app.post("/webhooks/billing", rawBodyMiddleware, async (req, res) => {
let event;
try {
event = verifyWebhookSignature(
req.rawBody,
req.headers,
process.env.BILLING_WEBHOOK_SECRET
);
} catch (error) {
return res.status(400).send("Invalid signature");
}
const alreadyProcessed = await eventRepository.exists(event.id);
if (alreadyProcessed) {
return res.sendStatus(200);
}
await database.transaction(async (tx) => {
switch (event.type) {
case "subscription.updated":
case "subscription.created":
await subscriptionRepository.upsertFromProvider(
tx,
event.data.subscription
);
break;
case "payment.succeeded":
await subscriptionRepository.recordSuccessfulPayment(
tx,
event.data.subscriptionId,
event.createdAt
);
break;
case "payment.failed":
await subscriptionRepository.markPastDue(
tx,
event.data.subscriptionId,
event.createdAt
);
break;
case "subscription.canceled":
await subscriptionRepository.markCanceled(
tx,
event.data.subscriptionId,
event.data.currentPeriodEnd
);
break;
}
await eventRepository.markProcessed(tx, event.id);
});
return res.sendStatus(200);
});
处理时需要注意:
- 验证 webhook 签名。
- 使用支付平台提供的唯一事件 ID,保证处理过程幂等。
- webhook 事件可能重复、延迟或乱序到达。
- 更新状态前比较事件时间或平台对象版本,防止旧事件覆盖新状态。
- webhook 应尽快返回成功,复杂任务可以放入队列异步执行。
- 不要信任浏览器提交的
subscription_status、金额或周期结束时间。
分开处理登录和功能鉴权
freemium 用户没有付费也需要登录,以便使用免费功能。因此,创建 session token 不应以 paid_up = "y" 为前提。
登录流程可以写成:
async function login(credentials) {
const user = await authenticate(credentials);
if (!user) {
throw new Error("Invalid credentials");
}
return createSessionToken({
sub: user.id
});
}
用户访问付费功能时,再在服务端检查权限:
app.post("/api/premium/export", requireAuthentication, async (req, res) => {
const subscription = await findSubscriptionByUserId(req.user.id);
if (!hasPremiumAccess(subscription)) {
return res.status(403).json({
code: "PREMIUM_SUBSCRIPTION_REQUIRED"
});
}
const result = await runPremiumExport(req.user.id);
return res.json(result);
});
如果把订阅状态写入 JWT,还要考虑 token 在有效期内不会自动更新。用户取消订阅、收到退款或扣款失败后,旧 token 可能仍会显示其拥有付费权限。可以采用以下办法:
- 执行付费操作时实时查询数据库或权限缓存。
- 缩短 access token 的有效期。
- 订阅更新后主动清理权限缓存。
- 对高价值操作始终进行服务端实时检查。
是否还需要定时任务
可以保留定时任务,但应将它用于对账,而不是定期根据日期翻转字段。
例如,每天找出最近发生变化或即将到期的订阅,再与支付平台的状态核对:
async function reconcileSubscriptions() {
const subscriptions = await findSubscriptionsNeedingReconciliation();
for (const local of subscriptions) {
try {
const remote = await billingProvider.getSubscription(
local.provider_subscription_id
);
await updateSubscriptionFromProvider(local.id, remote);
} catch (error) {
await logReconciliationFailure(local.id, error);
}
}
}
对账任务通常需要检查:
current_period_end已过期,但本地状态仍为active的记录。- 很长时间没有收到平台事件的活跃订阅。
- 处于
past_due或其他异常状态的订阅。 - webhook 处理失败或进入死信队列的事件。
- 本地状态与支付平台状态不一致的记录。
调用支付平台 API 时,还要处理分页、限流、超时和重试。一次查询失败不应直接导致订阅失效。应先记录错误,稍后重试。
取消订阅时不要立即撤销权限
“取消自动续订”和”立即终止订阅”是两种不同的操作。
如果用户在付费周期中途取消自动续订,通常仍可继续使用付费功能,直到 current_period_end。此时可以保存:
subscription_status = active
cancel_at_period_end = true
current_period_end = 2026-09-30T12:00:00Z
付费周期结束且用户没有续费后,再终止其付费权益。退款、拒付或管理员强制停用是否需要立即撤销权限,应分别按照退款政策和风险规则处理。
注意事项
- 数据库状态是本地鉴权的依据,支付平台是账单事实的最终来源。
- 所有时间统一使用 UTC,并保存带时区的时间戳。
- 不要保存完整信用卡号、CVV 等敏感卡片信息,只保存支付平台返回的 customer ID、subscription ID 等引用。
- 购买接口也要使用幂等键,避免用户重复点击后创建多个订阅。
- 套餐升降级、按比例计费、试用、退款和拒付都需要明确的权限规则。
- webhook 处理日志应记录事件 ID、订阅 ID、处理结果和失败原因,但不能记录敏感支付信息。
- 对账时如果支付平台暂时不可用,应重试或短暂保留原状态,避免大批用户被错误降级。
核心原则是:不要维护一个需要定时猜测真假的布尔字段。系统应保存订阅的生命周期状态和明确的权益截止时间,通过 webhook 实时更新,再用定时对账补偿异常。
备注:内容仅供参考。
