use axum::{ Json, extract::{Extension, Path, 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, 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_client_generation_inputs, save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata, }, 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, }, }; 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"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectCreateRequest { title: Option, } #[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, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetFolderUpdateRequest { label: Option, collapsed: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetCreateRequest { folder_id: String, label: String, image_src: String, object_key: Option, asset_object_id: Option, width: u32, height: u32, source_type: String, prompt: Option, actual_prompt: Option, model: Option, provider: Option, task_id: Option, asset_kind: Option, generation_inputs: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectResourceCreateRequest { image_src: String, object_key: Option, asset_object_id: Option, width: u32, height: u32, source_type: String, prompt: Option, actual_prompt: Option, model: Option, provider: Option, task_id: Option, source_resource_id: Option, asset_kind: Option, generation_inputs: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorAssetUpdateRequest { label: Option, folder_id: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectResponse { project: EditorProjectPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectRecentResponse { project: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEditorProjectListResponse { projects: Vec, } #[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, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, 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, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, 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)? .into_iter() .map(editor_project_payload_from_record) .collect(); Ok(json_success_body( Some(&request_context), ExternalEditorProjectListResponse { projects }, )) } pub async fn load_recent_external_editor_project( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, 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, Path(project_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, 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, Path(project_id): Path, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, 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, Path(project_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, 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, Path(project_id): Path, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, 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, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, 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, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, 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, Path(folder_id): Path, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, 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, Path(folder_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, 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, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; let generation_inputs_json = serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?; 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, }) .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, Path(asset_id): Path, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, 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, Path(asset_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, 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, Path(project_id): Path, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; let generation_inputs_json = serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?; 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(), }) .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, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result { 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, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, Json(payload): Json, ) -> Result { 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, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result { 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, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, Json(payload): Json, ) -> Result { 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, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result< Json, JsonRejection, >, ) -> Result { 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, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result { 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, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result< Json, JsonRejection, >, ) -> Result { 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, Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, payload: Result< Json, JsonRejection, >, ) -> Result { 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, Path(operation_id): Path, Extension(request_context): Extension, Extension(principal): Extension, ) -> Result, 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::(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( request_context: &RequestContext, payload: Result, JsonRejection>, ) -> Result, 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, ) -> 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, } } 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 { 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( generation_inputs: Option, ) -> Result, AppError> { serialize_editor_asset_metadata(sanitize_editor_client_generation_inputs(generation_inputs)) } #[cfg(test)] mod tests { use super::*; 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_canvas_save_request_requires_expected_revision() { let missing_revision = serde_json::from_value::(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::(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_cannot_forge_internal_audit_fields() { let serialized = serialize_external_editor_generation_inputs(Some(json!({ "fields": [{"label": "角色设定", "value": "骑士"}], "screenColorHex": "#00FF00", "mattingProvider": "forged-provider", "mattingModel": "forged-model", "characterAnimation": {"durationSeconds": 4} }))) .expect("外部编辑器生成输入应可序列化") .expect("非空生成输入应保留"); let parsed: Value = serde_json::from_str(&serialized).expect("生成输入应为合法 JSON"); assert_eq!(parsed["fields"][0]["value"], "骑士"); assert_eq!(parsed["characterAnimation"]["durationSeconds"], 4); assert!(parsed.get("screenColorHex").is_none()); assert!(parsed.get("mattingProvider").is_none()); assert!(parsed.get("mattingModel").is_none()); } #[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 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"); 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() ); 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() ); 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" ); } } }