这一天从后台的一个告警条开始。
后台模型管理页顶着一行提示:12 个启用中的模型未配置成本,利润分成将按 0 成本计算(虚高)。库里那 12 个模型的输入输出成本都填过。点进去看,值也在。
顺着这条线查下去,同一天里翻出四处独立的错,四处都不报错。
一、按量资源没乘用量
成本核算走的是 CalculateCost。它对资源价格表的处理是一行赋值:
costRMB = rp.CostRMB // 简化:暂不支持按量精细成本(需要 UsageRecord 记录用量字段)
CostRMB 的口径是「每 PerUnits 个单位的成本」,不是「一次调用的成本」。视频按秒计费,PerUnits 是 1 秒,CostRMB 是每秒成本。直接取这个值,等于把一部 10 秒的片子按 1 秒算成本。
后果不是少算一点:利润虚高数倍,代理分成按虚高的利润多付。账面上看不出异常,因为售价那一侧是对的。
修法是用扣点反推用量。CostRMB 与 Rate 同口径(都是「每 PerUnits 个单位的价/成本」),所以点数之比就是用量之比:
costRMB = rp.CostRMB * float64(points) / unitRate
PER_CALL 的资源在这个公式下自然退化成「每次成本」,不用特判。视频资源(VIDEO_IO)另有一种情形:Kling 这类模型的输入单价是 0,只有输出秒有价,所以反推要用 OutputRate。
还有一个细节:要用折扣前的点数。VIP 折扣只影响用户付多少,不影响真实用量。用实扣点数反推,等于让会员折扣把成本也打了折。
测试用例把这个口径钉死了:
// 10 秒 → 扣 1000 点
want: 800 // 分
// 若为 80 说明没乘用量,利润会虚高 10 倍
二、图生视频把输入秒算进了成本
上面那套「按点数反推」有个前提:点数只反映真实用量。图生视频不满足这个前提。
图生视频的扣点包含两部分——输入秒和输出秒。从总点数反推,会把输入秒也算成成本秒。以 480p 图生视频为例,传 5 秒生成 5 秒,成本被算成 ¥5,真实只有 ¥2.5。
利润被低估。这是与上一处反方向的错:一处虚高、一处虚低,互相不抵消,只是让账更乱。它还会触发假的「亏本」告警。
修法是别再反推,直接记真实用量。UsageRecord 加一个字段:
// 成本核算基准用量(视频=输出秒数、按量资源=单位数)。0 = 未记录,成本回落按点数反推。
// 必须单独记:图生视频的扣点含「输入秒」,从点数反推会把输入秒也算成成本秒,成本虚高、分成少付。
BilledUnits int64 `gorm:"not null;default:0"`
CalculateCost 优先用 BilledUnits,没有值才回落到按点数反推。这样存量数据仍算得出来,新数据算得对。
下游要跟着改两处:扣费时写入 BilledUnits,结算时按实际输出秒回写——预扣按上限算,实际输出可能更短,成本基准要同步下调。
三、红名单的判据写反了
回到开头那 12 个模型。
后台判断「成本是否已配置」的条件是四项成本全不为 0:
const hasCost =
m.inputCostRmbPerMillion !== 0 &&
m.outputCostRmbPerMillion !== 0 &&
m.cacheInputCostRmbPerMillion !== 0 &&
m.cacheOutputCostRmbPerMillion !== 0;
缓存创建成本按约定统一填 0——大部分模型没有这项。用 && 串起来,等于要求一个按约定填 0 的字段非 0,条件永远不成立。
改成「输入或输出任一大于 0」:
const hasCost =
(m.inputCostRmbPerMillion ?? 0) > 0 || (m.outputCostRmbPerMillion ?? 0) > 0;
同一个文件里还查出两处更隐蔽的:资源缺口的键取了 r.id || r.key,而真实字段名是 resourceKey,取出来是 undefined;告警条只统计 LLM 模型,28 项生图/视频/工具的资源缺口在后台根本不显示。
前者是字段名读错,后者是漏了一半。修完之后告警条分开报数:
⚠️ N 个模型、M 项资源(生图/视频/工具)未配置成本
四、填进去的成本,从来没进过库
前面三处都是算错。第四处是没算——因为成本值根本没存下来。
后台填资源成本,点保存,提示成功,回到列表看还是 0。
链路两端都为这个字段做了指针语义设计。Go 侧 CostRMB 是 *float64,判断 nil 就跳过写入——这个设计的意图是「请求里没带这个字段,就别动已有的值」。Node 侧的参数类型 costRmb 也是可选的。
问题出在中间那层 zod schema:它没声明 costRmb。
const upsertSchema = z.object({
resourceKey: z.string().trim().min(1).max(64),
// ...
enabled: z.boolean(),
// costRmb 不在列表里
});
zod 默认剥离未声明的字段。请求穿过这层之后,costRmb 就没了,Go 侧拿到的 JSON 里没有这个键,判定 nil,跳过写入。
TypeScript 查不出来——UpsertResourcePriceArgs.costRmb 本来就是可选的,前端传不传都合法。两端各自的类型检查都是绿的。
影响面是全部按 resourceKey 计价的资源:图片、视频、文本、建站、工具。cost_rmb 一直是 0,利润按 0 成本算,代理分成偏高。
修法是把这个字段声明出来,同时保留「不传就不动」的语义:
// 必须 optional 而非 default(0):billing 侧 CostRMB 是 *float64,nil 才跳过写入。
// 给默认值会让「只改售价」的请求把已配好的成本清零。
costRmb: z.number().nonnegative().optional(),
这条注释值得留:default(0) 看起来更安全,实际会把成本清零。这两个语义在这条链路上差别很大。
守卫测试也补上了。schema-passthrough.test.ts 这个文件此前只盖了会员卡与充值套餐,资源计价因为 upsertSchema 没导出,成了盲区:
// 「成本不能被剥离」
expect(p.data.costRmb).toBe(0.35);
// 「不带成本时保持 undefined,绝不落成 0」
expect(p.data.costRmb).toBeUndefined();
// 「显式填 0 是合法值,与不带该字段语义不同」
五、还有一处不报错:结算失败被吞掉
同一天查计费链路时,还看到 51 处这样写:
}).catch(() => undefined);
结算、退款、台账状态回写,失败一律静默。结算永久失败会让预留被对账任务全额退款,平台白送一次成片;退款失败则用户点数悬置。两种情况此前都留不下痕迹,无法对账。
51 处改成带标签的日志,行为不变:
}).catch((e) => console.error("[billing-ledger] 画布节点退款 pending 回写失败", e));
台账推进也加了行数校验。updateMany 的结果原本被丢弃,现在会检查:
if (reservedMark.count === 0) {
// Go 侧 opID 幂等兜底不会重复扣钱;0 行说明状态已被并发推进,留痕供对账排查
console.error(`[billing-ledger] 公众号图文文本预扣台账未推进 op=${operationId}`);
}
对账任务的扫描也补了三处:查询错误不再吞掉、单轮 Limit(1000)、逐条失败单独记日志。最后一条解决的是坏记录每 5 分钟被无声重扫一次的问题。
六、统一入口与单位
修完这四处,后台的成本录入还是散的:资源成本在一个面板,LLM 模型成本在另一个页面,基础生图、基础视频生成、小说正文、小说封面这四类没有成本入口。
新加了一个「成本配置」页,把资源成本和模型成本收到一处,每项同时显示售价和实时算出的毛利率。五个计价面板加 mode="price" | "cost" 参数:成本模式下售价只读、成本可编辑。
同一轮里还剥掉两处重复计价。电商长图的母版、分段、主图各有一套 resourceKey,但它们和聊天、画布、漫剧、建站调的是同一个上游生图接口,默认价也完全相同(10/20/40)。后台要把同一份成本填四遍。并入标准生图价之后,删掉三个 key 派生函数和后台对应的 9 项配置。小说封面同理,并入 1K 标准生图价。
成本输入框的单位也改了。原先标签写死「元/次」,而 billing 侧的 CostRMB 与 Rate 同口径,是按每 PerUnits 个单位算的。建站按月扣、公众号图文按千字扣、数字人成片按秒扣,都提示按次填,填进去的成本会差好几个数量级。
// 成本单位必须跟计价单位一致——按月计费的填每月成本、按千字计费的填每千字成本。
// 写死「元/次」会让人按整单成本填,利润与代理分成直接算错。
export function costUnitSuffix(unitLabel: string): string {
return unitLabel.replace(/^每\s*/, "") || "次";
}
这几处错的共同点
四处错,四处都没有报错:
- 按量成本没乘用量,算出来是一个偏小的正数,看起来合法。
- 图生视频把输入秒算进成本,同样是个正数。
- 红名单判据要求一个按约定填 0 的字段非 0,条件永远为假,于是「已配置」和「未配置」互换。
- schema 剥掉字段之后,Go 侧把「没带这个字段」当成「不要改」,跳过写入——一个设计得没错的语义,配上一条丢字段的链路。
没有一处会抛异常,没有一处会在日志里留下痕迹。它们只在一种情况下暴露:有人拿计算器和账本对了一遍。
成本链路的正确性没法靠运行观察。它需要一个独立的口径来源——这里是「扣点数与真实用量的比例」——以及把这个比例固定下来的测试。测试里那句 若为 80 说明没乘用量,利润会虚高 10 倍,比任何断言值都重要:它写清了这条用例在防什么。
至于中间层 schema,教训更具体。zod 剥离未声明字段是设计行为,也是这类链路最容易被忽略的一环:两端的类型都允许字段缺失,只有中间那一层会把它拿走,而它什么都不说。守卫测试要覆盖的是每一个会真正影响计费的字段,不是覆盖文件。
■