修复 External 去背景重放与画布竞态
Project CI / Repository checks (pull_request) Successful in 3m36s
Project CI / Frontend tests (pull_request) Successful in 3m59s
Project CI / Backend tests (pull_request) Successful in 5m27s
Project CI / Native shell tests (pull_request) Successful in 14m56s

按原始请求指纹在可变预检前返回幂等任务
修正来源身份优先级与跨项目歧义校验
冻结并复验原位替换目标的完整媒体语义
修复 Python helper 原位替换请求并补正向路由测试
This commit is contained in:
2026-08-24 14:11:34 +08:00
parent 015591594e
commit d39a211154
7 changed files with 921 additions and 159 deletions
@@ -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(
+56
View File
@@ -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,