修复游戏场景后端契约旁路
阻止通用图片接口绕过场景专用合同 规范场景任务标题与用户可见摘要 修复空白场景素材名称回退行为 补充场景接口和任务投影回归测试 同步场景技术方案与排障约定
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user