36 KiB
用户余额与资源包设计
结论
用户余额不应直接放在用户表中。用户表只负责身份、登录、租户归属和状态等基础信息;余额、冻结金额、充值、扣费、退款、资源包领取和资源消耗应由独立的计费域表维护。
推荐采用以下结构:
- 用户表:只保存用户身份信息,不保存真实余额。
- 钱包账户表:保存用户当前余额快照。
- 钱包流水表:保存所有余额变动,是资金审计的核心依据。
- 资源包定义表:保存可购买或可赠送的生图、文本、视频等资源包配置。
- 用户资源包权益表:保存用户实际拥有的资源包实例和剩余额度。
- 资源消耗流水表:保存每次任务消耗了哪个资源包、消耗多少。
- 兑换码表和兑换流水表:保存兑换码资格、领取历史、发放结果和失败回滚。
- 任务记录表:保存模型调用、usage、最终扣费、RequestID、耗时等运行事实。
核心原则是:任务记录证明为什么扣,钱包和资源流水证明扣了什么。
设计目标
- 支持余额充值、赠送、后台调整、消费、退款和失败返还。
- 支持后期增加文本资源包、生图资源包、视频资源包、音频资源包等权益。
- 支持资源包优先抵扣,资源包不足时余额补扣。
- 支持兑换码发放资源包,并覆盖单次码、多人码、活动码、资源包产品码等营销场景。
- 支持个人资源包和组织资源包,并支持付费额度、赠送额度分开管理。
- 支持同步任务和异步长任务的预占、冻结、结算和释放。
- 支持按用户、租户、API Key、任务、RequestID 查询完整账务链路。
- 支持接入
server-main和 Gateway 独立部署两种模式。 - 保持计费结算幂等,避免任务重试、回调重放导致重复扣费。
现有基础
当前仓库已经有钱包和任务计费相关基础表:
gateway_wallet_accountsgateway_wallet_transactionsgateway_recharge_ordersgateway_tasksgateway_task_attemptsgateway_task_events
其中 gateway_tasks 已包含任务级计费字段:
api_key_idapi_key_namerequested_modelresolved_modelrequest_idusagemetricsbilling_summaryfinal_charge_amountresponse_started_atresponse_finished_atresponse_duration_ms
当前 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只做聚合展示,剩余再汇总到钱包扣费。
为什么不放用户表
余额直接放在用户表会带来几个问题:
- 用户表会承载身份、权限、租户、余额、资源权益等多种职责,后期很难维护。
- 余额变化需要强审计,只存一个
balance无法解释每次变化来源。 - 充值、扣费、退款、赠送、后台调整都需要幂等和流水,用户表字段无法覆盖。
- 资源包有有效期、来源、剩余额度、优先级和适用资源类型,不适合塞进用户表。
- 长任务需要冻结和释放额度,只靠用户表余额无法表达中间态。
因此用户表最多可以保留只读展示缓存,例如最近余额快照、是否欠费等,但不能作为账务事实源。
领域模型
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_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
钱包表设计
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 |
扩展信息 |
建议继续保持唯一约束:
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 |
预占释放 |
已有唯一索引应继续保留:
UNIQUE (account_id, idempotency_key)
WHERE idempotency_key IS NOT NULL
资源包表设计
gateway_resource_packages
定义平台可售卖、可赠送、可配置的资源包模板。它是 SKU 或权益模板,不代表用户已经拥有。
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,
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,
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',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
字段补充说明:
| 字段 | 说明 |
|---|---|
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 |
说明 |
|---|---|
text |
文本模型消耗,可按 token 或 1k token 计量 |
image |
文生图消耗 |
image_edit |
图像编辑消耗 |
video |
视频生成或图生视频消耗 |
audio |
音频生成或处理消耗 |
music |
音乐生成消耗 |
digital_human |
数字人生成消耗 |
model |
3D 或模型类资源消耗 |
如果某类能力后续需要更细粒度,可以在 metadata 中记录适用模型、分辨率、质量、时长范围等约束,或者再拆出 gateway_resource_package_rules。
gateway_user_resource_entitlements
保存用户实际拥有的资源包实例。用户购买一次或被赠送一次,就生成一条权益记录。
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 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,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
推荐索引:
CREATE INDEX IF NOT EXISTS idx_gateway_entitlements_user_available
ON gateway_user_resource_entitlements (
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 |
说明 |
|---|---|
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
保存资源包消耗流水。每次任务消耗资源包,都需要记录消耗前后额度。
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,
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,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
amount_breakdown 用来记录本次从付费额度和赠送额度各扣了多少,例如:
{ "bonus": 6, "paid": 4 }
这样既可以保留 server-main 中“先扣赠送,再扣套餐内额度”的策略,也能在退款、过期清理和营销成本核算时区分付费额度与赠送额度。
推荐唯一索引:
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;
兑换码设计
兑换码是资源包发放入口,不应直接改钱包余额。普通算力兑换码也应发放一条个人资源包权益,这样后续过期、禁用、消耗流水和对账都能走同一条资源包链路。
gateway_redeem_codes
保存兑换码本身和可兑换权益。建议只存规范化后的 hash,避免明文 code 进入数据库、URL、日志和后台列表。
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 或会员权益表处理组织权限 |
推荐索引:
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 数组。
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)
);
默认同一用户对同一兑换码只能成功兑换一次:
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 维护次数,而不是移除总次数和幂等约束。
兑换流程
- 用户提交 code 到
POST /api/v1/billing/redeem-codes/redeem的请求体。 - 服务端对 code 做规范化,例如 trim、统一大小写、移除空格或连字符,再计算
code_hash。 - 开启事务,锁定
gateway_redeem_codes行。 - 校验
status、deleted_at、starts_at、expires_at、current_uses < max_uses、租户/渠道/活动限制。 - 校验用户是否已兑换过;默认同一用户只能兑换一次。
- 如果是
resource_package,校验package_id存在,且purchase_enabled = true或redeem_enabled = true。 - 用条件更新抢占次数,例如
current_uses = current_uses + 1 WHERE current_uses < max_uses,插入gateway_redeem_code_redemptions(status='claiming')。 - 创建
gateway_user_resource_entitlements,source_type='redeem_code'、source_id=redeem_code_id、redeemed_by_gateway_user_id=当前用户。 - 如果产品带会员或组织权限,将会员发放写入同一事务内的本地表,或写入 outbox 让
server-main异步处理。 - 更新兑换流水为
granted,提交事务。
失败处理:
- 如果事务内发放失败,整笔事务回滚,
current_uses不增加。 - 如果事务后调用外部系统失败,不要静默吞掉。应把兑换流水标记为
failed或reversed,并通过补偿任务重试或人工处理。 - 用户看到的错误信息应保持收敛,例如“兑换码无效、已过期或已达到使用上限”,避免暴露 code 是否存在。
兑换码运营能力
管理端建议支持:
- 单个创建和批量生成兑换码。
- 共享多人码和一人一码两种模式。
- 设置总可用次数、单用户次数、开始时间、过期时间。
- 绑定资源包模板,或直接设置普通算力额度和有效期。
- 限定租户、用户组、渠道、新用户、组织范围等使用条件。
- 启用、禁用、软删除、导出和查看兑换历史。
- 查看每个 code 的已领取次数、失败次数、剩余次数和最近兑换时间。
安全要求:
- 兑换接口必须做用户、IP、租户维度的限流,防止撞库。
- 后台列表只显示
masked_code,明文 code 只在创建或导入结果中短暂返回。 - 兑换接口不要把 code 放在 URL path、埋点明文或错误日志里。
- 批量生成 code 时要使用加密安全随机数,并做唯一冲突重试。
扣费顺序
结算时应按 billings 中的资源类型明细逐行扣减,而不是只拿 final_charge_amount 一次性扣。这样文本、图片、视频等不同资源类型可以命中各自的资源包规则。
默认扣费顺序:
- 解析任务最终消耗,生成
billing_summary和final_charge_amount。 - 展开
billings,按resourceType分组,并把聚合结果写回billing_summary。 - 按扣费策略查找可用资源包和钱包账户。
- 优先扣即将过期的资源包。
- 同一过期时间下按
priority从小到大扣。 - 单个资源包内部先扣
bonus_remaining,再扣paid_remaining。 - 资源包不足时,剩余部分从钱包余额扣。
- 如果资源包和余额都不足,任务进入余额不足错误,不发起真实 provider 调用。
为了兼容 server-main 的组织扣费语义,建议把扣费顺序做成策略配置,默认可设置为:
| 顺序 | 来源 | 说明 |
|---|---|---|
| 1 | organization_entitlement |
用户所在组织或上级组织的资源包 |
| 2 | organization_wallet |
组织钱包余额,只有开启组织账户扣除时启用 |
| 3 | user_entitlement |
用户个人资源包 |
| 4 | user_wallet |
用户个人钱包余额 |
如果 Gateway 独立模式暂时没有组织钱包,可以先只启用 organization_entitlement 和 user_entitlement,组织余额继续由 server-main 回调结算。
资源包查询条件:
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())
ORDER BY expires_at ASC NULLS LAST, priority ASC, created_at ASC
FOR UPDATE
结算前的余额预检要按完整扣费策略计算总可用额度。如果资源包只能覆盖一部分,必须确认钱包能覆盖剩余部分后再进入 provider 调用;否则直接返回余额不足。正式结算时资源包扣减、钱包扣减和流水写入要在同一个事务内完成。
结算流程
同步任务
同步任务可以在 provider 成功返回后直接结算:
- 写入或更新
gateway_tasks。 - 保存 usage、metrics、billings、billing_summary、final_charge_amount。
- 开启数据库事务。
- 锁定用户资源包和钱包账户。
- 按资源类型逐行计算资源包扣减和余额补扣。
- 写入
gateway_resource_usage_records。 - 如需余额补扣,写入
gateway_wallet_transactions。 - 更新资源包剩余额度和钱包余额。
- 提交事务。
事务内必须先确认本次任务的所有扣费来源都能覆盖最终金额,再执行扣减。不能出现“资源包已扣一部分,但余额补扣失败”的半完成状态。
异步长任务
生图、视频、数字人等异步任务建议增加预占流程:
- 创建任务时根据 estimated billing 计算预计消耗。
- 预占资源包或冻结余额。
- 任务成功时按实际消耗结算,多退少补。
- 任务失败、取消或超时后释放预占。
- 每一步都写幂等流水,避免重试导致重复冻结或重复释放。
如果不想立刻新增独立预占表,可以先使用 frozen_balance 和 frozen_amount 表达冻结快照,但长期建议补充 gateway_billing_reservations 记录预占明细。
建议预占表:
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} |
| 兑换码领取 | 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,后续如果引入资源包和预占,建议新逻辑逐步迁移到更细粒度的幂等键,同时保留旧键兼容已有流水。
与任务记录的关系
gateway_tasks 是运行事实表,应该保存:
- 用户请求了哪个模型。
- 实际路由到了哪个平台和模型。
- provider 返回了哪些 usage。
- 开始响应时间、结束响应时间和响应耗时。
- 最终结算金额和计费摘要。
- API Key 名称、Key ID、RequestID 等审计字段。
钱包和资源包流水不重复保存完整任务响应,只保存必要关联和账务快照:
reference_type = 'gateway_task'reference_id = gateway_tasks.idmetadata.taskIdmetadata.requestIdmetadata.modelmetadata.resolvedModelmetadata.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:创建充值订单。POST /api/v1/billing/redeem-codes/redeem:兑换兑换码,code 放在请求体中。GET /api/v1/billing/redeem-codes/history:查看自己的兑换记录。
面向管理员:
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/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/...,避免管理面和运行面权限混用。
接入模式
Gateway 独立模式
Gateway 作为计费事实源:
- 用户钱包、充值订单、资源包、消耗流水全部在 Gateway 落库。
- 兑换码创建、兑换、资源包发放和会员权益发放全部在 Gateway 落库。
- 任务运行前由 Gateway 完成余额和资源包校验。
- 任务完成后由 Gateway 完成结算。
- 如果产品包含组织或会员权益,Gateway 需要有本地组织/会员表,或通过 outbox 同步到下游业务系统。
接入 server-main 模式
如果 server-main 仍是充值和用户余额事实源,有两种可选策略:
- Gateway 只记录任务计费明细,结算请求回调
server-main完成扣费。 - Gateway 维护本地镜像钱包和资源包,用于快速校验和查询,但充值、退款等资金入口仍以
server-main为准。
无论选择哪种策略,任务侧都应保留 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:钱包结算稳定
- 继续使用现有
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_redeem_codes。 - 新增
gateway_redeem_code_redemptions。 - 支持普通算力码和资源包产品码。
- 支持多人共享码、一人一码、批量生成、启用禁用和软删除。
- 兑换时原子抢占次数,发放失败自动回滚或进入补偿队列。
- 用户兑换接口改为请求体传 code,并对 code 做 hash 查询和脱敏展示。
Phase 4:预占和释放
- 新增
gateway_billing_reservations。 - 异步长任务创建时冻结资源或余额。
- 成功时结算,失败时释放。
- 支持多退少补。
Phase 5:报表与对账
- 按用户、租户、API Key、模型、资源类型统计收入和消耗。
- 提供任务维度对账详情。
- 提供钱包流水、资源包消耗、兑换码领取和发放结果导出。
- 增加异常流水巡检,例如负余额、重复扣费、预占未释放、兑换已占用但未发放、资源包剩余与流水不一致。
风险点
- 余额扣减必须在事务内使用
FOR UPDATE锁定账户,不能先查后扣。 - 资源包扣减也必须锁定权益行,避免并发任务超扣。
- 所有结算、退款、释放都必须有幂等键。
- 不要让
estimated billing和真实结算使用两套价格逻辑。 - 不要只依赖钱包余额判断可用额度,资源包也要纳入预检。
- 不要把资源包余额折算成用户表余额,否则后续过期、退款和按类型限制都会变复杂。
- 不要让兑换码领取次数和权益发放分离提交;如果下游发放失败,必须回滚或留下可补偿的失败流水。
- 不要把明文兑换码放在 URL、日志、埋点或管理端列表里。
- 不要忽略组织资源包。旧系统里组织资源包、组织余额、个人资源包和个人余额存在明确优先级。
- 不要把算力有效期和会员有效期混用,
balance_valid_time与valid_time应分开迁移。 - 不要把资源包不足时的部分扣减留在数据库里。资源包扣减和余额补扣必须同事务成功或同事务失败。
- 测试模式和模拟任务也应写入清晰的模拟计费标识,避免和真实扣费混淆。
推荐实现边界
短期可以先保留当前钱包结算主线,只补齐文档和接口;资源包作为第二阶段新增,不影响现有任务扣费。
中期要把结算入口抽成统一服务,例如 BillingService.SettleTask(ctx, task):
- 输入:任务 ID、用户 ID、
billings明细、最终计费金额、usage、billing summary。 - 输出:扣减了哪些资源包、扣了多少余额、最终结算状态。
- 内部:统一处理资源包优先、余额补扣、幂等、事务和流水。
兑换码入口建议抽成 BillingService.RedeemCode(ctx, user, code):
- 输入:用户、租户、兑换码明文。
- 输出:兑换流水、发放的资源包权益、可选会员权益。
- 内部:统一处理 code hash、次数抢占、用户重复兑换校验、资源包发放、会员发放、失败回滚和审计流水。
长期则把预估、预占、结算、退款、兑换码发放都收口在同一个计费域服务里。任务 runner 只负责在合适节点调用计费服务,不直接操作钱包或资源包表;营销和支付入口也只调用计费服务,不直接写资源包实例。