Files
Genarrative/server-rs/crates/api-server/src/external_editor_api.rs
T
lhk229 72acd0ec05
Project CI / Frontend tests (push) Successful in 3m40s
Project CI / Backend tests (push) Successful in 4m11s
Project CI / Repository checks (push) Successful in 1m19s
Project CI / Native shell tests (push) Successful in 12m36s
停止向用户泄露抠图模型 (#120)
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/120
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-07-31 16:40:43 +08:00

1076 lines
38 KiB
Rust

use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::{StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput,
EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput,
};
use crate::{
api_response::json_success_body,
character_animation_assets::{
generate_editor_character_animation_for_owner, generate_editor_video_for_owner,
},
editor_project::{
EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE,
EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload,
EditorAssetLibraryPayload, EditorAssetPayload, EditorCanvasViewportPayload,
EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest,
EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload,
EditorUiDesignAssetExtractionRequest, current_utc_micros, edit_editor_image_for_owner,
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record,
editor_asset_payload_from_record, editor_project_payload_from_record,
editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner,
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string,
parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
},
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner,
},
};
const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api";
const SCOPE_EDITOR_PROJECT: &str = "editor:project";
const SCOPE_EDITOR_CANVAS: &str = "editor:canvas";
const SCOPE_EDITOR_IMAGE_GENERATE: &str = "editor:image-generate";
const SCOPE_EDITOR_ASSET: &str = "editor:asset";
const OPENAPI_JSON: &str =
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectCreateRequest {
title: Option<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>,
payload: Result<Json<EditorImageGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
generate_editor_image_for_owner(
&state,
&request_context,
editor_generation_caller(&principal, payload.project_id.clone()),
payload,
)
.await
}
pub async fn edit_external_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<EditorImageEditRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
edit_editor_image_for_owner(
&state,
&request_context,
editor_generation_caller(&principal, payload.project_id.clone()),
payload,
)
.await
}
pub async fn generate_external_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
payload: Result<Json<EditorIconSpritesheetGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
generate_editor_icon_spritesheet_for_owner(
&state,
&request_context,
editor_generation_caller(&principal, payload.project_id.clone()),
payload,
)
.await
}
pub async fn extract_external_editor_ui_design_assets(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<EditorUiDesignAssetExtractionRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
extract_editor_ui_design_assets_for_owner(
&state,
&request_context,
editor_generation_caller(&principal, payload.project_id.clone()),
payload,
)
.await
}
pub async fn generate_external_editor_character_animation(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
payload: Result<
Json<shared_contracts::assets::EditorCharacterAnimationGenerateRequest>,
JsonRejection,
>,
) -> Result<Json<Value>, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
generate_editor_character_animation_for_owner(
state,
request_context,
principal.owner_user_id().to_string(),
payload,
None,
)
.await
}
pub async fn generate_external_editor_video(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
payload: Result<Json<shared_contracts::assets::EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
generate_editor_video_for_owner(
state,
request_context,
principal.owner_user_id().to_string(),
payload,
)
.await
}
pub async fn generate_external_editor_sound_effect(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
payload: Result<
Json<shared_contracts::assets::EditorSoundEffectGenerateRequest>,
JsonRejection,
>,
) -> Result<Json<Value>, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
generate_editor_sound_effect_for_owner(
state,
request_context,
principal.owner_user_id().to_string(),
payload,
)
.await
}
pub async fn generate_external_editor_background_music(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
payload: Result<
Json<shared_contracts::assets::EditorBackgroundMusicGenerateRequest>,
JsonRejection,
>,
) -> Result<Json<Value>, Response> {
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
generate_editor_background_music_for_owner(
state,
request_context,
principal.owner_user_id().to_string(),
payload,
)
.await
}
fn editor_generation_caller(
principal: &ExternalApiPrincipal,
project_id: Option<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::*;
#[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 exported_openapi_json_contains_external_editor_routes_and_security() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
assert_eq!(parsed["openapi"], "3.1.0");
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/assets/direct-upload-tickets")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/assets/objects/confirm")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/assets/read-url")
.is_some()
);
assert!(
parsed["paths"]["/api/external/v1/editor/projects"]
.get("get")
.is_some()
);
assert!(
parsed["paths"]["/api/external/v1/editor/projects"]
.get("post")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects/recent")
.is_some()
);
assert!(
parsed["paths"]["/api/external/v1/editor/projects/{projectId}"]
.get("delete")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects/{projectId}/metadata")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/generations")
.is_some()
);
assert!(
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("priceMudPoints")))
);
assert!(
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]
.get("priceMudPoints")
.is_none()
);
let image_style_schema =
&parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["style"];
assert_eq!(image_style_schema["anyOf"][0]["type"], "string");
assert!(image_style_schema["anyOf"][0].get("enum").is_none());
assert_eq!(image_style_schema["examples"], json!(["none", "pixelArt"]));
assert!(
parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"]
.get("default")
.is_none()
);
assert_eq!(
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
"array"
);
assert_eq!(
parsed["components"]["schemas"]["EditorCanvas"]["properties"]["layers"]["type"],
"array"
);
assert!(
parsed["components"]["schemas"]["ExternalEditorCanvasSaveRequest"]["required"]
.as_array()
.is_some_and(|required| required.contains(&json!("expectedRevision")))
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/edits")
.is_some()
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("priceMudPoints")))
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
.get("priceMudPoints")
.is_none()
);
assert!(
parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]
.get("targetLayerId")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/icon-spritesheets/generations")
.is_some()
);
let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
["properties"]["style"];
assert_eq!(icon_style_schema["anyOf"][0]["type"], "string");
assert!(icon_style_schema["anyOf"][0].get("enum").is_none());
assert_eq!(icon_style_schema["examples"], json!(["none", "pixelArt"]));
assert_eq!(
parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
["sliceWarning"]["anyOf"][0]["$ref"],
"#/components/schemas/EditorIconSpritesheetSliceWarning"
);
assert_eq!(
parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"]
["anyOf"][0]["$ref"],
"#/components/schemas/EditorGenerationWarning"
);
assert_eq!(
parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
["warning"]["anyOf"][0]["$ref"],
"#/components/schemas/EditorGenerationWarning"
);
assert_eq!(
parsed["components"]["schemas"]["EditorGenerationWarning"]["required"],
json!(["code", "reason"])
);
assert_eq!(
parsed["components"]["schemas"]["EditorGenerationWarning"]["properties"]["code"]["enum"],
json!([
"postprocess-failed-source-preserved",
"dimension-restore-fallback",
"unsupported-image-style",
"multiple-generation-warnings"
])
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/ui-designs/assets/extractions")
.is_some()
);
let ui_extraction_schema =
&parsed["components"]["schemas"]["EditorUiDesignAssetExtractionRequest"];
assert_eq!(
ui_extraction_schema["properties"]["aspectRatio"]["const"],
"1:1"
);
assert_eq!(
ui_extraction_schema["properties"]["imageSize"]["enum"],
json!(["1K", "2K"])
);
assert_eq!(
ui_extraction_schema["properties"]["model"]["default"],
"gemini-3.1-flash-image-preview"
);
assert!(
ui_extraction_schema["properties"]
.get("priceMudPoints")
.is_none()
);
assert!(ui_extraction_schema["properties"].get("style").is_none());
assert!(
parsed["paths"]
.get("/api/external/v1/editor/videos/generations")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/audios/sound-effects/generations")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/audios/background-music/generations")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/assets/library")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects/{projectId}/resources")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/assets/folders")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/assets/{assetId}")
.is_some()
);
assert!(parsed["paths"].get("/api/profile/api-keys").is_none());
assert!(
parsed["components"]["securitySchemes"]
.get("UserAccessToken")
.is_none()
);
assert_eq!(
parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"],
"bearer"
);
for schema_name in [
"EditorProjectResource",
"EditorAsset",
"EditorImageGenerationResponse",
"EditorIconSpritesheetGenerationResponse",
"EditorVideoGenerationResponse",
"EditorAudioGenerationResponse",
] {
let schema = &parsed["components"]["schemas"][schema_name];
assert!(
schema["properties"].get("provider").is_none(),
"{schema_name} 不应向普通用户公开 provider"
);
assert!(
schema["required"]
.as_array()
.is_some_and(|required| !required.contains(&json!("provider"))),
"{schema_name} 不应要求 provider"
);
}
}
}