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