修复 External 去背景重放与画布竞态
按原始请求指纹在可变预检前返回幂等任务 修正来源身份优先级与跨项目歧义校验 冻结并复验原位替换目标的完整媒体语义 修复 Python helper 原位替换请求并补正向路由测试
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<T>(
|
||||
owner_user_id: &str,
|
||||
job_kind: &str,
|
||||
payload: &T,
|
||||
idempotency_key: &str,
|
||||
) -> Result<ExternalApiEditorGenerationRequestIdentity, AppError>
|
||||
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::<Vec<_>>();
|
||||
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<Option<ExternalGenerationJobRecord>, 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<T>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
job_kind: &str,
|
||||
source_entity_id: impl Into<String>,
|
||||
request_label: impl Into<String>,
|
||||
price_mud_points: u64,
|
||||
payload: &T,
|
||||
identity: &ExternalApiEditorGenerationRequestIdentity,
|
||||
) -> Result<ExternalGenerationJobRecord, AppError>
|
||||
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<ExternalGenerationJobRecord, AppError> {
|
||||
let persisted_fingerprint = serde_json::from_str::<Value>(&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<T>(
|
||||
payload: &T,
|
||||
identity: &ExternalApiEditorGenerationRequestIdentity,
|
||||
) -> Result<String, AppError>
|
||||
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<T>(
|
||||
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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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<AppStateInner>);
|
||||
|
||||
#[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<Mutex<Option<TestExternalBackgroundRemovalEnqueue>>>,
|
||||
oss_client: Option<OssClient>,
|
||||
#[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<String>,
|
||||
expected_source_image_src: impl Into<String>,
|
||||
expected_idempotency_key: impl Into<String>,
|
||||
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<ExternalGenerationJobRecord> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user