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