修复游戏场景后端契约旁路
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m2s
Project CI / Native shell tests (pull_request) Successful in 12m32s

阻止通用图片接口绕过场景专用合同

规范场景任务标题与用户可见摘要

修复空白场景素材名称回退行为

补充场景接口和任务投影回归测试

同步场景技术方案与排障约定
This commit is contained in:
2026-08-06 13:44:16 +08:00
parent 0c05faeac3
commit 447c23840c
5 changed files with 232 additions and 8 deletions
@@ -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`
@@ -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`
+61
View File
@@ -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"));
@@ -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>) -> String {
resolve_editor_generated_asset_label(asset_label, "游戏场景")
}
pub async fn generate_editor_scene(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -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<Json<EditorImageGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, 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!({
@@ -1962,18 +1962,30 @@ fn build_external_generation_job_summary_row(
row: &ExternalGenerationJob,
cached_request_prompt: Option<Option<String>>,
) -> ExternalGenerationJobSummary {
let request_payload = serde_json::from_str::<serde_json::Value>(&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<String> {
@@ -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<String> {
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<String> {
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);