192 lines
7.9 KiB
Markdown
192 lines
7.9 KiB
Markdown
# 计费与用量设计(Stripe + 硬配额)- ImageForge
|
||
|
||
目标:形成“套餐/订阅/用量/发票/支付/风控”可落地的闭环,为后续实现提供清晰边界与数据模型依据。
|
||
|
||
---
|
||
|
||
## 1. 计费模型(已确认)
|
||
|
||
已确认口径:
|
||
- 支付渠道:**Stripe**
|
||
- 超额策略:**硬配额**(超过当期额度返回 `QUOTA_EXCEEDED` / HTTP `402`)
|
||
- 配额周期:**按订阅周期**(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_limit`
|
||
- `retention_days`:结果保留期。
|
||
- `features`:例如 webhook、团队、IP 白名单等开关。
|
||
|
||
### 1.3 订阅(Subscription)
|
||
采用“月度订阅 + 含量(硬配额)”:
|
||
- 用户在每个订阅周期获得固定含量(`included_units_per_period`)。
|
||
- 超出含量:直接拒绝(`QUOTA_EXCEEDED` / HTTP `402`)。
|
||
|
||
---
|
||
|
||
## 2. 用量计算与配额扣减(Metering)
|
||
|
||
### 2.1 何时扣减
|
||
- **成功生成输出**时扣减(以文件粒度)。
|
||
- 对于异步任务:Worker 完成文件压缩后写入用量事件;API/前端通过任务查询看到实时用量。
|
||
|
||
### 2.2 幂等与去重
|
||
需要两层保护:
|
||
1) **请求幂等**:`Idempotency-Key` 防止重复创建任务/重复扣减。
|
||
2) **用量幂等**:每个输出文件生成唯一 `usage_event_id`(或以 `task_file_id` 唯一)确保不会重复入账。
|
||
|
||
### 2.3 用量数据结构(建议)
|
||
- `usage_events`:明细账本(append-only),用于可追溯与对账。
|
||
- `user_id` / `api_key_id` / `source`(web/api)
|
||
- `task_id` / `task_file_id`
|
||
- `units`(通常为 1)
|
||
- `bytes_in` / `bytes_out` / `format_in` / `format_out`
|
||
- `occurred_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:
|
||
|
||
```sql
|
||
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` ↔ Stripe `customer.id`
|
||
- `plans` ↔ Stripe `product/price`(建议每个 plan 对应一个 Stripe `price`)
|
||
- `subscriptions.provider_subscription_id` ↔ Stripe `subscription.id`
|
||
- `invoices.provider_invoice_id` ↔ Stripe `invoice.id`
|
||
- `payments.provider_payment_id` ↔ Stripe `payment_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.completed`
|
||
- `customer.subscription.created`
|
||
- `customer.subscription.updated`
|
||
- `customer.subscription.deleted`
|
||
- `invoice.paid`
|
||
- `invoice.payment_failed`
|
||
|
||
---
|
||
|
||
## 5. 发票与对账
|
||
|
||
### 5.1 发票(Invoice)
|
||
发票用于:
|
||
- 向用户展示本期费用、税务信息(后续)、支付状态。
|
||
- 与支付记录/订阅周期关联。
|
||
|
||
最小字段:
|
||
- `invoice_number`(展示用)
|
||
- `currency`、`total_amount`
|
||
- `status`:draft/open/paid/void/uncollectible(可简化)
|
||
- `period_start` / `period_end`
|
||
- `paid_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)。
|
||
- 匿名试用是否开放、开放到什么程度。
|