Files
Genarrative/server-rs/crates/api-server/src/external_editor_api.rs
T
kdletters 1f904d28e9
Project CI / Frontend tests (push) Successful in 4m2s
Project CI / Repository checks (push) Successful in 2m16s
Project CI / Backend tests (push) Successful in 10m14s
Project CI / Native shell tests (push) Failing after 19m54s
AGC 客户端 MCP 能力暴露 (#274)
## 目标
保留现有客户端对话与 Codex app-server 链路,把客户端自身受控业务能力通过 MCP 暴露给 Codex。

## 范围
- 客户端会话、项目文件、资源、画布、生成、预览等稳定能力
- 审核 Skill 的索引与按需指导资源
- 复用现有账号、项目路径、权限、计费、幂等、锁和恢复边界

## 明确不做
- 不替换客户端对话入口或 Codex app-server
- 不让客户端替 Codex 判断高层意图、完成状态或规划
- 不暴露任意 Tauri command、shell、凭据、内部 URL、数据库和管理能力

当前 PR 先建立独立分支与审查边界,后续提交实现与定向验证。

Reviewed-on: #274
2026-09-08 22:01:29 +08:00

2914 lines
112 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,
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,
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_undocumented_fields_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})),
] {
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)
);
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 联合不能带 discriminatorBGM 回 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");
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"
);
}
}
}