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 + } + }) + }) })