PAYATHON 2026

Node 中如何高效检查用户订阅支付状态?

支付老李

结论

不要把 next_payment_date 当作长期有效的支付凭证写入 JWT,也不需要每次请求都直接查询 Database。

可以采用以下方案:

  1. JWT 只保存用户身份和较为稳定的授权信息,例如 userId。
  2. Database 保存订阅状态,作为应用侧持久化的事实来源。
  3. Redis 等缓存保存短期订阅权限。
  4. 支付成功、续费失败、退款或订阅取消时,通过支付平台的 Webhook 更新 Database,同时刷新或删除缓存。
  5. 请求付费路由时先查缓存,未命中时再查 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。

备注:内容仅供参考。