e1d031b86f
新增托管式Streamable HTTP MCP端点并复用外部API Key鉴权 统一图片视频音频等生成请求为幂等异步提交和状态查询 提供可发现的使用说明、OpenAPI资源及完整Skill下载包 同步更新OpenAPI契约并规定接口变更必须连带维护 适配AI游戏创作Shell的异步提交轮询与安全重试 补齐MCP方法映射、异步Worker和客户端回归测试
1466 lines
54 KiB
Rust
1466 lines
54 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Extension, Path, State, rejection::JsonRejection},
|
|
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use serde::de::DeserializeOwned;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use shared_contracts::external_generation::{
|
|
ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse,
|
|
ExternalGenerationJobStatus,
|
|
};
|
|
use shared_kernel::build_prefixed_uuid_id;
|
|
use spacetime_client::{
|
|
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
|
|
EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput,
|
|
EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
|
|
EditorProjectGetRecordInput, EditorProjectRenameRecordInput,
|
|
EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput,
|
|
ExternalGenerationJobRecord, SpacetimeClientError,
|
|
};
|
|
|
|
use crate::{
|
|
api_response::json_success_body,
|
|
character_animation_assets::{
|
|
enqueue_editor_character_animation_for_owner, enqueue_editor_video_generation_for_owner,
|
|
},
|
|
editor_generation_queue::editor_generation_queue_state,
|
|
editor_project::{
|
|
EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE,
|
|
EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload,
|
|
EditorAssetLibraryPayload, EditorAssetPayload, EditorCanvasViewportPayload,
|
|
EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest,
|
|
EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload,
|
|
EditorUiDesignAssetExtractionRequest, current_utc_micros,
|
|
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record,
|
|
editor_asset_payload_from_record, editor_project_payload_from_record,
|
|
editor_project_resource_payload_from_record,
|
|
enqueue_editor_icon_spritesheet_generation_for_owner, enqueue_editor_image_edit_for_owner,
|
|
enqueue_editor_image_generation_for_owner,
|
|
enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error,
|
|
normalize_editor_persisted_media_src, normalize_optional_string,
|
|
parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs,
|
|
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
|
|
},
|
|
external_api_auth::ExternalApiPrincipal,
|
|
external_generation::map_external_generation_job_status_detail,
|
|
http_error::AppError,
|
|
request_context::RequestContext,
|
|
state::AppState,
|
|
vector_engine_audio_generation::{
|
|
enqueue_editor_background_music_generation_for_owner,
|
|
enqueue_editor_sound_effect_generation_for_owner,
|
|
},
|
|
};
|
|
|
|
const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api";
|
|
const SCOPE_EDITOR_PROJECT: &str = "editor:project";
|
|
const SCOPE_EDITOR_CANVAS: &str = "editor:canvas";
|
|
const SCOPE_EDITOR_IMAGE_GENERATE: &str = "editor:image-generate";
|
|
const SCOPE_EDITOR_ASSET: &str = "editor:asset";
|
|
const OPENAPI_JSON: &str =
|
|
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
|
|
const EXTERNAL_GENERATION_POLL_AFTER_MS: u64 = 1_500;
|
|
const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key";
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalEditorProjectCreateRequest {
|
|
title: Option<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>,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[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, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalEditorProjectDeleteResponse {
|
|
deleted_project_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalEditorAssetLibraryResponse {
|
|
library: EditorAssetLibraryPayload,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalEditorAssetFolderResponse {
|
|
folder: EditorAssetFolderPayload,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalEditorAssetFolderDeleteResponse {
|
|
library: EditorAssetLibraryPayload,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalEditorAssetResponse {
|
|
asset: EditorAssetPayload,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ExternalEditorProjectResourceResponse {
|
|
resource: EditorProjectResourcePayload,
|
|
}
|
|
|
|
pub async fn openapi_json() -> Response {
|
|
(
|
|
[(CONTENT_TYPE, "application/json; charset=utf-8")],
|
|
OPENAPI_JSON,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
pub async fn create_external_editor_project(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
Json(payload): Json<ExternalEditorProjectCreateRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
|
|
let project = state
|
|
.spacetime_client()
|
|
.create_editor_project(EditorProjectCreateRecordInput {
|
|
project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX),
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
title: normalize_project_title(payload.title),
|
|
now_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorProjectResponse {
|
|
project: editor_project_payload_from_record(project),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn list_external_editor_projects(
|
|
State(state): State<AppState>,
|
|
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)?
|
|
.into_iter()
|
|
.map(editor_project_payload_from_record)
|
|
.collect();
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorProjectListResponse { projects },
|
|
))
|
|
}
|
|
|
|
pub async fn load_recent_external_editor_project(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
|
|
let project = state
|
|
.spacetime_client()
|
|
.get_recent_editor_project(principal.owner_user_id().to_string())
|
|
.await
|
|
.map_err(map_editor_project_error)?
|
|
.map(editor_project_payload_from_record);
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorProjectRecentResponse { project },
|
|
))
|
|
}
|
|
|
|
pub async fn get_external_editor_project(
|
|
State(state): State<AppState>,
|
|
Path(project_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
|
|
let project = state
|
|
.spacetime_client()
|
|
.get_editor_project(EditorProjectGetRecordInput {
|
|
project_id,
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorProjectResponse {
|
|
project: editor_project_payload_from_record(project),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn rename_external_editor_project(
|
|
State(state): State<AppState>,
|
|
Path(project_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
Json(payload): Json<ExternalEditorProjectRenameRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
|
|
let project = state
|
|
.spacetime_client()
|
|
.rename_editor_project(EditorProjectRenameRecordInput {
|
|
project_id,
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
title: payload.title,
|
|
updated_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorProjectResponse {
|
|
project: editor_project_payload_from_record(project),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn delete_external_editor_project(
|
|
State(state): State<AppState>,
|
|
Path(project_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
|
|
let deleted_project_id = state
|
|
.spacetime_client()
|
|
.delete_editor_project(EditorProjectDeleteRecordInput {
|
|
project_id,
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorProjectDeleteResponse { deleted_project_id },
|
|
))
|
|
}
|
|
|
|
pub async fn save_external_editor_canvas(
|
|
State(state): State<AppState>,
|
|
Path(project_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
Json(payload): Json<ExternalEditorCanvasSaveRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_CANVAS)?;
|
|
let project = save_editor_project_layout_with_revision_and_get(
|
|
&state,
|
|
project_id.as_str(),
|
|
principal.owner_user_id(),
|
|
payload.viewport.into_record(),
|
|
payload.layers,
|
|
payload.expected_revision,
|
|
)
|
|
.await?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorProjectResponse {
|
|
project: editor_project_payload_from_record(project),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_external_editor_asset_library(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
|
|
let library = state
|
|
.spacetime_client()
|
|
.get_editor_asset_library(principal.owner_user_id().to_string(), current_utc_micros())
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorAssetLibraryResponse {
|
|
library: editor_asset_library_payload_from_record(library),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn create_external_editor_asset_folder(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
Json(payload): Json<ExternalEditorAssetFolderCreateRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
|
|
let folder = state
|
|
.spacetime_client()
|
|
.create_editor_asset_folder(EditorAssetFolderCreateRecordInput {
|
|
folder_id: build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX),
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
label: payload.label,
|
|
sort_order: payload.sort_order.unwrap_or(100),
|
|
now_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorAssetFolderResponse {
|
|
folder: editor_asset_folder_payload_from_record(folder),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn update_external_editor_asset_folder(
|
|
State(state): State<AppState>,
|
|
Path(folder_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
Json(payload): Json<ExternalEditorAssetFolderUpdateRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
|
|
let folder = state
|
|
.spacetime_client()
|
|
.update_editor_asset_folder(EditorAssetFolderUpdateRecordInput {
|
|
folder_id,
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
label: normalize_optional_string(payload.label),
|
|
collapsed: payload.collapsed,
|
|
updated_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorAssetFolderResponse {
|
|
folder: editor_asset_folder_payload_from_record(folder),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn delete_external_editor_asset_folder(
|
|
State(state): State<AppState>,
|
|
Path(folder_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
|
|
let library = state
|
|
.spacetime_client()
|
|
.delete_editor_asset_folder(EditorAssetFolderDeleteRecordInput {
|
|
folder_id,
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
updated_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorAssetFolderDeleteResponse {
|
|
library: editor_asset_library_payload_from_record(library),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn create_external_editor_asset(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
Json(payload): Json<ExternalEditorAssetCreateRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
|
|
let generation_inputs_json =
|
|
serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?;
|
|
let object_key = normalize_optional_string(payload.object_key);
|
|
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
|
|
let asset = state
|
|
.spacetime_client()
|
|
.create_editor_asset(EditorAssetCreateRecordInput {
|
|
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
folder_id: payload.folder_id,
|
|
label: payload.label,
|
|
asset_object_id: normalize_optional_string(payload.asset_object_id),
|
|
image_src,
|
|
object_key,
|
|
width: payload.width,
|
|
height: payload.height,
|
|
source_type: payload.source_type,
|
|
prompt: normalize_optional_string(payload.prompt),
|
|
actual_prompt: normalize_optional_string(payload.actual_prompt),
|
|
model: normalize_optional_string(payload.model),
|
|
provider: normalize_optional_string(payload.provider),
|
|
task_id: normalize_optional_string(payload.task_id),
|
|
asset_kind: normalize_optional_string(payload.asset_kind),
|
|
generation_inputs_json,
|
|
source_resource_id: None,
|
|
generation_cost_mud_points: 0,
|
|
now_micros: current_utc_micros(),
|
|
thumbnail_src: None,
|
|
group_task_id: None,
|
|
group_task_expected_asset_count: None,
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorAssetResponse {
|
|
asset: editor_asset_payload_from_record(asset),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn update_external_editor_asset(
|
|
State(state): State<AppState>,
|
|
Path(asset_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
Json(payload): Json<ExternalEditorAssetUpdateRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
|
|
let asset = state
|
|
.spacetime_client()
|
|
.update_editor_asset(EditorAssetUpdateRecordInput {
|
|
asset_id,
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
label: normalize_optional_string(payload.label),
|
|
folder_id: normalize_optional_string(payload.folder_id),
|
|
updated_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorAssetResponse {
|
|
asset: editor_asset_payload_from_record(asset),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn delete_external_editor_asset(
|
|
State(state): State<AppState>,
|
|
Path(asset_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
|
|
let asset = state
|
|
.spacetime_client()
|
|
.delete_editor_asset(EditorAssetDeleteRecordInput {
|
|
asset_id,
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorAssetResponse {
|
|
asset: editor_asset_payload_from_record(asset),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn create_external_editor_project_resource(
|
|
State(state): State<AppState>,
|
|
Path(project_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
Json(payload): Json<ExternalEditorProjectResourceCreateRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
|
|
let generation_inputs_json =
|
|
serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?;
|
|
let object_key = normalize_optional_string(payload.object_key);
|
|
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
|
|
let resource = state
|
|
.spacetime_client()
|
|
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
|
|
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
|
|
project_id,
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
asset_object_id: normalize_optional_string(payload.asset_object_id),
|
|
image_src,
|
|
object_key,
|
|
width: payload.width,
|
|
height: payload.height,
|
|
source_type: payload.source_type,
|
|
prompt: normalize_optional_string(payload.prompt),
|
|
actual_prompt: normalize_optional_string(payload.actual_prompt),
|
|
model: normalize_optional_string(payload.model),
|
|
provider: normalize_optional_string(payload.provider),
|
|
task_id: normalize_optional_string(payload.task_id),
|
|
source_resource_id: normalize_optional_string(payload.source_resource_id),
|
|
asset_kind: normalize_optional_string(payload.asset_kind),
|
|
generation_inputs_json,
|
|
updated_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(map_editor_project_error)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorProjectResourceResponse {
|
|
resource: editor_project_resource_payload_from_record(resource),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn generate_external_editor_image(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
headers: HeaderMap,
|
|
payload: Result<Json<EditorImageGenerationRequest>, JsonRejection>,
|
|
) -> Result<Response, AppError> {
|
|
let Json(payload) = parse_editor_generation_json_payload(payload)?;
|
|
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
|
let idempotency_key = require_idempotency_key(&headers)?;
|
|
let project_id = payload.project_id.clone();
|
|
let job = enqueue_editor_image_generation_for_owner(
|
|
&state,
|
|
&request_context,
|
|
&editor_generation_caller(&principal, project_id),
|
|
payload,
|
|
Some(idempotency_key),
|
|
)
|
|
.await?;
|
|
Ok(external_generation_accepted_response(&request_context, job))
|
|
}
|
|
|
|
pub async fn edit_external_editor_image(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
headers: HeaderMap,
|
|
Json(payload): Json<EditorImageEditRequest>,
|
|
) -> Result<Response, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
|
let idempotency_key = require_idempotency_key(&headers)?;
|
|
let project_id = payload.project_id.clone();
|
|
let job = enqueue_editor_image_edit_for_owner(
|
|
&state,
|
|
&request_context,
|
|
&editor_generation_caller(&principal, project_id),
|
|
payload,
|
|
Some(idempotency_key),
|
|
)
|
|
.await?;
|
|
Ok(external_generation_accepted_response(&request_context, job))
|
|
}
|
|
|
|
pub async fn generate_external_editor_icon_spritesheet(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
headers: HeaderMap,
|
|
payload: Result<Json<EditorIconSpritesheetGenerationRequest>, JsonRejection>,
|
|
) -> Result<Response, AppError> {
|
|
let Json(payload) = parse_editor_generation_json_payload(payload)?;
|
|
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
|
let idempotency_key = require_idempotency_key(&headers)?;
|
|
let project_id = payload.project_id.clone();
|
|
let job = enqueue_editor_icon_spritesheet_generation_for_owner(
|
|
&state,
|
|
&request_context,
|
|
&editor_generation_caller(&principal, project_id),
|
|
payload,
|
|
Some(idempotency_key),
|
|
)
|
|
.await?;
|
|
Ok(external_generation_accepted_response(&request_context, job))
|
|
}
|
|
|
|
pub async fn extract_external_editor_ui_design_assets(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
headers: HeaderMap,
|
|
Json(payload): Json<EditorUiDesignAssetExtractionRequest>,
|
|
) -> Result<Response, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
|
let idempotency_key = require_idempotency_key(&headers)?;
|
|
let project_id = payload.project_id.clone();
|
|
let job = enqueue_editor_ui_design_asset_extraction_for_owner(
|
|
&state,
|
|
&request_context,
|
|
&editor_generation_caller(&principal, project_id),
|
|
payload,
|
|
Some(idempotency_key),
|
|
)
|
|
.await?;
|
|
Ok(external_generation_accepted_response(&request_context, job))
|
|
}
|
|
|
|
pub async fn generate_external_editor_character_animation(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
headers: HeaderMap,
|
|
payload: Result<
|
|
Json<shared_contracts::assets::EditorCharacterAnimationGenerateRequest>,
|
|
JsonRejection,
|
|
>,
|
|
) -> Result<Response, Response> {
|
|
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
|
let idempotency_key = require_idempotency_key(&headers)
|
|
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
|
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
|
|
let job = enqueue_editor_character_animation_for_owner(
|
|
&state,
|
|
&request_context,
|
|
principal.owner_user_id(),
|
|
payload,
|
|
Some(idempotency_key),
|
|
)
|
|
.await?;
|
|
Ok(external_generation_accepted_response(&request_context, job))
|
|
}
|
|
|
|
pub async fn generate_external_editor_video(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
headers: HeaderMap,
|
|
payload: Result<Json<shared_contracts::assets::EditorVideoGenerateRequest>, JsonRejection>,
|
|
) -> Result<Response, Response> {
|
|
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
|
let idempotency_key = require_idempotency_key(&headers)
|
|
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
|
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
|
|
let job = enqueue_editor_video_generation_for_owner(
|
|
&state,
|
|
&request_context,
|
|
principal.owner_user_id(),
|
|
payload,
|
|
Some(idempotency_key),
|
|
)
|
|
.await?;
|
|
Ok(external_generation_accepted_response(&request_context, job))
|
|
}
|
|
|
|
pub async fn generate_external_editor_sound_effect(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
headers: HeaderMap,
|
|
payload: Result<
|
|
Json<shared_contracts::assets::EditorSoundEffectGenerateRequest>,
|
|
JsonRejection,
|
|
>,
|
|
) -> Result<Response, Response> {
|
|
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
|
let idempotency_key = require_idempotency_key(&headers)
|
|
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
|
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
|
|
let job = enqueue_editor_sound_effect_generation_for_owner(
|
|
&state,
|
|
&request_context,
|
|
principal.owner_user_id(),
|
|
payload,
|
|
Some(idempotency_key),
|
|
)
|
|
.await?;
|
|
Ok(external_generation_accepted_response(&request_context, job))
|
|
}
|
|
|
|
pub async fn generate_external_editor_background_music(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
headers: HeaderMap,
|
|
payload: Result<
|
|
Json<shared_contracts::assets::EditorBackgroundMusicGenerateRequest>,
|
|
JsonRejection,
|
|
>,
|
|
) -> Result<Response, Response> {
|
|
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
|
let idempotency_key = require_idempotency_key(&headers)
|
|
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
|
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
|
|
let job = enqueue_editor_background_music_generation_for_owner(
|
|
&state,
|
|
&request_context,
|
|
principal.owner_user_id(),
|
|
payload,
|
|
Some(idempotency_key),
|
|
)
|
|
.await?;
|
|
Ok(external_generation_accepted_response(&request_context, job))
|
|
}
|
|
|
|
pub async fn get_external_editor_generation_job(
|
|
State(state): State<AppState>,
|
|
Path(operation_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(principal): Extension<ExternalApiPrincipal>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
|
let input = ExternalGenerationJobGetRecordInput {
|
|
job_id: operation_id,
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
};
|
|
let summary = state
|
|
.spacetime_client()
|
|
.get_external_generation_job_summary(input.clone())
|
|
.await
|
|
.map_err(map_external_generation_lookup_error)?;
|
|
let detail = map_external_generation_job_status_detail(summary.clone());
|
|
let result = if detail.status.status == ExternalGenerationJobStatus::Completed {
|
|
let artifacts = state
|
|
.spacetime_client()
|
|
.get_external_generation_job_generated_artifacts(input)
|
|
.await
|
|
.map_err(map_external_generation_lookup_error)?;
|
|
let payload = artifacts
|
|
.result_payload_json
|
|
.as_deref()
|
|
.and_then(|payload| serde_json::from_str::<Value>(payload).ok())
|
|
.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": EXTERNAL_EDITOR_PROVIDER,
|
|
"message": "生成任务已完成,但结果暂时不可读取。",
|
|
}))
|
|
})?;
|
|
Some(payload.get("result").cloned().ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": EXTERNAL_EDITOR_PROVIDER,
|
|
"message": "生成任务已完成,但稳定结果引用缺失。",
|
|
}))
|
|
})?)
|
|
} else {
|
|
None
|
|
};
|
|
let poll_after_ms = matches!(
|
|
detail.status.status,
|
|
ExternalGenerationJobStatus::Queued | ExternalGenerationJobStatus::Running
|
|
)
|
|
.then_some(EXTERNAL_GENERATION_POLL_AFTER_MS);
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
ExternalEditorGenerationJobResponse {
|
|
operation_id: detail.status.operation_id,
|
|
kind: summary.job_kind,
|
|
status: detail.status.status,
|
|
phase_label: detail.status.phase_label,
|
|
phase_detail: detail.status.phase_detail,
|
|
progress: detail.status.progress,
|
|
error: detail.status.error,
|
|
warning: detail.warning,
|
|
result,
|
|
poll_after_ms,
|
|
updated_at_micros: detail.status.updated_at_micros,
|
|
},
|
|
))
|
|
}
|
|
|
|
fn external_generation_accepted_response(
|
|
request_context: &RequestContext,
|
|
job: ExternalGenerationJobRecord,
|
|
) -> Response {
|
|
let kind = job.job_kind.clone();
|
|
let status = editor_generation_queue_state(job);
|
|
let status_url = format!("/api/external/v1/generations/{}", status.operation_id);
|
|
let mut response = (
|
|
StatusCode::ACCEPTED,
|
|
json_success_body(
|
|
Some(request_context),
|
|
ExternalEditorGenerationSubmissionResponse {
|
|
operation_id: status.operation_id,
|
|
kind,
|
|
status: status.status,
|
|
status_url: status_url.clone(),
|
|
poll_after_ms: EXTERNAL_GENERATION_POLL_AFTER_MS,
|
|
updated_at_micros: status.updated_at_micros,
|
|
},
|
|
),
|
|
)
|
|
.into_response();
|
|
if let Ok(location) = HeaderValue::from_str(&status_url) {
|
|
response.headers_mut().insert("location", location);
|
|
}
|
|
response
|
|
.headers_mut()
|
|
.insert("retry-after", HeaderValue::from_static("2"));
|
|
response
|
|
}
|
|
|
|
fn require_idempotency_key(headers: &HeaderMap) -> Result<&str, AppError> {
|
|
let value = headers
|
|
.get(IDEMPOTENCY_KEY_HEADER)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": EXTERNAL_EDITOR_PROVIDER,
|
|
"message": "生成请求必须携带 Idempotency-Key 请求头。",
|
|
}))
|
|
})?;
|
|
if value.len() > 128 || !value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) {
|
|
return Err(
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": EXTERNAL_EDITOR_PROVIDER,
|
|
"message": "Idempotency-Key 必须是 1-128 个可打印 ASCII 字符,且不能包含空格。",
|
|
})),
|
|
);
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn parse_external_generation_json_payload<T: DeserializeOwned>(
|
|
request_context: &RequestContext,
|
|
payload: Result<Json<T>, JsonRejection>,
|
|
) -> Result<Json<T>, Response> {
|
|
payload.map_err(|error| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_details(json!({
|
|
"provider": EXTERNAL_EDITOR_PROVIDER,
|
|
"message": error.body_text(),
|
|
}))
|
|
.into_response_with_context(Some(request_context))
|
|
})
|
|
}
|
|
|
|
fn map_external_generation_lookup_error(error: SpacetimeClientError) -> AppError {
|
|
if error.to_string().contains("不存在") {
|
|
AppError::from_status(StatusCode::NOT_FOUND)
|
|
} else {
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": EXTERNAL_EDITOR_PROVIDER,
|
|
"message": "生成任务状态暂时不可用。",
|
|
}))
|
|
}
|
|
}
|
|
|
|
fn editor_generation_caller(
|
|
principal: &ExternalApiPrincipal,
|
|
project_id: Option<String>,
|
|
) -> EditorGenerationCaller {
|
|
EditorGenerationCaller {
|
|
owner_user_id: principal.owner_user_id().to_string(),
|
|
audit_subject_user_id: Some(principal.owner_user_id().to_string()),
|
|
audit_project_id: normalize_optional_string(project_id),
|
|
phase_reporter: None,
|
|
}
|
|
}
|
|
|
|
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(
|
|
generation_inputs: Option<Value>,
|
|
) -> Result<Option<String>, AppError> {
|
|
serialize_editor_asset_metadata(sanitize_editor_client_generation_inputs(generation_inputs))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn external_generation_job_fixture(status: &str) -> ExternalGenerationJobRecord {
|
|
ExternalGenerationJobRecord {
|
|
job_id: "task-external-test".to_string(),
|
|
dedupe_key: "external-api-generation:editor_image_generation:fingerprint".to_string(),
|
|
job_kind: "editor_image_generation".to_string(),
|
|
owner_user_id: "user-1".to_string(),
|
|
source_module: "editor-canvas".to_string(),
|
|
source_entity_id: "project-1".to_string(),
|
|
request_label: "图片画布生成图片".to_string(),
|
|
request_payload_json: "{}".to_string(),
|
|
status: status.to_string(),
|
|
attempt: 0,
|
|
max_attempts: 1,
|
|
last_error_message: None,
|
|
worker_id: None,
|
|
lease_expires_at: None,
|
|
available_at: "2026-07-31T00:00:00Z".to_string(),
|
|
result_payload_json: None,
|
|
created_at: "2026-07-31T00:00:00Z".to_string(),
|
|
started_at: None,
|
|
completed_at: None,
|
|
updated_at: "2026-07-31T00:00:00Z".to_string(),
|
|
updated_at_micros: 1_785_456_000_000_000,
|
|
lease_token: None,
|
|
price_mud_points: 2,
|
|
refund_ledger_id: None,
|
|
notification_acknowledged_at: None,
|
|
notification_acknowledged_at_micros: None,
|
|
phase: None,
|
|
}
|
|
}
|
|
|
|
fn request_context(wants_envelope: bool) -> RequestContext {
|
|
RequestContext::new(
|
|
"req-external-generation-test".to_string(),
|
|
"POST /api/external/v1/editor/images/generations".to_string(),
|
|
std::time::Duration::ZERO,
|
|
wants_envelope,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn external_editor_canvas_save_request_requires_expected_revision() {
|
|
let missing_revision = serde_json::from_value::<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_cannot_forge_internal_audit_fields() {
|
|
let serialized = serialize_external_editor_generation_inputs(Some(json!({
|
|
"fields": [{"label": "角色设定", "value": "骑士"}],
|
|
"screenColorHex": "#00FF00",
|
|
"mattingProvider": "forged-provider",
|
|
"mattingModel": "forged-model",
|
|
"characterAnimation": {"durationSeconds": 4}
|
|
})))
|
|
.expect("外部编辑器生成输入应可序列化")
|
|
.expect("非空生成输入应保留");
|
|
let parsed: Value = serde_json::from_str(&serialized).expect("生成输入应为合法 JSON");
|
|
|
|
assert_eq!(parsed["fields"][0]["value"], "骑士");
|
|
assert_eq!(parsed["characterAnimation"]["durationSeconds"], 4);
|
|
assert!(parsed.get("screenColorHex").is_none());
|
|
assert!(parsed.get("mattingProvider").is_none());
|
|
assert!(parsed.get("mattingModel").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn external_generation_requires_bounded_printable_idempotency_key() {
|
|
let missing = HeaderMap::new();
|
|
let error = require_idempotency_key(&missing).expect_err("外部生成必须显式提供幂等键");
|
|
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
|
assert!(error.body_text().contains("Idempotency-Key"));
|
|
|
|
let mut whitespace = HeaderMap::new();
|
|
whitespace.insert(IDEMPOTENCY_KEY_HEADER, HeaderValue::from_static(" "));
|
|
assert!(require_idempotency_key(&whitespace).is_err());
|
|
|
|
let mut valid = HeaderMap::new();
|
|
let longest_valid = "x".repeat(128);
|
|
valid.insert(
|
|
IDEMPOTENCY_KEY_HEADER,
|
|
HeaderValue::from_str(&longest_valid).expect("128 字节可打印 ASCII 应是合法 header"),
|
|
);
|
|
assert_eq!(
|
|
require_idempotency_key(&valid).expect("边界长度幂等键应通过"),
|
|
longest_valid
|
|
);
|
|
|
|
for invalid in [
|
|
"x".repeat(129),
|
|
"contains space".to_string(),
|
|
"中文".to_string(),
|
|
] {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
IDEMPOTENCY_KEY_HEADER,
|
|
HeaderValue::from_str(&invalid).expect("测试值应可构造为 HTTP header"),
|
|
);
|
|
let error = require_idempotency_key(&headers)
|
|
.expect_err("超长、含空格或非 ASCII 的幂等键必须拒绝");
|
|
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_generation_submission_is_accepted_with_poll_contract() {
|
|
let response = external_generation_accepted_response(
|
|
&request_context(false),
|
|
external_generation_job_fixture("pending"),
|
|
);
|
|
|
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("location")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("/api/external/v1/generations/task-external-test")
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get("retry-after")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("2")
|
|
);
|
|
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
|
|
.await
|
|
.expect("submission body 应可读取");
|
|
let payload: Value = serde_json::from_slice(&body).expect("submission body 应为 JSON");
|
|
|
|
assert_eq!(payload["operationId"], json!("task-external-test"));
|
|
assert_eq!(payload["kind"], json!("editor_image_generation"));
|
|
assert_eq!(payload["status"], json!("queued"));
|
|
assert_eq!(
|
|
payload["statusUrl"],
|
|
json!("/api/external/v1/generations/task-external-test")
|
|
);
|
|
assert_eq!(
|
|
payload["pollAfterMs"],
|
|
json!(EXTERNAL_GENERATION_POLL_AFTER_MS)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn idempotent_completed_submission_reports_completed_in_envelope() {
|
|
let response = external_generation_accepted_response(
|
|
&request_context(true),
|
|
external_generation_job_fixture("completed"),
|
|
);
|
|
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
|
|
.await
|
|
.expect("submission envelope 应可读取");
|
|
let payload: Value = serde_json::from_slice(&body).expect("submission envelope 应为 JSON");
|
|
|
|
assert_eq!(payload["ok"], json!(true));
|
|
assert_eq!(payload["data"]["operationId"], json!("task-external-test"));
|
|
assert_eq!(payload["data"]["status"], json!("completed"));
|
|
assert_eq!(
|
|
payload["data"]["pollAfterMs"],
|
|
json!(EXTERNAL_GENERATION_POLL_AFTER_MS)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn generation_lookup_hides_cross_owner_jobs_as_not_found() {
|
|
let not_found = map_external_generation_lookup_error(SpacetimeClientError::Procedure(
|
|
"external_generation_job 不存在".to_string(),
|
|
));
|
|
assert_eq!(not_found.status_code(), StatusCode::NOT_FOUND);
|
|
|
|
let unavailable =
|
|
map_external_generation_lookup_error(SpacetimeClientError::ConnectDropped);
|
|
assert_eq!(unavailable.status_code(), StatusCode::BAD_GATEWAY);
|
|
assert!(!unavailable.body_text().contains("ConnectDropped"));
|
|
}
|
|
|
|
#[test]
|
|
fn exported_openapi_json_contains_external_editor_routes_and_security() {
|
|
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
|
|
|
|
assert_eq!(parsed["openapi"], "3.1.0");
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/projects")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/assets/direct-upload-tickets")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/assets/objects/confirm")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/assets/read-url")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]["/api/external/v1/editor/projects"]
|
|
.get("get")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]["/api/external/v1/editor/projects"]
|
|
.get("post")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/projects/recent")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]["/api/external/v1/editor/projects/{projectId}"]
|
|
.get("delete")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/projects/{projectId}/metadata")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/images/generations")
|
|
.is_some()
|
|
);
|
|
for path in [
|
|
"/api/external/v1/editor/images/generations",
|
|
"/api/external/v1/editor/images/edits",
|
|
"/api/external/v1/editor/icon-spritesheets/generations",
|
|
"/api/external/v1/editor/ui-designs/assets/extractions",
|
|
"/api/external/v1/editor/character-animations/generations",
|
|
"/api/external/v1/editor/videos/generations",
|
|
"/api/external/v1/editor/audios/sound-effects/generations",
|
|
"/api/external/v1/editor/audios/background-music/generations",
|
|
] {
|
|
let operation = &parsed["paths"][path]["post"];
|
|
assert!(operation["responses"].get("202").is_some(), "{path}");
|
|
assert!(operation["responses"].get("200").is_none(), "{path}");
|
|
assert!(
|
|
operation["parameters"]
|
|
.as_array()
|
|
.is_some_and(|parameters| {
|
|
parameters.iter().any(|parameter| {
|
|
parameter.get("$ref").and_then(Value::as_str)
|
|
== Some("#/components/parameters/IdempotencyKey")
|
|
})
|
|
})
|
|
);
|
|
}
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/generations/{operationId}")
|
|
.is_some()
|
|
);
|
|
for path in [
|
|
"/api/external/v1/agent-integration.json",
|
|
"/api/external/v1/skill/SKILL.md",
|
|
"/api/external/v1/skill.zip",
|
|
"/api/external/v1/mcp",
|
|
] {
|
|
assert!(parsed["paths"].get(path).is_some(), "{path}");
|
|
}
|
|
assert!(
|
|
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["required"]
|
|
.as_array()
|
|
.is_some_and(|required| !required.contains(&json!("priceMudPoints")))
|
|
);
|
|
assert!(
|
|
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]
|
|
.get("priceMudPoints")
|
|
.is_none()
|
|
);
|
|
let image_style_schema =
|
|
&parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["style"];
|
|
assert_eq!(image_style_schema["anyOf"][0]["type"], "string");
|
|
assert!(image_style_schema["anyOf"][0].get("enum").is_none());
|
|
assert_eq!(image_style_schema["examples"], json!(["none", "pixelArt"]));
|
|
assert!(
|
|
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"]
|
|
.get("default")
|
|
.is_none()
|
|
);
|
|
assert_eq!(
|
|
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
|
|
"array"
|
|
);
|
|
assert_eq!(
|
|
parsed["components"]["schemas"]["EditorCanvas"]["properties"]["layers"]["type"],
|
|
"array"
|
|
);
|
|
assert!(
|
|
parsed["components"]["schemas"]["ExternalEditorCanvasSaveRequest"]["required"]
|
|
.as_array()
|
|
.is_some_and(|required| required.contains(&json!("expectedRevision")))
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/images/edits")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["components"]["schemas"]["EditorImageEditRequest"]["required"]
|
|
.as_array()
|
|
.is_some_and(|required| !required.contains(&json!("priceMudPoints")))
|
|
);
|
|
assert!(
|
|
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
|
|
.get("priceMudPoints")
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
|
|
.get("targetLayerId")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/icon-spritesheets/generations")
|
|
.is_some()
|
|
);
|
|
let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
|
|
["properties"]["style"];
|
|
assert_eq!(icon_style_schema["anyOf"][0]["type"], "string");
|
|
assert!(icon_style_schema["anyOf"][0].get("enum").is_none());
|
|
assert_eq!(icon_style_schema["examples"], json!(["none", "pixelArt"]));
|
|
assert_eq!(
|
|
parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
|
|
["sliceWarning"]["anyOf"][0]["$ref"],
|
|
"#/components/schemas/EditorIconSpritesheetSliceWarning"
|
|
);
|
|
assert_eq!(
|
|
parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"]
|
|
["anyOf"][0]["$ref"],
|
|
"#/components/schemas/EditorGenerationWarning"
|
|
);
|
|
assert_eq!(
|
|
parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
|
|
["warning"]["anyOf"][0]["$ref"],
|
|
"#/components/schemas/EditorGenerationWarning"
|
|
);
|
|
assert_eq!(
|
|
parsed["components"]["schemas"]["EditorGenerationWarning"]["required"],
|
|
json!(["code", "reason"])
|
|
);
|
|
assert_eq!(
|
|
parsed["components"]["schemas"]["EditorGenerationWarning"]["properties"]["code"]["enum"],
|
|
json!([
|
|
"postprocess-failed-source-preserved",
|
|
"dimension-restore-fallback",
|
|
"unsupported-image-style",
|
|
"multiple-generation-warnings"
|
|
])
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/ui-designs/assets/extractions")
|
|
.is_some()
|
|
);
|
|
let ui_extraction_schema =
|
|
&parsed["components"]["schemas"]["EditorUiDesignAssetExtractionRequest"];
|
|
assert_eq!(
|
|
ui_extraction_schema["properties"]["aspectRatio"]["const"],
|
|
"1:1"
|
|
);
|
|
assert_eq!(
|
|
ui_extraction_schema["properties"]["imageSize"]["enum"],
|
|
json!(["1K", "2K"])
|
|
);
|
|
assert_eq!(
|
|
ui_extraction_schema["properties"]["model"]["default"],
|
|
"gemini-3.1-flash-image-preview"
|
|
);
|
|
assert!(
|
|
ui_extraction_schema["properties"]
|
|
.get("priceMudPoints")
|
|
.is_none()
|
|
);
|
|
assert!(ui_extraction_schema["properties"].get("style").is_none());
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/videos/generations")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/audios/sound-effects/generations")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/audios/background-music/generations")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/assets/library")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/projects/{projectId}/resources")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/assets/folders")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
parsed["paths"]
|
|
.get("/api/external/v1/editor/assets/{assetId}")
|
|
.is_some()
|
|
);
|
|
assert!(parsed["paths"].get("/api/profile/api-keys").is_none());
|
|
assert!(
|
|
parsed["components"]["securitySchemes"]
|
|
.get("UserAccessToken")
|
|
.is_none()
|
|
);
|
|
assert_eq!(
|
|
parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"],
|
|
"bearer"
|
|
);
|
|
for schema_name in [
|
|
"EditorProjectResource",
|
|
"EditorAsset",
|
|
"EditorImageGenerationResponse",
|
|
"EditorIconSpritesheetGenerationResponse",
|
|
"EditorVideoGenerationResponse",
|
|
"EditorAudioGenerationResponse",
|
|
] {
|
|
let schema = &parsed["components"]["schemas"][schema_name];
|
|
assert!(
|
|
schema["properties"].get("provider").is_none(),
|
|
"{schema_name} 不应向普通用户公开 provider"
|
|
);
|
|
assert!(
|
|
schema["required"]
|
|
.as_array()
|
|
.is_some_and(|required| !required.contains(&json!("provider"))),
|
|
"{schema_name} 不应要求 provider"
|
|
);
|
|
}
|
|
}
|
|
}
|