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:
65
src/pages/SystemSettings.test.tsx
Normal file
65
src/pages/SystemSettings.test.tsx
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user