Node 中如何高效检查用户订阅支付状态?
结论
不要把 next_payment_date 当作长期有效的支付凭证写入 JWT,也不需要每次请求都直接查询 Database。
可以采用以下方案:
- JWT 只保存用户身份和较为稳定的授权信息,例如
userId。 - Database 保存订阅状态,作为应用侧持久化的事实来源。
- Redis 等缓存保存短期订阅权限。
- 支付成功、续费失败、退款或订阅取消时,通过支付平台的 Webhook 更新 Database,同时刷新或删除缓存。
- 请求付费路由时先查缓存,未命中时再查 Database。
这样,各个设备会共享同一份服务端状态,同时减少对 Database 的重复查询。
为什么不应只依赖 JWT
JWT 签发后,其中的内容通常要等到过期或重新签发时才会变化。把支付状态写入 JWT,意味着每台设备拿到的都是一份独立的状态快照。
即使用户已经在电脑端付款,手机端旧 JWT 中的 next_payment_date 也不会自动更新。要解决这个问题,只能采取以下措施:
- 强制所有设备重新获取 token;
- 缩短 JWT 有效期并频繁刷新;
- 在服务端维护 token 版本,并在请求时额外检查。
这些方案仍然离不开服务端状态,而且会增加系统复杂度。
JWT 适合证明请求来自哪个用户,不适合保存订阅状态、封禁状态、角色撤销等随时可能变化的权限信息。
推荐的数据流
一次付费路由请求可以按以下顺序处理:
请求付费路由
↓
验证 JWT,取得 userId
↓
查询 Redis 中的订阅权限
↓
命中缓存 ─────────→ 根据状态放行或拒绝
↓ 未命中
查询 Database
↓
写入短期缓存
↓
根据状态放行或拒绝
支付状态发生变化时:
支付平台 Webhook
↓
验证 Webhook 签名
↓
更新 Database 中的订阅记录
↓
删除或刷新 Redis 缓存
手机和电脑访问同一个服务端缓存和 Database,不再依赖各自 JWT 中的旧支付日期。
Node.js 示例
下面以 Express 和 Redis 为例。具体字段需要按照所使用的支付平台和业务规则调整。
订阅权限查询
const SUBSCRIPTION_CACHE_TTL_SECONDS = 60;
async function getSubscriptionAccess(userId) {
const cacheKey = `subscription:access:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached);
}
const subscription = await db.subscription.findUnique({
where: { userId },
select: {
status: true,
currentPeriodEnd: true
}
});
const now = Date.now();
const result = {
allowed:
subscription?.status === 'active' &&
subscription.currentPeriodEnd.getTime() > now,
status: subscription?.status ?? 'none',
currentPeriodEnd:
subscription?.currentPeriodEnd?.toISOString() ?? null
};
await redis.set(
cacheKey,
JSON.stringify(result),
{ EX: SUBSCRIPTION_CACHE_TTL_SECONDS }
);
return result;
}
这里的 status === 'active' 只是示例。实际项目可能还要处理 trialing、宽限期,以及订阅已经取消但当前周期仍然有效等状态。
付费路由中间件
async function requirePaidSubscription(req, res, next) {
try {
const userId = req.auth.userId;
const access = await getSubscriptionAccess(userId);
if (!access.allowed) {
return res.status(402).json({
code: 'SUBSCRIPTION_REQUIRED',
paymentUrl: '/billing/checkout'
});
}
next();
} catch (error) {
next(error);
}
}
如果应用同时提供网页端和 API,可以由服务端返回明确的错误码,再让前端决定是否跳转:
const response = await fetch('/api/protected-resource', {
headers: {
Authorization: `Bearer ${token}`
}
});
if (response.status === 402) {
const body = await response.json();
window.location.assign(body.paymentUrl);
}
对于纯浏览器页面,也可以由中间件直接执行重定向:
return res.redirect(303, '/billing/checkout');
不能只在前端检查。真正的付费资源必须在服务端再次验证权限。
支付成功后立即同步状态
缓存 TTL 只能保证状态最终完成更新。如果要让付款状态立即在其他设备上生效,就需要在 Webhook 中主动使缓存失效。
app.post(
'/webhooks/payment',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = verifyWebhookSignature(req.body, req.headers);
} catch {
return res.sendStatus(400);
}
const eventAlreadyHandled = await db.webhookEvent.findUnique({
where: { eventId: event.id }
});
if (eventAlreadyHandled) {
return res.sendStatus(200);
}
const subscriptionUpdate = mapPaymentEvent(event);
if (!subscriptionUpdate) {
return res.sendStatus(200);
}
await db.$transaction(async (tx) => {
await tx.subscription.upsert({
where: { userId: subscriptionUpdate.userId },
create: subscriptionUpdate,
update: subscriptionUpdate
});
await tx.webhookEvent.create({
data: {
eventId: event.id,
processedAt: new Date()
}
});
});
await redis.del(
`subscription:access:${subscriptionUpdate.userId}`
);
return res.sendStatus(200);
}
);
verifyWebhookSignature() 和 mapPaymentEvent() 是占位函数,需要根据支付平台提供的 SDK 或签名规则实现。不能因为请求体中带有 userId 或 status,就直接信任这个 Webhook。
缓存删除后,任意设备的下一次请求都会重新读取 Database,拿到最新状态。
付款完成页面也可以主动刷新
Webhook 可能会晚于用户返回站点。付款完成页面可以向服务端发起一次状态刷新请求:
app.post('/api/subscription/refresh', async (req, res, next) => {
try {
const userId = req.auth.userId;
const remoteSubscription =
await paymentProvider.getSubscriptionForUser(userId);
await db.subscription.update({
where: { userId },
data: {
status: remoteSubscription.status,
currentPeriodEnd: remoteSubscription.currentPeriodEnd
}
});
await redis.del(`subscription:access:${userId}`);
res.json({ refreshed: true });
} catch (error) {
next(error);
}
});
这个接口应加入身份验证、频率限制和权限控制。它的作用是改善付款后的即时体验,Webhook 仍然是异步同步的主要入口。
缓存时间如何选择
TTL 没有统一的最佳值,需要结合业务风险和访问量来决定:
- 低风险的内容服务可以缓存几十秒到几分钟。
- 高价值下载、计费操作或敏感功能应使用更短的 TTL,必要时直接读取 Database。
- 付款成功后可以立即删除缓存,让新权限马上生效。
- 退款、拒付、封禁或管理员撤销权限时,也应立即删除缓存。
还可以采用不对称的缓存策略:
const ttl = result.allowed ? 60 : 10;
await redis.set(cacheKey, JSON.stringify(result), { EX: ttl });
缩短未付费状态的缓存时间,可以减少用户付款后仍被拒绝的情况。如果 Webhook 和主动失效机制足够可靠,通常不必把 TTL 设置得特别短。
需要特别注意的细节
不要只比较 next_payment_date
支付日期已到并不一定意味着订阅已经失效。支付平台可能还在重试扣款,业务也可能设有宽限期。判断权限时可以保存并检查以下信息:
- 订阅状态;
- 当前计费周期结束时间;
- 是否处于试用期;
- 是否处于宽限期;
- 是否已经退款、拒付或撤销。
最终的判断规则应由业务定义,并与支付平台的状态模型对应。
Webhook 必须支持幂等
支付平台可能重复发送同一个事件。应记录事件 ID,避免同一事件重复延长订阅,或多次执行其他操作。
还要处理事件乱序。更新订阅时可以保存支付平台的事件时间、订阅版本或周期结束时间,防止较旧的事件覆盖新状态。
防止缓存击穿
热门用户的缓存失效,或者大量缓存同时失效时,多个并发请求可能一起查询 Database。可以通过短锁或请求合并进行控制:
const lockKey = `lock:subscription:${userId}`;
const locked = await redis.set(lockKey, '1', {
NX: true,
EX: 5
});
只有获得锁的请求负责回源,其他请求短暂重试。多数普通应用可以先使用简单的 cache-aside 模式,等确认存在并发压力后,再增加这一层处理。
明确缓存故障时的策略
如果 Redis 不可用,可以回退到 Database:
try {
return await getSubscriptionAccessFromCache(userId);
} catch (error) {
logger.warn({ error, userId }, 'Subscription cache unavailable');
return getSubscriptionAccessFromDatabase(userId);
}
如果 Database 也不可用,高价值付费操作通常应该拒绝访问,也就是采取失败时关闭的策略。是否允许用户短暂继续访问,需要根据业务风险决定。
最终建议
采用「JWT 身份认证 + Database 持久状态 + Redis 短期缓存 + Webhook 主动失效」的组合。
JWT 可以保存 userId,但不能把它当作实时订阅凭证。处理付费请求时,先读取共享缓存,缓存未命中再访问 Database。支付状态发生变化后,更新 Database 并立即清理缓存。这样既能解决多设备状态不一致的问题,也不需要让每个请求都直接访问 Database。
备注:内容仅供参考。
