From 0bbe910d69d9d3c5342471697d73a1dd6954d03e Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 6 Aug 2026 20:17:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=96=E9=83=A8=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=8E=A5=E5=8F=A3=E7=BB=95=E8=BF=87=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一主站与外部图片生成的场景边界校验 新增场景伪装请求零入队回归测试 同步外部OpenAPI、Skill与场景技术文档 --- .../references/api-operations.md | 1 + .../references/requests-and-outputs.md | 2 + .../genarrative-external-v1.openapi.json | 18 ++- ...方案】图片画布游戏场景生成链路-2026-08-04.md | 2 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 5 +- .../api-server/src/editor_generation_queue.rs | 9 ++ .../crates/api-server/src/editor_project.rs | 21 ++-- .../api-server/src/external_api_auth.rs | 9 ++ .../api-server/src/external_editor_api.rs | 103 +++++++++++++++++- server-rs/crates/api-server/src/state.rs | 30 +++++ 10 files changed, 184 insertions(+), 16 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 1597a2a12..ca0542557 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -88,6 +88,7 @@ Use video/audio reference arrays only with models that support them. Do not pass Use OpenAPI as the final authority; these common values are a routing aid: - Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it. +- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. - Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. - Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`. - Image `imageSize`: `0.5K`, `1K`, `2K`. diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index 1f3d04e3c..1de4c8ed6 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -104,6 +104,8 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep ## Art Spec and Image Request +Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt. + Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt: ```json diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 659c13a6f..74366ec63 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3195,7 +3195,7 @@ "ui-design", "publication-material" ], - "description": "省略时生成普通图片;其它值选择对应的专用生成流程。" + "description": "省略时生成普通图片;其它值选择对应的专用生成流程。External v1 当前不开放结构化游戏场景生成,scene 不能通过该通用图片接口提交。" }, "model": { "type": "string", @@ -3236,10 +3236,18 @@ ] }, "assetKind": { - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "type": "string", + "not": { + "pattern": "^\\s*scene\\s*$" + } + }, + { + "type": "null" + } + ], + "description": "生成产物分类。External v1 通用图片接口禁止使用 scene;结构化游戏场景必须使用主站场景专用契约。" }, "generationInputs": { "$ref": "#/components/schemas/JsonValue" diff --git a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md index 5589cad78..539daf15c 100644 --- a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md +++ b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md @@ -83,7 +83,7 @@ assetLabel? canvasCompletion? ``` -请求不接受前端组装后的完整 `prompt`。通用 `/api/editor/images/generations` 也必须拒绝 `kind = scene` 或 `assetKind = scene`,防止调用方绕过结构化字段校验和后端 Prompt 组装。本期不修改 `/api/external/v1`。 +请求不接受前端组装后的完整 `prompt`。通用 `/api/editor/images/generations` 与 `/api/external/v1/editor/images/generations` 共用同一边界校验,均拒绝 `kind = scene` 或 `assetKind = scene`,防止调用方绕过结构化字段校验和后端 Prompt 组装。External v1 当前没有场景专用路由,因此不能通过通用图片接口提交结构化场景;主站 `/api/editor/scenes/generations` 构造规范请求后直接复用队列,不经过通用入口校验。 完整 Provider Prompt 仍只能由后端生成。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 4c21135d2..005222e4e 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -32,7 +32,7 @@ v1 只开放以下能力: - `POST /api/external/v1/editor/assets`:创建素材记录。 - `PATCH /api/external/v1/editor/assets/{assetId}`:更新素材名称或所在文件夹。 - `DELETE /api/external/v1/editor/assets/{assetId}`:删除素材记录。 -- `POST /api/external/v1/editor/images/generations`:异步提交编辑器图片素材生成;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`。 +- `POST /api/external/v1/editor/images/generations`:异步提交编辑器图片素材生成;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`。External v1 当前不开放结构化游戏场景生成,`kind = scene` 与 `assetKind = scene` 均在入队前返回 `400`。 - `POST /api/external/v1/editor/images/edits`:异步提交已有图片重绘 / 调整。 - `POST /api/external/v1/editor/icon-spritesheets/generations`:异步提交规范图驱动的图标 spritesheet 生成和拆分。 - `POST /api/external/v1/editor/ui-designs/assets/extractions`:异步提交 UI 设计图素材提取 / 拆分。 @@ -212,7 +212,7 @@ SpacetimeDB procedure: 外部生成接口复用站内编辑器已有 DTO、入队器和 worker executor,不维护第二套生成语义: -- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。 +- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。主站和 External 的通用图片入口共用场景专用合同边界校验,禁止用 `kind = scene` 或 `assetKind = scene` 绕过后端场景 Prompt 组装;场景专用 handler 自己构造规范请求,不受该通用入口校验影响。 - 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。 - 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;请求携带 `assetFolderId` 时按站内规则写入素材库,音频类外部调用使用 API Key 所属账号作为 asset owner。 - API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。 @@ -252,6 +252,7 @@ docs/openapi/genarrative-external-v1.openapi.json - API Key 创建只返回一次明文,列表不返回明文。 - 撤销后的 API Key 调用外部接口返回 `401`。 - 八类外部生成 POST 缺少或携带非法 `Idempotency-Key` 时返回 `400`;同一 owner、请求和 key 重试只得到同一 operation。 +- External 通用图片生成携带 `kind = scene` 或 `assetKind = scene` 时均返回 `400`,且不得产生入队尝试。 - 八类外部生成 POST 固定返回 `202`,查询能从 `queued/running` 收敛到 `completed/failed`;调用方超时后使用原 operationId 继续查询。 - 外部图片生成、重绘、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐 completed 后,生成结果按请求同时出现在画布资源和账号级素材库。 - 角色图、图标 spritesheet 和 UI 素材提取的 completed result 允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。 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 7970c8b75..91095fdef 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -321,6 +321,15 @@ async fn enqueue_serialized_editor_generation_job_with_identity( job_id: String, dedupe_key: String, ) -> Result { + #[cfg(test)] + if state.record_test_editor_generation_enqueue_attempt() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": EDITOR_GENERATION_QUEUE_PROVIDER, + "message": "测试已在 SpacetimeDB 写入前截获编辑器生成入队", + })), + ); + } let now_micros = current_utc_micros(); state .spacetime_client() diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 30bfc3ac6..183c6e92d 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1840,13 +1840,9 @@ pub async fn generate_editor_scene( generate_editor_image_for_owner(&state, &request_context, caller, image_payload).await } -pub async fn generate_editor_image( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, AppError> { - let Json(payload) = parse_editor_generation_json_payload(payload)?; +pub(crate) fn ensure_generic_editor_image_generation_contract( + payload: &EditorImageGenerationRequest, +) -> Result<(), AppError> { if payload .kind .as_deref() @@ -1863,6 +1859,17 @@ pub async fn generate_editor_image( })), ); } + Ok(()) +} + +pub async fn generate_editor_image( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, AppError> { + let Json(payload) = parse_editor_generation_json_payload(payload)?; + ensure_generic_editor_image_generation_contract(&payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let queue_job = enqueue_editor_image_generation_for_owner( diff --git a/server-rs/crates/api-server/src/external_api_auth.rs b/server-rs/crates/api-server/src/external_api_auth.rs index 5f6f5044d..4ce511d76 100644 --- a/server-rs/crates/api-server/src/external_api_auth.rs +++ b/server-rs/crates/api-server/src/external_api_auth.rs @@ -27,6 +27,15 @@ pub struct ExternalApiPrincipal { } impl ExternalApiPrincipal { + #[cfg(test)] + pub(crate) fn for_test(owner_user_id: &str, scopes: &[&str]) -> Self { + Self { + owner_user_id: owner_user_id.to_string(), + key_id: "external-api-key-test".to_string(), + scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(), + } + } + pub fn owner_user_id(&self) -> &str { self.owner_user_id.as_str() } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index ea4761c12..e0803097d 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -39,7 +39,8 @@ use crate::{ editor_project_resource_payload_from_record, enqueue_editor_icon_spritesheet_generation_for_owner, enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner, - enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error, + enqueue_editor_ui_design_asset_extraction_for_owner, + ensure_generic_editor_image_generation_contract, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs, save_editor_project_layout_with_revision_and_get, serialize_editor_generation_inputs, @@ -645,6 +646,7 @@ pub async fn generate_external_editor_image( let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers)?; + ensure_generic_editor_image_generation_contract(&payload)?; let project_id = payload.project_id.clone(); let job = enqueue_editor_image_generation_for_owner( &state, @@ -1024,6 +1026,8 @@ fn serialize_external_editor_image_sequence_frames( #[cfg(test)] mod tests { use super::*; + use axum::{Router, body::Body, routing::post}; + use tower::ServiceExt; const EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS: [&str; 2] = [ "ExternalEditorAssetCreateRequest", @@ -1306,6 +1310,80 @@ mod tests { ); } + async fn assert_external_generic_image_scene_bypass_is_rejected_before_queueing( + case_name: &str, + idempotency_key: &str, + request_body: Value, + ) { + let state = AppState::new(crate::config::AppConfig::default()) + .expect("external image test state should build"); + state.fail_test_editor_generation_enqueue(); + let app = Router::new() + .route( + "/api/external/v1/editor/images/generations", + post(generate_external_editor_image), + ) + .layer(Extension(request_context(false))) + .layer(Extension(ExternalApiPrincipal::for_test( + "user-external-scene-bypass", + &[SCOPE_EDITOR_IMAGE_GENERATE], + ))) + .with_state(state.clone()); + let response = app + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/api/external/v1/editor/images/generations") + .header("content-type", "application/json") + .header(IDEMPOTENCY_KEY_HEADER, idempotency_key) + .body(Body::from(request_body.to_string())) + .expect("external scene bypass request should build"), + ) + .await + .expect("external scene bypass response should return"); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("external scene bypass response body should collect"); + let body_text = String::from_utf8_lossy(&body); + + assert_eq!( + (status, state.test_editor_generation_enqueue_attempts()), + (StatusCode::BAD_REQUEST, 0), + "{case_name} must fail at the generic External boundary before queueing: {body_text}", + ); + assert!( + body_text.contains("/api/editor/scenes/generations"), + "{case_name} should direct callers to the dedicated scene contract: {body_text}", + ); + } + + #[tokio::test] + async fn external_generic_image_generation_rejects_scene_kind_before_queueing() { + assert_external_generic_image_scene_bypass_is_rejected_before_queueing( + "scene kind", + "scene-bypass-kind", + json!({ + "prompt": "绕过后端场景 Prompt 组装", + "kind": "scene", + }), + ) + .await; + } + + #[tokio::test] + async fn external_generic_image_generation_rejects_scene_asset_kind_before_queueing() { + assert_external_generic_image_scene_bypass_is_rejected_before_queueing( + "scene asset kind", + "scene-bypass-asset-kind", + json!({ + "prompt": "把普通图片伪装成正式场景产物", + "assetKind": "scene", + }), + ) + .await; + } + #[test] fn generation_lookup_hides_cross_owner_jobs_as_not_found() { let not_found = map_external_generation_lookup_error(SpacetimeClientError::Procedure( @@ -1531,6 +1609,29 @@ mod tests { .get("default") .is_none() ); + let image_kind_schema = + &parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"]; + assert!( + image_kind_schema["enum"] + .as_array() + .is_some_and(|values| !values.contains(&json!("scene"))) + ); + assert!( + image_kind_schema["description"] + .as_str() + .is_some_and(|description| description.contains("不开放结构化游戏场景")) + ); + let image_asset_kind_schema = &parsed["components"]["schemas"]["EditorImageGenerationRequest"] + ["properties"]["assetKind"]; + assert_eq!( + image_asset_kind_schema["anyOf"][0]["not"]["pattern"], + json!(r"^\s*scene\s*$") + ); + assert!( + image_asset_kind_schema["description"] + .as_str() + .is_some_and(|description| description.contains("禁止使用 scene")) + ); let generation_references = &parsed["components"]["schemas"]["EditorImageGenerationRequest"] ["properties"]["referenceImageSrcs"]; assert_eq!(generation_references["maxItems"], 9); diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index af376db99..cc3ad56b9 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1,5 +1,7 @@ #[cfg(test)] use std::sync::Mutex; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; use std::{ collections::BTreeMap, error::Error, @@ -244,6 +246,10 @@ pub struct AppStateInner { test_feature_gate_config: Arc>>>, #[cfg(test)] test_spacetime_health: Arc>>, + #[cfg(test)] + test_editor_generation_enqueue_attempts: AtomicUsize, + #[cfg(test)] + test_fail_editor_generation_enqueue: AtomicBool, oss_client: Option, #[cfg_attr(test, allow(dead_code))] auth_store: InMemoryAuthStore, @@ -553,6 +559,10 @@ impl AppState { test_spacetime_health: Arc::new(Mutex::new(Some( SpacetimeClientHealthSnapshot::healthy_for_test(), ))), + #[cfg(test)] + test_editor_generation_enqueue_attempts: AtomicUsize::new(0), + #[cfg(test)] + test_fail_editor_generation_enqueue: AtomicBool::new(false), oss_client, auth_store, password_entry_service, @@ -731,6 +741,26 @@ impl AppState { .expect("test spacetime health should lock") = Some(snapshot); } + #[cfg(test)] + pub(crate) fn fail_test_editor_generation_enqueue(&self) { + self.test_fail_editor_generation_enqueue + .store(true, Ordering::Release); + } + + #[cfg(test)] + pub(crate) fn record_test_editor_generation_enqueue_attempt(&self) -> bool { + self.test_editor_generation_enqueue_attempts + .fetch_add(1, Ordering::AcqRel); + self.test_fail_editor_generation_enqueue + .load(Ordering::Acquire) + } + + #[cfg(test)] + pub(crate) fn test_editor_generation_enqueue_attempts(&self) -> usize { + self.test_editor_generation_enqueue_attempts + .load(Ordering::Acquire) + } + #[cfg(any())] pub async fn upsert_creation_entry_type_config( &self,