- 用户数据隔离:所有用户数据按 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
7.9 KiB
用户数据隔离方案评审报告
结论
方案大方向正确,但存在 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.rs 的 setup 中读取 auth.json,将 user_id 存入 ManagedState,所有命令通过 app.state::<CurrentUser>() 获取。
前端完全不需要传 userId,Rust 层自治。
❌ 缺陷 2:迁移用 fs::rename(move)— 不可逆且易崩溃
方案原文:
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 实施,趁数据量小、迁移成本低时做。