修复外部图片接口绕过场景合同
统一主站与外部图片生成的场景边界校验 新增场景伪装请求零入队回归测试 同步外部OpenAPI、Skill与场景技术文档
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user