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

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

团队共用余额。调用方决定先冻结多少、最后扣多少;Billing 负责执行,重复请求也只处理一次。

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

所有人,使用同一池积分

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

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

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

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

先占住 20,再按结果结账

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

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

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

Billing 不决定估价或收费策略。调用方分别传入预占金额和实际扣费金额。预占时检查可用积分;完成时按实际金额扣费,即使超过预占金额也会完成,余额可以为负。用量记录(usage)原样保存。

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

记录得细,账单看得懂

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

记录与展示分开

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

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

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

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

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

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

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

调用方给定金额与幂等键,可选提供摘要和用量记录。Billing 负责处理资金,并保存原始业务信息。

调用方传什么,Billing 怎么处理

已核对会议与会后确认
调用方提供 Billing 做什么 调用方决定什么
预占金额 开始前先冻结这笔积分,账户余额暂不减少 预占金额如何计算
实际扣费金额 从余额扣除这笔积分,并取消本次全部冻结;重复请求只处理一次 实际费用如何计算、为什么为 0
用量记录(usage) 按调用方给定的 JSON 原样保存和读取 键名、结构和业务含义
幂等键、追踪字段、可选摘要 用幂等键防止重复预占,保存追踪信息和摘要 业务域与追踪编号的业务含义

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

预占:先检查可用积分

可用积分 = 账户余额 − 已冻结积分。可用积分足够,才冻结本次预占金额;不足则报错。

完成:按实际金额扣费

从余额扣除实际扣费金额,并取消本次全部冻结。实际扣费可以超过预占金额,余额可以为负。

用量记录:原样保存

调用方可以提供任意合法 JSON。Billing 不要求其中包含数量、模型、价格等字段。

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

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

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

Billing 提供两个资金用例

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

internal worker

决定预占多少、实际扣多少
提供用量记录,决定调用时点

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,不维护价格注册表,不解析 usage;预占与完成分别定义返回类型。

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

以一位团队成员发起音乐生成为例:调用方申请预占积分,Billing 创建资金记录并返回操作编号,生成结束后按该编号完成扣费。

调用方提供金额、幂等键、用户与业务信息Billing预占、完成、幂等和持久化账户余额、冻结、消费/充值记录
  1. 开始前:用幂等键申请预占积分

    调用方传入幂等键和预占金额。prepareOperation 检查可用积分,创建资金记录、冻结积分并返回操作编号。重试时复用原幂等键,返回原记录;若预占金额变了,则报冲突。消费用户、可选摘要和追踪信息以首次请求为准。

  2. 结束后:按操作编号完成扣费

    调用方传回操作编号、实际扣费金额和可选的用量记录。completeOperation 从余额扣除实际金额,取消本次全部冻结,并原样保存用量记录。金额由调用方决定,Billing 不从用量记录计算费用。

  3. Billing 只收尾一次

    相同操作编号、相同实际扣费金额,重复请求只返回第一次的结果,不再次扣款,也不覆盖用量记录;金额不同则报冲突。另一笔资金操作使用新的幂等键。

实际扣费不依赖用量记录的内容

即使用量记录没有 token、数量、模型或价格,也能按传入的实际金额完成扣费。实际扣费为 0 时,只取消本次冻结,不减少余额。任务是否成功由调用方判断。

幂等键 · 防止重复预占
幂等键(idempotencyKey)由调用方生成,Billing 原样保存。同一租户内,相同幂等键表示重试同一次预占。成功后返回操作编号(operationId),后续完成扣费使用该编号。Billing 不做哈希或业务推导。
业务域与追踪编号
domain 是业务域字符串,primaryId、secondaryId 是可空字符串。含义全部由调用方确定,仅用于查询关联,不参与幂等,不加唯一约束。
摘要(summary)· 选填
调用方可在预占时提供字符串,例如“音乐生成”。未提供存 NULL,不自动生成;原文用于列表与详情;重复调用不覆盖,完成时不再传入。
用量记录(usage)· 选填
调用方给定任意合法 JSON 值。Billing 原样存取,不校验业务 schema,不提取字段计算金额。
会议核对后删掉了哪些额外职责?

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

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

预占 20 积分后,怎样完成扣费?

团队初始余额为 100 积分,本次先预占 20 积分。生成结束后,调用方决定实际扣多少。切换下方结果,可以查看账户与资金记录怎样变化;所有金额单位均为积分。

internal worker 调用预占后执行

预占事务
BEGIN → COMMIT

调用方申请预占 20 积分。Billing 创建消费记录,将冻结积分从 0 增至 20;账户余额仍是 100,可用积分降至 80。账户和资金记录在同一事务中提交,失败则全部回滚。

相同幂等键只返回原资金记录,不再次冻结积分。余额不足抛 BILLING_INSUFFICIENT_BALANCE,由调用方处理。

BillingAccount · 租户账户
字段 之前 之后
余额 balance 100 100
冻结 held 0 20
可用积分(余额 − 冻结) 100 80
BillingOperation · 本次资金操作
字段 之前 之后
资金方向 direction 无记录 扣减(DEBIT)
计费状态 无记录 已冻结(HELD)
预占 / 实际扣费 无记录 20 / 未结算
消费用户 无记录 用户 A
查看预占事务 SQL

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

BEGIN;  -- prepareOperation,READ COMMITTED
INSERT INTO billing_operations
  (id, tenant_id, direction, consumer_user_id, idempotency_key,
   domain, primary_id, secondary_id, summary,
   reserved_amount, state, created_at)
VALUES
  (:id, :tenantId, 'DEBIT', :consumerUserId, :idempotencyKey,
   :domain, :primaryId, :secondaryId, :summary,
   20, 'HELD', CURRENT_TIMESTAMP)
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING
RETURNING id;
-- INSERT 返回 0 行:读取原记录;方向不是 DEBIT 或预占金额不同则报冲突,否则返回原记录。
-- 不再次冻结,不覆盖 summary、追踪或原时间。
UPDATE billing_accounts
SET held = held + 20, updated_at = CURRENT_TIMESTAMP
WHERE tenant_id = :tenantId
  AND balance - held >= 20
RETURNING balance, held;
-- 更新必须为 1 行,否则全部 ROLLBACK 并报余额不足。
COMMIT;
-- 之后的生成 REST 调用由 internal worker 在事务外负责。
调用方在事务外执行

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

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

按实际扣费金额,完成这笔操作

调用方提交操作编号、实际扣费金额和可选用量记录。Billing 从余额扣费、取消本次全部冻结,并保存用量记录;这些变更在同一事务中完成。

实际扣费金额都由调用方给定。例如实际扣费 130 积分时,虽然本次只预占了 20 积分,Billing 仍会扣除 130,账户余额变为 −30,冻结归零。

实际扣费 10 积分,取消本次全部冻结最终余额 90,冻结 0,可用 90。
调用方给定金额与 JSON
事务外 · 调用方负责

调用方传入操作编号(operationId)、实际扣费金额和可选用量记录(usage)。Billing 不从 JSON 计算费用。

完成事务
BEGIN → COMMIT

在一个事务中保存用量记录并完成资金操作:余额减去实际扣费金额,冻结减去本次原预占金额。允许实际扣费超过预占,余额可以为负。

写入暂时失败时,使用原操作编号重试。已经完成且实际扣费金额相同,返回原结果;金额不同则报冲突。

BillingAccount · 租户账户
字段 之前 之后
余额 balance 100 90
冻结 held 20 0
可用积分(余额 − 冻结) 80 90
BillingOperation · 本次资金操作
字段 之前 之后
计费状态 已冻结(HELD) 已扣费(SETTLED)
预占金额 20 20
实际扣费金额 未结算 10

本次预占的 20 积分已全部取消冻结;预占金额仍保留在资金记录里,供历史查询。

查看完成事务 SQL

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

-- 实际扣费金额由调用方给定;可选用量记录原样保存,不参与金额计算。
-- 本例:原预占金额为 20 积分,实际扣费金额为 10 积分。
BEGIN;  -- completeOperation,一个完成事务
UPDATE billing_operations
SET usage = :usage, state = 'SETTLED',
    settled_amount = 10, completed_at = CURRENT_TIMESTAMP
WHERE tenant_id = :tenantId AND id = :operationId
  AND direction = 'DEBIT' AND state = 'HELD'
RETURNING reserved_amount;
-- 更新 0 行:记录不存在或方向不符则报错;已完成且扣费金额相同则返回原结果,否则报冲突。
-- 只有本次完成成功,才按原预占金额 20 取消冻结:
UPDATE billing_accounts
SET balance = balance - 10, held = held - 20, updated_at = CURRENT_TIMESTAMP
WHERE tenant_id = :tenantId AND held >= 20;
-- 实际扣费可以超过预占金额;即使扣费后余额为负,也完成这笔操作。
-- 更新必须为 1 行,否则整个事务 ROLLBACK,按原单重试。
COMMIT;
同一预占 20 的五种结果
结果 余额 冻结 操作状态 / 已扣
实际扣费 20 积分 80 0 已扣费 / 20
实际扣费 10 积分 90 0 已扣费 / 10
实际扣费 0 积分 100 0 已释放 / 0
尚未完成 100 20 已冻结 / 未完成
实际扣费 130 积分 −30 0 已扣费 / 130

预占时检查可用积分是否足够;完成时按给定的实际金额扣费。冻结金额、预占金额和结算金额均非负,只有账户余额可以为负。数据库统一先写资金记录,再更新账户。

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

调用方决定策略

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

Billing 执行资金操作

先按预占金额冻结积分,再按实际扣费金额完成;用量记录原样保存,重复请求只处理一次。

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

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

账户表记录当前余额与冻结积分;资金操作表记录每笔预占、扣费或充值。账户余额允许为负,其他资金字段非负。用量记录的结构由调用方决定。

BillingAccount

tenantId · 主键
每租户一行,不额外生成账户 ID

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

held · BigInt ≥ 0
当前冻结额

createdAt / updatedAt
账户创建与最后资金变动时间

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

BillingOperation

id · CUID
Billing 生成,作为后续操作句柄

tenantId + idempotencyKey · 唯一
调用方提供幂等键,Billing 原样保存

direction / state
资金方向与操作状态

reservedAmount · BigInt
预占金额,非负;完成后保留作历史记录

settledAmount · BigInt?
结算金额:消费表示实际扣费,充值表示入账;未完成时为空

consumerUserId / summary
消费用户与人类可读摘要均可空

domain / primaryId / secondaryId
业务追踪;两级 ID 可空且均为字符串

usage · JSONB
合法 JSON 原样保存

createdAt / completedAt
创建与收尾时间,无 updatedAt

Tenant ── 1 个账户 ── 多笔消费或充值
字段约束与整数表示
字段/记录 约束
balance 有符号 64 位整数,可为负;不设置 balance ≥ held 或 balance ≥ 0 的全局约束。
冻结金额、预占金额、结算金额 均为非负整数积分;操作未完成时,结算金额为 null。预占要有足够的可用积分,实际扣费可以超过预占金额。
扣减(DEBIT) HELD 表示已冻结、尚未完成;实际扣费大于 0 时为 SETTLED(已扣费),等于 0 时为 RELEASED(已释放)。任务是否成功由调用方判断。
入账(CREDIT) 预占金额为 0,状态为 SETTLED(已入账);结算金额记录正整数充值额。消费用户与用量记录可空,业务域必填、摘要选填。充值记录与余额增加在同一事务中提交。
usage JSONB 原样存取,不规定键名、结构或业务含义。可以保存调用方需要的当次价格、成本、模型或任意其他 JSON,不单设 priceSnapshot。
追踪与摘要 domain 必填;summary、primaryId、secondaryId 为可空 TEXT。追踪不参与去重,不关联具体业务表;摘要由调用方给定,不从 JSON 自动拼装。
时间与历史 HELD 的 completedAt 为 null;首次收尾写时间,重复调用不刷新。资金操作不设 updatedAt 或软删除字段,业务来源删除不删除资金记录。

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

调用方在传入预占金额和实际扣费金额之前,完成换算、倍率和舍入。Billing 接收最终的整数积分;用量记录内部的数值不受这些资金字段规则限制。

方向与业务域分开

direction 只取 DEBIT / CREDIT,分别表示扣余额与加余额,由 Billing 用例固定。domain 是调用方提供的业务域字符串,例如 workflow、payment、manual,不决定资金方向。

消费可记为 DEBIT + workflow,充值可记为 CREDIT + payment。同一追踪可以有多笔操作;不从 domain 读取业务状态或推导金额。

按资金记录汇总消费

消费用户在首次预占时固定。按 completedAt 统计 DEBIT / SETTLED 的实际扣费金额,CREDIT 不计消费,HELD 单列占用。

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

并发与事务提案

预占检查可用积分,完成按实际金额扣费

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

完成时只检查账户冻结积分是否足以取消本次预占等账务完整性条件,不再检查余额是否足够。实际扣费可以超过预占金额;原操作只完成一次,重复调用不再扣款或覆盖用量记录。

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

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

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

后端 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 为本人的 DEBIT。支持方向、状态、用户、domain、两级 ID 与创建时间筛选。

返回 direction、summary、追踪、消费用户、金额、状态和时间。CREDIT 的正金额表示入账;按 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",
        "state": "SETTLED",
        "reserved_amount": "20",
        "settled_amount": "10",
        "created_at": "2026-10-20T01:00:00.000Z",
        "completed_at": "2026-10-20T01:02:00.000Z",
        "ref": {
          "consumer_user": {
            "id": "A",
            "display_name": "用户 A"
          }
        },
        "direction": "DEBIT",
        "summary": "音乐生成",
        "domain": "workflow",
        "primary_id": "R1",
        "secondary_id": "task_001"
      },
      {
        "id": "R0",
        "consumer_user_id": null,
        "state": "SETTLED",
        "reserved_amount": "0",
        "settled_amount": "100",
        "created_at": "2026-10-20T00:00:00.000Z",
        "completed_at": "2026-10-20T00:00:00.000Z",
        "ref": {
          "consumer_user": null
        },
        "direction": "CREDIT",
        "summary": null,
        "domain": "manual",
        "primary_id": "voucher_001",
        "secondary_id": null
      }
    ],
    "page_index": 1,
    "page_size": 20,
    "total": 2
  }
}

一笔资金记录的明细

GET/v1/billing/operations/:id

返回资金字段、人类可读 summary 与 domain / 两级 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",
    "state": "SETTLED",
    "reserved_amount": "20",
    "settled_amount": "10",
    "created_at": "2026-10-20T01:00:00.000Z",
    "completed_at": "2026-10-20T01:02:00.000Z",
    "ref": {
      "consumer_user": {
        "id": "A",
        "display_name": "用户 A"
      }
    },
    "direction": "DEBIT",
    "summary": "音乐生成",
    "domain": "workflow",
    "primary_id": "R1",
    "secondary_id": "task_001"
  }
}

按用户汇总消费

GET/v1/billing/user-consumptions

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

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

请求与响应示例
GET /v1/billing/user-consumptions?completed_from=2026-10-01T00:00:00Z&completed_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。
记录筛选 direction 可选 DEBIT / CREDIT;state 可选 HELD / SETTLED / RELEASED。consumer_user_id、domain、primary_id、secondary_id 可选,追踪 ID 不要求成对;created_from / created_to 成对。普通成员请求 CREDIT 返回 403。
汇总周期 completed_from / completed_to 必填,左闭右开、带时区且最长 366 天。只聚合 DEBIT / SETTLED;按 settled_amount DESC、consumer_user_id ASC 排序,金额相同的系统消费在后。
逐请求鉴权 JwtAuthGuard + TenantContextGuard 校验身份与有效成员;汇总再使用 TenantRolesGuard。普通成员筛选其他用户返回 403;明细不在当前可见范围统一返回 404。Owner 沿用有效 admin 角色,平台管理员不绕过租户授权。
一致性与展示 同一分页响应使用同一数据库快照。资金状态和金额直接读取保存值;不从 usage 推导字段。JSON 内容、业务解析和展示由调用方决定。

调用方决定时点与策略

internal worker 负责 REST、Conductor、执行记录、重试、停止和用户提示;也负责 预占金额、实际扣费金额、消费用户、幂等键、可选摘要、追踪字段与用量记录。Billing 不参与这些业务策略。

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

两个方法:先预占,再完成扣费

供 Orchestra 内的调用方进程内调用。金额、幂等键、摘要和追踪由调用方提供;Billing 提供原子资金操作。两级 ID 可空,均按字符串保存。

用例 输入与保证
预占积分
prepareOperation

传入:租户编号、幂等键、预占金额、业务域;消费用户、两级追踪编号和摘要选填。

执行:可用积分足够时,创建资金记录并冻结预占金额;不足则报 BILLING_INSUFFICIENT_BALANCE。相同幂等键返回原消费记录,预占金额不同或命中充值记录则报冲突。

返回:操作编号、当前状态、预占金额和结算金额;尚未完成时,结算金额为空。

完成扣费
completeOperation

传入:租户编号、预占时返回的操作编号、实际扣费金额;用量记录选填。

执行:从余额扣除实际金额,取消本次全部冻结,并保存用量记录。实际扣费可以超过预占金额,余额可以为负;重复请求的扣费金额相同则返回原结果,不同则报冲突。

返回:操作编号、最终状态、原预占金额和已确定的实际扣费金额。不覆盖第一次保存的摘要、追踪、用量记录或时间。

两个用例的 TypeScript 契约
interface PrepareBillingOperationInput {
  tenantId: string; // 租户编号
  idempotencyKey: string; // 调用方生成的幂等键,重试复用
  consumerUserId?: string; // 实际消费用户,选填
  domain: string; // 调用方定义的业务域
  primaryId?: string; // 一级追踪编号,选填
  secondaryId?: string; // 二级追踪编号,选填
  summary?: string; // 列表与详情展示的摘要,选填
  reservedAmount: bigint; // 预占金额,单位为整数积分
}

interface PrepareBillingOperationResult {
  operationId: string; // Billing 返回的资金操作编号
  state: 'HELD' | 'SETTLED' | 'RELEASED';
  reservedAmount: bigint; // 预占金额,单位为整数积分
  settledAmount: bigint | null; // 结算金额,尚未完成时为 null
}

interface CompleteBillingOperationInput {
  tenantId: string; // 租户编号
  operationId: string; // Billing 返回的资金操作编号
  settledAmount: bigint; // 实际扣费金额,单位为整数积分
  usage?: JsonValue; // 任意合法 JSON,选填,原样保存
}

interface CompleteBillingOperationResult {
  operationId: string; // Billing 返回的资金操作编号
  state: 'SETTLED' | 'RELEASED';
  reservedAmount: bigint; // 预占金额,单位为整数积分
  settledAmount: bigint; // 实际扣费金额,单位为整数积分
}

declare function prepareOperation(input: PrepareBillingOperationInput):
  Promise<PrepareBillingOperationResult>;
declare function completeOperation(input: CompleteBillingOperationInput):
  Promise<CompleteBillingOperationResult>;

预占请求重试时,原操作可能已经完成,所以 prepareOperation 可以返回三种状态,结算金额也可能为空。completeOperation 只返回已完成的状态,实际扣费金额一定有值;两个方法分别定义返回类型。

完成扣费时,只需传入租户编号、操作编号、实际扣费金额和可选用量记录。消费用户、摘要、业务域、追踪编号和预占金额直接读取原记录。实际扣费为 0 时,只取消本次冻结。

未来充值使用独立的一步入账用例

本轮只暴露预占与完成两个消费方法。当前人工充值在同一事务中保存 CREDIT / SETTLED 记录并增加余额;预占金额为 0,结算金额记录正整数充值额,创建时间与完成时间相同。

未来增加 recharge():接收 tenantId、idempotencyKey、domain、可选两级 ID、可选 summary 和正整数 amount;返回 operationId、SETTLED、amount。它固定执行 CREDIT,同一幂等键 只复用原 CREDIT 入账;命中 DEBIT 或金额不同均报冲突,不重复加余额。充值不经过预占/完成,调用方不能传 direction 改变同一个函数的资金方向。支付订单、渠道和确认时点仍由调用方管理;此入口本轮不实施。

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

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