docs: refine wallet resource package design

This commit is contained in:
wangbo 2026-05-11 18:07:26 +08:00
parent 63c38fe417
commit 4e54134e2a

View File

@ -12,6 +12,7 @@
- 资源包定义表:保存可购买或可赠送的生图、文本、视频等资源包配置。 - 资源包定义表:保存可购买或可赠送的生图、文本、视频等资源包配置。
- 用户资源包权益表:保存用户实际拥有的资源包实例和剩余额度。 - 用户资源包权益表:保存用户实际拥有的资源包实例和剩余额度。
- 资源消耗流水表:保存每次任务消耗了哪个资源包、消耗多少。 - 资源消耗流水表:保存每次任务消耗了哪个资源包、消耗多少。
- 兑换码表和兑换流水表:保存兑换码资格、领取历史、发放结果和失败回滚。
- 任务记录表保存模型调用、usage、最终扣费、RequestID、耗时等运行事实。 - 任务记录表保存模型调用、usage、最终扣费、RequestID、耗时等运行事实。
核心原则是:**任务记录证明为什么扣,钱包和资源流水证明扣了什么。** 核心原则是:**任务记录证明为什么扣,钱包和资源流水证明扣了什么。**
@ -21,6 +22,8 @@
- 支持余额充值、赠送、后台调整、消费、退款和失败返还。 - 支持余额充值、赠送、后台调整、消费、退款和失败返还。
- 支持后期增加文本资源包、生图资源包、视频资源包、音频资源包等权益。 - 支持后期增加文本资源包、生图资源包、视频资源包、音频资源包等权益。
- 支持资源包优先抵扣,资源包不足时余额补扣。 - 支持资源包优先抵扣,资源包不足时余额补扣。
- 支持兑换码发放资源包,并覆盖单次码、多人码、活动码、资源包产品码等营销场景。
- 支持个人资源包和组织资源包,并支持付费额度、赠送额度分开管理。
- 支持同步任务和异步长任务的预占、冻结、结算和释放。 - 支持同步任务和异步长任务的预占、冻结、结算和释放。
- 支持按用户、租户、API Key、任务、RequestID 查询完整账务链路。 - 支持按用户、租户、API Key、任务、RequestID 查询完整账务链路。
- 支持接入 `server-main` 和 Gateway 独立部署两种模式。 - 支持接入 `server-main` 和 Gateway 独立部署两种模式。
@ -54,6 +57,32 @@
当前 `SettleTaskBilling` 已经能按 `task.ID` 生成幂等键 `task:{task_id}:billing`,写入 `gateway_wallet_transactions`,并扣减 `gateway_wallet_accounts.balance`。后续资源包设计应复用这条结算主线,而不是另起一套任务结算逻辑。 当前 `SettleTaskBilling` 已经能按 `task.ID` 生成幂等键 `task:{task_id}:billing`,写入 `gateway_wallet_transactions`,并扣减 `gateway_wallet_accounts.balance`。后续资源包设计应复用这条结算主线,而不是另起一套任务结算逻辑。
## `server-main` 旧逻辑对照和补齐点
`server-main` 现有资源包不是单纯的用户余额它已经包含一套资源包和兑换码语义。Gateway 设计需要吸收这些能力,同时修正旧实现里不够原子化的地方。
旧逻辑中需要迁移或保留的点:
- `Product` 同时承担线上套餐和兑换码关联产品,关键字段包括 `balance`、`extra_balance`、`balance_valid_time`、`valid_time`、`resource_package_type`、`organization_id`、`status`、`enable_redeem_code`。
- `resource_packages` 是用户或组织实际拥有的资源包实例,包含 `source_type`、`source_id`、`source_name`、`user_id`、`organization_id`、`package_amount`、`bonus_amount`、`package_remaining`、`bonus_remaining`、`effective_time`、`expire_time`、`status`、`disabled`。
- 资源包来源幂等不是一个维度:订单资源包按 `source_type + source_id` 唯一;兑换码资源包按 `source_type + source_id + user_id` 唯一,因为同一个多人兑换码会给多个用户各发一份权益。
- 兑换码有 `maxUses`、`currentUses`、`redeemHistory`、`expiresAt`、`isActive`、`isDeleted`,同一用户不能重复兑换,同一兑换码不能超过总次数。
- 兑换码类型既有普通算力码,也有资源包产品码。资源包产品码必须校验产品存在,并且产品 `status = 1``enable_redeem_code = 1`
- 兑换码领取资格需要先原子抢占;下游资源包或会员发放失败时,必须回滚 `currentUses` 和领取历史,避免“次数被占用但权益未到账”。
- 产品的 `balance_valid_time` 控制算力资源包有效期;`valid_time` 控制会员或组织权限有效期。这两个有效期不能混用。
- 旧扣费顺序在开启组织账户扣除时是:组织资源包、组织余额、个人资源包、个人余额。资源包内部先扣赠送额度,再扣套餐内额度。
- 用户可用额度展示要把个人余额、组织余额、个人资源包和组织资源包合并展示;余额不足提醒也要按总可用额度判断。
- 资源包状态需要定时或按读取即时维护:`pending`、`active`、`used_up`、`expired`,同时支持 `disabled` 逻辑禁用。
- 迁移历史数据前要先清理重复来源记录,再加唯一索引,否则旧环境里可能因为历史重复数据导致索引创建失败。
旧逻辑里需要在 Gateway 中优化的点:
- 资源包扣减、余额补扣和流水写入必须在同一个数据库事务内完成。不能先扣掉部分资源包,再发现总余额不足后继续走其他扣费路径,否则会出现部分扣减的副作用。
- 资源包查询和扣减必须使用行级锁或条件更新,不能先查出多个包再逐个保存。
- 兑换码不建议继续把明文 `code` 放在 URL 路径和日志里。用户兑换接口应使用请求体提交 code服务端按规范化后的 hash 查询,管理端只展示脱敏码。
- `redeemHistory` 不建议继续用数组承载审计。Gateway 应拆成兑换流水表,便于分页、对账、失败重试和并发约束。
- 任务结算不能只看 `final_charge_amount` 一个总数。资源包抵扣应按 `billings[].resourceType` 明细逐行结算,`billing_summary` 只做聚合展示,剩余再汇总到钱包扣费。
## 为什么不放用户表 ## 为什么不放用户表
余额直接放在用户表会带来几个问题: 余额直接放在用户表会带来几个问题:
@ -74,6 +103,8 @@ erDiagram
gateway_wallet_accounts ||--o{ gateway_wallet_transactions : records gateway_wallet_accounts ||--o{ gateway_wallet_transactions : records
gateway_users ||--o{ gateway_user_resource_entitlements : owns gateway_users ||--o{ gateway_user_resource_entitlements : owns
gateway_resource_packages ||--o{ gateway_user_resource_entitlements : grants gateway_resource_packages ||--o{ gateway_user_resource_entitlements : grants
gateway_redeem_codes ||--o{ gateway_redeem_code_redemptions : claims
gateway_redeem_code_redemptions ||--o{ gateway_user_resource_entitlements : grants
gateway_user_resource_entitlements ||--o{ gateway_resource_usage_records : consumes gateway_user_resource_entitlements ||--o{ gateway_resource_usage_records : consumes
gateway_tasks ||--o{ gateway_resource_usage_records : settles gateway_tasks ||--o{ gateway_resource_usage_records : settles
gateway_tasks ||--o{ gateway_wallet_transactions : settles gateway_tasks ||--o{ gateway_wallet_transactions : settles
@ -163,10 +194,17 @@ CREATE TABLE IF NOT EXISTS gateway_resource_packages (
display_name text NOT NULL, display_name text NOT NULL,
resource_type text NOT NULL, resource_type text NOT NULL,
unit text NOT NULL, unit text NOT NULL,
paid_amount numeric NOT NULL DEFAULT 0,
bonus_amount numeric NOT NULL DEFAULT 0,
total_amount numeric NOT NULL, total_amount numeric NOT NULL,
price numeric NOT NULL DEFAULT 0, price numeric NOT NULL DEFAULT 0,
currency text NOT NULL DEFAULT 'resource', price_currency text NOT NULL DEFAULT 'CNY',
resource_currency text NOT NULL DEFAULT 'resource',
validity_days integer, validity_days integer,
membership_validity_days integer,
owner_scope text NOT NULL DEFAULT 'user',
purchase_enabled boolean NOT NULL DEFAULT true,
redeem_enabled boolean NOT NULL DEFAULT false,
priority integer NOT NULL DEFAULT 100, priority integer NOT NULL DEFAULT 100,
stackable boolean NOT NULL DEFAULT true, stackable boolean NOT NULL DEFAULT true,
status text NOT NULL DEFAULT 'active', status text NOT NULL DEFAULT 'active',
@ -176,6 +214,21 @@ CREATE TABLE IF NOT EXISTS gateway_resource_packages (
); );
``` ```
字段补充说明:
| 字段 | 说明 |
| --- | --- |
| `paid_amount` | 付费或主额度,对应 `server-main.Product.balance` |
| `bonus_amount` | 赠送额度,对应 `server-main.Product.extra_balance` |
| `total_amount` | 权益总额度,通常为 `paid_amount + bonus_amount` |
| `validity_days` | 算力资源包有效期,对应 `balance_valid_time` |
| `membership_validity_days` | 会员或组织权限有效期,对应 `valid_time` |
| `owner_scope` | 默认归属范围,`user` / `organization` |
| `purchase_enabled` | 是否可线上购买,对应旧产品 `status = 1` |
| `redeem_enabled` | 是否可被兑换码关联,对应旧产品 `enable_redeem_code = 1` |
线上购买入口和兑换码入口都可以复用这个模板,但入口权限要分开控制:一个套餐可以不展示在线购买,却允许运营用兑换码发放。
推荐 `resource_type` 与现有模型计费资源类型保持同一语义层: 推荐 `resource_type` 与现有模型计费资源类型保持同一语义层:
| `resource_type` | 说明 | | `resource_type` | 说明 |
@ -199,17 +252,28 @@ CREATE TABLE IF NOT EXISTS gateway_resource_packages (
CREATE TABLE IF NOT EXISTS gateway_user_resource_entitlements ( CREATE TABLE IF NOT EXISTS gateway_user_resource_entitlements (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL, gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE CASCADE, gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
owner_type text NOT NULL DEFAULT 'user',
owner_id text NOT NULL,
owner_name text,
issued_to_gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
redeemed_by_gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
package_id uuid REFERENCES gateway_resource_packages(id) ON DELETE SET NULL, package_id uuid REFERENCES gateway_resource_packages(id) ON DELETE SET NULL,
resource_type text NOT NULL, resource_type text NOT NULL,
unit text NOT NULL, unit text NOT NULL,
paid_amount numeric NOT NULL DEFAULT 0,
bonus_amount numeric NOT NULL DEFAULT 0,
total_amount numeric NOT NULL, total_amount numeric NOT NULL,
paid_remaining numeric NOT NULL DEFAULT 0,
bonus_remaining numeric NOT NULL DEFAULT 0,
remaining_amount numeric NOT NULL, remaining_amount numeric NOT NULL,
frozen_amount numeric NOT NULL DEFAULT 0, frozen_amount numeric NOT NULL DEFAULT 0,
priority integer NOT NULL DEFAULT 100, priority integer NOT NULL DEFAULT 100,
source_type text NOT NULL, source_type text NOT NULL,
source_id text, source_id text,
source_name text,
status text NOT NULL DEFAULT 'active', status text NOT NULL DEFAULT 'active',
disabled boolean NOT NULL DEFAULT false,
starts_at timestamptz NOT NULL DEFAULT now(), starts_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz, expires_at timestamptz,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb, metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
@ -223,24 +287,44 @@ CREATE TABLE IF NOT EXISTS gateway_user_resource_entitlements (
```sql ```sql
CREATE INDEX IF NOT EXISTS idx_gateway_entitlements_user_available CREATE INDEX IF NOT EXISTS idx_gateway_entitlements_user_available
ON gateway_user_resource_entitlements ( ON gateway_user_resource_entitlements (
gateway_user_id, owner_type,
owner_id,
resource_type, resource_type,
status, status,
expires_at, expires_at,
priority priority
); );
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_entitlement_order_source
ON gateway_user_resource_entitlements(source_type, source_id)
WHERE source_type = 'order' AND source_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_entitlement_redeem_source_user
ON gateway_user_resource_entitlements(source_type, source_id, redeemed_by_gateway_user_id)
WHERE source_type = 'redeem_code'
AND source_id IS NOT NULL
AND redeemed_by_gateway_user_id IS NOT NULL;
``` ```
推荐来源类型: 推荐来源类型:
| `source_type` | 说明 | | `source_type` | 说明 |
| --- | --- | | --- | --- |
| `purchase` | 用户购买 | | `order` | 支付订单发放,兼容 `server-main` 订单资源包 |
| `recharge_bonus` | 充值赠送 | | `redeem_code` | 兑换码发放 |
| `admin_grant` | 后台发放 | | `admin_grant` | 后台发放 |
| `campaign` | 活动发放 | | `campaign` | 活动发放 |
| `migration` | 数据迁移 | | `migration` | 数据迁移 |
`owner_type` 用于表达资源包归属:
| `owner_type` | 说明 |
| --- | --- |
| `user` | 个人资源包,`owner_id = gateway_user_id` 或外部用户 ID |
| `organization` | 组织资源包,`owner_id = Gateway 组织 ID 或 server-main 组织 ID` |
如果 Gateway 后续补充独立组织表,可以把 `owner_id` 拆为 `gateway_organization_id` 外键;在接入 `server-main` 的过渡阶段,保留字符串 `owner_id` 更利于镜像旧组织资源包。
### `gateway_resource_usage_records` ### `gateway_resource_usage_records`
保存资源包消耗流水。每次任务消耗资源包,都需要记录消耗前后额度。 保存资源包消耗流水。每次任务消耗资源包,都需要记录消耗前后额度。
@ -255,8 +339,11 @@ CREATE TABLE IF NOT EXISTS gateway_resource_usage_records (
resource_type text NOT NULL, resource_type text NOT NULL,
unit text NOT NULL, unit text NOT NULL,
amount numeric NOT NULL, amount numeric NOT NULL,
amount_breakdown jsonb NOT NULL DEFAULT '{}'::jsonb,
remaining_before numeric NOT NULL, remaining_before numeric NOT NULL,
remaining_after numeric NOT NULL, remaining_after numeric NOT NULL,
remaining_breakdown_before jsonb NOT NULL DEFAULT '{}'::jsonb,
remaining_breakdown_after jsonb NOT NULL DEFAULT '{}'::jsonb,
direction text NOT NULL DEFAULT 'debit', direction text NOT NULL DEFAULT 'debit',
usage_type text NOT NULL DEFAULT 'task_billing', usage_type text NOT NULL DEFAULT 'task_billing',
idempotency_key text, idempotency_key text,
@ -265,6 +352,14 @@ CREATE TABLE IF NOT EXISTS gateway_resource_usage_records (
); );
``` ```
`amount_breakdown` 用来记录本次从付费额度和赠送额度各扣了多少,例如:
```json
{ "bonus": 6, "paid": 4 }
```
这样既可以保留 `server-main` 中“先扣赠送,再扣套餐内额度”的策略,也能在退款、过期清理和营销成本核算时区分付费额度与赠送额度。
推荐唯一索引: 推荐唯一索引:
```sql ```sql
@ -273,23 +368,166 @@ CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_resource_usage_idempotency
WHERE idempotency_key IS NOT NULL; WHERE idempotency_key IS NOT NULL;
``` ```
## 兑换码设计
兑换码是资源包发放入口,不应直接改钱包余额。普通算力兑换码也应发放一条个人资源包权益,这样后续过期、禁用、消耗流水和对账都能走同一条资源包链路。
### `gateway_redeem_codes`
保存兑换码本身和可兑换权益。建议只存规范化后的 hash避免明文 code 进入数据库、URL、日志和后台列表。
```sql
CREATE TABLE IF NOT EXISTS gateway_redeem_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code_hash text NOT NULL UNIQUE,
code_prefix text,
masked_code text,
campaign_key text,
code_type text NOT NULL DEFAULT 'normal',
package_id uuid REFERENCES gateway_resource_packages(id) ON DELETE SET NULL,
direct_resource_type text,
direct_unit text,
direct_amount numeric NOT NULL DEFAULT 0,
direct_validity_days integer,
max_uses integer NOT NULL DEFAULT 1,
current_uses integer NOT NULL DEFAULT 0,
per_user_limit integer NOT NULL DEFAULT 1,
starts_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
status text NOT NULL DEFAULT 'active',
deleted_at timestamptz,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_by uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
推荐 `code_type`
| `code_type` | 说明 |
| --- | --- |
| `normal` | 普通算力码,使用 `direct_amount` 直接生成个人资源包 |
| `resource_package` | 资源包产品码,使用 `package_id` 生成个人或组织资源包 |
| `event` | 活动码,可通过 `metadata` 增加活动限制 |
| `vip` | 会员码,可通过 `metadata` 或会员权益表处理组织权限 |
推荐索引:
```sql
CREATE INDEX IF NOT EXISTS idx_gateway_redeem_codes_campaign
ON gateway_redeem_codes(campaign_key, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_gateway_redeem_codes_expires
ON gateway_redeem_codes(status, expires_at);
```
### `gateway_redeem_code_redemptions`
保存每次兑换尝试和发放结果,替代 `server-main``redeemHistory` 数组。
```sql
CREATE TABLE IF NOT EXISTS gateway_redeem_code_redemptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
redeem_code_id uuid REFERENCES gateway_redeem_codes(id) ON DELETE CASCADE,
gateway_tenant_id uuid REFERENCES gateway_tenants(id) ON DELETE SET NULL,
gateway_user_id uuid REFERENCES gateway_users(id) ON DELETE SET NULL,
owner_type text NOT NULL DEFAULT 'user',
owner_id text NOT NULL,
entitlement_id uuid REFERENCES gateway_user_resource_entitlements(id) ON DELETE SET NULL,
status text NOT NULL DEFAULT 'claiming',
idempotency_key text NOT NULL,
failure_reason text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
redeemed_at timestamptz NOT NULL DEFAULT now(),
granted_at timestamptz,
reversed_at timestamptz,
UNIQUE (redeem_code_id, idempotency_key)
);
```
默认同一用户对同一兑换码只能成功兑换一次:
```sql
CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_redeem_once_per_user
ON gateway_redeem_code_redemptions(redeem_code_id, gateway_user_id)
WHERE status IN ('claiming', 'granted');
```
如果后续确实要支持同一用户多次兑换同一 code可以新增 `gateway_redeem_code_user_counters`,按 `redeem_code_id + gateway_user_id` 维护次数,而不是移除总次数和幂等约束。
### 兑换流程
1. 用户提交 code 到 `POST /api/v1/billing/redeem-codes/redeem` 的请求体。
2. 服务端对 code 做规范化,例如 trim、统一大小写、移除空格或连字符再计算 `code_hash`
3. 开启事务,锁定 `gateway_redeem_codes` 行。
4. 校验 `status`、`deleted_at`、`starts_at`、`expires_at`、`current_uses < max_uses`、租户/渠道/活动限制
5. 校验用户是否已兑换过;默认同一用户只能兑换一次。
6. 如果是 `resource_package`,校验 `package_id` 存在,且 `purchase_enabled = true``redeem_enabled = true`
7. 用条件更新抢占次数,例如 `current_uses = current_uses + 1 WHERE current_uses < max_uses`,插入 `gateway_redeem_code_redemptions(status='claiming')`
8. 创建 `gateway_user_resource_entitlements``source_type='redeem_code'`、`source_id=redeem_code_id`、`redeemed_by_gateway_user_id=当前用户`。
9. 如果产品带会员或组织权限,将会员发放写入同一事务内的本地表,或写入 outbox 让 `server-main` 异步处理。
10. 更新兑换流水为 `granted`,提交事务。
失败处理:
- 如果事务内发放失败,整笔事务回滚,`current_uses` 不增加。
- 如果事务后调用外部系统失败,不要静默吞掉。应把兑换流水标记为 `failed``reversed`,并通过补偿任务重试或人工处理。
- 用户看到的错误信息应保持收敛,例如“兑换码无效、已过期或已达到使用上限”,避免暴露 code 是否存在。
### 兑换码运营能力
管理端建议支持:
- 单个创建和批量生成兑换码。
- 共享多人码和一人一码两种模式。
- 设置总可用次数、单用户次数、开始时间、过期时间。
- 绑定资源包模板,或直接设置普通算力额度和有效期。
- 限定租户、用户组、渠道、新用户、组织范围等使用条件。
- 启用、禁用、软删除、导出和查看兑换历史。
- 查看每个 code 的已领取次数、失败次数、剩余次数和最近兑换时间。
安全要求:
- 兑换接口必须做用户、IP、租户维度的限流防止撞库。
- 后台列表只显示 `masked_code`,明文 code 只在创建或导入结果中短暂返回。
- 兑换接口不要把 code 放在 URL path、埋点明文或错误日志里。
- 批量生成 code 时要使用加密安全随机数,并做唯一冲突重试。
## 扣费顺序 ## 扣费顺序
结算时应按 `billings` 中的资源类型明细逐行扣减,而不是只拿 `final_charge_amount` 一次性扣。这样文本、图片、视频等不同资源类型可以命中各自的资源包规则。
默认扣费顺序: 默认扣费顺序:
1. 解析任务最终消耗,生成 `billing_summary``final_charge_amount` 1. 解析任务最终消耗,生成 `billing_summary``final_charge_amount`
2. 按 `resource_type` 查找用户可用资源包。 2. 展开 `billings`,按 `resourceType` 分组,并把聚合结果写回 `billing_summary`
3. 优先扣即将过期的资源包。 3. 按扣费策略查找可用资源包和钱包账户。
4. 同一过期时间下按 `priority` 从小到大扣。 4. 优先扣即将过期的资源包。
5. 资源包不足时,剩余部分从 `gateway_wallet_accounts.balance` 扣。 5. 同一过期时间下按 `priority` 从小到大扣。
6. 如果资源包和余额都不足,任务进入余额不足错误,不发起真实 provider 调用。 6. 单个资源包内部先扣 `bonus_remaining`,再扣 `paid_remaining`
7. 资源包不足时,剩余部分从钱包余额扣。
8. 如果资源包和余额都不足,任务进入余额不足错误,不发起真实 provider 调用。
为了兼容 `server-main` 的组织扣费语义,建议把扣费顺序做成策略配置,默认可设置为:
| 顺序 | 来源 | 说明 |
| --- | --- | --- |
| 1 | `organization_entitlement` | 用户所在组织或上级组织的资源包 |
| 2 | `organization_wallet` | 组织钱包余额,只有开启组织账户扣除时启用 |
| 3 | `user_entitlement` | 用户个人资源包 |
| 4 | `user_wallet` | 用户个人钱包余额 |
如果 Gateway 独立模式暂时没有组织钱包,可以先只启用 `organization_entitlement``user_entitlement`,组织余额继续由 `server-main` 回调结算。
资源包查询条件: 资源包查询条件:
```sql ```sql
gateway_user_id = ? owner_type = ?
AND owner_id = ?
AND resource_type = ? AND resource_type = ?
AND status = 'active' AND status = 'active'
AND disabled = false
AND remaining_amount > 0 AND remaining_amount > 0
AND (starts_at IS NULL OR starts_at <= now()) AND (starts_at IS NULL OR starts_at <= now())
AND (expires_at IS NULL OR expires_at > now()) AND (expires_at IS NULL OR expires_at > now())
@ -297,6 +535,8 @@ ORDER BY expires_at ASC NULLS LAST, priority ASC, created_at ASC
FOR UPDATE FOR UPDATE
``` ```
结算前的余额预检要按完整扣费策略计算总可用额度。如果资源包只能覆盖一部分,必须确认钱包能覆盖剩余部分后再进入 provider 调用;否则直接返回余额不足。正式结算时资源包扣减、钱包扣减和流水写入要在同一个事务内完成。
## 结算流程 ## 结算流程
### 同步任务 ### 同步任务
@ -307,10 +547,13 @@ FOR UPDATE
2. 保存 usage、metrics、billings、billing_summary、final_charge_amount。 2. 保存 usage、metrics、billings、billing_summary、final_charge_amount。
3. 开启数据库事务。 3. 开启数据库事务。
4. 锁定用户资源包和钱包账户。 4. 锁定用户资源包和钱包账户。
5. 写入 `gateway_resource_usage_records` 5. 按资源类型逐行计算资源包扣减和余额补扣。
6. 如需余额补扣,写入 `gateway_wallet_transactions` 6. 写入 `gateway_resource_usage_records`
7. 更新资源包剩余额度和钱包余额。 7. 如需余额补扣,写入 `gateway_wallet_transactions`
8. 提交事务。 8. 更新资源包剩余额度和钱包余额。
9. 提交事务。
事务内必须先确认本次任务的所有扣费来源都能覆盖最终金额,再执行扣减。不能出现“资源包已扣一部分,但余额补扣失败”的半完成状态。
### 异步长任务 ### 异步长任务
@ -359,6 +602,10 @@ CREATE TABLE IF NOT EXISTS gateway_billing_reservations (
| 任务预占 | `task:{task_id}:reservation:{resource_type}` | | 任务预占 | `task:{task_id}:reservation:{resource_type}` |
| 任务释放 | `task:{task_id}:reservation:{reservation_id}:release` | | 任务释放 | `task:{task_id}:reservation:{reservation_id}:release` |
| 任务退款 | `task:{task_id}:refund:{reason}` | | 任务退款 | `task:{task_id}:refund:{reason}` |
| 兑换码领取 | `redeem:{redeem_code_id}:user:{gateway_user_id}` |
| 兑换码权益发放 | `redeem:{redeem_code_id}:user:{gateway_user_id}:entitlement` |
| 订单资源包发放 | `order:{order_id}:entitlement` |
| 会员权益发放 | `source:{source_type}:{source_id}:member:{gateway_user_id}` |
现有钱包扣费已经使用 `task:{task_id}:billing`,后续如果引入资源包和预占,建议新逻辑逐步迁移到更细粒度的幂等键,同时保留旧键兼容已有流水。 现有钱包扣费已经使用 `task:{task_id}:billing`,后续如果引入资源包和预占,建议新逻辑逐步迁移到更细粒度的幂等键,同时保留旧键兼容已有流水。
@ -394,6 +641,8 @@ CREATE TABLE IF NOT EXISTS gateway_billing_reservations (
- `GET /api/v1/billing/resource-entitlements`:查看资源包权益。 - `GET /api/v1/billing/resource-entitlements`:查看资源包权益。
- `GET /api/v1/billing/resource-usage`:查看资源包消耗。 - `GET /api/v1/billing/resource-usage`:查看资源包消耗。
- `POST /api/v1/billing/recharge-orders`:创建充值订单。 - `POST /api/v1/billing/recharge-orders`:创建充值订单。
- `POST /api/v1/billing/redeem-codes/redeem`兑换兑换码code 放在请求体中。
- `GET /api/v1/billing/redeem-codes/history`:查看自己的兑换记录。
面向管理员: 面向管理员:
@ -403,6 +652,11 @@ CREATE TABLE IF NOT EXISTS gateway_billing_reservations (
- `POST /api/admin/billing/resource-packages`:创建资源包。 - `POST /api/admin/billing/resource-packages`:创建资源包。
- `PATCH /api/admin/billing/resource-packages/{id}`:更新资源包。 - `PATCH /api/admin/billing/resource-packages/{id}`:更新资源包。
- `POST /api/admin/billing/resource-entitlements/grant`:给用户发放资源包。 - `POST /api/admin/billing/resource-entitlements/grant`:给用户发放资源包。
- `GET /api/admin/billing/redeem-codes`:查询兑换码和活动批次。
- `POST /api/admin/billing/redeem-codes`:创建单个兑换码。
- `POST /api/admin/billing/redeem-codes/batch`:批量生成或导入兑换码。
- `PATCH /api/admin/billing/redeem-codes/{id}`:启用、禁用或软删除兑换码。
- `GET /api/admin/billing/redeem-codes/{id}/redemptions`:查看兑换历史和发放结果。
- `GET /api/admin/billing/settlements`:查询任务结算链路。 - `GET /api/admin/billing/settlements`:查询任务结算链路。
管理接口应保持在 `/api/admin/...`,用户和 API Key 可访问接口保持在 `/api/v1/...`,避免管理面和运行面权限混用。 管理接口应保持在 `/api/admin/...`,用户和 API Key 可访问接口保持在 `/api/v1/...`,避免管理面和运行面权限混用。
@ -414,8 +668,10 @@ CREATE TABLE IF NOT EXISTS gateway_billing_reservations (
Gateway 作为计费事实源: Gateway 作为计费事实源:
- 用户钱包、充值订单、资源包、消耗流水全部在 Gateway 落库。 - 用户钱包、充值订单、资源包、消耗流水全部在 Gateway 落库。
- 兑换码创建、兑换、资源包发放和会员权益发放全部在 Gateway 落库。
- 任务运行前由 Gateway 完成余额和资源包校验。 - 任务运行前由 Gateway 完成余额和资源包校验。
- 任务完成后由 Gateway 完成结算。 - 任务完成后由 Gateway 完成结算。
- 如果产品包含组织或会员权益Gateway 需要有本地组织/会员表,或通过 outbox 同步到下游业务系统。
### 接入 `server-main` 模式 ### 接入 `server-main` 模式
@ -426,6 +682,23 @@ Gateway 作为计费事实源:
无论选择哪种策略,任务侧都应保留 `task_id`、`request_id`、`billing_summary` 和最终结算明细,保证跨系统可对账。 无论选择哪种策略,任务侧都应保留 `task_id`、`request_id`、`billing_summary` 和最终结算明细,保证跨系统可对账。
如果兑换码仍在 `server-main` 创建和管理Gateway 不应再单独发放同一份权益。推荐边界:
- `server-main` 负责兑换码创建、领取资格、会员权限和资源包发放。
- Gateway 通过同步任务或回调镜像 `resource_packages`、`redeem_codes`、`redeemHistory` 到本地查询表。
- Gateway 任务结算优先使用镜像资源包做预检;最终扣减仍回调 `server-main`,或在 Gateway 成为事实源后再切换本地扣减。
如果 Gateway 接管兑换码,迁移时需要:
- 把 `Product.balance` 映射为 `gateway_resource_packages.paid_amount`
- 把 `Product.extra_balance` 映射为 `gateway_resource_packages.bonus_amount`
- 把 `Product.balance_valid_time` 映射为 `validity_days`
- 把 `Product.valid_time` 映射为 `membership_validity_days`
- 把 `Product.resource_package_type` 映射为 `owner_scope`
- 把 `Product.enable_redeem_code` 映射为 `redeem_enabled`
- 把旧 `RedeemCode.redeemHistory` 拆成 `gateway_redeem_code_redemptions`
- 先按旧唯一规则清理重复资源包,再创建 Gateway 唯一索引。
## 分阶段落地 ## 分阶段落地
### Phase 1钱包结算稳定 ### Phase 1钱包结算稳定
@ -441,22 +714,33 @@ Gateway 作为计费事实源:
- 新增 `gateway_resource_packages` - 新增 `gateway_resource_packages`
- 新增 `gateway_user_resource_entitlements` - 新增 `gateway_user_resource_entitlements`
- 新增 `gateway_resource_usage_records` - 新增 `gateway_resource_usage_records`
- 结算时先扣资源包,再扣钱包余额。 - 支持个人资源包和组织资源包。
- 支持付费额度和赠送额度拆分,资源包内先扣赠送额度。
- 结算时按资源类型明细先扣资源包,再扣钱包余额。
- 管理后台支持创建资源包和给用户发放资源包。 - 管理后台支持创建资源包和给用户发放资源包。
### Phase 3预占和释放 ### Phase 3兑换码和营销发放
- 新增 `gateway_redeem_codes`
- 新增 `gateway_redeem_code_redemptions`
- 支持普通算力码和资源包产品码。
- 支持多人共享码、一人一码、批量生成、启用禁用和软删除。
- 兑换时原子抢占次数,发放失败自动回滚或进入补偿队列。
- 用户兑换接口改为请求体传 code并对 code 做 hash 查询和脱敏展示。
### Phase 4预占和释放
- 新增 `gateway_billing_reservations` - 新增 `gateway_billing_reservations`
- 异步长任务创建时冻结资源或余额。 - 异步长任务创建时冻结资源或余额。
- 成功时结算,失败时释放。 - 成功时结算,失败时释放。
- 支持多退少补。 - 支持多退少补。
### Phase 4报表与对账 ### Phase 5:报表与对账
- 按用户、租户、API Key、模型、资源类型统计收入和消耗。 - 按用户、租户、API Key、模型、资源类型统计收入和消耗。
- 提供任务维度对账详情。 - 提供任务维度对账详情。
- 提供钱包流水和资源包消耗导出。 - 提供钱包流水、资源包消耗、兑换码领取和发放结果导出。
- 增加异常流水巡检,例如负余额、重复扣费、预占未释放。 - 增加异常流水巡检,例如负余额、重复扣费、预占未释放、兑换已占用但未发放、资源包剩余与流水不一致
## 风险点 ## 风险点
@ -466,6 +750,11 @@ Gateway 作为计费事实源:
- 不要让 `estimated billing` 和真实结算使用两套价格逻辑。 - 不要让 `estimated billing` 和真实结算使用两套价格逻辑。
- 不要只依赖钱包余额判断可用额度,资源包也要纳入预检。 - 不要只依赖钱包余额判断可用额度,资源包也要纳入预检。
- 不要把资源包余额折算成用户表余额,否则后续过期、退款和按类型限制都会变复杂。 - 不要把资源包余额折算成用户表余额,否则后续过期、退款和按类型限制都会变复杂。
- 不要让兑换码领取次数和权益发放分离提交;如果下游发放失败,必须回滚或留下可补偿的失败流水。
- 不要把明文兑换码放在 URL、日志、埋点或管理端列表里。
- 不要忽略组织资源包。旧系统里组织资源包、组织余额、个人资源包和个人余额存在明确优先级。
- 不要把算力有效期和会员有效期混用,`balance_valid_time` 与 `valid_time` 应分开迁移。
- 不要把资源包不足时的部分扣减留在数据库里。资源包扣减和余额补扣必须同事务成功或同事务失败。
- 测试模式和模拟任务也应写入清晰的模拟计费标识,避免和真实扣费混淆。 - 测试模式和模拟任务也应写入清晰的模拟计费标识,避免和真实扣费混淆。
## 推荐实现边界 ## 推荐实现边界
@ -474,8 +763,14 @@ Gateway 作为计费事实源:
中期要把结算入口抽成统一服务,例如 `BillingService.SettleTask(ctx, task)` 中期要把结算入口抽成统一服务,例如 `BillingService.SettleTask(ctx, task)`
- 输入:任务 ID、用户 ID、资源类型、最终计费金额、usage、billing summary。 - 输入:任务 ID、用户 ID、`billings` 明细、最终计费金额、usage、billing summary。
- 输出:扣减了哪些资源包、扣了多少余额、最终结算状态。 - 输出:扣减了哪些资源包、扣了多少余额、最终结算状态。
- 内部:统一处理资源包优先、余额补扣、幂等、事务和流水。 - 内部:统一处理资源包优先、余额补扣、幂等、事务和流水。
长期则把预估、预占、结算、退款都收口在同一个计费域服务里,任务 runner 只负责在合适节点调用计费服务,不直接操作钱包或资源包表。 兑换码入口建议抽成 `BillingService.RedeemCode(ctx, user, code)`
- 输入:用户、租户、兑换码明文。
- 输出:兑换流水、发放的资源包权益、可选会员权益。
- 内部:统一处理 code hash、次数抢占、用户重复兑换校验、资源包发放、会员发放、失败回滚和审计流水。
长期则把预估、预占、结算、退款、兑换码发放都收口在同一个计费域服务里。任务 runner 只负责在合适节点调用计费服务,不直接操作钱包或资源包表;营销和支付入口也只调用计费服务,不直接写资源包实例。