Files
Genarrative/server-rs/crates/api-server/src/external_editor_api.rs
T
kdletters e099a519ba 修复外部 MCP 项目选择与参数提示
项目列表新增紧凑摘要和最新封面稳定引用
MCP 固定摘要视图并前置校验必填请求体
同步 External v1 OpenAPI、Skill 与项目记忆
补充十九项目超限和四个 415 工具回归测试
2026-08-07 19:21:34 +08:00

2036 lines
76 KiB
Rust

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::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::{
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, EditorCanvasViewportPayload,
EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, 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_project_payload_from_record,
editor_project_resource_payload_from_record,
enqueue_editor_icon_spritesheet_generation_for_owner, enqueue_editor_image_edit_for_owner,
enqueue_editor_image_generation_for_owner,
enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error,
normalize_editor_persisted_media_src, normalize_optional_string,
parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_generation_inputs,
},
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")]
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>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorProjectCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let project = state
.spacetime_client()
.create_editor_project(EditorProjectCreateRecordInput {
project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX),
owner_user_id: principal.owner_user_id().to_string(),
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>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorAssetFolderCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
let folder = state
.spacetime_client()
.create_editor_asset_folder(EditorAssetFolderCreateRecordInput {
folder_id: build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX),
owner_user_id: principal.owner_user_id().to_string(),
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>,
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 resource = state
.spacetime_client()
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
project_id,
owner_user_id: principal.owner_user_id().to_string(),
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)?;
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,
Json(payload): Json<EditorImageEditRequest>,
) -> 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_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 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))
}
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(payload) = parse_external_generation_json_payload(&request_context, payload)?;
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 spacetime_client::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
};
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(),
}
}
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);
}
}
#[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)
);
}
#[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()
);
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/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 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!("priceMudPoints")))
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
.get("priceMudPoints")
.is_none()
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
.get("targetLayerId")
.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_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_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()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/audios/background-music/generations")
.is_some()
);
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"
);
}
}
}