适合订阅制 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,适合计费模型非常特殊、现有组件无法表达的情况。开发账户页面和管理后台相对直接,账期计算、金额精度、幂等处理、状态机、退款、失败恢复和财务对账才是主要的时间成本。
备注:内容仅供参考。
