Migrate legacy AI and speech settings to backend

- Add a super-admin-only settings merge that copies legacy local AI provider keys into an empty backend settings record.

- Add matching migration for legacy Xunfei APPID, APIKey, and APISecret without overwriting existing backend secrets.

- Cover the migration and secret-preservation behavior with SystemSettings unit tests.

- Update feature, module, progress, and testing docs for the settings migration behavior.
This commit is contained in:
2026-05-02 02:22:26 +08:00
parent 7c6449b7bd
commit 8e0332b3cf
6 changed files with 140 additions and 2 deletions

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import type { SystemSettings } from '../types';
import { mergeLocalSecretsIntoApiSettings } from './SystemSettings';
const baseSettings: SystemSettings = {
frameCount: 12,
framePositions: [10, 20],
defaultTemplate: '',
frameMode: 'keep',
activeAiProvider: 'kimi',
aiProviders: {
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k-vision-preview' },
custom: { endpoint: '', apiKey: '', modelName: '' },
},
xfSpeechConfig: { appId: '', apiKey: '', apiSecret: '' },
};
describe('SystemSettings secret migration', () => {
it('fills missing backend AI and speech secrets from legacy local settings', () => {
const result = mergeLocalSecretsIntoApiSettings(baseSettings, {
activeAiProvider: 'custom',
aiProviders: {
custom: { endpoint: 'https://ai.example.test/v1', apiKey: 'local-ai-key', modelName: 'local-model' },
},
xfSpeechConfig: { appId: 'local-appid', apiKey: 'local-xf-key', apiSecret: 'local-xf-secret' },
});
expect(result.hasMergedSecrets).toBe(true);
expect(result.settings.activeAiProvider).toBe('custom');
expect(result.settings.aiProviders.custom).toEqual({
endpoint: 'https://ai.example.test/v1',
apiKey: 'local-ai-key',
modelName: 'local-model',
});
expect(result.settings.xfSpeechConfig).toEqual({
appId: 'local-appid',
apiKey: 'local-xf-key',
apiSecret: 'local-xf-secret',
});
});
it('does not replace existing backend secrets with local settings', () => {
const result = mergeLocalSecretsIntoApiSettings({
...baseSettings,
aiProviders: {
...baseSettings.aiProviders,
kimi: { ...baseSettings.aiProviders.kimi, apiKey: 'backend-ai-key' },
},
xfSpeechConfig: { appId: 'backend-appid', apiKey: 'backend-xf-key', apiSecret: 'backend-xf-secret' },
}, {
aiProviders: {
kimi: { endpoint: 'https://local.example.test/v1', apiKey: 'local-ai-key', modelName: 'local-model' },
},
xfSpeechConfig: { appId: 'local-appid', apiKey: 'local-xf-key', apiSecret: 'local-xf-secret' },
});
expect(result.hasMergedSecrets).toBe(false);
expect(result.settings.aiProviders.kimi.apiKey).toBe('backend-ai-key');
expect(result.settings.xfSpeechConfig).toEqual({
appId: 'backend-appid',
apiKey: 'backend-xf-key',
apiSecret: 'backend-xf-secret',
});
});
});

View File

@@ -35,6 +35,61 @@ const normalizeSettings = (
};
};
const hasValue = (value: unknown) => typeof value === 'string' && value.trim().length > 0;
export const mergeLocalSecretsIntoApiSettings = (
apiSettings: ISystemSettings & { frameMode?: 'uniform' | 'keep' },
localSettings: Partial<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>,
) => {
let hasMergedSecrets = false;
const localProviders = localSettings.aiProviders || {};
const aiProviders = { ...apiSettings.aiProviders };
for (const [providerKey, localProvider] of Object.entries(localProviders)) {
if (!localProvider || !hasValue(localProvider.apiKey)) continue;
const apiProvider = aiProviders[providerKey];
if (hasValue(apiProvider?.apiKey)) continue;
aiProviders[providerKey] = {
endpoint: localProvider.endpoint || apiProvider?.endpoint || '',
apiKey: localProvider.apiKey,
modelName: localProvider.modelName || apiProvider?.modelName || '',
};
hasMergedSecrets = true;
}
const activeAiProvider = hasValue(localProviders[localSettings.activeAiProvider || '']?.apiKey)
&& !hasValue(aiProviders[apiSettings.activeAiProvider]?.apiKey)
? localSettings.activeAiProvider || apiSettings.activeAiProvider
: apiSettings.activeAiProvider;
const localSpeech = localSettings.xfSpeechConfig;
const apiSpeech = apiSettings.xfSpeechConfig || { appId: '', apiKey: '', apiSecret: '' };
const xfSpeechConfig = {
appId: hasValue(apiSpeech.appId) ? apiSpeech.appId : localSpeech?.appId || '',
apiKey: hasValue(apiSpeech.apiKey) ? apiSpeech.apiKey : localSpeech?.apiKey || '',
apiSecret: hasValue(apiSpeech.apiSecret) ? apiSpeech.apiSecret : localSpeech?.apiSecret || '',
};
if (
(!hasValue(apiSpeech.appId) && hasValue(localSpeech?.appId))
|| (!hasValue(apiSpeech.apiKey) && hasValue(localSpeech?.apiKey))
|| (!hasValue(apiSpeech.apiSecret) && hasValue(localSpeech?.apiSecret))
) {
hasMergedSecrets = true;
}
return {
settings: {
...apiSettings,
activeAiProvider,
aiProviders,
xfSpeechConfig,
},
hasMergedSecrets,
};
};
export default function SystemSettings() {
const navigate = useNavigate();
const [currentUser, setCurrentUser] = useState<User | null>(null);
@@ -128,9 +183,21 @@ export default function SystemSettings() {
}).catch(() => {});
void getSystemSettings().then((apiSettings) => {
const next = normalizeSettings(apiSettings, savedTemplates);
const normalizedApiSettings = normalizeSettings(apiSettings, savedTemplates);
const { settings: next, hasMergedSecrets } = user.role === 'super'
? mergeLocalSecretsIntoApiSettings(normalizedApiSettings, savedSettings)
: { settings: normalizedApiSettings, hasMergedSecrets: false };
setSettings(next);
storage.set('systemSettings', next);
if (hasMergedSecrets) {
void updateSystemSettings(next).then((savedSettingsFromApi) => {
const normalized = normalizeSettings(savedSettingsFromApi, savedTemplates);
setSettings(normalized);
storage.set('systemSettings', normalized);
}).catch((error) => {
console.warn('Legacy local AI/speech settings migration failed.', error);
});
}
}).catch(() => {
if (!isLocalFallbackEnabled()) {
setSettings(normalizeSettings({}, savedTemplates));