Files
ystp/docs/billing.md

192 lines
7.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 计费与用量设计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
- 匿名试用是否开放、开放到什么程度。