diff --git a/apps/api/migrations/0069_billing_correctness_v2.sql b/apps/api/migrations/0069_billing_correctness_v2.sql new file mode 100644 index 0000000..87b0f9e --- /dev/null +++ b/apps/api/migrations/0069_billing_correctness_v2.sql @@ -0,0 +1,165 @@ +ALTER TABLE gateway_tasks + ADD COLUMN IF NOT EXISTS billing_version text NOT NULL DEFAULT 'effective-pricing-v2', + ADD COLUMN IF NOT EXISTS billing_status text NOT NULL DEFAULT 'not_started', + ADD COLUMN IF NOT EXISTS billing_currency text NOT NULL DEFAULT 'resource', + ADD COLUMN IF NOT EXISTS pricing_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS request_fingerprint text, + ADD COLUMN IF NOT EXISTS idempotency_key_hash text, + ADD COLUMN IF NOT EXISTS reservation_amount numeric(38, 9) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS execution_token uuid, + ADD COLUMN IF NOT EXISTS execution_lease_expires_at timestamptz, + ADD COLUMN IF NOT EXISTS billing_updated_at timestamptz, + ADD COLUMN IF NOT EXISTS billing_settled_at timestamptz; + +ALTER TABLE gateway_tasks + ADD CONSTRAINT gateway_tasks_billing_status_v2_check CHECK ( + billing_status IN ( + 'not_started', 'pending', 'processing', 'settled', 'released', + 'retryable_failed', 'manual_review', 'not_required' + ) + ) NOT VALID, + ADD CONSTRAINT gateway_tasks_reservation_amount_v2_check CHECK (reservation_amount >= 0) NOT VALID; + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_tasks_idempotency_hash_v2 + ON gateway_tasks(user_id, idempotency_key_hash) + WHERE idempotency_key_hash IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_gateway_tasks_execution_lease_v2 + ON gateway_tasks(status, execution_lease_expires_at) + WHERE status = 'running'; + +CREATE INDEX IF NOT EXISTS idx_gateway_tasks_billing_status_v2 + ON gateway_tasks(billing_status, billing_updated_at, created_at); + +ALTER TABLE gateway_task_attempts + ADD COLUMN IF NOT EXISTS pricing_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS request_fingerprint text, + ADD COLUMN IF NOT EXISTS upstream_submission_status text NOT NULL DEFAULT 'not_submitted', + ADD COLUMN IF NOT EXISTS upstream_submission_updated_at timestamptz; + +ALTER TABLE gateway_task_attempts + ADD CONSTRAINT gateway_task_attempts_submission_v2_check CHECK ( + upstream_submission_status IN ('not_submitted', 'submitting', 'response_received') + ) NOT VALID; + +ALTER TABLE settlement_outbox + ADD COLUMN IF NOT EXISTS action text NOT NULL DEFAULT 'settle', + ADD COLUMN IF NOT EXISTS amount numeric(38, 9) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'resource', + ADD COLUMN IF NOT EXISTS pricing_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS locked_by text, + ADD COLUMN IF NOT EXISTS lock_token uuid, + ADD COLUMN IF NOT EXISTS locked_at timestamptz, + ADD COLUMN IF NOT EXISTS last_error_code text, + ADD COLUMN IF NOT EXISTS last_error_message text, + ADD COLUMN IF NOT EXISTS completed_at timestamptz, + ADD COLUMN IF NOT EXISTS manual_review_reason text; + +ALTER TABLE settlement_outbox + ADD CONSTRAINT settlement_outbox_action_v2_check CHECK (action IN ('settle', 'release')) NOT VALID, + ADD CONSTRAINT settlement_outbox_status_v2_check CHECK ( + status IN ('pending', 'processing', 'retryable_failed', 'completed', 'manual_review') + ) NOT VALID, + ADD CONSTRAINT settlement_outbox_amount_v2_check CHECK (amount >= 0) NOT VALID; + +CREATE INDEX IF NOT EXISTS idx_settlement_outbox_claim_v2 + ON settlement_outbox(status, next_attempt_at, created_at) + WHERE status IN ('pending', 'retryable_failed'); + +CREATE INDEX IF NOT EXISTS idx_settlement_outbox_stale_v2 + ON settlement_outbox(status, locked_at) + WHERE status = 'processing'; + +ALTER TABLE model_pricing_rules + ADD COLUMN IF NOT EXISTS is_free boolean NOT NULL DEFAULT false; + +ALTER TABLE model_pricing_rules + ADD CONSTRAINT model_pricing_rules_explicit_free_v2_check CHECK ( + base_price > 0 OR (base_price = 0 AND is_free) + ) NOT VALID; + +ALTER TABLE gateway_wallet_accounts + ADD CONSTRAINT gateway_wallet_nonnegative_frozen_v2_check CHECK (frozen_balance >= 0) NOT VALID, + ADD CONSTRAINT gateway_wallet_balance_covers_frozen_v2_check CHECK (balance >= frozen_balance) NOT VALID; + +ALTER TABLE gateway_wallet_transactions + ADD CONSTRAINT gateway_wallet_transactions_account_restrict_v2 + FOREIGN KEY (account_id) REFERENCES gateway_wallet_accounts(id) ON DELETE RESTRICT NOT VALID; + +UPDATE gateway_tasks task +SET billing_status = 'not_required', + billing_updated_at = now() +WHERE task.run_mode <> 'production' + OR task.gateway_user_id IS NULL; + +UPDATE gateway_tasks task +SET billing_status = 'settled', + billing_currency = COALESCE(NULLIF(task.billing_summary->>'currency', ''), 'resource'), + billing_updated_at = now(), + billing_settled_at = COALESCE(task.finished_at, task.updated_at) +WHERE task.status = 'succeeded' + AND EXISTS ( + SELECT 1 + FROM gateway_wallet_transactions transaction + WHERE transaction.reference_type = 'gateway_task' + AND transaction.reference_id = task.id::text + AND transaction.transaction_type = 'task_billing' + ); + +UPDATE gateway_tasks task +SET billing_status = 'manual_review', + billing_currency = COALESCE(NULLIF(task.billing_summary->>'currency', ''), 'resource'), + billing_updated_at = now() +WHERE task.status = 'succeeded' + AND task.run_mode = 'production' + AND task.gateway_user_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM gateway_wallet_transactions transaction + WHERE transaction.reference_type = 'gateway_task' + AND transaction.reference_id = task.id::text + AND transaction.transaction_type = 'task_billing' + ); + +INSERT INTO settlement_outbox ( + task_id, event_type, action, amount, currency, payload, status, next_attempt_at +) +SELECT task.id, + 'task.billing.release', + 'release', + transaction.amount, + account.currency, + jsonb_build_object( + 'taskId', task.id, + 'reservationIdempotencyKey', transaction.idempotency_key, + 'classification', 'historical_terminal_reservation' + ), + 'pending', + now() +FROM gateway_tasks task +JOIN gateway_wallet_transactions transaction + ON transaction.reference_type = 'gateway_task' + AND transaction.reference_id = task.id::text + AND transaction.transaction_type = 'reserve' +JOIN gateway_wallet_accounts account ON account.id = transaction.account_id +WHERE task.status IN ('failed', 'cancelled') + AND COALESCE(transaction.idempotency_key, '') <> '' + AND NOT EXISTS ( + SELECT 1 + FROM gateway_wallet_transactions release + WHERE release.account_id = transaction.account_id + AND release.transaction_type = 'release' + AND release.idempotency_key = transaction.idempotency_key || ':release' + ) +ON CONFLICT (task_id, event_type) DO NOTHING; + +UPDATE gateway_tasks task +SET billing_status = 'pending', + billing_updated_at = now() +WHERE EXISTS ( + SELECT 1 + FROM settlement_outbox outbox + WHERE outbox.task_id = task.id + AND outbox.action = 'release' + AND outbox.status IN ('pending', 'processing', 'retryable_failed') +); diff --git a/docs/billing-flow-v2.md b/docs/billing-flow-v2.md new file mode 100644 index 0000000..4944897 --- /dev/null +++ b/docs/billing-flow-v2.md @@ -0,0 +1,63 @@ +# 计费业务流程 v2 + +## 正常流程 + +```mermaid +sequenceDiagram + participant UI as 前端 + participant API as Gateway API + participant DB as PostgreSQL + participant UP as 上游模型 + participant BW as 结算 Worker + + UI->>API: 参数变化后的只读估价 + API->>DB: 读取有效计价规则 + API-->>UI: 首选预计费用 + 候选最大冻结额 + UI->>API: 正式提交(可带 Idempotency-Key) + API->>DB: 重新计价、保存快照并冻结最大金额 + API->>DB: 领取执行租约 + API->>UP: 提交已预处理请求 + UP-->>API: 成功结果与实际用量 + API->>DB: 原子写 Attempt、任务成功、最终金额和 settle Outbox + API-->>UI: succeeded + billingStatus=pending + BW->>DB: SKIP LOCKED 领取 Outbox + BW->>DB: 原子扣费、释放冻结、完成 Outbox +``` + +## 失败与取消 + +上游提交前失败、上游明确失败和用户取消均在任务终态事务内写入 `release` Outbox。Worker 使用冻结流水的幂等键释放资金。若上游请求已经进入 `submitting` 但没有可靠响应,系统不得再次提交;任务转人工复核并保留冻结。 + +## 即时估价 + +前端仅对影响计费的字段生成稳定签名,包括模型、生成模式、数量、质量、分辨率、时长、音频、参考素材和 Token 上限。签名变化后等待 350ms,取消旧请求,并用递增序号阻止乱序响应覆盖。 + +界面必须区分: + +- 计算中; +- 显式免费; +- 正常预计费用与冻结上限; +- 价格不可用; +- 估价请求失败。 + +价格不可用或估价失败时禁止生产生成;模拟模式仍允许执行并显示原因。 + +## Worker 参数 + +- 轮询间隔:1 秒。 +- 单批数量:50。 +- 处理锁过期:120 秒。 +- 最大自动尝试:20 次。 +- 退避上限:15 分钟。 + +这些参数是安全默认值,可通过受控配置调整,但不得改变幂等键、快照和账务不变量。 + +## 发布与回滚 + +`BILLING_ENGINE_MODE` 支持: + +- `observe`:并行计算并记录差异,不改变旧路径账务结果。 +- `enforce`:启用 v2 缺价拒绝、冻结与结算闭环。 +- `hold`:在调用上游前拒绝新的生产任务,已有 Outbox 继续处理。 + +发布先运行 24 小时 observe,补齐所有缺价和非法规则,再以 10% 流量启用 enforce,确认没有重复扣费、长期冻结和异常差额后扩大到 100%。回滚只切换到 hold,不回滚数据库增量,也不恢复隐式零价。 diff --git a/docs/decisions/002-billing-correctness-v2.md b/docs/decisions/002-billing-correctness-v2.md new file mode 100644 index 0000000..3de4e20 --- /dev/null +++ b/docs/decisions/002-billing-correctness-v2.md @@ -0,0 +1,86 @@ +# 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。