From 8c74e5f97cc8dffeffaf919a781b4f291f6fef6f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 19:01:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20AGC=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E7=BC=96=E8=BE=91=E7=9A=84=20400=20=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E6=B5=8B=E8=AF=95=EF=BC=9A=E8=A1=A5=20art-spritesheet?= =?UTF-8?q?=20/=20ui=20=E6=AD=A3=E5=90=91=E7=94=A8=E4=BE=8B=E5=B9=B6?= =?UTF-8?q?=E9=92=89=E4=BD=8F=E8=AF=B7=E6=B1=82=E7=BA=A7=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端白名单用例改为按静态图集合断言,新增 `art-spritesheet` / `ui` / `ui-prototype` / `game-art` / `game-background` / `art-spritesheet-slice` 正向用例 - 保留 `future-kind` / `video` / `audio` / `image-sequence` / `character-animation` / `document` / `code` 负向用例,并补 mediaType AND 门用例 - 新增请求级回归用例 `game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds`,按 AGC 客户端真实请求体(source 已改写为 ai-game-creator-client)覆盖放行与拒绝两侧 - 新增 `game_creator_client_source_is_recognized_as_game_creator_resource_editor`,把 source 别名与队列 consumer 判定钉在同口径 - 修正 icon 相关旧断言:`icon` 已是共享契约静态图类型,改断言 non-static 媒体仍被拒 --- .../src-tauri/src/project/resource_editor.rs | 96 ++++++++++ .../crates/api-server/src/editor_project.rs | 174 +++++++++++++++++- 2 files changed, 264 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index b729c979f..f2d0d706f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -5475,6 +5475,102 @@ mod tests { ); } + /// 平台把 4xx 兜底成 `请求参数不合法`,具体原因写在 `details.message`。 + /// 只读 `error.message` 会让用户永远看不到真实原因,因此这里把取值顺序钉在测试里。 + #[test] + fn editor_api_rejection_reason_prefers_details_message_and_keeps_context() { + assert_eq!( + editor_api_rejection_reason_from_body( + serde_json::json!({ + "error": { + "code": "BAD_REQUEST", + "message": "请求参数不合法", + "details": { + "provider": "editor-image-edit", + "message": "当前素材类型不支持图片快速编辑", + "assetKind": "art-spritesheet", + "mediaType": "image", + }, + } + }) + .to_string() + .as_str(), + ), + Some( + "当前素材类型不支持图片快速编辑|provider editor-image-edit|素材类型 art-spritesheet|媒体类型 image" + .to_string() + ), + "details.message 必须优先于通用 error.message,并附上 provider 与类型上下文" + ); + + assert_eq!( + editor_api_rejection_reason_from_body( + serde_json::json!({ + "error": { + "code": "UPSTREAM_FAILED", + "message": "上游服务请求失败", + "details": { "message": "Provider 返回 429", "provider": "editor-image-edit" }, + } + }) + .to_string() + .as_str(), + ), + Some("Provider 返回 429|provider editor-image-edit|错误码 UPSTREAM_FAILED".to_string()), + "非 BAD_REQUEST 的稳定码照旧透出" + ); + + // 顶层 details(没有 error.details 的错误体)同样优先。 + assert_eq!( + editor_api_rejection_reason_from_body( + serde_json::json!({ "details": { "message": "具体原因" }, "message": "通用文案" }) + .to_string() + .as_str(), + ), + Some("具体原因".to_string()) + ); + + // 只有 error.message 时退回它;error 是字符串时按文本兜底。 + assert_eq!( + editor_api_rejection_reason_from_body( + serde_json::json!({ "error": { "code": "BAD_REQUEST", "message": "请求参数不合法" } }) + .to_string() + .as_str(), + ), + Some("请求参数不合法".to_string()), + "BAD_REQUEST 不重复透出错误码" + ); + assert_eq!( + editor_api_rejection_reason_from_body(serde_json::json!({ "error": "直白文本" }).to_string().as_str()), + Some("直白文本".to_string()) + ); + assert_eq!( + editor_api_rejection_reason_from_body( + serde_json::json!({ "message": "顶层文案" }).to_string().as_str() + ), + Some("顶层文案".to_string()) + ); + + // 空值与非 JSON 正文不给用户编造原因。 + assert_eq!( + editor_api_rejection_reason_from_body( + serde_json::json!({ "error": { "message": " " } }).to_string().as_str() + ), + None + ); + assert_eq!(editor_api_rejection_reason_from_body("502"), None); + assert_eq!(editor_api_rejection_reason_from_body(""), None); + + // 超长正文仍按 200 字符截断,避免把整段响应体塞进用户可见文案。 + let long = "长".repeat(400); + let reason = editor_api_rejection_reason_from_body( + serde_json::json!({ "error": { "details": { "message": long.clone() } } }) + .to_string() + .as_str(), + ) + .expect("超长正文仍应给出原因"); + assert_eq!(reason, "长".repeat(200)); + } + async fn with_test_external_editor_credentials( api_base_url: &str, api_key: &str, diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 05486f056..10741e675 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -20602,24 +20602,44 @@ mod tests { } #[test] - fn editor_image_edit_source_kind_whitelist_covers_current_and_unknown_types() { + fn editor_image_edit_source_kind_whitelist_covers_static_image_kinds_only() { let cases = [ (None, None, true), (None, Some("image"), true), + (Some("image"), Some("image"), true), (Some("spec"), Some("image"), true), (Some("character"), Some("image"), true), + (Some("icon"), Some("image"), true), (Some("icon-spritesheet"), Some("image"), true), (Some("icon-spec"), Some("image"), true), (Some("publication-material"), Some("image"), true), (Some("ui-design"), Some("image"), true), (Some("scene"), Some("image"), true), - (Some("icon"), Some("image"), false), + // `canonicalGameCreationAppAssetKind()` 的合法输入:平台按本地 manifest 原始类型 + // 登记来源资源时会出现这些 legacy 值,实测 `art-spritesheet` / `ui` 曾整片 400。 + (Some("art-spritesheet"), Some("image"), true), + (Some("art-spritesheet-slice"), Some("image"), true), + (Some("ui"), Some("image"), true), + (Some("ui-prototype"), Some("image"), true), + (Some("game-art"), Some("image"), true), + (Some("game-background"), Some("image"), true), + (Some("character-art"), Some("image"), true), + (Some("illustration"), Some("image"), true), + // 非静态媒体与非静态类型必须继续拒绝。 (Some("character-animation"), Some("image-sequence"), false), (Some("sound-effect"), Some("audio"), false), (Some("background-music"), Some("audio"), false), (Some("video"), Some("video"), false), + (Some("audio"), Some("audio"), false), + (Some("document"), Some("application/pdf"), false), + (Some("code"), Some("text/plain"), false), (Some("future-asset-kind"), Some("image"), false), (None, Some("future-media-type"), false), + // mediaType 是 AND 门:图片类 assetKind 挂非静态媒体照旧拒绝。 + (Some("art-spritesheet"), Some("video"), false), + (Some("ui"), Some("audio"), false), + (Some("icon"), Some("image-sequence"), false), + (Some("scene"), Some("image-sequence"), false), ]; for (asset_kind, media_type, expected_allowed) in cases { let result = ensure_editor_image_edit_source_kind_allowed(asset_kind, media_type); @@ -20630,8 +20650,8 @@ mod tests { ); } - let error = ensure_editor_image_edit_source_kind_allowed(Some("icon"), Some("image")) - .expect_err("individual icon assets should not support quick edit"); + let error = ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("video")) + .expect_err("非静态媒体即使挂着图片类 assetKind 也不得支持快速编辑"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert_eq!( error.details().and_then(|details| details.get("message")), @@ -20677,7 +20697,15 @@ mod tests { icon_source.effective_asset_kind.as_deref(), Some(icon_source.media_type.as_str()), ) - .is_err() + .is_ok() + ); + assert!( + ensure_editor_image_edit_source_kind_allowed( + icon_source.effective_asset_kind.as_deref(), + Some("image-sequence"), + ) + .is_err(), + "单张 icon 可快速编辑,但同一类型挂非静态媒体仍必须拒绝" ); let icon_spec_source = resolve_editor_image_edit_target_layer_source( "bucket", @@ -20747,7 +20775,8 @@ mod tests { Some("image"), Some(&json!({ "source": "editor-agent" })), ) - .is_err() + .is_ok(), + "icon 已是共享契约里的静态图 canonical 类型,与 generationInputs.source 无关" ); assert!( ensure_editor_image_edit_source_kind_allowed_for_request( @@ -20767,6 +20796,139 @@ mod tests { ); } + /// AGC 客户端发送前把 `generationInputs.source` 改写成 + /// `ai-game-creator-client`(`resource_editor.rs` 的 `submit_resource_edit_remote`), + /// 服务端必须按队列 consumer 的同口径把它认成同一个 AGC consumer,否则 AGC 分支是死代码。 + #[test] + fn game_creator_client_source_is_recognized_as_game_creator_resource_editor() { + assert!(is_game_creator_resource_editor_generation(Some(&json!({ + "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE + })))); + assert!(is_game_creator_resource_editor_generation(Some(&json!({ + "source": GAME_CREATOR_RESOURCE_EDITOR_SOURCE + })))); + assert!( + is_game_creator_resource_editor_generation(Some(&json!({ + "source": " game-creator-resource-editor " + }))), + "别名判定沿用 trim 口径" + ); + assert!(!is_game_creator_resource_editor_generation(Some(&json!({ + "source": "editor-agent" + })))); + assert!(!is_game_creator_resource_editor_generation(Some(&json!({})))); + assert!(!is_game_creator_resource_editor_generation(None)); + + // 端到端同口径:AGC 客户端实际发出的 source 必须与 `from_job` 的 consumer 判定一致。 + // 快速编辑端点把 `generationInputs` 对象本身当判定输入,所以这里按 `/generationInputs` 取。 + let mut queued_job = atomic_editor_generation_job_fixture(); + queued_job.dedupe_key = + "game-creator-client-generation:editor_image_generation:fingerprint".to_string(); + queued_job.request_payload_json = json!({ + "generationInputs": { "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE } + }) + .to_string(); + assert_eq!( + EditorGenerationQueueResultContext::from_job(&queued_job).consumer, + EditorGenerationQueueConsumer::GameCreatorResourceEditor + ); + let queued_payload: Value = + serde_json::from_str(queued_job.request_payload_json.as_str()) + .expect("fixture payload should be JSON"); + assert!(is_game_creator_resource_editor_generation( + queued_payload.pointer("/generationInputs") + )); + } + + /// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,source 已被改写成 + /// `ai-game-creator-client`,来源资源的 assetKind 是本地 manifest 原始类型 + /// `art-spritesheet` / `ui`(mediaType=image)。这条路径必须放行; + /// 非静态媒体(video/audio/image-sequence)必须继续拒绝。 + #[test] + fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() { + // 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形:请求体的 + // `generationInputs` 就是这里传入的判定输入。 + let agc_payload: Value = serde_json::from_str( + r#"{ + "prompt": "把这张图改成夜间配色", + "sourceReferenceId": "resource-art-spritesheet", + "generationInputs": { + "source": "ai-game-creator-client", + "assetKind": "art-spritesheet" + } + }"#, + ) + .expect("AGC quick edit payload should be JSON"); + let generation_inputs = agc_payload + .pointer("/generationInputs") + .expect("AGC payload should carry generationInputs"); + + for (asset_kind, media_type) in [ + ("art-spritesheet", "image"), + ("ui", "image"), + ("ui-prototype", "image"), + ("game-art", "image"), + ("game-background", "image"), + ("icon", "image"), + ] { + assert!( + ensure_editor_image_edit_source_kind_allowed_for_request( + Some(asset_kind), + Some(media_type), + Some(generation_inputs), + ) + .is_ok(), + "AGC 快速编辑必须放行 {asset_kind}/{media_type}" + ); + } + + for (asset_kind, media_type) in [ + ("art-spritesheet", "video"), + ("ui", "audio"), + ("video", "video"), + ("sound-effect", "audio"), + ("background-music", "audio"), + ("character-animation", "image-sequence"), + ("future-kind", "image"), + ] { + assert!( + ensure_editor_image_edit_source_kind_allowed_for_request( + Some(asset_kind), + Some(media_type), + Some(generation_inputs), + ) + .is_err(), + "非静态来源必须继续拒绝:{asset_kind}/{media_type}" + ); + } + + // 400 错误体必须带上真实原因,客户端才能诊断(对应 AGC 的 + // `editor_api_rejection_reason` 取值顺序修正)。 + let error = ensure_editor_image_edit_source_kind_allowed_for_request( + Some("future-kind"), + Some("image"), + Some(generation_inputs), + ) + .expect_err("未知类型必须拒绝"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error.details().and_then(|details| details.get("provider")), + Some(&json!("editor-image-edit")) + ); + assert_eq!( + error.details().and_then(|details| details.get("message")), + Some(&json!("当前素材类型不支持图片快速编辑")) + ); + assert_eq!( + error.details().and_then(|details| details.get("assetKind")), + Some(&json!("future-kind")) + ); + assert_eq!( + error.details().and_then(|details| details.get("mediaType")), + Some(&json!("image")) + ); + } + #[test] fn editor_image_edit_target_binding_prefers_object_id_and_fails_closed() { let target = EditorImageEditTargetLayerSource {