diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 50352f6d4..9335db148 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,5 +1,7 @@ use super::*; use crate::ui_editor::resource::font::FontAsset; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024; const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024; @@ -2320,6 +2322,54 @@ mod ui_editor_font_tests { } } +#[cfg(test)] +mod remote_asset_path_tests { + use super::*; + + #[test] + fn remote_asset_paths_distinguish_ids_that_sanitize_to_the_same_name() { + let slash_path = remote_asset_local_path("a/b", "png"); + let colon_path = remote_asset_local_path("a:b", "png"); + + assert_ne!(slash_path, colon_path); + assert_eq!(slash_path, remote_asset_local_path("a/b", "png")); + assert!(slash_path.starts_with("assets/uploads/remote-a_b-")); + assert!(slash_path.ends_with(".png")); + } + + #[test] + fn duplicate_remote_asset_destination_is_rejected() { + let mut destinations = HashSet::new(); + let local_path = remote_asset_local_path("stable-id", "webp"); + + reserve_remote_asset_destination(&mut destinations, &local_path) + .expect("reserve first destination"); + assert_eq!( + reserve_remote_asset_destination(&mut destinations, &local_path) + .expect_err("reject duplicate destination"), + format!("平台素材目标路径重复:{local_path}") + ); + } +} + +fn remote_asset_local_path(asset_id: &str, extension: &str) -> String { + let readable_id = sanitize_file_name(asset_id); + let identity_digest = format!("{:x}", Sha256::digest(asset_id.as_bytes())); + let short_digest = &identity_digest[..12]; + format!("assets/uploads/remote-{readable_id}-{short_digest}.{extension}") +} + +fn reserve_remote_asset_destination( + destinations: &mut HashSet, + local_path: &str, +) -> Result<(), String> { + if destinations.insert(local_path.to_string()) { + Ok(()) + } else { + Err(format!("平台素材目标路径重复:{local_path}")) + } +} + pub(crate) async fn import_ui_editor_remote_assets( project_path: String, assets: Vec, @@ -2332,6 +2382,7 @@ pub(crate) async fn import_ui_editor_remote_assets( // IPC 只传稳定引用/签名 URL,由 Rust 流式下载并限制响应体;下载期间不占有项目写锁。 let mut total_size = 0u64; let mut downloads = Vec::with_capacity(assets.len()); + let mut destinations = HashSet::with_capacity(assets.len()); for asset in assets { let asset_id = json_string_field(&asset, "assetId") .or_else(|| json_string_field(&asset, "objectKey")) @@ -2358,21 +2409,21 @@ pub(crate) async fn import_ui_editor_remote_assets( } else { return Err("平台素材不是受支持的 PNG/JPEG/WEBP 图片".to_string()); }; - downloads.push((asset, asset_id, media_type.to_string(), bytes)); + let extension = infer_file_extension( + json_string_field(&asset, "objectKey") + .or_else(|| json_string_field(&asset, "imageSrc")) + .as_deref(), + media_type, + ); + let local_path = remote_asset_local_path(&asset_id, extension); + reserve_remote_asset_destination(&mut destinations, &local_path)?; + downloads.push((asset, asset_id, media_type.to_string(), local_path, bytes)); } let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; // 远程素材导入保持与本地图片/字体相同的增量语义,不对已成功项目文件做整批回滚。 let mut imported = Vec::with_capacity(downloads.len()); - for (asset, asset_id, media_type, bytes) in downloads { - let extension = infer_file_extension( - json_string_field(&asset, "objectKey") - .or_else(|| json_string_field(&asset, "imageSrc")) - .as_deref(), - &media_type, - ); - let local_name = sanitize_file_name(&asset_id); - let local_path = format!("assets/uploads/remote-{local_name}.{extension}"); + for (asset, asset_id, media_type, local_path, bytes) in downloads { let target = resolve_local_project_path(root, &local_path)?; if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(|e| format!("创建导入目录失败:{e}"))?; diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 6f0f35a4e..6bc8c47ad 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -46,7 +46,7 @@ UI Editor 当前把“识别界面结构”定义为结构草稿阶段,而不 `merge_ui` 的 Tauri 命令参数继续保持 `State`,不新增前端字符串 payload 或 IPC 参数上限。命令进入 Rust 后、构建 LLM 请求前,按每棵源树独立限制 `512` 个节点和 `32` 层,不跨树求和;序列化投影超过 `2 MiB` 时同样拒绝。本边界不宣称覆盖 Tauri 自身的入参反序列化阶段。 -UI Editor 的图片、字体和远程素材导入采用增量提交合同:输入先完成各入口已有的预检,随后按顺序逐项写入和登记;前面已成功的项目在后续写入或登记失败时保持有效,调用方必须重新读取 manifest 取得已提交集合。该入口不提供整批事务回滚,也不承诺失败后“零文件、零 manifest 变化”;后续如需原子批量导入,必须新增明确的 transaction / reconciliation 合同,不能把当前入口静默改成另一种语义。 +UI Editor 的图片、字体和远程素材导入采用增量提交合同:输入先完成各入口已有的预检,随后按顺序逐项写入和登记;前面已成功的项目在后续写入或登记失败时保持有效,调用方必须重新读取 manifest 取得已提交集合。远程素材目标文件名必须同时包含清洗后的可读 ID 与原始稳定 ID 的确定性摘要,不能让不同 ID 因清洗结果相同而覆盖已有文件;同一批次计算出的重复目标路径必须在写入前拒绝。该入口不提供整批事务回滚,也不承诺失败后“零文件、零 manifest 变化”;后续如需原子批量导入,必须新增明确的 transaction / reconciliation 合同,不能把当前入口静默改成另一种语义。 作为 LLM 参考图使用的 UI 设计图单张文件上限为 `5 MiB`,不设置多图合计大小上限。前端设计图 AssetImporter 对本地和远端入口使用相同单文件限制;结构识别与界面语义建议在 Rust 中先检查文件元数据、再有限读取,并把读取与 base64 编码放在 blocking worker,避免阻塞 Tokio async worker。本阶段不新增图片像素数限制或命令超时。