From 69d798e48477204ad7502e7a9b870b9a627b7443 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:25:55 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20External=20v1=20=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E8=B7=AF=E7=94=B1=E7=9A=84=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=9E=84=E9=80=A0=E7=94=A8=E4=BE=8B=E5=B9=B6?= =?UTF-8?q?=E5=8B=BE=E9=80=89=E8=AF=A5=E9=AA=8C=E6=94=B6=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 background_stage_posts_the_dedicated_scene_route_and_its_ledger_matches:用规范图夹具驱动真实生成链路,断言背景阶段只发一次 scenes 路由 POST、正文是结构化场景意图(sceneContent/stylePreset=custom/customStyle/referenceImageSrcs,且没有 assetKind/prompt),成功后不留在途账本 - 变异验证:把场景分支的路由改回通用图片路由后该用例变红,改回后恢复绿 - External v1 场景路由里程碑勾选「客户端定向测试」一项,逐条记录账本、恢复与 reuse/regenerate 的实跑证据;剩余仅本地真实栈受理 smoke --- .../src/agent/generation/canvas_generation.rs | 321 ++++++++++++++++++ ...®¡划】ExternalV1游戏场景生成路由-2026-09-24.md | 9 +- ...‹碑】ExternalV1游戏场景生成路由-2026-09-24.md | 11 +- 3 files changed, 334 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 476a4f251..6931c6afd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -15894,4 +15894,325 @@ mod canvas_generation_tests { b"old-image" ); } + + /// 场景(背景)阶段的请求夹具:只接受专用场景路由,记录原始请求,并按队列 → 换签 → 下载链路服务完。 + fn serve_scene_route_fixture( + stream: &mut std::net::TcpStream, + base_url: &str, + posts: &std::sync::Mutex>, + others: &std::sync::Mutex>, + ) { + let request = read_test_http_request(stream); + if request.starts_with("POST /api/external/v1/editor/scenes/generations ") { + posts + .lock() + .expect("record scene generation posts") + .push(request); + write_test_json_response( + stream, + "202 Accepted", + &serde_json::json!({ + "data": { + "operationId": "scene-route-operation", + "status": "queued", + "pollAfterMs": 0, + } + }), + ); + return; + } + others + .lock() + .expect("record scene fixture requests") + .push(request.clone()); + if request.starts_with("GET /api/external/v1/generations/scene-route-operation ") { + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": { + "operationId": "scene-route-operation", + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": "scene-route-resource", + "objectKey": "scene-route.png", + } + } + } + }), + ); + return; + } + if request.starts_with("GET /api/external/v1/assets/read-url?") { + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "read": {"signedUrl": format!("{base_url}/scene-route.png")} + }), + ); + return; + } + if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") { + // 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。 + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": {"projects": [{ + "projectId": "manual-test-canvas", + "title": "场景生成画布", + }]} + }), + ); + return; + } + if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": {"library": {"folders": [{ + "folderId": "manual-test-assets", + "label": "场景生成素材", + }]}} + }), + ); + return; + } + if request.starts_with("GET /scene-route.png ") { + let png = rgba_test_png(u8::MAX).bytes; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ) + .expect("write scene fixture png headers"); + stream.write_all(&png).expect("write scene fixture png"); + return; + } + panic!("unexpected scene route fixture request: {request}"); + } + + /// 背景阶段(Scene)必须命中 external v1 的专用场景路由并以结构化场景字段提交,而且这一轮 + /// 真正写下的账本必须被「保留账本」判据按 Scene 认下来。 + /// + /// 这条配对正是 2026-09-23 之前出过错的地方:客户端向通用图片路由提交 `assetKind = scene` + /// 被平台参数校验拒绝(HTTP 400),而保留账本匹配器又期望 `game-background`。两侧任一侧回流, + /// 本条用例都会红。 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn background_stage_posts_the_dedicated_scene_route_and_its_ledger_matches() { + let temporary = tempfile::tempdir().expect("create scene route project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-scene-route", "场景路由测试") + .expect("init scene route project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow standalone scene generation"); + // `Scene` 属于「必须带规范图引用」的 kind:先种一张 assets/art-spec.png 并登记成 Canvas 来源。 + fs::write( + root.join(AGENT_RUNTIME_ART_SPEC_PATH), + rgba_test_png(u8::MAX).bytes, + ) + .expect("write canonical art spec"); + register_local_asset_at( + root, + AGENT_RUNTIME_ART_SPEC_PATH, + GameCreationAppAssetKind::IconSpec, + "image/png", + "scene-route-test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("manual-test-canvas".to_string()), + resource_id: Some("scene-route-art-spec".to_string()), + asset_object_id: Some("scene-route-art-spec-object".to_string()), + task_id: Some("scene-route-art-spec-task".to_string()), + prompt: None, + model: None, + generation_route: Some("/api/external/v1/editor/images/generations".to_string()), + generation_kind: Some("spec".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register canonical art spec"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind scene route fixture"); + listener + .set_nonblocking(true) + .expect("set scene route fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let api_key = "scene-route-key"; + install_test_external_project_binding(root, &base_url, api_key); + // 规范图的远端引用走 binding 缓存命中:预置绑定后不需要额外的上传夹具端点。 + { + let access = ExternalEditorBindingAccess::new(&base_url, api_key, None) + .expect("prepare scene route binding access"); + let manifest = read_manifest_for_project(root).expect("read scene route manifest"); + let source = manifest + .assets + .iter() + .find(|asset| asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH) + .expect("registered canonical art spec"); + let source_bytes = + fs::read(root.join(&source.local_path)).expect("read art spec bytes"); + let principal = + external_editor_binding_principal(&access).expect("derive scene route principal"); + let source_identity = new_external_editor_source_identity( + &source.id, + &format!("{:x}", Sha256::digest(&source_bytes)), + &source.media_type, + &source.kind, + ) + .expect("derive scene route source identity"); + let binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + "manual-test-canvas", + &source_identity, + Some("scene-route-art-spec-resource"), + "generated/scene-route-art-spec.png", + "scene-route-art-spec-object", + Some(1), + Some(1), + unix_timestamp(), + ) + .expect("create canonical resource binding"); + write_external_editor_resource_binding_at(root, &binding) + .expect("persist canonical resource binding"); + } + + let posts = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let others = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server_posts = posts.clone(); + let server_others = others.clone(); + let server_base_url = base_url.clone(); + let server = std::thread::spawn(move || { + let mut handlers = Vec::new(); + loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("accept scene route request: {error}"), + }; + let posts = server_posts.clone(); + let others = server_others.clone(); + let base_url = server_base_url.clone(); + handlers.push(std::thread::spawn(move || { + serve_scene_route_fixture(&mut stream, &base_url, &posts, &others); + })); + } + for handler in handlers { + handler.join().expect("join scene route fixture handler"); + } + }); + + let options = PlatformArtAssetGenerationOptions { + output_path: Some("assets/scene-route-background.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "1K".to_string(), + asset_kind: GameCreationAppAssetKind::Scene, + asset_label: "雨夜天台".to_string(), + replace_existing: false, + slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, + }; + let prompt = "雨夜天台眺望霓虹城市的横屏背景"; + let credentials = crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ); + let generated = crate::assets::with_external_editor_api_credentials(credentials, async { + match generate_platform_art_asset_with_options_at(root, prompt, &[], &options).await { + Ok(generated) => generated, + Err(error) => panic!("scene generation must succeed: {error}"), + } + }) + .await; + stop_sender.send(()).expect("stop scene route fixture"); + server.join().expect("join scene route fixture"); + + assert_eq!( + generated.resource_id.as_deref(), + Some("scene-route-resource"), + "场景生成必须复用夹具返回的远端资源身份" + ); + let captured = posts.lock().expect("read captured scene posts").clone(); + assert_eq!( + captured.len(), + 1, + "背景阶段只能有一次生成 POST:{captured:#?}" + ); + let post = &captured[0]; + assert!( + post.starts_with("POST /api/external/v1/editor/scenes/generations "), + "背景阶段必须走专用场景路由:{post}" + ); + let body_text = post + .split("\r\n\r\n") + .nth(1) + .expect("scene generation POST must carry a body"); + let body: serde_json::Value = + serde_json::from_str(body_text).expect("scene generation body must be JSON"); + assert!( + body["sceneContent"] + .as_str() + .is_some_and(|value| value.contains("雨夜天台眺望霓虹城市")), + "结构化场景意图必须放在 sceneContent:{body}" + ); + assert_eq!(body["stylePreset"], "custom", "{body}"); + assert!( + body["customStyle"] + .as_str() + .is_some_and(|value| !value.trim().is_empty()), + "自定义风格描述不能为空:{body}" + ); + assert!( + body.get("assetKind").is_none(), + "结构化场景请求不得再带通用 assetKind:{body}" + ); + assert!( + body.get("prompt").is_none(), + "结构化场景请求不得再带通用 prompt:{body}" + ); + assert!(body["referenceImageSrcs"].is_array(), "{body}"); + assert_eq!(body["assetLabel"], "雨夜天台", "{body}"); + + // standalone 成功安装后不留「在途账本」:同一条精确动作的恢复账本必须已被清理, + // 否则下一次同一动作会被当成恢复而不是新提交(场景侧的账本判据本身由 + // `retained_stage_recovery_accepts_the_same_reference_contract_as_submission` 覆盖)。 + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); + let runtime_context = + standalone_platform_art_generation_runtime_context(&generation_prompt, &options, false) + .expect("scene runtime context"); + assert!( + super::external_generation_state::read_platform_art_generation_runtime_state( + root, + &runtime_context, + ) + .expect("read scene generation ledger") + .is_none(), + "成功安装后不得保留在途生成账本" + ); + } } diff --git a/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md index 6030aeec5..219ce59d2 100644 --- a/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md +++ b/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md @@ -51,6 +51,9 @@ - 客户端:`canvas_generation.rs` 的 Scene 专属分支(新路由 + `sceneContent`)、保留账本匹配器(`canvas_generation.rs` 的 endpoint/`sceneContent` 判定)、`external_generation_state.rs`、`runtime_driver/recovery_scan.rs`、`direct_runtime/mod.rs` 的背景身份常量都与线上口径一致。 - 本轮实测:`cargo test -p api-server --manifest-path server-rs/Cargo.toml -- scene --test-threads=1` → **18 passed / 0 failed**(覆盖站内拒绝 scene 绕过、外部场景鉴权与非法入参、非法幂等键、共享场景契约与可下载队列结果、OpenAPI 契约)。 - 客户端账本与身份契约:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline -- external_generation_state --test-threads=1` → **10 passed**(覆盖生成账本身份、复用与并发认领口径;场景路由的账本匹配器就在这个模块里)。 -- 仍未取得(勾选状态保持未完成):客户端「背景阶段请求命中新场景路由且为结构化字段」这一条**没有现成用例**。`canvas_generation.rs` 的场景分支是内联在生成链路里的 `(endpoint, body)` 字面量(`/api/external/v1/editor/scenes/generations` + `sceneContent` / `stylePreset=custom`),要单独把它钉住,测试里得先种一张规范图——`Scene` 属于「必须带规范图引用」的 kind(`platform_art_asset_kind_requires_canonical_spec_reference`),所以还需要补规范图上传的夹具端点;本轮没有做,也不打算用「把同一段字面量抄进断言」的镜像用例凑数。 -- `reuse-or-create` / `regenerate` 的端到端回归与「本地真实栈 + 测试 Key 受理」smoke 同样未执行。 -- 本机限制:客户端 `-- background` 这个过滤词会匹配到一大片 `background_agent_runtime` 家族(本轮实测 138 条:47 passed / 91 failed,失败几乎都是等待 mock LLM 请求的 `Timeout` 或状态竞争),**不能用它作为本里程碑的判据**。已在 `pitfalls` 记录该家族的抖动特征与 A/B 方法:同一条用例在合并前的分支尖(`74b83aa1a`)上也会失败,安静状态下又能 3/3 通过。 \ No newline at end of file +- 客户端定向测试(本轮补齐):新增 `background_stage_posts_the_dedicated_scene_route_and_its_ledger_matches`。它用项目内夹具驱动**真实生成链路**,先种一张 `assets/art-spec.png` 并预置其账号绑定(`Scene` 属于必须带规范图引用的 kind),然后断言背景阶段只发一次 `POST /api/external/v1/editor/scenes/generations`,正文是结构化场景意图(`sceneContent` 带原始意图、`stylePreset=custom`、非空 `customStyle`、`referenceImageSrcs` 数组、没有 `assetKind` / `prompt`),并在成功安装后确认在途账本已被清理。 +- 变异验证:把场景分支的路由改回 `/api/external/v1/editor/images/generations` 后该用例立刻变红(夹具拒绝通用路由、生成失败),改回后恢复绿(1.59s)。因此这一条勾选不是镜像断言。 +- 账本与恢复口径:`retained_stage_recovery_accepts_the_same_reference_contract_as_submission`(Scene 账本必须按新路由 + `sceneContent` + `stylePreset=custom` 被认下)与 `legacy_route_background_registration_still_counts_as_valid_base`(旧路由登记仍可读)单跑通过。 +- `reuse-or-create` / `regenerate` 回归:`direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet`(复用已登记背景)、`failed_background_stage_after_local_commit_restores_recorded_spec_and_background` 与 `failed_complete_package_regeneration_restores_replaced_spec_and_background`(失败后按记录恢复 spec / 背景)单跑通过。 +- 仍未取得:**本地真实栈 + 测试 Key 的受理 smoke**(`npm run dev:api-server` 后用真实 API Key POST 新路由校验受理与重放),需要本地 dev 栈与可用 Key。 +- 本机限制:客户端 `-- background` 过滤会匹配一大片 `background_agent_runtime` 家族并大量 `Timeout`(本轮实测 138 条:47 passed / 91 failed),不能当判据;该家族抖动特征与 A/B 方法记在 `pitfalls`。 \ No newline at end of file diff --git a/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md index 489cbbd64..6c275d1d8 100644 --- a/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md +++ b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md @@ -36,7 +36,7 @@ - [x] 新路由契约测试通过:路由矩阵与鉴权、参数 400、同键重放返回原任务、同键不同请求 409。 - [x] 外部场景路由与站内路由对相同输入产出相同的后端 Prompt 与入队 payload(共享实现单测对照)。 - [x] `docs/openapi/genarrative-external-v1.openapi.json` 与实现一致,相关契约检查通过。 -- [ ] 客户端定向测试:背景阶段请求命中新路由且为结构化字段;保留账本匹配器与实际请求口径一致;`reuse-or-create` 与 `regenerate` 路径回归通过。 +- [x] 客户端定向测试:背景阶段请求命中新路由且为结构化字段;保留账本匹配器与实际请求口径一致;`reuse-or-create` 与 `regenerate` 路径回归通过。 - [ ] 本地真实栈 smoke:`npm run dev:api-server` + 测试 Key 下美术包背景阶段受理成功(Provider 出图按环境可用性记录为已验证或未验证)。 ## 证据要求 @@ -54,6 +54,9 @@ - 客户端:`canvas_generation.rs` 的 Scene 专属分支(新路由 + `sceneContent`)、保留账本匹配器(`canvas_generation.rs` 的 endpoint/`sceneContent` 判定)、`external_generation_state.rs`、`runtime_driver/recovery_scan.rs`、`direct_runtime/mod.rs` 的背景身份常量都与线上口径一致。 - 本轮实测:`cargo test -p api-server --manifest-path server-rs/Cargo.toml -- scene --test-threads=1` → **18 passed / 0 failed**(覆盖站内拒绝 scene 绕过、外部场景鉴权与非法入参、非法幂等键、共享场景契约与可下载队列结果、OpenAPI 契约)。 - 客户端账本与身份契约:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline -- external_generation_state --test-threads=1` → **10 passed**(覆盖生成账本身份、复用与并发认领口径;场景路由的账本匹配器就在这个模块里)。 -- 仍未取得(勾选状态保持未完成):客户端「背景阶段请求命中新场景路由且为结构化字段」这一条**没有现成用例**。`canvas_generation.rs` 的场景分支是内联在生成链路里的 `(endpoint, body)` 字面量(`/api/external/v1/editor/scenes/generations` + `sceneContent` / `stylePreset=custom`),要单独把它钉住,测试里得先种一张规范图——`Scene` 属于「必须带规范图引用」的 kind(`platform_art_asset_kind_requires_canonical_spec_reference`),所以还需要补规范图上传的夹具端点;本轮没有做,也不打算用「把同一段字面量抄进断言」的镜像用例凑数。 -- `reuse-or-create` / `regenerate` 的端到端回归与「本地真实栈 + 测试 Key 受理」smoke 同样未执行。 -- 本机限制:客户端 `-- background` 这个过滤词会匹配到一大片 `background_agent_runtime` 家族(本轮实测 138 条:47 passed / 91 failed,失败几乎都是等待 mock LLM 请求的 `Timeout` 或状态竞争),**不能用它作为本里程碑的判据**。已在 `pitfalls` 记录该家族的抖动特征与 A/B 方法:同一条用例在合并前的分支尖(`74b83aa1a`)上也会失败,安静状态下又能 3/3 通过。 \ No newline at end of file +- 客户端定向测试(本轮补齐):新增 `background_stage_posts_the_dedicated_scene_route_and_its_ledger_matches`。它用项目内夹具驱动**真实生成链路**,先种一张 `assets/art-spec.png` 并预置其账号绑定(`Scene` 属于必须带规范图引用的 kind),然后断言背景阶段只发一次 `POST /api/external/v1/editor/scenes/generations`,正文是结构化场景意图(`sceneContent` 带原始意图、`stylePreset=custom`、非空 `customStyle`、`referenceImageSrcs` 数组、没有 `assetKind` / `prompt`),并在成功安装后确认在途账本已被清理。 +- 变异验证:把场景分支的路由改回 `/api/external/v1/editor/images/generations` 后该用例立刻变红(夹具拒绝通用路由、生成失败),改回后恢复绿(1.59s)。因此这一条勾选不是镜像断言。 +- 账本与恢复口径:`retained_stage_recovery_accepts_the_same_reference_contract_as_submission`(Scene 账本必须按新路由 + `sceneContent` + `stylePreset=custom` 被认下)与 `legacy_route_background_registration_still_counts_as_valid_base`(旧路由登记仍可读)单跑通过。 +- `reuse-or-create` / `regenerate` 回归:`direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet`(复用已登记背景)、`failed_background_stage_after_local_commit_restores_recorded_spec_and_background` 与 `failed_complete_package_regeneration_restores_replaced_spec_and_background`(失败后按记录恢复 spec / 背景)单跑通过。 +- 仍未取得:**本地真实栈 + 测试 Key 的受理 smoke**(`npm run dev:api-server` 后用真实 API Key POST 新路由校验受理与重放),需要本地 dev 栈与可用 Key。 +- 本机限制:客户端 `-- background` 过滤会匹配一大片 `background_agent_runtime` 家族并大量 `Timeout`(本轮实测 138 条:47 passed / 91 failed),不能当判据;该家族抖动特征与 A/B 方法记在 `pitfalls`。 \ No newline at end of file