Files
easyai-ai-gateway/docs/计费体系/用户余额与资源包设计.md
T

482 lines
18 KiB
Markdown
Raw 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.
# 用户余额与资源包设计
## 结论
用户余额不应直接放在用户表中。用户表只负责身份、登录、租户归属和状态等基础信息;余额、冻结金额、充值、扣费、退款、资源包领取和资源消耗应由独立的计费域表维护。
推荐采用以下结构:
- 用户表:只保存用户身份信息,不保存真实余额。
- 钱包账户表:保存用户当前余额快照。
- 钱包流水表:保存所有余额变动,是资金审计的核心依据。
- 资源包定义表:保存可购买或可赠送的生图、文本、视频等资源包配置。
- 用户资源包权益表:保存用户实际拥有的资源包实例和剩余额度。
- 资源消耗流水表:保存每次任务消耗了哪个资源包、消耗多少。
- 任务记录表:保存模型调用、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 只负责在合适节点调用计费服务,不直接操作钱包或资源包表。