先整批校验设计图再登记,避免留下孤儿资源
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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user