Files
easyai-ai-gateway/docs/decisions/002-billing-correctness-v2.md
chengcheng 01a013c809 docs(billing): 固化计费闭环决策与迁移基础
新增 ADR-002、计费流程说明和 0069 增量迁移,建立独立计费状态、结算 Outbox、显式免费与钱包约束。历史成功未扣费任务仅进入人工复核,不执行追扣。\n\n验证:迁移安全验证与 tests/ci/migrations-test.sh 通过。
2026-07-20 23:09:18 +08:00

87 lines
5.4 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.
# ADR-002:计费正确性闭环与即时估价
## 状态
Accepted
## 日期
2026-07-20
## 背景
Gateway 已具备 PostgreSQL 本地钱包、任务冻结、任务扣费和钱包流水,但任务终态与钱包结算分属不同事务。进程在任务成功写入后、扣费前或释放前退出时,可能留下成功未扣费或永久冻结。现有估价只计算首选候选,缺价会退化为普通零价,且文本输出上限和精度规则与正式结算并不完全一致。
Comfy 的业务流程证明了三项做法有参考价值:任务状态与计费状态分离;计费操作使用独立令牌和过期接管;参数变化后立即生成只读估价。Gateway 不采用 Comfy 的三十分钟虚拟预占、结算时刷新最新价格、公开估价接口和负余额策略,因为这些做法会削弱本地钱包作为唯一账务事实源的约束。
## 决策
### 账务事实源与状态
Gateway PostgreSQL 本地钱包是唯一账务事实源,不与 Comfy 或其他系统双写。任务状态与计费状态独立:生成成功可以处于 `succeeded + pending`,结算失败不得篡改生成结果。
计费状态为:
- `not_started`:尚未产生账务动作。
- `pending`:结算或释放已进入 Outbox。
- `processing`Worker 已领取账务动作。
- `settled`:已幂等扣费并释放冻结。
- `released`:失败或取消任务的冻结已释放。
- `retryable_failed`:可自动重试。
- `manual_review`:上游结果不明或连续失败,需要人工处理。
- `not_required`:模拟任务、无本地钱包用户或其他明确无需计费的任务。
### 统一计价
估价、提交冻结和最终账单统一使用 `effective-pricing-v2`。规则优先级为平台模型、平台、基准模型;只接受处于生效期内、状态有效、币种为 `resource` 且计算器受支持的规则。普通零价、缺价或失效规则返回 `503 pricing_unavailable`;只有 `isFree=true` 的显式免费规则允许零费用。
金额决策使用 9 位十进制定点数。文本费用按实际 Token 比例计算,不按千 Token 向上取整;视频以五秒为一个单位向上取整。估价对所有有效候选计算并以最大值作为冻结上限,首选候选费用继续作为 `totalAmount`
前端估价是只读提示,不构成价格承诺。正式提交必须重新计价、冻结,并保存不可变计价快照。
### 结算闭环
成功 Attempt、任务成功结果、最终金额、计价快照和 `settle` Outbox 在同一个数据库事务写入;失败和取消任务原子写入 `release` Outbox。Worker 使用 `FOR UPDATE SKIP LOCKED` 批量领取,锁超时后允许其他实例接管,并在钱包行锁事务中完成幂等扣费或释放、任务计费状态和 Outbox 完成状态。
上游提交状态分为 `not_submitted``submitting``response_received`。处于 `submitting` 且结果不明的任务保留冻结并进入人工复核,不自动重试上游。
### 请求幂等与执行租约
生成接口可选接受单值 `Idempotency-Key`。数据库只保存键摘要以及规范化请求摘要;同键同请求按协议重放,同键不同请求返回 `409 idempotency_key_reused`,流式重复返回 `409 idempotency_stream_replay_unsupported`。日志和审计记录不得包含原始键或完整请求正文。
任务由带随机 `execution_token` 的五分钟租约领取,每三十秒续约。运行态更新和终态提交必须匹配令牌,旧 Worker 不得覆盖接管后的新结果。
## 账务不变量
1. `balance >= frozen_balance >= 0`
2. 同一任务和币种最多存在一笔有效扣费。
3. 一个冻结必须最终被结算消费或释放,不能同时发生两者。
4. 正式任务缺价时不得调用上游。
5. 最终扣费只能使用提交时保存的计价快照,不能读取更新后的价格。
6. 估价不得创建任务、冻结余额或写钱包流水。
7. 历史成功未扣费任务只标记人工复核,不自动追扣。
## 异常矩阵
| 发生点 | 任务状态 | 计费状态 | 自动动作 |
| --- | --- | --- | --- |
| 计价失败 | 未创建或失败 | `not_started` | 返回 `pricing_unavailable`,不调用上游 |
| 冻结失败 | 已创建 | `not_started` | 返回余额不足,不调用上游 |
| 上游提交前失败 | 失败 | `pending` | Outbox 释放冻结 |
| 上游提交结果不明 | 运行中或失败 | `manual_review` | 保留冻结,禁止自动重试上游 |
| 上游明确失败 | 失败 | `pending` | Outbox 释放冻结 |
| 生成成功、结算待执行 | 成功 | `pending` | 返回成功结果,Worker 异步结算 |
| 结算暂时失败 | 成功 | `retryable_failed` | 指数退避重试 |
| 结算连续失败 | 成功 | `manual_review` | 管理员审计化重试 |
## 历史数据策略
迁移只分类,不产生历史补扣:已有任务扣费流水的成功任务标记 `settled`;失败或取消任务的遗留冻结进入释放队列;成功但无扣费流水的生产任务进入 `manual_review`
## 后果
- 成功结果和账务暂时失败可以独立呈现,调用方必须读取 `billingStatus`
- 计价规则编辑器必须要求显式免费,并在规则不可用时阻止生产生成。
- 结算具有最终一致性,因此需要积压、延迟、重试和人工复核监控。
- 数据库增量在回滚时保留;应用切换到 `hold` 后拒绝新的生产任务,同时继续运行结算 Worker。