use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, http::{StatusCode, header::CONTENT_TYPE}, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, }; use crate::{ api_response::json_success_body, character_animation_assets::{ generate_editor_character_animation_for_owner, generate_editor_video_for_owner, }, 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, edit_editor_image_for_owner, 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, extract_editor_ui_design_assets_for_owner, generate_editor_icon_spritesheet_for_owner, generate_editor_image_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, http_error::AppError, request_context::RequestContext, state::AppState, vector_engine_audio_generation::{ generate_editor_background_music_for_owner, generate_editor_sound_effect_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"); #[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, payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; generate_editor_image_for_owner( &state, &request_context, editor_generation_caller(&principal, payload.project_id.clone()), payload, ) .await } pub async fn edit_external_editor_image( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; edit_editor_image_for_owner( &state, &request_context, editor_generation_caller(&principal, payload.project_id.clone()), payload, ) .await } pub async fn generate_external_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; generate_editor_icon_spritesheet_for_owner( &state, &request_context, editor_generation_caller(&principal, payload.project_id.clone()), payload, ) .await } pub async fn extract_external_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; extract_editor_ui_design_assets_for_owner( &state, &request_context, editor_generation_caller(&principal, payload.project_id.clone()), payload, ) .await } pub async fn generate_external_editor_character_animation( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, payload: Result< Json, JsonRejection, >, ) -> Result, Response> { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; generate_editor_character_animation_for_owner( state, request_context, principal.owner_user_id().to_string(), payload, None, ) .await } pub async fn generate_external_editor_video( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; generate_editor_video_for_owner( state, request_context, principal.owner_user_id().to_string(), payload, ) .await } pub async fn generate_external_editor_sound_effect( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, payload: Result< Json, JsonRejection, >, ) -> Result, Response> { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; generate_editor_sound_effect_for_owner( state, request_context, principal.owner_user_id().to_string(), payload, ) .await } pub async fn generate_external_editor_background_music( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, payload: Result< Json, JsonRejection, >, ) -> Result, Response> { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; generate_editor_background_music_for_owner( state, request_context, principal.owner_user_id().to_string(), payload, ) .await } 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::*; #[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 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() ); 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" ); } } }