diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fe42622ad..65ad961aa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -448,6 +448,14 @@ - 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`;`cargo test -p api-server inline_data_url --manifest-path server-rs/Cargo.toml`。 - 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/editor_generation_queue.rs`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +## 专用生成契约不能被通用生成接口和任务摘要绕过 + +- 现象:专用场景接口要求结构化 `sceneContent + stylePreset`,但调用方仍可向通用图片接口传 `kind = scene` 或 `assetKind = scene`,用任意完整 Prompt 生成并持久化正式场景;合法场景入队后,任务侧栏还可能显示后端完整规则文本和通用“生成图片”标题,空白素材名则可能回退成完整 Prompt。 +- 原因:专用 handler 内部复用了通用图片 payload、队列和 Worker,但公开通用 HTTP handler 没有限制专用身份;任务摘要又无条件优先提取 payload 顶层 `prompt`,素材名默认值只处理了字段省略,没有处理空白字符串。 +- 处理:公开通用 handler 拒绝专用 `kind / assetKind`,专用 handler 仍可直接调用内部共享执行函数;队列投影按 `kind = scene` 从权威 `generationInputs.fields[画面内容]` 派生标题和摘要,缺字段时失败关闭而不是回退内部 Prompt,并重新计算历史缓存;专用素材名统一把省略和空白收口为产品默认值。 +- 验证:路由测试先证明旁路会越过 HTTP 边界,再断言两种旁路均返回 `400` 且指向专用端点;摘要测试覆盖新任务、历史错误缓存和缺少画面内容三种情况;标签测试覆盖省略、空白、自定义和 80 字上限。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-module/src/external_generation.rs`、`docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md`。 + ## 图片编辑器角色动画必须提交稳定图片引用 - 现象:图片编辑器里对尚未上传的角色图点击 `生成动画` 后,前端或后端返回 `sourceImageSrc 必须先上传 OSS`。 diff --git a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md index 3e3d67936..7466ae155 100644 --- a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md +++ b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md @@ -83,7 +83,7 @@ assetLabel? canvasCompletion? ``` -请求不接受前端组装后的完整 `prompt`。本期不修改 `/api/external/v1`。 +请求不接受前端组装后的完整 `prompt`。通用 `/api/editor/images/generations` 也必须拒绝 `kind = scene` 或 `assetKind = scene`,防止调用方绕过结构化字段校验和后端 Prompt 组装。本期不修改 `/api/external/v1`。 完整 Provider Prompt 仍只能由后端生成。 @@ -121,6 +121,8 @@ prompt = 后端完整 Prompt 随后复用 `enqueue_editor_image_generation_for_owner`,队列类型继续是 `editor_image_generation`,Worker 继续执行 `generate_editor_image_for_owner`。 +队列标题固定为“图片画布生成游戏场景”。任务摘要只展示 `generationInputs.fields` 中的“画面内容”,不得把队列 payload 里的后端完整 Prompt 暴露到任务侧栏;历史错误摘要在投影刷新时按同一规则重新派生。`assetLabel` 省略、空字符串或纯空白时统一使用“游戏场景”,不能退回完整 Prompt 作为素材名称。 + 计费规则: - 前端展示价继续读取 `/api/editor/generation-pricing`。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 06a15d232..a59aa7dc9 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1912,6 +1912,67 @@ mod tests { ); } + #[tokio::test] + async fn editor_image_generation_rejects_scene_contract_bypasses() { + 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_bypass"); + let app = build_router(state); + let requests = [ + ( + "scene kind", + serde_json::json!({ + "prompt": "绕过后端场景 Prompt 组装", + "kind": "scene", + }), + ), + ( + "scene asset kind", + serde_json::json!({ + "prompt": "把普通图片伪装成正式场景产物", + "assetKind": "scene", + }), + ), + ]; + + for (case_name, request_body) in requests { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/images/generations") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "{case_name} must not bypass the dedicated scene contract" + ); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("/api/editor/scenes/generations"), + "{case_name} should point callers to the scene endpoint: {body_text}" + ); + } + } + #[tokio::test] async fn editor_scene_generation_requires_bearer_auth() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index f3df70daa..2402faa8d 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1740,6 +1740,18 @@ fn normalize_editor_scene_generation_options( ) } +fn editor_image_generation_request_label(kind: Option<&str>) -> &'static str { + if kind.is_some_and(|kind| kind.trim() == "scene") { + "图片画布生成游戏场景" + } else { + "图片画布生成图片" + } +} + +fn normalize_editor_scene_asset_label(asset_label: Option) -> String { + resolve_editor_generated_asset_label(asset_label, "游戏场景") +} + pub async fn generate_editor_scene( State(state): State, Extension(request_context): Extension, @@ -1782,7 +1794,7 @@ pub async fn generate_editor_scene( asset_kind: Some("scene".to_string()), generation_inputs: Some(generation_inputs), asset_folder_id: payload.asset_folder_id, - asset_label: payload.asset_label.or_else(|| Some("游戏场景".to_string())), + asset_label: Some(normalize_editor_scene_asset_label(payload.asset_label)), source_resource_id: None, canvas_completion: payload.canvas_completion, }; @@ -1812,6 +1824,22 @@ pub async fn generate_editor_image( payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; + if payload + .kind + .as_deref() + .is_some_and(|kind| kind.trim() == "scene") + || payload + .asset_kind + .as_deref() + .is_some_and(|asset_kind| asset_kind.trim() == "scene") + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-generation", + "message": "游戏场景必须通过 /api/editor/scenes/generations 提交结构化场景意图", + })), + ); + } let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let queue_job = enqueue_editor_image_generation_for_owner( @@ -1899,7 +1927,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( caller.owner_user_id.as_str(), EDITOR_IMAGE_GENERATION_JOB_KIND, source_entity_id, - "图片画布生成图片", + editor_image_generation_request_label(normalized_kind), price_mud_points, &payload, external_idempotency_key, @@ -18299,6 +18327,14 @@ mod tests { editor_image_generation_billing_asset_kind(Some("scene")), EDITOR_SCENE_IMAGE_ASSET_KIND, ); + assert_eq!( + editor_image_generation_request_label(Some(" scene ")), + "图片画布生成游戏场景", + ); + assert_eq!( + editor_image_generation_request_label(Some("character")), + "图片画布生成图片", + ); let profile = editor_generated_image_storage_profile(Some("scene")); assert_eq!(profile.asset_kind, EDITOR_SCENE_IMAGE_ASSET_KIND); assert_eq!(profile.path_kind, "scene-images"); @@ -18331,6 +18367,25 @@ mod tests { ); } + #[test] + fn scene_generation_blank_asset_label_uses_product_default() { + assert_eq!(normalize_editor_scene_asset_label(None), "游戏场景"); + assert_eq!( + normalize_editor_scene_asset_label(Some(" \t ".to_string())), + "游戏场景" + ); + assert_eq!( + normalize_editor_scene_asset_label(Some(" 雨夜车站 ".to_string())), + "雨夜车站" + ); + assert_eq!( + normalize_editor_scene_asset_label(Some("景".repeat(81))) + .chars() + .count(), + EDITOR_ASSET_LABEL_MAX_CHARS + ); + } + #[test] fn scene_generation_rebuilds_fields_and_only_keeps_reference_rows() { let payload: EditorSceneGenerateRequest = serde_json::from_value(json!({ diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 3052f7da3..a2951648e 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -1962,18 +1962,30 @@ fn build_external_generation_job_summary_row( row: &ExternalGenerationJob, cached_request_prompt: Option>, ) -> ExternalGenerationJobSummary { + let request_payload = serde_json::from_str::(&row.request_payload_json).ok(); + let is_scene_generation = row.source_module.trim() == EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE + && row.job_kind.trim() == "editor_image_generation" + && request_payload + .as_ref() + .is_some_and(external_generation_payload_is_scene); ExternalGenerationJobSummary { job_id: row.job_id.clone(), job_kind: row.job_kind.clone(), owner_user_id: row.owner_user_id.clone(), source_module: row.source_module.clone(), source_entity_id: row.source_entity_id.clone(), - request_label: row.request_label.clone(), - request_prompt: match cached_request_prompt { + request_label: if is_scene_generation { + "图片画布生成游戏场景".to_string() + } else { + row.request_label.clone() + }, + request_prompt: match cached_request_prompt.filter(|_| !is_scene_generation) { Some(prompt) => prompt .as_deref() .and_then(normalize_external_generation_request_prompt_text), - None => extract_external_generation_request_prompt(&row.request_payload_json), + None => request_payload.as_ref().and_then(|payload| { + extract_external_generation_request_prompt(payload, is_scene_generation) + }), }, status: row.status.clone(), last_error_message: row @@ -1994,6 +2006,13 @@ fn build_external_generation_job_summary_row( } } +fn external_generation_payload_is_scene(payload: &serde_json::Value) -> bool { + payload + .get("kind") + .and_then(serde_json::Value::as_str) + .is_some_and(|kind| kind.trim() == "scene") +} + fn extract_external_generation_warning_message( result_payload_json: Option<&str>, ) -> Option { @@ -2005,8 +2024,32 @@ fn extract_external_generation_warning_message( normalize_external_generation_warning_message(reason) } -fn extract_external_generation_request_prompt(request_payload_json: &str) -> Option { - let payload: serde_json::Value = serde_json::from_str(request_payload_json).ok()?; +fn extract_external_generation_request_prompt( + payload: &serde_json::Value, + is_scene_generation: bool, +) -> Option { + if is_scene_generation { + return payload + .get("generationInputs") + .and_then(|value| value.get("fields")) + .and_then(serde_json::Value::as_array) + .and_then(|fields| { + fields.iter().find_map(|field| { + if field + .get("title") + .and_then(serde_json::Value::as_str)? + .trim() + != "画面内容" + { + return None; + } + field + .get("value") + .and_then(serde_json::Value::as_str) + .and_then(normalize_external_generation_request_prompt_text) + }) + }); + } for key in ["prompt", "promptText", "spritesheetLabel"] { if let Some(prompt) = payload .get(key) @@ -2753,6 +2796,7 @@ mod tests { #[test] fn pending_job_is_claimable_only_after_available_time() { let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING); + row.request_label = "图片画布生成图片".to_string(); row.available_at = micros(1_000); assert!(!is_external_generation_job_claimable(&row, micros(999))); @@ -3082,6 +3126,60 @@ mod tests { } } + #[test] + fn scene_job_summary_uses_user_scene_content_instead_of_internal_prompt() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING); + row.source_module = EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE.to_string(); + row.job_kind = "editor_image_generation".to_string(); + row.request_label = "图片画布生成图片".to_string(); + row.request_payload_json = serde_json::json!({ + "kind": "scene", + "prompt": "【内部场景规则】仅生成环境背景,禁止 UI 和文字", + "generationInputs": { + "fields": [ + { "title": "画面内容", "value": "雨夜中的海边车站" }, + { "title": "视觉风格", "value": "日系动画" } + ], + "references": [] + } + }) + .to_string(); + + let summary = build_external_generation_job_summary_row(&row, None); + let refreshed = build_external_generation_job_summary_row( + &row, + Some(Some("【内部场景规则】旧摘要缓存".to_string())), + ); + + assert_eq!(summary.request_prompt.as_deref(), Some("雨夜中的海边车站")); + assert_eq!(summary.request_label, "图片画布生成游戏场景"); + assert_eq!( + refreshed.request_prompt.as_deref(), + Some("雨夜中的海边车站") + ); + assert_eq!(refreshed.request_label, "图片画布生成游戏场景"); + + row.request_payload_json = serde_json::json!({ + "kind": "scene", + "prompt": "【内部场景规则】缺少用户画面内容时也不得展示", + "generationInputs": { "fields": [], "references": [] } + }) + .to_string(); + let missing_scene_content = build_external_generation_job_summary_row(&row, None); + assert!(missing_scene_content.request_prompt.is_none()); + assert_eq!(missing_scene_content.request_label, "图片画布生成游戏场景"); + + row.source_module = "puzzle".to_string(); + row.job_kind = "puzzle_compile_draft".to_string(); + row.request_label = "拼图场景生成".to_string(); + let non_editor = build_external_generation_job_summary_row(&row, None); + assert_eq!(non_editor.request_label, "拼图场景生成"); + assert_eq!( + non_editor.request_prompt.as_deref(), + Some("【内部场景规则】缺少用户画面内容时也不得展示") + ); + } + #[test] fn job_summary_never_copies_inline_media_as_request_prompt() { let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_COMPLETED);