From 0756c9810257e63da3c77429f9bb5f530c698cc5 Mon Sep 17 00:00:00 2001 From: li Date: Fri, 28 Aug 2026 16:20:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E6=89=B9=E9=87=8F=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=98=BE=E5=BC=8F=E6=8F=90=E4=BA=A4=20codex=5Ffingerp?= =?UTF-8?q?rint=5Fmode=3Doff=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E6=8C=87=E7=BA=B9=E6=94=B6=E6=95=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批量编辑选「关闭(默认)」时,buildUpdatePayload 先 ensureExtra() 建出 extra 再 delete 掉唯一的键,payload 退化成 {account_ids, extra:{}},被后端 len(req.Extra) > 0 判为空更新,直接 400 "No updates provided";device/session/full 则正常。即便后端放行空 extra 也无济于事:批量更新走 JSONB 顶层合并 (extra = COALESCE(extra,'{}') || payload),删掉 payload 里的键只表示 「本次不更新该键」,清不掉账号上已有的 device/session/full。 根因是把 Create/Edit 的写法直接搬到了 Bulk:那两个表单提交完整 extra 对象、 后端 SetExtra 整体覆盖,删键确实等于清除;批量接口只合并增量键,两种持久化 语义不能共用同一套写法。 改为 off 也显式落键,与本函数里其它「关闭/清除」字段一致——codex_cli_only 直接落 false(EditAccountModal 相邻注释已写明「关闭时显式写 false,避免 extra 为空被后端忽略导致旧值无法清除」)、load_factor 落 0、proxy_id 落 0。 读取侧完全等价,不改变 #5610 定下的语义:codexFingerprintModeFromExtra 对 空值/非法值走 default 回落 off,对 "off" 命中同一分支; ShouldEnsureCodexFingerprintSeedForExtraUpdates 也只在 device/session/full 时要种子,显式 off 不会触发种子创建。 顺带给「编辑该项」勾选框补 id(同区域 Select 早有 data-testid,兄弟开关 codex_cli_only 也有 id),否则新增用例无法定位该控件。 新增 3 条用例:主复现(默认 off + 勾选编辑 ⇒ 提交 codex_fingerprint_mode=off, 并断言 extra 非空以钉死 400 那个形状)、显式 opt-in 仍原样提交、未勾选时不写入该键。 Fixes #6327 --- .../account/BulkEditAccountModal.vue | 24 +++++-- .../__tests__/BulkEditAccountModal.spec.ts | 71 +++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 0576194248..6e751df816 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -978,6 +978,7 @@
| null => { if (enableCodexFingerprintMode.value) { const extra = ensureExtra() - // off = 默认值,清键即可;device/session/full 是显式 opt-in,必须落键(#5610)。 - if (codexFingerprintMode.value !== 'off') { - extra.codex_fingerprint_mode = codexFingerprintMode.value - } else { - delete extra.codex_fingerprint_mode - } + // off 必须显式落键,不能靠删本地键表达。批量更新走 JSONB 顶层合并 + // (extra = COALESCE(extra,'{}') || payload),删掉 payload 里的键只表示 + // "本次不更新该键",清不掉账号上已有的 device/session/full;而且只删不写会让 + // 整个 payload 退化成 {extra:{}},被后端 len(req.Extra) > 0 判为空更新直接 400 + // "No updates provided"(#6327)。 + // + // Create/Edit 那两个表单可以删键,是因为它们提交完整 extra 对象、后端整体 + // SetExtra 覆盖;批量接口只合并增量键,两种持久化语义不能共用同一套写法。 + // + // 显式 off 与不设置在读取侧完全等价:codexFingerprintModeFromExtra 对空值/ + // 非法值走 default 回落 off,对 "off" 命中同一分支,所以 #5610 定下的 + // "不显式 opt-in 就保持旧客户端身份" 不受影响;ShouldEnsureCodexFingerprintSeed- + // ForExtraUpdates 同样只在 device/session/full 时要种子,off 不会触发。 + // + // 与本函数里其它"关闭/清除"字段的写法一致:codex_cli_only 直接落 false, + // load_factor 落 0,proxy_id 落 0 —— 批量路径一律用显式哨兵值,不用省略。 + extra.codex_fingerprint_mode = codexFingerprintMode.value } if (enableOpenAICompactMode.value) { diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index b6fa614223..faf319addf 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -920,4 +920,75 @@ describe('BulkEditAccountModal', () => { status: 'active' }) }) + // issue #6327:批量编辑无法把 Codex 指纹收敛关掉。 + // + // 批量更新走 JSONB 顶层合并(extra = COALESCE(extra,'{}') || payload),删掉 payload + // 里的键只表示「本次不更新该键」,清不掉账号已有的 device/session/full;而且只删不写会让 + // payload 退化成 {extra:{}},被后端 len(req.Extra) > 0 判为空更新直接 400 + // "No updates provided"。Create/Edit 那两个表单能删键,是因为它们提交完整 extra 对象、 + // 后端整体 SetExtra 覆盖——两种持久化语义不能共用同一套写法。 + it('OpenAI OAuth 批量编辑选择「关闭」时应显式提交 codex_fingerprint_mode=off(issue #6327)', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + + // 下拉框默认就是 off,用户只勾选「编辑该项」即提交——正是 issue 描述的操作路径。 + await wrapper.get('#bulk-edit-openai-codex-fingerprint-mode-enabled').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + extra: { + codex_fingerprint_mode: 'off' + } + }) + + // 缺陷时期的形状:extra 为空对象,后端必然回 400。显式钉死不得回退。 + const payload = vi.mocked(adminAPI.accounts.bulkUpdate).mock.calls[0][1] as { + extra: Record + } + expect(Object.keys(payload.extra).length).toBeGreaterThan(0) + }) + + // 与兄弟字段 codex_cli_only 的写法对齐:关闭态同样落显式值,不靠省略表达。 + it('OpenAI OAuth 批量编辑显式 opt-in 模式仍原样提交', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + + await wrapper.get('#bulk-edit-openai-codex-fingerprint-mode-enabled').setValue(true) + await wrapper + .get('[data-testid="bulk-codex-fingerprint-mode-select"]') + .setValue('session') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + extra: { + codex_fingerprint_mode: 'session' + } + }) + }) + + // 未勾选「编辑该项」时不得写入该键,否则批量编辑别的字段会顺手清掉账号的收敛设置。 + it('未勾选编辑该项时不写入 codex_fingerprint_mode', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + + await wrapper.get('#bulk-edit-openai-codex-cli-only-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-codex-cli-only-toggle').trigger('click') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + extra: { + codex_cli_only: true + } + }) + }) })