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>, +} + +#[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** 实施,趁数据量小、迁移成本低时做。 diff --git a/scripts/admin-ops.sql b/scripts/admin-ops.sql index e0f90ad..f71ac52 100644 --- a/scripts/admin-ops.sql +++ b/scripts/admin-ops.sql @@ -11,11 +11,11 @@ DO $$ DECLARE - v_mobile TEXT := '18860016386'; -- ← 修改:手机号 - v_nickname TEXT := '18860016386'; -- ← 修改:昵称(可为空) + v_mobile TEXT := '15359215971'; -- ← 修改:手机号 + v_nickname TEXT := '家迪装饰'; -- ← 修改:昵称(可为空) v_source TEXT := 'manual'; -- ← 修改:注册来源:manual / invite / promotion v_invited_by UUID := NULL; -- ← 修改:邀请人 user_id(没有则留 NULL) - v_gift_points INT := 2000; -- ← 修改:赠送初始积分(0 表示不赠送) + v_gift_points INT := 10000; -- ← 修改:赠送初始积分(0 表示不赠送) v_gift_days INT := 365; -- ← 修改:赠送积分有效期(天) v_user_id UUID; v_batch_id BIGINT; diff --git a/tauri-app/src-tauri/src/commands/asset.rs b/tauri-app/src-tauri/src/commands/asset.rs index 8a5477d..cc3020a 100644 --- a/tauri-app/src-tauri/src/commands/asset.rs +++ b/tauri-app/src-tauri/src/commands/asset.rs @@ -6,11 +6,17 @@ use crate::storage::project as project_storage; #[tauri::command] pub async fn save_project_asset( + app: tauri::AppHandle, project_id: String, filename: String, base64_data: String, ) -> ApiResponse { - match project_storage::save_project_asset(&project_id, &filename, &base64_data).map_err_string() { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None }, + }; + match project_storage::save_project_asset(&user_id, &project_id, &filename, &base64_data).map_err_string() { Ok(path) => ApiResponse { code: 200, message: "资源保存成功".to_string(), @@ -26,10 +32,16 @@ pub async fn save_project_asset( #[tauri::command] pub async fn get_video_save_path( + app: tauri::AppHandle, project_id: String, filename: String, ) -> ApiResponse { - match project_storage::get_video_save_path(&project_id, &filename).map_err_string() { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None }, + }; + match project_storage::get_video_save_path(&user_id, &project_id, &filename).map_err_string() { Ok(path) => ApiResponse { code: 200, message: "获取路径成功".to_string(), diff --git a/tauri-app/src-tauri/src/commands/auth_state.rs b/tauri-app/src-tauri/src/commands/auth_state.rs index fa247cb..25fe8c2 100644 --- a/tauri-app/src-tauri/src/commands/auth_state.rs +++ b/tauri-app/src-tauri/src/commands/auth_state.rs @@ -3,6 +3,7 @@ use crate::ApiResponse; use crate::StringResultExt; use crate::storage::auth as auth_storage; +use tauri::Manager; #[tauri::command] pub async fn load_auth_state(app: tauri::AppHandle) -> ApiResponse> { @@ -26,11 +27,21 @@ pub async fn save_auth_state( state: serde_json::Value, ) -> ApiResponse { match auth_storage::save_auth_state(&app, &state).map_err_string() { - Ok(_) => ApiResponse { - code: 200, - message: "Auth state saved successfully".to_string(), - data: Some(true), - }, + Ok(_) => { + // 保存 auth 成功后,检查是否需要迁移旧全局数据 + // 场景:用户在退出状态下升级应用,setup 阶段跳过迁移; + // 重新登录时触发迁移,确保旧数据正确归入当前用户目录 + if let Ok(data_dir) = app.path().app_local_data_dir() { + if let Err(e) = crate::migrate_legacy_data(&data_dir, &app) { + eprintln!("[save_auth_state] 数据迁移失败: {}", e); + } + } + ApiResponse { + code: 200, + message: "Auth state saved successfully".to_string(), + data: Some(true), + } + } Err(e) => ApiResponse { code: 500, message: format!("Failed to save auth state: {}", e), diff --git a/tauri-app/src-tauri/src/commands/cover_avatar.rs b/tauri-app/src-tauri/src/commands/cover_avatar.rs index 87c73b2..cb7ab98 100644 --- a/tauri-app/src-tauri/src/commands/cover_avatar.rs +++ b/tauri-app/src-tauri/src/commands/cover_avatar.rs @@ -17,8 +17,15 @@ pub struct CoverAvatarArgs { /// 加载封面形象库 #[tauri::command] -pub async fn load_cover_avatars() -> ApiResponse> { - match cover_avatar_storage::load_cover_avatars() { +pub async fn load_cover_avatars( + app: tauri::AppHandle, +) -> ApiResponse> { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(vec![]) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(vec![]) }, + }; + match cover_avatar_storage::load_cover_avatars(&user_id) { Ok(list) => ApiResponse { code: 200, message: "封面形象库加载成功".to_string(), @@ -35,8 +42,14 @@ pub async fn load_cover_avatars() -> ApiResponse ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) }, + }; let avatar = cover_avatar_storage::CoverAvatar { id: args.id, name: args.name, @@ -44,7 +57,7 @@ pub async fn save_cover_avatar( local_path: args.local_path, created_at: args.created_at, }; - match cover_avatar_storage::add_cover_avatar(avatar) { + match cover_avatar_storage::add_cover_avatar(&user_id, avatar) { Ok(_) => ApiResponse { code: 200, message: "封面形象保存成功".to_string(), @@ -61,9 +74,15 @@ pub async fn save_cover_avatar( /// 删除封面形象 #[tauri::command] pub async fn delete_cover_avatar_cmd( + app: tauri::AppHandle, id: String, ) -> ApiResponse { - match cover_avatar_storage::delete_cover_avatar(&id) { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) }, + }; + match cover_avatar_storage::delete_cover_avatar(&user_id, &id) { Ok(_) => ApiResponse { code: 200, message: "封面形象删除成功".to_string(), @@ -90,8 +109,15 @@ pub struct SaveCoverAvatarImageArgs { /// 保存封面形象图片文件(前端传入 base64 编码) #[tauri::command] pub async fn save_cover_avatar_image( + app: tauri::AppHandle, args: SaveCoverAvatarImageArgs, ) -> ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None }, + }; + let image_bytes = match base64::Engine::decode( &base64::engine::general_purpose::STANDARD, &args.image_data, @@ -105,6 +131,7 @@ pub async fn save_cover_avatar_image( }; match cover_avatar_storage::save_cover_avatar_image( + &user_id, &args.avatar_id, &image_bytes, &args.ext, diff --git a/tauri-app/src-tauri/src/commands/product.rs b/tauri-app/src-tauri/src/commands/product.rs index 18ebfa8..a27748c 100644 --- a/tauri-app/src-tauri/src/commands/product.rs +++ b/tauri-app/src-tauri/src/commands/product.rs @@ -23,8 +23,17 @@ pub struct ProductItem { /// 获取成品保存路径(项目成品目录) #[tauri::command] -pub async fn get_product_save_path(project_id: String, filename: String) -> ApiResponse { - match get_project_products_dir(&project_id) { +pub async fn get_product_save_path( + app: tauri::AppHandle, + project_id: String, + filename: String, +) -> ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None }, + }; + match get_project_products_dir(&user_id, &project_id) { Ok(products_dir) => { let safe_filename = match sanitize_filename(&filename) { Ok(name) => name, @@ -52,8 +61,13 @@ pub async fn get_product_save_path(project_id: String, filename: String) -> ApiR } #[tauri::command] -pub async fn list_local_products(_app: AppHandle) -> ApiResponse> { - let projects_root = match crate::storage::get_projects_root_dir() { +pub async fn list_local_products(app: AppHandle) -> ApiResponse> { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(vec![]) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(vec![]) }, + }; + let projects_root = match crate::storage::get_projects_root_dir(&user_id) { Ok(dir) => dir, Err(e) => return ApiResponse { code: 500, @@ -191,10 +205,19 @@ pub async fn list_local_products(_app: AppHandle) -> ApiResponse ApiResponse<()> { +pub async fn delete_local_product( + app: tauri::AppHandle, + path: String, +) -> ApiResponse<()> { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None }, + }; + let target = PathBuf::from(&path); - // 安全检查:确保路径在应用数据目录内 + // 安全检查:确保路径在应用数据目录内,且在当前用户目录下 let canonical = match target.canonicalize() { Ok(p) => p, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { @@ -229,6 +252,17 @@ pub async fn delete_local_product(path: String) -> ApiResponse<()> { }; } + // 用户隔离检查:路径必须在当前用户的 users/{id}/ 目录下 + let user_prefix = app_data.join("users").join(&user_id); + let user_prefix_canonical = user_prefix.canonicalize().unwrap_or_else(|_| user_prefix.clone()); + if !canonical.starts_with(&user_prefix_canonical) { + return ApiResponse { + code: 403, + message: "无权操作其他用户的数据".to_string(), + data: None, + }; + } + if target.exists() && target.is_file() { match fs::remove_file(&target) { Ok(_) => ApiResponse { @@ -252,7 +286,17 @@ pub async fn delete_local_product(path: String) -> ApiResponse<()> { } #[tauri::command] -pub async fn rename_local_product(path: String, new_filename: String) -> ApiResponse<()> { +pub async fn rename_local_product( + app: tauri::AppHandle, + path: String, + new_filename: String, +) -> ApiResponse<()> { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None }, + }; + let old_path = PathBuf::from(&path); // 安全检查 @@ -290,6 +334,17 @@ pub async fn rename_local_product(path: String, new_filename: String) -> ApiResp }; } + // 用户隔离检查 + let user_prefix = app_data.join("users").join(&user_id); + let user_prefix_canonical = user_prefix.canonicalize().unwrap_or_else(|_| user_prefix.clone()); + if !canonical.starts_with(&user_prefix_canonical) { + return ApiResponse { + code: 403, + message: "无权操作其他用户的数据".to_string(), + data: None, + }; + } + let parent = match old_path.parent() { Some(p) => p.to_path_buf(), None => return ApiResponse { @@ -339,7 +394,17 @@ pub async fn rename_local_product(path: String, new_filename: String) -> ApiResp /// 导出成品到用户指定位置 #[tauri::command] -pub async fn export_product(source_path: String, target_path: String) -> ApiResponse { +pub async fn export_product( + app: tauri::AppHandle, + source_path: String, + target_path: String, +) -> ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None }, + }; + let source = PathBuf::from(&source_path); let target = PathBuf::from(&target_path); @@ -371,6 +436,17 @@ pub async fn export_product(source_path: String, target_path: String) -> ApiResp }; } + // 用户隔离检查:source 必须在当前用户目录下 + let user_prefix = app_data.join("users").join(&user_id); + let user_prefix_canonical = user_prefix.canonicalize().unwrap_or_else(|_| user_prefix.clone()); + if !source_canonical.starts_with(&user_prefix_canonical) { + return ApiResponse { + code: 403, + message: "无权导出其他用户的数据".to_string(), + data: None, + }; + } + if !source.exists() || !source.is_file() { return ApiResponse { code: 404, diff --git a/tauri-app/src-tauri/src/commands/project.rs b/tauri-app/src-tauri/src/commands/project.rs index c9456ac..3e790b2 100644 --- a/tauri-app/src-tauri/src/commands/project.rs +++ b/tauri-app/src-tauri/src/commands/project.rs @@ -9,8 +9,17 @@ use crate::storage::project as project_storage; // ============================================================ #[tauri::command] -pub async fn save_project_meta_raw(project_id: String, json_content: String) -> ApiResponse { - match project_storage::save_project_meta_raw(&project_id, &json_content).map_err_string() { +pub async fn save_project_meta_raw( + app: tauri::AppHandle, + project_id: String, + json_content: String, +) -> ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) }, + }; + match project_storage::save_project_meta_raw(&user_id, &project_id, &json_content).map_err_string() { Ok(_) => ApiResponse { code: 200, message: "Project saved successfully".to_string(), @@ -25,8 +34,16 @@ pub async fn save_project_meta_raw(project_id: String, json_content: String) -> } #[tauri::command] -pub async fn load_project_meta(project_id: String) -> ApiResponse { - match project_storage::load_project_meta(&project_id).map_err_string() { +pub async fn load_project_meta( + app: tauri::AppHandle, + project_id: String, +) -> ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(serde_json::Value::Null) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(serde_json::Value::Null) }, + }; + match project_storage::load_project_meta(&user_id, &project_id).map_err_string() { Ok(data) => ApiResponse { code: 200, message: "Project loaded successfully".to_string(), @@ -41,8 +58,17 @@ pub async fn load_project_meta(project_id: String) -> ApiResponse ApiResponse { - match project_storage::save_project_segments_raw(&project_id, &json_content).map_err_string() { +pub async fn save_project_segments_raw( + app: tauri::AppHandle, + project_id: String, + json_content: String, +) -> ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) }, + }; + match project_storage::save_project_segments_raw(&user_id, &project_id, &json_content).map_err_string() { Ok(_) => ApiResponse { code: 200, message: "Segments saved successfully".to_string(), @@ -57,8 +83,16 @@ pub async fn save_project_segments_raw(project_id: String, json_content: String) } #[tauri::command] -pub async fn load_project_segments(project_id: String) -> ApiResponse { - match project_storage::load_project_segments(&project_id).map_err_string() { +pub async fn load_project_segments( + app: tauri::AppHandle, + project_id: String, +) -> ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(serde_json::Value::Array(vec![])) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(serde_json::Value::Array(vec![])) }, + }; + match project_storage::load_project_segments(&user_id, &project_id).map_err_string() { Ok(data) => ApiResponse { code: 200, message: "Segments loaded successfully".to_string(), @@ -73,8 +107,15 @@ pub async fn load_project_segments(project_id: String) -> ApiResponse ApiResponse> { - match project_storage::list_projects().map_err_string() { +pub async fn list_local_projects( + app: tauri::AppHandle, +) -> ApiResponse> { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(vec![]) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(vec![]) }, + }; + match project_storage::list_projects(&user_id).map_err_string() { Ok(projects) => ApiResponse { code: 200, message: "Projects listed successfully".to_string(), @@ -89,8 +130,16 @@ pub async fn list_local_projects() -> ApiResponse> { } #[tauri::command] -pub async fn delete_local_project(project_id: String) -> ApiResponse { - match project_storage::delete_project(&project_id).map_err_string() { +pub async fn delete_local_project( + app: tauri::AppHandle, + project_id: String, +) -> ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) }, + }; + match project_storage::delete_project(&user_id, &project_id).map_err_string() { Ok(_) => ApiResponse { code: 200, message: "Project deleted successfully".to_string(), @@ -105,10 +154,20 @@ pub async fn delete_local_project(project_id: String) -> ApiResponse { } #[tauri::command] -pub async fn delete_project_file(project_id: String, file_path: String) -> ApiResponse { +pub async fn delete_project_file( + app: tauri::AppHandle, + project_id: String, + file_path: String, +) -> ApiResponse { use crate::storage::get_project_dir_path; - let project_dir: std::path::PathBuf = match get_project_dir_path(&project_id) { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) }, + Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) }, + }; + + let project_dir: std::path::PathBuf = match get_project_dir_path(&user_id, &project_id) { Ok(d) => d, Err(e) => return ApiResponse { code: 500, diff --git a/tauri-app/src-tauri/src/commands/video_compose.rs b/tauri-app/src-tauri/src/commands/video_compose.rs index 28f8110..415530b 100644 --- a/tauri-app/src-tauri/src/commands/video_compose.rs +++ b/tauri-app/src-tauri/src/commands/video_compose.rs @@ -28,21 +28,55 @@ pub struct UploadResult { /// 兼容旧命名 pub type UploadVideoResult = UploadResult; -/// 获取项目视频目录 -fn get_project_video_dir(project_id: &str) -> Result { - crate::storage::get_project_videos_dir(project_id) +/// 获取项目视频目录(内部辅助函数,从 auth.json 读取当前用户) +fn get_project_video_dir(app: &AppHandle, project_id: &str) -> Result { + let user_id = match crate::storage::auth::get_current_user_id(app) { + Ok(Some(id)) => id, + Ok(None) => return Err("未登录".to_string()), + Err(e) => return Err(format!("获取用户信息失败: {}", e)), + }; + crate::storage::get_project_videos_dir(&user_id, project_id) .map_err(|e| format!("获取项目视频目录失败: {}", e)) } +/// 校验视频比例是否为 9:16(允许 1% 误差) +fn validate_nine_sixteen_aspect(width: u32, height: u32) -> Result<(), String> { + if width == 0 || height == 0 { + return Err("无法读取视频分辨率".to_string()); + } + let ratio = width as f64 / height as f64; + let expected = 9.0 / 16.0; + if (ratio - expected).abs() > 0.01 { + return Err(format!( + "视频比例必须是 9:16,当前为 {}×{}(比例 {:.3})", + width, height, ratio + )); + } + Ok(()) +} -/// 直接拼接已标准化的视频片段(不做裁剪,只做 concat) +/// 根据素材最小高度决定目标输出分辨率 +fn resolve_target_resolution(heights: &[u32]) -> Option<(u32, u32)> { + let min_height = heights.iter().min()?; + if *min_height <= 1280 { + Some((720, 1280)) + } else { + Some((1080, 1920)) + } +} + +/// 拼接视频片段 +/// +/// 1. 探测所有片段分辨率并校验 9:16 比例 +/// 2. 按最小高度决定目标输出分辨率 +/// 3. 统一标准化后拼接 #[tauri::command] pub async fn concat_video_clips( app: AppHandle, project_id: String, clip_paths: Vec, ) -> ApiResponse { - let video_dir = match get_project_video_dir(&project_id) { + let video_dir = match get_project_video_dir(&app, &project_id) { Ok(dir) => dir, Err(e) => { return ApiResponse { @@ -58,29 +92,49 @@ pub async fn concat_video_clips( .unwrap() .as_millis(); - // 生成 concat 列表文件 - let list_path = video_dir.join(format!("concat_list_{}.txt", timestamp)); - let mut list_content = String::new(); + // 1. 探测所有片段分辨率并校验 9:16 + let mut heights = Vec::new(); for path in &clip_paths { - list_content.push_str(&format!("file '{}'\n", ffmpeg_cmd::escape_ffmpeg_path(path))); - } - if let Err(e) = std::fs::write(&list_path, list_content) { - return ApiResponse { - code: 500, - message: format!("创建拼接列表失败: {}", e), - data: None, - }; + match ffmpeg_cmd::get_video_metadata(&app, path).await { + Ok(meta) => { + if let Err(e) = validate_nine_sixteen_aspect(meta.width, meta.height) { + return ApiResponse { code: 400, message: e, data: None }; + } + heights.push(meta.height); + } + Err(e) => { + return ApiResponse { + code: 500, + message: format!("读取视频元数据失败: {}", e), + data: None, + }; + } + } } - // 执行拼接(所有片段已标准化,编码一致,直接用 copy) + let (target_width, target_height) = match resolve_target_resolution(&heights) { + Some(res) => res, + None => { + return ApiResponse { + code: 400, + message: "没有可拼接的视频片段".to_string(), + data: None, + }; + } + }; + + // 2. 执行拼接(内部会先标准化到目标分辨率) let output_filename = format!("composed_{}.mp4", timestamp); let output_path = video_dir.join(&output_filename); let output_path_str = output_path.to_string_lossy().to_string(); - let concat_res = ffmpeg_cmd::concat_videos_copy(&app, list_path.to_str().unwrap(), &output_path_str).await; - - // 清理列表文件 - let _ = std::fs::remove_file(&list_path); + let concat_res = ffmpeg_cmd::concat_videos_robust( + &app, + clip_paths, + &output_path_str, + target_width, + target_height, + ).await; if let Err(e) = concat_res { return ApiResponse { @@ -91,7 +145,6 @@ pub async fn concat_video_clips( } // 总时长由前端提供(各片段时长的精确累加) - // 此处不通过 ffprobe 读取,避免依赖 let total_duration = 0.0; ApiResponse { @@ -103,7 +156,6 @@ pub async fn concat_video_clips( }), } } - /// 空镜片段生成请求参数 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -134,8 +186,25 @@ pub async fn generate_empty_shot_clip( let _ = std::fs::remove_file(&temp_audio); }; - // 1. 截取空镜视频并标准化 - if let Err(e) = ffmpeg_cmd::clip_video(&app, &args.video_source, 0.0, args.duration, &temp_video).await { + // 1. 探测输入分辨率并校验 9:16 比例 + let (video_width, video_height) = match ffmpeg_cmd::get_video_metadata(&app, &args.video_source).await { + Ok(meta) => { + if let Err(e) = validate_nine_sixteen_aspect(meta.width, meta.height) { + return ApiResponse { code: 400, message: e, data: None }; + } + (meta.width, meta.height) + } + Err(e) => { + return ApiResponse { + code: 500, + message: format!("读取视频元数据失败: {}", e), + data: None, + }; + } + }; + + // 2. 截取空镜视频并标准化(保持输入分辨率) + if let Err(e) = ffmpeg_cmd::clip_video(&app, &args.video_source, 0.0, args.duration, &temp_video, video_width, video_height).await { cleanup(); return ApiResponse { code: 500, @@ -278,7 +347,24 @@ pub async fn extract_video_segment( }; }; - match ffmpeg_cmd::clip_video(&app, &safe_input, args.start, args.duration, &safe_output).await { + // 探测输入分辨率并校验 9:16 比例 + let (video_width, video_height) = match ffmpeg_cmd::get_video_metadata(&app, &safe_input).await { + Ok(meta) => { + if let Err(e) = validate_nine_sixteen_aspect(meta.width, meta.height) { + return ApiResponse { code: 400, message: e, data: None }; + } + (meta.width, meta.height) + } + Err(e) => { + return ApiResponse { + code: 500, + message: format!("读取视频元数据失败: {}", e), + data: None, + }; + } + }; + + match ffmpeg_cmd::clip_video(&app, &safe_input, args.start, args.duration, &safe_output, video_width, video_height).await { Ok(_) => ApiResponse { code: 200, message: "视频片段截取成功".to_string(), diff --git a/tauri-app/src-tauri/src/commands/voice.rs b/tauri-app/src-tauri/src/commands/voice.rs index 8b3c41a..d3ecb39 100644 --- a/tauri-app/src-tauri/src/commands/voice.rs +++ b/tauri-app/src-tauri/src/commands/voice.rs @@ -20,8 +20,24 @@ pub struct VoiceMaterialArgs { /// 加载音色素材库 #[tauri::command] -pub async fn load_voice_materials() -> ApiResponse> { - match voice_storage::load_voice_materials() { +pub async fn load_voice_materials( + app: tauri::AppHandle, +) -> ApiResponse> { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { + code: 401, + message: "未登录".to_string(), + data: Some(vec![]), + }, + Err(e) => return ApiResponse { + code: 500, + message: format!("获取用户信息失败: {}", e), + data: Some(vec![]), + }, + }; + + match voice_storage::load_voice_materials(&user_id) { Ok(list) => ApiResponse { code: 200, message: "素材库加载成功".to_string(), @@ -38,8 +54,23 @@ pub async fn load_voice_materials() -> ApiResponse ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { + code: 401, + message: "未登录".to_string(), + data: Some(false), + }, + Err(e) => return ApiResponse { + code: 500, + message: format!("获取用户信息失败: {}", e), + data: Some(false), + }, + }; + let material = voice_storage::VoiceMaterial { id: args.id, name: args.name, @@ -49,7 +80,7 @@ pub async fn save_voice_material( status: args.status, created_at: args.created_at, }; - match voice_storage::add_voice_material(material) { + match voice_storage::add_voice_material(&user_id, material) { Ok(_) => ApiResponse { code: 200, message: "素材保存成功".to_string(), @@ -66,9 +97,24 @@ pub async fn save_voice_material( /// 删除音色素材 #[tauri::command] pub async fn delete_voice_material_cmd( + app: tauri::AppHandle, id: String, ) -> ApiResponse { - match voice_storage::delete_voice_material(&id) { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { + code: 401, + message: "未登录".to_string(), + data: Some(false), + }, + Err(e) => return ApiResponse { + code: 500, + message: format!("获取用户信息失败: {}", e), + data: Some(false), + }, + }; + + match voice_storage::delete_voice_material(&user_id, &id) { Ok(_) => ApiResponse { code: 200, message: "素材删除成功".to_string(), @@ -98,8 +144,23 @@ pub struct SaveAudioArgs { /// 保存音频文件(前端传入 base64 编码) #[tauri::command] pub async fn save_audio( + app: tauri::AppHandle, args: SaveAudioArgs, ) -> ApiResponse { + let user_id = match crate::storage::auth::get_current_user_id(&app) { + Ok(Some(id)) => id, + Ok(None) => return ApiResponse { + code: 401, + message: "未登录".to_string(), + data: None, + }, + Err(e) => return ApiResponse { + code: 500, + message: format!("获取用户信息失败: {}", e), + data: None, + }, + }; + let audio_bytes = match base64::Engine::decode( &base64::engine::general_purpose::STANDARD, &args.audio_data, @@ -113,6 +174,7 @@ pub async fn save_audio( }; match voice_storage::save_audio_file( + &user_id, &args.project_id, &args.audio_id, &audio_bytes, diff --git a/tauri-app/src-tauri/src/ffmpeg_cmd.rs b/tauri-app/src-tauri/src/ffmpeg_cmd.rs index d871ad4..f7fb587 100644 --- a/tauri-app/src-tauri/src/ffmpeg_cmd.rs +++ b/tauri-app/src-tauri/src/ffmpeg_cmd.rs @@ -206,16 +206,27 @@ pub async fn run_ffmpeg_in_dir( /** - * 标准化单个视频片段 (调整为 1080:1920, 25fps, libx264, aac 44100Hz stereo) + * 标准化单个视频片段 (调整为目标分辨率, 25fps, libx264, aac 44100Hz stereo) */ -pub async fn standardize_video(app: &AppHandle, input_path: &str, output_path: &str) -> Result<(), String> { +pub async fn standardize_video( + app: &AppHandle, + input_path: &str, + output_path: &str, + target_width: u32, + target_height: u32, +) -> Result<(), String> { // 验证路径安全 let safe_input = validate_safe_path(input_path)?; let safe_output = sanitize_output_path(output_path)?; - + + let vf = format!( + "fps=25,scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,format=yuv420p", + target_width, target_height, target_width, target_height + ); + let args = vec![ "-i".to_string(), safe_input, - "-vf".to_string(), "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p".to_string(), + "-vf".to_string(), vf, "-c:v".to_string(), "libx264".to_string(), "-c:a".to_string(), "aac".to_string(), "-ar".to_string(), "44100".to_string(), @@ -251,7 +262,13 @@ pub async fn concat_videos_copy(app: &AppHandle, list_path: &str, output_path: & /** * 拼接视频 - 兼容模式 (多步走:标准化 -> 拼接) */ -pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec, output_path: &str) -> Result<(), String> { +pub async fn concat_videos_robust( + app: &AppHandle, + video_paths: Vec, + output_path: &str, + target_width: u32, + target_height: u32, +) -> Result<(), String> { // 使用输出路径的父目录作为临时目录(确保在安全目录内) let output_parent = std::path::Path::new(output_path) .parent() @@ -261,13 +278,13 @@ pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec, out .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis(); - + let mut standardized_paths = Vec::new(); // 1. 标准化每个片段(内部已验证路径) for (i, path) in video_paths.iter().enumerate() { let std_path = output_parent.join(format!("std_{}_{}.mp4", timestamp, i)); - match standardize_video(app, path, std_path.to_str().unwrap()).await { + match standardize_video(app, path, std_path.to_str().unwrap(), target_width, target_height).await { Ok(()) => standardized_paths.push(std_path), Err(e) => { // 清理本轮已创建的标准化文件 @@ -325,14 +342,26 @@ pub async fn add_audio_to_video(app: &AppHandle, video_path: &str, audio_path: & } /** - * 将封面图转换为一段短视频 (0.5s, 1080x1920, 25fps) + * 将封面图转换为一段短视频 (0.5s, 目标分辨率, 25fps) * 带静音音频轨道,避免 concat 时丢失后续片段音频 */ -pub async fn create_cover_video(app: &AppHandle, input_path: &str, output_path: &str, duration: &str) -> Result<(), String> { +pub async fn create_cover_video( + app: &AppHandle, + input_path: &str, + output_path: &str, + duration: &str, + target_width: u32, + target_height: u32, +) -> Result<(), String> { // 验证路径安全 let safe_input = validate_safe_path(input_path)?; let safe_output = sanitize_output_path(output_path)?; - + + let vf = format!( + "scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,setsar=1", + target_width, target_height, target_width, target_height + ); + let args = vec![ "-loop".to_string(), "1".to_string(), "-i".to_string(), safe_input, @@ -342,7 +371,7 @@ pub async fn create_cover_video(app: &AppHandle, input_path: &str, output_path: "-c:a".to_string(), "aac".to_string(), "-t".to_string(), duration.to_string(), "-pix_fmt".to_string(), "yuv420p".to_string(), - "-vf".to_string(), "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1".to_string(), + "-vf".to_string(), vf, "-r".to_string(), "25".to_string(), "-shortest".to_string(), "-y".to_string(), @@ -745,7 +774,7 @@ pub async fn get_video_metadata(app: &AppHandle, input_path: &str) -> Result Result<(), String> { let safe_output = sanitize_output_path(output_path)?; @@ -770,11 +801,16 @@ pub async fn clip_video( let start_str = format!("{:.3}", start); let duration_str = format!("{:.3}", duration); + let vf = format!( + "fps=25,scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,format=yuv420p", + target_width, target_height, target_width, target_height + ); + let args = vec![ "-ss".to_string(), start_str, "-t".to_string(), duration_str, "-i".to_string(), safe_input, - "-vf".to_string(), "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p".to_string(), + "-vf".to_string(), vf, "-c:v".to_string(), "libx264".to_string(), "-preset".to_string(), "veryfast".to_string(), "-crf".to_string(), "23".to_string(), diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 9270834..5acc380 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -292,6 +292,135 @@ fn get_bgm_cache_dir() -> Result { .map_err(|e| e.to_string()) } +// ============================================================ +// 数据迁移:旧全局数据 → 用户隔离目录 +// ============================================================ + +pub fn migrate_legacy_data( + data_dir: &std::path::Path, + app: &tauri::AppHandle, +) -> Result<(), crate::storage::StorageError> { + + // 1. 检查迁移标记 + let flag = data_dir.join(".migration_v1_done"); + if flag.exists() { + return Ok(()); + } + + // 2. 检查是否有旧数据 + let legacy_items = vec![ + "voices.json", + "cover_avatars.json", + "cover_avatars", + "projects", + ]; + let has_legacy = legacy_items.iter().any(|name| data_dir.join(name).exists()); + if !has_legacy { + std::fs::write(&flag, "none")?; + return Ok(()); + } + + // 3. 获取当前登录用户 + let raw_user_id = crate::storage::auth::get_current_user_id(app)?; + let target_dir = match raw_user_id { + Some(id) => { + let safe_id = crate::storage::sanitize_id(&id)?; + data_dir.join("users").join(&safe_id) + } + None => { + // 未登录:暂不迁移,等登录后由 save_auth_state 触发 + return Ok(()); + } + }; + + crate::storage::ensure_dir(&target_dir)?; + + // 4. 逐个 move(rename 在同一文件系统内是原子的) + // 若目标已存在(如之前部分迁移过),跳过该项,确保不丢数据 + let mut any_skipped = false; + for name in &legacy_items { + let src = data_dir.join(name); + let dest = target_dir.join(name); + if src.exists() { + if dest.exists() { + eprintln!("[migrate] 目标已存在,跳过: {}", dest.display()); + any_skipped = true; + continue; + } + if let Err(e) = std::fs::rename(&src, &dest) { + eprintln!("[migrate] 移动 {} 失败: {}", name, e); + return Err(e.into()); + } + } + } + + // 5. 修复项目 JSON 中的绝对路径引用(meta.json / segments.json) + // 旧数据中的 coverPath、videoPath 等存的是绝对路径,move 后前缀变了 + let old_prefix = data_dir.to_string_lossy().to_string(); + let new_prefix = target_dir.to_string_lossy().to_string(); + let projects_dir = target_dir.join("projects"); + if projects_dir.exists() { + for entry in std::fs::read_dir(&projects_dir)? { + let entry = entry?; + let project_dir = entry.path(); + if !project_dir.is_dir() { continue; } + for filename in &["meta.json", "segments.json"] { + let path = project_dir.join(filename); + if path.exists() { + if let Err(e) = fix_legacy_paths_in_file(&path, &old_prefix, &new_prefix) { + eprintln!("[migrate] 修复路径失败 {:?}: {}", path, e); + } + } + } + } + } + + // 6. 写迁移标记 + let flag_content = if any_skipped { + format!("skipped:{}", target_dir.file_name().unwrap_or_default().to_string_lossy()) + } else { + target_dir.file_name().unwrap_or_default().to_string_lossy().to_string() + }; + std::fs::write(&flag, flag_content)?; + + Ok(()) +} + +/// 递归替换 JSON 字符串值中的旧路径前缀为新路径前缀 +fn fix_legacy_paths_in_json(value: &mut serde_json::Value, old_prefix: &str, new_prefix: &str) { + match value { + serde_json::Value::String(s) => { + if s.starts_with(old_prefix) { + *s = new_prefix.to_string() + &s[old_prefix.len()..]; + } + } + serde_json::Value::Array(arr) => { + for item in arr { + fix_legacy_paths_in_json(item, old_prefix, new_prefix); + } + } + serde_json::Value::Object(obj) => { + for (_, v) in obj { + fix_legacy_paths_in_json(v, old_prefix, new_prefix); + } + } + _ => {} + } +} + +/// 读取 JSON 文件,替换其中的旧路径前缀,写回文件 +fn fix_legacy_paths_in_file( + path: &std::path::Path, + old_prefix: &str, + new_prefix: &str, +) -> Result<(), crate::storage::StorageError> { + let content = std::fs::read_to_string(path)?; + let mut value: serde_json::Value = serde_json::from_str(&content)?; + fix_legacy_paths_in_json(&mut value, old_prefix, new_prefix); + std::fs::write(path, serde_json::to_string_pretty(&value)?)?; + Ok(()) +} + // ============================================================ // 应用入口 // ============================================================ @@ -316,6 +445,12 @@ pub fn run() { // 初始化应用数据目录(所有业务数据的根目录) if let Ok(app_data_dir) = app.path().app_local_data_dir() { crate::storage::init_app_data_dir(app_data_dir.clone()); + + // 数据迁移:将旧的全局数据按当前登录用户隔离到 users/{user_id}/ 目录 + if let Err(e) = migrate_legacy_data(&app_data_dir, &app.handle()) { + eprintln!("[setup] 数据迁移失败: {}", e); + } + // 后台清理过期视频缓存和 BGM 缓存,不阻塞首屏 let app_data_dir_clone = app_data_dir.clone(); std::thread::spawn(move || { @@ -538,13 +673,67 @@ async fn video_composite_synthesis( }; } }; + + // 探测所有视频素材分辨率,校验 9:16 并决定目标输出分辨率 + let mut heights = Vec::new(); + for path in &request.video_paths { + match ffmpeg_cmd::get_video_metadata(&app, path).await { + Ok(meta) => { + if meta.width == 0 || meta.height == 0 { + return ApiResponse { + code: 400, + message: format!("无法读取视频分辨率: {}", path), + data: None, + }; + } + let ratio = meta.width as f64 / meta.height as f64; + let expected = 9.0 / 16.0; + if (ratio - expected).abs() > 0.01 { + return ApiResponse { + code: 400, + message: format!( + "视频比例必须是 9:16,当前为 {}×{}(比例 {:.3}): {}", + meta.width, meta.height, ratio, path + ), + data: None, + }; + } + heights.push(meta.height); + } + Err(e) => { + return ApiResponse { + code: 500, + message: format!("读取视频元数据失败 {}: {}", path, e), + data: None, + }; + } + } + } + + if heights.is_empty() { + return ApiResponse { + code: 400, + message: "没有可处理的视频片段".to_string(), + data: None, + }; + } + + let min_height = *heights.iter().min().unwrap(); + let (target_width, target_height) = if min_height <= 1280 { + (720, 1280) + } else { + (1080, 1920) + }; + let payload = serde_json::json!({ "videoPaths": request.video_paths, "audioPath": request.audio_path.unwrap_or_default(), "coverPath": request.cover_path, "outputPath": request.output_path, }); - let response = video_processing::handle_video_synthesis(&app, &project_dir, payload).await; + let response = video_processing::handle_video_synthesis( + &app, &project_dir, payload, target_width, target_height + ).await; if response.code == 200 { if let Some(data) = response.data { if let Some(path) = data.as_object() diff --git a/tauri-app/src-tauri/src/storage/auth.rs b/tauri-app/src-tauri/src/storage/auth.rs index 7816f52..aff1c02 100644 --- a/tauri-app/src-tauri/src/storage/auth.rs +++ b/tauri-app/src-tauri/src/storage/auth.rs @@ -43,3 +43,16 @@ pub fn clear_auth_state(app: &AppHandle) -> Result<(), StorageError> { Err(e) => Err(e.into()), } } + +/// 获取当前登录用户的 user_id +/// +/// 从 auth.json 中读取已认证的 user.id,Rust 层自治,不依赖前端传参。 +pub fn get_current_user_id(app: &AppHandle) -> Result, StorageError> { + #[derive(serde::Deserialize)] + struct UserInfo { id: String } + #[derive(serde::Deserialize)] + struct AuthState { user: Option } + + let auth: Option = load_auth_state(app)?; + Ok(auth.and_then(|a| a.user).map(|u| u.id)) +} diff --git a/tauri-app/src-tauri/src/storage/cover_avatar.rs b/tauri-app/src-tauri/src/storage/cover_avatar.rs index 86c8a90..f84653e 100644 --- a/tauri-app/src-tauri/src/storage/cover_avatar.rs +++ b/tauri-app/src-tauri/src/storage/cover_avatar.rs @@ -29,20 +29,20 @@ pub struct CoverAvatarsList { } /// 加载封面形象库 -pub fn load_cover_avatars() -> Result { - let path = crate::storage::paths::get_cover_avatars_json_path()?; +pub fn load_cover_avatars(user_id: &str) -> Result { + let path = crate::storage::paths::get_cover_avatars_json_path(user_id)?; Ok(read_json(&path)?.unwrap_or_default()) } /// 保存封面形象库 -pub fn save_cover_avatars(list: &CoverAvatarsList) -> Result<(), StorageError> { - let path = crate::storage::paths::get_cover_avatars_json_path()?; +pub fn save_cover_avatars(user_id: &str, list: &CoverAvatarsList) -> Result<(), StorageError> { + let path = crate::storage::paths::get_cover_avatars_json_path(user_id)?; atomic_write_json(&path, list) } /// 添加封面形象 -pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> { - let mut list = load_cover_avatars()?; +pub fn add_cover_avatar(user_id: &str, avatar: CoverAvatar) -> Result<(), StorageError> { + let mut list = load_cover_avatars(user_id)?; // 去重:相同 id 替换 if let Some(pos) = list.avatars.iter().position(|a| a.id == avatar.id) { list.avatars[pos] = avatar; @@ -50,12 +50,12 @@ pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> { list.avatars.push(avatar); } list.updated_at = chrono_lite_now(); - save_cover_avatars(&list) + save_cover_avatars(user_id, &list) } /// 删除封面形象 -pub fn delete_cover_avatar(id: &str) -> Result<(), StorageError> { - let mut list = load_cover_avatars()?; +pub fn delete_cover_avatar(user_id: &str, id: &str) -> Result<(), StorageError> { + let mut list = load_cover_avatars(user_id)?; let pos = list.avatars.iter().position(|a| a.id == id) .ok_or_else(|| StorageError::Io(std::io::Error::new( std::io::ErrorKind::NotFound, @@ -63,18 +63,19 @@ pub fn delete_cover_avatar(id: &str) -> Result<(), StorageError> { )))?; list.avatars.remove(pos); list.updated_at = chrono_lite_now(); - save_cover_avatars(&list) + save_cover_avatars(user_id, &list) } /// 保存封面形象图片文件到本地 /// /// 将 base64 编码的图片数据写入 `cover_avatars/` 子目录。 pub fn save_cover_avatar_image( + user_id: &str, avatar_id: &str, data: &[u8], ext: &str, ) -> Result { - let avatars_dir = get_cover_avatars_dir()?; + let avatars_dir = get_cover_avatars_dir(user_id)?; ensure_dir(&avatars_dir)?; // 净化扩展名:只允许字母数字,防止路径遍历 diff --git a/tauri-app/src-tauri/src/storage/mod.rs b/tauri-app/src-tauri/src/storage/mod.rs index e687a6a..40417eb 100644 --- a/tauri-app/src-tauri/src/storage/mod.rs +++ b/tauri-app/src-tauri/src/storage/mod.rs @@ -16,12 +16,14 @@ pub mod cover_avatar; pub use engine::{ atomic_write_json, atomic_write_bytes, - with_file_lock, read_json, sanitize_filename, StorageError, + with_file_lock, read_json, sanitize_filename, sanitize_id, ensure_dir, StorageError, }; pub use paths::{ - init_app_data_dir, get_app_data_dir, get_projects_root_dir, - get_project_dir, get_project_dir_path, get_project_assets_dir, - get_project_videos_dir, get_project_products_dir, get_voices_json_path, - get_app_config_json_path, get_cover_avatars_dir, get_cover_avatars_json_path, + init_app_data_dir, get_app_data_dir, get_user_data_dir, + get_projects_root_dir, get_project_dir, get_project_dir_path, + get_project_assets_dir, get_project_videos_dir, get_project_products_dir, + get_voices_json_path, get_app_config_json_path, + get_cover_avatars_dir, get_cover_avatars_json_path, + get_bgm_cache_dir, get_temp_dir, }; diff --git a/tauri-app/src-tauri/src/storage/paths.rs b/tauri-app/src-tauri/src/storage/paths.rs index 2feb027..a8b4ebc 100644 --- a/tauri-app/src-tauri/src/storage/paths.rs +++ b/tauri-app/src-tauri/src/storage/paths.rs @@ -4,6 +4,11 @@ //! 所有返回 PathBuf 的函数内部已做路径净化。 //! //! 存储根目录为 app_local_data_dir,在应用启动时通过 init_app_data_dir 初始化。 +//! +//! 【用户数据隔离说明】 +//! 用户相关数据(项目、音色素材、封面头像)按 user_id 隔离: +//! {app_local_data_dir}/users/{user_id}/ +//! 全局共享数据(BGM 缓存、设备级配置)保持在根目录。 use std::path::PathBuf; use std::sync::OnceLock; @@ -26,68 +31,114 @@ pub fn get_app_data_dir() -> Result<&'static PathBuf, StorageError> { ))) } -/// 获取项目根目录(所有项目的父目录) -/// {app_local_data_dir}/projects/ -pub fn get_projects_root_dir() -> Result { +// ============================================================ +// 用户隔离根目录 +// ============================================================ + +/// 获取用户数据根目录 +/// {app_local_data_dir}/users/{user_id}/ +pub fn get_user_data_dir(user_id: &str) -> Result { + let safe_id = sanitize_id(user_id)?; let base = get_app_data_dir()?; + let path = base.join("users").join(&safe_id); + crate::storage::engine::ensure_dir(&path)?; + Ok(path) +} + +// ============================================================ +// 项目相关路径(用户隔离) +// ============================================================ + +/// 获取项目根目录(所有项目的父目录) +/// {app_local_data_dir}/users/{user_id}/projects/ +pub fn get_projects_root_dir(user_id: &str) -> Result { + let base = get_user_data_dir(user_id)?; let path = base.join("projects"); crate::storage::engine::ensure_dir(&path)?; Ok(path) } /// 获取项目目录路径(不自动创建) -/// {app_local_data_dir}/projects/{project_id}/ -pub fn get_project_dir_path(project_id: &str) -> Result { +/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/ +pub fn get_project_dir_path(user_id: &str, project_id: &str) -> Result { let safe_id = sanitize_id(project_id)?; - let base = get_app_data_dir()?; - Ok(base.join("projects").join(&safe_id)) + let base = get_projects_root_dir(user_id)?; + Ok(base.join(&safe_id)) } /// 获取项目目录(自动创建) -/// {app_local_data_dir}/projects/{project_id}/ -pub fn get_project_dir(project_id: &str) -> Result { - let path = get_project_dir_path(project_id)?; +/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/ +pub fn get_project_dir(user_id: &str, project_id: &str) -> Result { + let path = get_project_dir_path(user_id, project_id)?; crate::storage::engine::ensure_dir(&path)?; Ok(path) } /// 获取项目内的 assets 目录 -/// {app_local_data_dir}/projects/{project_id}/assets/ -pub fn get_project_assets_dir(project_id: &str) -> Result { - let path = get_project_dir(project_id)?; +/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/assets/ +pub fn get_project_assets_dir(user_id: &str, project_id: &str) -> Result { + let path = get_project_dir(user_id, project_id)?; let assets = path.join("assets"); crate::storage::engine::ensure_dir(&assets)?; Ok(assets) } /// 获取项目内的 videos 目录 -/// {app_local_data_dir}/projects/{project_id}/videos/ -pub fn get_project_videos_dir(project_id: &str) -> Result { - let path = get_project_dir(project_id)?; +/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/videos/ +pub fn get_project_videos_dir(user_id: &str, project_id: &str) -> Result { + let path = get_project_dir(user_id, project_id)?; let videos = path.join("videos"); crate::storage::engine::ensure_dir(&videos)?; Ok(videos) } /// 获取项目成品目录 -/// {app_local_data_dir}/projects/{project_id}/products/ -pub fn get_project_products_dir(project_id: &str) -> Result { - let safe_id = sanitize_id(project_id)?; - let base = get_app_data_dir()?; - let path = base.join("projects").join(&safe_id).join("products"); +/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/products/ +pub fn get_project_products_dir(user_id: &str, project_id: &str) -> Result { + let base = get_project_dir(user_id, project_id)?; + let path = base.join("products"); crate::storage::engine::ensure_dir(&path)?; Ok(path) } +// ============================================================ +// 音色素材库路径(用户隔离) +// ============================================================ + /// 获取私有音色素材库 JSON 路径 -/// {app_local_data_dir}/voices.json -pub fn get_voices_json_path() -> Result { - let base = get_app_data_dir()?; +/// {app_local_data_dir}/users/{user_id}/voices.json +pub fn get_voices_json_path(user_id: &str) -> Result { + let base = get_user_data_dir(user_id)?; Ok(base.join("voices.json")) } +// ============================================================ +// 封面头像路径(用户隔离) +// ============================================================ + +/// 获取封面形象图片存储目录 +/// {app_local_data_dir}/users/{user_id}/cover_avatars/ +pub fn get_cover_avatars_dir(user_id: &str) -> Result { + let base = get_user_data_dir(user_id)?; + let path = base.join("cover_avatars"); + crate::storage::engine::ensure_dir(&path)?; + Ok(path) +} + +/// 获取封面形象库 JSON 路径 +/// {app_local_data_dir}/users/{user_id}/cover_avatars.json +pub fn get_cover_avatars_json_path(user_id: &str) -> Result { + let base = get_user_data_dir(user_id)?; + Ok(base.join("cover_avatars.json")) +} + +// ============================================================ +// 全局共享路径(不隔离) +// ============================================================ + /// 获取应用配置路径 /// {app_local_data_dir}/config.json +/// 注意:config.json 存的是设备级配置(API 地址、运行环境),保持全局共享。 pub fn get_app_config_json_path() -> Result { let base = get_app_data_dir()?; Ok(base.join("config.json")) @@ -95,6 +146,7 @@ pub fn get_app_config_json_path() -> Result { /// 获取认证状态文件路径 /// {app_config_dir}/auth.json +/// 注意:auth.json 只存当前登录态,全局唯一,切换账号时覆盖。 pub fn get_auth_state_path(app: &AppHandle) -> Result { let path = app.path().app_config_dir() .map_err(|e| StorageError::Io(std::io::Error::new( @@ -105,27 +157,22 @@ pub fn get_auth_state_path(app: &AppHandle) -> Result { Ok(path.join("auth.json")) } -/// 获取封面形象图片存储目录 -/// {app_local_data_dir}/cover_avatars/ -pub fn get_cover_avatars_dir() -> Result { - let base = get_app_data_dir()?; - let path = base.join("cover_avatars"); - crate::storage::engine::ensure_dir(&path)?; - Ok(path) -} - -/// 获取封面形象库 JSON 路径 -/// {app_local_data_dir}/cover_avatars.json -pub fn get_cover_avatars_json_path() -> Result { - let base = get_app_data_dir()?; - Ok(base.join("cover_avatars.json")) -} - /// 获取 BGM 缓存目录 /// {app_local_data_dir}/bgm_cache/ +/// 注意:BGM 缓存是公共资源,无需按用户隔离。 pub fn get_bgm_cache_dir() -> Result { let base = get_app_data_dir()?; let path = base.join("bgm_cache"); crate::storage::engine::ensure_dir(&path)?; Ok(path) } + +/// 获取临时目录 +/// {app_local_data_dir}/temp/ +#[allow(dead_code)] +pub fn get_temp_dir() -> Result { + let base = get_app_data_dir()?; + let path = base.join("temp"); + crate::storage::engine::ensure_dir(&path)?; + Ok(path) +} diff --git a/tauri-app/src-tauri/src/storage/project.rs b/tauri-app/src-tauri/src/storage/project.rs index c30b735..f8819e2 100644 --- a/tauri-app/src-tauri/src/storage/project.rs +++ b/tauri-app/src-tauri/src/storage/project.rs @@ -21,10 +21,11 @@ use crate::storage::paths::{ /// 保存项目元数据 pub fn save_project_meta( + user_id: &str, project_id: &str, data: &Value, ) -> Result<(), StorageError> { - let dir = get_project_dir(project_id)?; + let dir = get_project_dir(user_id, project_id)?; let path = dir.join("meta.json"); with_file_lock(&path, || { atomic_write_json(&path, data) @@ -33,18 +34,20 @@ pub fn save_project_meta( /// 保存项目元数据(原始 JSON 字符串) pub fn save_project_meta_raw( + user_id: &str, project_id: &str, json_content: &str, ) -> Result<(), StorageError> { let data: Value = serde_json::from_str(json_content)?; - save_project_meta(project_id, &data) + save_project_meta(user_id, project_id, &data) } /// 加载项目元数据 pub fn load_project_meta( + user_id: &str, project_id: &str, ) -> Result { - let dir = get_project_dir_path(project_id)?; + let dir = get_project_dir_path(user_id, project_id)?; let path = dir.join("meta.json"); match read_json(&path)? { Some(data) => Ok(data), @@ -58,10 +61,11 @@ pub fn load_project_meta( /// 保存分镜数据 pub fn save_project_segments( + user_id: &str, project_id: &str, segments: &Value, ) -> Result<(), StorageError> { - let dir = get_project_dir(project_id)?; + let dir = get_project_dir(user_id, project_id)?; let path = dir.join("segments.json"); with_file_lock(&path, || { atomic_write_json(&path, segments) @@ -70,18 +74,20 @@ pub fn save_project_segments( /// 保存分镜数据(原始 JSON 字符串) pub fn save_project_segments_raw( + user_id: &str, project_id: &str, json_content: &str, ) -> Result<(), StorageError> { let data: Value = serde_json::from_str(json_content)?; - save_project_segments(project_id, &data) + save_project_segments(user_id, project_id, &data) } /// 加载分镜数据 pub fn load_project_segments( + user_id: &str, project_id: &str, ) -> Result { - let dir = get_project_dir_path(project_id)?; + let dir = get_project_dir_path(user_id, project_id)?; let path = dir.join("segments.json"); match read_json(&path)? { Some(data) => Ok(data), @@ -97,9 +103,8 @@ pub fn load_project_segments( /// /// 遍历 projects 目录,读取每个项目的 meta.json。 /// 读取失败的项目会被跳过并记录警告,不会导致整个列表失败。 -pub fn list_projects() -> Result, StorageError> { - let base = get_meijiaka_dir()?; - let projects_dir = base.join("projects"); +pub fn list_projects(user_id: &str) -> Result, StorageError> { + let projects_dir = crate::storage::paths::get_projects_root_dir(user_id)?; if !projects_dir.exists() { return Ok(vec![]); @@ -143,8 +148,8 @@ pub fn list_projects() -> Result, StorageError> { /// /// 使用 `get_project_dir_path`(不自动创建目录), /// 避免删除不存在项目时先创建空目录的 bug。 -pub fn delete_project(project_id: &str) -> Result<(), StorageError> { - let dir = get_project_dir_path(project_id)?; +pub fn delete_project(user_id: &str, project_id: &str) -> Result<(), StorageError> { + let dir = get_project_dir_path(user_id, project_id)?; if dir.exists() { fs::remove_dir_all(&dir)?; } @@ -157,12 +162,13 @@ pub fn delete_project(project_id: &str) -> Result<(), StorageError> { /// 保存项目资源(Base64 解码后写入) pub fn save_project_asset( + user_id: &str, project_id: &str, filename: &str, base64_data: &str, ) -> Result { let safe_filename = sanitize_filename(filename)?; - let dir = get_project_assets_dir(project_id)?; + let dir = get_project_assets_dir(user_id, project_id)?; let path = dir.join(&safe_filename); let decoded = base64::engine::general_purpose::STANDARD @@ -177,16 +183,11 @@ pub fn save_project_asset( /// 获取视频保存路径(不写入文件) /// 保存到项目 videos 目录下,与后端下载路径保持一致 pub fn get_video_save_path( + user_id: &str, project_id: &str, filename: &str, ) -> Result { let safe_filename = sanitize_filename(filename)?; - let dir = get_project_videos_dir(project_id)?; + let dir = get_project_videos_dir(user_id, project_id)?; Ok(dir.join(&safe_filename)) } - -// 兼容旧接口:从 persistence.rs 迁移时需要的 get_meijiaka_dir -fn get_meijiaka_dir() -> Result { - crate::storage::paths::get_app_data_dir() - .map(|p| p.to_path_buf()) -} diff --git a/tauri-app/src-tauri/src/storage/voice.rs b/tauri-app/src-tauri/src/storage/voice.rs index 2135705..a293753 100644 --- a/tauri-app/src-tauri/src/storage/voice.rs +++ b/tauri-app/src-tauri/src/storage/voice.rs @@ -26,6 +26,7 @@ pub struct AudioMeta { /// /// 将 base64 编码的音频数据写入 `audios/` 子目录。 pub fn save_audio_file( + user_id: &str, project_id: &str, audio_id: &str, data: &[u8], @@ -33,7 +34,7 @@ pub fn save_audio_file( voice_id: &str, duration: f64, ) -> Result { - let project_dir = get_project_dir(project_id)?; + let project_dir = get_project_dir(user_id, project_id)?; let audios_dir = project_dir.join("audios"); ensure_dir(&audios_dir)?; @@ -87,20 +88,20 @@ pub struct VoiceMaterialsList { } /// 加载音色素材库 -pub fn load_voice_materials() -> Result { - let path = crate::storage::paths::get_voices_json_path()?; +pub fn load_voice_materials(user_id: &str) -> Result { + let path = crate::storage::paths::get_voices_json_path(user_id)?; Ok(read_json(&path)?.unwrap_or_default()) } /// 保存音色素材库 -pub fn save_voice_materials(list: &VoiceMaterialsList) -> Result<(), StorageError> { - let path = crate::storage::paths::get_voices_json_path()?; +pub fn save_voice_materials(user_id: &str, list: &VoiceMaterialsList) -> Result<(), StorageError> { + let path = crate::storage::paths::get_voices_json_path(user_id)?; crate::storage::engine::atomic_write_json(&path, list) } /// 添加音色素材 -pub fn add_voice_material(material: VoiceMaterial) -> Result<(), StorageError> { - let mut list = load_voice_materials()?; +pub fn add_voice_material(user_id: &str, material: VoiceMaterial) -> Result<(), StorageError> { + let mut list = load_voice_materials(user_id)?; // 去重:相同 id 替换 if let Some(pos) = list.materials.iter().position(|m| m.id == material.id) { list.materials[pos] = material; @@ -108,12 +109,12 @@ pub fn add_voice_material(material: VoiceMaterial) -> Result<(), StorageError> { list.materials.push(material); } list.updated_at = chrono_lite_now(); - save_voice_materials(&list) + save_voice_materials(user_id, &list) } /// 删除音色素材 -pub fn delete_voice_material(id: &str) -> Result<(), StorageError> { - let mut list = load_voice_materials()?; +pub fn delete_voice_material(user_id: &str, id: &str) -> Result<(), StorageError> { + let mut list = load_voice_materials(user_id)?; let pos = list.materials.iter().position(|m| m.id == id) .ok_or_else(|| StorageError::Io(std::io::Error::new( std::io::ErrorKind::NotFound, @@ -121,5 +122,5 @@ pub fn delete_voice_material(id: &str) -> Result<(), StorageError> { )))?; list.materials.remove(pos); list.updated_at = chrono_lite_now(); - save_voice_materials(&list) + save_voice_materials(user_id, &list) } diff --git a/tauri-app/src-tauri/src/video_processing.rs b/tauri-app/src-tauri/src/video_processing.rs index 5a93080..4950d74 100644 --- a/tauri-app/src-tauri/src/video_processing.rs +++ b/tauri-app/src-tauri/src/video_processing.rs @@ -9,6 +9,8 @@ pub async fn handle_video_synthesis( app: &tauri::AppHandle, project_dir: &std::path::PathBuf, payload: serde_json::Value, + target_width: u32, + target_height: u32, ) -> ApiResponse { let payload_obj = match payload.as_object() { Some(p) => p, @@ -59,7 +61,7 @@ pub async fn handle_video_synthesis( cover_v_path.push(format!("temp_cover_{}.mp4", utils::uuid_v4())); let cover_v_path_str = cover_v_path.to_string_lossy().to_string(); - match crate::ffmpeg_cmd::create_cover_video(app, c_path, &cover_v_path_str, "0.5").await { + match crate::ffmpeg_cmd::create_cover_video(app, c_path, &cover_v_path_str, "0.5", target_width, target_height).await { Ok(_) => { paths_vec.insert(0, cover_v_path_str); temp_cover_video = Some(cover_v_path); @@ -75,7 +77,7 @@ pub async fn handle_video_synthesis( } // 执行视频拼接 - match crate::ffmpeg_cmd::concat_videos_robust(app, paths_vec, concat_output.to_string_lossy().as_ref()).await { + match crate::ffmpeg_cmd::concat_videos_robust(app, paths_vec, concat_output.to_string_lossy().as_ref(), target_width, target_height).await { Ok(_) => { // 如果有音频,合并音频;否则直接使用拼接结果 let has_audio = audio_path.map(|p| !p.is_empty()).unwrap_or(false); diff --git a/tauri-app/src/api/modules/localStorage.ts b/tauri-app/src/api/modules/localStorage.ts index 214738e..15ab7b8 100644 --- a/tauri-app/src/api/modules/localStorage.ts +++ b/tauri-app/src/api/modules/localStorage.ts @@ -115,7 +115,6 @@ export const localProjectApi = { dubbingAudioDuration: meta.dubbingAudioDuration, voiceSpeed: meta.voiceSpeed, voiceVolume: meta.voiceVolume, - voicePitch: meta.voicePitch, avatarMaterialPath: meta.avatarMaterialPath, avatarMaterialName: meta.avatarMaterialName, avatarMaterialDuration: meta.avatarMaterialDuration, diff --git a/tauri-app/src/hooks/useCanvasSubtitleRenderer.ts b/tauri-app/src/hooks/useCanvasSubtitleRenderer.ts index 98b373e..fe56fac 100644 --- a/tauri-app/src/hooks/useCanvasSubtitleRenderer.ts +++ b/tauri-app/src/hooks/useCanvasSubtitleRenderer.ts @@ -36,6 +36,8 @@ interface UseCanvasSubtitleRendererOptions { mainTitleStyle?: Partial; subTitleStyle?: Partial; enabled: boolean; + /** ASS 坐标系基准高度,默认 1920;720p 视频应传入 1280 */ + playResY?: number; } export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOptions) { @@ -49,6 +51,7 @@ export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOpti mainTitleStyle, subTitleStyle, enabled, + playResY, } = options; const rafRef = useRef(null); @@ -84,7 +87,8 @@ export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOpti // 计算缩放比例(基于高度) // 扣除