Files
meijiaka-zy/docs/user-data-isolation-review.md
T
小鱼开发 ca4a0b1303 feat: 用户数据隔离、动态分辨率、字幕缩放、多项体验优化
- 用户数据隔离:所有用户数据按 users/{user_id}/ 隔离,Rust IPC 命令自治读取 auth.json
- 安全加固:delete_local_product/rename_local_product/export_product 增加前缀校验
- 移除音调(pitch)功能:从 VoiceSynthesis、projectStore、types 等完全移除
- 动态视频分辨率:根据素材最小高度自动选择 720p/1080p,9:16 比例强校验
- ASS 字幕按目标分辨率等比例缩放(720p 和 1080p 视觉一致)
- Canvas 预览支持参数化 playResY,预览与压制效果一致
- 配音合成增加台词字数校验弹窗(语速>1.0时要求更多字)
- BGM 默认音量从 25% 调至 15%
- 素材选择提示文案更新(9:16 比例,5-60秒)
- 视频校验从严格 1080x1920 改为 9:16 比例判断
- 背景图片弹窗宽度从 440px 放大到 560px
2026-06-04 17:30:54 +08:00

7.9 KiB
Raw Blame History

用户数据隔离方案评审报告

结论

方案大方向正确,但存在 3 个关键设计缺陷必须修正,修正后可用。


关键缺陷

缺陷 1:前端传 userId 到 Rust 命令 — 安全漏洞

方案原文:

方式 A(推荐):命令函数签名增加 user_id: String 参数 前端调用时传入 user.id

问题: Tauri #[tauri::command] 的参数由前端传入,前端可以伪造任意 userId

// 恶意前端代码:A 用户传入 B 的 userId,即可读取 B 的本地数据
await invoke('load_voice_materials', { userId: 'B的userId' })

这完全破坏了隔离的安全性。

修正方案(推荐方式 B):

Rust 命令从已认证的 auth.json 中读取当前 user.id,不接收前端传来的 userId

use tauri::State;

// 全局状态存储当前用户
pub struct CurrentUser {
    pub id: std::sync::Mutex<Option<String>>,
}

#[tauri::command]
pub async fn load_voice_materials(
    app: tauri::AppHandle,
) -> ApiResponse<Vec<VoiceMaterial>> {
    // 从 auth.json 读取已认证的 user.id
    let auth: Option<AuthState> = 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.rssetup 中读取 auth.json,将 user_id 存入 ManagedState,所有命令通过 app.state::<CurrentUser>() 获取。

前端完全不需要传 userIdRust 层自治。


缺陷 2:迁移用 fs::renamemove)— 不可逆且易崩溃

方案原文:

fs::rename(&legacy_voices, user_dir.join("voices.json"))?;

问题:

  • rename 是原子操作,但多个文件的迁移不是原子的
  • 如果迁移 voices.json 后、迁移 projects/ 前进程崩溃,会留下半迁移状态
  • 不可逆,回滚困难

修正方案:

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<AuthState> = 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() 中增加数据刷新契约

// 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 命令:

#[tauri::command]
pub async fn switch_user(
    app: tauri::AppHandle,
    user_id: Option<String>,
) -> Result<(), String> {
    // 更新 ManagedState 中的当前 user_id
    let state = app.state::<CurrentUser>();
    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 实施,趁数据量小、迁移成本低时做。