ADR 0063 — 同一笔花费的两种受众:owner 的账单与成员的消耗
- 状态: 已接受
- 日期: 2026-08-09
- 先前背景: ADR 0042
(
token_usage中冻结的价格,正是它让任何报表可复现)、RN-058(agent 花费的密钥是 owner 的)以及 RN-060(密钥的花费属于 owner,只有他能看)
背景
需求是一个花费摘要标签页,风格类似 OpenRouter 的 activity 界面,但讲的是本产品的 provider、owner 和 agent。谁看什么的决定也一并给出,而且是用户的决定:owner 看 workspace 的一切;成员只看自己的消耗。
数据从来不是问题。自第 9 阶段起,token_usage 的一切都在列中——provider、模型、参与者、token、成本、binding 来源、延迟,以及产生该成本、在调用那一刻由
ADR 0042 冻结的价格。缺的是聚合:现有五个,全部按 agent 或按 provider×月,没有一个按模型、按 workspace 内的项目、按会话或按人。
难点在别处,是产品已有的两条规则之间的冲突:
- 按照 RN-058,任何 agent 花费的 LLM 密钥都是 workspace owner 的。运行一个 agent 的成员花费的是别人的凭据;
- 按照 RN-060,这笔花费的报表属于 owner 而且只属于他,路由上有
@RequireRole('owner')。不是maintainer:所有者的账单不是运营某个项目的人的事。
两者合起来,让“我花了多少”在字面上成了请求别人账单的一个切片。存在两条简单的出路,两条都是错的:
- 把凭据报表开放给成员,过滤出他的行。这等于从后门撤销 RN-060——响应仍然会讲 provider 和密钥,而这正是规则留给所有者的东西,成员只要把各行相加就能重建这份账单;
- 什么都不给成员看。 这同样是错的,而且有一个实际原因:他的消耗存在,以他的名字记录在
token_usage中,而唯一不能看到它的人恰恰是他自己。
决策
两种受众收到不同的报表,因为他们问的是不同的问题。哪一个都不是另一个的切片。
RN-060 继续管辖按凭据的报表。 GET /workspaces/:id/credential-spend、GetCredentialSpendUseCase 和
CredentialSpendSection 保持原样:按provider(密钥的单位)分组,要求 owner,回答关于账单的问题——“这个月我的 OpenRouter 密钥出去了多少”。新标签页完整复用它,而不是重写。
成员的视图按参与者**,以 token 和估算成本计,并且不按 provider 或凭据拆分。GET /projects/:id/spend/me 返回调用者在一个项目内按会话和按天的消耗。参与者来自已认证的令牌,用例不暴露询问他人的方式:不存在可以写入他人 id 的参数。“成员看不到其他参与者的行”是签名的属性,而不是有人可能忘了调用的检查。
agent 不计入成员的账。 token_usage 记录的是谁花了钱,而不是谁下令花钱;把 agent 归属给启动它的人,就是编造一个表中没有的数据。agent 的花费出现在 owner 的报表中,密钥属于他。
新聚合中不存在 provider 维度。 五个新维度(model、project、actor、session、day)住在仓储的一个方法 sumGroupedBy(dimensao, escopo) 中,而 provider 不在其中。这种缺席是结构性的,不是遗忘:按 provider 拆分花费就是按凭据拆分,成员的视图正是这样被阻止因疏忽获得这一维度——没有可传入的参数。出于同样的原因,两个 provider 服务同一个模型名时,在 model 维度中落到同一行:把它们分开会以另一个名字重新引入凭据维度。
owner 两者都看——workspace 按模型、项目、参与者和天的拆分,以及紧接其下的按凭据账单——因为他是唯一可以看两者的人。界面从不在没有该角色的情况下调用 owner 的路由:故意请求一个 403 是安全日志中的噪声。
没有图表库。 是两种形状,每种一个序列:按天的柱状图(离散量——折线会暗示两天之间存在不存在的连续花费)和水平排名条。内联 SVG 中的 <rect> 和 <span> 以及 CSS 就够了,在这里引入图表依赖,是为了十行就能画出的几何形状而增加运行时负担。按天的序列从 api 以稠密形式返回:没有花费的日子以零进入,否则三周内的三次调用会变成三根挨在一起的柱子,与连续三天的使用无法区分。
后果
成员的问题被明确声明为不完整,这是正确的代价。 他看到自己的聊天,看不到自己运行的 agent。只要密钥是 owner 的(RN-058),任何显示给成员的“agent”数字都会是别人的花费顶着他的名字。如果某天产品有了按人的凭据,这一决定需要重新审视——通过一份新 ADR,而不是编辑本 ADR。
这是两条路由,而不是一条按角色分支的路由。 一个根据调用者改变形态的单一端点,会在同一个名字下有两份合约,而暴露面测试(docs/security-surface.md)会用最宽松的角色对单条路由分类——恰恰丢失本 ADR 存在所要维护的区分。
model 维度会混合 provider。 想知道某个模型由谁提供服务的人在这里找不到答案,这是刻意的。upstream_provider 继续留在表中,供需要逐案调查的人使用。
没有 migration,也没有索引——暂时。 token_usage 只有 PK,两个查询都做 seq scan。在隔离的测试数据库中用 52.5 万行测量:workspace 报表耗时 55 ms,成员报表 38 ms。在 token_usage(created_at) 上加索引后,同样的计划变成 bitmap heap scan,降到 32 ms 和 19 ms。收益真实,索引也便宜,但这一波的 migration 名额属于另一个阶段;它会在之后加入,测量已经完成并记录在此。改变账目的是数据量:两个查询都读取整个窗口,成本随 token_usage 的大小增长,而不是随请求的大小。
窗口是滑动的,并有上限(180 天,默认 30)。 没有上限的报表会邀请人通过 query string 扫描整张表,而那次变成白屏的 429(RN-088/RN-090)的教训还太近,不能忽视。
显示给成员的成本是估算的,界面也这么说。 它来自调用那一刻冻结的价格(ADR 0042),这是现有的最好数字——但到达 owner 的账单来自 provider,我们从未承诺两者分毫不差。
排除在外,已声明: 货币和汇率(backlog,用编造的汇率换算比诚实的美元更糟)、按区域的花费(自 ADR 0038 起被切分——真正的上限仍在项目、会话和 task 上)以及任何导出。该标签页只读:其中没有写入动词,正是动词的缺席让这条边界可以验证。