Remove legacy settings secret migration

- Remove the super-admin one-time migration from browser local settings into backend AI and speech settings.

- Delete the migration-specific SystemSettings unit test that no longer matches the desired behavior.

- Restore feature, module, testing, and progress docs so settings secrets are documented as backend-managed only.
This commit is contained in:
2026-05-02 02:26:21 +08:00
parent 8e0332b3cf
commit 8de3a12dc1
6 changed files with 2 additions and 140 deletions

View File

@@ -44,7 +44,7 @@
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`;需要有效 Provider 配置、模型和网络。 | | AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`;需要有效 Provider 配置、模型和网络。 |
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 | | AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、有效配置和网络。 | | 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、有效配置和网络。 |
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。超级管理员设置页可把旧本地缓存中的 AI/讯飞密钥一次性迁移到后端空配置中。 | | AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |
| 系统设置 | 真实集成 | `SystemSettings` 优先调用 `/api/settings/system` 读取、保存和重置抽帧、默认模板、AI Provider、语音配置只有开发/显式回退模式下 API 不可用才回退本地缓存。 | | 系统设置 | 真实集成 | `SystemSettings` 优先调用 `/api/settings/system` 读取、保存和重置抽帧、默认模板、AI Provider、语音配置只有开发/显式回退模式下 API 不可用才回退本地缓存。 |
| 审计日志查看 | 真实集成 | 超级管理员和管理员可进入审计日志页,调用 `GET /api/audit-logs` 查看登录、报告、模板、用户、部门、设置和文件等操作;管理员只看本部门或自己相关日志。 | | 审计日志查看 | 真实集成 | 超级管理员和管理员可进入审计日志页,调用 `GET /api/audit-logs` 查看登录、报告、模板、用户、部门、设置和文件等操作;管理员只看本部门或自己相关日志。 |
| Docker/Nginx 静态部署 | 真实可用 | 可构建静态文件并用 Nginx 托管 SPA。 | | Docker/Nginx 静态部署 | 真实可用 | 可构建静态文件并用 Nginx 托管 SPA。 |

View File

@@ -38,14 +38,10 @@
系统通过后端 `/api/ai/models` 测试连接和获取模型列表,通过 `/api/ai/chat` 在报告编辑器中生成内容。后端读取全局共用 Provider Key 并代理 OpenAI 兼容 `/models``/chat/completions`,普通用户读取设置时不会拿到 AI Key。 系统通过后端 `/api/ai/models` 测试连接和获取模型列表,通过 `/api/ai/chat` 在报告编辑器中生成内容。后端读取全局共用 Provider Key 并代理 OpenAI 兼容 `/models``/chat/completions`,普通用户读取设置时不会拿到 AI Key。
从旧前端 mock 版本迁移时,如果超级管理员浏览器的 `localStorage.systemSettings` 中仍保留旧 AI Key而后端全局配置为空设置页会在读取后端配置后把本地旧密钥合并到当前设置并自动写入后端。该逻辑只用于迁移已有浏览器缓存源码、文档和 Docker 配置仍不内置真实密钥。
## 讯飞语音配置 ## 讯飞语音配置
`xfSpeechConfig` 保存讯飞 WebSocket IAT 所需的 APPID、APIKey 和 APISecret。报告编辑器只连接本系统 `/api/speech/iat`,由后端读取这些配置、生成讯飞鉴权 URL 并转发音频和识别结果。普通用户读取设置时不会拿到 APIKey/APISecret。 `xfSpeechConfig` 保存讯飞 WebSocket IAT 所需的 APPID、APIKey 和 APISecret。报告编辑器只连接本系统 `/api/speech/iat`,由后端读取这些配置、生成讯飞鉴权 URL 并转发音频和识别结果。普通用户读取设置时不会拿到 APIKey/APISecret。
讯飞配置同样支持从旧本地 `xfSpeechConfig` 向后端 Settings API 做一次性迁移。迁移成功后,医生端语音听写只依赖后端代理和登录 Session。
## 默认模板 ## 默认模板
`defaultTemplate` 指向模板 ID。新建报告时如果当前用户可见该模板编辑器会自动加载对应模板内容否则回退到默认内置报告内容。 `defaultTemplate` 指向模板 ID。新建报告时如果当前用户可见该模板编辑器会自动加载对应模板内容否则回退到默认内置报告内容。

View File

@@ -72,4 +72,3 @@
| 2026-05-02 | 新增 `ReportMedia` 表和迁移,报告视频/关键帧引用从 `Report.metadata` 拆出。 | | 2026-05-02 | 新增 `ReportMedia` 表和迁移,报告视频/关键帧引用从 `Report.metadata` 拆出。 |
| 2026-05-02 | 新增 Dashboard API、数据库 Session Store、审计服务、HTML 白名单清洗、本地回退开关和 Docker 上传目录 volume清理 Gemini 旧依赖。 | | 2026-05-02 | 新增 Dashboard API、数据库 Session Store、审计服务、HTML 白名单清洗、本地回退开关和 Docker 上传目录 volume清理 Gemini 旧依赖。 |
| 2026-05-02 | 新增审计日志查询 API/页面、Auth Context 路由角色守卫,并把 Playwright E2E 改为真实后端 API seed。 | | 2026-05-02 | 新增审计日志查询 API/页面、Auth Context 路由角色守卫,并把 Playwright E2E 改为真实后端 API seed。 |
| 2026-05-02 | 系统设置页新增旧本地 AI/讯飞密钥向后端空配置的一次性迁移逻辑,避免后端化后直接提示密钥未配置。 |

View File

@@ -90,7 +90,6 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
| 后端模板兼容映射 | 已覆盖 | `server/src/templates/template.mapper.test.ts` | | 后端模板兼容映射 | 已覆盖 | `server/src/templates/template.mapper.test.ts` |
| 后端用户兼容映射 | 已覆盖 | `server/src/users/users.mapper.test.ts` | | 后端用户兼容映射 | 已覆盖 | `server/src/users/users.mapper.test.ts` |
| 后端系统设置 schema | 已覆盖 | `server/src/settings/settings.schemas.test.ts` | | 后端系统设置 schema | 已覆盖 | `server/src/settings/settings.schemas.test.ts` |
| 系统设置旧密钥迁移 | 已覆盖 | `src/pages/SystemSettings.test.tsx` 覆盖后端空配置时从旧本地缓存合并 AI Key 和讯飞配置,并避免覆盖已有后端密钥。 |
| 后端 AI 代理 schema | 已覆盖 | `server/src/ai/ai.schemas.test.ts` | | 后端 AI 代理 schema | 已覆盖 | `server/src/ai/ai.schemas.test.ts` |
| 后端语音代理首帧处理 | 已覆盖 | `server/src/speech/xf-frame.test.ts` | | 后端语音代理首帧处理 | 已覆盖 | `server/src/speech/xf-frame.test.ts` |
| 后端字段库 schema | 已覆盖 | `server/src/library/library.schemas.test.ts` | | 后端字段库 schema | 已覆盖 | `server/src/library/library.schemas.test.ts` |

View File

@@ -1,65 +0,0 @@
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,61 +35,6 @@ 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() { export default function SystemSettings() {
const navigate = useNavigate(); const navigate = useNavigate();
const [currentUser, setCurrentUser] = useState<User | null>(null); const [currentUser, setCurrentUser] = useState<User | null>(null);
@@ -183,21 +128,9 @@ export default function SystemSettings() {
}).catch(() => {}); }).catch(() => {});
void getSystemSettings().then((apiSettings) => { void getSystemSettings().then((apiSettings) => {
const normalizedApiSettings = normalizeSettings(apiSettings, savedTemplates); const next = normalizeSettings(apiSettings, savedTemplates);
const { settings: next, hasMergedSecrets } = user.role === 'super'
? mergeLocalSecretsIntoApiSettings(normalizedApiSettings, savedSettings)
: { settings: normalizedApiSettings, hasMergedSecrets: false };
setSettings(next); setSettings(next);
storage.set('systemSettings', 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(() => { }).catch(() => {
if (!isLocalFallbackEnabled()) { if (!isLocalFallbackEnabled()) {
setSettings(normalizeSettings({}, savedTemplates)); setSettings(normalizeSettings({}, savedTemplates));