Files
Genarrative/server-rs/crates/api-server/src/external_editor_api.rs
T
kdletters fc14190f58
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
图集切片模式改为必须显式声明并补齐决策要求 (#408)
## 背景

切图新增基于连通域的切分后,LLM 仍倾向显式传 `sliceMode=grid`:参数只存在于部分 LLM 可见面、带默认值、没有任何决策规则,生成结果也不回显生效模式。

## 变更

- 平台:`/api/editor/icon-spritesheets/generations` 与 `/api/external/v1/editor/icon-spritesheets/generations` 把 `sliceMode` 改为必填并移除默认值;缺失、空白或未知取值在引用解析、定价与任何 provider / OSS 副作用之前返回 `400`,错误统一带 `field` 与决策要求。
- 契约:`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不接受网格尺寸;`sliceCount` 只约束连通域切分,请求与响应的公开上限统一为 `256`;OpenAPI 去掉默认值并补必填与失败语义。
- AGC:MCP 工具说明去掉默认值并补决策要求,桥接层新增可测试的切分声明校验;原生工具 `canvas.asset_generate` 暴露 `sliceMode/gridX/gridY/sliceCount` 并要求图集显式声明;生成结果回显 `sliceMode/gridX/gridY` 与 `slicePaths`;严格图集在本地提交前校验平台回显与请求声明一致。
- 标准美术包:显式声明 `connected-components` 加 `sliceCount=4`,并在四张 canonical 切片用途映射前校验数量,禁止截断或错位。
- 前端与画板:画板 Agent 工具装配与画板提交计划显式声明连通域切分;前端类型要求显式 `sliceMode` 并在本地校验声明自洽。
- 文档与 Skill:主规范、OpenAPI、AGC Skill、外部编辑器 Skill、里程碑与实施计划、共享决策记录同步更新。
- 测试环境:测试构建对提权 Windows 主机上系统临时目录的所有者偏差做一次性所有者初始化重试,临时目录之外的越权所有者继续失败关闭。

## 兼容性影响

省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端与第三方外部 API 调用方)会在图集生成上收到 `400`;这是本次"不允许默认值"的预期结果,仓库内自有调用方已全部改为显式声明。

## 验证

- 平台:`slice_mode_must_be_declared_*` 与 OpenAPI 契约测试通过;全量 `cargo test -p api-server` 1043 通过 / 11 失败(`wallet_refund_outbox` 临时文件 `拒绝访问`,已在改动前基线复现,属本机环境)。
- AGC:`slice` 30、`spritesheet` 21、`direct_tools_mcp` 23、`agent_native_tools` 16、`canvas_generation_tests` 83、提示词上限与桥接门禁各 1 条、`cargo check --tests` 全部通过。
- 前端:182 条定向测试与 `typecheck` 通过。
- 门禁:`cargo fmt --check`(两个 workspace)、`check:encoding`、`check:doc-index`、`git diff --check` 通过。
- 未验证:真实 Provider 与浏览器试玩、确定性 e2e 车道;整机全量 AGC 单进程运行在本机受提权 shell 的所有者与时序问题影响,不作为门禁信号。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/408
2026-09-17 18:10:34 +08:00

2989 lines
116 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::{
Json,
extract::{Extension, Path, Query, State, rejection::JsonRejection},
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::assets::EditorCanvasGenerationCompletionPayload;
use shared_contracts::external_generation::{
ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse,
ExternalGenerationJobStatus,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput,
EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput,
ExternalGenerationJobRecord, SpacetimeClientError,
};
use crate::editor_project_icon::{
EditorIconSpritesheetGenerationRequest, enqueue_editor_icon_spritesheet_generation_for_owner,
};
use crate::{
api_response::json_success_body,
character_animation_assets::{
enqueue_editor_character_animation_for_owner, enqueue_editor_video_generation_for_owner,
},
editor_generation_queue::editor_generation_queue_state,
editor_project::{
EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE,
EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload,
EditorAssetLibraryPayload, EditorAssetPayload, EditorBackgroundRemovalRequest,
EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest,
EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload,
EditorUiDesignAssetExtractionRequest, current_utc_micros,
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record,
editor_asset_payload_from_record, editor_idempotent_create_id,
editor_project_payload_from_record, editor_project_resource_payload_from_record,
enqueue_editor_background_removal_for_owner, enqueue_editor_image_edit_for_owner,
enqueue_editor_image_generation_for_owner,
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,
optional_editor_idempotency_key, parse_editor_generation_json_payload,
sanitize_editor_untrusted_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_generation_inputs,
},
external_api_auth::ExternalApiPrincipal,
external_generation::map_external_generation_job_status_detail,
http_error::AppError,
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
enqueue_editor_background_music_generation_for_owner,
enqueue_editor_sound_effect_generation_for_owner,
},
};
use std::num::NonZeroU64;
const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api";
const SCOPE_EDITOR_PROJECT: &str = "editor:project";
const SCOPE_EDITOR_CANVAS: &str = "editor:canvas";
const SCOPE_EDITOR_IMAGE_GENERATE: &str = "editor:image-generate";
const SCOPE_EDITOR_ASSET: &str = "editor:asset";
const OPENAPI_JSON: &str =
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
const EXTERNAL_GENERATION_POLL_AFTER_MS: u64 = 1_500;
const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key";
const PROJECT_COVER_SNAPSHOT_ASSET_KIND: &str = "project-cover-snapshot";
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum ExternalEditorProjectListView {
#[default]
Full,
Summary,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectListQuery {
#[serde(default)]
view: ExternalEditorProjectListView,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectCreateRequest {
title: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorCanvasSaveRequest {
viewport: EditorCanvasViewportPayload,
layers: Value,
expected_revision: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectRenameRequest {
title: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorAssetFolderCreateRequest {
label: String,
sort_order: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorAssetFolderUpdateRequest {
label: Option<String>,
collapsed: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorAssetCreateRequest {
folder_id: String,
label: String,
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
image_sequence_frames: Option<Value>,
image_sequence_duration_ms: Option<NonZeroU64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectResourceCreateRequest {
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
source_resource_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
image_sequence_frames: Option<Value>,
image_sequence_duration_ms: Option<NonZeroU64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct ExternalEditorBackgroundRemovalRequest {
source_image_src: String,
background_mode: Option<String>,
screen_color: Option<String>,
project_id: Option<String>,
target_layer_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
asset_folder_id: Option<String>,
asset_label: Option<String>,
source_resource_id: Option<String>,
canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
}
impl From<ExternalEditorBackgroundRemovalRequest> for EditorBackgroundRemovalRequest {
fn from(payload: ExternalEditorBackgroundRemovalRequest) -> Self {
Self {
source_image_src: payload.source_image_src,
background_mode: payload.background_mode,
screen_color: payload.screen_color,
project_id: payload.project_id,
target_layer_id: payload.target_layer_id,
asset_kind: payload.asset_kind,
generation_inputs: payload.generation_inputs,
asset_folder_id: payload.asset_folder_id,
asset_label: payload.asset_label,
source_resource_id: payload.source_resource_id,
task_id: None,
canvas_completion: payload.canvas_completion,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorAssetUpdateRequest {
label: Option<String>,
folder_id: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectResponse {
project: EditorProjectPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectRecentResponse {
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectListResponse {
projects: Vec<EditorProjectPayload>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectSummary {
project_id: String,
title: String,
updated_at: String,
cover: Option<ExternalEditorProjectCoverSummary>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectCoverSummary {
resource_id: String,
object_key: String,
width: u32,
height: u32,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectSummaryListResponse {
projects: Vec<ExternalEditorProjectSummary>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectDeleteResponse {
deleted_project_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorAssetLibraryResponse {
library: EditorAssetLibraryPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorAssetFolderResponse {
folder: EditorAssetFolderPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorAssetFolderDeleteResponse {
library: EditorAssetLibraryPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorAssetResponse {
asset: EditorAssetPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectResourceResponse {
resource: EditorProjectResourcePayload,
}
pub async fn openapi_json() -> Response {
(
[(CONTENT_TYPE, "application/json; charset=utf-8")],
OPENAPI_JSON,
)
.into_response()
}
pub async fn create_external_editor_project(
State(state): State<AppState>,
headers: HeaderMap,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorProjectCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let owner_user_id = principal.owner_user_id().to_string();
let project_id = optional_editor_idempotency_key(&headers)?
.map(|key| {
editor_idempotent_create_id(
EDITOR_PROJECT_ID_PREFIX,
&owner_user_id,
"external-editor-project",
key,
)
})
.unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX));
let project = state
.spacetime_client()
.create_editor_project(EditorProjectCreateRecordInput {
project_id,
owner_user_id,
title: normalize_project_title(payload.title),
now_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn list_external_editor_projects(
State(state): State<AppState>,
Query(query): Query<ExternalEditorProjectListQuery>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let projects = state
.spacetime_client()
.list_editor_projects(principal.owner_user_id().to_string())
.await
.map_err(map_editor_project_error)?;
match query.view {
ExternalEditorProjectListView::Full => Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectListResponse {
projects: projects
.into_iter()
.map(editor_project_payload_from_record)
.collect(),
},
)),
ExternalEditorProjectListView::Summary => Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectSummaryListResponse {
projects: projects
.into_iter()
.map(external_editor_project_summary_from_record)
.collect(),
},
)),
}
}
fn external_editor_project_summary_from_record(
record: EditorProjectRecord,
) -> ExternalEditorProjectSummary {
let cover = record
.resources
.iter()
.filter_map(|resource| {
(resource.asset_kind.as_deref() == Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND))
.then_some(resource)
.zip(
resource
.object_key
.as_deref()
.filter(|object_key| !object_key.trim().is_empty()),
)
})
.max_by(|(left, _), (right, _)| {
left.updated_at
.cmp(&right.updated_at)
.then_with(|| left.resource_id.cmp(&right.resource_id))
})
.map(|(resource, object_key)| ExternalEditorProjectCoverSummary {
resource_id: resource.resource_id.clone(),
object_key: object_key.to_string(),
width: resource.width,
height: resource.height,
updated_at: resource.updated_at.clone(),
});
ExternalEditorProjectSummary {
project_id: record.project_id,
title: record.title,
updated_at: record.updated_at,
cover,
}
}
pub async fn load_recent_external_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let project = state
.spacetime_client()
.get_recent_editor_project(principal.owner_user_id().to_string())
.await
.map_err(map_editor_project_error)?
.map(editor_project_payload_from_record);
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectRecentResponse { project },
))
}
pub async fn get_external_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn rename_external_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorProjectRenameRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let project = state
.spacetime_client()
.rename_editor_project(EditorProjectRenameRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
title: payload.title,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn delete_external_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let deleted_project_id = state
.spacetime_client()
.delete_editor_project(EditorProjectDeleteRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectDeleteResponse { deleted_project_id },
))
}
pub async fn save_external_editor_canvas(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorCanvasSaveRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_CANVAS)?;
let project = save_editor_project_layout_with_revision_and_get(
&state,
project_id.as_str(),
principal.owner_user_id(),
payload.viewport.into_record(),
payload.layers,
payload.expected_revision,
)
.await?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn get_external_editor_asset_library(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let library = state
.spacetime_client()
.get_editor_asset_library(principal.owner_user_id().to_string(), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorAssetLibraryResponse {
library: editor_asset_library_payload_from_record(library),
},
))
}
pub async fn create_external_editor_asset_folder(
State(state): State<AppState>,
headers: HeaderMap,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorAssetFolderCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let owner_user_id = principal.owner_user_id().to_string();
let folder_id = optional_editor_idempotency_key(&headers)?
.map(|key| {
editor_idempotent_create_id(
EDITOR_ASSET_FOLDER_ID_PREFIX,
&owner_user_id,
"external-editor-asset-folder",
key,
)
})
.unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX));
let folder = state
.spacetime_client()
.create_editor_asset_folder(EditorAssetFolderCreateRecordInput {
folder_id,
owner_user_id,
label: payload.label,
sort_order: payload.sort_order.unwrap_or(100),
now_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorAssetFolderResponse {
folder: editor_asset_folder_payload_from_record(folder),
},
))
}
pub async fn update_external_editor_asset_folder(
State(state): State<AppState>,
Path(folder_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorAssetFolderUpdateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let folder = state
.spacetime_client()
.update_editor_asset_folder(EditorAssetFolderUpdateRecordInput {
folder_id,
owner_user_id: principal.owner_user_id().to_string(),
label: normalize_optional_string(payload.label),
collapsed: payload.collapsed,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorAssetFolderResponse {
folder: editor_asset_folder_payload_from_record(folder),
},
))
}
pub async fn delete_external_editor_asset_folder(
State(state): State<AppState>,
Path(folder_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let library = state
.spacetime_client()
.delete_editor_asset_folder(EditorAssetFolderDeleteRecordInput {
folder_id,
owner_user_id: principal.owner_user_id().to_string(),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorAssetFolderDeleteResponse {
library: editor_asset_library_payload_from_record(library),
},
))
}
pub async fn create_external_editor_asset(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorAssetCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let generation_inputs_json = serialize_external_editor_generation_inputs(
payload.asset_kind.as_deref(),
payload.generation_inputs.clone(),
)?;
let image_sequence_frames_json =
serialize_external_editor_image_sequence_frames(payload.image_sequence_frames)?;
let object_key = normalize_optional_string(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let asset = state
.spacetime_client()
.create_editor_asset(EditorAssetCreateRecordInput {
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
owner_user_id: principal.owner_user_id().to_string(),
folder_id: payload.folder_id,
label: payload.label,
asset_object_id: normalize_optional_string(payload.asset_object_id),
image_src,
object_key,
width: payload.width,
height: payload.height,
source_type: payload.source_type,
prompt: normalize_optional_string(payload.prompt),
actual_prompt: normalize_optional_string(payload.actual_prompt),
model: normalize_optional_string(payload.model),
provider: normalize_optional_string(payload.provider),
task_id: normalize_optional_string(payload.task_id),
asset_kind: normalize_optional_string(payload.asset_kind),
generation_inputs_json,
source_resource_id: None,
generation_cost_mud_points: 0,
now_micros: current_utc_micros(),
thumbnail_src: None,
group_task_id: None,
group_task_expected_asset_count: None,
image_sequence_frames_json,
image_sequence_duration_ms: payload.image_sequence_duration_ms.map(NonZeroU64::get),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorAssetResponse {
asset: editor_asset_payload_from_record(asset),
},
))
}
pub async fn update_external_editor_asset(
State(state): State<AppState>,
Path(asset_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorAssetUpdateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let asset = state
.spacetime_client()
.update_editor_asset(EditorAssetUpdateRecordInput {
asset_id,
owner_user_id: principal.owner_user_id().to_string(),
label: normalize_optional_string(payload.label),
folder_id: normalize_optional_string(payload.folder_id),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorAssetResponse {
asset: editor_asset_payload_from_record(asset),
},
))
}
pub async fn delete_external_editor_asset(
State(state): State<AppState>,
Path(asset_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let asset = state
.spacetime_client()
.delete_editor_asset(EditorAssetDeleteRecordInput {
asset_id,
owner_user_id: principal.owner_user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorAssetResponse {
asset: editor_asset_payload_from_record(asset),
},
))
}
pub async fn create_external_editor_project_resource(
State(state): State<AppState>,
Path(project_id): Path<String>,
headers: HeaderMap,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorProjectResourceCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let generation_inputs_json = serialize_external_editor_generation_inputs(
payload.asset_kind.as_deref(),
payload.generation_inputs.clone(),
)?;
let image_sequence_frames_json =
serialize_external_editor_image_sequence_frames(payload.image_sequence_frames)?;
let object_key = normalize_optional_string(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let owner_user_id = principal.owner_user_id().to_string();
let resource_id = optional_editor_idempotency_key(&headers)?
.map(|key| {
editor_idempotent_create_id(
EDITOR_RESOURCE_ID_PREFIX,
&owner_user_id,
"external-editor-project-resource",
key,
)
})
.unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX));
let resource = state
.spacetime_client()
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
resource_id,
project_id,
owner_user_id,
asset_object_id: normalize_optional_string(payload.asset_object_id),
image_src,
object_key,
width: payload.width,
height: payload.height,
source_type: payload.source_type,
prompt: normalize_optional_string(payload.prompt),
actual_prompt: normalize_optional_string(payload.actual_prompt),
model: normalize_optional_string(payload.model),
provider: normalize_optional_string(payload.provider),
task_id: normalize_optional_string(payload.task_id),
source_resource_id: normalize_optional_string(payload.source_resource_id),
asset_kind: normalize_optional_string(payload.asset_kind),
generation_inputs_json,
updated_at_micros: current_utc_micros(),
image_sequence_frames_json,
image_sequence_duration_ms: payload.image_sequence_duration_ms.map(NonZeroU64::get),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResourceResponse {
resource: editor_project_resource_payload_from_record(resource),
},
))
}
pub async fn generate_external_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<Json<EditorImageGenerationRequest>, JsonRejection>,
) -> Result<Response, AppError> {
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,
&request_context,
&editor_generation_caller(&principal, project_id),
payload,
Some(idempotency_key),
)
.await?;
Ok(external_generation_accepted_response(&request_context, job))
}
pub async fn edit_external_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<Json<EditorImageEditRequest>, JsonRejection>,
) -> Result<Response, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)?;
let project_id = payload.project_id.clone();
let job = enqueue_editor_image_edit_for_owner(
&state,
&request_context,
&editor_generation_caller(&principal, project_id),
payload,
Some(idempotency_key),
)
.await?;
Ok(external_generation_accepted_response(&request_context, job))
}
pub async fn remove_external_editor_image_background(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<Json<ExternalEditorBackgroundRemovalRequest>, JsonRejection>,
) -> Result<Response, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
let payload = EditorBackgroundRemovalRequest::from(payload);
let project_id = payload.project_id.clone();
#[cfg(test)]
if let Some(job) = state.intercept_test_external_background_removal_enqueue(
principal.owner_user_id(),
payload.source_image_src.as_str(),
idempotency_key,
) {
return Ok(external_generation_accepted_response(&request_context, job));
}
let job = enqueue_editor_background_removal_for_owner(
&state,
&request_context,
&editor_generation_caller(&principal, project_id),
payload,
Some(idempotency_key),
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
Ok(external_generation_accepted_response(&request_context, job))
}
pub async fn generate_external_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<Json<EditorIconSpritesheetGenerationRequest>, JsonRejection>,
) -> Result<Response, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)?;
let project_id = payload.project_id.clone();
let job = enqueue_editor_icon_spritesheet_generation_for_owner(
&state,
&request_context,
&editor_generation_caller(&principal, project_id),
payload,
Some(idempotency_key),
)
.await?;
Ok(external_generation_accepted_response(&request_context, job))
}
pub async fn extract_external_editor_ui_design_assets(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
Json(payload): Json<EditorUiDesignAssetExtractionRequest>,
) -> Result<Response, AppError> {
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)?;
let project_id = payload.project_id.clone();
let job = enqueue_editor_ui_design_asset_extraction_for_owner(
&state,
&request_context,
&editor_generation_caller(&principal, project_id),
payload,
Some(idempotency_key),
)
.await?;
Ok(external_generation_accepted_response(&request_context, job))
}
pub async fn generate_external_editor_character_animation(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<
Json<shared_contracts::assets::EditorCharacterAnimationGenerateRequest>,
JsonRejection,
>,
) -> Result<Response, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
let job = enqueue_editor_character_animation_for_owner(
&state,
&request_context,
principal.owner_user_id(),
payload,
Some(idempotency_key),
)
.await?;
Ok(external_generation_accepted_response(&request_context, job))
}
pub async fn generate_external_editor_video(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<Json<shared_contracts::assets::EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Response, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
let job = enqueue_editor_video_generation_for_owner(
&state,
&request_context,
principal.owner_user_id(),
payload,
Some(idempotency_key),
)
.await?;
Ok(external_generation_accepted_response(&request_context, job))
}
fn canonicalize_external_editor_sound_effect_model(
value: Option<&str>,
) -> Result<&'static str, AppError> {
shared_contracts::assets::canonicalize_editor_sound_effect_model(value).map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"field": "model",
"message": message,
}))
})
}
pub async fn generate_external_editor_sound_effect(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<
Json<shared_contracts::assets::EditorSoundEffectGenerateRequest>,
JsonRejection,
>,
) -> Result<Response, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let Json(mut payload) = parse_external_generation_json_payload(&request_context, payload)?;
let model = canonicalize_external_editor_sound_effect_model(payload.model.as_deref())
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
payload.model = Some(model.to_string());
let job = enqueue_editor_sound_effect_generation_for_owner(
&state,
&request_context,
principal.owner_user_id(),
payload,
Some(idempotency_key),
)
.await?;
Ok(external_generation_accepted_response(&request_context, job))
}
pub async fn generate_external_editor_background_music(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<
Json<shared_contracts::assets::EditorBackgroundMusicGenerateRequest>,
JsonRejection,
>,
) -> Result<Response, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
let job = enqueue_editor_background_music_generation_for_owner(
&state,
&request_context,
principal.owner_user_id(),
payload,
Some(idempotency_key),
)
.await?;
Ok(external_generation_accepted_response(&request_context, job))
}
pub async fn get_external_editor_generation_job(
State(state): State<AppState>,
Path(operation_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let input = ExternalGenerationJobGetRecordInput {
job_id: operation_id,
owner_user_id: principal.owner_user_id().to_string(),
};
let summary = state
.spacetime_client()
.get_external_generation_job_summary(input.clone())
.await
.map_err(map_external_generation_lookup_error)?;
let detail = map_external_generation_job_status_detail(summary.clone());
let result = if detail.status.status == ExternalGenerationJobStatus::Completed {
let artifacts = state
.spacetime_client()
.get_external_generation_job_generated_artifacts(input)
.await
.map_err(map_external_generation_lookup_error)?;
let payload = artifacts
.result_payload_json
.as_deref()
.and_then(|payload| serde_json::from_str::<Value>(payload).ok())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"message": "生成任务已完成,但结果暂时不可读取。",
}))
})?;
Some(payload.get("result").cloned().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"message": "生成任务已完成,但稳定结果引用缺失。",
}))
})?)
} else {
None
};
let poll_after_ms = matches!(
detail.status.status,
ExternalGenerationJobStatus::Queued | ExternalGenerationJobStatus::Running
)
.then_some(EXTERNAL_GENERATION_POLL_AFTER_MS);
Ok(json_success_body(
Some(&request_context),
ExternalEditorGenerationJobResponse {
operation_id: detail.status.operation_id,
kind: summary.job_kind,
status: detail.status.status,
phase_label: detail.status.phase_label,
phase_detail: detail.status.phase_detail,
progress: detail.status.progress,
error: detail.status.error,
warning: detail.warning,
result,
poll_after_ms,
updated_at_micros: detail.status.updated_at_micros,
},
))
}
fn external_generation_accepted_response(
request_context: &RequestContext,
job: ExternalGenerationJobRecord,
) -> Response {
let kind = job.job_kind.clone();
let status = editor_generation_queue_state(job);
let status_url = format!("/api/external/v1/generations/{}", status.operation_id);
let mut response = (
StatusCode::ACCEPTED,
json_success_body(
Some(request_context),
ExternalEditorGenerationSubmissionResponse {
operation_id: status.operation_id,
kind,
status: status.status,
status_url: status_url.clone(),
poll_after_ms: EXTERNAL_GENERATION_POLL_AFTER_MS,
updated_at_micros: status.updated_at_micros,
},
),
)
.into_response();
if let Ok(location) = HeaderValue::from_str(&status_url) {
response.headers_mut().insert("location", location);
}
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("2"));
response
}
fn require_idempotency_key(headers: &HeaderMap) -> Result<&str, AppError> {
let value = headers
.get(IDEMPOTENCY_KEY_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"message": "生成请求必须携带 Idempotency-Key 请求头。",
}))
})?;
if value.len() > 128 || !value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"message": "Idempotency-Key 必须是 1-128 个可打印 ASCII 字符,且不能包含空格。",
})),
);
}
Ok(value)
}
fn parse_external_generation_json_payload<T: DeserializeOwned>(
request_context: &RequestContext,
payload: Result<Json<T>, JsonRejection>,
) -> Result<Json<T>, Response> {
payload.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"message": error.body_text(),
}))
.into_response_with_context(Some(request_context))
})
}
fn map_external_generation_lookup_error(error: SpacetimeClientError) -> AppError {
if error.to_string().contains("不存在") {
AppError::from_status(StatusCode::NOT_FOUND)
} else {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"message": "生成任务状态暂时不可用。",
}))
}
}
fn editor_generation_caller(
principal: &ExternalApiPrincipal,
project_id: Option<String>,
) -> EditorGenerationCaller {
EditorGenerationCaller {
owner_user_id: principal.owner_user_id().to_string(),
audit_subject_user_id: Some(principal.owner_user_id().to_string()),
audit_project_id: normalize_optional_string(project_id),
phase_reporter: None,
operation: None,
}
}
fn require_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), AppError> {
if principal.has_scope(scope) {
return Ok(());
}
Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"keyId": principal.key_id(),
"scope": scope,
"message": "API Key 缺少所需权限",
})),
)
}
fn require_scope_response(
request_context: &RequestContext,
principal: &ExternalApiPrincipal,
scope: &str,
) -> Result<(), Response> {
require_scope(principal, scope)
.map_err(|error| error.into_response_with_context(Some(request_context)))
}
fn normalize_project_title(title: Option<String>) -> String {
title
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string())
}
fn serialize_external_editor_generation_inputs(
asset_kind: Option<&str>,
generation_inputs: Option<Value>,
) -> Result<Option<String>, AppError> {
serialize_editor_generation_inputs(
asset_kind,
sanitize_editor_untrusted_generation_inputs(generation_inputs),
)
}
fn serialize_external_editor_image_sequence_frames(
frames: Option<Value>,
) -> Result<Option<String>, AppError> {
crate::editor_project::serialize_editor_image_sequence_frames(frames)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, body::Body, routing::post};
use spacetime_client::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
};
use tower::ServiceExt;
fn external_editor_project_resource_fixture(
resource_id: &str,
asset_kind: Option<&str>,
object_key: Option<&str>,
updated_at: &str,
) -> EditorProjectResourceRecord {
serde_json::from_value(json!({
"resource_id": resource_id,
"project_id": "proj-summary-test",
"owner_user_id": "user-1",
"asset_object_id": format!("asset-object-{resource_id}"),
"image_src": format!("/api/assets/{resource_id}"),
"object_key": object_key,
"width": 320,
"height": 240,
"source_type": "uploaded",
"prompt": null,
"actual_prompt": null,
"model": null,
"provider": null,
"task_id": null,
"source_resource_id": null,
"asset_kind": asset_kind,
"generation_inputs": null,
"public_showcase_enabled": false,
"created_at": updated_at,
"updated_at": updated_at
}))
.expect("项目资源 fixture 应兼容可选字段扩展")
}
fn external_editor_project_record_fixture(
resources: Vec<EditorProjectResourceRecord>,
) -> EditorProjectRecord {
EditorProjectRecord {
project_id: "proj-summary-test".to_string(),
owner_user_id: "user-1".to_string(),
title: "摘要项目".to_string(),
canvas: EditorCanvasRecord {
canvas_id: "canvas-summary-test".to_string(),
project_id: "proj-summary-test".to_string(),
title: "摘要项目".to_string(),
viewport: EditorCanvasViewportRecord {
x: 0.0,
y: 0.0,
scale: 1.0,
},
layers: json!([{"large": "canvas payload must not enter summary"}]),
revision: 3,
layout_storage_version: 1,
background_color: None,
created_at: "2026-08-01T00:00:00Z".to_string(),
updated_at: "2026-08-07T00:00:00Z".to_string(),
},
viewport: EditorCanvasViewportRecord {
x: 0.0,
y: 0.0,
scale: 1.0,
},
layers: json!([{"large": "project payload must not enter summary"}]),
resources,
created_at: "2026-08-01T00:00:00Z".to_string(),
updated_at: "2026-08-07T00:00:00Z".to_string(),
}
}
#[test]
fn external_sound_effect_model_canonicalizer_freezes_the_t1_matrix() {
for accepted in [
None,
Some(""),
Some(" \t\r\n"),
Some("\u{0085}\u{2003}\u{00a0}"),
Some("eleven_text_to_sound_v2"),
Some("\u{0085} eleven_text_to_sound_v2 \u{2003}"),
] {
assert_eq!(
canonicalize_external_editor_sound_effect_model(accepted)
.expect("accepted model form should canonicalize"),
shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL,
"accepted={accepted:?}"
);
}
for rejected in [
"audio1.0",
"AUDIO1.0",
"Eleven_text_to_sound_v2",
"eleven_text_to_sound_v3",
"\u{200b}",
"\u{feff}",
] {
let error = canonicalize_external_editor_sound_effect_model(Some(rejected))
.expect_err("legacy and unknown non-empty models should be rejected");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(error.body_text().contains("model"));
}
}
const EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS: [&str; 2] = [
"ExternalEditorAssetCreateRequest",
"ExternalEditorProjectResourceCreateRequest",
];
fn external_generation_job_fixture(status: &str) -> ExternalGenerationJobRecord {
ExternalGenerationJobRecord {
job_id: "task-external-test".to_string(),
dedupe_key: "external-api-generation:editor_image_generation:fingerprint".to_string(),
job_kind: "editor_image_generation".to_string(),
owner_user_id: "user-1".to_string(),
source_module: "editor-canvas".to_string(),
source_entity_id: "project-1".to_string(),
request_label: "图片画布生成图片".to_string(),
request_payload_json: "{}".to_string(),
status: status.to_string(),
attempt: 0,
max_attempts: 1,
last_error_message: None,
worker_id: None,
lease_expires_at: None,
available_at: "2026-07-31T00:00:00Z".to_string(),
result_payload_json: None,
created_at: "2026-07-31T00:00:00Z".to_string(),
started_at: None,
completed_at: None,
updated_at: "2026-07-31T00:00:00Z".to_string(),
updated_at_micros: 1_785_456_000_000_000,
lease_token: None,
price_mud_points: 2,
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: None,
}
}
fn request_context(wants_envelope: bool) -> RequestContext {
RequestContext::new(
"req-external-generation-test".to_string(),
"POST /api/external/v1/editor/images/generations".to_string(),
std::time::Duration::ZERO,
wants_envelope,
)
}
#[test]
fn external_editor_project_list_query_defaults_to_full_and_accepts_summary() {
let without_view = "http://localhost/api/external/v1/editor/projects"
.parse()
.expect("测试 URI 应合法");
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&without_view)
.expect("缺省 view 应保持 full 兼容语义");
assert_eq!(query.view, ExternalEditorProjectListView::Full);
let explicit_full = "http://localhost/api/external/v1/editor/projects?view=full"
.parse()
.expect("测试 URI 应合法");
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&explicit_full)
.expect("显式 full 应合法");
assert_eq!(query.view, ExternalEditorProjectListView::Full);
let summary = "http://localhost/api/external/v1/editor/projects?view=summary"
.parse()
.expect("测试 URI 应合法");
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&summary)
.expect("summary 应合法");
assert_eq!(query.view, ExternalEditorProjectListView::Summary);
let unknown = "http://localhost/api/external/v1/editor/projects?view=compact"
.parse()
.expect("测试 URI 应合法");
assert!(
Query::<ExternalEditorProjectListQuery>::try_from_uri(&unknown).is_err(),
"未知 view 不得静默回落到 full"
);
}
#[test]
fn external_editor_project_summary_selects_latest_persisted_cover() {
let project = external_editor_project_record_fixture(vec![
external_editor_project_resource_fixture(
"resource-cover-old",
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
Some("editor/project-covers/old.webp"),
"2026-08-02T00:00:00Z",
),
external_editor_project_resource_fixture(
"resource-other-newer",
Some("editor_generated_image"),
Some("editor/generated/newer.webp"),
"2026-08-06T00:00:00Z",
),
external_editor_project_resource_fixture(
"resource-cover-empty-key",
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
Some(" "),
"2026-08-07T00:00:00Z",
),
external_editor_project_resource_fixture(
"resource-cover-latest",
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
Some("editor/project-covers/latest.webp"),
"2026-08-05T00:00:00Z",
),
]);
let summary = external_editor_project_summary_from_record(project);
let serialized = serde_json::to_value(&summary).expect("项目摘要应可序列化");
assert_eq!(
serialized,
json!({
"projectId": "proj-summary-test",
"title": "摘要项目",
"updatedAt": "2026-08-07T00:00:00Z",
"cover": {
"resourceId": "resource-cover-latest",
"objectKey": "editor/project-covers/latest.webp",
"width": 320,
"height": 240,
"updatedAt": "2026-08-05T00:00:00Z"
}
})
);
assert!(serialized.get("canvas").is_none());
assert!(serialized.get("layers").is_none());
assert!(serialized.get("resources").is_none());
}
#[test]
fn external_editor_project_summary_returns_null_without_persisted_cover() {
let project = external_editor_project_record_fixture(vec![
external_editor_project_resource_fixture(
"resource-non-cover",
Some("editor_generated_image"),
Some("editor/generated/image.webp"),
"2026-08-06T00:00:00Z",
),
external_editor_project_resource_fixture(
"resource-cover-without-object",
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
None,
"2026-08-07T00:00:00Z",
),
]);
let summary = external_editor_project_summary_from_record(project);
assert_eq!(summary.cover, None);
assert_eq!(
serde_json::to_value(summary).expect("无封面摘要应可序列化")["cover"],
Value::Null
);
}
#[test]
fn nineteen_large_projects_stay_below_mcp_limit_in_summary_view() {
const MCP_LIMIT_BYTES: usize = 4 * 1024 * 1024;
let projects = (0..19)
.map(|index| {
let mut project = external_editor_project_record_fixture(Vec::new());
project.project_id = format!("proj-summary-{index}");
project.canvas.project_id = project.project_id.clone();
project.title = format!("项目 {index}");
project.layers = json!({"largePayload": "x".repeat(160_000)});
project.canvas.layers = json!({"largePayload": "x".repeat(160_000)});
project
})
.collect::<Vec<_>>();
let full = serde_json::to_vec(
&projects
.iter()
.cloned()
.map(editor_project_payload_from_record)
.collect::<Vec<_>>(),
)
.expect("完整项目列表应可序列化");
let summaries = projects
.into_iter()
.map(external_editor_project_summary_from_record)
.collect::<Vec<_>>();
let summary = serde_json::to_vec(&summaries).expect("项目摘要列表应可序列化");
assert!(
full.len() > MCP_LIMIT_BYTES,
"测试数据必须先复现完整列表超过 MCP 上限"
);
assert_eq!(summaries.len(), 19);
assert!(
summary.len() < MCP_LIMIT_BYTES,
"同一批项目的摘要必须低于 MCP 上限"
);
assert!(
!summary
.windows(b"largePayload".len())
.any(|window| { window == b"largePayload" })
);
}
#[test]
fn external_editor_canvas_save_request_requires_expected_revision() {
let missing_revision = serde_json::from_value::<ExternalEditorCanvasSaveRequest>(json!({
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
"layers": [],
}))
.expect_err("外部画布保存缺少 expectedRevision 时必须在进入写路径前失败");
assert!(missing_revision.to_string().contains("expectedRevision"));
let request = serde_json::from_value::<ExternalEditorCanvasSaveRequest>(json!({
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
"layers": [],
"expectedRevision": 7,
}))
.expect("外部画布保存携带 expectedRevision 时应通过反序列化");
assert_eq!(request.expected_revision, 7);
}
#[test]
fn external_editor_generation_inputs_sanitize_audit_fields_for_regular_assets() {
let serialized = serialize_external_editor_generation_inputs(
Some("image"),
Some(json!({
"fields": [{"label": "角色设定", "value": "骑士"}],
"screenColorHex": "#00FF00",
"mattingProvider": "forged-provider",
"mattingModel": "forged-model",
"references": [{
"title": "伪造引用",
"label": "其他用户素材",
"refType": "asset",
"refId": "asset-forged"
}],
"extension": {"durationSeconds": 4}
})),
)
.expect("外部编辑器生成输入应可序列化")
.expect("非空生成输入应保留");
let parsed: Value = serde_json::from_str(&serialized).expect("生成输入应为合法 JSON");
assert_eq!(parsed["fields"][0]["value"], "骑士");
assert_eq!(parsed["extension"]["durationSeconds"], 4);
assert!(parsed.get("screenColorHex").is_none());
assert!(parsed.get("mattingProvider").is_none());
assert!(parsed.get("mattingModel").is_none());
assert!(parsed.get("references").is_none());
}
#[test]
fn external_editor_character_animation_generation_inputs_reject_legacy_runtime_fields() {
for (field, value) in [
("characterAnimation", json!({ "durationSeconds": 4 })),
("frames", json!([])),
("previewVideoPath", json!("/generated/action/preview.mp4")),
("frameCount", json!(32)),
("fps", json!(8)),
("durationSeconds", json!(4)),
] {
let mut generation_inputs = serde_json::Map::new();
generation_inputs.insert("fields".to_string(), json!([]));
generation_inputs.insert(field.to_string(), value);
let error = serialize_external_editor_generation_inputs(
Some("character-animation"),
Some(Value::Object(generation_inputs)),
)
.expect_err("External v1 不应再接受角色动作旧运行字段");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error
.details()
.and_then(|details| details["fields"][0].as_str()),
Some(field)
);
assert!(error.body_text().contains("imageSequenceFrames"));
assert!(error.body_text().contains("imageSequenceDurationMs"));
}
}
#[test]
fn external_editor_sequence_frames_reject_temporary_signed_urls_without_stable_references() {
let error = serialize_external_editor_image_sequence_frames(Some(json!([
{
"imageSrc": "https://example.invalid/frame-01.png?Expires=60&Signature=temporary-1",
"width": 192,
"height": 256
},
{
"imageSrc": "https://example.invalid/frame-02.png?Expires=60&Signature=temporary-2",
"width": 192,
"height": 256
}
])))
.expect_err("External v1 不能把仅含临时签名 URL 的帧持久化为正式动作");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("fields")),
Some(&json!(["objectKey", "assetObjectId"]))
);
assert!(error.body_text().contains("临时签名 URL"));
}
#[test]
fn external_editor_sequence_frames_derive_persistent_paths_from_object_keys() {
let serialized = serialize_external_editor_image_sequence_frames(Some(json!([
{
"imageSrc": "https://example.invalid/frame-01.png?Expires=60&Signature=temporary-1",
"objectKey": "/generated/action/frame-01.png",
"assetObjectId": "asset-object-frame-01",
"width": 192,
"height": 256
},
{
"imageSrc": "https://example.invalid/frame-02.png?Expires=60&Signature=temporary-2",
"objectKey": "generated/action/frame-02.png",
"assetObjectId": "asset-object-frame-02",
"width": 192,
"height": 256
}
])))
.expect("带稳定对象引用的 External v1 正式帧应可持久化")
.expect("正式帧序列应产生 JSON");
let frames: Value = serde_json::from_str(&serialized).expect("正式帧序列应为合法 JSON");
assert_eq!(frames[0]["imageSrc"], "/generated/action/frame-01.png");
assert_eq!(frames[1]["imageSrc"], "/generated/action/frame-02.png");
assert!(frames.as_array().is_some_and(|frames| {
frames.iter().all(|frame| {
!frame["imageSrc"]
.as_str()
.is_some_and(|image_src| image_src.contains("Signature="))
})
}));
}
#[test]
fn external_generation_requires_bounded_printable_idempotency_key() {
let missing = HeaderMap::new();
let error = require_idempotency_key(&missing).expect_err("外部生成必须显式提供幂等键");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(error.body_text().contains("Idempotency-Key"));
let mut whitespace = HeaderMap::new();
whitespace.insert(IDEMPOTENCY_KEY_HEADER, HeaderValue::from_static(" "));
assert!(require_idempotency_key(&whitespace).is_err());
let mut valid = HeaderMap::new();
let longest_valid = "x".repeat(128);
valid.insert(
IDEMPOTENCY_KEY_HEADER,
HeaderValue::from_str(&longest_valid).expect("128 字节可打印 ASCII 应是合法 header"),
);
assert_eq!(
require_idempotency_key(&valid).expect("边界长度幂等键应通过"),
longest_valid
);
for invalid in [
"x".repeat(129),
"contains space".to_string(),
"中文".to_string(),
] {
let mut headers = HeaderMap::new();
headers.insert(
IDEMPOTENCY_KEY_HEADER,
HeaderValue::from_str(&invalid).expect("测试值应可构造为 HTTP header"),
);
let error = require_idempotency_key(&headers)
.expect_err("超长、含空格或非 ASCII 的幂等键必须拒绝");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
}
}
#[test]
fn external_project_create_rejects_an_invalid_optional_idempotency_header() {
let mut headers = HeaderMap::new();
headers.insert(
IDEMPOTENCY_KEY_HEADER,
HeaderValue::from_static("contains space"),
);
let error = optional_editor_idempotency_key(&headers)
.expect_err("External project create 必须拒绝非法可选幂等键");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
let source = include_str!("external_editor_api.rs");
let handler = source
.split_once("pub async fn create_external_editor_project(")
.and_then(|(_, tail)| {
tail.split_once("pub async fn list_external_editor_projects(")
.map(|(body, _)| body)
})
.expect("external project create handler");
assert!(handler.contains("optional_editor_idempotency_key(&headers)?"));
}
#[tokio::test]
async fn external_generation_submission_is_accepted_with_poll_contract() {
let response = external_generation_accepted_response(
&request_context(false),
external_generation_job_fixture("pending"),
);
assert_eq!(response.status(), StatusCode::ACCEPTED);
assert_eq!(
response
.headers()
.get("location")
.and_then(|value| value.to_str().ok()),
Some("/api/external/v1/generations/task-external-test")
);
assert_eq!(
response
.headers()
.get("retry-after")
.and_then(|value| value.to_str().ok()),
Some("2")
);
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("submission body 应可读取");
let payload: Value = serde_json::from_slice(&body).expect("submission body 应为 JSON");
assert_eq!(payload["operationId"], json!("task-external-test"));
assert_eq!(payload["kind"], json!("editor_image_generation"));
assert_eq!(payload["status"], json!("queued"));
assert_eq!(
payload["statusUrl"],
json!("/api/external/v1/generations/task-external-test")
);
assert_eq!(
payload["pollAfterMs"],
json!(EXTERNAL_GENERATION_POLL_AFTER_MS)
);
}
#[tokio::test]
async fn idempotent_completed_submission_reports_completed_in_envelope() {
let response = external_generation_accepted_response(
&request_context(true),
external_generation_job_fixture("completed"),
);
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("submission envelope 应可读取");
let payload: Value = serde_json::from_slice(&body).expect("submission envelope 应为 JSON");
assert_eq!(payload["ok"], json!(true));
assert_eq!(payload["data"]["operationId"], json!("task-external-test"));
assert_eq!(payload["data"]["status"], json!("completed"));
assert_eq!(
payload["data"]["pollAfterMs"],
json!(EXTERNAL_GENERATION_POLL_AFTER_MS)
);
}
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_background_removal_rejects_invalid_parameters_before_queueing() {
let state = AppState::new(crate::config::AppConfig::default())
.expect("external background removal test state should build");
state.fail_test_editor_generation_enqueue();
let app = Router::new()
.route(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
"user-external-background-removal",
&[SCOPE_EDITOR_IMAGE_GENERATE],
)))
.with_state(state.clone());
for (case_name, extra_field) in [
(
"internal taskId",
json!({"taskId": "caller-controlled-task"}),
),
("unknown field", json!({"unexpected": true})),
("invalid mode", json!({"backgroundMode": "unknown"})),
("empty mode", json!({"backgroundMode": ""})),
("uppercase mode", json!({"backgroundMode": "FLAT"})),
("color without flat", json!({"screenColor": "auto"})),
(
"complex with color",
json!({"backgroundMode": "complex", "screenColor": "#123456"}),
),
(
"empty color",
json!({"backgroundMode": "flat", "screenColor": ""}),
),
(
"invalid color",
json!({"backgroundMode": "flat", "screenColor": "red"}),
),
(
"non-string color",
json!({"backgroundMode": "flat", "screenColor": 123}),
),
] {
let mut request_body = json!({"sourceImageSrc": "editor-upload/source.png"});
request_body
.as_object_mut()
.expect("background removal body should be an object")
.extend(
extra_field
.as_object()
.expect("extra field fixture should be an object")
.clone(),
);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/background-removals")
.header("content-type", "application/json")
.header(IDEMPOTENCY_KEY_HEADER, "background-removal-contract-test")
.body(Body::from(request_body.to_string()))
.expect("external background removal request should build"),
)
.await
.expect("external background removal response should return");
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case_name}");
assert_eq!(
state.test_editor_generation_enqueue_attempts(),
0,
"{case_name} must fail before queueing",
);
}
}
#[tokio::test]
async fn external_background_removal_rejects_unstable_sources_before_queueing() {
let state = AppState::new(crate::config::AppConfig::default())
.expect("external background removal source test state should build");
state.fail_test_editor_generation_enqueue();
let app = Router::new()
.route(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
"user-external-background-removal-source",
&[SCOPE_EDITOR_IMAGE_GENERATE],
)))
.with_state(state.clone());
for source_image_src in [
"data:image/png;base64,AAAA",
"blob:browser-only",
"https://oss.example/private.png?signature=temporary",
] {
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/background-removals")
.header("content-type", "application/json")
.header(
IDEMPOTENCY_KEY_HEADER,
"background-removal-source-contract-test",
)
.body(Body::from(
json!({"sourceImageSrc": source_image_src}).to_string(),
))
.expect("external background removal source request should build"),
)
.await
.expect("external background removal source response should return");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{source_image_src}"
);
assert_eq!(
state.test_editor_generation_enqueue_attempts(),
0,
"unstable source must fail before queueing: {source_image_src}",
);
}
}
#[tokio::test]
async fn external_background_removal_rejects_non_static_asset_kind_before_queueing() {
let state = AppState::new(crate::config::AppConfig::default())
.expect("external background removal kind test state should build");
state.fail_test_editor_generation_enqueue();
let app = Router::new()
.route(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
"user-external-background-removal-kind",
&[SCOPE_EDITOR_IMAGE_GENERATE],
)))
.with_state(state.clone());
for asset_kind in ["video", "audio", "character-animation", "image-sequence"] {
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/background-removals")
.header("content-type", "application/json")
.header(
IDEMPOTENCY_KEY_HEADER,
format!("background-removal-kind-{asset_kind}"),
)
.body(Body::from(
json!({
"sourceImageSrc": "editor-upload/source.png",
"assetKind": asset_kind,
})
.to_string(),
))
.expect("external background removal kind request should build"),
)
.await
.expect("external background removal kind response should return");
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{asset_kind}");
assert_eq!(
state.test_editor_generation_enqueue_attempts(),
0,
"non-static kind must fail before queueing: {asset_kind}",
);
}
}
#[tokio::test]
async fn external_background_removal_rejects_target_without_project_before_queueing() {
let state = AppState::new(crate::config::AppConfig::default())
.expect("external background removal target test state should build");
state.fail_test_editor_generation_enqueue();
let app = Router::new()
.route(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
"user-external-background-removal-target",
&[SCOPE_EDITOR_IMAGE_GENERATE],
)))
.with_state(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/background-removals")
.header("content-type", "application/json")
.header(
IDEMPOTENCY_KEY_HEADER,
"background-removal-target-contract-test",
)
.body(Body::from(
json!({
"sourceImageSrc": "editor-upload/source.png",
"targetLayerId": "layer-source",
})
.to_string(),
))
.expect("external background removal target request should build"),
)
.await
.expect("external background removal target response should return");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(
state.test_editor_generation_enqueue_attempts(),
0,
"target without project must fail before queueing",
);
}
#[tokio::test]
async fn external_background_removal_route_accepts_a_valid_submission_once() {
const OWNER_USER_ID: &str = "user-external-background-removal-success";
const SOURCE_IMAGE_SRC: &str = "editor-upload/background-removal-source.png";
const IDEMPOTENCY_KEY: &str = "background-removal-success-contract-test";
const OPERATION_ID: &str = "task-external-background-removal-success";
let state = AppState::new(crate::config::AppConfig::default())
.expect("external background removal success test state should build");
let mut queued_job = external_generation_job_fixture("pending");
queued_job.job_id = OPERATION_ID.to_string();
queued_job.job_kind = "editor_background_removal".to_string();
queued_job.owner_user_id = OWNER_USER_ID.to_string();
state.set_test_external_background_removal_enqueue(
OWNER_USER_ID,
SOURCE_IMAGE_SRC,
IDEMPOTENCY_KEY,
queued_job,
);
let request_body = json!({"sourceImageSrc": SOURCE_IMAGE_SRC}).to_string();
let without_scope = Router::new()
.route(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
OWNER_USER_ID,
&[],
)))
.with_state(state.clone());
let forbidden = without_scope
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/background-removals")
.header("content-type", "application/json")
.header(IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_KEY)
.body(Body::from(request_body.clone()))
.expect("external background removal forbidden request should build"),
)
.await
.expect("external background removal forbidden response should return");
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
assert_eq!(state.test_editor_generation_enqueue_attempts(), 0);
let app = Router::new()
.route(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
OWNER_USER_ID,
&[SCOPE_EDITOR_IMAGE_GENERATE],
)))
.with_state(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/background-removals")
.header("content-type", "application/json")
.header(IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_KEY)
.body(Body::from(request_body))
.expect("external background removal success request should build"),
)
.await
.expect("external background removal success response should return");
assert_eq!(response.status(), StatusCode::ACCEPTED);
assert_eq!(
response
.headers()
.get("location")
.and_then(|value| value.to_str().ok()),
Some("/api/external/v1/generations/task-external-background-removal-success")
);
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("external background removal success body should collect");
let payload: Value = serde_json::from_slice(&body)
.expect("external background removal success body should be JSON");
assert_eq!(payload["operationId"], json!(OPERATION_ID));
assert_eq!(payload["status"], json!("queued"));
assert_eq!(
payload["statusUrl"],
json!(format!("/api/external/v1/generations/{OPERATION_ID}"))
);
assert_eq!(
payload["pollAfterMs"],
json!(EXTERNAL_GENERATION_POLL_AFTER_MS)
);
assert_eq!(
state.test_editor_generation_enqueue_attempts(),
1,
"valid External background removal should enqueue exactly once",
);
}
#[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(
"external_generation_job 不存在".to_string(),
));
assert_eq!(not_found.status_code(), StatusCode::NOT_FOUND);
let unavailable =
map_external_generation_lookup_error(SpacetimeClientError::ConnectDropped);
assert_eq!(unavailable.status_code(), StatusCode::BAD_GATEWAY);
assert!(!unavailable.body_text().contains("ConnectDropped"));
}
#[test]
fn external_openapi_character_animation_create_requires_complete_sequence_metadata() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
for request_schema_name in EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS {
let request_schema = &parsed["components"]["schemas"][request_schema_name];
let conditional = &request_schema["allOf"][0];
assert_eq!(
conditional["then"]["required"],
json!(["imageSequenceFrames", "imageSequenceDurationMs"]),
"{request_schema_name}"
);
assert_eq!(
request_schema["properties"]["imageSequenceFrames"]["minItems"],
json!(2),
"{request_schema_name}"
);
let frame_schema = &parsed["components"]["schemas"]["EditorImageSequenceFrame"];
assert_eq!(
frame_schema["required"],
json!(["imageSrc", "objectKey", "assetObjectId", "width", "height"]),
"{request_schema_name}"
);
for stable_field in ["objectKey", "assetObjectId"] {
assert_eq!(
frame_schema["properties"][stable_field]["type"],
json!("string"),
"{request_schema_name}.{stable_field}"
);
assert_eq!(
frame_schema["properties"][stable_field]["minLength"],
json!(1),
"{request_schema_name}.{stable_field}"
);
}
assert_eq!(
request_schema["properties"]["imageSequenceDurationMs"]["minimum"],
json!(1),
"{request_schema_name}"
);
assert_eq!(
conditional["then"]["properties"]["generationInputs"]["$ref"],
"#/components/schemas/ExternalCharacterAnimationGenerationInputs",
"{request_schema_name}"
);
}
}
#[test]
fn external_openapi_non_animation_create_forbids_sequence_metadata() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
for request_schema_name in EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS {
let request_schema = &parsed["components"]["schemas"][request_schema_name];
let conditional = &request_schema["allOf"][0];
assert_eq!(
conditional["if"]["required"],
json!(["assetKind"]),
"{request_schema_name}"
);
let forbidden_fields = conditional["else"]["not"]["anyOf"]
.as_array()
.expect("非角色动作分支必须逐项禁止序列字段")
.iter()
.filter_map(|schema| schema["required"][0].as_str())
.collect::<Vec<_>>();
assert_eq!(
forbidden_fields,
["imageSequenceFrames", "imageSequenceDurationMs"],
"{request_schema_name}"
);
}
}
#[test]
fn exported_openapi_json_contains_external_editor_routes_and_security() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
assert_eq!(parsed["openapi"], "3.1.0");
let legacy_runtime_fields = [
"characterAnimation",
"frames",
"previewVideoPath",
"frameCount",
"fps",
"durationSeconds",
];
let forbidden_generation_inputs = &parsed["components"]["schemas"]["ExternalCharacterAnimationGenerationInputs"]
["not"]["anyOf"];
assert_eq!(
forbidden_generation_inputs
.as_array()
.expect("角色动作 generationInputs 约束必须是数组")
.iter()
.filter_map(|schema| schema["required"][0].as_str())
.collect::<Vec<_>>(),
legacy_runtime_fields
);
for request_schema in EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS {
assert_eq!(
parsed["components"]["schemas"][request_schema]["allOf"][0]["if"]["properties"]["assetKind"]
["const"],
"character-animation"
);
}
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/assets/direct-upload-tickets")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/assets/objects/confirm")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/assets/read-url")
.is_some()
);
assert!(
parsed["paths"]["/api/external/v1/editor/projects"]
.get("get")
.is_some()
);
assert!(
parsed["paths"]["/api/external/v1/editor/projects"]
.get("post")
.is_some()
);
for path in [
"/api/external/v1/editor/projects",
"/api/external/v1/editor/projects/{projectId}/resources",
"/api/external/v1/editor/assets/folders",
] {
assert_eq!(
parsed["paths"][path]["post"]["responses"]["409"]["$ref"],
"#/components/responses/IdempotentCreateConflict",
"{path} 必须公开专用稳定创建冲突"
);
assert!(
parsed["paths"][path]["post"]["parameters"]
.as_array()
.is_some_and(|parameters| {
parameters.iter().any(|parameter| {
parameter.get("$ref").and_then(Value::as_str)
== Some("#/components/parameters/IdempotentCreateKey")
})
}),
"{path} 必须公开可选的稳定创建幂等键"
);
}
assert_eq!(
parsed["components"]["parameters"]["IdempotentCreateKey"]["required"],
false
);
assert!(
parsed["components"]["parameters"]["IdempotentCreateKey"]["description"]
.as_str()
.is_some_and(|description| description.contains("已删除"))
);
assert!(
parsed["paths"]["/api/external/v1/editor/projects"]["post"]["responses"]
.get("400")
.is_some(),
"External project create 必须声明非法 Idempotency-Key 的 400"
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects/recent")
.is_some()
);
assert!(
parsed["paths"]["/api/external/v1/editor/projects/{projectId}"]
.get("delete")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects/{projectId}/metadata")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/generations")
.is_some()
);
for path in [
"/api/external/v1/editor/images/generations",
"/api/external/v1/editor/images/edits",
"/api/external/v1/editor/images/background-removals",
"/api/external/v1/editor/icon-spritesheets/generations",
"/api/external/v1/editor/ui-designs/assets/extractions",
"/api/external/v1/editor/character-animations/generations",
"/api/external/v1/editor/videos/generations",
"/api/external/v1/editor/audios/sound-effects/generations",
"/api/external/v1/editor/audios/background-music/generations",
] {
let operation = &parsed["paths"][path]["post"];
assert!(operation["responses"].get("202").is_some(), "{path}");
assert!(operation["responses"].get("200").is_none(), "{path}");
assert!(
operation["parameters"]
.as_array()
.is_some_and(|parameters| {
parameters.iter().any(|parameter| {
parameter.get("$ref").and_then(Value::as_str)
== Some("#/components/parameters/IdempotencyKey")
})
})
);
}
assert!(
parsed["paths"]
.get("/api/external/v1/generations/{operationId}")
.is_some()
);
for path in [
"/api/external/v1/agent-integration.json",
"/api/external/v1/skill/SKILL.md",
"/api/external/v1/skill.zip",
"/api/external/v1/mcp",
] {
assert!(parsed["paths"].get(path).is_some(), "{path}");
}
assert!(
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("priceMudPoints")))
);
assert!(
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]
.get("priceMudPoints")
.is_none()
);
let image_style_schema =
&parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["style"];
assert_eq!(image_style_schema["anyOf"][0]["type"], "string");
assert!(image_style_schema["anyOf"][0].get("enum").is_none());
assert_eq!(image_style_schema["examples"], json!(["none", "pixelArt"]));
assert!(
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"]
.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);
assert!(
generation_references["items"]["description"]
.as_str()
.is_some_and(|description| description.contains("超限返回 400"))
);
assert_eq!(
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
"array"
);
assert_eq!(
parsed["components"]["schemas"]["EditorCanvas"]["properties"]["layers"]["type"],
"array"
);
assert!(
parsed["components"]["schemas"]["ExternalEditorCanvasSaveRequest"]["required"]
.as_array()
.is_some_and(|required| required.contains(&json!("expectedRevision")))
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/edits")
.is_some()
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["required"]
.as_array()
.is_some_and(|required| required.contains(&json!("sourceReferenceId"))
&& !required.contains(&json!("priceMudPoints")))
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
.get("priceMudPoints")
.is_none()
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
.get("targetLayerId")
.is_some()
);
let image_edit_schema =
&parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"];
for legacy_field in ["sourceImageSrc", "sourceResourceId", "assetKind"] {
assert!(
image_edit_schema.get(legacy_field).is_none(),
"{legacy_field}"
);
}
assert!(
image_edit_schema["sourceReferenceId"]["description"]
.as_str()
.is_some_and(|description| description.contains("项目资源 ID 或素材 ID")
&& description.contains("objectKey")
&& description.contains("未登记上传对象")
&& description.contains("400"))
);
let image_edit_references = &image_edit_schema["referenceImageSrcs"];
assert!(
image_edit_references["items"]["description"]
.as_str()
.is_some_and(|description| description.contains("sourceReferenceId")
&& !description.contains("sourceImageSrc"))
);
assert_eq!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["additionalProperties"],
json!(false)
);
let image_edit_operation = &parsed["paths"]["/api/external/v1/editor/images/edits"]["post"];
assert_eq!(
image_edit_operation["x-genarrative-allowed-effective-asset-kinds"],
json!([
null,
"spec",
"character",
"icon-spritesheet",
"icon-spec",
"publication-material",
"ui-design",
"scene"
])
);
assert!(
image_edit_operation["description"]
.as_str()
.is_some_and(|description| description.contains("targetLayerId")
&& description.contains("sourceReferenceId")
&& description.contains("assetObjectId")
&& description.contains("bucket/objectKey")
&& description.contains("scene")
&& description.contains("未知类型")
&& description.contains("返回 400"))
);
assert!(image_edit_operation["responses"].get("400").is_some());
for (schema, max_items) in [
("EditorImageEditRequest", 8),
("EditorIconSpritesheetGenerationRequest", 8),
("EditorUiDesignAssetExtractionRequest", 5),
] {
let references =
&parsed["components"]["schemas"][schema]["properties"]["referenceImageSrcs"];
assert_eq!(references["maxItems"], max_items, "{schema}");
assert!(
references["items"]["description"]
.as_str()
.is_some_and(|description| description.contains("超限返回 400")),
"{schema}"
);
}
assert!(
parsed["paths"]
.get("/api/external/v1/editor/icon-spritesheets/generations")
.is_some()
);
let icon_spritesheet_request =
&parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"];
assert!(
icon_spritesheet_request["required"]
.as_array()
.is_some_and(|required| required.contains(&json!("referenceId")))
);
assert!(
icon_spritesheet_request["properties"]
.get("referenceImageSrc")
.is_none()
);
let icon_descriptions = &icon_spritesheet_request["properties"]["iconDescriptions"];
assert_eq!(
icon_descriptions["items"]["maxLength"],
crate::editor_project_icon::EDITOR_ICON_DESCRIPTION_MAX_CHARS
);
assert_eq!(
icon_descriptions["x-genarrative-maxTotalCharacters"],
crate::editor_project_icon::EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS
);
assert_eq!(
icon_descriptions["x-genarrative-maxTotalUtf8Bytes"],
crate::editor_project_icon::EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES
);
assert!(
icon_descriptions["description"]
.as_str()
.is_some_and(|description| description.contains("同步返回 400"))
);
assert_eq!(
icon_spritesheet_request["properties"]["sliceCount"]["minimum"],
json!(1)
);
assert_eq!(
icon_spritesheet_request["properties"]["sliceCount"]["maximum"],
json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_SLICES)
);
assert_eq!(
icon_spritesheet_request["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
assert!(
icon_spritesheet_request["properties"]["sliceMode"]
.get("default")
.is_none(),
"sliceMode must not advertise a default"
);
assert!(
icon_spritesheet_request["required"]
.as_array()
.is_some_and(|required| required.contains(&json!("sliceMode"))),
"sliceMode must be required"
);
assert!(
icon_spritesheet_request["properties"]["sliceMode"]["description"]
.as_str()
.is_some_and(|description| description.contains("没有默认值")
&& description.contains("field=sliceMode")
&& description.contains("gridX/gridY")),
"sliceMode description must carry the explicit decision requirement"
);
assert_eq!(
icon_spritesheet_request["properties"]["gridX"]["maximum"],
json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS)
);
let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
["properties"]["style"];
assert_eq!(icon_style_schema["anyOf"][0]["type"], "string");
assert!(icon_style_schema["anyOf"][0].get("enum").is_none());
assert_eq!(icon_style_schema["examples"], json!(["none", "pixelArt"]));
assert_eq!(
parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
["sliceWarning"]["anyOf"][0]["$ref"],
"#/components/schemas/EditorIconSpritesheetSliceWarning"
);
assert!(parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]["sliceCount"].is_object());
assert_eq!(
parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"]
["anyOf"][0]["$ref"],
"#/components/schemas/EditorGenerationWarning"
);
assert_eq!(
parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
["warning"]["anyOf"][0]["$ref"],
"#/components/schemas/EditorGenerationWarning"
);
assert_eq!(
parsed["components"]["schemas"]["EditorGenerationWarning"]["required"],
json!(["code", "reason"])
);
assert_eq!(
parsed["components"]["schemas"]["EditorGenerationWarning"]["properties"]["code"]["enum"],
json!([
"postprocess-failed-source-preserved",
"dimension-restore-fallback",
"unsupported-image-style",
"multiple-generation-warnings"
])
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/ui-designs/assets/extractions")
.is_some()
);
let ui_extraction_schema =
&parsed["components"]["schemas"]["EditorUiDesignAssetExtractionRequest"];
assert_eq!(
ui_extraction_schema["properties"]["aspectRatio"]["const"],
"1:1"
);
assert_eq!(
ui_extraction_schema["properties"]["imageSize"]["enum"],
json!(["1K", "2K"])
);
assert_eq!(
ui_extraction_schema["properties"]["model"]["default"],
"gemini-3.1-flash-image-preview"
);
assert!(
ui_extraction_schema["properties"]
.get("priceMudPoints")
.is_none()
);
assert!(ui_extraction_schema["properties"].get("style").is_none());
assert!(
parsed["paths"]
.get("/api/external/v1/editor/videos/generations")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/audios/sound-effects/generations")
.is_some()
);
let sound_request = &parsed["components"]["schemas"]["EditorSoundEffectGenerationRequest"];
assert_eq!(sound_request["required"], json!(["prompt"]));
assert_eq!(
sound_request["properties"]["prompt"]["maxLength"],
json!(2048)
);
assert_eq!(
sound_request["properties"]["model"]["default"],
json!(shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL)
);
assert_eq!(
sound_request["properties"]["duration"]["anyOf"][0]["type"],
json!("number")
);
assert_eq!(
sound_request["properties"]["duration"]["anyOf"][0]["minimum"],
json!(0.5)
);
assert_eq!(
sound_request["properties"]["duration"]["anyOf"][0]["maximum"],
json!(30)
);
assert_eq!(sound_request["properties"]["loop"]["default"], json!(false));
let audio_response = &parsed["components"]["schemas"]["EditorAudioGenerationResponse"];
assert_eq!(
audio_response["properties"]["durationSeconds"]["maximum"],
json!(600)
);
assert_eq!(
audio_response["properties"]["loop"]["type"],
json!(["boolean", "null"])
);
let generation_job =
&parsed["components"]["schemas"]["ExternalEditorGenerationJobResponse"];
assert_eq!(
generation_job["properties"]["result"]["$ref"],
"#/components/schemas/ExternalEditorGenerationCompletedResult"
);
assert!(
generation_job["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("result"))),
"历史 completed 轮询结果可能没有 result,不能在无版本迁移时改为必填"
);
let completed_result =
&parsed["components"]["schemas"]["ExternalEditorGenerationCompletedResult"];
assert_eq!(
completed_result["oneOf"][0]["$ref"],
"#/components/schemas/ExternalEditorSoundEffectCompactResult"
);
assert_eq!(
completed_result["oneOf"][1]["$ref"],
"#/components/schemas/ExternalEditorGenerationGenericCompactResult"
);
assert!(
completed_result.get("discriminator").is_none(),
"completed result 联合不能带 discriminator:BGM 回 audioKind=background-music,图片、\
视频与历史结果没有 audioKind,而 fallback 分支不可能把 audioKind 声明为必填,\
任何映射都覆盖不全合法结果"
);
let sound_effect_result =
&parsed["components"]["schemas"]["ExternalEditorSoundEffectCompactResult"];
assert_eq!(sound_effect_result["required"], json!(["audioKind"]));
assert_eq!(
sound_effect_result["properties"]["audioKind"]["const"],
"sound-effect"
);
assert_eq!(
sound_effect_result["properties"]["durationSeconds"]["type"],
"number"
);
assert_eq!(
sound_effect_result["properties"]["durationSeconds"]["exclusiveMinimum"],
json!(0)
);
assert_eq!(
sound_effect_result["properties"]["durationSeconds"]["maximum"],
json!(600)
);
assert_eq!(sound_effect_result["properties"]["loop"]["type"], "boolean");
for stable_reference in ["taskId", "objectKey", "assetObjectId", "resource", "asset"] {
assert!(
sound_effect_result["properties"]
.get(stable_reference)
.is_some(),
"SFX compact result must expose {stable_reference} when it is present"
);
}
for redacted_field in ["prompt", "actualPrompt", "model", "provider"] {
assert!(
sound_effect_result["properties"]
.get(redacted_field)
.is_none(),
"SFX compact schema must not declare redacted {redacted_field}"
);
}
let generic_result =
&parsed["components"]["schemas"]["ExternalEditorGenerationGenericCompactResult"];
assert_eq!(generic_result["not"]["required"], json!(["audioKind"]));
assert_eq!(
generic_result["not"]["properties"]["audioKind"]["const"],
"sound-effect"
);
assert_eq!(
generic_result["not"]["properties"]
.as_object()
.map(serde_json::Map::len),
Some(1),
"fallback 只能排除 SFX 一种 audioKind;再排除别的值会把 BGM 等合法结果挤出联合"
);
assert!(
generic_result["not"]["properties"]["audioKind"]
.get("enum")
.is_none(),
"排除条件必须是 const sound-effect,换成 enum 会连带排掉 BGM"
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/audios/background-music/generations")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/background-removals")
.is_some()
);
assert_eq!(
parsed["paths"]["/api/external/v1/editor/images/background-removals"]["post"]["operationId"],
"removeExternalEditorImageBackground"
);
assert_eq!(
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["required"],
json!(["sourceImageSrc"])
);
assert_eq!(
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["additionalProperties"],
json!(false)
);
assert!(
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["properties"]
.get("taskId")
.is_none()
);
let background_target_description = parsed["components"]["schemas"]
["EditorBackgroundRemovalRequest"]["properties"]["targetLayerId"]["description"]
.as_str()
.expect("background removal targetLayerId should document placement semantics");
let background_properties =
&parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["properties"];
assert_eq!(
background_properties["backgroundMode"]["enum"],
json!(["complex", "flat", null])
);
assert_eq!(
background_properties["backgroundMode"]["default"],
"complex"
);
assert_eq!(
background_properties["screenColor"]["pattern"],
"^(auto|#[0-9A-Fa-f]{6})$"
);
assert_eq!(
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["then"]["properties"]
["backgroundMode"]["const"],
"flat"
);
assert!(background_target_description.contains("projectId"));
assert!(background_target_description.contains("canvasCompletion"));
assert!(background_target_description.contains("assetObjectId"));
assert!(background_target_description.contains("bucket/objectKey"));
assert!(background_target_description.contains("不自动写入画布"));
let background_asset_kind = &parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]
["properties"]["assetKind"];
assert_eq!(
background_asset_kind["x-genarrative-media-family"],
"static-image"
);
let background_asset_kind_description = background_asset_kind["description"]
.as_str()
.expect("background removal assetKind should document authoritative static semantics");
assert!(background_asset_kind_description.contains("权威来源类型"));
assert!(background_asset_kind_description.contains("入队前返回 400"));
let background_source_resource_description = parsed["components"]["schemas"]
["EditorBackgroundRemovalRequest"]["properties"]["sourceResourceId"]["description"]
.as_str()
.expect("background removal sourceResourceId should document disambiguation");
assert!(background_source_resource_description.contains("消歧"));
assert!(background_source_resource_description.contains("targetLayerId"));
assert!(background_source_resource_description.contains("Worker"));
assert!(
parsed["paths"]
.get("/api/external/v1/editor/assets/library")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects/{projectId}/resources")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/assets/folders")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/assets/{assetId}")
.is_some()
);
assert!(parsed["paths"].get("/api/profile/api-keys").is_none());
assert!(
parsed["components"]["securitySchemes"]
.get("UserAccessToken")
.is_none()
);
assert_eq!(
parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"],
"bearer"
);
for schema_name in [
"EditorProjectResource",
"EditorAsset",
"EditorImageGenerationResponse",
"EditorIconSpritesheetGenerationResponse",
"EditorVideoGenerationResponse",
"EditorAudioGenerationResponse",
] {
let schema = &parsed["components"]["schemas"][schema_name];
assert!(
schema["properties"].get("provider").is_none(),
"{schema_name} 不应向普通用户公开 provider"
);
assert!(
schema["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("provider"))),
"{schema_name} 不应要求 provider"
);
}
}
}