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

18 KiB
Raw Blame History

用户余额与资源包设计

结论

用户余额不应直接放在用户表中。用户表只负责身份、登录、租户归属和状态等基础信息;余额、冻结金额、充值、扣费、退款、资源包领取和资源消耗应由独立的计费域表维护。

推荐采用以下结构:

  • 用户表:只保存用户身份信息,不保存真实余额。
  • 钱包账户表:保存用户当前余额快照。
  • 钱包流水表:保存所有余额变动,是资金审计的核心依据。
  • 资源包定义表:保存可购买或可赠送的生图、文本、视频等资源包配置。
  • 用户资源包权益表:保存用户实际拥有的资源包实例和剩余额度。
  • 资源消耗流水表:保存每次任务消耗了哪个资源包、消耗多少。
  • 任务记录表:保存模型调用、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 无法解释每次变化来源。
  • 充值、扣费、退款、赠送、后台调整都需要幂等和流水,用户表字段无法覆盖。
  • 资源包有有效期、来源、剩余额度、优先级和适用资源类型,不适合塞进用户表。
  • 长任务需要冻结和释放额度,只靠用户表余额无法表达中间态。

因此用户表最多可以保留只读展示缓存,例如最近余额快照、是否欠费等,但不能作为账务事实源。

领域模型

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 扩展信息

建议继续保持唯一约束:

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,
  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

保存用户实际拥有的资源包实例。用户购买一次或被赠送一次,就生成一条权益记录。

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()
);

推荐索引:

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

保存资源包消耗流水。每次任务消耗资源包,都需要记录消耗前后额度。

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()
);

推荐唯一索引:

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_summaryfinal_charge_amount
  2. resource_type 查找用户可用资源包。
  3. 优先扣即将过期的资源包。
  4. 同一过期时间下按 priority 从小到大扣。
  5. 资源包不足时,剩余部分从 gateway_wallet_accounts.balance 扣。
  6. 如果资源包和余额都不足,任务进入余额不足错误,不发起真实 provider 调用。

资源包查询条件:

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_balancefrozen_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}

现有钱包扣费已经使用 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_transactionsgateway_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_idrequest_idbilling_summary 和最终结算明细,保证跨系统可对账。

分阶段落地

Phase 1:钱包结算稳定

  • 继续使用现有 gateway_wallet_accountsgateway_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 只负责在合适节点调用计费服务,不直接操作钱包或资源包表。