feat: workerize external generation

This commit is contained in:
2026-06-05 17:29:08 +08:00
parent 5150925947
commit 8d54ea3374
60 changed files with 5285 additions and 700 deletions

View File

@@ -137,6 +137,124 @@ pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing(
Ok(replacement.session_id)
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleCompileDraftWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub billing_asset_id: String,
pub ai_redraw: bool,
#[serde(default)]
pub prompt_text: Option<String>,
#[serde(default)]
pub reference_image_src: Option<String>,
#[serde(default)]
pub image_model: Option<String>,
pub requested_at_micros: i64,
}
pub(crate) async fn execute_puzzle_compile_draft_worker_job(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: PuzzleCompileDraftWorkerPayload,
external_generation_guard: ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
let now = current_utc_micros();
let session = if payload.ai_redraw {
execute_billable_asset_operation_with_cost(
state.root_state(),
&payload.owner_user_id,
"puzzle_initial_image",
&payload.billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
compile_puzzle_draft_with_initial_cover(
state,
request_context,
payload.session_id.clone(),
payload.owner_user_id.clone(),
payload.prompt_text.as_deref(),
payload.reference_image_src.as_deref(),
payload.image_model.as_deref(),
now,
&external_generation_guard,
)
.await
},
)
.await
} else {
compile_puzzle_draft_with_uploaded_cover(
state,
request_context,
payload.session_id.clone(),
payload.owner_user_id.clone(),
payload.prompt_text.as_deref(),
payload.reference_image_src.as_deref(),
now,
&external_generation_guard,
)
.await
};
match session {
Ok(session) => Ok(session),
Err(error) => {
match mark_puzzle_compile_failure_for_worker(
state,
&payload.session_id,
&payload.owner_user_id,
error.body_text(),
now,
&external_generation_guard,
)
.await
{
Ok(()) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
}
Err(mark_error) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
}
}
}
}
}
pub(crate) async fn mark_puzzle_compile_failure_for_worker(
state: &PuzzleApiState,
session_id: &str,
owner_user_id: &str,
error_message: String,
failed_at_micros: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<(), AppError> {
let result = state
.spacetime_client()
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
error_message,
failed_at_micros,
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await;
if let Err(error) = result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
owner_user_id,
message = %error,
"拼图 worker 草稿失败态回写失败"
);
return Err(map_puzzle_client_error(error));
}
Ok(())
}
pub(crate) fn select_puzzle_level_for_api(
draft: &PuzzleResultDraftRecord,
level_id: Option<&str>,
@@ -1186,10 +1304,18 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
reference_image_src: Option<&str>,
image_model: Option<&str>,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
.compile_puzzle_agent_draft_with_external_generation_guard(
session_id.clone(),
owner_user_id.clone(),
now,
external_generation_guard.job_id.clone(),
external_generation_guard.worker_id.clone(),
external_generation_guard.lease_token.clone(),
)
.await
.map_err(map_puzzle_compile_error)?;
let draft = compiled_session.draft.clone().ok_or_else(|| {
@@ -1332,7 +1458,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
let (saved_session, save_used_fallback) = state
let saved_session = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: compiled_session.session_id.clone(),
@@ -1341,42 +1467,12 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
levels_json: levels_json_with_generated_name.clone(),
candidates_json,
saved_at_micros: current_utc_micros(),
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)
.map(|session| (session, false))
.or_else(|error| {
if is_spacetimedb_connectivity_app_error(&error) {
// 中文注释:首图已落 OSS 时SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %compiled_session.session_id,
owner_user_id = %owner_user_id,
message = %error.body_text(),
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let session = apply_generated_puzzle_candidates_to_session_snapshot(
apply_generated_puzzle_levels_to_session_snapshot(
apply_generated_puzzle_first_level_name_to_session_snapshot(
compiled_session.clone(),
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
now,
),
updated_levels.clone(),
now,
),
target_level.level_id.as_str(),
candidates.into_records(),
reference_image_src,
now,
);
Ok((session, true))
} else {
Err(error)
}
})?;
.map_err(map_puzzle_client_error)?;
match state
.spacetime_client()
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
@@ -1413,9 +1509,6 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
fallback_level_name.as_str(),
now,
);
if save_used_fallback {
return Ok(saved_session);
}
match state
.spacetime_client()
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
@@ -1454,6 +1547,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let uploaded_image_src = reference_image_src
.map(str::trim)
@@ -1488,7 +1582,14 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
})?;
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
.compile_puzzle_agent_draft_with_external_generation_guard(
session_id.clone(),
owner_user_id.clone(),
now,
external_generation_guard.job_id.clone(),
external_generation_guard.worker_id.clone(),
external_generation_guard.lease_token.clone(),
)
.await
.map_err(map_puzzle_compile_error)?;
let draft = compiled_session.draft.clone().ok_or_else(|| {
@@ -1628,7 +1729,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
"message": format!("拼图上传图候选序列化失败:{error}"),
}))
})?;
let (saved_session, save_used_fallback) = state
let saved_session = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: compiled_session.session_id.clone(),
@@ -1637,41 +1738,12 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
levels_json: levels_json_with_generated_name.clone(),
candidates_json,
saved_at_micros: current_utc_micros(),
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)
.map(|session| (session, false))
.or_else(|error| {
if is_spacetimedb_connectivity_app_error(&error) {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %compiled_session.session_id,
owner_user_id = %owner_user_id,
message = %error.body_text(),
"拼图上传图草稿回写不可用,降级返回本地快照"
);
let session = apply_generated_puzzle_candidates_to_session_snapshot(
apply_generated_puzzle_levels_to_session_snapshot(
apply_generated_puzzle_first_level_name_to_session_snapshot(
compiled_session.clone(),
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
now,
),
updated_levels.clone(),
now,
),
target_level.level_id.as_str(),
vec![candidate.clone()],
reference_image_src,
now,
);
Ok((session, true))
} else {
Err(error)
}
})?;
.map_err(map_puzzle_client_error)?;
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
match state
.spacetime_client()
@@ -1709,9 +1781,6 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
fallback_level_name.as_str(),
now,
);
if save_used_fallback {
return Ok(saved_session);
}
match state
.spacetime_client()
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
@@ -1742,6 +1811,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
}
}
#[cfg(test)]
pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
target_level_id: &str,
@@ -1794,23 +1864,7 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
session
}
pub(crate) fn apply_generated_puzzle_levels_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
levels: Vec<PuzzleDraftLevelRecord>,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
return session;
};
if levels.is_empty() {
return session;
}
draft.levels = levels;
sync_puzzle_primary_draft_fields_from_level(draft);
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
#[cfg(test)]
pub(crate) fn apply_generated_puzzle_first_level_name_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
target_level_id: &str,
@@ -1863,45 +1917,6 @@ pub(crate) fn apply_generated_puzzle_initial_metadata_to_session_snapshot(
session
}
pub(crate) fn apply_generated_puzzle_metadata_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
target_level_id: &str,
metadata: &PuzzleLevelNaming,
previous_level_name: &str,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
return session;
};
let Some(target_index) = draft
.levels
.iter()
.position(|level| level.level_id == target_level_id)
.or_else(|| (!draft.levels.is_empty()).then_some(0))
else {
return session;
};
draft.levels[target_index].level_name = metadata.level_name.clone();
if metadata.ui_background_prompt.is_some() {
draft.levels[target_index].ui_background_prompt = metadata.ui_background_prompt.clone();
}
if target_index == 0 {
apply_generated_puzzle_initial_metadata_to_draft(
draft,
metadata,
previous_level_name,
updated_at_micros,
);
} else {
sync_puzzle_primary_draft_fields_from_level(draft);
}
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
pub(crate) fn apply_generated_puzzle_initial_metadata_to_draft(
draft: &mut PuzzleResultDraftRecord,
metadata: &PuzzleLevelNaming,
@@ -1951,45 +1966,3 @@ pub(crate) fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResu
});
}
}
pub(crate) fn replace_puzzle_session_draft_snapshot(
mut session: PuzzleAgentSessionRecord,
draft: PuzzleResultDraftRecord,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
session.draft = Some(draft);
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
pub(crate) fn apply_generated_puzzle_ui_background_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
target_level_id: &str,
prompt: String,
image_src: String,
image_object_key: Option<String>,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
return session;
};
let Some(target_index) = draft
.levels
.iter()
.position(|level| level.level_id == target_level_id)
.or_else(|| (!draft.levels.is_empty()).then_some(0))
else {
return session;
};
let level = &mut draft.levels[target_index];
level.ui_background_prompt = Some(prompt);
level.ui_background_image_src = Some(image_src);
level.ui_background_image_object_key = image_object_key;
if target_index == 0 {
sync_puzzle_primary_draft_fields_from_level(draft);
}
session.progress_percent = session.progress_percent.max(96);
session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string());
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}

View File

@@ -22,6 +22,510 @@ pub(crate) fn should_use_uploaded_puzzle_image_directly(
.is_some_and(|value| !value.is_empty())
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleGenerateImagesWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub billing_asset_id: String,
#[serde(default)]
pub level_id: Option<String>,
#[serde(default)]
pub prompt_text: Option<String>,
#[serde(default)]
pub reference_image_src: Option<String>,
#[serde(default)]
pub reference_image_srcs: Vec<String>,
#[serde(default)]
pub reference_image_asset_object_id: Option<String>,
#[serde(default)]
pub reference_image_asset_object_ids: Vec<String>,
#[serde(default)]
pub image_model: Option<String>,
#[serde(default)]
pub ai_redraw: Option<bool>,
#[serde(default)]
pub should_auto_name_level: Option<bool>,
#[serde(default)]
pub work_title: Option<String>,
#[serde(default)]
pub work_description: Option<String>,
#[serde(default)]
pub picture_description: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub theme_tags: Option<Vec<String>>,
#[serde(default)]
pub levels_json: Option<String>,
pub requested_at_micros: i64,
}
impl PuzzleGenerateImagesWorkerPayload {
fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest {
ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_images".to_string(),
prompt_text: self.prompt_text.clone(),
reference_image_src: self.reference_image_src.clone(),
reference_image_srcs: self.reference_image_srcs.clone(),
reference_image_asset_object_id: self.reference_image_asset_object_id.clone(),
reference_image_asset_object_ids: self.reference_image_asset_object_ids.clone(),
image_model: self.image_model.clone(),
ai_redraw: self.ai_redraw,
candidate_count: Some(1),
should_auto_name_level: self.should_auto_name_level,
candidate_id: None,
level_id: self.level_id.clone(),
work_title: self.work_title.clone(),
work_description: self.work_description.clone(),
picture_description: self.picture_description.clone(),
level_name: None,
summary: self.summary.clone(),
theme_tags: self.theme_tags.clone(),
levels_json: self.levels_json.clone(),
}
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleGenerateUiBackgroundWorkerPayload {
pub session_id: String,
pub owner_user_id: String,
pub billing_asset_id: String,
#[serde(default)]
pub level_id: Option<String>,
#[serde(default)]
pub prompt_text: Option<String>,
#[serde(default)]
pub levels_json: Option<String>,
pub requested_at_micros: i64,
}
impl PuzzleGenerateUiBackgroundWorkerPayload {
fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest {
ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_ui_background".to_string(),
prompt_text: self.prompt_text.clone(),
reference_image_src: None,
reference_image_srcs: Vec::new(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: None,
ai_redraw: None,
candidate_count: None,
should_auto_name_level: None,
candidate_id: None,
level_id: self.level_id.clone(),
work_title: None,
work_description: None,
picture_description: None,
level_name: None,
summary: None,
theme_tags: None,
levels_json: self.levels_json.clone(),
}
}
}
pub(crate) async fn execute_puzzle_generate_images_worker_job(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: PuzzleGenerateImagesWorkerPayload,
external_generation_guard: ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
let now = current_utc_micros();
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&payload.owner_user_id,
"puzzle_generated_image",
&payload.billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
execute_puzzle_generate_images_worker_job_inner(
state,
request_context,
&payload,
now,
&external_generation_guard,
)
.await
},
)
.await;
match session {
Ok(session) => Ok(session),
Err(error) => {
match mark_puzzle_level_generation_failure_for_worker(
state,
&payload,
error.body_text(),
now,
&external_generation_guard,
)
.await
{
Ok(()) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
}
Err(mark_error) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
}
}
}
}
}
pub(crate) async fn execute_puzzle_generate_ui_background_worker_job(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: PuzzleGenerateUiBackgroundWorkerPayload,
external_generation_guard: ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, PuzzleExternalGenerationWorkerError> {
let now = current_utc_micros();
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&payload.owner_user_id,
"puzzle_ui_background_image",
&payload.billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
execute_puzzle_generate_ui_background_worker_job_inner(
state,
request_context,
&payload,
now,
&external_generation_guard,
)
.await
},
)
.await;
match session {
Ok(session) => Ok(session),
Err(error) => {
match mark_puzzle_level_generation_failure_for_external_generation(
state,
&payload.session_id,
&payload.owner_user_id,
payload.level_id.clone(),
payload.levels_json.clone(),
error.body_text(),
now,
&external_generation_guard,
)
.await
{
Ok(()) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
}
Err(mark_error) => {
Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error))
}
}
}
}
}
async fn execute_puzzle_generate_images_worker_job_inner(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: &PuzzleGenerateImagesWorkerPayload,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let action_payload = payload.to_action_request();
let target_level_id = payload.level_id.clone();
let levels_json = payload.levels_json.clone();
let session = get_puzzle_session_for_image_generation(
state,
payload.session_id.clone(),
payload.owner_user_id.clone(),
&action_payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let mut target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let prompt = resolve_puzzle_level_image_prompt(
payload.prompt_text.as_deref(),
&target_level.picture_description,
&draft.summary,
);
let should_auto_name_level = payload
.should_auto_name_level
.unwrap_or_else(|| target_level.level_name.trim().is_empty());
if should_auto_name_level {
let naming =
generate_puzzle_first_level_name(state, target_level.picture_description.as_str())
.await;
target_level.level_name = naming.level_name.clone();
target_level.ui_background_prompt = naming.ui_background_prompt.clone();
}
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
);
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
// 中文注释:拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_start_index = target_level.candidates.len();
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let mut candidates =
if should_use_uploaded_puzzle_image_directly(primary_reference_image_src, ai_redraw) {
vec![
create_uploaded_puzzle_image_candidate(
state,
payload.owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src.expect("checked reference image"),
candidate_start_index,
)
.await?,
]
} else {
let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id);
generate_puzzle_image_candidates(
state,
payload.owner_user_id.as_str(),
Some(profile_id.as_str()),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src,
ai_redraw,
payload.image_model.as_deref(),
1,
candidate_start_index,
)
.await
.map_err(map_puzzle_generation_endpoint_error)?
};
if candidates.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
})),
);
}
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
.filter(|_| should_auto_name_level)
{
target_level.level_name = refined_naming.level_name.clone();
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt = refined_naming.ui_background_prompt.clone();
}
}
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, primary_reference_image_src);
for candidate in &mut candidates {
candidate.record.prompt = prompt.clone();
}
let selected_candidate = candidates
.iter()
.find(|candidate| candidate.record.selected)
.or_else(|| candidates.first())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
}))
})?;
let asset_bundle = generate_puzzle_level_asset_bundle_required(
state,
request_context,
payload.owner_user_id.as_str(),
&session.session_id,
&target_level,
&selected_candidate.downloaded_image,
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
asset_bundle,
);
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,
target_level.level_id.as_str(),
&selected_candidate.record,
);
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(|candidate| to_puzzle_generated_image_candidate(&candidate.record))
.collect::<Vec<_>>(),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: payload.owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: levels_json_with_generated_name,
candidates_json,
saved_at_micros: now,
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)
}
async fn execute_puzzle_generate_ui_background_worker_job_inner(
state: &PuzzleApiState,
request_context: &RequestContext,
payload: &PuzzleGenerateUiBackgroundWorkerPayload,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let action_payload = payload.to_action_request();
let target_level_id = payload.level_id.clone();
let levels_json = payload.levels_json.clone();
let session = get_puzzle_session_for_image_generation(
state,
payload.session_id.clone(),
payload.owner_user_id.clone(),
&action_payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let raw_prompt = payload
.prompt_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_default()
.to_string();
let resolved_prompt =
normalize_puzzle_ui_background_prompt(raw_prompt.as_str(), &draft, &target_level);
let generated = generate_puzzle_ui_background_image(
state,
request_context,
payload.owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
resolved_prompt.as_str(),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
state
.spacetime_client()
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: payload.owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json,
prompt: resolved_prompt.clone(),
image_src: generated.image_src.clone(),
image_object_key: Some(generated.object_key.clone()),
saved_at_micros: now,
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)
}
pub(crate) async fn mark_puzzle_level_generation_failure_for_worker(
state: &PuzzleApiState,
payload: &PuzzleGenerateImagesWorkerPayload,
error_message: String,
failed_at_micros: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<(), AppError> {
mark_puzzle_level_generation_failure_for_external_generation(
state,
&payload.session_id,
&payload.owner_user_id,
payload.level_id.clone(),
payload.levels_json.clone(),
error_message,
failed_at_micros,
external_generation_guard,
)
.await
}
async fn mark_puzzle_level_generation_failure_for_external_generation(
state: &PuzzleApiState,
session_id: &str,
owner_user_id: &str,
level_id: Option<String>,
levels_json: Option<String>,
error_message: String,
failed_at_micros: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<(), AppError> {
let result = state
.spacetime_client()
.mark_puzzle_level_generation_failed(PuzzleLevelGenerationFailureRecordInput {
session_id: session_id.to_string(),
owner_user_id: owner_user_id.to_string(),
level_id,
levels_json,
error_message,
failed_at_micros,
external_generation_job_id: external_generation_guard.job_id.clone(),
external_generation_worker_id: external_generation_guard.worker_id.clone(),
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await;
if let Err(error) = result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session_id,
owner_user_id = %owner_user_id,
message = %error,
"拼图 worker 关卡生图失败态回写失败"
);
return Err(map_puzzle_client_error(error));
}
Ok(())
}
pub(crate) async fn create_uploaded_puzzle_image_candidate(
state: &PuzzleApiState,
owner_user_id: &str,

View File

@@ -609,35 +609,6 @@ pub async fn execute_puzzle_agent_action(
"拼图 Agent action 开始执行"
);
let mark_puzzle_compile_failure = |error: &AppError, compile_session_id: &str| {
let state = state.clone();
let owner_user_id = owner_user_id.clone();
let error_message = error.body_text();
let session_id = compile_session_id.to_string();
let log_session_id = session_id.clone();
let log_owner_user_id = owner_user_id.clone();
async move {
let result = state
.spacetime_client()
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
session_id,
owner_user_id,
error_message,
failed_at_micros: now,
})
.await;
if let Err(error) = result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %log_session_id,
owner_user_id = %log_owner_user_id,
message = %error,
"拼图草稿失败态回写失败,继续返回原始错误"
);
}
}
};
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let ai_redraw = payload.ai_redraw.unwrap_or(true);
@@ -667,61 +638,88 @@ pub async fn execute_puzzle_agent_action(
Ok(next_session_id) => next_session_id,
Err(response) => return Err(response),
};
let session = if ai_redraw {
execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_initial_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
compile_puzzle_draft_with_initial_cover(
&state,
&request_context,
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
primary_reference_image_src,
payload.image_model.as_deref(),
now,
)
.await
},
)
.await
} else {
compile_puzzle_draft_with_uploaded_cover(
&state,
&request_context,
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
primary_reference_image_src,
now,
)
.await
let worker_payload = PuzzleCompileDraftWorkerPayload {
session_id: compile_session_id.clone(),
owner_user_id: owner_user_id.clone(),
billing_asset_id: billing_asset_id.clone(),
ai_redraw,
prompt_text: prompt_text.map(ToOwned::to_owned),
reference_image_src: primary_reference_image_src.map(ToOwned::to_owned),
image_model: payload.image_model.clone(),
requested_at_micros: now,
};
let session = match session {
Ok(session) => Ok(session),
Err(error) => {
mark_puzzle_compile_failure(&error, &compile_session_id).await;
Err(puzzle_error_response(
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图生成任务参数序列化失败:{error}"),
})),
)
})?;
let external_generation_job_id = build_prefixed_uuid_id("extgen-");
let job = state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
job_id: external_generation_job_id.clone(),
dedupe_key: format!(
"puzzle:compile_puzzle_draft:{compile_session_id}:{external_generation_job_id}"
),
job_kind: PUZZLE_COMPILE_DRAFT_JOB_KIND.to_string(),
owner_user_id: owner_user_id.clone(),
source_module: "puzzle".to_string(),
source_entity_id: compile_session_id.clone(),
request_label: "拼图首关草稿生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now,
created_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
error,
))
}
map_puzzle_client_error(error),
)
})?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
let (status, progress) = match job.status.as_str() {
"completed" => ("completed", 100),
"running" => ("running", session.progress_percent.max(10)),
"failed" => ("failed", session.progress_percent),
_ => ("queued", session.progress_percent.max(5)),
};
(
"compile_puzzle_draft",
"首关拼图草稿",
if ai_redraw {
"已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。"
} else {
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
return Ok(json_success_body(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: job.job_id,
operation_type: "compile_puzzle_draft".to_string(),
status: status.to_string(),
phase_label: "首关拼图草稿".to_string(),
phase_detail: if ai_redraw {
"首关草稿生成已进入后台队列。".to_string()
} else {
"首关草稿编译已进入后台队列。".to_string()
},
progress,
error: job.last_error_message,
},
session: map_puzzle_agent_session_response(session),
},
session,
)
));
}
"save_puzzle_form_draft" => {
let seed_text = build_puzzle_form_seed_text_from_parts(
@@ -783,367 +781,205 @@ pub async fn execute_puzzle_agent_action(
payload.levels_json.as_deref(),
)
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
});
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_generated_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
let levels_json = levels_json?;
let session = get_puzzle_session_for_image_generation(
&state,
session_id.clone(),
owner_user_id.clone(),
&payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let mut target_level =
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let fallback_level_name = target_level.level_name.clone();
let prompt = resolve_puzzle_level_image_prompt(
payload.prompt_text.as_deref(),
&target_level.picture_description,
&draft.summary,
);
let should_auto_name_level = payload
.should_auto_name_level
.unwrap_or_else(|| target_level.level_name.trim().is_empty());
let mut generated_naming = if should_auto_name_level {
let naming = generate_puzzle_first_level_name(
&state,
target_level.picture_description.as_str(),
)
.await;
target_level.level_name = naming.level_name.clone();
target_level.ui_background_prompt = naming.ui_background_prompt.clone();
Some(naming)
} else {
None
};
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
);
let primary_reference_image_src =
reference_image_sources.first().map(String::as_str);
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_start_index = target_level.candidates.len();
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let mut candidates = if should_use_uploaded_puzzle_image_directly(
primary_reference_image_src,
ai_redraw,
) {
vec![
create_uploaded_puzzle_image_candidate(
&state,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src.expect("checked reference image"),
candidate_start_index,
)
.await?,
]
} else {
let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id);
generate_puzzle_image_candidates(
&state,
owner_user_id.as_str(),
Some(profile_id.as_str()),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src,
ai_redraw,
payload.image_model.as_deref(),
1,
candidate_start_index,
)
.await
.map_err(map_puzzle_generation_endpoint_error)?
};
if candidates.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
}),
));
}
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
&state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
.filter(|_| should_auto_name_level)
{
target_level.level_name = refined_naming.level_name.clone();
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt =
refined_naming.ui_background_prompt.clone();
}
generated_naming = Some(refined_naming);
}
let generated_level_name = target_level.level_name.clone();
let mut updated_levels = build_puzzle_levels_with_primary_update(
&draft,
&target_level,
primary_reference_image_src,
);
for candidate in &mut candidates {
candidate.record.prompt = prompt.clone();
}
let selected_candidate = candidates
.iter()
.find(|candidate| candidate.record.selected)
.or_else(|| candidates.first())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
}))
})?;
let asset_bundle = generate_puzzle_level_asset_bundle_required(
&state,
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
})),
)
})?;
let worker_payload = PuzzleGenerateImagesWorkerPayload {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
billing_asset_id: billing_asset_id.clone(),
level_id: target_level_id.clone(),
prompt_text: payload.prompt_text.clone(),
reference_image_src: payload.reference_image_src.clone(),
reference_image_srcs: payload.reference_image_srcs.clone(),
reference_image_asset_object_id: payload.reference_image_asset_object_id.clone(),
reference_image_asset_object_ids: payload.reference_image_asset_object_ids.clone(),
image_model: payload.image_model.clone(),
ai_redraw: payload.ai_redraw,
should_auto_name_level: payload.should_auto_name_level,
work_title: payload.work_title.clone(),
work_description: payload.work_description.clone(),
picture_description: payload.picture_description.clone(),
summary: payload.summary.clone(),
theme_tags: payload.theme_tags.clone(),
levels_json,
requested_at_micros: now,
};
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图关卡图片生成任务参数序列化失败:{error}"),
})),
)
})?;
let external_generation_job_id = build_prefixed_uuid_id("extgen-");
let source_entity_id = target_level_id
.as_deref()
.map(|level_id| format!("{session_id}:{level_id}"))
.unwrap_or_else(|| session_id.clone());
let job = state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
job_id: external_generation_job_id.clone(),
dedupe_key: format!(
"puzzle:generate_puzzle_images:{session_id}:{external_generation_job_id}"
),
job_kind: PUZZLE_GENERATE_IMAGES_JOB_KIND.to_string(),
owner_user_id: owner_user_id.clone(),
source_module: "puzzle".to_string(),
source_entity_id,
request_label: "拼图关卡图片生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now,
created_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
owner_user_id.as_str(),
&session.session_id,
&target_level,
&selected_candidate.downloaded_image,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
asset_bundle,
);
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,
target_level.level_id.as_str(),
&selected_candidate.record,
);
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(|candidate| to_puzzle_generated_image_candidate(&candidate.record))
.collect::<Vec<_>>(),
})?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
let save_result = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: levels_json_with_generated_name,
candidates_json,
saved_at_micros: now,
})
.await;
match save_result {
Ok(session) => Ok(session),
Err(error)
if should_skip_asset_operation_billing_for_connectivity(&error) =>
{
// 中文注释VectorEngine/OSS 已生成真实图片时SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
owner_user_id = %owner_user_id,
error = %error,
"拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let fallback_session =
replace_puzzle_session_draft_snapshot(session, draft, now);
let fallback_session = if should_auto_name_level {
apply_generated_puzzle_first_level_name_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
now,
)
} else {
fallback_session
};
let mut fallback_session =
apply_generated_puzzle_candidates_to_session_snapshot(
apply_generated_puzzle_levels_to_session_snapshot(
fallback_session,
updated_levels,
now,
),
target_level.level_id.as_str(),
candidates.into_records(),
primary_reference_image_src,
now,
);
if let Some(generated_naming) = generated_naming.as_ref() {
fallback_session =
apply_generated_puzzle_metadata_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
generated_naming,
fallback_level_name.as_str(),
now,
);
}
Ok(fallback_session)
}
Err(error) => Err(map_puzzle_client_error(error)),
}
})?;
let (status, progress) = match job.status.as_str() {
"completed" => ("completed", 100),
"running" => ("running", 35),
"failed" => ("failed", 0),
_ => ("queued", 8),
};
return Ok(json_success_body(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: job.job_id,
operation_type: "generate_puzzle_images".to_string(),
status: status.to_string(),
phase_label: "拼图图片生成".to_string(),
phase_detail: "关卡图片生成已进入后台队列。".to_string(),
progress,
error: job.last_error_message,
},
session: map_puzzle_agent_session_response(session),
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_images",
"拼图图片生成",
"已生成并替换当前拼图图片。",
session,
)
));
}
"generate_puzzle_ui_background" => {
let target_level_id = payload.level_id.clone();
let raw_prompt = payload
.prompt_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_default()
.to_string();
let levels_json = normalize_puzzle_levels_json_for_module(
payload.levels_json.as_deref(),
)
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
});
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_ui_background_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
let levels_json = levels_json?;
let session = get_puzzle_session_for_image_generation(
&state,
session_id.clone(),
owner_user_id.clone(),
&payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let target_level =
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let resolved_prompt = normalize_puzzle_ui_background_prompt(
raw_prompt.as_str(),
&draft,
&target_level,
);
let generated = generate_puzzle_ui_background_image(
&state,
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
})),
)
})?;
let worker_payload = PuzzleGenerateUiBackgroundWorkerPayload {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
billing_asset_id: billing_asset_id.clone(),
level_id: target_level_id.clone(),
prompt_text: payload.prompt_text.clone(),
levels_json,
requested_at_micros: now,
};
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图 UI 背景图生成任务参数序列化失败:{error}"),
})),
)
})?;
let external_generation_job_id = build_prefixed_uuid_id("extgen-");
let source_entity_id = target_level_id
.as_deref()
.map(|level_id| format!("{session_id}:{level_id}"))
.unwrap_or_else(|| session_id.clone());
let job = state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
job_id: external_generation_job_id.clone(),
dedupe_key: format!(
"puzzle:generate_puzzle_ui_background:{session_id}:{external_generation_job_id}"
),
job_kind: PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND.to_string(),
owner_user_id: owner_user_id.clone(),
source_module: "puzzle".to_string(),
source_entity_id,
request_label: "拼图 UI 背景图生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now,
created_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
resolved_prompt.as_str(),
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
let save_result = state
.spacetime_client()
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json,
prompt: resolved_prompt.clone(),
image_src: generated.image_src.clone(),
image_object_key: Some(generated.object_key.clone()),
saved_at_micros: now,
})
.await;
match save_result {
Ok(session) => Ok(session),
Err(error)
if should_skip_asset_operation_billing_for_connectivity(&error) =>
{
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
owner_user_id = %owner_user_id,
error = %error,
"拼图 UI 背景图生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let fallback_session =
replace_puzzle_session_draft_snapshot(session, draft, now);
Ok(apply_generated_puzzle_ui_background_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
resolved_prompt,
generated.image_src,
Some(generated.object_key),
now,
))
}
Err(error) => Err(map_puzzle_client_error(error)),
}
})?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
let (status, progress) = match job.status.as_str() {
"completed" => ("completed", 100),
"running" => ("running", session.progress_percent.max(55)),
"failed" => ("failed", session.progress_percent),
_ => ("queued", session.progress_percent.max(12)),
};
return Ok(json_success_body(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: job.job_id,
operation_type: "generate_puzzle_ui_background".to_string(),
status: status.to_string(),
phase_label: "UI 背景图生成".to_string(),
phase_detail: "拼图 UI 背景图生成已进入后台队列。".to_string(),
progress,
error: job.last_error_message,
},
session: map_puzzle_agent_session_response(session),
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_ui_background",
"UI 背景图生成",
"已生成拼图 UI 背景图。",
session,
)
));
}
"generate_puzzle_tags" => {
let work_title = payload

View File

@@ -484,6 +484,108 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
);
}
#[test]
fn puzzle_generate_images_worker_payload_keeps_action_snapshot() {
let raw_levels_json = serde_json::to_string(&vec![json!({
"levelId": "puzzle-level-2",
"levelName": "",
"pictureDescription": "新关卡里有一座发光钟楼。",
"candidates": [],
"selectedCandidateId": null,
"coverImageSrc": null,
"coverAssetId": null,
"generationStatus": "generating",
})])
.expect("levels json");
let levels_json = normalize_puzzle_levels_json_for_module(Some(raw_levels_json.as_str()))
.expect("levels should normalize")
.expect("levels json should exist");
let payload = PuzzleGenerateImagesWorkerPayload {
session_id: "puzzle-session-1".to_string(),
owner_user_id: "user-1".to_string(),
billing_asset_id: "puzzle-session-1:123".to_string(),
level_id: Some("puzzle-level-2".to_string()),
prompt_text: Some("发光钟楼".to_string()),
reference_image_src: None,
reference_image_srcs: vec!["data:image/png;base64,abc".to_string()],
reference_image_asset_object_id: Some("asset-object-1".to_string()),
reference_image_asset_object_ids: vec!["asset-object-2".to_string()],
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: Some(true),
should_auto_name_level: Some(true),
work_title: Some("暖灯猫街作品".to_string()),
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
picture_description: None,
summary: Some("一套雨夜猫街主题拼图。".to_string()),
theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]),
levels_json: Some(levels_json.clone()),
requested_at_micros: 123,
};
let encoded = serde_json::to_string(&payload).expect("payload should serialize");
let decoded: PuzzleGenerateImagesWorkerPayload =
serde_json::from_str(encoded.as_str()).expect("payload should deserialize");
assert_eq!(decoded.level_id.as_deref(), Some("puzzle-level-2"));
assert_eq!(decoded.reference_image_srcs.len(), 1);
assert_eq!(
decoded.reference_image_asset_object_ids,
vec!["asset-object-2".to_string()]
);
assert_eq!(decoded.should_auto_name_level, Some(true));
let records = parse_puzzle_level_records_from_module_json(
decoded.levels_json.as_deref().expect("levels json"),
)
.expect("levels should parse as module json");
assert_eq!(records[0].level_id, "puzzle-level-2");
assert_eq!(records[0].generation_status, "generating");
}
#[test]
fn puzzle_generate_ui_background_worker_payload_keeps_action_snapshot() {
let raw_levels_json = serde_json::to_string(&vec![json!({
"levelId": "puzzle-level-3",
"levelName": "钟楼回廊",
"pictureDescription": "新关卡里有一座发光钟楼。",
"uiBackgroundPrompt": "发光钟楼延展成竖屏回廊,远处有暖色窗光。",
"candidates": [],
"selectedCandidateId": null,
"coverImageSrc": null,
"coverAssetId": null,
"generationStatus": "generating",
})])
.expect("levels json");
let levels_json = normalize_puzzle_levels_json_for_module(Some(raw_levels_json.as_str()))
.expect("levels should normalize")
.expect("levels json should exist");
let payload = PuzzleGenerateUiBackgroundWorkerPayload {
session_id: "puzzle-session-1".to_string(),
owner_user_id: "user-1".to_string(),
billing_asset_id: "puzzle-session-1:456".to_string(),
level_id: Some("puzzle-level-3".to_string()),
prompt_text: Some("发光钟楼延展成竖屏回廊".to_string()),
levels_json: Some(levels_json.clone()),
requested_at_micros: 456,
};
let encoded = serde_json::to_string(&payload).expect("payload should serialize");
let decoded: PuzzleGenerateUiBackgroundWorkerPayload =
serde_json::from_str(encoded.as_str()).expect("payload should deserialize");
assert_eq!(decoded.level_id.as_deref(), Some("puzzle-level-3"));
assert_eq!(
decoded.prompt_text.as_deref(),
Some("发光钟楼延展成竖屏回廊")
);
assert_eq!(decoded.requested_at_micros, 456);
let records = parse_puzzle_level_records_from_module_json(
decoded.levels_json.as_deref().expect("levels json"),
)
.expect("levels should parse as module json");
assert_eq!(records[0].level_id, "puzzle-level-3");
assert_eq!(records[0].generation_status, "generating");
}
#[test]
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
assert_eq!(