7.9 KiB
7.9 KiB
计费与用量设计(Stripe + 硬配额)- ImageForge
目标:形成“套餐/订阅/用量/发票/支付/风控”可落地的闭环,为后续实现提供清晰边界与数据模型依据。
1. 计费模型(已确认)
已确认口径:
- 支付渠道:Stripe
- 超额策略:硬配额(超过当期额度返回
QUOTA_EXCEEDED/ HTTP402) - 配额周期:按订阅周期(Stripe
current_period_start~current_period_end),不是自然月
1.0 周期定义(统一口径)
为避免前后端与账本口径不一致,本项目将“周期”分为三类:
- Pro/Business(付费):按 Stripe 订阅周期(
current_period_start~current_period_end)。 - Free(未订阅):按自然月(UTC+8)重置,用于展示“本月已用/剩余/重置时间”。
- 匿名试用:按自然日(UTC+8)重置,仅用于网站试用(默认每日 10 次)。
1.1 计量单位
compression_unit:每成功压缩 1 个输出文件计 1 单位。
规则:
- 同一任务中失败的文件不计费。
- 同一请求重试若携带相同
Idempotency-Key,不重复计费(返回相同结果)。 - 输出格式转换(png->webp 等)不额外加价(首期),后续可按“高级功能”计价。
1.2 套餐(Plan)
套餐是“功能 + 配额 + 限制”的集合,建议存为可配置(DB 或配置中心)。
最小字段:
included_units_per_period:周期含量(对付费为订阅周期;对 Free 为自然月)。max_file_size_mb/max_files_per_batch/concurrency_limitretention_days:结果保留期。features:例如 webhook、团队、IP 白名单等开关。
1.3 订阅(Subscription)
采用“月度订阅 + 含量(硬配额)”:
- 用户在每个订阅周期获得固定含量(
included_units_per_period)。 - 超出含量:直接拒绝(
QUOTA_EXCEEDED/ HTTP402)。
2. 用量计算与配额扣减(Metering)
2.1 何时扣减
- 成功生成输出时扣减(以文件粒度)。
- 对于异步任务:Worker 完成文件压缩后写入用量事件;API/前端通过任务查询看到实时用量。
2.2 幂等与去重
需要两层保护:
- 请求幂等:
Idempotency-Key防止重复创建任务/重复扣减。 - 用量幂等:每个输出文件生成唯一
usage_event_id(或以task_file_id唯一)确保不会重复入账。
2.3 用量数据结构(建议)
usage_events:明细账本(append-only),用于可追溯与对账。user_id/api_key_id/source(web/api)task_id/task_file_idunits(通常为 1)bytes_in/bytes_out/format_in/format_outoccurred_at
usage_periods:按订阅周期聚合(加速配额判断)。user_id+period_start+period_end唯一used_units、bytes_in、bytes_out等
配额判断:
- 写入
usage_events前先检查本周期used_units是否已达included_units_per_period(并发场景需原子性:事务/锁/原子计数)。 - 失败时返回
QUOTA_EXCEEDED,并在响应中附带“已用/上限/重置时间”。
2.4 并发安全的扣减算法(推荐)
目标:在高并发下也不能“超扣”。
推荐做法(Worker 侧每成功文件 1 次扣减):
- 先拿到当期
included_units(来自 plan)。 - 用单条 SQL 做“条件更新”,只有未超额时才 +1:
UPDATE usage_periods
SET used_units = used_units + 1,
bytes_in = bytes_in + $1,
bytes_out = bytes_out + $2,
updated_at = NOW()
WHERE user_id = $3
AND period_start = $4
AND period_end = $5
AND used_units + 1 <= $6
RETURNING used_units;
如果 RETURNING 无行(0 行更新),表示额度已耗尽:该文件应标记为 QUOTA_EXCEEDED,并丢弃输出(不返回/不落盘/不入账)。
说明:为保持“成功才扣减”的口径,2.4 默认按“成功后尝试扣减”实现;在配额临界点可能出现“先压缩、后发现额度不足”的少量无效计算。若要完全避免,可升级为“预留额度 + 失败回滚”的方案(复杂度更高,后续可做 V1+ 优化)。
2.5 批量任务与配额(硬配额)
为尽量减少“上传后才发现额度不足”的体验问题,建议双层策略:
- 创建任务前预检:
remaining_units < files_count时直接返回402 QUOTA_EXCEEDED(不创建任务)。 - 扣减时最终校验:写入账本/聚合时仍按 2.4 做原子扣减,防止并发竞态导致超额。
说明:
- 批量任务的计量仍以“成功文件数”为准;失败文件(含
QUOTA_EXCEEDED)不计费。 - 前端建议在上传前调用
GET /billing/usage(登录)或读取配额头(API)做本地提示/拦截。
3. 订阅生命周期(状态机)
3.1 状态
建议:
trialing:试用中(可选)active:正常past_due:欠费(支付失败)paused:暂停(可选)canceled:已取消incomplete:创建未完成(例如支付页未完成)
3.2 关键动作
- 升级/降级套餐:下周期生效(首期建议),避免按天结算复杂度。
- 取消订阅:
cancel_at_period_end=true,到期生效;到期后自动降级到 Free(不续费)。
4. 支付集成(Stripe)
4.1 核心对象映射(建议)
users.billing_customer_id↔ Stripecustomer.idplans↔ Stripeproduct/price(建议每个 plan 对应一个 Stripeprice)subscriptions.provider_subscription_id↔ Stripesubscription.idinvoices.provider_invoice_id↔ Stripeinvoice.idpayments.provider_payment_id↔ Stripepayment_intent.id(或 charge id,按实现选)
4.2 Checkout / Portal
- Checkout:后端创建 Stripe Checkout Session,前端跳转
checkout_url。 - Portal:后端创建 Stripe Billing Portal Session,前端跳转管理支付方式/取消订阅。
4.3 Stripe Webhook(商用必须)
要求:
- 验签:使用
STRIPE_WEBHOOK_SECRET校验Stripe-Signature。 - 事件幂等:按
provider_event_id去重(落库webhook_events)。 - 乱序容忍:以 Stripe 事件时间 + 当前 DB 状态做“只前进不回退”更新。
- 可重放:保存原始 payload(脱敏)用于排查。
建议首期处理的事件(示例):
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
5. 发票与对账
5.1 发票(Invoice)
发票用于:
- 向用户展示本期费用、税务信息(后续)、支付状态。
- 与支付记录/订阅周期关联。
最小字段:
invoice_number(展示用)currency、total_amountstatus:draft/open/paid/void/uncollectible(可简化)period_start/period_endpaid_at
5.2 对账原则
- 以
usage_events为最终真相(source of truth)。 - 以
invoice为结算结果(面向用户)。 - 管理后台可以按用户/周期拉取用量明细,支持导出 CSV。
6. 风控策略(与计费强相关)
6.1 防盗刷
- API Key 支持:权限范围、速率限制、(可选)IP 白名单、(可选)域名白名单(仅对 Web 场景有效)。
- 异常检测:短时间内用量突增、来自异常国家/ASN、重复失败率高。
6.2 欠费与滥用处理
- 欠费(past_due):建议限制创建新任务,并在 UI 引导用户去 Portal 完成支付。
- 严重滥用:冻结用户/API Key,并记录审计日志。
7. 需要在文档中保持一致的“最终口径”
上线前必须定稿并在 docs/api.md、docs/database.md、docs/ui.md 同步:
- 套餐表(默认配额值)。
- 配额刷新周期(按订阅周期)。
- 超额策略(硬配额 / HTTP 402)。
- 匿名试用是否开放、开放到什么程度。