首版收敛 · 先让一次收费跑通

一池积分,
先把一次收费做好。

团队共用余额。调用方给出预扣金额和最终金额,Billing 负责冻结、扣款与幂等。

从团队余额开始
充值:人工改库同事务增加余额并记充值记录
金额:调用方给定统一使用整数 credit
账户:只管租户余额所有成员共用可用积分
先看账户 · 一张团队预付卡

所有人,使用同一池积分

“租户”就是一个团队。开工只检查这个团队还剩多少可用积分。

首版已确认
人工改库充值加余额并保存充值记录
团队积分池 · 示例
100积分
可用积分 = 余额 − 已冻结积分
小林使用共享余额
小夏使用共享余额
小周使用共享余额
只过一道门:团队可用积分够不够?
余额足够,可以预留

余额不足由 Billing 预扣用例明确报错。internal worker 负责停止执行与通知用户;Billing 不处理运行状态或用户提示。

再跟一次任务 · 开工到收尾

先占住 20,再按结果结账

以下金额和结果均为调用方示例。Billing 接收预扣 20,再按调用方给定的最终金额完成。

点击体验建议流程
生成 2 首歌演示价:每首有效歌曲 10 积分
这一笔任务的过程
准备开工
调用方要求预扣 20
先占住积分
防止被其他任务花掉
确认产出
看实际交付几首
结账并松开余量
这一步才正式扣费
任务 A · 歌曲生成还没开始
第 1 首待生成
第 2 首待生成
先按预计用量预扣 20 积分。
“预扣”在这里体现为冻结,此时还没有正式消费。
开工后,选择这次任务遇到的结果
这里会留下每一步的记录。
看这 100 积分去了哪里三部分相加,始终是 100
现在还可用
100积分
暂时占住
0积分
本次正式扣除
0积分

积分池里的余额是 100;目前没有任何冻结。

Billing 不决定估价或收费策略。调用方传 H 和 C;预扣检查可用余额,完成允许 C 超过 H 并扣成负余额。usage 仅原样保存。

最后看账 · 为什么扣这些积分

记录得细,账单看得懂

这里的 usage 结构与账单字段都是调用方示例。Billing 不定义 JSON 内容,也不从中生成业务账单。

记录与展示分开

同一操作,调用方可组织两种视图

账单会跟随上方演示变化
调用方给定的 usage 示例
请求生成2首
有效产出调用方示例—
未成功产出—
其他执行明细内部执行后记录

按节点保存实际用量和执行尝试。
歌词还可记录 token 与缓存用量。

调用方自行
组织展示
客户账单等待任务
使用工具歌曲生成
有效歌曲—
示例单价10 积分 / 首
实际扣费—积分
调用方提供什么 JSON,Billing 就保存什么
账单内容与展示由调用方组织
不同业务可保存不同 JSON。字段名、结构、token、价格或模型都不属于 Billing 的内部契约。
下方演示不代表 Billing 的返回 schema;资金接口只依赖独立的金额入参,不依赖 usage 中任何字段。
金额和策略由调用方决定

Billing 接收整数 credit;不根据 token、次数、模型或 usage 计算金额。

首版取舍已确认
接口边界 · 只维护资金

金额由调用方给,Billing 负责执行

两个资金用例不依赖定价、估价或用量 schema。调用方策略改变,不要求修改 Billing。

入参直接表达本次资金动作

已核对会议与会后确认
调用方提供 Billing 做什么 Billing 不解释什么
预扣金额 H 检查可用余额,成功后冻结 H 预估为何是这个数
最终金额 C 扣 C,释放原 H,幂等收尾 费用如何计算、为何为 0
usage · JSONB 原样保存与内部读取 键名、结构和业务含义
操作身份 按键去重,保存归属和关联 节点生命周期和业务授权策略

价格、成本转换、倍率、折扣、舍入、失败或取消时收多少,都由调用方确定。Billing 不维护价格映射,不解析 usage 计算金额。

预扣:可用额必须足够

balance − held ≥ H,才增加冻结。不足返回明确业务错误。

完成:允许超预扣

按给定 C 扣余额,释放原 H。C 可以超过 H,余额可以为负。

usage:不设内部结构

调用方给定 JSONB 内容,Billing 不要求任何业务字段。

最小架构 · 仍在现有后端内完成

一个计费模块,两张业务表

两张表维护租户账户和资金记录,两个用例接收调用方给定的金额。消费身份与职责 → · 数据库操作 → · 用例契约 →

Billing 提供两个资金用例

internal worker 在合适的时点调用预扣与完成。Billing 接收金额和 JSONB,负责资金;REST、Conductor 和其他业务策略归调用方。

internal worker

给出 H、C 和 usage
决定何时调用

BillingModule

prepareOperation
completeOperation

PostgreSQL

租户账户
消费/充值记录

Conductor↔Orchestra internal worker↔生成 REST API

预估、定价、重试、结果判断、停止运行和用户提示由调用方负责。Billing 不查询生成服务,也不解读 usage。

模块内部怎样组织?

领域对象维护整数资金规则,应用用例完成预扣或收尾,一个 BillingRepository 原子保存两张表;不引入计价引擎或结果读取适配器。

src/modules/billing/
  domain/models/        CreditAccount、BillingOperation、Points(bigint)
  domain/ports/         BillingRepository
  application/use-cases/
    prepare-billing-operation.use-case.ts
    complete-billing-operation.use-case.ts
    ...                 余额、记录和消费汇总查询
  presentation/http/    资金查询 Controller 与 DTO
  infrastructure/       Prisma 仓库
  billing.module.ts     装配并导出资金用例

调用方直接提供 reservedAmount 和 settledAmount。Billing 不要求 estimatedUsage,不维护价格注册表,不按 chargeCode 解析 JSON。

一次资金操作,由调用方给出身份

示例:租户 T、消费用户 A、资金操作 B1。业务执行身份、是否新开一笔操作和费用策略由调用方决定。

调用方key、用户、分类、H、C、usageBilling预扣、完成、幂等和持久化账户余额、冻结、消费/充值记录
  1. 调用方提供稳定 key 与 H

    prepareOperation 首次创建 B1,固定消费用户 A 并冻结 H。余额不足明确报错;同一 key 重复调用返回原 B1,不再占额。

  2. 调用方提供最终 C 与 usage

    completeOperation 按给定 C 扣款、释放原 H,并保存原 JSON。Billing 不判断费用来源,也不需要 usage 中存在任何字段。

  3. Billing 只收尾一次

    原单已完成,后续调用返回原终值,不重复扣款或覆盖 usage。新的资金操作应由调用方提供新的 key。

资金金额独立于 usage

即使 usage 没有 token、数量、模型或价格,Billing 仍能按合法 C 完成。C 为 0 时只释放原冻结;这表示零额资金收尾,不表示 Billing 判断了执行失败。

key · 资金身份
operationKey 由调用方提供,BillingOperation.id 是资金记录 ID。Billing 只按 tenantId + operationKey 去重,不制定节点或会话键规则。
usage · 不透明 JSON
内容可以是调用方需要的任意合法 JSON 值。Billing 原样存取,不校验业务 schema,不提取计费字段,不从中计算金额。
ref · 关联依据
消费用户、chargeCode 和来源引用用于归属、分类与关联。调用方决定业务语义,Billing 不沿引用读取执行结果。
会议核对后删掉了哪些额外职责?

删除 estimatedUsage → 价格映射 → 计算金额的流程;金额改为调用方直接传入。删除 usage 的业务字段要求和价格内容禁令;保留一列 JSONB 原样存取。资金表示改为 64 位整数。

worker 的 REST 化、Conductor、轮询、状态保存、重试、停止和用户提示均由调用方接入。当前仅更新设计,尚未实现 Billing。

一次生成,数据库怎样变化?

示例 H=20,最终 C 由调用方给定;初始余额 100。下方只演示资金变化,不表达任何估价或收费策略。

internal worker 调用预扣后执行

预留事务
BEGIN → COMMIT

调用方给定 H=20。Billing 新建消费记录,并在可用余额足够时冻结 20;两表同事务提交,失败全部回滚。

同一 key 只返回原计费记录,不再次占额。余额不足抛 BILLING_INSUFFICIENT_BALANCE,由调用方处理。

BillingAccount · 租户账户
字段 之前 之后
余额 balance 100 100
冻结 held 0 20
可用 balance − held 100 80
BillingOperation · 本次计费
字段 之前 之后
记录类型 type 无记录 CONSUMPTION
计费状态 无记录 HELD
原预留 / 已扣 无记录 20 / 未结算
消费用户 无记录 用户 A
查看预留事务 SQL

以下示意省略审计字段。用例先校验输入;重复键返回原记录,余额不足时整个事务回滚。Billing 不锁定或解释来源任务。

BEGIN;  -- prepareOperation,READ COMMITTED
INSERT INTO billing_operations
  (id, tenant_id, type, consumer_user_id, charge_code,
   operation_key, reserved_amount, state)
VALUES
  (:id, :tenantId, 'CONSUMPTION', :consumerUserId, :chargeCode,
   :operationKey, 20, 'HELD')
ON CONFLICT (tenant_id, operation_key) DO NOTHING
RETURNING id;
-- INSERT 返回 0 行:读取原单并返回,不执行账户更新。
UPDATE billing_accounts
SET held = held + 20
WHERE tenant_id = :tenantId
  AND balance - held >= 20
RETURNING balance, held;
-- 更新必须为 1 行,否则全部 ROLLBACK 并报余额不足。
COMMIT;
-- 之后的生成 REST 调用由 internal worker 在事务外负责。
调用方在事务外执行

internal worker 自行调用生成 REST API,并负责 Conductor 通讯。Billing 不管理这段执行流程。

预扣失败后的停止与提示由调用方负责;预扣成功但执行结果未知,保留原单与 20 冻结。

调用方给定 C,完成原单

调用方提交原 operationId、最终金额 C 和可选 usage。Billing 在一个事务内保存原 JSON、收尾、扣款并释放原 H。

所有分支的 C 都是调用方入参;超额分支给定 C=130。Billing 不解释业务结果、金额来源或 usage 内容。

一首有效:扣 10,释放余下 10最终余额 90,冻结 0,可用 90。
调用方给定金额与 JSON
事务外 · 调用方负责

调用方传入原 operationId、最终金额 C 和可选 JSONB usage。Billing 不从 JSON 计算金额。

完成事务
BEGIN → COMMIT

一个事务保存原 JSON 和资金终态,按 C 扣款,并释放原 H;允许 C 超过 H 和负余额。

账务写入暂时失败,只重试原单完成;已收尾返回原结果,不再次扣款。

BillingAccount · 租户账户
字段 之前 之后
余额 balance 100 90
冻结 held 20 0
可用 balance − held 80 90
BillingOperation · 本次计费
字段 之前 之后
计费状态 HELD SETTLED
原预留 reservedAmount 20 20
最终扣费 settledAmount 未结算 10

原预留 20 留作历史记录;SETTLED 后已不再占用账户冻结。

查看完成事务 SQL

usage、终态和资金同事务;只以原单 HELD 条件收尾一次,不再检查余额足够。

-- 调用方直接给定 C 和可选 usage,Billing 不计算金额。
-- 本次调用方给定 C = 10。
BEGIN;  -- completeOperation,一个完成事务
UPDATE billing_operations
SET usage = :usage, state = 'SETTLED',
    settled_amount = 10, settled_at = CURRENT_TIMESTAMP
WHERE tenant_id = :tenantId AND id = :operationId
  AND type = 'CONSUMPTION' AND state = 'HELD'
RETURNING reserved_amount;
-- 更新 0 行:读取原终态并返回,不再次更新账户。
-- 只有本次收尾成功,才使用返回的原预扣 H = 20:
UPDATE billing_accounts
SET balance = balance - 10, held = held - 20
WHERE tenant_id = :tenantId AND held >= 20;
-- 不检查 balance >= C,也不限制 C <= H。
-- 更新必须为 1 行,否则整个事务 ROLLBACK,按原单重试。
COMMIT;
同一预扣 20 的五种结果
结果 余额 冻结 操作状态 / 已扣
C = 20 80 0 SETTLED / 20
C = 10 90 0 SETTLED / 10
C = 0 100 0 RELEASED / 0
尚未完成 100 20 HELD / 未结算
C = 130 −30 0 SETTLED / 130

预扣检查 balance − held ≥ H;完成允许 C > H,不再检查余额足够。冻结和发生金额始终非负,资金写入按“操作 → 账户”顺序。

谁负责执行、停止和恢复?

调用方决定策略

预估、定价、业务结果、失败或取消时收多少、外部执行和用户提示,都由调用方负责。

Billing 执行资金操作

按 H 预扣,按 C 完成;保存 usage 原值。维持非负冻结、允许负余额,资金操作幂等。

Billing 不基于超时、取消、来源失效或业务结果自动处理资金。调用方决定何时调用完成及传入金额;未完成的操作保持 HELD。

两张表,金额使用 64 位整数

balance 可为负,其他资金字段非负;usage 的结构和内部数值由调用方决定。

BillingAccount

tenantId · 唯一
每租户一行

balance · BigInt
账户余额,允许负数

held · BigInt ≥ 0
当前冻结额

available = balance − held
计算值可负,不另存列

BillingOperation

type
CONSUMPTION 消费/RECHARGE 充值

tenantId + operationKey · 唯一
同一资金操作只生效一次

reservedAmount / settledAmount / state
整数预扣额、最终金额与资金状态

consumerUserId / chargeCode / source
调用方给定的用户、分类和来源

usage · JSONB
调用方传什么 JSON,就存什么

Tenant ── 1 个账户 ── 多笔消费或充值
字段约束与整数表示
字段/记录 约束
balance 有符号 64 位整数,可为负;不设置 balance ≥ held 或 balance ≥ 0 的全局约束。
held / H / C 非负整数 credit;未完成的 C 为 null。预扣检查 balance − held ≥ H,完成允许 C > H。
CONSUMPTION HELD 表示尚未收尾;C > 0 为 SETTLED,C = 0 为 RELEASED。只表达资金事实,不表达生成是否成功。
RECHARGE H = 0、SETTLED,正整数 R 直接增加余额;消费用户、分类、usage 可空。充值记录与加余额同事务提交。
usage JSONB 原样存取,不规定键名、结构或业务含义。可以保存调用方需要的当次价格、成本、模型或任意其他 JSON,不单设 priceSnapshot。

Prisma BigInt → PostgreSQL BIGINT;应用层使用 bigint,HTTP 输出十进制整数字符串。PG 的 BIGINT 是有符号 64 位,其他资金列加 CHECK ≥ 0 表达业务非负,不宣称有原生 uint64 全范围。PostgreSQL 整数类型 · BigInt 与序列化。

Billing 不放大、兑换或舍入金额;这些处理由调用方在传入 H、C 前完成。usage 内部的值不受资金字段的整数规则限制。

分类不绑定价格规则

type 区分消费/充值;chargeCode 是调用方提供的分类字符串,用于保存与筛选,不绑定价格注册表或 JSON schema。

来源引用只做关联,Billing 不据此读取执行状态或推导费用;名称展示、业务字段和内容解析由调用方负责。

按资金记录汇总消费

消费用户在预扣时固定。只汇总 CONSUMPTION 且 SETTLED 的最终金额,充值不计消费,HELD 单列占用。

消费归属由调用方给定,不从 worker 系统身份反推。不新增个人钱包、用户额度或自动对账系统。

并发与事务提案

预扣检查余额,完成按给定 C 扣款

READ COMMITTED、原子条件 UPDATE 与短事务。预扣建单与占额一起提交;完成保存原 usage、收尾原操作,再更新账户,任一步失败一起回滚。

完成只保留 held ≥ H 等账务完整性校验,不再检查余额够不够,也不要求 C ≤ H。原操作只从 HELD 收尾一次,重复调用不再扣款或覆盖原 JSON。

资金写入顺序“操作 → 账户”。并发账户更新使用 PG 行级写锁,等待后重新判断 WHERE 条件;不增加 version 乐观锁。

依据:PostgreSQL 条件更新 · 行级写锁。实际账务并发尚未实测。

Billing 验证什么?
主题 必须成立
整数与 JSON 大整数资金不丢精度,资金非负字段拒绝负值;usage 原样存取,不要求存在 token、数量、价格或结果字段。
预扣与完成 并发预扣不超支,失败全回滚,同 key 只占额一次;完成允许超 H 与负余额,重复完成不再次扣款。
类型与读取 充值原子入账;消费统计排除充值;负余额如实返回,资金归属与查询权限正确。

后端 API 怎样对接?

资金用例在进程内调用;下方 HTTP 查询仍为接口提案。金额以整数字符串传输,不从 usage 生成业务字段。

沿用仓库的 REST 契约

统一前缀 /v1,请求携带 Authorization: Bearer 和 X-Tenant-Id。请求、响应字段用 snake_case;成功响应为 { code: 0, message: "success", data }。控制器返回 payload,由全局拦截器包装一次。

资金金额统一为整数 credit,HTTP 使用十进制整数字符串;时间使用带时区的 ISO 8601。租户来自已验证上下文,金额的换算和业务 JSON 展示由调用方决定。

当前租户余额

GET/v1/billing/accounts/current

有效租户成员可读。返回 balance、held、available,同一次查询读取余额与冻结,可用额由二者计算。

无账户返回三个 "0",GET 不建行。balance 和 available 可以为负,按真实值返回;新增预扣仍以事务内检查为准。

请求与响应示例
GET /v1/billing/accounts/current
Authorization: Bearer <access_token>
X-Tenant-Id: T
{
  "code": 0,
  "message": "success",
  "data": {
    "tenant_id": "T",
    "balance": "100",
    "held": "20",
    "available": "80"
  }
}

消费与充值记录

GET/v1/billing/operations

管理员查看当前租户消费和充值;普通成员只查看 consumer_user_id 为本人的消费。支持 type、状态、用户、charge_code、来源与创建时间筛选。

返回 type、分类、消费用户、预扣额、最终金额与状态;充值金额表示入账。按 created_at DESC、id DESC 分页,列表与 total 范围一致。下方为管理员示例。

请求与响应示例
GET /v1/billing/operations?page_index=1&page_size=20&state=SETTLED
Authorization: Bearer <access_token>
X-Tenant-Id: T
{
  "code": 0,
  "message": "success",
  "data": {
    "list": [
      {
        "id": "B1",
        "consumer_user_id": "A",
        "charge_code": "music.generate",
        "state": "SETTLED",
        "reserved_amount": "20",
        "settled_amount": "10",
        "created_at": "2026-10-20T01:00:00.000Z",
        "settled_at": "2026-10-20T01:02:00.000Z",
        "ref": {
          "consumer_user": {
            "id": "A",
            "display_name": "用户 A"
          }
        },
        "type": "CONSUMPTION"
      },
      {
        "id": "R0",
        "type": "RECHARGE",
        "consumer_user_id": null,
        "charge_code": null,
        "state": "SETTLED",
        "reserved_amount": "0",
        "settled_amount": "100",
        "created_at": "2026-10-20T00:00:00.000Z",
        "settled_at": "2026-10-20T00:00:00.000Z",
        "ref": {
          "consumer_user": null
        }
      }
    ],
    "page_index": 1,
    "page_size": 20,
    "total": 2
  }
}

一笔资金记录的明细

GET/v1/billing/operations/:id

返回固定资金字段与业务来源引用,不从 usage 提取数量、单价或其他业务账单字段。内部读用例返回 JSONB 原值,其业务展示由调用方组织。

HELD 的最终金额为 null;零额完成为 "0"。usage 不设内部 schema,不作为金额计算、结果判断或完成前置条件。

请求与响应示例
GET /v1/billing/operations/B1
Authorization: Bearer <access_token>
X-Tenant-Id: T
{
  "code": 0,
  "message": "success",
  "data": {
    "id": "B1",
    "consumer_user_id": "A",
    "charge_code": "music.generate",
    "state": "SETTLED",
    "reserved_amount": "20",
    "settled_amount": "10",
    "created_at": "2026-10-20T01:00:00.000Z",
    "settled_at": "2026-10-20T01:02:00.000Z",
    "ref": {
      "consumer_user": {
        "id": "A",
        "display_name": "用户 A"
      }
    },
    "source_type": "canvas_debug",
    "source_id": "R1",
    "step_key": "S",
    "type": "CONSUMPTION"
  }
}

按用户汇总消费

GET/v1/billing/user-consumptions

仅当前租户有效 admin/Owner 可读。按 consumer_user_id 分组,在结算周期内仅汇总 CONSUMPTION / SETTLED 的实际扣费;排除充值。

total 是用户分组数,summary 汇总全部匹配记录,不只当前页。原用户被移除后保留历史 ID,关联摘要可为 null;无用户的系统消费单列。当前冻结在余额和 HELD 列表中查看。

请求与响应示例
GET /v1/billing/user-consumptions?settled_from=2026-10-01T00:00:00Z&settled_to=2026-11-01T00:00:00Z
Authorization: Bearer <access_token>
X-Tenant-Id: T
{
  "code": 0,
  "message": "success",
  "data": {
    "list": [
      {
        "consumer_user_id": "A",
        "settled_amount": "10",
        "operation_count": 1,
        "ref": {
          "consumer_user": {
            "id": "A",
            "display_name": "用户 A"
          }
        }
      }
    ],
    "page_index": 1,
    "page_size": 20,
    "total": 1,
    "summary": {
      "settled_amount": "10",
      "operation_count": 1
    }
  }
}

筛选、分页与权限

约定 首版请求规则
分页 列表和用户汇总均采用 page_index 默认 1、page_size 默认 20、最大 100;返回 data.list、page_index、page_size、total。空结果返回 [] 与 total = 0。
记录筛选 type 可选 CONSUMPTION/RECHARGE;state 可选 HELD/SETTLED/RELEASED。consumer_user_id、charge_code 可选;source_type / source_id 与 created_from / created_to 各自成对传入。普通成员请求充值返回 403。
汇总周期 settled_from / settled_to 必填,左闭右开、带时区且最长 366 天。只聚合 CONSUMPTION / SETTLED;按 settled_amount DESC、consumer_user_id ASC 排序,金额相同的系统消费在后。
逐请求鉴权 JwtAuthGuard + TenantContextGuard 校验身份与有效成员;汇总再使用 TenantRolesGuard。普通成员筛选其他用户返回 403;明细不在当前可见范围统一返回 404。Owner 沿用有效 admin 角色,平台管理员不绕过租户授权。
一致性与展示 同一分页响应使用同一数据库快照。资金状态和金额直接读取保存值;不从 usage 推导字段。JSON 内容、业务解析和展示由调用方决定。

调用方决定时点与策略

internal worker 负责 REST、Conductor、执行记录、重试、停止和用户提示,也负责确定 H、C、消费身份与 usage。Billing 不参与这些策略。

两个资金用例仅以给定金额执行资金变化,未完成的操作保持 HELD;Billing 不因超时、取消或业务状态自动释放。

两个接口,只接收给定金额

供 Orchestra 内的 internal worker 进程内调用。字段与用例名是实现提案,金额规则和职责边界已按会议与会后确认收敛。

用例 输入与保证
prepareOperation 输入 tenantId、operationKey、consumerUserId、chargeCode、source、reservedAmount(H,bigint)。检查 balance − held ≥ H,原子建单与冻结;不足报 BILLING_INSUFFICIENT_BALANCE。同 key 返回原单,不再次占额。返回 operationId、state、reservedAmount。
completeOperation 输入 tenantId、operationId、settledAmount(C,bigint)、可选 usage(JSON 值)。按 C 扣款并释放原 H,原样保存 JSON;允许 C > H 和负余额。返回 operationId、state、reservedAmount、settledAmount。重复完成返回原终值,不再扣款或覆盖 usage。

Billing 只校验资金入参和原操作状态。不估价、不定价、不解析 usage,不要求 JSON 包含任何业务字段。C 为 0 时只释放原 H,不判断为什么为 0。

数据库暂时失败可按原操作重试。充值仍由人工同事务记入账记录并加余额,不额外建设充值写接口。

错误响应与首版验收
HTTP / code 含义
400 / 40000 缺少租户头、筛选或分页不合法、时间范围不合法。
401 / 40100 未登录,或 Token 无效/过期。
403 / 40300 账号、租户或成员无效,或没有所请求的数据范围/管理员权限。
404 / 40400 明细不存在,或不在当前租户与用户的可见范围。
500 / 50000 查询或存储异常。不能用空列表、零余额或成功结算掩盖失败。

验收围绕整数精度、并发占额、超额与负余额完成、资金幂等、JSONB 原样存取以及读取范围。价格、倍率、舍入、业务结果和 usage 内容均不属于 Billing 策略或验收。