控制台支持编辑视频按秒价格表 - #710
Conversation
Constraint: Frontend only. The option key, its strict save-time validation, and the adapters that read it all already exist; the UI is a visual editor for what is currently API-only. Rejected: Re-implementing rule validation in TypeScript | It would create a second source of truth for rule validity, and the failure mode is silent -- a rule the UI accepts but the matcher never matches rejects every request for that model. Rejected: Two fixed columns (resolution + has_video) | kling prices by mode and has no resolution parameter, so a fixed shape would let an administrator write a rule that can never match. Decision: ModelPrice is written automatically and hidden. It cancels out of the quota calculation so it cannot change what a customer pays, but its presence is what switches a model onto per-second billing. Confidence: high Scope-risk: narrow -- 6 edits and 1 new file in web/default, no backend. Tested: Resolution vocabulary in the spec verified against setting/billing_setting/video_price.go. Not-tested: No code changed in this commit.
10 tasks. Tasks 1-3 are pure helpers with real unit tests; 4-8 thread the mode through the existing sheet; 9 covers all eight locales; 10 verifies the backend was not touched. Constraint: frontend only -- a task-10 check fails the plan if any Go file appears in the diff. Directive: ModelPrice is read-modify-write. Production holds 102 entries for unrelated models and a wholesale write erases them.
The console offers a hardcoded copy of canonicalResolutions, because the browser cannot import Go. Its own test asserts against that same literal, so it pins TypeScript against itself: adding a resolution here would leave the dropdown silently missing a tier while both test suites stayed green. That is not hypothetical. This vocabulary is already duplicated once between taskcommon and billing_setting -- the import direction forbids sharing -- and those two drifted until a test pinned them. The console is the third copy. Verified load-bearing by mutation: adding a resolution to canonicalResolutions without updating the console list makes this fail, naming the missing tier. Raised by the agent implementing the console helper types.
17 new keys across en, zh, fr, ru, ja, vi, es, pt. CLAUDE.md records i18n as a repeat source of defects here, with es and pt shipped untranslated before, so each locale carries a real translation rather than the English string copied across. Verified two ways: a key-by-key check against en.json, and the repo's own bun run i18n:sync, whose untranslated reports list none of these keys. French 'mode' matches the English spelling because that is the correct French word, not an untranslated copy.
f9ec2af to
4ff2b86
Compare
|
|
section-registry reads billing_setting_video.video_price_rules straight out of the settings map, so it is undefined until something saves a video price rule -- which is every deployment's state on first load after this feature ships. normalizeJsonString then called .trim() on it and threw during render, taking down the whole billing settings page. Guarded in normalizeJsonString rather than at the one call site: every other field survives only because its key always happens to exist, so the next new option key would reintroduce this. Existing behaviour for strings is unchanged. Found by OpenCodeReview on PR #710.
逐条核实结果(
|
背景
上一个 PR(#700)把视频计费从硬编码迁到了可配置的按秒价格表,但只有后端。价格规则至今只能通过
PUT /api/option/手工提交一段 7691 字符的 JSON —— 没有取值提示、没有校验反馈,除非保存被拒才知道写错了。这个 PR 补上控制台界面。
改动
模型定价弹窗的计费模式从三种变成四种:
选中后展开规则子表,每条规则可配:
纯前端,不改后端
option key、严格保存校验、适配器读取全都已经在跑。这次只是给一个目前只能走 API 的配置做可视化编辑器。
规则校验不在前端重做,交给后端返回错误原文。理由不是省事:TypeScript 里再实现一遍,就有了第二个"什么算合法规则"的真相源。这个功能已经因为同类原因栽过一次 —— 分辨率词表因 import 方向限制被迫在两个 Go 包各存一份,结果真的漂移了,靠测试才钉住。浏览器里再来第三份会以同样方式漂,而且失败是静默的:界面接受但匹配器永不命中 = 该模型每个请求被拒。
实施中发现的三个问题
都不在原计划里,是执行过程中查出来的。
① 写
billing_mode: 'video'会让官网定价接口整个报错service/website_pricing.go:166的switch遇到未知billing_mode会return ... unsupported billing mode for model %q,整个响应失败。按常规做法给新模式写
billing_mode会直接打挂官网价格页。改为从"模型是否出现在规则表里"判断模式 —— 这正好和后端自己的IsVideoModelConfigured(video_price.go:377)一致:出现在表里就是走按秒计费。不这么做的话模式根本无法回读,选完保存再打开就丢了。
② 三处类型系统抓不到的分支误判
已配价的视频模型必然带
ModelPrice条目,而现有判断是price ? 'per-request' : 'per-token'—— 视频模型会被误判成按次计费,规则表静默不显示。因为这些是带兜底的
if/else而非穷尽switch,TypeScript 不报错。已修buildModelSnapshots、handleEdit、以及弹窗的 reset effect。③ 控制台词表和后端之间没有任何约束
计划里的词表测试是拿
RESOLUTION_VALUES跟硬编码字面量比对 —— 自己跟自己比。后端加第八个分辨率时,两边测试都照样通过,但下拉框会静默少一档,管理员再也配不了。后端本来就为此导出了
CanonicalResolutionValues()。补了一个 Go 侧测试把两边钉住,变异验证过:往 Go 加1440p不改前端,测试立即失败并指出缺哪档。这是本 PR 唯一的 Go 改动,且只是测试文件,无生产代码。
关键实现细节
ModelPrice是读-改-写,不是覆盖。 生产上有 102 条其他模型的定价,整体写入会全部清空。代码只改目标模型那一个 key:那次捕获是承重的:既有的
delete priceMap[name]在模式分支之前执行,之后再读就永远是undefined,会把0.14/0.08重写成1。已有值绝不覆盖。
doubao-seedance-2-5-260628(0.14) 和MiniMax-H3(0.08) 是刻意设的基数,改成 1 会让日志里的video_billing_units历史数值断层。ModelPrice对管理员隐藏。 它是每秒价 × 秒数 ÷ ModelPrice的除数,外层链路再乘回来,所以它的值改不了客户实付多少。但它的存在与否决定模型是否切换到按秒计费,且非正数会让后端拒绝该模型所有请求 —— 所以必须写。显示一个看起来像价格却不按价格行事的数字只会招致误读。ratio-settings-card.tsx的 8 处平行分支(schema/normalize/format/reset×2/submit/keyMap)全部处理。第 8 处UpstreamRatioSync故意跳过 —— 那是导入上游倍率,视频规则是本地编写的,不参与同步。设计取舍
维度用勾选框,不用"留空即通配" —— 空文本框分不清"匹配任意值"和"还没填",而这两者后果相反。
取值用下拉,不用自由输入 —— 后端会折叠大小写(
4K能存),但1440p会被直接拒。下拉从根上消除这类拼写错误,而不是靠管理员读错误信息。basis必须显式选,无默认值 —— 两者在典型请求上差约一倍价格,给默认值等于替管理员默认选了个价。渠道维度确实不一致,这是维度做成开放集而非两个固定列的原因:
resolution+has_videomode(std/pro)—— 它没有输出分辨率参数has_video写死两列会让管理员给 kling 配出永不命中的规则,而对已配价的模型那意味着每个请求被拒。
验证
新增 18 个单测覆盖类型、序列化、草稿校验。词表钉死那个测试做过变异验证。
范围检查:
git diff --name-only origin/main...HEAD | grep '\.go$' | grep -v '_test.go$'→ 空。无生产 Go 代码改动。i18n:17 个 key × 8 语言。逐 key 比对
en.json防英文复制,并跑了仓库自带的bun run i18n:sync—— 未翻译报告里这些 key 一个没有。法语mode与英文同拼写是正确法语,非漏翻。未做
手动验收(需要跑起来的控制台):
ModelPrice未变(对照backup_ModelPrice.json里的 96 条)部署建议(Rule 12)
not required—— 改动限于web/default(控制台 SPA)与一个 Go 测试文件,不触及/v1、relay、计费结算或任何运行时路径newapi-console需要构建。newapi-web、Terraform、Cloudflare 不涉及。无 DB migration、无新环境变量设计文档:
docs/superpowers/specs/2026-08-13-video-pricing-ui-design.md