PAYATHON 2026

适合订阅制 web service 业务网站的最佳 framework 是什么?

选型许工

结论

通用 framework 很难可靠覆盖账户、套餐、用量计量、周期性计费、支付、权限控制和运营后台。实际落地时,通常需要按职责拆成边界清楚的模块:

  • 使用 Django、Ruby on Rails、Laravel、Spring Boot 或类似框架开发账户系统、自助控制台和管理后台。
  • 由支付服务商处理支付凭证、扣款和退款。
  • 使用专门的 subscription billing 系统管理订阅、账单、优惠和欠费状态。
  • 单独开发用量采集与额度控制模块。
  • 通过 API key 或 OAuth 2.0,让 CMS plugin 和不同语言的 SDK 访问服务。

如果倾向开源并准备在 Linux 上自托管,可以评估 Kill Bill、Lago 等 billing 项目。这些项目主要解决计费问题,完整的 SaaS 网站仍需自行开发。项目功能、许可证、支付渠道和维护状态都可能变化,选型时要根据当前版本逐项验证。

为什么不建议使用大而全的 framework

这些需求属于不同领域,各自的生命周期也不相同:

领域主要职责
Web 应用注册、登录、账户资料、自助控制台
身份与权限API key、OAuth、角色、套餐权限
订阅管理套餐、升级、降级、试用、暂停、取消
支付支付凭证、扣款、退款、拒付
用量计量记录请求次数、流量或其他计费单位
账单账单周期、税费、折扣、发票、欠费
运营后台用户管理、审计、消息模板、人工处理
服务运维监控、告警、状态页、日志

Web framework 适合开发账户、页面和后台,但一般不会完整处理按比例计费、失败重试、退款、税务和账务对账。billing 系统负责计费,却无法自行判断某个 API 请求是否应该放行。

如果把这些能力都塞进同一个应用,调整套餐或更换支付渠道时很容易牵动核心服务。拆分模块后,测试会更简单,也能逐步更换供应商。

推荐的系统结构

系统可以分为控制面和数据面。

控制面处理:

  • 用户注册、登录和资料维护
  • 套餐选择与订阅状态
  • 支付页面和账单历史
  • API key 管理
  • 管理员操作与审计
  • 套餐、优惠码和消息模板配置

数据面处理:

  • 校验 API key
  • 判断账户是否可用
  • 检查配额或速率限制
  • 执行业务请求
  • 异步上报用量

业务 API 无需在每个请求中同步调用支付平台,只需读取本地保存的授权状态,例如 active、past_due 或 suspended。支付和订阅状态则通过 webhook 同步到本地数据库。

CMS Plugin / SDK
        |
        v
API Gateway -> API Key 校验 -> 配额检查 -> Web Service
                                   |
                                   v
                              用量事件队列
                                   |
                                   v
                           Metering / Billing

用户浏览器 -> 自助控制台 -> Subscription Billing -> Payment Provider
                                   |
                                Webhook
                                   |
                                   v
                         本地订阅状态与权限

实施步骤

1. 定义套餐和计费规则

写代码前,先确定这些规则:

  • 免费额度按自然月还是滚动周期计算
  • 超出额度后拒绝请求、限速还是按量收费
  • 升级是否立即生效
  • 降级是否等到下个账期
  • 取消后是否保留到当前周期结束
  • 欠费用户可以使用多久
  • 同一次请求失败重试时是否重复计量
  • 用量按请求数、数据量、处理时间还是其他单位计算

这些规则会影响数据库结构和最终的账单结果,只定义一个含义模糊的 plan 字段通常无法满足需要。

2. 建立最小领域模型

至少需要以下实体:

CREATE TABLE accounts (
    id              BIGINT PRIMARY KEY,
    status          VARCHAR(32) NOT NULL,
    billing_email   VARCHAR(255),
    created_at      TIMESTAMP NOT NULL
);

CREATE TABLE subscriptions (
    id                      BIGINT PRIMARY KEY,
    account_id              BIGINT NOT NULL REFERENCES accounts(id),
    plan_code               VARCHAR(64) NOT NULL,
    status                  VARCHAR(32) NOT NULL,
    provider_subscription_id VARCHAR(255),
    current_period_start    TIMESTAMP,
    current_period_end      TIMESTAMP,
    cancel_at_period_end    BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE api_keys (
    id              BIGINT PRIMARY KEY,
    account_id      BIGINT NOT NULL REFERENCES accounts(id),
    key_hash        VARCHAR(255) NOT NULL,
    revoked_at      TIMESTAMP,
    created_at      TIMESTAMP NOT NULL
);

CREATE TABLE usage_events (
    id              BIGINT PRIMARY KEY,
    event_key       VARCHAR(255) NOT NULL UNIQUE,
    account_id      BIGINT NOT NULL REFERENCES accounts(id),
    metric          VARCHAR(64) NOT NULL,
    quantity        BIGINT NOT NULL,
    occurred_at     TIMESTAMP NOT NULL
);

event_key 必须保持唯一,防止 SDK 重试或消息队列重复投递引发重复计费。数据库中只保存 API key 的哈希值,明文不能写入数据库或日志。

3. 在 API 入口检查授权和额度

下面是一个不依赖具体支付平台的 TypeScript 示例:

type AccessDecision =
  | { allowed: true; accountId: string }
  | { allowed: false; status: 401 | 403 | 429; reason: string };

async function authorizeRequest(apiKey: string): Promise<AccessDecision> {
  const keyRecord = await apiKeyRepository.findByHash(hashApiKey(apiKey));

  if (!keyRecord || keyRecord.revokedAt) {
    return { allowed: false, status: 401, reason: "invalid_api_key" };
  }

  const account = await accountRepository.findById(keyRecord.accountId);

  if (!account || account.status === "suspended") {
    return { allowed: false, status: 403, reason: "account_suspended" };
  }

  const entitlement = await entitlementService.getCurrent(account.id);

  if (!entitlement.enabled) {
    return { allowed: false, status: 403, reason: "subscription_inactive" };
  }

  const usage = await usageService.getCurrentPeriodUsage(
    account.id,
    entitlement.metric
  );

  if (usage >= entitlement.hardLimit) {
    return { allowed: false, status: 429, reason: "quota_exceeded" };
  }

  return { allowed: true, accountId: account.id };
}

授权结果可以短时间缓存。套餐升级、账户停用或 API key 吊销后,需要主动清除相关缓存。

4. 异步记录用量

业务请求成功后生成唯一的用量事件,再交给后台任务汇总:

await usageQueue.publish({
  eventKey: request.id,
  accountId: access.accountId,
  metric: "api_requests",
  quantity: 1,
  occurredAt: new Date().toISOString()
});

直接执行 usage = usage + 1 容易在并发请求、超时重试或消息重复投递时产生错误统计。事件表配合唯一键和幂等消费,可以避免大部分重复计量。

还需要在业务规则中明确一次有效使用的定义,包括参数校验未通过、服务内部失败或客户端中断时是否计费。

5. 通过 webhook 同步订阅状态

支付或 billing 系统发生扣款成功、扣款失败、订阅取消等事件后,通过 webhook 更新本地状态。处理程序需要验证签名,并保证重复处理同一事件不会产生额外影响:

async function handleBillingWebhook(
  rawBody: Buffer,
  signature: string
): Promise<void> {
  const event = billingProvider.verifyAndParse(rawBody, signature);

  if (await webhookRepository.exists(event.id)) {
    return;
  }

  await database.transaction(async (tx) => {
    await subscriptionService.applyProviderEvent(tx, event);
    await webhookRepository.markProcessed(tx, event.id);
  });
}

签名校验必须使用原始请求体。先解析成 JSON 再重新序列化,可能导致签名验证失败。处理程序也要兼容事件乱序,不能假设 webhook 会严格按照业务发生顺序送达。

开源组件怎么选

选型时应先看各组件的职责边界,再比较功能清单:

  • Kill Bill 主要处理订阅、账单和支付编排。
  • Lago 主要处理订阅计费及用量计费。
  • OpenMeter 一类工具主要负责用量采集和计量。
  • Django Admin、Rails Admin 或自建后台可用于账户审核和人工操作。
  • Prometheus、Grafana 等运维工具适合采集服务指标和发送告警,不适合承担账单统计。

正式选型前,至少完成一次覆盖注册、升级、降级、超额、扣款失败、补缴、退款和取消的概念验证。开源项目能否直接采用,要结合当前版本支持的支付渠道、税务要求、许可证、升级路径以及团队的维护能力判断。

支付信息的处理原则

网站通常不应直接接收或保存银行卡号。优先使用支付服务商提供的 hosted checkout、嵌入式安全组件或 tokenization,让敏感支付数据直接提交给支付服务商。本地只保存客户 ID、支付方式 token、卡片品牌、末四位等允许保存的信息。

支付页面即使由第三方托管,也要检查适用的 PCI DSS 范围、当地税务规则、发票要求、退款政策和数据保护要求。这些合规工作无法由技术框架代替。

容易被忽略的问题

  • 免费套餐也要建立 entitlement,避免代码中到处出现 if free_user。
  • 套餐价格需要保留版本,旧用户未必会自动使用新价格。
  • 管理员修改账户、额度或账单时,必须留下审计记录。
  • 消息发送失败不应回滚已经完成的扣款或订阅变更。
  • 账单用量和实时限流可能采用不同的一致性策略,需要定期对账。
  • 暂停、取消和删除账户是三种不同的操作,不宜共用一个布尔字段。
  • API key 需要支持轮换、撤销以及多个 key 并存。
  • 优惠码需要明确有效期、适用套餐、使用次数以及是否允许叠加。
  • 监控指标和可计费用量要分开保存,不能直接用监控采样数据生成账单。

实际选型建议

希望尽快上线的团队,可以采用成熟的 Web framework,并接入托管支付和 billing 服务,先完成自助控制台、权限同步和用量计量。

如果必须自托管,可以使用 Django、Rails、Laravel 或 Spring Boot 构建控制面,再接入 Kill Bill、Lago 等开源组件。同时要计入部署、升级、备份、对账和故障处理的成本。

自行开发完整 billing engine,适合计费模型非常特殊、现有组件无法表达的情况。开发账户页面和管理后台相对直接,账期计算、金额精度、幂等处理、状态机、退款、失败恢复和财务对账才是主要的时间成本。

备注:内容仅供参考。