新增 External v1 去背景生成链路
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled

新增外部去背景 API、MCP 工具与异步队列契约

补齐来源归属、媒体类型、幂等重放和画布原子持久化校验

修复 provenance 重建、assetKindOverride 门禁与 revision retry 竞态

同步 Python helper、Skill、OpenAPI 及项目文档
This commit is contained in:
2026-08-24 12:25:17 +08:00
parent 44ee28c43f
commit 3c8ece15f8
15 changed files with 1953 additions and 150 deletions
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ use axum::{
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,
@@ -33,12 +34,13 @@ use crate::{
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, 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,
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_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,
@@ -161,6 +163,37 @@ pub struct ExternalEditorProjectResourceCreateRequest {
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 {
@@ -774,6 +807,31 @@ pub async fn edit_external_editor_image(
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();
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>,
@@ -1737,6 +1795,214 @@ mod tests {
.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_generic_image_generation_rejects_scene_asset_kind_before_queueing() {
assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
@@ -1921,6 +2187,7 @@ mod tests {
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",
@@ -2346,6 +2613,55 @@ mod tests {
.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")
+96 -32
View File
@@ -3,7 +3,7 @@ use std::sync::{Arc, LazyLock};
use axum::{
body::Body,
http::{
Method, Request,
HeaderMap, Method, Request,
header::{AUTHORIZATION, CONTENT_TYPE},
},
};
@@ -246,17 +246,8 @@ fn build_mcp_operations() -> Vec<McpOperation> {
};
let method = Method::from_bytes(method_name.to_ascii_uppercase().as_bytes())
.expect("known HTTP method");
let requires_idempotency_key = matches!(
operation_id,
"generateExternalEditorImage"
| "editExternalEditorImage"
| "generateExternalEditorIconSpritesheet"
| "extractExternalEditorUiDesignAssets"
| "generateExternalEditorCharacterAnimation"
| "generateExternalEditorVideo"
| "generateExternalEditorSoundEffect"
| "generateExternalEditorBackgroundMusic"
);
let requires_idempotency_key =
operation_requires_idempotency_key(&openapi, path_item, operation);
let description = operation
.get("description")
.or_else(|| operation.get("summary"))
@@ -287,6 +278,34 @@ fn build_mcp_operations() -> Vec<McpOperation> {
operations
}
fn operation_requires_idempotency_key(
openapi: &Value,
path_item: &Map<String, Value>,
operation: &Map<String, Value>,
) -> bool {
path_item
.get("parameters")
.and_then(Value::as_array)
.into_iter()
.flatten()
.chain(
operation
.get("parameters")
.and_then(Value::as_array)
.into_iter()
.flatten(),
)
.filter_map(|parameter| resolve_openapi_reference(openapi, parameter))
.any(|parameter| {
parameter.get("in").and_then(Value::as_str) == Some("header")
&& parameter
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name.eq_ignore_ascii_case("Idempotency-Key"))
&& parameter.get("required").and_then(Value::as_bool) == Some(true)
})
}
fn build_operation_input_schema(
openapi: &Value,
path_item: &Map<String, Value>,
@@ -513,18 +532,7 @@ async fn dispatch_operation(
"application/json".parse().expect("valid content type"),
);
}
if operation.requires_idempotency_key {
let idempotency_key = arguments
.get("idempotencyKey")
.and_then(Value::as_str)
.ok_or_else(|| json!({"error": "生成工具必须提供 idempotencyKey"}))?;
request.headers_mut().insert(
"idempotency-key",
idempotency_key
.parse()
.map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?,
);
}
apply_operation_headers(operation, &arguments, request.headers_mut())?;
let response = modules::external_api::router(state.clone())
.with_state(state)
@@ -557,6 +565,27 @@ async fn dispatch_operation(
}
}
fn apply_operation_headers(
operation: &McpOperation,
arguments: &Map<String, Value>,
headers: &mut HeaderMap,
) -> Result<(), Value> {
if !operation.requires_idempotency_key {
return Ok(());
}
let idempotency_key = arguments
.get("idempotencyKey")
.and_then(Value::as_str)
.ok_or_else(|| json!({"error": "生成工具必须提供 idempotencyKey"}))?;
headers.insert(
"idempotency-key",
idempotency_key
.parse()
.map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?,
);
Ok(())
}
fn validate_required_body(
operation: &McpOperation,
arguments: &Map<String, Value>,
@@ -739,6 +768,7 @@ mod tests {
.collect();
assert_eq!(names.len(), MCP_OPERATIONS.len());
assert!(names.contains_key("generate_external_editor_image"));
assert!(names.contains_key("remove_external_editor_image_background"));
assert!(names.contains_key("get_external_editor_generation_job"));
let list_projects = MCP_OPERATIONS
@@ -877,19 +907,53 @@ mod tests {
#[test]
fn generation_tools_require_idempotency_key() {
let operation = MCP_OPERATIONS
for tool_name in [
"generate_external_editor_image",
"remove_external_editor_image_background",
] {
let operation = MCP_OPERATIONS
.iter()
.find(|operation| operation.tool_name == tool_name)
.unwrap_or_else(|| panic!("{tool_name} should exist"));
assert!(operation.requires_idempotency_key, "{tool_name}");
assert_eq!(
operation.input_schema.get("required"),
Some(&json!(["body", "idempotencyKey"])),
"{tool_name}"
);
}
let image_generation = MCP_OPERATIONS
.iter()
.find(|operation| operation.tool_name == "generate_external_editor_image")
.expect("image generation tool should exist");
assert!(operation.requires_idempotency_key);
assert_eq!(
operation.input_schema.get("required"),
Some(&json!(["body", "idempotencyKey"]))
);
assert_eq!(
operation.input_schema["properties"]["body"]["properties"]["projectId"]["type"],
image_generation.input_schema["properties"]["body"]["properties"]["projectId"]["type"],
json!(["string", "null"])
);
let background_removal = MCP_OPERATIONS
.iter()
.find(|operation| operation.tool_name == "remove_external_editor_image_background")
.expect("background removal tool should exist");
assert_eq!(
background_removal.input_schema["properties"]["body"]["required"],
json!(["sourceImageSrc"])
);
let mut headers = HeaderMap::new();
apply_operation_headers(
background_removal,
&Map::from_iter([("idempotencyKey".to_string(), json!("issue-178-request"))]),
&mut headers,
)
.expect("background removal idempotency header should be forwarded");
assert_eq!(
headers
.get("idempotency-key")
.and_then(|value| value.to_str().ok()),
Some("issue-178-request")
);
}
#[test]
@@ -1090,7 +1154,7 @@ mod tests {
(
4,
SKILL_REQUESTS_AND_OUTPUTS_URI,
"All eight generation POST routes require",
"All nine generation POST routes require",
),
] {
let request = Request::builder()
@@ -23,7 +23,8 @@ use crate::{
generate_external_editor_video, get_external_editor_asset_library,
get_external_editor_generation_job, get_external_editor_project,
list_external_editor_projects, load_recent_external_editor_project, openapi_json,
rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
remove_external_editor_image_background, rename_external_editor_project,
save_external_editor_canvas, update_external_editor_asset,
update_external_editor_asset_folder,
},
external_mcp,
@@ -186,6 +187,12 @@ pub fn router(state: AppState) -> Router<AppState> {
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background).route_layer(
middleware::from_fn_with_state(state.clone(), require_external_api_key),
),
)
.route(
"/api/external/v1/editor/icon-spritesheets/generations",
post(generate_external_editor_icon_spritesheet).route_layer(