先整批校验设计图再登记,避免留下孤儿资源

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」(已用回退实现的失败结果反证过)
This commit is contained in:
2026-09-24 17:44:19 +08:00
parent 56760a7397
commit 4046318118
2 changed files with 154 additions and 45 deletions
@@ -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();
@@ -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<UiDesignDocumentImage>, Vec<String>), 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>), String> {
let (asset_id, relative_path, registered_id) =
) -> Result<ResolvedDesignImage, String> {
let (asset_id, relative_path, media_type): (Option<String>, 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> {