From 40463181182eb6905fd1fa8f6c9c2eef137400fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 17:44:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=88=E6=95=B4=E6=89=B9=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=9B=BE=E5=86=8D=E7=99=BB=E8=AE=B0=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E7=95=99=E4=B8=8B=E5=AD=A4=E5=84=BF=E8=B5=84?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src-tauri/src/ui_editor/agent_tools/creation.rs 把 prepare_design_image 拆成只读的 resolve_design_image 与登记两步:旧实现边校验边 register_local_asset_at,批次里后面某张设计图格式/尺寸不合法或路径缺失时,前面已登记的图片会留在 manifest.json 里且没有任何文档引用,而 rollback_created_document 只覆盖更晚的初始化失败 同批次查重改为按解析出的身份(已登记图片用 assetId、待登记图片用归一化路径)在登记前判定,重复引用不再先写一次 manifest 同文件新增 ResolvedDesignImage 承载「已登记 / 本次待登记」两种解析结果,待登记项在第二阶段登记后才拿到 assetId src-tauri/src/tests/project.rs 新增 create_ui_design_doc_from_images_leaves_no_orphan_assets_when_a_later_image_fails,覆盖「第二张缺图时第一张不得进 manifest」(已用回退实现的失败结果反证过) --- .../src-tauri/src/tests/project.rs | 54 +++++++ .../src/ui_editor/agent_tools/creation.rs | 145 ++++++++++++------ 2 files changed, 154 insertions(+), 45 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 4b5ab0d47..f1ed549ff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2897,6 +2897,60 @@ fn create_ui_design_doc_from_images_skips_a_taken_ui_design_path() { fs::remove_dir_all(root).ok(); } +/// 批次里后面某张设计图不合法时,前面已通过校验的图片不能留在 manifest 里。 +/// +/// 登记动作本身是 manifest 副作用:如果边校验边登记,一旦后续引用失败就没有回滚路径, +/// 会在 manifest 里留下没有任何文档引用的图片条目。整批先校验再登记才不会漏。 +#[test] +fn create_ui_design_doc_from_images_leaves_no_orphan_assets_when_a_later_image_fails() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "UI 设计批次校验").expect("project init"); + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 32, + 24, + image::Rgba([1, 2, 3, 255]), + )) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode design image"); + fs::create_dir_all(root.join("assets")).expect("assets dir"); + fs::write(root.join("assets/page.png"), bytes).expect("write design image"); + + let error = crate::ui_editor::agent_tools::create_ui_design_doc_from_images( + crate::ui_editor::agent_tools::CreateUiDesignDocFromImagesInput { + project_path: root.to_string_lossy().to_string(), + expected_project_id: "project-1".to_string(), + images: vec![ + crate::ui_editor::agent_tools::UiDesignImageReference { + asset_id: None, + path: Some("assets/page.png".to_string()), + }, + crate::ui_editor::agent_tools::UiDesignImageReference { + asset_id: None, + path: Some("assets/missing.png".to_string()), + }, + ], + }, + ) + .expect_err("批次中存在缺失的设计图时必须整批失败"); + assert!(error.contains("missing.png"), "{error}"); + + let manifest = crate::read_existing_manifest_for_project(&root).expect("read manifest"); + assert!( + manifest + .assets + .iter() + .all(|asset| asset.local_path != "assets/page.png"), + "整批校验失败时不该把已校验通过的图片登记进 manifest" + ); + assert!(!root.join("ui/UI 设计 1.json").is_file()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn register_local_asset_rejects_missing_or_unsafe_path() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/creation.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/creation.rs index b4095a084..7ba5f227f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/creation.rs @@ -131,34 +131,102 @@ pub(crate) fn create_ui_design_doc_from_images( }) } -/// 解析并登记设计图,返回要装进文档的图片与本次调用新登记的图片资源 id。 +/// 只读校验出来的设计图引用:资源身份已确定,但还没写 manifest。 +enum ResolvedDesignImage { + /// manifest 里已有这张图片,本次直接复用它的资源身份。 + Registered { + asset_id: String, + relative_path: String, + pixel_size: (u32, u32), + }, + /// 本次调用需要新登记的设计图,登记后才有 assetId。 + Pending { + relative_path: String, + pixel_size: (u32, u32), + media_type: &'static str, + }, +} + +impl ResolvedDesignImage { + /// 同批次查重键:已登记图片按资源身份,待登记图片按归一化路径。 + fn identity(&self) -> String { + match self { + ResolvedDesignImage::Registered { asset_id, .. } => asset_id.clone(), + ResolvedDesignImage::Pending { relative_path, .. } => { + format!("path:{relative_path}") + } + } + } +} + +/// 先整批校验、再登记,任何一张不合法都不会在 manifest 里留下无人引用的图片条目。 fn prepare_design_images( root: &Path, manifest: &GameCreationAppManifest, references: &[UiDesignImageReference], ) -> Result<(Vec, Vec), String> { - let mut images = Vec::with_capacity(references.len()); - let mut registered_ids = Vec::new(); - let mut seen_ids = BTreeSet::new(); + let mut resolved = Vec::with_capacity(references.len()); + let mut seen = BTreeSet::new(); for reference in references { - let (image, registered_id) = prepare_design_image(root, manifest, reference)?; - if let Some(id) = registered_id { - registered_ids.push(id); - } - if !seen_ids.insert(image.image_id.clone()) { + let image = resolve_design_image(root, manifest, reference)?; + if !seen.insert(image.identity()) { return Err("同一次调用不能重复登记同一张设计图".to_string()); } - images.push(image); + resolved.push(image); + } + let mut images = Vec::with_capacity(resolved.len()); + let mut registered_ids = Vec::new(); + for image in resolved { + let (asset_id, relative_path, pixel_size) = match image { + ResolvedDesignImage::Registered { + asset_id, + relative_path, + pixel_size, + } => (asset_id, relative_path, pixel_size), + ResolvedDesignImage::Pending { + relative_path, + pixel_size, + media_type, + } => { + let registered = register_local_asset_at( + root, + &relative_path, + GameCreationAppAssetKind::UiDesign, + media_type, + "ui-design", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + )?; + registered_ids.push(registered.id.clone()); + (registered.id, relative_path, pixel_size) + } + }; + images.push(UiDesignDocumentImage { + image_id: asset_id, + path: relative_path, + pixel_size, + }); } Ok((images, registered_ids)) } -fn prepare_design_image( +/// 解析一张设计图引用并读取它的真实尺寸:只读,不写 manifest。 +fn resolve_design_image( root: &Path, manifest: &GameCreationAppManifest, reference: &UiDesignImageReference, -) -> Result<(UiDesignDocumentImage, Option), String> { - let (asset_id, relative_path, registered_id) = +) -> Result { + let (asset_id, relative_path, media_type): (Option, String, Option<&'static str>) = match (reference.asset_id.as_deref(), reference.path.as_deref()) { (Some(asset_id), None) => { let asset_id = asset_id.trim(); @@ -168,7 +236,7 @@ fn prepare_design_image( .find(|asset| asset.id == asset_id) .ok_or_else(|| format!("设计图资源不存在:{asset_id}"))?; require_image_media_type(&asset.media_type)?; - (asset.id.clone(), asset.local_path.clone(), None) + (Some(asset.id.clone()), asset.local_path.clone(), None) } (None, Some(path)) => { let normalized_path = normalize_relative_path(path.trim())?; @@ -180,51 +248,38 @@ fn prepare_design_image( // 已登记图片复用原资源身份,不因为本次调用改写它的 kind 与分类。 Some(existing) => { require_image_media_type(&existing.media_type)?; - (existing.id.clone(), existing.local_path.clone(), None) + (Some(existing.id.clone()), existing.local_path.clone(), None) } None => { let absolute_path = resolve_local_project_path(root, &normalized_path)?; let media_type = design_image_media_type(&absolute_path)?; - let registered = register_local_asset_at( - root, - &normalized_path, - GameCreationAppAssetKind::UiDesign, - media_type, - "ui-design", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - )?; - (registered.id.clone(), normalized_path, Some(registered.id)) + (None, normalized_path, Some(media_type)) } } } _ => return Err("每张设计图必须且只能给 assetId 或 path".to_string()), }; let absolute_path = resolve_local_project_path(root, &relative_path)?; - let dimensions = image::open(&absolute_path) + let pixel_size = image::open(&absolute_path) .map_err(|error| format!("读取设计图失败:{relative_path}:{error}"))? .dimensions(); - if dimensions.0 == 0 || dimensions.1 == 0 { + if pixel_size.0 == 0 || pixel_size.1 == 0 { return Err(format!("设计图尺寸无效:{relative_path}")); } - Ok(( - UiDesignDocumentImage { - image_id: asset_id, - path: relative_path, - pixel_size: dimensions, - }, - registered_id, - )) + match (asset_id, media_type) { + (Some(asset_id), _) => Ok(ResolvedDesignImage::Registered { + asset_id, + relative_path, + pixel_size, + }), + (None, Some(media_type)) => Ok(ResolvedDesignImage::Pending { + relative_path, + pixel_size, + media_type, + }), + // 上面两条已覆盖 (assetId) 与 (path 未登记) 两种解析结果。 + (None, None) => Err("每张设计图必须且只能给 assetId 或 path".to_string()), + } } fn require_image_media_type(media_type: &str) -> Result<(), String> {