docs: refine wallet resource package design
This commit is contained in:
parent
63c38fe417
commit
4e54134e2a
@ -12,6 +12,7 @@
|
||||
- 资源包定义表:保存可购买或可赠送的生图、文本、视频等资源包配置。
|
||||
- 用户资源包权益表:保存用户实际拥有的资源包实例和剩余额度。
|
||||
- 资源消耗流水表:保存每次任务消耗了哪个资源包、消耗多少。
|
||||
- 兑换码表和兑换流水表:保存兑换码资格、领取历史、发放结果和失败回滚。
|
||||
- 任务记录表:保存模型调用、usage、最终扣费、RequestID、耗时等运行事实。
|
||||
|
||||
核心原则是:**任务记录证明为什么扣,钱包和资源流水证明扣了什么。**
|
||||
@ -21,6 +22,8 @@
|
||||
- 支持余额充值、赠送、后台调整、消费、退款和失败返还。
|
||||
- 支持后期增加文本资源包、生图资源包、视频资源包、音频资源包等权益。
|
||||
- 支持资源包优先抵扣,资源包不足时余额补扣。
|
||||
- 支持兑换码发放资源包,并覆盖单次码、多人码、活动码、资源包产品码等营销场景。
|
||||
- 支持个人资源包和组织资源包,并支持付费额度、赠送额度分开管理。
|
||||
- 支持同步任务和异步长任务的预占、冻结、结算和释放。
|
||||
- 支持按用户、租户、API Key、任务、RequestID 查询完整账务链路。
|
||||
- 支持接入 `server-main` 和 Gateway 独立部署两种模式。
|
||||
@ -54,6 +57,32 @@
|
||||
|
||||
当前 `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_users ||--o{ gateway_user_resource_entitlements : owns
|
||||
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_tasks ||--o{ gateway_resource_usage_records : 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,
|
||||
resource_type 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,
|
||||
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,
|
||||
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,
|
||||
stackable boolean NOT NULL DEFAULT true,
|
||||
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` | 说明 |
|
||||
@ -199,17 +252,28 @@ CREATE TABLE IF NOT EXISTS gateway_resource_packages (
|
||||
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,
|
||||
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,
|
||||
resource_type 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,
|
||||
paid_remaining numeric NOT NULL DEFAULT 0,
|
||||
bonus_remaining numeric NOT NULL DEFAULT 0,
|
||||
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,
|
||||
source_name text,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
disabled boolean NOT NULL DEFAULT false,
|
||||
starts_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
@ -223,24 +287,44 @@ CREATE TABLE IF NOT EXISTS gateway_user_resource_entitlements (
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_entitlements_user_available
|
||||
ON gateway_user_resource_entitlements (
|
||||
gateway_user_id,
|
||||
owner_type,
|
||||
owner_id,
|
||||
resource_type,
|
||||
status,
|
||||
expires_at,
|
||||
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` | 说明 |
|
||||
| --- | --- |
|
||||
| `purchase` | 用户购买 |
|
||||
| `recharge_bonus` | 充值赠送 |
|
||||
| `order` | 支付订单发放,兼容 `server-main` 订单资源包 |
|
||||
| `redeem_code` | 兑换码发放 |
|
||||
| `admin_grant` | 后台发放 |
|
||||
| `campaign` | 活动发放 |
|
||||
| `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`
|
||||
|
||||
保存资源包消耗流水。每次任务消耗资源包,都需要记录消耗前后额度。
|
||||
@ -255,8 +339,11 @@ CREATE TABLE IF NOT EXISTS gateway_resource_usage_records (
|
||||
resource_type text NOT NULL,
|
||||
unit text NOT NULL,
|
||||
amount numeric NOT NULL,
|
||||
amount_breakdown jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
remaining_before 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',
|
||||
usage_type text NOT NULL DEFAULT 'task_billing',
|
||||
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
|
||||
@ -273,23 +368,166 @@ CREATE UNIQUE INDEX IF NOT EXISTS uniq_gateway_resource_usage_idempotency
|
||||
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`。
|
||||
2. 按 `resource_type` 查找用户可用资源包。
|
||||
3. 优先扣即将过期的资源包。
|
||||
4. 同一过期时间下按 `priority` 从小到大扣。
|
||||
5. 资源包不足时,剩余部分从 `gateway_wallet_accounts.balance` 扣。
|
||||
6. 如果资源包和余额都不足,任务进入余额不足错误,不发起真实 provider 调用。
|
||||
2. 展开 `billings`,按 `resourceType` 分组,并把聚合结果写回 `billing_summary`。
|
||||
3. 按扣费策略查找可用资源包和钱包账户。
|
||||
4. 优先扣即将过期的资源包。
|
||||
5. 同一过期时间下按 `priority` 从小到大扣。
|
||||
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
|
||||
gateway_user_id = ?
|
||||
owner_type = ?
|
||||
AND owner_id = ?
|
||||
AND resource_type = ?
|
||||
AND status = 'active'
|
||||
AND disabled = false
|
||||
AND remaining_amount > 0
|
||||
AND (starts_at IS NULL OR starts_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
|
||||
```
|
||||
|
||||
结算前的余额预检要按完整扣费策略计算总可用额度。如果资源包只能覆盖一部分,必须确认钱包能覆盖剩余部分后再进入 provider 调用;否则直接返回余额不足。正式结算时资源包扣减、钱包扣减和流水写入要在同一个事务内完成。
|
||||
|
||||
## 结算流程
|
||||
|
||||
### 同步任务
|
||||
@ -307,10 +547,13 @@ FOR UPDATE
|
||||
2. 保存 usage、metrics、billings、billing_summary、final_charge_amount。
|
||||
3. 开启数据库事务。
|
||||
4. 锁定用户资源包和钱包账户。
|
||||
5. 写入 `gateway_resource_usage_records`。
|
||||
6. 如需余额补扣,写入 `gateway_wallet_transactions`。
|
||||
7. 更新资源包剩余额度和钱包余额。
|
||||
8. 提交事务。
|
||||
5. 按资源类型逐行计算资源包扣减和余额补扣。
|
||||
6. 写入 `gateway_resource_usage_records`。
|
||||
7. 如需余额补扣,写入 `gateway_wallet_transactions`。
|
||||
8. 更新资源包剩余额度和钱包余额。
|
||||
9. 提交事务。
|
||||
|
||||
事务内必须先确认本次任务的所有扣费来源都能覆盖最终金额,再执行扣减。不能出现“资源包已扣一部分,但余额补扣失败”的半完成状态。
|
||||
|
||||
### 异步长任务
|
||||
|
||||
@ -359,6 +602,10 @@ CREATE TABLE IF NOT EXISTS gateway_billing_reservations (
|
||||
| 任务预占 | `task:{task_id}:reservation:{resource_type}` |
|
||||
| 任务释放 | `task:{task_id}:reservation:{reservation_id}:release` |
|
||||
| 任务退款 | `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`,后续如果引入资源包和预占,建议新逻辑逐步迁移到更细粒度的幂等键,同时保留旧键兼容已有流水。
|
||||
|
||||
@ -394,6 +641,8 @@ CREATE TABLE IF NOT EXISTS gateway_billing_reservations (
|
||||
- `GET /api/v1/billing/resource-entitlements`:查看资源包权益。
|
||||
- `GET /api/v1/billing/resource-usage`:查看资源包消耗。
|
||||
- `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`:创建资源包。
|
||||
- `PATCH /api/admin/billing/resource-packages/{id}`:更新资源包。
|
||||
- `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`:查询任务结算链路。
|
||||
|
||||
管理接口应保持在 `/api/admin/...`,用户和 API Key 可访问接口保持在 `/api/v1/...`,避免管理面和运行面权限混用。
|
||||
@ -414,8 +668,10 @@ CREATE TABLE IF NOT EXISTS gateway_billing_reservations (
|
||||
Gateway 作为计费事实源:
|
||||
|
||||
- 用户钱包、充值订单、资源包、消耗流水全部在 Gateway 落库。
|
||||
- 兑换码创建、兑换、资源包发放和会员权益发放全部在 Gateway 落库。
|
||||
- 任务运行前由 Gateway 完成余额和资源包校验。
|
||||
- 任务完成后由 Gateway 完成结算。
|
||||
- 如果产品包含组织或会员权益,Gateway 需要有本地组织/会员表,或通过 outbox 同步到下游业务系统。
|
||||
|
||||
### 接入 `server-main` 模式
|
||||
|
||||
@ -426,6 +682,23 @@ Gateway 作为计费事实源:
|
||||
|
||||
无论选择哪种策略,任务侧都应保留 `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:钱包结算稳定
|
||||
@ -441,22 +714,33 @@ Gateway 作为计费事实源:
|
||||
- 新增 `gateway_resource_packages`。
|
||||
- 新增 `gateway_user_resource_entitlements`。
|
||||
- 新增 `gateway_resource_usage_records`。
|
||||
- 结算时先扣资源包,再扣钱包余额。
|
||||
- 支持个人资源包和组织资源包。
|
||||
- 支持付费额度和赠送额度拆分,资源包内先扣赠送额度。
|
||||
- 结算时按资源类型明细先扣资源包,再扣钱包余额。
|
||||
- 管理后台支持创建资源包和给用户发放资源包。
|
||||
|
||||
### Phase 3:预占和释放
|
||||
### Phase 3:兑换码和营销发放
|
||||
|
||||
- 新增 `gateway_redeem_codes`。
|
||||
- 新增 `gateway_redeem_code_redemptions`。
|
||||
- 支持普通算力码和资源包产品码。
|
||||
- 支持多人共享码、一人一码、批量生成、启用禁用和软删除。
|
||||
- 兑换时原子抢占次数,发放失败自动回滚或进入补偿队列。
|
||||
- 用户兑换接口改为请求体传 code,并对 code 做 hash 查询和脱敏展示。
|
||||
|
||||
### Phase 4:预占和释放
|
||||
|
||||
- 新增 `gateway_billing_reservations`。
|
||||
- 异步长任务创建时冻结资源或余额。
|
||||
- 成功时结算,失败时释放。
|
||||
- 支持多退少补。
|
||||
|
||||
### Phase 4:报表与对账
|
||||
### Phase 5:报表与对账
|
||||
|
||||
- 按用户、租户、API Key、模型、资源类型统计收入和消耗。
|
||||
- 提供任务维度对账详情。
|
||||
- 提供钱包流水和资源包消耗导出。
|
||||
- 增加异常流水巡检,例如负余额、重复扣费、预占未释放。
|
||||
- 提供钱包流水、资源包消耗、兑换码领取和发放结果导出。
|
||||
- 增加异常流水巡检,例如负余额、重复扣费、预占未释放、兑换已占用但未发放、资源包剩余与流水不一致。
|
||||
|
||||
## 风险点
|
||||
|
||||
@ -466,6 +750,11 @@ Gateway 作为计费事实源:
|
||||
- 不要让 `estimated billing` 和真实结算使用两套价格逻辑。
|
||||
- 不要只依赖钱包余额判断可用额度,资源包也要纳入预检。
|
||||
- 不要把资源包余额折算成用户表余额,否则后续过期、退款和按类型限制都会变复杂。
|
||||
- 不要让兑换码领取次数和权益发放分离提交;如果下游发放失败,必须回滚或留下可补偿的失败流水。
|
||||
- 不要把明文兑换码放在 URL、日志、埋点或管理端列表里。
|
||||
- 不要忽略组织资源包。旧系统里组织资源包、组织余额、个人资源包和个人余额存在明确优先级。
|
||||
- 不要把算力有效期和会员有效期混用,`balance_valid_time` 与 `valid_time` 应分开迁移。
|
||||
- 不要把资源包不足时的部分扣减留在数据库里。资源包扣减和余额补扣必须同事务成功或同事务失败。
|
||||
- 测试模式和模拟任务也应写入清晰的模拟计费标识,避免和真实扣费混淆。
|
||||
|
||||
## 推荐实现边界
|
||||
@ -474,8 +763,14 @@ Gateway 作为计费事实源:
|
||||
|
||||
中期要把结算入口抽成统一服务,例如 `BillingService.SettleTask(ctx, task)`:
|
||||
|
||||
- 输入:任务 ID、用户 ID、资源类型、最终计费金额、usage、billing summary。
|
||||
- 输入:任务 ID、用户 ID、`billings` 明细、最终计费金额、usage、billing summary。
|
||||
- 输出:扣减了哪些资源包、扣了多少余额、最终结算状态。
|
||||
- 内部:统一处理资源包优先、余额补扣、幂等、事务和流水。
|
||||
|
||||
长期则把预估、预占、结算、退款都收口在同一个计费域服务里,任务 runner 只负责在合适节点调用计费服务,不直接操作钱包或资源包表。
|
||||
兑换码入口建议抽成 `BillingService.RedeemCode(ctx, user, code)`:
|
||||
|
||||
- 输入:用户、租户、兑换码明文。
|
||||
- 输出:兑换流水、发放的资源包权益、可选会员权益。
|
||||
- 内部:统一处理 code hash、次数抢占、用户重复兑换校验、资源包发放、会员发放、失败回滚和审计流水。
|
||||
|
||||
长期则把预估、预占、结算、退款、兑换码发放都收口在同一个计费域服务里。任务 runner 只负责在合适节点调用计费服务,不直接操作钱包或资源包表;营销和支付入口也只调用计费服务,不直接写资源包实例。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user