use axum::http::StatusCode; use serde::Serialize; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use shared_contracts::external_generation::{ ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, }; use shared_kernel::{build_prefixed_uuid_id, offset_datetime_to_unix_micros}; use spacetime_client::{ ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, }; use crate::{http_error::AppError, request_context::RequestContext, state::AppState}; pub(crate) const EDITOR_IMAGE_GENERATION_JOB_KIND: &str = "editor_image_generation"; pub(crate) const EDITOR_ICON_SPEC_GENERATION_JOB_KIND: &str = "editor_icon_spec_generation"; pub(crate) const EDITOR_IMAGE_EDIT_JOB_KIND: &str = "editor_image_edit"; pub(crate) const EDITOR_BACKGROUND_REMOVAL_JOB_KIND: &str = "editor_background_removal"; pub(crate) const EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND: &str = "editor_icon_spritesheet_generation"; pub(crate) const EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND: &str = "editor_ui_design_asset_extraction"; pub(crate) const EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND: &str = "editor_character_animation_generation"; pub(crate) const EDITOR_VIDEO_GENERATION_JOB_KIND: &str = "editor_video_generation"; pub(crate) const EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND: &str = "editor_sound_effect_generation"; pub(crate) const EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND: &str = "editor_background_music_generation"; pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas"; 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"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EditorGenerationQueuedResponse { pub(crate) queue_state: ExternalGenerationJobStatusRecord, } pub(crate) async fn enqueue_editor_generation_job( state: &AppState, request_context: &RequestContext, owner_user_id: &str, job_kind: &str, source_entity_id: impl Into, request_label: impl Into, price_mud_points: u64, payload: &T, ) -> Result where T: Serialize, { let job_id = build_prefixed_uuid_id("task-"); let dedupe_key = build_editor_generation_dedupe_key( EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX, owner_user_id, job_kind, request_context.request_id().trim(), ); enqueue_editor_generation_job_with_identity( state, owner_user_id, job_kind, source_entity_id, request_label, price_mud_points, payload, job_id.clone(), dedupe_key, ) .await } fn build_editor_generation_dedupe_key( namespace: &str, owner_user_id: &str, job_kind: &str, stable_key: &str, ) -> String { let mut hasher = Sha256::new(); hasher.update(owner_user_id.trim().as_bytes()); hasher.update(b"\0"); hasher.update(job_kind.trim().as_bytes()); hasher.update(b"\0"); hasher.update(stable_key.as_bytes()); 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, owner_user_id: &str, job_kind: &str, source_entity_id: impl Into, request_label: impl Into, price_mud_points: u64, payload: &T, job_id: String, dedupe_key: String, ) -> Result where T: Serialize, { let request_payload_json = serialize_editor_generation_job_payload(payload)?; 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.clone(), job_id, dedupe_key, ) .await?; ensure_editor_generation_job_matches_request( job, owner_user_id, job_kind, request_payload_json.as_str(), "请求幂等键已用于不同的生成请求,请复用原请求参数或更换请求标识。", ) } #[allow(clippy::too_many_arguments)] pub(crate) async fn enqueue_external_api_editor_generation_job( state: &AppState, owner_user_id: &str, job_kind: &str, source_entity_id: impl Into, request_label: impl Into, price_mud_points: u64, payload: &T, idempotency_key: &str, ) -> Result where T: Serialize, { let request_payload_json = serialize_editor_generation_job_payload(payload)?; let dedupe_key = build_editor_generation_dedupe_key( EXTERNAL_API_GENERATION_DEDUPE_PREFIX, owner_user_id, job_kind, idempotency_key, ); let requested_job_id = build_prefixed_uuid_id("task-"); 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.clone(), requested_job_id.clone(), dedupe_key, ) .await?; ensure_external_api_generation_job_matches_request( job, owner_user_id, job_kind, request_payload_json.as_str(), ) } fn ensure_external_api_generation_job_matches_request( job: ExternalGenerationJobRecord, owner_user_id: &str, job_kind: &str, request_payload_json: &str, ) -> Result { ensure_editor_generation_job_matches_request( job, owner_user_id, job_kind, request_payload_json, "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", ) } fn ensure_editor_generation_job_matches_request( job: ExternalGenerationJobRecord, owner_user_id: &str, job_kind: &str, request_payload_json: &str, conflict_message: &str, ) -> Result { if job.job_kind != job_kind || job.owner_user_id != owner_user_id || !editor_generation_request_payloads_match( job_kind, job.request_payload_json.as_str(), request_payload_json, ) { return Err( AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": EDITOR_GENERATION_QUEUE_PROVIDER, "message": conflict_message, })), ); } Ok(job) } fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -> bool { let Some(generation_inputs) = value .as_object_mut() .and_then(|payload| payload.get_mut("generationInputs")) .and_then(Value::as_object_mut) else { return false; }; generation_inputs.remove("references").is_some() } fn generation_input_references(value: &Value) -> Option<&Value> { value.pointer("/generationInputs/references") } fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool { matches!( job_kind, EDITOR_IMAGE_GENERATION_JOB_KIND | EDITOR_IMAGE_EDIT_JOB_KIND | EDITOR_BACKGROUND_REMOVAL_JOB_KIND | EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND | EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND ) } fn editor_generation_request_payloads_match( job_kind: &str, existing: &str, requested: &str, ) -> bool { if existing == requested { return true; } let (Ok(mut existing), Ok(mut requested)) = ( serde_json::from_str::(existing), serde_json::from_str::(requested), ) else { return false; }; // 这些站内 job 的真实来源始终由 sourceImageSrc / referenceImageSrcs 等请求字段表达, // generationInputs.references 只携带随后由 owner-scoped 记录重建的展示槽位。滚动部署 // 期间旧任务可能没有 references、新任务可能只含安全 id;比较幂等请求时两边都移除 // 该冗余字段,实际媒体来源或其它参数的任何变化仍会冲突。视频等未迁移 job 保持严格比较。 if !job_kind_migrated_away_from_client_generation_references(job_kind) { return false; } if generation_input_references(&existing).is_some() == generation_input_references(&requested).is_some() { return false; } strip_untrusted_generation_input_references_from_payload(&mut existing); strip_untrusted_generation_input_references_from_payload(&mut requested); existing == requested } #[allow(clippy::too_many_arguments)] pub(crate) async fn enqueue_editor_generation_job_for_caller( state: &AppState, request_context: &RequestContext, owner_user_id: &str, job_kind: &str, source_entity_id: impl Into, request_label: impl Into, price_mud_points: u64, payload: &T, external_idempotency_key: Option<&str>, ) -> Result where T: Serialize, { let source_entity_id = source_entity_id.into(); let request_label = request_label.into(); match external_idempotency_key { Some(idempotency_key) => { let dedupe_namespace = editor_generation_idempotency_namespace(payload); enqueue_idempotent_editor_generation_job( state, owner_user_id, job_kind, source_entity_id, request_label, price_mud_points, payload, idempotency_key, dedupe_namespace, ) .await } None => { enqueue_editor_generation_job( state, request_context, owner_user_id, job_kind, source_entity_id, request_label, price_mud_points, payload, ) .await } } } fn editor_generation_idempotency_namespace(payload: &T) -> &'static str where T: Serialize, { let source = serde_json::to_value(payload) .ok() .and_then(|payload| editor_generation_client_source(&payload).map(str::to_string)); if source.as_deref() == Some(GAME_CREATOR_CLIENT_GENERATION_SOURCE) { GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX } else { EXTERNAL_API_GENERATION_DEDUPE_PREFIX } } pub(crate) fn editor_generation_client_source(payload: &Value) -> Option<&str> { [ "/generationInputs/source", "/request/generationInputs/source", ] .into_iter() .find_map(|pointer| { payload .pointer(pointer) .and_then(Value::as_str) .map(str::trim) .filter(|source| !source.is_empty()) }) } #[allow(clippy::too_many_arguments)] async fn enqueue_idempotent_editor_generation_job( state: &AppState, owner_user_id: &str, job_kind: &str, source_entity_id: impl Into, request_label: impl Into, price_mud_points: u64, payload: &T, idempotency_key: &str, dedupe_namespace: &str, ) -> Result where T: Serialize, { if dedupe_namespace == EXTERNAL_API_GENERATION_DEDUPE_PREFIX { return enqueue_external_api_editor_generation_job( state, owner_user_id, job_kind, source_entity_id, request_label, price_mud_points, payload, idempotency_key, ) .await; } let request_payload_json = serialize_editor_generation_job_payload(payload)?; let dedupe_key = build_editor_generation_dedupe_key( dedupe_namespace, owner_user_id, job_kind, idempotency_key, ); 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.clone(), build_prefixed_uuid_id("task-"), dedupe_key, ) .await?; ensure_editor_generation_job_matches_request( job, owner_user_id, job_kind, request_payload_json.as_str(), "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", ) } #[allow(clippy::too_many_arguments)] async fn enqueue_serialized_editor_generation_job_with_identity( state: &AppState, owner_user_id: &str, job_kind: &str, source_entity_id: impl Into, request_label: impl Into, price_mud_points: u64, request_payload_json: String, job_id: String, dedupe_key: String, ) -> Result { #[cfg(test)] if state.record_test_editor_generation_enqueue_attempt() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": EDITOR_GENERATION_QUEUE_PROVIDER, "message": "测试已在 SpacetimeDB 写入前截获编辑器生成入队", })), ); } let now_micros = current_utc_micros(); state .spacetime_client() .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { dedupe_key, job_id, job_kind: job_kind.to_string(), owner_user_id: owner_user_id.to_string(), source_module: EDITOR_GENERATION_QUEUE_SOURCE_MODULE.to_string(), source_entity_id: source_entity_id.into(), request_label: request_label.into(), request_payload_json, max_attempts: 1, available_at_micros: now_micros, created_at_micros: now_micros, price_mud_points, }) .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": EDITOR_GENERATION_QUEUE_PROVIDER, "message": error.to_string(), })) }) } fn serialize_editor_generation_job_payload(payload: &T) -> Result where T: Serialize + ?Sized, { let payload_value = serde_json::to_value(payload).map_err(payload_serialization_error)?; if contains_inline_media_reference(&payload_value) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": EDITOR_GENERATION_QUEUE_PROVIDER, "message": "编辑器生成任务参数禁止包含 data: 或 blob: 内联媒体引用,请先将媒体上传到对象存储并改传 objectKey 或 resourceId。", })), ); } let request_payload_json = serde_json::to_string(&payload_value).map_err(payload_serialization_error)?; let payload_bytes = request_payload_json.len(); if payload_bytes > MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES { return Err( AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ "provider": EDITOR_GENERATION_QUEUE_PROVIDER, "message": format!( "编辑器生成任务 JSON 大小为 {payload_bytes} 字节,超过持久化上限 {MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES} 字节;请移除冗余数据并改传 objectKey 或 resourceId。" ), "actualBytes": payload_bytes, "maxBytes": MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES, })), ); } Ok(request_payload_json) } #[cfg(test)] pub(crate) fn serialize_editor_generation_job_payload_for_test( payload: &T, ) -> Result where T: Serialize + ?Sized, { serialize_editor_generation_job_payload(payload) } fn payload_serialization_error(error: serde_json::Error) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": EDITOR_GENERATION_QUEUE_PROVIDER, "message": format!("编辑器 worker 任务参数序列化失败:{error}"), })) } fn contains_inline_media_reference(value: &Value) -> bool { match value { Value::String(value) => is_inline_media_reference(value), Value::Array(values) => values.iter().any(contains_inline_media_reference), Value::Object(values) => values.iter().any(|(key, value)| { is_inline_media_reference(key) || contains_inline_media_reference(value) }), Value::Null | Value::Bool(_) | Value::Number(_) => false, } } fn is_inline_media_reference(value: &str) -> bool { let prefix = value.trim_start().as_bytes().get(..5); prefix.is_some_and(|prefix| { prefix.eq_ignore_ascii_case(b"data:") || prefix.eq_ignore_ascii_case(b"blob:") }) } pub(crate) fn editor_generation_queue_state( job: ExternalGenerationJobRecord, ) -> ExternalGenerationJobStatusRecord { let (status, phase_detail, progress) = match job.status.as_str() { "completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100), "running" if job.phase.as_deref() == Some("processing") => { (ExternalGenerationJobStatus::Running, "正在处理。", 70) } "running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35), "failed" | "cancelled" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0), _ => (ExternalGenerationJobStatus::Queued, "排队中。", 8), }; ExternalGenerationJobStatusRecord { operation_id: job.job_id, status, phase_label: job.request_label, phase_detail: phase_detail.to_string(), progress, error: job.last_error_message, updated_at_micros: job.updated_at_micros, } } pub(crate) fn editor_generation_source_entity_id( project_id: Option<&str>, fallback: &str, ) -> String { project_id .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(fallback) .to_string() } fn current_utc_micros() -> i64 { offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc()) } #[cfg(test)] mod tests { use super::*; fn queue_job_fixture(status: &str, phase: Option<&str>) -> ExternalGenerationJobRecord { ExternalGenerationJobRecord { job_id: "task-queue-test".to_string(), dedupe_key: "editor-canvas:test:task-queue-test".to_string(), job_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(), owner_user_id: "user-1".to_string(), source_module: EDITOR_GENERATION_QUEUE_SOURCE_MODULE.to_string(), source_entity_id: "project-1".to_string(), request_label: "图片画布生成图片".to_string(), request_payload_json: "{}".to_string(), status: status.to_string(), attempt: 0, max_attempts: 1, last_error_message: None, worker_id: None, lease_expires_at: None, available_at: "2026-07-31T00:00:00Z".to_string(), result_payload_json: None, created_at: "2026-07-31T00:00:00Z".to_string(), started_at: None, completed_at: None, updated_at: "2026-07-31T00:00:00Z".to_string(), updated_at_micros: 1_785_456_000_000_000, lease_token: None, price_mud_points: 2, refund_ledger_id: None, notification_acknowledged_at: None, notification_acknowledged_at_micros: None, phase: phase.map(ToOwned::to_owned), } } #[test] fn editor_api_request_dedupe_key_is_stable_and_namespaced() { let first = build_editor_generation_dedupe_key( EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX, "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, "request-1", ); let replay = build_editor_generation_dedupe_key( EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX, " user-1 ", EDITOR_IMAGE_GENERATION_JOB_KIND, "request-1", ); let other_owner = build_editor_generation_dedupe_key( EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX, "user-2", EDITOR_IMAGE_GENERATION_JOB_KIND, "request-1", ); let external = build_editor_generation_dedupe_key( EXTERNAL_API_GENERATION_DEDUPE_PREFIX, "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, "request-1", ); assert_eq!(first, replay); assert_ne!(first, other_owner); assert_ne!(first, external); assert!(first.starts_with("editor-api-request-generation:editor_image_generation:")); } #[test] fn game_creator_client_uses_its_own_stable_idempotency_namespace() { let game_creator = json!({ "prompt": "生成主角", "generationInputs": { "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, }, }); let ordinary = json!({ "prompt": "生成主角", "generationInputs": { "source": "editor-web", }, }); assert_eq!( editor_generation_idempotency_namespace(&game_creator), GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX ); assert_eq!( editor_generation_idempotency_namespace(&ordinary), EXTERNAL_API_GENERATION_DEDUPE_PREFIX ); let game_creator_key = build_editor_generation_dedupe_key( editor_generation_idempotency_namespace(&game_creator), "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, "stable-key-1", ); let external_key = build_editor_generation_dedupe_key( editor_generation_idempotency_namespace(&ordinary), "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, "stable-key-1", ); assert!( game_creator_key.starts_with("game-creator-client-generation:editor_image_generation:") ); assert_ne!(game_creator_key, external_key); let wrapped_edit = json!({ "version": 2, "request": { "generationInputs": { "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, }, }, }); assert_eq!( editor_generation_idempotency_namespace(&wrapped_edit), GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX ); } #[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( EXTERNAL_API_GENERATION_DEDUPE_PREFIX, " user-1 ", EDITOR_IMAGE_GENERATION_JOB_KIND, " request-1 ", ); assert_eq!( dedupe_key, "external-api-generation:editor_image_generation:81e38a8eace5f098041b3c240f99053c555ccfa18139295576c9b4ba3d9acff6" ); } #[test] fn replayed_editor_generation_job_must_match_original_request() { let mut job = queue_job_fixture("queued", None); job.owner_user_id = "user-1".to_string(); job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(); job.request_payload_json = r#"{"prompt":"same"}"#.to_string(); assert!( ensure_editor_generation_job_matches_request( job.clone(), "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, r#"{"prompt":"same"}"#, "幂等冲突", ) .is_ok() ); let error = ensure_editor_generation_job_matches_request( job, "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, r#"{"prompt":"changed"}"#, "幂等冲突", ) .expect_err("same request id must reject a different payload"); assert_eq!(error.status_code(), StatusCode::CONFLICT); } #[test] fn replayed_legacy_external_job_ignores_only_removed_client_references() { let mut job = queue_job_fixture("queued", None); job.owner_user_id = "user-1".to_string(); job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(); job.request_payload_json = serde_json::to_string(&json!({ "prompt": "same", "generationInputs": { "fields": [{"title": "提示词", "value": "same"}], "references": [{ "title": "旧客户端引用", "refType": "asset", "refId": "asset-forged" }] } })) .expect("legacy payload should serialize"); let requested = serde_json::to_string(&json!({ "prompt": "same", "generationInputs": { "fields": [{"title": "提示词", "value": "same"}] } })) .expect("current payload should serialize"); assert!( ensure_editor_generation_job_matches_request( job.clone(), "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, requested.as_str(), "幂等冲突", ) .is_ok() ); let changed = requested.replace("same", "changed"); let error = ensure_editor_generation_job_matches_request( job, "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, changed.as_str(), "幂等冲突", ) .expect_err("non-reference payload changes must still conflict"); assert_eq!(error.status_code(), StatusCode::CONFLICT); } #[test] fn replayed_migrated_job_accepts_new_safe_reference_slots() { let mut job = queue_job_fixture("queued", None); job.owner_user_id = "user-1".to_string(); job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(); job.request_payload_json = serde_json::to_string(&json!({ "prompt": "same", "referenceImageSrcs": ["asset-1"], "generationInputs": { "version": 2, "action": "image.generate", "fields": [{"id": "prompt", "title": "提示词", "value": "same"}] } })) .expect("existing payload should serialize"); let requested = serde_json::to_string(&json!({ "prompt": "same", "referenceImageSrcs": ["asset-1"], "generationInputs": { "version": 2, "action": "image.generate", "fields": [{"id": "prompt", "title": "提示词", "value": "same"}], "references": [{"id": "reference"}] } })) .expect("requested payload should serialize"); assert!( ensure_editor_generation_job_matches_request( job, "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, requested.as_str(), "幂等冲突", ) .is_ok() ); } #[test] fn replayed_migrated_jobs_with_different_safe_slots_conflict() { let mut job = queue_job_fixture("queued", None); job.owner_user_id = "user-1".to_string(); job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(); job.request_payload_json = serde_json::to_string(&json!({ "prompt": "same", "referenceImageSrcs": ["asset-1"], "generationInputs": { "version": 2, "action": "image.generate", "fields": [], "references": [{"id": "source"}] } })) .expect("existing payload should serialize"); let requested = serde_json::to_string(&json!({ "prompt": "same", "referenceImageSrcs": ["asset-1"], "generationInputs": { "version": 2, "action": "image.generate", "fields": [], "references": [{"id": "specReference"}] } })) .expect("requested payload should serialize"); let error = ensure_editor_generation_job_matches_request( job, "user-1", EDITOR_IMAGE_GENERATION_JOB_KIND, requested.as_str(), "幂等冲突", ) .expect_err("different migrated safe slots must conflict"); assert_eq!(error.status_code(), StatusCode::CONFLICT); } #[test] fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() { let mut job = queue_job_fixture("queued", None); job.owner_user_id = "user-1".to_string(); job.job_kind = "editor_video_generation".to_string(); job.request_payload_json = serde_json::to_string(&json!({ "prompt": "same", "generationInputs": { "references": [{"refType": "asset", "refId": "asset-1"}] } })) .expect("existing video payload should serialize"); let requested = serde_json::to_string(&json!({ "prompt": "same", "generationInputs": { "references": [{"refType": "asset", "refId": "asset-2"}] } })) .expect("requested video payload should serialize"); let error = ensure_editor_generation_job_matches_request( job, "user-1", "editor_video_generation", requested.as_str(), "幂等冲突", ) .expect_err("different retained references must conflict"); assert_eq!(error.status_code(), StatusCode::CONFLICT); } #[test] fn replayed_video_job_does_not_use_image_reference_migration_compatibility() { let mut job = queue_job_fixture("queued", None); job.owner_user_id = "user-1".to_string(); job.job_kind = EDITOR_VIDEO_GENERATION_JOB_KIND.to_string(); job.request_payload_json = serde_json::to_string(&json!({ "prompt": "same", "generationInputs": { "references": [{"refType": "asset", "refId": "asset-1"}] } })) .expect("existing video payload should serialize"); let requested = serde_json::to_string(&json!({ "prompt": "same", "generationInputs": {} })) .expect("requested video payload should serialize"); let error = ensure_editor_generation_job_matches_request( job, "user-1", EDITOR_VIDEO_GENERATION_JOB_KIND, requested.as_str(), "幂等冲突", ) .expect_err("video payloads did not migrate away from references"); assert_eq!(error.status_code(), StatusCode::CONFLICT); } #[test] fn serialize_payload_accepts_persistable_media_references() { let payload = json!({ "sourceImageObjectKey": "users/user-1/editor/source.png", "resourceId": "resource-1", "nested": [{ "prompt": "保留 data 与 blob 这两个普通单词" }], }); let serialized = serialize_editor_generation_job_payload(&payload) .expect("objectKey 和 resourceId 应允许进入持久任务 JSON"); assert_eq!( serde_json::from_str::(&serialized).expect("应生成有效 JSON"), payload ); } #[test] fn serialize_payload_rejects_nested_data_url_case_insensitively() { let payload = json!({ "input": { "references": [ { "url": " \nDaTa:image/png;base64,AAAA" } ] } }); let error = serialize_editor_generation_job_payload(&payload) .expect_err("任意层级的 Data URL 都必须被拒绝"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("禁止包含 data: 或 blob:")); } #[test] fn serialize_payload_rejects_nested_blob_url_case_insensitively() { let payload = json!({ "input": [{ "source": { "url": "\tBLOB:https://example.test/id" } }] }); let error = serialize_editor_generation_job_payload(&payload) .expect_err("任意层级的 Blob URL 都必须被拒绝"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("禁止包含 data: 或 blob:")); } #[test] fn serialize_payload_rejects_json_larger_than_persistence_limit() { let payload = json!({ "prompt": "x".repeat(MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES), }); let error = serialize_editor_generation_job_payload(&payload) .expect_err("超过上限的任务 JSON 必须被拒绝"); assert_eq!(error.status_code().as_u16(), 413); assert!(error.body_text().contains("超过持久化上限")); } #[test] fn external_background_music_idempotency_matches_the_raw_payload_exactly() { let raw_prompt = "\u{0085}\u{2003}森林音乐\u{00a0}"; let raw_payload = shared_contracts::assets::EditorBackgroundMusicGenerateRequest { gpt_description_prompt: raw_prompt.to_string(), make_instrumental: false, project_id: Some("project-1".to_string()), canvas_completion: None, generation_inputs: None, asset_folder_id: None, asset_label: None, }; let raw_payload_json = serialize_editor_generation_job_payload(&raw_payload) .expect("External BGM 原始 payload 应可序列化"); let mut persisted_job = queue_job_fixture("pending", None); persisted_job.job_kind = EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND.to_string(); persisted_job.request_payload_json = raw_payload_json.clone(); ensure_external_api_generation_job_matches_request( persisted_job.clone(), "user-1", EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, &raw_payload_json, ) .expect("同一原始 payload 的幂等重放应复用既有任务"); let canonical_payload = shared_contracts::assets::EditorBackgroundMusicGenerateRequest { gpt_description_prompt: "森林音乐".to_string(), ..raw_payload }; let canonical_payload_json = serialize_editor_generation_job_payload(&canonical_payload) .expect("canonical 等价值应可序列化"); let error = ensure_external_api_generation_job_matches_request( persisted_job, "user-1", EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, &canonical_payload_json, ) .expect_err("同一幂等键改用 canonical 等价值必须按既有精确语义冲突"); assert_eq!(error.status_code(), StatusCode::CONFLICT); assert!( error .body_text() .contains("Idempotency-Key 已用于不同的生成请求") ); } #[test] fn queue_state_maps_idempotent_replays_to_the_persisted_status() { let cases = [ ( "pending", None, ExternalGenerationJobStatus::Queued, "排队中。", 8, ), ( "running", Some("generating"), ExternalGenerationJobStatus::Running, "正在生成。", 35, ), ( "running", Some("processing"), ExternalGenerationJobStatus::Running, "正在处理。", 70, ), ( "completed", None, ExternalGenerationJobStatus::Completed, "生成已完成。", 100, ), ( "failed", None, ExternalGenerationJobStatus::Failed, "生成失败。", 0, ), ]; for (persisted_status, phase, expected_status, expected_detail, expected_progress) in cases { let state = editor_generation_queue_state(queue_job_fixture(persisted_status, phase)); assert_eq!(state.operation_id, "task-queue-test"); assert_eq!(state.status, expected_status, "status={persisted_status}"); assert_eq!( state.phase_detail, expected_detail, "status={persisted_status}" ); assert_eq!( state.progress, expected_progress, "status={persisted_status}" ); } } #[test] fn queue_state_keeps_failed_error_for_idempotent_replay() { let mut job = queue_job_fixture("failed", None); job.last_error_message = Some("生成失败摘要".to_string()); let state = editor_generation_queue_state(job); assert_eq!(state.status, ExternalGenerationJobStatus::Failed); assert_eq!(state.error.as_deref(), Some("生成失败摘要")); } }