fix(account): prevent whitelist model mapping conflicts

This commit is contained in:
wucm667
2026-09-23 17:47:47 +08:00
parent a3eb7ef302
commit e2c35cf394
9 changed files with 80 additions and 5 deletions
@@ -343,6 +343,7 @@
<ModelWhitelistSelector
v-model="allowedModels"
:model-mappings="modelMappings"
:platforms="targetSelectedPlatforms"
/>
@@ -1518,6 +1518,7 @@
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector
v-model="allowedModels"
:model-mappings="modelMappings"
:platform="form.platform"
:sync-credentials="syncPreviewCredentials"
@upstream-synced="upstreamModelsPreviewed = true"
@@ -2005,6 +2006,7 @@
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector
v-model="allowedModels"
:model-mappings="modelMappings"
platform="anthropic"
:sync-credentials="syncPreviewCredentials"
@upstream-synced="upstreamModelsPreviewed = true"
@@ -2346,6 +2348,7 @@
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector
v-model="allowedModels"
:model-mappings="modelMappings"
:platform="form.platform"
:sync-credentials="syncPreviewCredentials"
@upstream-synced="upstreamModelsPreviewed = true"
@@ -299,7 +299,7 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
<ModelWhitelistSelector v-model="allowedModels" :model-mappings="modelMappings" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
@@ -779,7 +779,7 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
<ModelWhitelistSelector v-model="allowedModels" :model-mappings="modelMappings" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
@@ -991,7 +991,7 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
<ModelWhitelistSelector v-model="allowedModels" :model-mappings="modelMappings" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
@@ -1213,7 +1213,7 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" />
<ModelWhitelistSelector v-model="allowedModels" :model-mappings="modelMappings" platform="anthropic" />
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
@@ -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 = ''
}
@@ -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'))
@@ -24,7 +24,7 @@ vi.mock('vue-i18n', async () => {
return {
...actual,
useI18n: () => ({
t: (key: string) => (key === 'common.copy' ? '复制' : key)
t: (key: string, params?: Record<string, string>) => 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')
@@ -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',
@@ -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',
@@ -933,6 +933,7 @@ export default {
enterCustomModelName: '输入自定义模型名称',
addModel: '填入',
modelExists: '该模型已存在',
modelMappingConflict: '该模型已配置映射 {from} → {to},请在模型映射中修改或删除后再添加白名单模型',
modelCount: '{count} 个模型',
poolMode: '池模式',
poolModeHint: '上游为账号池时启用,错误不标记本地账号状态',