From 2370dc509b89cf6de7804d6a1c59d610bfe46167 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 24 Sep 2026 16:18:40 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8F=B7=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E7=94=9F=E6=88=90=E7=9A=84=E5=B9=82=E7=AD=89=E4=B8=8E?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E8=BF=94=E5=9B=9E=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 站内场景入口读取并透传可选幂等键,复用既有队列去重逻辑 场景配方保留 AGC 来源标记,恢复可下载的队列完成结果 补充非法幂等键、场景结果序列化和幂等命名空间回归测试 同步 OpenAPI、技术方案、实施计划及排障记录 --- .../genarrative-external-v1.openapi.json | 3 +- ...®¡划】ExternalV1游戏场景生成路由-2026-09-24.md | 3 +- ...‹碑】ExternalV1游戏场景生成路由-2026-09-24.md | 3 +- docs/project-memory/shared-memory/pitfalls.md | 6 ++ ...–¹案】ExternalV1游戏场景生成路由-2026-09-24.md | 5 +- server-rs/crates/api-server/src/app.rs | 38 ++++++++++ .../api-server/src/editor_generation_queue.rs | 13 ++++ .../crates/api-server/src/editor_project.rs | 70 ++++++++++++++++++- 8 files changed, 134 insertions(+), 7 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 927babb33..63a51cbac 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3059,7 +3059,8 @@ "type": ["string", "null"] }, "generationInputs": { - "$ref": "#/components/schemas/JsonValue" + "$ref": "#/components/schemas/JsonValue", + "description": "场景配方由服务端重建;仅保留 source 精确等于 ai-game-creator-client 的客户端来源标记,用于选择 AGC 队列结果与幂等命名空间。调用方 fields、action 和引用 provenance 不会覆盖服务端配方。" }, "assetFolderId": { "type": ["string", "null"] diff --git a/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md index 78a248478..ec8a48ea5 100644 --- a/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md +++ b/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md @@ -15,7 +15,7 @@ - `docs/openapi/genarrative-external-v1.openapi.json` 与对应契约测试 - AGC `src-tauri/src/agent/generation/canvas_generation.rs`、`src/agent/direct_runtime/mod.rs`、`src/agent/generation/external_generation_state.rs`、`src/agent/runtime_driver/recovery_scan.rs`、`src/project/manifest.rs`(背景阶段路由与身份口径) - `.codex/skills/genarrative-external-editor-api/references/`(外部 API 说明) -- 明确不修改:站内场景路由行为、通用图片入口校验、SpacetimeDB schema、计费、动画/抠图链路(issue #495)。 +- 明确不修改:站内场景生成规则、通用图片入口校验、SpacetimeDB schema、计费、动画/抠图链路(issue #495);站内入口补齐现有 AGC 账号模式的幂等与结果协议。 ## 实现顺序 @@ -26,6 +26,7 @@ 5. 客户端:`canvas_generation.rs` 请求构造为 Scene 拆专属分支——新路由 + `EditorSceneGenerateRequest` 形状 body(`sceneContent` = 现有场景 prompt 文本,`stylePreset = "custom"`,`customStyle` = 现有风格描述文案,其余字段沿用);修 Scene 保留账本匹配器(新路由 + `sceneContent` 口径),统一 `game-background`/`scene` 不一致。 6. 客户端:身份/对账常量迁移——`direct_runtime/mod.rs` 背景身份三元组路由与 kind、`manifest.rs` 期望路由、`recovery_scan.rs`、`external_generation_state.rs`;保持旧路由登记可读。 7. 文档:外部 API Skill references 补新路由;运行编码与 diff 检查。 +8. 审查修复:站内场景 handler 读取并透传可选幂等键;共享组装只保留精确 AGC 来源标记。增加非法键路由测试与场景 payload 到队列可下载结果的回归测试,同步 OpenAPI 元数据说明。 ## 验证命令 diff --git a/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md index 72ce291c3..a760cad64 100644 --- a/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md +++ b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md @@ -19,7 +19,7 @@ ## 不在本里程碑内 -- 不改站内场景路由行为与通用图片入口的 scene 拒绝校验。 +- 保持站内场景生成规则,补齐账号模式幂等与结果协议;不改通用图片入口的 scene 拒绝校验。 - 不改 SpacetimeDB schema、队列类型、计费档位。 - 不迁移历史背景登记数据。 - 不处理 issue #495 的两个既有问题。 @@ -32,6 +32,7 @@ ## 验收标准 +- [x] 账号场景入口透传可选幂等键、入队前拒绝非法键;场景配方保留 AGC 来源标记且队列完成结果包含可下载资源(路由校验及组装到结果序列化的定向测试)。 - [ ] 新路由契约测试通过:路由矩阵与鉴权、参数 400、同键重放返回原任务、同键不同请求 409。 - [ ] 外部场景路由与站内路由对相同输入产出相同的后端 Prompt 与入队 payload(共享实现单测对照)。 - [ ] `docs/openapi/genarrative-external-v1.openapi.json` 与实现一致,相关契约检查通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0a3a42c64..e8ab901aa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,11 @@ # 踩坑与排障记录 +## 2026-09-24 AGC 生成路由迁移必须核对账号队列契约 + +- AGC 普通账号会把 external editor 路由映射到站内入口;只验证 API Key 路由不足以证明客户端链路可用。 +- 站内入口必须读取客户端持久化的 `Idempotency-Key`,场景配方重建须保留精确的 `generationInputs.source = ai-game-creator-client` 标记。否则可能按请求 ID 重复入队,且普通队列 consumer 不保存客户端轮询所需的可下载 `result`。 +- 场景其余执行字段和引用身份仍由服务端重建,不整体信任调用方 `generationInputs`。验证应覆盖来源标记经过组装、队列清理和结果序列化的完整纯逻辑链路,以及账号路由的幂等键校验。 + ## 同一祖先下的多个项目会各自弹一次 UAC - **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。 diff --git a/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md index 3a229f6f5..9bb76550d 100644 --- a/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md +++ b/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md @@ -18,7 +18,7 @@ AGC 美术包背景阶段(结构化场景意图) ## 非目标 -- 不改动站内 `/api/editor/scenes/generations` 的请求字段、Prompt 组装结果与计费语义。 +- 保持站内 `/api/editor/scenes/generations` 的场景字段、Prompt 和计费规则;补齐 AGC 账号模式所需的可选幂等键与队列结果协议。 - 不放松通用 `/api/editor/images/generations` 与 `/api/external/v1/editor/images/generations` 对 `kind = scene` / `assetKind = scene` 的拒绝。 - 不新增场景 Worker、任务表、计费档位或 SpacetimeDB schema。 - 不改变美术包背景图的出图风格与尺寸(16:9 / 1K)。 @@ -40,6 +40,7 @@ AGC 美术包背景阶段(结构化场景意图) 4. 受理响应与现役外部生成入口同形(operationId 异步受理信封),轮询继续走 `/api/external/v1/generations/{operation_id}`。 5. 入队后 `kind = scene`、`assetKind = scene`,队列类型、Worker、计费与持久化与站内场景路由一致;队列标题与任务摘要口径不变。 6. AGC 美术包背景阶段以 `stylePreset = custom` + `customStyle` 承载现有风格描述,`sceneContent` 承载 brief 衍生的画面内容,出图风格与比例不因迁移改变。 +7. 普通账号自动映射到 `/api/editor/scenes/generations`,该入口读取可选 `Idempotency-Key` 并传给现有队列;未提供时保留站内按请求 ID 入队的行为。场景组装仅保留 `generationInputs.source = ai-game-creator-client` 这一精确标记,其余配方字段仍由服务端重建。该标记让账号任务沿用 AGC 幂等命名空间和包含可下载 `result` 的队列结果,轮询走 `/api/runtime/external-generation/jobs/{operation_id}`。 ### 失败、重试与幂等 @@ -47,6 +48,7 @@ AGC 美术包背景阶段(结构化场景意图) 2. 缺少或非法幂等键、越权 scope 的拒绝语义与现役外部生成入口一致。 3. 同一幂等键 + 同一请求重放返回原任务,不新建任务、不重复扣费;同键不同请求返回 409。 4. Provider 失败、取消与 lease 耗尽沿用现有扣退费语义。 +5. 账号场景入口拒绝非法幂等键(400);同键重放复用现有队列幂等实现。来源标记不参与权限授予,任务归属仍来自已认证用户。 ### 权限、归属与数据边界 @@ -64,6 +66,7 @@ AGC 美术包背景阶段(结构化场景意图) | 条款 | 验收方式 | 证据 | | ---- | -------- | ---- | +| 账号入口幂等键校验与 AGC 下载结果 | 非法键路由测试、场景来源到结果序列化测试、共享队列幂等命名空间测试 | `cargo test --locked -p api-server scene`(18 项)、`editor_generation_queue::tests`(19 项)、`external`(158 项)通过;本地启动因 SpacetimeDB 连接拒绝未通过健康检查,真实 Provider 出图与账号同键重放尚未联调 | | 新路由受理/参数校验/鉴权/幂等重放 | api-server 契约测试与单测 | 待补 | | 与站内路由同一 Prompt 组装结果 | 共享实现的单测对照 | 待补 | | OpenAPI 与实现一致 | 契约测试 + `check:openapi` 类门禁 | 待补 | diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 201f2327c..659686d1f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -2749,6 +2749,44 @@ mod tests { } } + #[tokio::test] + async fn editor_scene_generation_rejects_invalid_idempotency_key_before_queueing() { + let state = AppState::new(AppConfig { + external_generation_mode: ExternalGenerationMode::Queue, + ..AppConfig::default() + }) + .expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138232", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_idempotency"); + state.fail_test_editor_generation_enqueue(); + let app = build_router(state.clone()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/scenes/generations") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("idempotency-key", "contains space") + .body(Body::from( + serde_json::json!({ + "sceneContent": "雨夜小镇", + "stylePreset": "anime", + "generationInputs": { "source": "ai-game-creator-client" }, + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should complete"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(state.test_editor_generation_enqueue_attempts(), 0); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert!(String::from_utf8_lossy(&body).contains("Idempotency-Key")); + } + #[tokio::test] async fn editor_scene_generation_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index b615a870b..75d8856f1 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -826,6 +826,19 @@ mod tests { editor_generation_idempotency_namespace(&game_creator), GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX ); + let scene = crate::editor_project::build_editor_scene_image_generation_payload( + serde_json::from_value(json!({ + "sceneContent": "雨夜小镇", + "stylePreset": "anime", + "generationInputs": { "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE }, + })) + .expect("scene request should deserialize"), + ) + .expect("scene payload should build"); + assert_eq!( + editor_generation_idempotency_namespace(&scene), + GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX, + ); assert_eq!( editor_generation_idempotency_namespace(&ordinary), EXTERNAL_API_GENERATION_DEDUPE_PREFIX diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9a6aad4bf..d67344797 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2658,12 +2658,23 @@ fn build_editor_scene_generation_inputs( { fields.push(json!({ "id": "customStyle", "title": "自定义画风", "value": custom_style })); } - json!({ + let mut inputs = json!({ "version": 2, "action": "scene.generate", "fields": fields, "references": references, - }) + }); + // AGC 账号任务依赖来源标记选择幂等命名空间和可下载的队列结果;其余字段仍由服务端重建。 + if payload + .generation_inputs + .as_ref() + .and_then(|inputs| inputs.get("source")) + .and_then(Value::as_str) + == Some(GAME_CREATOR_CLIENT_GENERATION_SOURCE) + { + inputs["source"] = json!(GAME_CREATOR_CLIENT_GENERATION_SOURCE); + } + inputs } fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str { @@ -2739,9 +2750,11 @@ pub async fn generate_editor_scene( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; + let idempotency_key = optional_editor_idempotency_key(&headers)?; let image_payload = build_editor_scene_image_generation_payload(payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { @@ -2750,7 +2763,7 @@ pub async fn generate_editor_scene( &request_context, &caller, image_payload, - None, + idempotency_key, ) .await?; return Ok(json_success_body( @@ -13316,6 +13329,57 @@ mod tests { ); } + #[test] + fn scene_generation_preserves_agc_downloadable_queue_result() { + let request = serde_json::from_value::(json!({ + "sceneContent": "雨夜小镇", + "stylePreset": "anime", + "referenceImageSrcs": ["art-spec-resource"], + "generationInputs": { + "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, + "action": "client-supplied-action", + "fields": [{ "id": "prompt", "value": "不可信配方" }], + "references": [{ "refId": "untrusted-resource" }], + }, + })) + .expect("scene request should deserialize"); + let mut image_payload = build_editor_scene_image_generation_payload(request) + .expect("scene payload should build"); + image_payload.generation_inputs = + sanitize_editor_queued_generation_inputs(image_payload.generation_inputs); + let inputs = image_payload.generation_inputs.as_ref().unwrap(); + assert_eq!(inputs["source"], GAME_CREATOR_CLIENT_GENERATION_SOURCE); + assert_eq!(inputs["action"], "scene.generate"); + assert_eq!(inputs["fields"][0]["value"], "雨夜小镇"); + assert_eq!(inputs["references"], json!([{ "id": "reference" }])); + + let mut job = atomic_editor_generation_job_fixture(); + job.request_payload_json = serde_json::to_string(&image_payload).unwrap(); + let context = EditorGenerationQueueResultContext::from_job(&job); + assert_eq!( + context.consumer, + EditorGenerationQueueConsumer::GameCreatorResourceEditor + ); + let result: Value = serde_json::from_str( + &serialize_atomic_editor_generation_job_result( + &context, + &json!({ + "ok": true, + "objectKey": "generated/scene.png", + "resource": { + "resourceId": "scene-resource", + "objectKey": "generated/scene.png", + "assetObjectId": "scene-object", + }, + }), + ) + .expect("AGC scene result should serialize"), + ) + .unwrap(); + assert_eq!(result["result"]["objectKey"], "generated/scene.png"); + assert_eq!(result["result"]["resource"]["resourceId"], "scene-resource"); + } + #[test] fn background_removal_options_preserve_queue_parameters_and_legacy_identity() { for (fields, mode, color) in [