Skip to main content
LadeStack LogoLadeStack
SaaS Architecture 约 11 分钟 2024年7月28日

构建高可用订阅计费系统:生命周期、Webhook 与幂等性设计

面向生产级 SaaS 订阅计费系统的实战架构指南,涵盖 Stripe 集成、加密 Webhook 校验、幂等状态机设计以及多租户权益同步机制。

SaaS 订阅计费系统架构设计图

引言:计费工程的分布式复杂性

在 SaaS 工程实践中,许多团队往往低估了订阅计费系统的复杂度,误以为仅仅是嵌入一个结账组件并在数据库中记录一个 subscription_id。然而在实际生产环境中,计费模块是企业运行的最核心且最不容有失的分布式系统之一。

一个高可用的计费引擎必须能够优雅地处理异步支付确认、临时网络分区、乱序到达的 Webhook 事件、信用卡到期、按比例折算(Proration)的升降级以及多租户访问权益(Entitlements)同步。任何状态漏洞都可能导致收入流失或错误冻结正常用户的权限。

本文将深入探讨生产级订阅计费系统的架构设计与实现策略。


1. 核心架构与关注点分离

在编写代码前,必须在系统层面确立支付处理与应用授权的严格分离:

[客户端浏览器]
      │
      │ 1. 发起结账 / 更新支付方式
      ▼
[SaaS 应用服务端] ──── 2. 创建会话 / 客户档案 ─────► [Stripe / 计费服务商]
      │                                                           │
      │ 4. 查询租户权限 (本地 DB / 缓存)                           │
      ▼                                                           │ 3. 异步分发事件
[应用数据库 / Redis] ◄───── 5. 幂等 Webhook 处理 ◄────────────────┘
(租户, 订阅状态, 账单)

核心设计规范

  1. 绝不直接接触信用卡敏感数据(PAN): 严禁任何信用卡主账号和 CVC 代码经过自家服务器。全部采用 Stripe Elements 等客户端令牌化方案,严格控制在 PCI DSS SAQ A 合规范围以内。
  2. 以 Webhook 作为单一事实来源(SSOT): 严禁在结账重定向的 HTTP 同步响应中直接开通权限。网络超时或用户关闭页面会导致状态脱节,持久化状态必须由加密校验过的 Webhook 驱动。
  3. 解耦支付产品 ID 与应用功能权限: 业务层应检查 tenant.hasFeature('advanced_analytics'),而非在代码中硬编码 stripe_price_id === 'price_xxx'。

2. 订阅生命周期状态机

Stripe 订阅包含以下典型状态流转:

  • incomplete: 等待 3D 认证(3DS)或强客户身份认证(SCA)完成。
  • active: 扣款成功,租户所有对应权益完全激活。
  • past_due: 续费失败(如卡内余额不足、卡片过期)。切忌立即封锁访问,应进入缓冲宽限期(Grace Period)并触发催缴(Dunning)通知。
  • canceled: 订阅已终止。通常保持权限至当前计费周期结束(cancel_at_period_end: true)。
  • unpaid: 智能重试完全耗尽后的最终状态,系统应降级为免费版或只读模式。

3. Webhook 签名验证与幂等消费

Stripe 在网络投递失败时会进行带退避算法的重试,这意味着系统必须同时应对至少一次投递(At-Least-Once Delivery)和事件乱序到达。

TypeScript 签名校验实现

import express, { Request, Response } from 'express';
import Stripe from 'stripe';
import { processWebhookEvent } from './billingService';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
  apiVersion: '2024-06-20',
});

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET as string;

// 注意:签名计算必须使用未经解析的 raw Buffer 载荷
export const webhookHandler = async (req: Request, res: Response): Promise<void> => {
  const sig = req.headers['stripe-signature'];

  if (!sig || typeof sig !== 'string') {
    res.status(400).send('缺少 Stripe-Signature 请求头');
    return;
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err: any) {
    console.error(`Webhook 验签失败: ${err.message}`);
    res.status(400).send(`Webhook Error: ${err.message}`);
    return;
  }

  try {
    await processWebhookEvent(event);
    res.status(200).json({ received: true });
  } catch (err: any) {
    console.error(`处理事件失败 ${event.id}:`, err);
    res.status(500).json({ error: '服务端消费异常' });
  }
};

4. 数据库幂等性表与事务保护

在 PostgreSQL 中引入幂等性注册表以杜绝重复扣费或重复通知:

-- Webhook 审计与幂等表
CREATE TABLE billing_webhook_events (
    id VARCHAR(255) PRIMARY KEY, -- Stripe 事件 ID (evt_xxx)
    event_type VARCHAR(100) NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    payload JSONB NOT NULL
);

-- 租户订阅记录表
CREATE TABLE tenant_subscriptions (
    tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
    stripe_customer_id VARCHAR(255) UNIQUE NOT NULL,
    stripe_subscription_id VARCHAR(255) UNIQUE,
    stripe_price_id VARCHAR(255),
    plan_tier VARCHAR(50) NOT NULL DEFAULT 'free',
    status VARCHAR(50) NOT NULL DEFAULT 'active',
    current_period_start TIMESTAMPTZ,
    current_period_end TIMESTAMPTZ,
    cancel_at_period_end BOOLEAN DEFAULT FALSE,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

事务级幂等消费逻辑

import { db } from './db';
import Stripe from 'stripe';

export async function processWebhookEvent(event: Stripe.Event): Promise<void> {
  const client = await db.getClient();

  try {
    await client.query('BEGIN');

    // 1. 行级排他锁校验是否已处理
    const existing = await client.query(
      'SELECT id FROM billing_webhook_events WHERE id = $1 FOR UPDATE',
      [event.id]
    );

    if (existing.rows.length > 0) {
      console.log(`事件 ${event.id} 已消费,直接跳过。`);
      await client.query('ROLLBACK');
      return;
    }

    // 2. 记录事件唯一标识
    await client.query(
      'INSERT INTO billing_webhook_events (id, event_type, payload) VALUES ($1, $2, $3)',
      [event.id, event.type, JSON.stringify(event)]
    );

    // 3. 执行核心业务状态变更
    switch (event.type) {
      case 'customer.subscription.created':
      case 'customer.subscription.updated': {
        const subscription = event.data.object as Stripe.Subscription;
        await syncSubscriptionState(client, subscription);
        break;
      }
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        await handleSubscriptionCanceled(client, subscription);
        break;
      }
    }

    await client.query('COMMIT');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

5. 租户权限同步与 Redis 高速缓存

数据库更新后,应及时刷新分布式 Redis 缓存以满足高频接口鉴权需求:

import { PoolClient } from 'pg';
import Stripe from 'stripe';
import { redisClient } from './redis';

async function syncSubscriptionState(client: PoolClient, sub: Stripe.Subscription): Promise<void> {
  const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
  const priceId = sub.items.data[0]?.price.id;
  const tier = priceId === 'price_enterprise' ? 'enterprise' : 'pro';

  const result = await client.query(
    `UPDATE tenant_subscriptions
     SET stripe_subscription_id = $1,
         stripe_price_id = $2,
         plan_tier = $3,
         status = $4,
         current_period_start = to_timestamp($5),
         current_period_end = to_timestamp($6),
         cancel_at_period_end = $7,
         updated_at = NOW()
     WHERE stripe_customer_id = $8
     RETURNING tenant_id`,
    [sub.id, priceId, tier, sub.status, sub.current_period_start, sub.current_period_end, sub.cancel_at_period_end, customerId]
  );

  if (result.rows.length === 0) return;
  const tenantId = result.rows[0].tenant_id;

  // 写入高速权限缓存
  const cacheKey = `entitlements:tenant:${tenantId}`;
  await redisClient.set(cacheKey, JSON.stringify({
    tier,
    status: sub.status,
    seatLimit: tier === 'enterprise' ? 100 : 10,
    canAccessAI: true
  }), 'EX', 86400);
}

总结与架构建议

订阅计费工程的核心是在跨系统边界的分布式环境下确保状态的一致性与健壮性。通过严格执行签名验签、数据库事务级幂等性记录以及轻量化的租户权限缓存,您的 SaaS 系统便能在交易量快速攀升时依旧稳如磐石。

延伸阅读

More Engineering Articles