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
}