Skip to content

控制台支持编辑视频按秒价格表 - #710

Merged
think-back merged 13 commits into
mainfrom
feat/video-per-second-billing
Aug 13, 2026
Merged

控制台支持编辑视频按秒价格表#710
think-back merged 13 commits into
mainfrom
feat/video-per-second-billing

Conversation

@think-back

Copy link
Copy Markdown
Collaborator

背景

上一个 PR(#700)把视频计费从硬编码迁到了可配置的按秒价格表,但只有后端。价格规则至今只能通过 PUT /api/option/ 手工提交一段 7691 字符的 JSON —— 没有取值提示、没有校验反馈,除非保存被拒才知道写错了。

这个 PR 补上控制台界面。

改动

模型定价弹窗的计费模式从三种变成四种:

( )按 Token   ( )按次   ( )阶梯表达式   (•)视频按秒

选中后展开规则子表,每条规则可配:

  • 匹配维度(勾选式):分辨率 / 参考视频 / 生成模式
  • 每秒单价
  • 计费基准:输出时长 或 输入+输出总时长(后者需填兜底秒数)

纯前端,不改后端

option key、严格保存校验、适配器读取全都已经在跑。这次只是给一个目前只能走 API 的配置做可视化编辑器。

规则校验不在前端重做,交给后端返回错误原文。理由不是省事:TypeScript 里再实现一遍,就有了第二个"什么算合法规则"的真相源。这个功能已经因为同类原因栽过一次 —— 分辨率词表因 import 方向限制被迫在两个 Go 包各存一份,结果真的漂移了,靠测试才钉住。浏览器里再来第三份会以同样方式漂,而且失败是静默的:界面接受但匹配器永不命中 = 该模型每个请求被拒。

实施中发现的三个问题

都不在原计划里,是执行过程中查出来的。

① 写 billing_mode: 'video' 会让官网定价接口整个报错

service/website_pricing.go:166switch 遇到未知 billing_modereturn ... unsupported billing mode for model %q整个响应失败

按常规做法给新模式写 billing_mode 会直接打挂官网价格页。改为从"模型是否出现在规则表里"判断模式 —— 这正好和后端自己的 IsVideoModelConfiguredvideo_price.go:377)一致:出现在表里就是走按秒计费。

不这么做的话模式根本无法回读,选完保存再打开就丢了。

② 三处类型系统抓不到的分支误判

已配价的视频模型必然带 ModelPrice 条目,而现有判断是 price ? 'per-request' : 'per-token' —— 视频模型会被误判成按次计费,规则表静默不显示。

因为这些是带兜底的 if/else 而非穷尽 switchTypeScript 不报错。已修 buildModelSnapshotshandleEdit、以及弹窗的 reset effect。

③ 控制台词表和后端之间没有任何约束

计划里的词表测试是拿 RESOLUTION_VALUES 跟硬编码字面量比对 —— 自己跟自己比。后端加第八个分辨率时,两边测试都照样通过,但下拉框会静默少一档,管理员再也配不了。

后端本来就为此导出了 CanonicalResolutionValues()。补了一个 Go 侧测试把两边钉住,变异验证过:往 Go 加 1440p 不改前端,测试立即失败并指出缺哪档。

这是本 PR 唯一的 Go 改动,且只是测试文件,无生产代码。

关键实现细节

ModelPrice 是读-改-写,不是覆盖。 生产上有 102 条其他模型的定价,整体写入会全部清空。代码只改目标模型那一个 key:

const existingModelPrice = priceMap[name]   // 必须在既有的 delete 之前捕获
// ...
priceMap[name] = existingModelPrice ?? 1

那次捕获是承重的:既有的 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 必须显式选,无默认值 —— 两者在典型请求上差约一倍价格,给默认值等于替管理员默认选了个价。

渠道维度确实不一致,这是维度做成开放集而非两个固定列的原因:

渠道 维度
seedance 系 / doubao / byteplus / vertex / gemini / ali / vidu resolution + has_video
kling mode(std/pro)—— 它没有输出分辨率参数
xaigrok / sonilo / jimeng has_video

写死两列会让管理员给 kling 配出永不命中的规则,而对已配价的模型那意味着每个请求被拒

验证

bun run typecheck   通过(注意 bun run build 只打包不做类型检查)
bun test src/features/system-settings/   62 通过 / 0 失败
go test ./setting/billing_setting/       通过

新增 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)

  • Router deploy: not required —— 改动限于 web/default(控制台 SPA)与一个 Go 测试文件,不触及 /v1、relay、计费结算或任何运行时路径
  • 其他目标newapi-console 需要构建。newapi-web、Terraform、Cloudflare 不涉及。无 DB migration、无新环境变量
  • 风险:低。功能是给既有配置加编辑界面,后端行为不变
  • 多节点(Rule 11):不涉及 —— 编辑器走既有 API 做普通 option 写入,后端保存路径已持模块写锁

设计文档:docs/superpowers/specs/2026-08-13-video-pricing-ui-design.md

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.
@think-back
think-back force-pushed the feat/video-per-second-billing branch from f9ec2af to 4ff2b86 Compare August 13, 2026 10:30
@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 commit f9ec2af9 · 共 8 条

web/default/src/features/system-settings/models/video-pricing-types.ts

  • L70-78: [阻塞] 这里把任何不可解析或非数组的已存配置直接降级为空数组,而保存链路会基于 parseAllRules(videoRules) 再整键写回视频规则表;一旦线上配置出现旧格式、局部损坏或临时不可读,管理员编辑任意模型后就可能把整张视频按秒计费规则清空,造成计费配置丢失/错误。建议区分“确实为空”和“解析失败”,解析失败时阻止覆盖并提示用户,或至少保留原始配置不参与写回。
export function parseAllRules(raw: string | undefined): VideoPriceRule[] | null {
  if (!raw) return []
  try {
    const parsed = JSON.parse(raw)
    return Array.isArray(parsed) ? (parsed as VideoPriceRule[]) : null
  } catch {
    return null
  }
}

web/default/src/i18n/locales/ja.json

  • L6549-6565: [阻塞] 这里新增的翻译项被放在了对象结束符 } 之后,当前文件会变成非法 JSON,导致日文 locale 在构建/加载时直接失败,相关页面可能无法正常渲染。请把这些键值对移动到前面的对象内部,并保留最外层只出现一次闭合 }
"resolution": "解像度",
  "mode": "生成モード",
  "has_video": "参照動画",
  "Add rule": "ルールを追加",
  "Delete rule": "ルールを削除",
  "Match dimensions": "マッチ条件",
  "Billing basis": "課金基準",
  "Fallback seconds": "フォールバック秒数",
  "Price per second ($)": "1秒あたりの単価($)",
  "output_duration": "出力時間",
  "total_duration": "入力 + 出力の合計時間",
  "Video per-second": "動画・秒単位",
  "Per-second": "秒単位",
  "Per-second video billing": "動画の秒単位課金",
  "{{count}} per-second rules": "秒単位ルール {{count}} 件",
  "Price per second must be greater than zero": "1秒あたりの単価は 0 より大きい必要があります",
  "Total duration billing requires a fallback in seconds": "合計時間での課金にはフォールバック秒数が必要です"
}

web/default/src/features/system-settings/models/model-pricing-sheet.tsx

  • L480-482: [严重] 这里把 videoRules 原样写回,规则里的 model 不会随当前表单中的模型名同步。新增模型先切到 video 再修改名称时,会把旧名称保存进去,导致后续规则匹配不到当前模型,视频计费配置失效。建议在提交前统一用 values.name.trim() 重写每条 rule.model,或按当前名称重新生成/过滤规则。
if (pricingMode === 'video') {
        const nextVideoRules = videoRules.map((rule) => ({
          ...rule,
          model: values.name.trim(),
        }))
        data.videoRules = JSON.stringify(nextVideoRules)
      }
  • L480-482: [严重] 这里没有把视频规则的草稿校验接入提交流程,price_per_second <= 0total_duration 缺少 fallback_seconds 的规则仍然会被提交给后端。这样会把明显无效的计费配置落库,或者在保存时触发后端失败。建议在 commitDraft 前统一校验 videoRules,校验失败直接阻断保存。
if (pricingMode === 'video') {
        const nextVideoRules = videoRules.map((rule) => ({
          ...rule,
          model: values.name.trim(),
        }))
        data.videoRules = JSON.stringify(nextVideoRules)
      }

web/default/src/features/system-settings/models/ratio-settings-card.tsx

  • L112: [阻塞] 这里只校验 VideoRules 是合法 JSON,但视频按秒价格表会直接影响计费,若用户通过 JSON 模式输入对象、字符串或缺少 model/price_per_second/basis/match 的数组,前端仍会放行并提交到 billing_setting_video.video_price_rules。如果后端未完全拒绝或按空规则解析,可能导致视频模型计费规则丢失/计费错误;即使后端拒绝,也会在同一次保存中造成前面已提交字段与该字段失败的部分保存风险。建议至少在 schema 中限制为 VideoPriceRule[] 结构,并校验价格为正数、basis 合法、match 为对象等关键字段。
VideoRules: createJsonStringField(t, {
      predicate: (parsed) =>
        Array.isArray(parsed) &&
        parsed.every(
          (rule) =>
            rule &&
            typeof rule === 'object' &&
            typeof (rule as { model?: unknown }).model === 'string' &&
            (rule as { model: string }).model.trim().length > 0 &&
            ((rule as { basis?: unknown }).basis === 'output_duration' ||
              (rule as { basis?: unknown }).basis === 'total_duration') &&
            Number.isFinite(
              (rule as { price_per_second?: unknown }).price_per_second
            ) &&
            (rule as { price_per_second: number }).price_per_second > 0 &&
            (rule as { match?: unknown }).match !== null &&
            typeof (rule as { match?: unknown }).match === 'object' &&
            !Array.isArray((rule as { match?: unknown }).match)
        ),
      predicateMessage: 'Expected a JSON array of valid video pricing rules',
    }),

web/default/src/features/system-settings/models/model-pricing-snapshots.ts

  • L294-309: [严重] 这里对 videoModelNames.has(name) 直接提前返回,并且无条件把 hasConflict 置为 false。如果同一个模型同时还存在旧的 per-token / per-request / tiered_expr 配置,或者 billingModeMap 里残留了其他模式,这些冲突会被静默吞掉,控制台会把一份实际上混杂的计价配置展示成“正常的 video 模式”,后续保存还可能把原有字段覆盖掉。建议在 video 分支里也保留冲突检测,至少在发现视频规则与其他计费字段并存时提示或阻止保存。
if (videoModelNames.has(name)) {
      const hasConflict =
        (price !== '' &&
          (ratio !== '' ||
            completion !== '' ||
            cache !== '' ||
            createCache !== '' ||
            image !== '' ||
            audio !== '' ||
            audioCompletion !== '')) ||
        modeForModel === 'tiered_expr'

      return {
        name,
        billingMode: 'video',
        videoRules: JSON.stringify(rulesForModel(allVideoRules, name)),
        price,
        ratio,
        cacheRatio: cache,
        createCacheRatio: createCache,
        completionRatio: completion,
        imageRatio: image,
        audioRatio: audio,
        audioCompletionRatio: audioCompletion,
        hasConflict,
      }
    }

web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx

  • L891: [严重] 这里新增了 savedVideoRules 参与快照构建和变更对比,但 memo 的自定义比较没有比较该 prop。保存视频规则后通常只有 saved 值回写变化,而当前 videoRules 可能已经等于提交值,此时组件会被错误跳过渲染,继续用旧的 saved 视频规则计算 dirty/差异状态,导致视频计费配置保存后仍显示未保存或后续批量操作基于过期基线。建议把 savedVideoRules 也纳入比较,确保服务端保存态变化能触发重渲染。
prevProps.videoRules === nextProps.videoRules &&
      prevProps.savedVideoRules === nextProps.savedVideoRules &&

web/default/src/features/system-settings/models/video-pricing-editor.tsx

  • L167-172: [阻塞] 这里把 number 输入框的草稿值直接用 Number(...) 写入计费规则,但当前保存路径只调用了通用表单校验,没有根据 validateRuleDraft 阻断视频规则保存;用户清空输入框时会写入 0,输入超大值时可能写入 Infinity,这些无效价格仍会被序列化到按秒计费规则中,存在直接导致计费配置错误的风险。建议不要在输入阶段把非法草稿落到规则对象,或至少在提交前遍历所有视频规则并在存在 validateRuleDraft(rule) 时阻断保存。
onChange={(event) => {
                  const value = Number(event.target.value)
                  if (Number.isFinite(value) && value > 0) {
                    updateRule(index, {
                      ...rule,
                      price_per_second: value,
                    })
                  }
                }}

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 commit 4ff2b865 · 共 10 条

web/default/src/features/system-settings/models/model-ratio-form.tsx

  • L259: [严重] 这里虽然把 VideoRules 传给了视觉编辑器,但 modelJsonFields 仍然没有把这个字段渲染到 JSON 模式里。结果是用户切到 JSON 编辑时无法查看或修改视频按秒价格表,遇到规则内容异常时也没有回退入口,功能在该模式下实际上不可用。建议把 VideoRules 同步加入 JSON 表单字段列表和对应的类型定义,保证两种编辑模式都能完整读写该配置。
videoRules={form.watch('VideoRules')}
              // JSON mode should also render the VideoRules field via modelJsonFields.

web/default/src/features/system-settings/models/model-pricing-sheet.tsx

  • L480-482: [阻塞] 这里直接把 videoRules 原样序列化提交,但规则里的 model 字段只在进入 video 模式时初始化一次,后续如果用户新建视频模型时先切换到 video 再填写名称,或在编辑过程中修改了模型名,这些规则不会自动改写为当前名称。结果会把空/旧模型名持久化,后端按模型名匹配时就找不到这组按秒计费规则,造成视频定价配置丢失或错绑。建议在保存前统一将规则的 model 归一化为当前表单名称,或者在名称变更时同步重建/重映射 videoRules
if (pricingMode === 'video') {
        data.videoRules = JSON.stringify(
          videoRules.map((rule) => ({ ...rule, model: values.name.trim() }))
        )
      }

web/default/src/features/system-settings/models/ratio-settings-card.tsx

  • L193: [严重] 新增的 VideoRules 直接传入 normalizeJsonString;如果后端在升级中的环境尚未返回 billing_setting_video.video_price_rules(section-registry 中也是直接透传),这里会对 undefined 调用 trim(),导致整个计费设置页渲染崩溃。建议对所有读取 modelDefaults.VideoRules 的新增位置统一兜底为空字符串。
VideoRules: normalizeJsonString(modelDefaults.VideoRules ?? ''),

web/default/src/features/system-settings/models/model-pricing-snapshots.ts

  • L291-309: [严重] 这里将命中视频规则的模型直接标记为 billingMode: 'video'hasConflict: false,会掩盖同名模型同时存在 token ratio、per-request price(除视频所需的 ModelPrice 外)、或其它计费配置的脏数据/迁移冲突。控制台不会显示冲突提示,后续保存可能静默覆盖另一套计费字段,导致展示与实际计费配置不一致。建议在视频分支也计算并暴露冲突(至少对除视频必需 ModelPrice 外的其它计费来源做检测)。
// Checked before the per-request test below: a video model always carries a
    // ModelPrice entry -- the divisor the per-second chain cancels out -- so the
    // price test would otherwise claim it for per-request and hide its rules.
    if (videoModelNames.has(name)) {
      const hasVideoConflict =
        modeForModel !== undefined ||
        billingExprMap[name] !== undefined ||
        ratio !== '' ||
        completion !== '' ||
        cache !== '' ||
        createCache !== '' ||
        image !== '' ||
        audio !== '' ||
        audioCompletion !== ''

      return {
        name,
        billingMode: 'video',
        videoRules: JSON.stringify(rulesForModel(allVideoRules, name)),
        price,
        ratio,
        cacheRatio: cache,
        createCacheRatio: createCache,
        completionRatio: completion,
        imageRatio: image,
        audioRatio: audio,
        audioCompletionRatio: audioCompletion,
        hasConflict: hasVideoConflict,
      }
    }

web/default/src/features/system-settings/models/video-pricing-editor.tsx

  • L102-103: [阻塞] 视频计费模式下可以把最后一条规则删除,组件只显示空列表但仍允许父级保存 videoRules=[];按现有合并逻辑,规则表中没有该 model 后会失去“视频按秒计费”标识,同时仍可能写入占位 ModelPrice,导致该模型被按固定请求价/非视频模式计费,属于计费错误。建议至少保留一条规则,或在保存前阻止空规则的视频配置。
onClick={() => {
                  if (rules.length <= 1) return
                  onChange(rules.filter((_, i) => i !== index))
                }}
                disabled={rules.length <= 1}
                aria-label={t('Delete rule')}
  • L170: [严重] 这里把输入值直接转成 number 并写入规则;type=number 的编辑中间态(清空、e. 等)会变成 0NaN。当前组件只是渲染错误提示,父级保存并不会因为 validateRuleDraft 失败而中断,NaN 序列化后还会变成 null,可能把非法价格配置提交到后端并造成计费配置失效。建议在写入前拒绝非有限数,并在保存链路中统一校验所有规则通过后才允许提交。
price_per_second: Number.isFinite(
                      Number(event.target.value)
                    )
                      ? Number(event.target.value)
                      : rule.price_per_second,
  • L214: [严重] fallback_seconds 同样会把空值/非法中间态写成 0NaN,而视频规则校验目前只展示错误、不阻止保存;对于 total_duration 计费,非法兜底秒数会让后端拒绝配置或产生错误计费兜底。建议仅接受正的有限数,并在父级保存前阻断包含非法 fallback 的规则。
fallback_seconds: Number.isFinite(
                          Number(event.target.value)
                        )
                          ? Number(event.target.value)
                          : rule.fallback_seconds,

web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx

  • L556-557: [阻塞] 视频模式保存前只做了 JSON 解析,没有校验解析结果是否为空或规则字段是否合法;用户切到 Video 后默认会产生 price_per_second=0 的空规则,或者删除所有规则后仍可继续写入 ModelPrice。这样后端无法通过规则表识别为按秒计费,模型可能退化为按请求/非视频计费,造成计费金额错误。建议在持久化前阻止空规则和 price/fallback 非法的规则保存,并给出错误提示。
const editedRules = parseAllRules(data.videoRules)
      if (
        data.billingMode === 'video' &&
        (editedRules.length === 0 ||
          editedRules.some(
            (rule) =>
              !Number.isFinite(rule.price_per_second) ||
              rule.price_per_second <= 0 ||
              (rule.basis === 'total_duration' &&
                (!Number.isFinite(rule.fallback_seconds ?? Number.NaN) ||
                  (rule.fallback_seconds ?? 0) <= 0))
          ))
      ) {
        toast.error(t('Video per-second pricing requires at least one valid rule'))
        return
      }
      let nextVideoRules = parseAllRules(videoRules)
  • L610: [阻塞] 这里保留 existingModelPrice 时没有校验其是否为正数;如果旧配置或 JSON 编辑中已有 0/负数,切换为视频按秒计费后会继续写回无效 divisor。代码注释也说明非正值会导致后端拒绝每个请求,影响线上模型可用性。建议仅在 existingModelPrice 为有限正数时保留,否则回退为 1。
priceMap[name] =
            Number.isFinite(existingModelPrice) && (existingModelPrice ?? 0) > 0
              ? existingModelPrice
              : 1
  • L891: [严重] memo 比较函数遗漏了新增的 savedVideoRules。保存成功后如果父组件只刷新 savedVideoRules、当前 videoRules 值未变化,本组件会被错误跳过渲染,models 中基于 savedVideoRules 计算的 savedRows/差异状态仍使用旧值,可能出现保存后仍显示未保存变更或视频模式统计滞后。建议把 savedVideoRules 纳入比较条件。
prevProps.savedVideoRules === nextProps.savedVideoRules &&
      prevProps.videoRules === nextProps.videoRules &&

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.
@think-back

Copy link
Copy Markdown
Collaborator Author

逐条核实结果(a659370b8

三条重点意见我都写了验证代码,结论一条成立、一条不成立、一条属既有设计。


ratio-settings-card.tsx L193 崩溃 —— 成立,已修

这条是真的,而且比描述的更普遍。

section-registry.tsx:40 是直接查表:

VideoRules: settings['billing_setting_video.video_price_rules'],

这个 key 在任何还没保存过视频价格规则的部署上都不存在 —— 也就是这个功能上线后的每一次首次加载。normalizeJsonString 无兜底调用 .trim(),渲染期抛错,整个计费设置页挂掉。

已用测试复现:

(fail) undefined normalizes to empty rather than throwing
(fail) null normalizes to empty rather than throwing

修在 normalizeJsonString 而不是那一个调用点。 其余 8 个字段不崩,只是因为它们的 key 恰好总是存在 —— 在调用点加兜底的话,下一个新增 option key 会原样重演。签名改为 string | undefined | null,字符串行为不变。


model-pricing-sheet.tsx L480 规则绑错模型名 —— 不成立

意见说规则的 model 只在切换模式时赋值一次,改名或后填名称会持久化空/旧名。

保存路径会重新盖章。 mergeModelRules 对每条规则都覆写 model

return [...others, ...next.map((rule) => ({ ...rule, model }))]

model-ratio-visual-editor.tsx:611 用保存循环里的 name 调用它,所以弹窗里的陈旧名称在落盘前就被覆盖了。

写测试验证过两种场景,均通过:

✓ save re-stamps an empty model name    ('' -> 'my-model')
✓ save re-stamps a renamed model        ('old-name' -> 'new-name')

按建议在 sheet 里再盖一次章是无害的冗余,但不修复任何真实缺陷 —— 而多一处赋值就多一处未来可能与保存路径不一致的地方。不改。


⚠️ model-ratio-form.tsx JSON 模式缺 VideoRules —— 属既有设计,非本 PR 引入

modelJsonFields 只有 8 个字段,全是数值倍率表(ModelPrice/ModelRatio/CacheRatio/…)。

BillingModeBillingExpr 同样不在里面 —— 阶梯表达式模式早就是这个状态,且已经这样发布了。JSON 模式覆盖的是数值映射,模式专属字段一律只在可视化编辑器里。

所以这不是本 PR 造成的回退,是既有的一致做法。把 VideoRules 单独加进去反而会造成不一致:视频规则能在 JSON 模式编辑,阶梯表达式不能。

要改的话应该是一并BillingMode/BillingExpr/VideoRules 三个都加上,那是独立的一致性改进,不属于本 PR 范围。


关于视觉编辑器冲突检测那条

意见建议视频分支也计算 hasConflict。这条有道理但需要先确认一个前提:视频模型必然ModelPrice(按秒计算链的除数),所以按现有 hasConflict 逻辑它会永远报冲突。要做的话得先把「视频必需的 ModelPrice」从冲突判定里排除掉,否则每个视频模型都挂着一个假警告。

我倾向留待手动验收时观察真实脏数据情况再决定 —— 现在加,很可能加出一个永远为真的告警。


验证

bun run typecheck        通过
bun test src/features/system-settings/   65 通过 / 0 失败(新增 3 个)

冲突也已解决:main 上有 79 个新提交,冲突全在 8 个 i18n 文件(双方各加各的 key)。已 rebase 并逐个核对 main 的 key 一个没丢,PR 状态现为 MERGEABLE

@think-back
think-back merged commit 68132b0 into main Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants