From ca4a0b1303b13ce3f22c7ee51953f36520ec8741 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=B0=8F=E9=B1=BC=E5=BC=80=E5=8F=91?=
Date: Thu, 4 Jun 2026 17:30:54 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E9=9A=94=E7=A6=BB=E3=80=81=E5=8A=A8=E6=80=81=E5=88=86=E8=BE=A8?=
=?UTF-8?q?=E7=8E=87=E3=80=81=E5=AD=97=E5=B9=95=E7=BC=A9=E6=94=BE=E3=80=81?=
=?UTF-8?q?=E5=A4=9A=E9=A1=B9=E4=BD=93=E9=AA=8C=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 用户数据隔离:所有用户数据按 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
---
docs/user-data-isolation-plan.md | 183 +++++++++++++
docs/user-data-isolation-review.md | 259 ++++++++++++++++++
scripts/admin-ops.sql | 6 +-
tauri-app/src-tauri/src/commands/asset.rs | 16 +-
.../src-tauri/src/commands/auth_state.rs | 21 +-
.../src-tauri/src/commands/cover_avatar.rs | 35 ++-
tauri-app/src-tauri/src/commands/product.rs | 92 ++++++-
tauri-app/src-tauri/src/commands/project.rs | 87 +++++-
.../src-tauri/src/commands/video_compose.rs | 138 ++++++++--
tauri-app/src-tauri/src/commands/voice.rs | 70 ++++-
tauri-app/src-tauri/src/ffmpeg_cmd.rs | 62 ++++-
tauri-app/src-tauri/src/lib.rs | 191 ++++++++++++-
tauri-app/src-tauri/src/storage/auth.rs | 13 +
.../src-tauri/src/storage/cover_avatar.rs | 23 +-
tauri-app/src-tauri/src/storage/mod.rs | 12 +-
tauri-app/src-tauri/src/storage/paths.rs | 127 ++++++---
tauri-app/src-tauri/src/storage/project.rs | 39 +--
tauri-app/src-tauri/src/storage/voice.rs | 23 +-
tauri-app/src-tauri/src/video_processing.rs | 6 +-
tauri-app/src/api/modules/localStorage.ts | 1 -
.../src/hooks/useCanvasSubtitleRenderer.ts | 8 +-
.../src/pages/VideoCreation/CoverDesign.tsx | 2 +-
.../pages/VideoCreation/SubtitleBurning.tsx | 88 ++++--
.../src/pages/VideoCreation/VideoCompose.tsx | 10 +-
.../pages/VideoCreation/VoiceSynthesis.tsx | 66 +++--
tauri-app/src/pages/VideoCreation/index.tsx | 1 -
.../_components/AvatarMaterialSelector.tsx | 2 +-
.../hooks/useEmptyShotMaterials.ts | 2 +-
.../VideoGeneration/utils/videoValidation.ts | 23 +-
tauri-app/src/store/authStore.ts | 23 ++
tauri-app/src/store/projectStore.ts | 19 +-
tauri-app/src/types/project.ts | 2 -
tauri-app/src/utils/assGenerator.ts | 24 +-
tauri-app/src/utils/projectMeta.ts | 1 -
34 files changed, 1419 insertions(+), 256 deletions(-)
create mode 100644 docs/user-data-isolation-plan.md
create mode 100644 docs/user-data-isolation-review.md
diff --git a/docs/user-data-isolation-plan.md b/docs/user-data-isolation-plan.md
new file mode 100644
index 0000000..da043b5
--- /dev/null
+++ b/docs/user-data-isolation-plan.md
@@ -0,0 +1,183 @@
+# 用户数据隔离改造方案
+
+## 背景
+
+当前所有本地数据存储在全局路径下,切换账号后 A 用户的数据对 B 用户可见。涉及隐私(音色素材)和商业数据(项目文件),需要按 `user_id` 隔离。
+
+## 目标
+
+- 按登录用户的 `user_id` 隔离本地数据
+- 未登录时使用匿名沙盒或禁止写入
+- 兼容旧数据,首次启动自动迁移
+
+## 改动范围
+
+### 1. Rust 层 — 路径改造(`src/storage/paths.rs`)
+
+新增用户隔离根目录,用户相关数据全部迁移:
+
+```
+{app_local_data_dir}/
+ users/
+ {user_id}/
+ voices.json ← 音色素材库
+ cover_avatars.json ← 封面头像库
+ projects/ ← 项目目录
+ config.json ← 用户级配置(可选)
+ bgm_cache/ ← 全局共享(缓存无隐私问题)
+ config.json ← 全局配置(窗口状态、偏好设置等)
+ temp/ ← 临时文件(定时清理)
+```
+
+**需要修改的函数:**
+
+| 当前路径 | 改造后 |
+|---|---|
+| `{data}/voices.json` | `{data}/users/{user_id}/voices.json` |
+| `{data}/cover_avatars.json` | `{data}/users/{user_id}/cover_avatars.json` |
+| `{data}/cover_avatars/` | `{data}/users/{user_id}/cover_avatars/` |
+| `{data}/projects/` | `{data}/users/{user_id}/projects/` |
+| `{data}/config.json` | 保持全局(或拆分为全局+用户级) |
+| `{data}/bgm_cache/` | 保持全局 |
+| `{config}/auth.json` | 保持全局(只存当前登录态) |
+
+**新增:**
+- `get_current_user_id() -> Option`:从全局状态或调用方获取当前 user_id
+- `get_user_data_dir(user_id: &str)`:返回用户隔离目录
+
+### 2. Rust 层 — 存储命令改造
+
+**受影响的命令文件:**
+- `src/commands/voice.rs` — load/save/delete voice materials
+- `src/commands/project.rs` — 项目 CRUD
+- `src/commands/avatar.rs` — 封面头像
+- `src/commands/config.rs` — 配置(决定全局 or 用户级)
+
+**改造方式:**
+
+方式 A(推荐):命令函数签名增加 `user_id: String` 参数
+```rust
+#[tauri::command]
+pub async fn load_voice_materials(user_id: String) -> ApiResponse> {
+ let path = get_user_voices_json_path(&user_id)?;
+ // ...
+}
+```
+
+方式 B:命令内部从 AppHandle 全局状态读取当前 user_id
+```rust
+let user_id = app.state::().id.clone();
+```
+
+**推荐方式 A**,前端调用时传入 `user.id`,减少 Rust 层对全局状态的依赖,更明确。
+
+### 3. 前端 — AuthStore 改造(`src/store/authStore.ts`)
+
+**新增:**
+- 登录成功后,写入一个全局标记 `CURRENT_USER_ID = user.id`
+- 登出/被踢时,清除该标记
+- 所有调用 Rust 存储命令的地方,带上 `userId` 参数
+
+**受影响的 Store:**
+- `voiceStore.ts` — `loadVoiceMaterials()`、`addVoiceMaterial()` 等需要传 `userId`
+- `projectStore.ts` — 项目操作需要传 `userId`
+- `avatarStore.ts` — 封面头像操作需要传 `userId`
+
+### 4. 前端 — API 调用改造
+
+所有 `invoke('xxx', { ... })` 调用用户相关命令时,增加 `userId`:
+
+```typescript
+// 改造前
+await invoke('load_voice_materials')
+
+// 改造后
+const userId = useAuthStore.getState().user?.id;
+await invoke('load_voice_materials', { userId })
+```
+
+### 5. 数据迁移策略
+
+**检测时机:** 应用启动时,`init_app_data_dir` 之后
+
+**迁移逻辑(Rust):**
+```rust
+pub fn migrate_legacy_data(data_dir: &Path) -> Result<(), StorageError> {
+ // 1. 检查是否存在旧数据(全局 voices.json / projects / cover_avatars.json)
+ let legacy_voices = data_dir.join("voices.json");
+ if !legacy_voices.exists() {
+ return Ok(()); // 无旧数据,跳过
+ }
+
+ // 2. 读取 auth.json 获取当前登录的 user_id
+ let auth_path = get_auth_state_path(app)?;
+ let auth: Option = read_json(&auth_path)?;
+ let user_id = match auth.and_then(|a| a.user).map(|u| u.id) {
+ Some(id) => id,
+ None => {
+ // 未登录但有旧数据:移到 anonymous/ 目录,提示用户登录后迁移
+ return move_to_anonymous(data_dir);
+ }
+ };
+
+ // 3. 创建用户目录并迁移
+ let user_dir = data_dir.join("users").join(&user_id);
+ ensure_dir(&user_dir)?;
+
+ // 迁移 voices.json
+ if legacy_voices.exists() {
+ fs::rename(&legacy_voices, user_dir.join("voices.json"))?;
+ }
+
+ // 迁移 cover_avatars.json + cover_avatars/
+ let legacy_avatars = data_dir.join("cover_avatars.json");
+ if legacy_avatars.exists() {
+ fs::rename(&legacy_avatars, user_dir.join("cover_avatars.json"))?;
+ }
+ let legacy_avatars_dir = data_dir.join("cover_avatars");
+ if legacy_avatars_dir.exists() {
+ fs::rename(&legacy_avatars_dir, user_dir.join("cover_avatars"))?;
+ }
+
+ // 迁移 projects/
+ let legacy_projects = data_dir.join("projects");
+ if legacy_projects.exists() {
+ fs::rename(&legacy_projects, user_dir.join("projects"))?;
+ }
+
+ // 4. 写迁移标记,避免重复迁移
+ let flag = data_dir.join(".migration_v1_done");
+ fs::write(&flag, "done")?;
+
+ Ok(())
+}
+```
+
+**迁移顺序:**
+1. 启动应用
+2. 初始化 `app_local_data_dir`
+3. 检测 `.migration_v1_done` 标记,不存在则执行迁移
+4. 迁移完成后继续正常启动
+
+## 工作量评估
+
+| 模块 | 文件数 | 预估工作量 |
+|---|---|---|
+| paths.rs 改造 | 1 | 2h |
+| Rust 存储命令加 user_id 参数 | ~8 个文件 | 4h |
+| lib.rs 注册新签名 | 1 | 0.5h |
+| 前端 Store 改造 | ~4 个文件 | 3h |
+| 数据迁移逻辑 | 1 个新文件 | 3h |
+| 测试验证 | — | 2h |
+| **总计** | | **~14.5h** |
+
+## 风险点
+
+1. **多设备登录同一账号**:数据只在本地隔离,不同设备间数据不互通。如果需要跨设备同步,需要后端支持。
+2. **匿名数据**:未登录时产生的数据(理论上目前不存在,因为功能都需要登录),需要决定是禁止未登录操作还是存到 `anonymous/` 目录。
+3. **回滚**:迁移是单向的(移动文件),回滚需要手动恢复。建议迁移前做备份副本而非直接 move。
+
+## 建议实施时机
+
+- **v1.8.0** 或 **v1.9.0** 版本中实施
+- 最好在素材库数据量还不大的早期做,迁移成本低
diff --git a/docs/user-data-isolation-review.md b/docs/user-data-isolation-review.md
new file mode 100644
index 0000000..bfceed8
--- /dev/null
+++ b/docs/user-data-isolation-review.md
@@ -0,0 +1,259 @@
+# 用户数据隔离方案评审报告
+
+## 结论
+
+**方案大方向正确,但存在 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
- 格式:MP4 / MOV · 时长:5-20秒 · 分辨率:1080×1920
+ 格式:MP4 / MOV · 时长:5-60秒 · 比例:9:16(720×1280 或 1080×1920)