aa8e3507d1
新增外部去背景 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>
1427 lines
49 KiB
Rust
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("生成失败摘要"));
|
|
}
|
|
}
|