From 63c38fe4175a065cae43cb9bf8d6efb37c884463 Mon Sep 17 00:00:00 2001 From: wangbo Date: Mon, 11 May 2026 17:56:22 +0800 Subject: [PATCH] docs: add billing wallet and resource package design --- docs/计费体系/用户余额与资源包设计.md | 481 ++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 docs/计费体系/用户余额与资源包设计.md diff --git a/docs/计费体系/用户余额与资源包设计.md b/docs/计费体系/用户余额与资源包设计.md new file mode 100644 index 0000000..dc8072a --- /dev/null +++ b/docs/计费体系/用户余额与资源包设计.md @@ -0,0 +1,481 @@ +# 用户余额与资源包设计 + +## 结论 + +用户余额不应直接放在用户表中。用户表只负责身份、登录、租户归属和状态等基础信息;余额、冻结金额、充值、扣费、退款、资源包领取和资源消耗应由独立的计费域表维护。 + +推荐采用以下结构: + +- 用户表:只保存用户身份信息,不保存真实余额。 +- 钱包账户表:保存用户当前余额快照。 +- 钱包流水表:保存所有余额变动,是资金审计的核心依据。 +- 资源包定义表:保存可购买或可赠送的生图、文本、视频等资源包配置。 +- 用户资源包权益表:保存用户实际拥有的资源包实例和剩余额度。 +- 资源消耗流水表:保存每次任务消耗了哪个资源包、消耗多少。 +- 任务记录表:保存模型调用、usage、最终扣费、RequestID、耗时等运行事实。 + +核心原则是:**任务记录证明为什么扣,钱包和资源流水证明扣了什么。** + +## 设计目标 + +- 支持余额充值、赠送、后台调整、消费、退款和失败返还。 +- 支持后期增加文本资源包、生图资源包、视频资源包、音频资源包等权益。 +- 支持资源包优先抵扣,资源包不足时余额补扣。 +- 支持同步任务和异步长任务的预占、冻结、结算和释放。 +- 支持按用户、租户、API Key、任务、RequestID 查询完整账务链路。 +- 支持接入 `server-main` 和 Gateway 独立部署两种模式。 +- 保持计费结算幂等,避免任务重试、回调重放导致重复扣费。 + +## 现有基础 + +当前仓库已经有钱包和任务计费相关基础表: + +- `gateway_wallet_accounts` +- `gateway_wallet_transactions` +- `gateway_recharge_orders` +- `gateway_tasks` +- `gateway_task_attempts` +- `gateway_task_events` + +其中 `gateway_tasks` 已包含任务级计费字段: + +- `api_key_id` +- `api_key_name` +- `requested_model` +- `resolved_model` +- `request_id` +- `usage` +- `metrics` +- `billing_summary` +- `final_charge_amount` +- `response_started_at` +- `response_finished_at` +- `response_duration_ms` + +当前 `SettleTaskBilling` 已经能按 `task.ID` 生成幂等键 `task:{task_id}:billing`,写入 `gateway_wallet_transactions`,并扣减 `gateway_wallet_accounts.balance`。后续资源包设计应复用这条结算主线,而不是另起一套任务结算逻辑。 + +## 为什么不放用户表 + +余额直接放在用户表会带来几个问题: + +- 用户表会承载身份、权限、租户、余额、资源权益等多种职责,后期很难维护。 +- 余额变化需要强审计,只存一个 `balance` 无法解释每次变化来源。 +- 充值、扣费、退款、赠送、后台调整都需要幂等和流水,用户表字段无法覆盖。 +- 资源包有有效期、来源、剩余额度、优先级和适用资源类型,不适合塞进用户表。 +- 长任务需要冻结和释放额度,只靠用户表余额无法表达中间态。 + +因此用户表最多可以保留只读展示缓存,例如最近余额快照、是否欠费等,但不能作为账务事实源。 + +## 领域模型 + +```mermaid +erDiagram + gateway_users ||--o{ gateway_wallet_accounts : owns + gateway_wallet_accounts ||--o{ gateway_wallet_transactions : records + gateway_users ||--o{ gateway_user_resource_entitlements : owns + gateway_resource_packages ||--o{ gateway_user_resource_entitlements : grants + gateway_user_resource_entitlements ||--o{ gateway_resource_usage_records : consumes + gateway_tasks ||--o{ gateway_resource_usage_records : settles + gateway_tasks ||--o{ gateway_wallet_transactions : settles +``` + +## 钱包表设计 + +### `gateway_wallet_accounts` + +保存当前余额快照,用于高频读取和结算锁定。该表不是唯一审计依据,审计以流水为准。 + +当前已有字段基本可继续使用: + +| 字段 | 说明 | +| --- | --- | +| `id` | 钱包账户 ID | +| `gateway_tenant_id` | Gateway 租户 ID | +| `gateway_user_id` | Gateway 用户 ID | +| `tenant_id` | 外部租户 ID | +| `tenant_key` | 外部租户 Key | +| `user_id` | 外部用户 ID | +| `currency` | 余额币种,当前默认 `resource` | +| `balance` | 可用余额 | +| `frozen_balance` | 冻结余额 | +| `total_recharged` | 累计充值或发放 | +| `total_spent` | 累计消费 | +| `status` | `active` / `disabled` | +| `metadata` | 扩展信息 | + +建议继续保持唯一约束: + +```sql +UNIQUE (gateway_user_id, currency) +``` + +### `gateway_wallet_transactions` + +保存余额所有变化,是余额审计和追账的事实表。 + +当前已有字段基本可继续使用: + +| 字段 | 说明 | +| --- | --- | +| `id` | 流水 ID | +| `account_id` | 钱包账户 ID | +| `gateway_tenant_id` | Gateway 租户 ID | +| `gateway_user_id` | Gateway 用户 ID | +| `direction` | `credit` / `debit` | +| `transaction_type` | 交易类型 | +| `amount` | 变动金额 | +| `balance_before` | 变动前余额 | +| `balance_after` | 变动后余额 | +| `idempotency_key` | 幂等键 | +| `reference_type` | 关联对象类型 | +| `reference_id` | 关联对象 ID | +| `metadata` | 任务、模型、RequestID、账单明细等扩展信息 | + +推荐交易类型: + +| 类型 | 方向 | 场景 | +| --- | --- | --- | +| `recharge` | `credit` | 用户充值 | +| `grant` | `credit` | 系统赠送 | +| `admin_adjust` | `credit` / `debit` | 后台调整 | +| `task_billing` | `debit` | 任务成功结算 | +| `refund` | `credit` | 退款或失败返还 | +| `reserve` | `debit` | 长任务预占 | +| `release` | `credit` | 预占释放 | + +已有唯一索引应继续保留: + +```sql +UNIQUE (account_id, idempotency_key) +WHERE idempotency_key IS NOT NULL +``` + +## 资源包表设计 + +### `gateway_resource_packages` + +定义平台可售卖、可赠送、可配置的资源包模板。它是 SKU 或权益模板,不代表用户已经拥有。 + +```sql +CREATE TABLE IF NOT EXISTS gateway_resource_packages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + package_key text NOT NULL UNIQUE, + display_name text NOT NULL, + resource_type text NOT NULL, + unit text NOT NULL, + total_amount numeric NOT NULL, + price numeric NOT NULL DEFAULT 0, + currency text NOT NULL DEFAULT 'resource', + validity_days integer, + priority integer NOT NULL DEFAULT 100, + stackable boolean NOT NULL DEFAULT true, + status text NOT NULL DEFAULT 'active', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +``` + +推荐 `resource_type` 与现有模型计费资源类型保持同一语义层: + +| `resource_type` | 说明 | +| --- | --- | +| `text` | 文本模型消耗,可按 token 或 1k token 计量 | +| `image` | 文生图消耗 | +| `image_edit` | 图像编辑消耗 | +| `video` | 视频生成或图生视频消耗 | +| `audio` | 音频生成或处理消耗 | +| `music` | 音乐生成消耗 | +| `digital_human` | 数字人生成消耗 | +| `model` | 3D 或模型类资源消耗 | + +如果某类能力后续需要更细粒度,可以在 `metadata` 中记录适用模型、分辨率、质量、时长范围等约束,或者再拆出 `gateway_resource_package_rules`。 + +### `gateway_user_resource_entitlements` + +保存用户实际拥有的资源包实例。用户购买一次或被赠送一次,就生成一条权益记录。 + +```sql +CREATE TABLE IF NOT EXISTS gateway_user_resource_entitlements ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, + gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE, + package_id uuid REFERENCES gateway_resource_packages(id) ON DELETE SET NULL, + resource_type text NOT NULL, + unit text NOT NULL, + total_amount numeric NOT NULL, + remaining_amount numeric NOT NULL, + frozen_amount numeric NOT NULL DEFAULT 0, + priority integer NOT NULL DEFAULT 100, + source_type text NOT NULL, + source_id text, + status text NOT NULL DEFAULT 'active', + starts_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +``` + +推荐索引: + +```sql +CREATE INDEX IF NOT EXISTS idx_gateway_entitlements_user_available + ON gateway_user_resource_entitlements ( + gateway_user_id, + resource_type, + status, + expires_at, + priority + ); +``` + +推荐来源类型: + +| `source_type` | 说明 | +| --- | --- | +| `purchase` | 用户购买 | +| `recharge_bonus` | 充值赠送 | +| `admin_grant` | 后台发放 | +| `campaign` | 活动发放 | +| `migration` | 数据迁移 | + +### `gateway_resource_usage_records` + +保存资源包消耗流水。每次任务消耗资源包,都需要记录消耗前后额度。 + +```sql +CREATE TABLE IF NOT EXISTS gateway_resource_usage_records ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, + gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL, + task_id uuid REFERENCES gateway_tasks(id) ON DELETE SET NULL, + entitlement_id uuid REFERENCES gateway_user_resource_entitlements(id) ON DELETE SET NULL, + resource_type text NOT NULL, + unit text NOT NULL, + amount numeric NOT NULL, + remaining_before numeric NOT NULL, + remaining_after numeric NOT NULL, + direction text NOT NULL DEFAULT 'debit', + usage_type text NOT NULL DEFAULT 'task_billing', + idempotency_key text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +推荐唯一索引: + +```sql +CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_resource_usage_idempotency + ON gateway_resource_usage_records(entitlement_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; +``` + +## 扣费顺序 + +默认扣费顺序: + +1. 解析任务最终消耗,生成 `billing_summary` 和 `final_charge_amount`。 +2. 按 `resource_type` 查找用户可用资源包。 +3. 优先扣即将过期的资源包。 +4. 同一过期时间下按 `priority` 从小到大扣。 +5. 资源包不足时,剩余部分从 `gateway_wallet_accounts.balance` 扣。 +6. 如果资源包和余额都不足,任务进入余额不足错误,不发起真实 provider 调用。 + +资源包查询条件: + +```sql +gateway_user_id = ? +AND resource_type = ? +AND status = 'active' +AND remaining_amount > 0 +AND (starts_at IS NULL OR starts_at <= now()) +AND (expires_at IS NULL OR expires_at > now()) +ORDER BY expires_at ASC NULLS LAST, priority ASC, created_at ASC +FOR UPDATE +``` + +## 结算流程 + +### 同步任务 + +同步任务可以在 provider 成功返回后直接结算: + +1. 写入或更新 `gateway_tasks`。 +2. 保存 usage、metrics、billings、billing_summary、final_charge_amount。 +3. 开启数据库事务。 +4. 锁定用户资源包和钱包账户。 +5. 写入 `gateway_resource_usage_records`。 +6. 如需余额补扣,写入 `gateway_wallet_transactions`。 +7. 更新资源包剩余额度和钱包余额。 +8. 提交事务。 + +### 异步长任务 + +生图、视频、数字人等异步任务建议增加预占流程: + +1. 创建任务时根据 estimated billing 计算预计消耗。 +2. 预占资源包或冻结余额。 +3. 任务成功时按实际消耗结算,多退少补。 +4. 任务失败、取消或超时后释放预占。 +5. 每一步都写幂等流水,避免重试导致重复冻结或重复释放。 + +如果不想立刻新增独立预占表,可以先使用 `frozen_balance` 和 `frozen_amount` 表达冻结快照,但长期建议补充 `gateway_billing_reservations` 记录预占明细。 + +建议预占表: + +```sql +CREATE TABLE IF NOT EXISTS gateway_billing_reservations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, + gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE, + task_id uuid REFERENCES gateway_tasks(id) ON DELETE CASCADE, + reservation_type text NOT NULL, + resource_type text NOT NULL, + unit text NOT NULL, + amount numeric NOT NULL, + entitlement_id uuid REFERENCES gateway_user_resource_entitlements(id) ON DELETE SET NULL, + wallet_account_id uuid REFERENCES gateway_wallet_accounts(id) ON DELETE SET NULL, + status text NOT NULL DEFAULT 'reserved', + idempotency_key text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + settled_at timestamptz, + released_at timestamptz, + UNIQUE (task_id, idempotency_key) +); +``` + +## 幂等设计 + +任务结算幂等键建议统一: + +| 场景 | 幂等键 | +| --- | --- | +| 任务余额扣费 | `task:{task_id}:wallet:billing` | +| 任务资源包扣费 | `task:{task_id}:entitlement:{entitlement_id}:billing` | +| 任务预占 | `task:{task_id}:reservation:{resource_type}` | +| 任务释放 | `task:{task_id}:reservation:{reservation_id}:release` | +| 任务退款 | `task:{task_id}:refund:{reason}` | + +现有钱包扣费已经使用 `task:{task_id}:billing`,后续如果引入资源包和预占,建议新逻辑逐步迁移到更细粒度的幂等键,同时保留旧键兼容已有流水。 + +## 与任务记录的关系 + +`gateway_tasks` 是运行事实表,应该保存: + +- 用户请求了哪个模型。 +- 实际路由到了哪个平台和模型。 +- provider 返回了哪些 usage。 +- 开始响应时间、结束响应时间和响应耗时。 +- 最终结算金额和计费摘要。 +- API Key 名称、Key ID、RequestID 等审计字段。 + +钱包和资源包流水不重复保存完整任务响应,只保存必要关联和账务快照: + +- `reference_type = 'gateway_task'` +- `reference_id = gateway_tasks.id` +- `metadata.taskId` +- `metadata.requestId` +- `metadata.model` +- `metadata.resolvedModel` +- `metadata.billingSummary` + +这样查询单个任务时,可以从 `gateway_tasks` 看到运行结果,从 `gateway_wallet_transactions` 和 `gateway_resource_usage_records` 看到结算结果。 + +## API 建议 + +面向用户: + +- `GET /api/v1/billing/wallet`:查看余额。 +- `GET /api/v1/billing/transactions`:查看余额流水。 +- `GET /api/v1/billing/resource-entitlements`:查看资源包权益。 +- `GET /api/v1/billing/resource-usage`:查看资源包消耗。 +- `POST /api/v1/billing/recharge-orders`:创建充值订单。 + +面向管理员: + +- `GET /api/admin/billing/wallets`:查询用户钱包。 +- `POST /api/admin/billing/wallet-adjustments`:后台余额调整。 +- `GET /api/admin/billing/resource-packages`:查询资源包。 +- `POST /api/admin/billing/resource-packages`:创建资源包。 +- `PATCH /api/admin/billing/resource-packages/{id}`:更新资源包。 +- `POST /api/admin/billing/resource-entitlements/grant`:给用户发放资源包。 +- `GET /api/admin/billing/settlements`:查询任务结算链路。 + +管理接口应保持在 `/api/admin/...`,用户和 API Key 可访问接口保持在 `/api/v1/...`,避免管理面和运行面权限混用。 + +## 接入模式 + +### Gateway 独立模式 + +Gateway 作为计费事实源: + +- 用户钱包、充值订单、资源包、消耗流水全部在 Gateway 落库。 +- 任务运行前由 Gateway 完成余额和资源包校验。 +- 任务完成后由 Gateway 完成结算。 + +### 接入 `server-main` 模式 + +如果 `server-main` 仍是充值和用户余额事实源,有两种可选策略: + +- Gateway 只记录任务计费明细,结算请求回调 `server-main` 完成扣费。 +- Gateway 维护本地镜像钱包和资源包,用于快速校验和查询,但充值、退款等资金入口仍以 `server-main` 为准。 + +无论选择哪种策略,任务侧都应保留 `task_id`、`request_id`、`billing_summary` 和最终结算明细,保证跨系统可对账。 + +## 分阶段落地 + +### Phase 1:钱包结算稳定 + +- 继续使用现有 `gateway_wallet_accounts`、`gateway_wallet_transactions`。 +- 补齐充值、赠送、退款、后台调整接口。 +- 确保任务结算只在成功任务上触发。 +- 保证 `task_id` 维度幂等。 +- 余额不足时在发起 provider 调用前失败。 + +### Phase 2:资源包权益 + +- 新增 `gateway_resource_packages`。 +- 新增 `gateway_user_resource_entitlements`。 +- 新增 `gateway_resource_usage_records`。 +- 结算时先扣资源包,再扣钱包余额。 +- 管理后台支持创建资源包和给用户发放资源包。 + +### Phase 3:预占和释放 + +- 新增 `gateway_billing_reservations`。 +- 异步长任务创建时冻结资源或余额。 +- 成功时结算,失败时释放。 +- 支持多退少补。 + +### Phase 4:报表与对账 + +- 按用户、租户、API Key、模型、资源类型统计收入和消耗。 +- 提供任务维度对账详情。 +- 提供钱包流水和资源包消耗导出。 +- 增加异常流水巡检,例如负余额、重复扣费、预占未释放。 + +## 风险点 + +- 余额扣减必须在事务内使用 `FOR UPDATE` 锁定账户,不能先查后扣。 +- 资源包扣减也必须锁定权益行,避免并发任务超扣。 +- 所有结算、退款、释放都必须有幂等键。 +- 不要让 `estimated billing` 和真实结算使用两套价格逻辑。 +- 不要只依赖钱包余额判断可用额度,资源包也要纳入预检。 +- 不要把资源包余额折算成用户表余额,否则后续过期、退款和按类型限制都会变复杂。 +- 测试模式和模拟任务也应写入清晰的模拟计费标识,避免和真实扣费混淆。 + +## 推荐实现边界 + +短期可以先保留当前钱包结算主线,只补齐文档和接口;资源包作为第二阶段新增,不影响现有任务扣费。 + +中期要把结算入口抽成统一服务,例如 `BillingService.SettleTask(ctx, task)`: + +- 输入:任务 ID、用户 ID、资源类型、最终计费金额、usage、billing summary。 +- 输出:扣减了哪些资源包、扣了多少余额、最终结算状态。 +- 内部:统一处理资源包优先、余额补扣、幂等、事务和流水。 + +长期则把预估、预占、结算、退款都收口在同一个计费域服务里,任务 runner 只负责在合适节点调用计费服务,不直接操作钱包或资源包表。