Files
meijiaka-zy/docs/frontend-compatibility-audit-v2.md
T
小鱼开发 915339d42a release: bump version to 1.6.1
Frontend fixes:
- fix(VideoCompose): clear step dirty flag after compose success
- refactor(MyWorks): play product videos directly without transcode cache
- feat(CoverDesign): swap main/subtitle positions in cover preview
- fix(SubtitleBurning): charge points after burn success instead of before
- fix(VoiceSynthesis/VideoGeneration/SubtitleBurning/CoverDesign): mark downstream steps dirty on re-generation
- fix(MyWorks): bind video event listeners after async videoUrl load
- fix(CoverDesign): revoke Blob URLs on upload/unmount to prevent memory leak
2026-05-25 22:35:35 +08:00

26 KiB
Raw Blame History

前端系统兼容性审查报告 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 输入超时 → 混音失败

实际代码路径

// 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);
  }
}
// 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 为本地文件:

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

let safe_bgm = validate_safe_path(bgm_path)?;  // 强制要求本地路径

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

业务场景

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

实际代码

// 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:扣费前置 + 失败清理 若无法改后端,至少做到失败时清理文件:

} 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.jsonsegments.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 备份恢复后项目数据"假死"

修复建议

短期(最小改动):加载项目时检测路径有效性,无效时给出明确提示:

// initProjectStore 中
const validatedMeta = await validateProjectPaths(meta);
if (validatedMeta.brokenPaths.length > 0) {
  toast.warn(`项目 ${validatedMeta.brokenPaths.join(', ')} 关联的文件已丢失,可能因迁移设备或清理磁盘导致`);
}

长期

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

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

业务场景

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

实际代码

// 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. 合成前检查磁盘空间:
// 在 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());
}
  1. copy 失败时保留临时文件,给用户手动恢复的机会:
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 侧 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 侧 download_file
let client = reqwest::Client::new();  // 默认无超时
let bytes = match response.bytes().await { ... };  // 全量入内存
std::fs::write(&safe_output, &bytes);

用户实际看到

  • 上传/下载大文件时应用突然消失(被系统杀死)
  • 或进度条卡住很久,没有任何反馈
  • 重启后需要重新开始整个流程

影响评估

  • 长视频用户(核心目标用户群)完全无法使用
  • 应用稳定性差,低配置设备体验极差

修复建议

上传改用流式:

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")?;

下载改用边下边写 + 超时:

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. 重新打开后发现之前的修改全部丢失

实际代码

// 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 中增加用户可见提示:

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 段 extractAudioSegmentuploadAudioFile 失败(网络抖动、文件被占用)
  3. 错误被 catch 后只 console.error,循环继续
  4. 最终提示「配音合成完成」
  5. 用户导出视频后发现第 3 分镜没有声音

实际代码

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

用户实际看到

  • 提示「配音合成完成」
  • 导出视频后发现部分片段无声
  • 无法定位是哪一段出了问题

修复建议

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. 实际上后端任务仍在执行,用户需重新提交并再次等待

实际代码

// 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;
}

用户实际看到

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

修复建议

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 < requiredPoints0 < 5阻止
  6. 用户明明有积分,却无法使用

实际代码

// 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 或旧值
  • 刷新页面后余额恢复正常

修复建议

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

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

业务场景

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

实际代码

// 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 为空则禁用试听:

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 上背景为空白,用户不知道为什么

实际代码

// 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 中区分错误类型并提示:

} 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 安装后只是重启应用,不触发任何数据迁移

建议 在应用启动时(bootstrapApp.tsx useEffect)增加一次性的全局迁移检查:

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 历史中有多个字段被删除/重命名:

  • subtitlePresetcaptionPreset
  • dubbingVoiceIdselectedVoiceId
  • selectedHumanId / selectedElementId 被移除
  • captionmainTitleCoverDesign 中有 fallback

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

建议migrateMeta 中增加字段映射:

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(本轮迭代修复,影响体验)

  1. #4 磁盘满保护:合成前检查空间,copy 失败保留临时文件
  2. #6 保存失败无提示saveMetaToLocalFile 错误 toast 提示
  3. #7 分段配音失败静默:记录失败段并提示用户
  4. #8 轮询闪断:增加网络错误容忍和重试
  5. #9 余额误判:余额获取失败时不阻断用户

P2(后续排期,架构改进)

  1. #3 项目跨设备迁移:路径相对化 + 导出/导入功能
  2. #10 多实例并发:应用层单实例锁
  3. #11 BGM 预览硬编码:移除开发者路径
  4. #12 封面跨域提示:增加错误提示
  5. #13 自动更新迁移:全局迁移框架
  6. #14 旧字段映射migrateMeta 扩展