修复外部图片接口绕过场景合同
Project CI / Repository checks (pull_request) Failing after 44s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled

统一主站与外部图片生成的场景边界校验
新增场景伪装请求零入队回归测试
同步外部OpenAPI、Skill与场景技术文档
This commit is contained in:
2026-08-06 20:17:52 +08:00
parent 0620f64348
commit 0bbe910d69
10 changed files with 184 additions and 16 deletions
@@ -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`.
@@ -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
@@ -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"
@@ -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 仍只能由后端生成。
@@ -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` 表达。
@@ -321,6 +321,15 @@ async fn enqueue_serialized_editor_generation_job_with_identity(
job_id: String,
dedupe_key: String,
) -> Result<ExternalGenerationJobRecord, AppError> {
#[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()
@@ -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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<EditorImageGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<EditorImageGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, 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(
@@ -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()
}
@@ -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);
+30
View File
@@ -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<Mutex<Option<Vec<module_runtime::FeatureGateConfigSnapshot>>>>,
#[cfg(test)]
test_spacetime_health: Arc<Mutex<Option<SpacetimeClientHealthSnapshot>>>,
#[cfg(test)]
test_editor_generation_enqueue_attempts: AtomicUsize,
#[cfg(test)]
test_fail_editor_generation_enqueue: AtomicBool,
oss_client: Option<OssClient>,
#[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,