From d39a21115462e4e2f144cf0b57d97d58be7a7a8a Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 24 Aug 2026 14:11:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20External=20=E5=8E=BB?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E9=87=8D=E6=94=BE=E4=B8=8E=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按原始请求指纹在可变预检前返回幂等任务 修正来源身份优先级与跨项目歧义校验 冻结并复验原位替换目标的完整媒体语义 修复 Python helper 原位替换请求并补正向路由测试 --- .../genarrative-external-editor-api/SKILL.md | 2 +- .../references/requests-and-outputs.md | 2 +- .../scripts/genarrative_external_api.py | 33 +- .../api-server/src/editor_generation_queue.rs | 343 ++++++++++- .../crates/api-server/src/editor_project.rs | 535 +++++++++++++----- .../api-server/src/external_editor_api.rs | 109 ++++ server-rs/crates/api-server/src/state.rs | 56 ++ 7 files changed, 921 insertions(+), 159 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index 1664abf11..e6a9f55e7 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -119,7 +119,7 @@ client.remove_background( ) ``` -Background removal preserves the source pixel size. When `canvasSession` is used, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Without `canvasCompletion`, `targetLayerId` may replace only the layer that points to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation. +Background removal preserves the source pixel size. For normal canvas placement with `canvasSession`, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation. Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`. diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index fee0bf3fb..94dc6d622 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -100,7 +100,7 @@ A minimal `canvasCompletion` is: `dialogId` is optional. The placeholder supplies canvas placement and completion coordinates; it is not a final media pixel-size constraint. For successful pixel-art snapping, the result layer uses the final logical-grid PNG dimensions even when they differ from the placeholder. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed. -Background removal preserves the source image dimensions. The Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. Without `canvasCompletion`, an in-place `targetLayerId` must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order. +Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order. Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame. diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index 15d60e69c..cc5a4a4b5 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -582,11 +582,16 @@ class GenarrativeExternalClient: session = fields.get("canvasSession") if session is None: session = fields.get("canvas_session") + target_layer_id = normalize_optional_text(fields.get("targetLayerId")) + if target_layer_id and fields.get("canvasCompletion") is not None: + raise GenarrativeApiError( + "targetLayerId and canvasCompletion are mutually exclusive for background removal" + ) canvas_width = fields.get("canvasWidth") canvas_height = fields.get("canvasHeight") if (canvas_width is None) != (canvas_height is None): raise GenarrativeApiError("canvasWidth and canvasHeight must be provided together") - if session is not None and canvas_width is None: + if session is not None and canvas_width is None and not target_layer_id: if source_width is None or source_height is None: raise GenarrativeApiError( "remove_background requires source_width and source_height when canvasSession is used without canvasWidth/canvasHeight" @@ -599,6 +604,8 @@ class GenarrativeExternalClient: source_width or 1, source_height or 1, ) + if target_layer_id: + fields.pop("canvasCompletion", None) idempotency_key = fields.pop("idempotencyKey", None) return self.submit_and_wait_generation( "/api/external/v1/editor/images/background-removals", @@ -838,6 +845,30 @@ def _self_test() -> None: assert calls[0]["body"]["canvasCompletion"]["placeholder"]["width"] == 720 assert calls[0]["body"]["canvasCompletion"]["placeholder"]["height"] == 1280 calls.clear() + client.remove_background( + "uploads/source.png", + canvasSession=session, + targetLayerId="layer-1", + assetLabel="原位去背景结果", + ) + assert calls[0]["body"]["projectId"] == "proj-demo" + assert calls[0]["body"]["assetFolderId"] == "editor-asset-folder-demo" + assert calls[0]["body"]["assetLabel"] == "原位去背景结果" + assert calls[0]["body"]["targetLayerId"] == "layer-1" + assert "canvasCompletion" not in calls[0]["body"] + calls.clear() + try: + client.remove_background( + "uploads/source.png", + canvasSession=session, + targetLayerId="layer-1", + canvasCompletion={"title": "冲突完成指令"}, + ) + except GenarrativeApiError as error: + assert "targetLayerId and canvasCompletion are mutually exclusive" in str(error) + else: + raise AssertionError("background removal must reject conflicting canvas placement modes") + assert calls == [] try: client.remove_background("uploads/source.png", canvasSession=session) except GenarrativeApiError as error: diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 70f45dcf0..0fdb965f9 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -6,7 +6,10 @@ use shared_contracts::external_generation::{ ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, }; use shared_kernel::{build_prefixed_uuid_id, offset_datetime_to_unix_micros}; -use spacetime_client::{ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord}; +use spacetime_client::{ + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobGetRecordInput, + ExternalGenerationJobRecord, SpacetimeClientError, +}; use crate::{http_error::AppError, request_context::RequestContext, state::AppState}; @@ -30,6 +33,7 @@ const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker"; const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation"; const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation"; +const EXTERNAL_API_REQUEST_FINGERPRINT_FIELD: &str = "_externalApiRequestFingerprint"; pub(crate) const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str = "game-creator-client-generation"; pub(crate) const GAME_CREATOR_CLIENT_GENERATION_SOURCE: &str = "ai-game-creator-client"; @@ -89,6 +93,186 @@ fn build_editor_generation_dedupe_key( format!("{namespace}:{job_kind}:{:x}", hasher.finalize()) } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExternalApiEditorGenerationRequestIdentity { + job_id: String, + dedupe_key: String, + request_fingerprint: String, +} + +pub(crate) fn external_api_editor_generation_request_identity( + owner_user_id: &str, + job_kind: &str, + payload: &T, + idempotency_key: &str, +) -> Result +where + T: Serialize, +{ + let payload = serde_json::to_value(payload).map_err(payload_serialization_error)?; + let canonical_payload = canonicalize_external_api_request_value(payload); + let canonical_payload = + serde_json::to_vec(&canonical_payload).map_err(payload_serialization_error)?; + let mut request_hasher = Sha256::new(); + request_hasher.update(b"genarrative-external-api-request-v1\0"); + request_hasher.update(canonical_payload); + let request_fingerprint = format!("{:x}", request_hasher.finalize()); + let dedupe_key = build_editor_generation_dedupe_key( + EXTERNAL_API_GENERATION_DEDUPE_PREFIX, + owner_user_id, + job_kind, + idempotency_key, + ); + let mut job_hasher = Sha256::new(); + job_hasher.update(b"genarrative-external-api-operation-v1\0"); + job_hasher.update(dedupe_key.as_bytes()); + let job_digest = format!("{:x}", job_hasher.finalize()); + + Ok(ExternalApiEditorGenerationRequestIdentity { + job_id: format!("task-{}", &job_digest[..32]), + dedupe_key, + request_fingerprint, + }) +} + +fn canonicalize_external_api_request_value(value: Value) -> Value { + match value { + Value::Array(values) => Value::Array( + values + .into_iter() + .map(canonicalize_external_api_request_value) + .collect(), + ), + Value::Object(values) => { + let mut entries = values.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key, canonicalize_external_api_request_value(value))) + .collect(), + ) + } + other => other, + } +} + +pub(crate) async fn find_external_api_editor_generation_replay( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + identity: &ExternalApiEditorGenerationRequestIdentity, +) -> Result, AppError> { + let job = match state + .spacetime_client() + .get_external_generation_job(ExternalGenerationJobGetRecordInput { + job_id: identity.job_id.clone(), + owner_user_id: owner_user_id.to_string(), + }) + .await + { + Ok(job) => job, + Err(SpacetimeClientError::Procedure(message)) + if message == "external_generation_job 不存在" => + { + return Ok(None); + } + Err(error) => { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": EDITOR_GENERATION_QUEUE_PROVIDER, + "message": format!("读取 External 幂等任务失败:{error}"), + })), + ); + } + }; + ensure_external_api_editor_generation_request_identity(job, owner_user_id, job_kind, identity) + .map(Some) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn enqueue_external_api_editor_generation_with_request_identity( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + payload: &T, + identity: &ExternalApiEditorGenerationRequestIdentity, +) -> Result +where + T: Serialize, +{ + let request_payload_json = + serialize_external_api_editor_generation_payload_with_identity(payload, identity)?; + let job = enqueue_serialized_editor_generation_job_with_identity( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + request_payload_json, + identity.job_id.clone(), + identity.dedupe_key.clone(), + ) + .await?; + ensure_external_api_editor_generation_request_identity(job, owner_user_id, job_kind, identity) +} + +fn ensure_external_api_editor_generation_request_identity( + job: ExternalGenerationJobRecord, + owner_user_id: &str, + job_kind: &str, + identity: &ExternalApiEditorGenerationRequestIdentity, +) -> Result { + let persisted_fingerprint = serde_json::from_str::(&job.request_payload_json) + .ok() + .and_then(|payload| { + payload + .get(EXTERNAL_API_REQUEST_FINGERPRINT_FIELD) + .and_then(Value::as_str) + .map(str::to_string) + }); + if job.job_id == identity.job_id + && job.dedupe_key == identity.dedupe_key + && job.job_kind == job_kind + && job.owner_user_id == owner_user_id + && persisted_fingerprint.as_deref() == Some(identity.request_fingerprint.as_str()) + { + return Ok(job); + } + Err( + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": EDITOR_GENERATION_QUEUE_PROVIDER, + "message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", + })), + ) +} + +fn serialize_external_api_editor_generation_payload_with_identity( + payload: &T, + identity: &ExternalApiEditorGenerationRequestIdentity, +) -> Result +where + T: Serialize + ?Sized, +{ + let mut payload = serde_json::to_value(payload).map_err(payload_serialization_error)?; + payload + .as_object_mut() + .ok_or_else(|| { + payload_serialization_error(serde_json::Error::io(std::io::Error::other( + "编辑器生成任务参数必须是 JSON object", + ))) + })? + .insert( + EXTERNAL_API_REQUEST_FINGERPRINT_FIELD.to_string(), + Value::String(identity.request_fingerprint.clone()), + ); + serialize_editor_generation_job_payload(&payload) +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn enqueue_editor_generation_job_with_identity( state: &AppState, @@ -678,6 +862,163 @@ mod tests { ); } + #[test] + fn external_background_removal_operation_id_is_key_scoped_and_body_fingerprint_is_separate() { + let first_body = json!({ + "sourceImageSrc": "editor-upload/source-a.png", + "projectId": "project-1", + }); + let reordered_same_body = json!({ + "projectId": "project-1", + "sourceImageSrc": "editor-upload/source-a.png", + }); + let changed_body = json!({ + "sourceImageSrc": "editor-upload/source-b.png", + "projectId": "project-1", + }); + let spoofed_client_source = json!({ + "sourceImageSrc": "editor-upload/source-a.png", + "projectId": "project-1", + "generationInputs": { + "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, + }, + }); + let first = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &first_body, + "stable-key", + ) + .expect("request identity should build"); + let reordered = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &reordered_same_body, + "stable-key", + ) + .expect("canonical request identity should build"); + let changed = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &changed_body, + "stable-key", + ) + .expect("changed request identity should build"); + let spoofed = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &spoofed_client_source, + "stable-key", + ) + .expect("external request identity must ignore payload-controlled namespaces"); + + assert_eq!(first.job_id, reordered.job_id); + assert_eq!(first.request_fingerprint, reordered.request_fingerprint); + assert_eq!(first.job_id, changed.job_id); + assert_ne!(first.request_fingerprint, changed.request_fingerprint); + assert_eq!(first.job_id, spoofed.job_id); + assert_eq!(first.dedupe_key, spoofed.dedupe_key); + assert_ne!(first.request_fingerprint, spoofed.request_fingerprint); + assert!(first.job_id.starts_with("task-")); + assert_ne!( + first.job_id, + external_api_editor_generation_request_identity( + "user-2", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &first_body, + "stable-key", + ) + .unwrap() + .job_id, + ); + assert_ne!( + first.job_id, + external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &first_body, + "other-key", + ) + .unwrap() + .job_id, + ); + } + + #[test] + fn external_background_removal_replay_compares_raw_request_before_canonical_worker_payload() { + let raw_request = json!({ + "sourceImageSrc": "resource-source", + "projectId": "project-output", + }); + let identity = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &raw_request, + "stable-key", + ) + .expect("request identity should build"); + let canonical_worker_payload = json!({ + "sourceImageSrc": "resource-source", + "projectId": "project-output", + "sourceResourceId": "resource-source", + "assetKind": "character", + }); + let persisted_payload = serialize_external_api_editor_generation_payload_with_identity( + &canonical_worker_payload, + &identity, + ) + .expect("canonical worker payload should serialize with its private request fingerprint"); + let mut job = queue_job_fixture("queued", None); + job.job_id = identity.job_id.clone(); + job.dedupe_key = identity.dedupe_key.clone(); + job.job_kind = EDITOR_BACKGROUND_REMOVAL_JOB_KIND.to_string(); + job.request_payload_json = persisted_payload.clone(); + + assert!( + ensure_external_api_editor_generation_request_identity( + job.clone(), + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &identity, + ) + .is_ok(), + "same raw request must replay even when its canonical worker payload came from mutable preflight", + ); + + let changed_identity = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &json!({ + "sourceImageSrc": "resource-other", + "projectId": "project-output", + }), + "stable-key", + ) + .expect("changed request identity should build"); + let error = ensure_external_api_editor_generation_request_identity( + job, + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &changed_identity, + ) + .expect_err("same key with a different raw request must conflict"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + + let restored: crate::editor_project::EditorBackgroundRemovalRequest = + serde_json::from_str(&persisted_payload) + .expect("worker must ignore the private queue fingerprint envelope field"); + let restored_payload = + serde_json::to_value(restored).expect("worker payload should encode"); + assert_eq!(restored_payload["sourceImageSrc"], json!("resource-source")); + assert_eq!(restored_payload["assetKind"], json!("character")); + assert!( + restored_payload + .get(EXTERNAL_API_REQUEST_FINGERPRINT_FIELD) + .is_none(), + "private replay identity must not enter generationInputs or worker provenance", + ); + } + #[test] fn external_api_dedupe_key_preserves_legacy_hash_bytes() { let dedupe_key = build_editor_generation_dedupe_key( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index cb2609c5b..0e60b3ea0 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -83,6 +83,9 @@ use crate::{ GAME_CREATOR_CLIENT_GENERATION_SOURCE, editor_generation_client_source, editor_generation_queue_state, editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller, + enqueue_external_api_editor_generation_with_request_identity, + external_api_editor_generation_request_identity, + find_external_api_editor_generation_replay, }, editor_green_screen::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, @@ -820,7 +823,7 @@ pub(crate) enum EditorCanvasLayoutPlan { owner_user_id: String, project_id: Option, target_layer_id: Option, - expected_target_resource_id: Option, + expected_target: Option, resource: Option, replacement_title: Option, recenter_replacement: bool, @@ -849,7 +852,7 @@ impl EditorCanvasLayoutPlan { owner_user_id: &str, project_id: Option<&str>, target_layer_id: Option<&str>, - expected_target_resource_id: Option<&str>, + expected_target: Option<&EditorBackgroundRemovalTargetLayerSource>, resource: Option<&EditorProjectResourcePayload>, replacement_title: Option<&str>, recenter_replacement: bool, @@ -858,7 +861,7 @@ impl EditorCanvasLayoutPlan { owner_user_id: owner_user_id.to_string(), project_id: project_id.map(str::to_string), target_layer_id: target_layer_id.map(str::to_string), - expected_target_resource_id: expected_target_resource_id.map(str::to_string), + expected_target: expected_target.cloned(), resource: resource.cloned(), replacement_title: replacement_title.map(str::to_string), recenter_replacement, @@ -892,7 +895,7 @@ impl EditorCanvasLayoutPlan { owner_user_id, project_id, target_layer_id, - expected_target_resource_id, + expected_target, resource, replacement_title, recenter_replacement, @@ -902,7 +905,7 @@ impl EditorCanvasLayoutPlan { owner_user_id, project_id.as_deref(), target_layer_id.as_deref(), - expected_target_resource_id.as_deref(), + expected_target.as_ref(), resource.as_ref(), replacement_title.as_deref(), *recenter_replacement, @@ -6168,11 +6171,21 @@ pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot( canvas_item.map(|(_, layer_id)| layer_id), ) } else { + let expected_target = source.target_resource_id.as_ref().map(|resource_id| { + EditorBackgroundRemovalTargetLayerSource { + bucket: source.bucket.clone(), + object_key: source.object_key.clone(), + asset_object_id: source.asset_object_id.clone(), + resource_id: resource_id.clone(), + asset_kind: source.effective_asset_kind.clone(), + media_type: source.media_type.clone(), + } + }); EditorCanvasLayoutPlan::background_removal( owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), - source.target_resource_id.as_deref(), + expected_target.as_ref(), prepared_asset.record.resource.as_ref(), Some(asset_label.as_str()), true, @@ -6276,9 +6289,16 @@ pub(crate) async fn enqueue_editor_background_removal_for_owner( mut payload: EditorBackgroundRemovalRequest, external_idempotency_key: Option<&str>, ) -> Result { - payload.generation_inputs = - sanitize_editor_queued_generation_inputs(payload.generation_inputs.take()); - payload.target_layer_id = normalize_optional_string(payload.target_layer_id.take()); + let external_request_identity = external_idempotency_key + .map(|idempotency_key| { + external_api_editor_generation_request_identity( + caller.owner_user_id.as_str(), + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &payload, + idempotency_key, + ) + }) + .transpose()?; ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-background-removal", @@ -6291,6 +6311,20 @@ pub(crate) async fn enqueue_editor_background_removal_for_owner( payload.target_layer_id.as_deref(), payload.canvas_completion.is_some(), )?; + if let Some(identity) = external_request_identity.as_ref() + && let Some(job) = find_external_api_editor_generation_replay( + state, + caller.owner_user_id.as_str(), + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + identity, + ) + .await? + { + return Ok(job); + } + payload.generation_inputs = + sanitize_editor_queued_generation_inputs(payload.generation_inputs.take()); + payload.target_layer_id = normalize_optional_string(payload.target_layer_id.take()); let target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), @@ -6338,18 +6372,32 @@ pub(crate) async fn enqueue_editor_background_removal_for_owner( payload.project_id.as_deref(), "editor-background-removal", ); - enqueue_editor_generation_job_for_caller( - state, - request_context, - caller.owner_user_id.as_str(), - EDITOR_BACKGROUND_REMOVAL_JOB_KIND, - source_entity_id, - "图片画布去除背景", - 0, - &payload, - external_idempotency_key, - ) - .await + if let Some(identity) = external_request_identity.as_ref() { + enqueue_external_api_editor_generation_with_request_identity( + state, + caller.owner_user_id.as_str(), + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + source_entity_id, + "图片画布去除背景", + 0, + &payload, + identity, + ) + .await + } else { + enqueue_editor_generation_job_for_caller( + state, + request_context, + caller.owner_user_id.as_str(), + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + source_entity_id, + "图片画布去除背景", + 0, + &payload, + None, + ) + .await + } } fn resolve_editor_background_removal_canvas_target( @@ -6381,12 +6429,13 @@ fn resolve_editor_background_removal_canvas_target( } #[derive(Clone, Debug, PartialEq, Eq)] -struct EditorBackgroundRemovalTargetLayerSource { +pub(crate) struct EditorBackgroundRemovalTargetLayerSource { bucket: String, object_key: String, asset_object_id: Option, resource_id: String, asset_kind: Option, + media_type: String, } fn resolve_editor_background_removal_source_resource_binding( @@ -6553,6 +6602,7 @@ fn resolve_editor_background_removal_canvas_target_source( asset_object_id: resource.asset_object_id.clone(), resource_id: resource.resource_id.clone(), asset_kind, + media_type: media_type.to_string(), }) } @@ -6757,9 +6807,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), - resolved_canvas_target - .as_ref() - .map(|target| target.resource_id.as_str()), + resolved_canvas_target.as_ref(), prepared_asset.record.resource.as_ref(), Some(asset_label.as_str()), false, @@ -10663,7 +10711,7 @@ async fn prepare_editor_canvas_background_removal_layout( owner_user_id: &str, project_id: Option<&str>, target_layer_id: Option<&str>, - expected_target_resource_id: Option<&str>, + expected_target: Option<&EditorBackgroundRemovalTargetLayerSource>, resource: Option<&EditorProjectResourcePayload>, replacement_title: Option<&str>, recenter_replacement: bool, @@ -10693,8 +10741,9 @@ async fn prepare_editor_canvas_background_removal_layout( let project_payload = editor_project_payload_from_record(project); ensure_editor_canvas_background_removal_target_resource_matches( &project_payload.layers, + &project_payload.resources, target_layer_id, - expected_target_resource_id, + expected_target, )?; let Some(layers) = replace_editor_canvas_layer_with_resource( project_payload.layers, @@ -10718,40 +10767,59 @@ async fn prepare_editor_canvas_background_removal_layout( fn ensure_editor_canvas_background_removal_target_resource_matches( layers: &Value, + resources: &[EditorProjectResourcePayload], target_layer_id: &str, - expected_target_resource_id: Option<&str>, + expected_target: Option<&EditorBackgroundRemovalTargetLayerSource>, ) -> Result<(), AppError> { - let Some(expected_target_resource_id) = expected_target_resource_id - .map(str::trim) - .filter(|value| !value.is_empty()) - else { + let Some(expected_target) = expected_target else { return Ok(()); }; - let current_resource_id = layers - .as_array() - .and_then(|items| { - items.iter().find(|item| { - item.get("layerId") - .and_then(Value::as_str) - .is_some_and(|layer_id| layer_id == target_layer_id) - }) - }) - .and_then(|item| item.get("resourceId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()); - if current_resource_id == Some(expected_target_resource_id) { + let current_target = resolve_editor_background_removal_canvas_target_source( + expected_target.bucket.as_str(), + layers, + resources, + target_layer_id, + ) + .map_err(|_| { + editor_canvas_background_removal_target_changed(target_layer_id, expected_target) + })?; + let same_object = match ( + expected_target.asset_object_id.as_deref(), + current_target.asset_object_id.as_deref(), + ) { + (Some(expected_id), Some(current_id)) => expected_id == current_id, + _ => { + expected_target.bucket == current_target.bucket + && expected_target.object_key == current_target.object_key + } + }; + if expected_target.resource_id == current_target.resource_id + && expected_target.asset_kind == current_target.asset_kind + && expected_target.media_type == current_target.media_type + && same_object + { return Ok(()); } - Err( - AppError::from_status(StatusCode::CONFLICT).with_details(json!({ - "provider": "editor-project", - "message": "图片画布版本冲突:去背景目标图层的当前资源已变化。", - "targetLayerId": target_layer_id, - "expectedTargetResourceId": expected_target_resource_id, - "currentTargetResourceId": current_resource_id, - })), - ) + Err(editor_canvas_background_removal_target_changed( + target_layer_id, + expected_target, + )) +} + +fn editor_canvas_background_removal_target_changed( + target_layer_id: &str, + expected_target: &EditorBackgroundRemovalTargetLayerSource, +) -> AppError { + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "editor-project", + "message": "图片画布版本冲突:去背景目标图层的当前资源或媒体语义已变化。", + "targetLayerId": target_layer_id, + "expectedTargetResourceId": expected_target.resource_id.as_str(), + "expectedTargetAssetKind": expected_target.asset_kind.as_deref(), + "expectedTargetMediaType": expected_target.media_type.as_str(), + "expectedTargetAssetObjectId": expected_target.asset_object_id.as_deref(), + "expectedTargetObjectKey": expected_target.object_key.as_str(), + })) } pub(crate) async fn save_editor_project_layout_with_revision_and_get( @@ -12795,58 +12863,75 @@ fn resolve_editor_background_removal_source_metadata_from_records( .filter(|value| !value.is_empty()); let source_reference = source_reference.trim(); - if let Some(source_resource_id) = source_resource_id { - let resource = if let Some(project_id) = project_id { - projects - .iter() - .find(|project| project.project_id.trim() == project_id) - .and_then(|project| { - project - .resources - .iter() - .find(|resource| resource.resource_id.trim() == source_resource_id) - }) - } else { - find_editor_background_removal_resource(projects, None, source_resource_id) - } - .ok_or_else(|| editor_reference_image_forbidden(source_object_key))?; - if !editor_record_object_key_matches( - resource.object_key.as_deref(), - resource.image_src.as_str(), - source_object_key, - ) { - return Err(editor_reference_image_forbidden(source_object_key)); - } - return Ok(EditorBackgroundRemovalSourceMetadata { - model: resolve_editor_background_removal_resource_model(projects, resource, project_id), - asset_object_id: resource.asset_object_id.clone(), - asset_kind: editor_background_removal_resource_asset_kind(resource), - }); - } - - if let Some(resource) = - find_editor_background_removal_resource(projects, project_id, source_reference) - { - return Ok(EditorBackgroundRemovalSourceMetadata { - model: resolve_editor_background_removal_resource_model(projects, resource, project_id), - asset_object_id: resource.asset_object_id.clone(), - asset_kind: editor_background_removal_resource_asset_kind(resource), - }); - } - - if let Some(asset) = assets + let explicit_resource = + find_editor_background_removal_resource(projects, None, source_reference); + let explicit_asset = assets .iter() - .find(|asset| asset.asset_id.trim() == source_reference) - { + .find(|asset| asset.asset_id.trim() == source_reference); + if explicit_resource.is_some() && explicit_asset.is_some() { + return Err(editor_background_removal_source_identity_conflict( + source_reference, + source_resource_id, + )); + } + + if let Some(resource) = explicit_resource { + if source_resource_id.is_some_and(|resource_id| resource_id != resource.resource_id.trim()) + { + return Err(editor_background_removal_source_identity_conflict( + source_reference, + source_resource_id, + )); + } + return Ok(EditorBackgroundRemovalSourceMetadata { + model: resolve_editor_background_removal_resource_model(projects, resource, project_id), + asset_object_id: resource.asset_object_id.clone(), + asset_kind: editor_background_removal_resource_asset_kind(resource), + }); + } + + if let Some(asset) = explicit_asset { + if let Some(source_resource_id) = source_resource_id { + let asset_source_resource_id = asset + .source_resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let matching_resource = + find_editor_background_removal_resource(projects, None, source_resource_id); + let binding_matches = asset_source_resource_id == Some(source_resource_id) + && matching_resource.is_some_and(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + source_object_key, + ) && match ( + asset.asset_object_id.as_deref(), + resource.asset_object_id.as_deref(), + ) { + (Some(asset_object_id), Some(resource_object_id)) => { + asset_object_id == resource_object_id + } + _ => true, + } + }); + if !binding_matches { + return Err(editor_background_removal_source_identity_conflict( + source_reference, + Some(source_resource_id), + )); + } + } return Ok(EditorBackgroundRemovalSourceMetadata { model: sanitize_editor_user_model(asset.model.clone()).or_else(|| { asset.source_resource_id.as_deref().and_then(|resource_id| { - find_editor_background_removal_resource(projects, project_id, resource_id) - .and_then(|resource| { + find_editor_background_removal_resource(projects, None, resource_id).and_then( + |resource| { resolve_editor_background_removal_resource_model( projects, resource, project_id, ) - }) + }, + ) }) }), asset_object_id: asset.asset_object_id.clone(), @@ -12854,49 +12939,42 @@ fn resolve_editor_background_removal_source_metadata_from_records( }); } - let mut matching_resources = project_id - .and_then(|project_id| { - projects - .iter() - .find(|project| project.project_id.trim() == project_id) - }) - .map(|project| { - project - .resources - .iter() - .filter(|resource| { - editor_record_object_key_matches( - resource.object_key.as_deref(), - resource.image_src.as_str(), - source_object_key, - ) - }) - .collect::>() - }) - .unwrap_or_default(); - if matching_resources.is_empty() { - matching_resources = projects - .iter() - .flat_map(|project| project.resources.iter()) - .filter(|resource| { - editor_record_object_key_matches( - resource.object_key.as_deref(), - resource.image_src.as_str(), - source_object_key, - ) - }) - .collect(); + if let Some(source_resource_id) = source_resource_id { + let resource = find_editor_background_removal_resource(projects, None, source_resource_id) + .ok_or_else(|| editor_reference_image_forbidden(source_object_key))?; + if !editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + source_object_key, + ) { + return Err(editor_background_removal_source_identity_conflict( + source_reference, + Some(source_resource_id), + )); + } + return Ok(EditorBackgroundRemovalSourceMetadata { + model: resolve_editor_background_removal_resource_model(projects, resource, project_id), + asset_object_id: resource.asset_object_id.clone(), + asset_kind: editor_background_removal_resource_asset_kind(resource), + }); } + + let matching_resources = projects + .iter() + .flat_map(|project| project.resources.iter()) + .filter(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + source_object_key, + ) + }); let resource_metadata = - matching_resources - .into_iter() - .map(|resource| EditorBackgroundRemovalSourceMetadata { - model: resolve_editor_background_removal_resource_model( - projects, resource, project_id, - ), - asset_object_id: resource.asset_object_id.clone(), - asset_kind: editor_background_removal_resource_asset_kind(resource), - }); + matching_resources.map(|resource| EditorBackgroundRemovalSourceMetadata { + model: resolve_editor_background_removal_resource_model(projects, resource, project_id), + asset_object_id: resource.asset_object_id.clone(), + asset_kind: editor_background_removal_resource_asset_kind(resource), + }); let asset_metadata = assets .iter() .filter(|asset| { @@ -12926,6 +13004,19 @@ fn resolve_editor_background_removal_source_metadata_from_records( ) } +fn editor_background_removal_source_identity_conflict( + source_reference: &str, + source_resource_id: Option<&str>, +) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "sourceResourceId", + "sourceImageSrc": source_reference, + "sourceResourceId": source_resource_id, + "message": "sourceImageSrc 的显式资源身份与 sourceResourceId 冲突。", + })) +} + fn resolve_editor_background_removal_source_model_from_records( projects: &[EditorProjectRecord], assets: &[EditorAssetRecord], @@ -14328,9 +14419,9 @@ mod tests { "audio must not keep its former broad conflict classifier", ); assert!( - editor_source.contains("expected_target_resource_id") - && editor_source.contains("去背景目标图层的当前资源已变化"), - "background replacement retry must revalidate the original target resource", + editor_source.contains("expected_target") + && editor_source.contains("去背景目标图层的当前资源或媒体语义已变化"), + "background replacement retry must revalidate the original target identity and media semantics", ); } @@ -15196,6 +15287,44 @@ mod tests { assert_eq!(asset_metadata.asset_kind.as_deref(), Some("spec")); } + #[test] + fn background_removal_explicit_source_id_rejects_conflicting_source_resource_id() { + let source_key = "generated-character-drafts/editor/source-a.png"; + let other_key = "generated-character-drafts/editor/source-b.png"; + let projects = vec![test_editor_project_record( + "project-1", + vec![ + test_editor_project_resource_record( + "resource-a", + "project-1", + source_key, + Some("gpt-image-2"), + None, + ), + test_editor_project_resource_record( + "resource-b", + "project-1", + other_key, + Some("nanobanana2"), + None, + ), + ], + )]; + + let error = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + &[], + Some("project-1"), + Some("resource-b"), + "resource-a", + source_key, + ) + .expect_err("sourceImageSrc business ID must not inherit another resource's provenance"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("显式资源身份")); + } + #[test] fn background_removal_source_metadata_rejects_ambiguous_object_key() { let source_key = "generated-character-drafts/editor/shared.png"; @@ -15234,6 +15363,55 @@ mod tests { assert!(error.body_text().contains("sourceResourceId")); } + #[test] + fn background_removal_raw_object_key_detects_owner_wide_conflicts_across_projects() { + let source_key = "generated-character-drafts/editor/shared-across-projects.png"; + let mut target_project_resource = test_editor_project_resource_record( + "resource-character", + "project-target", + source_key, + Some("gpt-image-2"), + None, + ); + target_project_resource.asset_kind = Some("character".to_string()); + let mut other_project_resource = test_editor_project_resource_record( + "resource-spec", + "project-source", + source_key, + Some("nanobanana2"), + None, + ); + other_project_resource.asset_kind = Some("spec".to_string()); + let projects = vec![ + test_editor_project_record("project-target", vec![target_project_resource]), + test_editor_project_record("project-source", vec![other_project_resource]), + ]; + + let error = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + &[], + Some("project-target"), + None, + source_key, + source_key, + ) + .expect_err("output projectId must not hide conflicting owner-wide source registrations"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("sourceResourceId")); + + let selected = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + &[], + Some("project-target"), + Some("resource-spec"), + source_key, + source_key, + ) + .expect("an explicit sourceResourceId should disambiguate across output projects"); + assert_eq!(selected.asset_kind.as_deref(), Some("spec")); + assert_eq!(selected.model.as_deref(), Some("nanobanana2")); + } + #[test] fn background_removal_source_metadata_rejects_cross_table_identity_conflict() { let source_key = "generated-character-drafts/editor/shared-cross-table.png"; @@ -15268,7 +15446,7 @@ mod tests { } #[test] - fn background_removal_source_model_rejects_mismatched_project_resource() { + fn background_removal_source_model_allows_source_from_another_owned_project() { let projects = vec![ test_editor_project_record( "project-1", @@ -15292,7 +15470,7 @@ mod tests { ), ]; - let error = resolve_editor_background_removal_source_model_from_records( + let model = resolve_editor_background_removal_source_model_from_records( projects.as_slice(), &[], Some("project-1"), @@ -15300,9 +15478,9 @@ mod tests { "resource-2", "generated-character-drafts/editor/project-2.png", ) - .expect_err("project and resource must belong to the same source record"); + .expect("projectId is the output target and must not scope an owned source resource"); - assert_eq!(error.status_code(), StatusCode::FORBIDDEN); + assert_eq!(model.as_deref(), Some("gpt-image-2")); } #[test] @@ -21149,6 +21327,7 @@ mod tests { asset_object_id: Some("object-source".to_string()), resource_id: "resource-source".to_string(), asset_kind: Some("character".to_string()), + media_type: "image".to_string(), }; ensure_editor_background_removal_target_matches_source(&source, &target) .expect("same authoritative object should allow in-place replacement"); @@ -21215,26 +21394,72 @@ mod tests { } #[test] - fn editor_background_removal_revision_retry_rejects_changed_target_resource() { - let layers = json!([{ + fn editor_background_removal_revision_retry_rejects_changed_target_identity_or_semantics() { + let original_resource = + editor_project_resource_for_canvas_test("resource-original", "character", 320, 240); + let original_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-original" + }]); + let expected = resolve_editor_background_removal_canvas_target_source( + "bucket", + &original_layers, + std::slice::from_ref(&original_resource), + "layer-source", + ) + .expect("original target snapshot should resolve"); + + let replacement_resource = + editor_project_resource_for_canvas_test("resource-new", "character", 320, 240); + let changed_resource_layers = json!([{ "layerId": "layer-source", "resourceId": "resource-new" }]); let error = ensure_editor_canvas_background_removal_target_resource_matches( - &layers, + &changed_resource_layers, + std::slice::from_ref(&replacement_resource), "layer-source", - Some("resource-original"), + Some(&expected), ) .expect_err("revision retry must not replace a changed target resource"); assert_eq!(error.status_code(), StatusCode::CONFLICT); - assert!(error.body_text().contains("当前资源已变化")); + assert!(error.body_text().contains("媒体语义已变化")); ensure_editor_canvas_background_removal_target_resource_matches( - &layers, + &original_layers, + std::slice::from_ref(&original_resource), "layer-source", - Some("resource-new"), + Some(&expected), ) .expect("unchanged target resource should remain replaceable"); + + let changed_override_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-original", + "assetKindOverride": "spec" + }]); + let error = ensure_editor_canvas_background_removal_target_resource_matches( + &changed_override_layers, + std::slice::from_ref(&original_resource), + "layer-source", + Some(&expected), + ) + .expect_err("revision retry must freeze the effective assetKindOverride"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + + let changed_media_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-original", + "mediaType": "video" + }]); + let error = ensure_editor_canvas_background_removal_target_resource_matches( + &changed_media_layers, + std::slice::from_ref(&original_resource), + "layer-source", + Some(&expected), + ) + .expect_err("revision retry must freeze the target mediaType"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); } #[test] diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index cc238e1d4..0a15d26ef 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -858,6 +858,14 @@ pub async fn remove_external_editor_image_background( let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?; let payload = EditorBackgroundRemovalRequest::from(payload); let project_id = payload.project_id.clone(); + #[cfg(test)] + if let Some(job) = state.intercept_test_external_background_removal_enqueue( + principal.owner_user_id(), + payload.source_image_src.as_str(), + idempotency_key, + ) { + return Ok(external_generation_accepted_response(&request_context, job)); + } let job = enqueue_editor_background_removal_for_owner( &state, &request_context, @@ -2063,6 +2071,107 @@ mod tests { ); } + #[tokio::test] + async fn external_background_removal_route_accepts_a_valid_submission_once() { + const OWNER_USER_ID: &str = "user-external-background-removal-success"; + const SOURCE_IMAGE_SRC: &str = "editor-upload/background-removal-source.png"; + const IDEMPOTENCY_KEY: &str = "background-removal-success-contract-test"; + const OPERATION_ID: &str = "task-external-background-removal-success"; + + let state = AppState::new(crate::config::AppConfig::default()) + .expect("external background removal success test state should build"); + let mut queued_job = external_generation_job_fixture("pending"); + queued_job.job_id = OPERATION_ID.to_string(); + queued_job.job_kind = "editor_background_removal".to_string(); + queued_job.owner_user_id = OWNER_USER_ID.to_string(); + state.set_test_external_background_removal_enqueue( + OWNER_USER_ID, + SOURCE_IMAGE_SRC, + IDEMPOTENCY_KEY, + queued_job, + ); + let request_body = json!({"sourceImageSrc": SOURCE_IMAGE_SRC}).to_string(); + + let without_scope = Router::new() + .route( + "/api/external/v1/editor/images/background-removals", + post(remove_external_editor_image_background), + ) + .layer(Extension(request_context(false))) + .layer(Extension(ExternalApiPrincipal::for_test( + OWNER_USER_ID, + &[], + ))) + .with_state(state.clone()); + let forbidden = without_scope + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/api/external/v1/editor/images/background-removals") + .header("content-type", "application/json") + .header(IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_KEY) + .body(Body::from(request_body.clone())) + .expect("external background removal forbidden request should build"), + ) + .await + .expect("external background removal forbidden response should return"); + assert_eq!(forbidden.status(), StatusCode::FORBIDDEN); + assert_eq!(state.test_editor_generation_enqueue_attempts(), 0); + + let app = Router::new() + .route( + "/api/external/v1/editor/images/background-removals", + post(remove_external_editor_image_background), + ) + .layer(Extension(request_context(false))) + .layer(Extension(ExternalApiPrincipal::for_test( + OWNER_USER_ID, + &[SCOPE_EDITOR_IMAGE_GENERATE], + ))) + .with_state(state.clone()); + let response = app + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/api/external/v1/editor/images/background-removals") + .header("content-type", "application/json") + .header(IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_KEY) + .body(Body::from(request_body)) + .expect("external background removal success request should build"), + ) + .await + .expect("external background removal success response should return"); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()), + Some("/api/external/v1/generations/task-external-background-removal-success") + ); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("external background removal success body should collect"); + let payload: Value = serde_json::from_slice(&body) + .expect("external background removal success body should be JSON"); + assert_eq!(payload["operationId"], json!(OPERATION_ID)); + assert_eq!(payload["status"], json!("queued")); + assert_eq!( + payload["statusUrl"], + json!(format!("/api/external/v1/generations/{OPERATION_ID}")) + ); + assert_eq!( + payload["pollAfterMs"], + json!(EXTERNAL_GENERATION_POLL_AFTER_MS) + ); + assert_eq!( + state.test_editor_generation_enqueue_attempts(), + 1, + "valid External background removal should enqueue exactly once", + ); + } + #[tokio::test] async fn external_generic_image_generation_rejects_scene_asset_kind_before_queueing() { assert_external_generic_image_scene_bypass_is_rejected_before_queueing( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 12ee233b7..501182c91 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -27,6 +27,8 @@ use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider, OpenAiChatTokenB use platform_matting::{MattingClient, MattingConfig}; use platform_oss::{OssClient, OssConfig, OssError}; use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient}; +#[cfg(test)] +use spacetime_client::ExternalGenerationJobRecord; use spacetime_client::{ EditorGenerationModelPricingRecord, EditorGenerationPricingConfigRecord, EditorGenerationPricingConfigUpsertRecordInput, EditorGenerationPricingTierRecord, @@ -128,6 +130,15 @@ impl BackpressureState { #[derive(Clone)] pub struct AppState(Arc); +#[cfg(test)] +#[derive(Clone)] +struct TestExternalBackgroundRemovalEnqueue { + expected_owner_user_id: String, + expected_source_image_src: String, + expected_idempotency_key: String, + job: ExternalGenerationJobRecord, +} + impl fmt::Debug for AppState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("AppState").field(self.0.as_ref()).finish() @@ -256,6 +267,9 @@ pub struct AppStateInner { test_editor_generation_enqueue_attempts: AtomicUsize, #[cfg(test)] test_fail_editor_generation_enqueue: AtomicBool, + #[cfg(test)] + test_external_background_removal_enqueue: + Arc>>, oss_client: Option, #[cfg_attr(test, allow(dead_code))] auth_store: InMemoryAuthStore, @@ -611,6 +625,8 @@ impl AppState { test_editor_generation_enqueue_attempts: AtomicUsize::new(0), #[cfg(test)] test_fail_editor_generation_enqueue: AtomicBool::new(false), + #[cfg(test)] + test_external_background_removal_enqueue: Arc::new(Mutex::new(None)), oss_client, auth_store, password_entry_service, @@ -810,6 +826,46 @@ impl AppState { .load(Ordering::Acquire) } + #[cfg(test)] + pub(crate) fn set_test_external_background_removal_enqueue( + &self, + expected_owner_user_id: impl Into, + expected_source_image_src: impl Into, + expected_idempotency_key: impl Into, + job: ExternalGenerationJobRecord, + ) { + *self + .test_external_background_removal_enqueue + .lock() + .expect("test external background removal enqueue should lock") = + Some(TestExternalBackgroundRemovalEnqueue { + expected_owner_user_id: expected_owner_user_id.into(), + expected_source_image_src: expected_source_image_src.into(), + expected_idempotency_key: expected_idempotency_key.into(), + job, + }); + } + + #[cfg(test)] + pub(crate) fn intercept_test_external_background_removal_enqueue( + &self, + owner_user_id: &str, + source_image_src: &str, + idempotency_key: &str, + ) -> Option { + let fixture = self + .test_external_background_removal_enqueue + .lock() + .expect("test external background removal enqueue should lock") + .clone()?; + assert_eq!(owner_user_id, fixture.expected_owner_user_id); + assert_eq!(source_image_src, fixture.expected_source_image_src); + assert_eq!(idempotency_key, fixture.expected_idempotency_key); + self.test_editor_generation_enqueue_attempts + .fetch_add(1, Ordering::AcqRel); + Some(fixture.job) + } + #[cfg(any())] pub async fn upsert_creation_entry_type_config( &self,