# 用户数据隔离方案评审报告 ## 结论 **方案大方向正确,但存在 3 个关键设计缺陷必须修正**,修正后可用。 --- ## 关键缺陷 ### ❌ 缺陷 1:前端传 userId 到 Rust 命令 — 安全漏洞 **方案原文:** > 方式 A(推荐):命令函数签名增加 `user_id: String` 参数 > 前端调用时传入 `user.id` **问题:** Tauri `#[tauri::command]` 的参数由前端传入,前端可以伪造任意 `userId`。 ```typescript // 恶意前端代码:A 用户传入 B 的 userId,即可读取 B 的本地数据 await invoke('load_voice_materials', { userId: 'B的userId' }) ``` **这完全破坏了隔离的安全性。** **修正方案(推荐方式 B):** Rust 命令从已认证的 `auth.json` 中读取当前 `user.id`,不接收前端传来的 `userId`: ```rust use tauri::State; // 全局状态存储当前用户 pub struct CurrentUser { pub id: std::sync::Mutex>, } #[tauri::command] pub async fn load_voice_materials( app: tauri::AppHandle, ) -> ApiResponse> { // 从 auth.json 读取已认证的 user.id let auth: Option = crate::storage::auth::load_auth_state(&app)?; let user_id = match auth.and_then(|a| a.user).map(|u| u.id) { Some(id) => id, None => return ApiResponse::err(401, "未登录"), }; let path = get_user_voices_json_path(&user_id)?; // ... } ``` 或更简洁:在 `lib.rs` 的 `setup` 中读取 `auth.json`,将 `user_id` 存入 `ManagedState`,所有命令通过 `app.state::()` 获取。 **前端完全不需要传 `userId`,Rust 层自治。** --- ### ❌ 缺陷 2:迁移用 `fs::rename`(move)— 不可逆且易崩溃 **方案原文:** ```rust fs::rename(&legacy_voices, user_dir.join("voices.json"))?; ``` **问题:** - `rename` 是原子操作,但**多个文件的迁移不是原子的** - 如果迁移 `voices.json` 后、迁移 `projects/` 前进程崩溃,会留下半迁移状态 - 不可逆,回滚困难 **修正方案:** ```rust pub fn migrate_legacy_data(data_dir: &Path, app: &AppHandle) -> Result<(), StorageError> { // 1. 检查迁移标记 let flag = data_dir.join(".migration_v1_done"); if flag.exists() { return Ok(()); } // 2. 检查是否有旧数据 let legacy_items = vec![ ("voices.json", false), ("cover_avatars.json", false), ("cover_avatars/", true), ("projects/", true), ]; let has_legacy = legacy_items.iter().any(|(name, _)| data_dir.join(name).exists()); if !has_legacy { // 无旧数据,直接写标记跳过 fs::write(&flag, "none")?; return Ok(()); } // 3. 获取当前登录用户 let auth: Option = crate::storage::auth::load_auth_state(app)?; let user_id = match auth.and_then(|a| a.user).map(|u| u.id) { Some(id) => id, None => { // 未登录但有旧数据:复制到 anonymous/,登录后再手动迁移 let anon_dir = data_dir.join("anonymous"); ensure_dir(&anon_dir)?; for (name, is_dir) in &legacy_items { let src = data_dir.join(name); if src.exists() { let dest = anon_dir.join(name); if *is_dir { copy_dir_all(&src, &dest)?; } else { fs::copy(&src, &dest)?; } } } fs::write(&flag, "anonymous")?; return Ok(()); } }; // 4. 创建用户目录,用 copy 而非 move let user_dir = data_dir.join("users").join(&user_id); ensure_dir(&user_dir)?; for (name, is_dir) in &legacy_items { let src = data_dir.join(name); let dest = user_dir.join(name); if src.exists() { if *is_dir { copy_dir_all(&src, &dest)?; } else { fs::copy(&src, &dest)?; } } } // 5. copy 成功后,再写标记,然后延迟清理旧数据 fs::write(&flag, user_id)?; // 可选:启动后台线程,延迟 7 天后删除旧数据 // 给用户一个回滚窗口 Ok(()) } ``` --- ### ⚠️ 缺陷 3:缺少切换用户时的热切换设计 **当前问题:** - 切换账号后,前端 Zustand store 中仍残留上一个用户的数据 - 需要页面刷新或重启应用才能加载新用户的数据 **修正方案:** 在 `authStore.logout()` 和 `authStore.login()` 中增加**数据刷新契约**: ```typescript // authStore.ts logout: async () => { // ... 原有逻辑 ... // 新增:清空所有用户相关的内存状态 useVoiceStore.getState().reset(); useProjectStore.getState().reset(); useAvatarStore.getState().reset(); // 通知 Rust 层清空缓存 await invoke('switch_user', { userId: null }); }, login: async (phone, code) => { // ... 原有登录逻辑 ... // 新增:通知 Rust 层切换数据目录 await invoke('switch_user', { userId: data.user.id }); // 重新加载当前用户的数据 await useVoiceStore.getState().loadMaterials(); await useProjectStore.getState().loadProjects(); }, ``` Rust 层 `switch_user` 命令: ```rust #[tauri::command] pub async fn switch_user( app: tauri::AppHandle, user_id: Option, ) -> Result<(), String> { // 更新 ManagedState 中的当前 user_id let state = app.state::(); let mut guard = state.id.lock().map_err(|e| e.to_string())?; *guard = user_id; Ok(()) } ``` --- ## 次要问题 ### ⚠️ `sanitize_id` 对 user_id 的兼容性 当前 `sanitize_id` 只允许 `a-zA-Z0-9_-`。测试确认: - UUID 格式(`550e8400-e29b-41d4-a716-446655440000`):✅ 通过 - 邮箱格式(`user@example.com`):❌ 失败 - 含点号(`user.name`):❌ 失败 **建议:** 确认后端 `user.id` 的格式。如果是 UUID,无需改动;如果是邮箱或其他格式,需要对 `user_id` 单独做路径净化(如用 `sanitize_filename` 或直接 base64/urlencode)。 ### ⚠️ `temp/` 目录也应隔离 当前 `extract_audio_from_video` 写到 `app_local_data_dir/temp/`,如果多用户并发操作会冲突。建议: ``` {app_local_data_dir}/temp/{user_id}/ ``` ### ⚠️ 全局 `config.json` 的拆分 当前 `config.json` 存的是 API 地址和环境配置(`production`/`debug`),属于设备级配置,保持全局合理。 但如果以后增加用户偏好(主题、音量、快捷键),需要拆分为: - `config.json` — 设备级(全局) - `users/{user_id}/preferences.json` — 用户级 --- ## 与主流规范对比 | 应用 | 隔离方式 | 说明 | |---|---|---| | **微信/QQ 桌面端** | ✅ 按账号隔离目录 | 每个账号独立数据目录,切换账号需重启 | | **Discord/Slack** | ❌ 不隔离 | 完全云端,本地只有缓存 | | **VS Code** | ⚠️ 按 workspace | 不严格按用户,但 settings.json 可放用户目录 | | **Figma** | ❌ 不隔离 | 完全云端 | | **Adobe Creative Cloud** | ✅ 按账号隔离 | 登录后数据按 Creative Cloud 账号隔离 | **本项目定位:** 本地创作工具,数据以本地为主,且支持多设备登录(手机号+验证码)。 **结论:** 按 `user_id` 隔离是**合理且必要的**,尤其在涉及**声音复刻素材**这种高敏感隐私数据时。 --- ## 修正后的工作量 | 模块 | 修正后工作量 | 说明 | |---|---|---| | `paths.rs` 改造 | 2h | 加 `users/{user_id}/` 前缀 | | `ManagedState` + `switch_user` | 2h | 新增当前用户状态管理 | | Rust 命令读 auth.json 获取 user_id | 4h | 替代前端传参方案 | | 前端 Store 增加 `reset()` + 登录后重载 | 2h | 热切换体验 | | 数据迁移(copy + 标记) | 3h | 安全迁移 | | 测试验证 | 2h | | | **总计** | **~15h** | 比原方案多 0.5h(主要在 State 管理) | --- ## 建议实施时机 **v1.8.0** 实施,趁数据量小、迁移成本低时做。