fix(frontend): 批量编辑显式提交 codex_fingerprint_mode=off,修复无法关闭指纹收敛

批量编辑选「关闭(默认)」时,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
This commit is contained in:
li
2026-08-28 16:20:14 +08:00
parent d4754c211a
commit 0756c98102
2 changed files with 89 additions and 6 deletions
@@ -978,6 +978,7 @@
<div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexFingerprintMode') }}</label>
<input
id="bulk-edit-openai-codex-fingerprint-mode-enabled"
v-model="enableCodexFingerprintMode"
type="checkbox"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@@ -2089,12 +2090,23 @@ const buildUpdatePayload = (): Record<string, unknown> | 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) {
@@ -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<string, unknown>
}
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
}
})
})
})