From e2c35cf394ac91c7e35f14d0fa837f630ca25e3f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 23 Sep 2026 17:47:47 +0800 Subject: [PATCH] fix(account): prevent whitelist model mapping conflicts --- .../account/BulkEditAccountModal.vue | 1 + .../components/account/CreateAccountModal.vue | 3 ++ .../components/account/EditAccountModal.vue | 8 ++--- .../account/ModelWhitelistSelector.vue | 6 ++++ .../__tests__/EditAccountModal.spec.ts | 19 +++++++++++ .../__tests__/ModelWhitelistSelector.spec.ts | 33 ++++++++++++++++++- .../__tests__/useModelWhitelist.spec.ts | 13 ++++++++ .../src/i18n/locales/en/admin/accounts.ts | 1 + .../src/i18n/locales/zh/admin/accounts.ts | 1 + 9 files changed, 80 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 22f6f3e788..7acd3ac79b 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -343,6 +343,7 @@ diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index c6616c679f..ff0f07d138 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1518,6 +1518,7 @@
- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -779,7 +779,7 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -991,7 +991,7 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -1213,7 +1213,7 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ t('admin.accounts.supportsAllModels') }} diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index 5f359ccca1..d1e7e557cb 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -159,6 +159,7 @@ const { t } = useI18n() const props = defineProps<{ modelValue: string[] + modelMappings?: { from: string; to: string }[] platform?: string platforms?: string[] accountId?: number @@ -274,6 +275,11 @@ const addCustom = () => { appStore.showInfo(t('admin.accounts.modelExists')) return } + const conflict = props.modelMappings?.find(mapping => mapping.from.trim() === model && mapping.to.trim() && mapping.to.trim() !== model) + if (conflict) { + appStore.showInfo(t('admin.accounts.modelMappingConflict', { from: model, to: conflict.to.trim() })) + return + } emit('update:modelValue', [...props.modelValue, model]) customModel.value = '' } diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 7b104410c1..92274571a6 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -70,6 +70,7 @@ const BaseDialogStub = defineComponent({ const ModelWhitelistSelectorStub = defineComponent({ name: 'ModelWhitelistSelector', props: { + modelMappings: { type: Array, default: () => [] }, modelValue: { type: Array, default: () => [] @@ -330,6 +331,24 @@ describe('EditAccountModal', () => { afterEach(() => vi.useRealTimers()) + it('passes existing non-identity mappings to the whitelist selector and preserves them on save', async () => { + const account = buildAccount() + account.credentials.model_mapping = { 'gpt-5.2': 'gpt-5.2', 'gpt-latest': 'deepseek-chat' } + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('modelMappings')).toEqual([ + { from: 'gpt-latest', to: 'deepseek-chat' } + ]) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual(account.credentials.model_mapping) + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true, account: { ...account } }) + expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('modelMappings')).toEqual([ + { from: 'gpt-latest', to: 'deepseek-chat' } + ]) + }) + it('sets expiry presets from now instead of extending the saved expiry', async () => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2028-02-29T12:34:00')) diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index 97667e3ee4..576df0d0b9 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -24,7 +24,7 @@ vi.mock('vue-i18n', async () => { return { ...actual, useI18n: () => ({ - t: (key: string) => (key === 'common.copy' ? '复制' : key) + t: (key: string, params?: Record) => key === 'common.copy' ? '复制' : key === 'admin.accounts.modelMappingConflict' ? `Model mapping conflict: ${params?.from} → ${params?.to}` : key }) } }) @@ -91,6 +91,37 @@ describe('ModelWhitelistSelector', () => { syncUpstreamModelsPreview.mockReset() }) + it('rejects a custom whitelist model that is already mapped to a different target', async () => { + const wrapper = mountSelector({ modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] }) + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue(' gpt-latest ') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(showInfo).toHaveBeenCalledWith(expect.stringContaining('gpt-latest → deepseek-chat')) + }) + + it('keeps the existing duplicate identity warning before checking mappings', async () => { + const wrapper = mountSelector({ modelValue: ['gpt-latest'], modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] }) + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('gpt-latest') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + expect(showInfo).toHaveBeenCalledWith('admin.accounts.modelExists') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('allows matching identity mapping as a whitelist model', async () => { + const wrapper = mountSelector({ modelMappings: [{ from: 'gpt-latest', to: 'gpt-latest' }] }) + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('gpt-latest') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-latest']]]) + }) + + it('still allows custom models without a mapping prop', async () => { + const wrapper = mountSelector() + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('custom-model') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[['custom-model']]]) + }) + it('copies a model ID without selecting the model', async () => { const wrapper = mountSelector() await wrapper.get('div.cursor-pointer').trigger('click') diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index bb73136e0a..ef61e6ab45 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -158,6 +158,19 @@ describe('useModelWhitelist', () => { }) }) + it('combined mode retains a mapping when a whitelist entry has the same source', () => { + expect(buildModelMappingObject('combined', ['gpt-latest'], [{ from: 'gpt-latest', to: 'deepseek-chat' }])).toEqual({ + 'gpt-latest': 'deepseek-chat' + }) + }) + + it('split mapping keeps only identity entries in the whitelist after reopening', () => { + expect(splitModelMappingObject({ 'gpt-latest': 'deepseek-chat', 'gpt-5.4': 'gpt-5.4' })).toEqual({ + allowedModels: ['gpt-5.4'], + modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] + }) + }) + it('splitModelMappingObject 会把身份映射还原成白名单,其余保留为映射', () => { const parsed = splitModelMappingObject({ 'gpt-5.4': 'gpt-5.4', diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 40fd7b6f0b..685ca6c62b 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -828,6 +828,7 @@ export default { enterCustomModelName: 'Enter custom model name', addModel: 'Add', modelExists: 'Model already exists', + modelMappingConflict: 'A mapping already exists for {from} → {to}. Modify or remove it under Model Mapping before adding this whitelist model', modelCount: '{count} models', poolMode: 'Pool Mode', poolModeHint: 'Enable when upstream is an account pool; errors won\'t mark local account status', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index bc374be293..d4309e9485 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -933,6 +933,7 @@ export default { enterCustomModelName: '输入自定义模型名称', addModel: '填入', modelExists: '该模型已存在', + modelMappingConflict: '该模型已配置映射 {from} → {to},请在模型映射中修改或删除后再添加白名单模型', modelCount: '{count} 个模型', poolMode: '池模式', poolModeHint: '上游为账号池时启用,错误不标记本地账号状态',