# 前端系统兼容性审查报告 v2（业务场景驱动）

> 审查范围：`tauri-app/src` 全部源码 + `tauri-app/src-tauri/src` Rust 层命令
> 审查方法：按用户真实使用路径和数据流转分析，非通用技术罗列
> 审查日期：2026-05-21
> 当前版本：v1.6.0

---

## 一、综述

本次审查以**用户真实操作流程**为主线，从数据持久化、媒体处理、第三方服务、版本升级、跨设备迁移五个业务维度展开，共发现 **14 项与业务直接相关的兼容性问题**。

**核心结论**：
1. **BGM 云端化改造存在链路缺口**：前端存储了 URL，但混音时直接传给 FFmpeg，未做本地缓存，网络波动或 URL 过期会导致合成失败或产生"无声成片"。
2. **积分消费存在 TOCTOU 竞态**：预检通过→合成完成→扣费失败之间有时间窗口，可能导致用户白嫖或重复扣费。
3. **项目数据完全不可迁移**：所有本地路径为绝对路径，无导出/导入功能，换设备后项目报废。
4. **磁盘满等大文件场景缺乏保护**：合成成果可能直接丢失，大视频上传/下载全量读内存。
5. **多处"静默失败"**：保存项目、分段配音、BGM 混音等关键环节出错时不提示用户，导致用户以为成功实际数据残缺。

---

## 二、🔴 严重问题（影响功能、数据或资金）

### 1. BGM 云端化后混音链路断裂——"无声成片"与合成失败

**业务场景**：
1. 用户在 BGM 弹窗中选择一首云端 BGM（七牛云 URL）
2. 保存项目（`bgmMusicPath` 写入 `meta.json`，值为 `https://media.liche.cn/.../xxx.mp3`）
3. 几天后点击「合成视频」，FFmpeg 混音时直接拉取该 URL
4. 网络波动或 URL 签名过期 → FFmpeg HTTP 输入超时 → 混音失败

**实际代码路径**：
```typescript
// VideoCompose.tsx:265-276
if (bgmMusicPath) {
  const mixRes = await invoke('mix_bgm_to_video', {
    args: {
      videoPath: result,
      bgmPath: bgmFullPath,   // <-- 这里是七牛云 URL，不是本地路径
      outputPath: result,
      bgmVolume: (bgmVolume ?? 0.25),
    },
  });
  if (mixRes.code !== 200) {
    console.warn('BGM 混合失败，使用无 BGM 版本:', mixRes.message);
  }
}
```

```rust
// ffmpeg_cmd.rs:509-546
pub async fn mix_bgm_to_video(...) {
    let safe_video = validate_safe_path(video_path)?;  // 只校验视频路径
    let safe_bgm = bgm_path.to_string();                // <-- 直接透传 URL，无校验
    run_ffmpeg(app, vec!["-i", safe_video, "-i", safe_bgm, ...])
}
```

**用户实际看到**：
- 界面提示「压制成片完成」✅
- 播放视频发现**没有背景音乐** ❌
- 用户以为是自己操作问题，反复合成浪费积分和时间

**影响评估**：
- 功能完整性受损：选了 BGM 却出无声视频
- 积分浪费：每次合成消耗积分，但产出不符合预期
- 用户信任度下降：无法解释为什么有时有 BGM 有时没有

**修复建议**：
在 `VideoCompose.tsx` `handleStart` 中，混音前确保 BGM 为本地文件：
```typescript
let finalBgmPath = bgmMusicPath;
if (bgmMusicPath?.startsWith('http')) {
  // 下载到本地缓存目录
  const cacheDir = await invoke<string>('get_bgm_cache_dir');
  const cachedPath = `${cacheDir}/bgm_${bgmMusicId}.mp3`;
  const exists = await invoke<boolean>('file_exists', { path: cachedPath });
  if (!exists) {
    useProgressStore.getState().update('正在下载背景音乐...');
    await videoComposeApi.downloadFile({ url: bgmMusicPath, outputPath: cachedPath });
  }
  finalBgmPath = cachedPath;
}
// 然后传给 mix_bgm_to_video
```

Rust 侧 `mix_bgm_to_video` 应恢复 `validate_safe_path` 校验，拒绝 URL：
```rust
let safe_bgm = validate_safe_path(bgm_path)?;  // 强制要求本地路径
```

---

### 2. 积分消费 TOCTOU 竞态——合成完成扣费失败导致"白嫖"或需重来

**业务场景**：
1. 用户点击「合成视频」，预检余额充足（如 50 积分，需扣 5 分）
2. 视频合成耗时 3-5 分钟
3. 期间用户在手机端或其他场景消费了积分，余额降至 3 分
4. 合成完成后调用 `consumePoints`，返回 402 "积分不足"
5. 前端回滚 `finalVideoPath` 状态，但**不删除已生成的视频文件**

**实际代码**：
```typescript
// VideoCompose.tsx:287-309
const composePoints = usePointStore.getState().getRule('compose')?.points || 5;
try {
  await pointsApi.consumePoints({
    points: composePoints,
    sourceType: 'compose',
    sourceId: `compose_${useAuthStore.getState().user?.id || 'unknown'}_${Date.now()}`,
    description: '压制成片',
  });
} catch (err) {
  const msg = err instanceof Error ? err.message : String(err);
  setResultPath('');
  setFinalVideoPath(undefined);   // 回滚前端状态
  setExportedAt(undefined);
  // ❌ 没有删除 products/ 目录下已生成的视频文件
  if (msg.includes('402') || msg.includes('积分不足')) {
    setShowPointsModal(true);
    return;
  }
}
```

**用户实际看到**：
- 提示「积分不足」弹出充值弹窗
- 但 `~/Library/Application Support/cn.meijiaka.ai-zy/projects/xxx/products/` 下已经有一份完整的 `.mp4`
- 用户可以通过 Finder 直接找到并播放该文件，**实际已白嫖成功**
- 或者用户充值后再次点击合成，重复消耗时间

**影响评估**：
- 资金损失风险：用户可在不扣积分的情况下拿到成品
- 用户体验差：明明看到"合成完成"的进度条走到 100%，最后说积分不够
- 运营数据失真：成品文件存在但系统无消费记录

**修复建议（方案二选一）**：

**方案 A：积分预占/冻结机制（推荐）**
后端新增 "预占积分" API，合成前预占 5 积分，合成完成后确认扣费，失败则释放。消除时间窗口。

**方案 B：扣费前置 + 失败清理**
若无法改后端，至少做到失败时清理文件：
```typescript
} catch (err) {
  // 回滚状态
  setResultPath('');
  setFinalVideoPath(undefined);
  // 清理已生成的文件
  if (outputPath) {
    await invoke('delete_project_file', { projectId, filePath: outputPath })
      .catch(() => {});  // 清理失败不阻断错误提示
  }
  // ... 原有错误处理
}
```

---

### 3. 项目数据绝对路径依赖——换设备后项目完全报废

**业务场景**：
1. 用户 Mac A 上创建项目，生成视频，一切正常
2. 用户将 `~/Library/Application Support/cn.meijiaka.ai-zy/projects/` 文件夹复制到 Mac B（或 Time Machine 恢复）
3. 在 Mac B 上打开应用，项目列表显示正常
4. 点击项目进入编辑，视频预览空白、配音无法播放、封面无法加载

**根本原因**：
`meta.json` 和 `segments.json` 中所有本地文件路径均为**绝对路径**：
```json
{
  "avatarMaterialPath": "/Users/alice/Library/Application Support/cn.meijiaka.ai-zy/projects/proj_xxx/assets/voice.mp3",
  "burnedVideoPath": "/Users/alice/Library/Application Support/.../burned_xxx.mp4",
  "coverPath": "/Users/alice/Library/Application Support/.../cover.png"
}
```

Mac B 上用户名为 `bob`，上述路径全部指向不存在的目录。

**当前代码无修复机制**：
- `loadMeta` 直接返回磁盘数据，无路径校验或重映射
- `getLocalFileUrl` 调用 Rust `validate_media_path`，校验通过后会返回 `asset://` URL，但文件不存在时直接抛错
- `useLocalVideo` 抛错后显示空白，无降级提示

**用户实际看到**：
- 项目标题、主题等文字信息正常
- 视频预览区域空白或转圈
- 配音试听按钮点击无反应
- 用户以为数据损坏，恐慌

**影响评估**：
- 用户换机/重装系统后所有本地项目无法继续编辑
- 与「桌面端本地持久化」的核心卖点相矛盾
- Time Machine 备份恢复后项目数据"假死"

**修复建议**：

**短期（最小改动）**：加载项目时检测路径有效性，无效时给出明确提示：
```typescript
// initProjectStore 中
const validatedMeta = await validateProjectPaths(meta);
if (validatedMeta.brokenPaths.length > 0) {
  toast.warn(`项目 ${validatedMeta.brokenPaths.join(', ')} 关联的文件已丢失，可能因迁移设备或清理磁盘导致`);
}
```

**长期**：
1. 持久化时存储**相对路径**（相对于项目目录）
2. 加载时解析为绝对路径：
```typescript
function resolveProjectPath(projectId: string, relativePath: string): string {
  return `${APP_LOCAL_DATA_DIR}/projects/${projectId}/${relativePath}`;
}
```
3. 新增「项目包导出/导入」功能：将 `meta.json` + `segments.json` + `assets/` + `videos/` 打包为 `.zip`

---

### 4. 磁盘空间不足时合成成果直接丢失

**业务场景**：
1. 用户 Mac 剩余空间 2GB
2. 合成一个 1.5GB 的视频，临时文件 + 输出文件刚好占满磁盘
3. FFmpeg 合成成功，但 `std::fs::copy` 移动最终文件时因磁盘满失败
4. 临时文件被清理，用户一无所获

**实际代码**：
```rust
// video_processing.rs:93
std::fs::rename(&final_output, output_path)
    .or_else(|_| {
        std::fs::copy(&final_output, output_path)
            .and_then(|_| std::fs::remove_file(&final_output))
    })
```

`rename` 跨卷时失败，`copy` 在磁盘满时失败，临时文件在 `Drop` 或后续清理中被删除。

**用户实际看到**：
- 进度条走到 100%，显示「正在保存...」
- 突然报错「移动最终视频失败」
- 数分钟的等待 + 积分消耗，结果什么都没有

**影响评估**：
- 极端挫败感：用户最高预期时刻（"马上完成了"）直接失败
- 积分和时间双重浪费

**修复建议**：
1. 合成前检查磁盘空间：
```rust
// 在 handleStart 调用前
let required_space = estimate_output_size(video_paths) * 2;  // 输出 + 临时文件
let available = fs2::available_space(&output_parent)?;
if available < required_space {
    return Err("磁盘空间不足，需要至少 {} GB 可用空间".into());
}
```
2. `copy` 失败时保留临时文件，给用户手动恢复的机会：
```rust
if let Err(e) = std::fs::copy(&final_output, output_path) {
    return Err(format!("保存失败（磁盘可能已满）。临时文件保留在: {}，错误: {}",
        final_output.display(), e));
}
```

---

### 5. 大文件上传/下载全量读内存——低配机器 OOM

**业务场景**：
1. 用户生成了一段 10 分钟 1080p 视频，文件大小 500MB
2. 点击「上传」或系统自动上传到七牛云/后端
3. Rust 侧 `std::fs::read(local_path)` 将 500MB 全量读入内存
4. 再复制到 reqwest multipart body，峰值内存占用 >1GB
5. 8GB 内存的 MacBook Air 可能触发系统 OOM，应用被杀死

**实际代码**：
```rust
// Rust 侧 upload_video_file / upload_audio_file
let file_bytes = match std::fs::read(local_path) { ... };
let form = reqwest::multipart::Form::new()
    .part("file", reqwest::multipart::Part::bytes(file_bytes) ...);
```

```rust
// Rust 侧 download_file
let client = reqwest::Client::new();  // 默认无超时
let bytes = match response.bytes().await { ... };  // 全量入内存
std::fs::write(&safe_output, &bytes);
```

**用户实际看到**：
- 上传/下载大文件时应用突然消失（被系统杀死）
- 或进度条卡住很久，没有任何反馈
- 重启后需要重新开始整个流程

**影响评估**：
- 长视频用户（核心目标用户群）完全无法使用
- 应用稳定性差，低配置设备体验极差

**修复建议**：

上传改用流式：
```rust
use tokio::fs::File;
use tokio::io::AsyncReadExt;

let file = File::open(local_path).await?;
let stream = tokio_util::codec::FramedRead::new(file, tokio_util::codec::BytesCodec::new());
let body = reqwest::Body::wrap_stream(stream);
let part = reqwest::multipart::Part::stream(body)
    .file_name(filename)
    .mime_str("video/mp4")?;
```

下载改用边下边写 + 超时：
```rust
let client = reqwest::Client::builder()
    .timeout(std::time::Duration::from_secs(300))
    .connect_timeout(std::time::Duration::from_secs(30))
    .build()?;

let mut response = client.get(url).send().await?;
let mut file = tokio::fs::File::create(&safe_output).await?;
while let Some(chunk) = response.chunk().await? {
    tokio::io::copy(&mut chunk.as_ref(), &mut file).await?;
}
```

---

## 三、🟡 中等问题（影响体验或存在数据风险）

### 6. 保存项目失败用户完全不知情——数据丢失风险

**业务场景**：
1. 用户在 CoverDesign 页面调整标题，触发自动保存
2. 磁盘已满（或文件被其他程序锁定）
3. `saveMetaToLocalFile` 抛出 IO 错误
4. 错误被 `.catch` 捕获后只 `console.error`，**没有任何 UI 提示**
5. 用户继续编辑，关闭应用
6. 重新打开后发现之前的修改全部丢失

**实际代码**：
```typescript
// localStorage.ts 中的 safeInvoke 错误处理
try {
  const result = await invoke<T>(cmd, args);
  return result;
} catch (error) {
  console.error(`Tauri IPC 调用失败 [${cmd}]:`, error);
  throw error;  // 抛给上层
}

// saveMetaToLocalFile 调用链
metaSavePromise = metaSavePromise.then(task).catch(err => {
  console.error('保存项目元数据失败:', err);
  throw err;  // 继续抛出，但无人处理
});
```

**用户实际看到**：
- 没有任何错误提示
- 下次打开项目时数据回到旧状态
- 用户以为是应用 bug，不信任自动保存功能

**修复建议**：
在 `saveMetaToLocalFile` 的 catch 中增加用户可见提示：
```typescript
metaSavePromise = metaSavePromise.then(task).catch(err => {
  console.error('保存项目元数据失败:', err);
  const message = err instanceof Error ? err.message : String(err);
  if (message.includes('磁盘') || message.includes('space') || message.includes('No space')) {
    toast.error('项目保存失败：磁盘空间不足，请清理后重试');
  } else {
    toast.error('项目保存失败，请检查文件权限或重启应用');
  }
  throw err;
});
```

---

### 7. 配音分段失败静默继续——"部分缺失"的配音

**业务场景**：
1. 用户生成 10 段配音，每段对应一个分镜
2. 第 3 段 `extractAudioSegment` 或 `uploadAudioFile` 失败（网络抖动、文件被占用）
3. 错误被 catch 后只 `console.error`，循环继续
4. 最终提示「配音合成完成」
5. 用户导出视频后发现第 3 分镜没有声音

**实际代码**：
```typescript
// VoiceSynthesis.tsx:243-245（近似逻辑）
for (const segment of segments) {
  try {
    await extractAudioSegment(...);
    await uploadAudioFile(...);
  } catch (err) {
    console.error('分段处理失败:', err);  // ❌ 静默吞掉
    // 循环继续...
  }
}
toast.success('配音合成完成');
```

**用户实际看到**：
- 提示「配音合成完成」✅
- 导出视频后发现部分片段无声 ❌
- 无法定位是哪一段出了问题

**修复建议**：
```typescript
const failedSegments: string[] = [];
for (const segment of segments) {
  try {
    await extractAudioSegment(...);
    await uploadAudioFile(...);
  } catch (err) {
    console.error('分段处理失败:', err);
    failedSegments.push(segment.id);
    // 继续处理其他段，但记录失败
  }
}
if (failedSegments.length > 0) {
  toast.warn(`配音合成部分完成，第 ${failedSegments.join(', ')} 段处理失败，请检查网络后重试`);
} else {
  toast.success('配音合成完成');
}
```

---

### 8. 轮询任务状态遇到网络闪断直接失败——长任务前功尽弃

**业务场景**：
1. 用户提交 Vidu 视频生成任务，进入轮询等待
2. 轮询 3 分钟后，用户 WiFi 短暂断开 5 秒
3. `getTaskStatus` 抛出网络错误
4. `while` 循环无内部 try-catch，整个函数抛出异常
5. 前端提示「视频生成失败」
6. 实际上后端任务仍在执行，用户需重新提交并再次等待

**实际代码**：
```typescript
// useVideoGeneration.ts / ScriptCreation.tsx 等处的轮询逻辑
while (status === 'pending' || status === 'running') {
  await new Promise(resolve => setTimeout(resolve, pollInterval));
  const resp = await taskApi.getTaskStatus(taskId);  // ❌ 无 try-catch
  status = resp.status;
}
```

**用户实际看到**：
- 等待数分钟后突然报错「失败」
- 重新提交后又需等待同样长的时间
- 后端实际上可能已经完成了任务，但前端放弃了

**修复建议**：
```typescript
let consecutiveErrors = 0;
const MAX_CONSECUTIVE_ERRORS = 3;

while (status === 'pending' || status === 'running') {
  await new Promise(resolve => setTimeout(resolve, pollInterval));
  try {
    const resp = await taskApi.getTaskStatus(taskId);
    status = resp.status;
    consecutiveErrors = 0;
  } catch (err) {
    consecutiveErrors++;
    console.warn(`轮询失败 (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, err);
    if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
      throw new Error('网络异常，视频状态获取失败，请稍后重试');
    }
    // 继续轮询，给用户一个恢复的机会
  }
}
```

---

### 9. 余额获取失败 = 误判为余额不足——有积分却被阻止

**业务场景**：
1. 用户打开应用，网络较差
2. `fetchBalance` 调用失败（`console.error` 后静默）
3. `balance` 保持默认值 `0`
4. 用户点击「合成视频」
5. 预检：`currentBalance < requiredPoints` → `0 < 5` → **阻止**
6. 用户明明有积分，却无法使用

**实际代码**：
```typescript
// pointStore.ts
fetchBalance: async () => {
  try {
    const data = await pointsApi.getBalance();
    set({ balance: data.balance, rules: data.rules });
  } catch (e) {
    console.error('获取积分余额失败:', e);  // ❌ 静默失败
    // balance 保持旧值或 0
  }
},
```

**用户实际看到**：
- 点击合成按钮后弹出「积分不足」充值弹窗
- 用户去「我的」页面查看，发现余额显示为 0 或旧值
- 刷新页面后余额恢复正常

**修复建议**：
```typescript
fetchBalance: async () => {
  try {
    const data = await pointsApi.getBalance();
    set({ balance: data.balance, rules: data.rules, balanceError: null });
  } catch (e) {
    console.error('获取积分余额失败:', e);
    set({ balanceError: '获取余额失败，请检查网络' });
    // balance 保持旧值，不要变成 0
  }
},

// 预检时
if (balanceError) {
  // 无法确认余额，允许操作但提示风险
  toast.warn('余额获取失败，将尝试扣费，若余额不足会提示充值');
  return true;
}
```

---

### 10. 多应用实例并发修改导致数据覆盖

**业务场景**：
1. 用户双击应用图标，意外打开两个窗口（或命令行启动第二个实例）
2. 实例 A 在 CoverDesign 修改标题为「现代简约风」
3. 实例 B 在 ScriptCreation 修改主题为「奶油风」
4. 两个实例同时点击保存
5. 实例 A 的保存覆盖了实例 B 的修改（或反之）

**根因分析**：
`saveMetaToLocalFile` 使用 Read-Modify-Write 模式：
1. 读 `meta.json`
2. 内存合并
3. 写 `meta.json`（带文件锁，保证单写）

但文件锁只保护"写"操作，两个实例可以同时读取同一个文件，然后各自基于旧版本修改并写入，导致后写入的覆盖前者。

**用户实际看到**：
- 在一个窗口里明明保存了修改
- 切到另一个窗口再切回来，发现修改消失了
- 用户以为是应用不稳定

**修复建议**：
1. **应用层单实例锁**：启动时检查是否已有实例在运行
```rust
// main.rs
let single = single_instance::SingleInstance::new("cn.meijiaka.ai-zy").unwrap();
if !single.is_single() {
    // 已有实例，唤起旧实例窗口并退出
    return;
}
```
2. **或文件锁扩展为读写锁**：读取时也加共享锁，防止并发读-改-写

---

### 11. BGM 预览硬编码开发者路径——正式包无法预览系统 BGM

**业务场景**：
1. 用户安装正式版应用
2. 进入 BGM 选择弹窗
3. 点击任意系统 BGM 的试听按钮
4. 无声音，或报错

**实际代码**：
```typescript
// VideoCompose.tsx:113
const audioSrc = item.url || (item.filePath ? `/Users/0fun/work/meijiaka-zy/mixkit_bgm/${item.filePath}` : '');
```

当 `item.url` 为空且 `item.filePath` 存在时，构造的路径是开发者本机绝对路径 `/Users/0fun/...`，正式包用户机器上不存在此目录。

**影响评估**：
- 虽然云端化后 `item.url` 应始终有值，但如果 API 返回异常或旧数据残留，会回退到硬编码路径
- 开发环境测试时「正常」的功能，正式包上直接失效

**修复建议**：
直接移除硬编码回退，若 `item.url` 为空则禁用试听：
```typescript
const audioSrc = item.url;
if (!audioSrc) {
  toast.warn('该音乐暂无可用的试听链接');
  return;
}
```

---

### 12. 封面 Fabric.js 跨域加载失败无用户提示

**业务场景**：
1. 用户选择一张网络图片作为封面背景
2. 该图片服务器未配置 CORS 头
3. `image.crossOrigin = 'anonymous'` 加载失败
4. `useCoverFabric.ts` 中 catch 静默吞掉错误
5. Canvas 上背景为空白，用户不知道为什么

**实际代码**：
```typescript
// useCoverFabric.ts:192-196
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = (e) => reject(e);
image.src = imagePath;
// ...
} catch {
  // no-op: 背景图加载失败已在内部处理
}
```

**用户实际看到**：
- 选了背景图，但 Canvas 预览为纯色背景
- 不知道是因为图片跨域、链接失效还是其他原因

**修复建议**：
在 catch 中区分错误类型并提示：
```typescript
} catch (err) {
  console.error('封面背景加载失败:', err);
  if (imagePath.startsWith('http')) {
    toast.error('封面图片加载失败，可能是跨域限制或链接失效，请尝试本地上传');
  } else {
    toast.error('封面图片加载失败，文件可能已被移动或删除');
  }
}
```

---

## 四、🟢 低风险/建议（5 项）

### 13. 自动更新后无数据迁移逻辑

**业务场景**：
1. v1.6.0 用户自动更新到 v1.7.0
2. v1.7.0 新增了某个必填字段（如 `videoCodec`）
3. 旧项目加载后该字段为 `undefined`
4. 如果新功能直接读取此字段不做防御，可能崩溃

**现状**：
- `migrateMeta` 只处理了 `v0 → v1`（添加 `version` 字段）
- 注释预留了 `v1 → v2` 的扩展点，但无实际实现
- Tauri updater 安装后只是重启应用，不触发任何数据迁移

**建议**：
在应用启动时（`bootstrap` 或 `App.tsx` useEffect）增加一次性的全局迁移检查：
```typescript
async function runGlobalMigrations() {
  const appVersion = await getVersion();
  const lastMigratedVersion = localStorage.getItem('last_migrated_version');
  if (lastMigratedVersion === appVersion) return;
  
  // 遍历所有本地项目，执行迁移
  const projects = await localProjectApi.listProjects();
  for (const project of projects) {
    const meta = await localProjectApi.loadMeta(project.id);
    if (meta) {
      const migrated = migrateMeta(meta);  // 扩展此函数
      await localProjectApi.saveMeta(project.id, migrated);
    }
  }
  
  localStorage.setItem('last_migrated_version', appVersion);
}
```

---

### 14. 旧字段删除无运行时降级处理

**现状**：
Git 历史中有多个字段被删除/重命名：
- `subtitlePreset` → `captionPreset`
- `dubbingVoiceId` → `selectedVoiceId`
- `selectedHumanId` / `selectedElementId` 被移除
- `caption` → `mainTitle`（CoverDesign 中有 fallback）

**当前行为**：
旧项目加载后，旧字段保留在 `meta.json` 中但被忽略，对应功能降级为默认状态。对用户来说，打开旧项目后发现某些设置"复位"了，但不明白为什么。

**建议**：
在 `migrateMeta` 中增加字段映射：
```typescript
function migrateMeta(raw: Record<string, unknown>): Partial<ProjectMeta> {
  // v0 → v1
  if ((raw.version as number) < 1) {
    raw.version = 1;
  }
  
  // 字段重命名映射
  if (raw.subtitlePreset && !raw.captionPreset) {
    raw.captionPreset = raw.subtitlePreset;
    delete raw.subtitlePreset;
  }
  if (raw.dubbingVoiceId && !raw.selectedVoiceId) {
    raw.selectedVoiceId = raw.dubbingVoiceId;
    delete raw.dubbingVoiceId;
  }
  
  return raw as Partial<ProjectMeta>;
}
```

---

## 五、按业务维度汇总

| 业务维度 | 问题编号 | 核心风险 | 用户感知 |
|----------|----------|----------|----------|
| **BGM/音频** | 1, 11 | 合成无声、预览失效 | "为什么选了音乐却没有声音" |
| **积分/资金** | 2, 9 | 白嫖可能、误判余额不足 | "明明有积分却说不让用" |
| **数据持久化** | 3, 6, 10, 13, 14 | 换机报废、保存失败无感知、多实例覆盖 | "修改保存后怎么没了" |
| **视频合成** | 4, 5, 7, 8 | 磁盘满丢失、OOM、分段缺失、长任务闪断 | "等了5分钟结果什么都没有" |
| **封面/视觉** | 12 | 跨域图片加载失败无提示 | "选了图片但封面是空的" |

---

## 六、修复优先级（按业务影响排序）

### P0（立即修复，影响核心功能或资金）
1. **#1 BGM 混音链路缺口**：混音前下载 URL 到本地缓存
2. **#2 积分 TOCTOU**：扣费失败时清理已生成文件，或推动后端预占机制
3. **#5 大文件 OOM**：上传/下载改用流式传输

### P1（本轮迭代修复，影响体验）
4. **#4 磁盘满保护**：合成前检查空间，`copy` 失败保留临时文件
5. **#6 保存失败无提示**：`saveMetaToLocalFile` 错误 toast 提示
6. **#7 分段配音失败静默**：记录失败段并提示用户
7. **#8 轮询闪断**：增加网络错误容忍和重试
8. **#9 余额误判**：余额获取失败时不阻断用户

### P2（后续排期，架构改进）
9. **#3 项目跨设备迁移**：路径相对化 + 导出/导入功能
10. **#10 多实例并发**：应用层单实例锁
11. **#11 BGM 预览硬编码**：移除开发者路径
12. **#12 封面跨域提示**：增加错误提示
13. **#13 自动更新迁移**：全局迁移框架
14. **#14 旧字段映射**：`migrateMeta` 扩展
