Files
Genarrative/server-rs/crates/api-server/src/editor_generation_queue.rs
T
suzmii aa8e3507d1
Project CI / Repository checks (push) Successful in 4m20s
Project CI / Frontend tests (push) Successful in 4m31s
Project CI / Backend tests (push) Successful in 5m45s
Project CI / Native shell tests (push) Successful in 14m53s
新增 External v1 去背景生成链路 (#184)
新增外部去背景 API、MCP 工具与异步队列契约

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

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

同步 Python helper、Skill、OpenAPI 及项目文档

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/184
Co-authored-by: suzmii <suzmii@foxmail.com>
Co-committed-by: suzmii <suzmii@foxmail.com>
2026-08-24 14:37:32 +08:00

1427 lines
49 KiB
Rust

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<T>(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
request_label: impl Into<String>,
price_mud_points: u64,
payload: &T,
) -> Result<ExternalGenerationJobRecord, AppError>
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<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,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
request_label: impl Into<String>,
price_mud_points: u64,
payload: &T,
job_id: String,
dedupe_key: String,
) -> Result<ExternalGenerationJobRecord, AppError>
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<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,
idempotency_key: &str,
) -> Result<ExternalGenerationJobRecord, AppError>
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<ExternalGenerationJobRecord, AppError> {
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<ExternalGenerationJobRecord, AppError> {
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::<Value>(existing),
serde_json::from_str::<Value>(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<T>(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
request_label: impl Into<String>,
price_mud_points: u64,
payload: &T,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, AppError>
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<T>(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<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,
idempotency_key: &str,
dedupe_namespace: &str,
) -> Result<ExternalGenerationJobRecord, AppError>
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<String>,
request_label: impl Into<String>,
price_mud_points: u64,
request_payload_json: String,
job_id: String,
dedupe_key: String,
) -> Result<ExternalGenerationJobRecord, AppError> {
#[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<T>(payload: &T) -> Result<String, AppError>
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<T>(
payload: &T,
) -> Result<String, AppError>
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::<serde_json::Value>(&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("生成失败摘要"));
}
}