ca4a0b1303
- 用户数据隔离:所有用户数据按 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
260 lines
7.9 KiB
Markdown
260 lines
7.9 KiB
Markdown
# 用户数据隔离方案评审报告
|
||
|
||
## 结论
|
||
|
||
**方案大方向正确,但存在 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<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)— 不可逆且易崩溃
|
||
|
||
**方案原文:**
|
||
```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<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()` 中增加**数据刷新契约**:
|
||
|
||
```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<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** 实施,趁数据量小、迁移成本低时做。
|