合并 master 并保留外部生成 worker 模式

合入 master 的拼消消、微信能力、OpenSSL 3.2 和 SpacetimeDB 2.4.1 更新
保留外部内容生成 queue/inline、worker lease 与动态扩缩容口径
补齐拼图后台图片生成队列轮询和运行态返回恢复
同步容器、生产运维和 Hermes 共享记忆中的 worker 文档
This commit is contained in:
2026-06-09 16:55:32 +08:00
497 changed files with 66318 additions and 13329 deletions

View File

@@ -137,6 +137,10 @@ pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing(
Ok(replacement.session_id)
}
fn default_puzzle_image_generation_points_cost() -> u64 {
PUZZLE_IMAGE_GENERATION_POINTS_COST
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PuzzleCompileDraftWorkerPayload {
@@ -144,6 +148,8 @@ pub(crate) struct PuzzleCompileDraftWorkerPayload {
pub owner_user_id: String,
pub billing_asset_id: String,
pub ai_redraw: bool,
#[serde(default = "default_puzzle_image_generation_points_cost")]
pub billing_points_cost: u64,
#[serde(default)]
pub prompt_text: Option<String>,
#[serde(default)]
@@ -166,7 +172,7 @@ pub(crate) async fn execute_puzzle_compile_draft_worker_job(
&payload.owner_user_id,
"puzzle_initial_image",
&payload.billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
payload.billing_points_cost,
async {
compile_puzzle_draft_with_initial_cover(
state,
@@ -198,7 +204,32 @@ pub(crate) async fn execute_puzzle_compile_draft_worker_job(
};
match session {
Ok(session) => Ok(session),
Ok(session) => {
if session
.draft
.as_ref()
.is_some_and(|draft| draft.generation_status == "ready")
{
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id: payload.owner_user_id.clone(),
task_name: Some("拼图".to_string()),
work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()),
status: GenerationResultSubscribeMessageStatus::Succeeded,
consumed_points: if payload.ai_redraw {
payload.billing_points_cost
} else {
0
},
completed_at_micros: current_utc_micros(),
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
}
Ok(session)
}
Err(error) => {
match mark_puzzle_compile_failure_for_worker(
state,
@@ -211,6 +242,19 @@ pub(crate) async fn execute_puzzle_compile_draft_worker_job(
.await
{
Ok(()) => {
send_generation_result_subscribe_message_after_completion(
state.root_state(),
GenerationResultSubscribeMessage {
owner_user_id: payload.owner_user_id.clone(),
task_name: Some("拼图".to_string()),
work_name: None,
status: GenerationResultSubscribeMessageStatus::Failed,
consumed_points: 0,
completed_at_micros: now,
page: Some("/pages/web-view/index".to_string()),
},
)
.await;
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
}
Err(mark_error) => {
@@ -425,13 +469,18 @@ pub(crate) fn build_puzzle_session_snapshot_from_action_payload(
levels,
form_draft: None,
};
let stage = if is_puzzle_session_snapshot_publish_ready(&draft) {
"ready_to_publish"
} else {
"image_refining"
};
Ok(PuzzleAgentSessionRecord {
session_id: session_id.to_string(),
seed_text: String::new(),
current_turn: 0,
progress_percent: 94,
stage: "ready_to_publish".to_string(),
stage: stage.to_string(),
anchor_pack,
draft: Some(draft),
messages: Vec::new(),
@@ -1095,6 +1144,7 @@ pub(crate) fn attach_selected_puzzle_candidate_to_levels(
}
}
#[cfg(test)]
pub(crate) fn resolve_puzzle_initial_ui_background_prompt(
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
@@ -1160,6 +1210,7 @@ pub(crate) fn build_puzzle_ui_background_generation_prompt(
)
}
#[cfg(test)]
pub(crate) fn attach_puzzle_level_ui_background(
levels: &mut [PuzzleDraftLevelRecord],
level_id: &str,
@@ -1201,27 +1252,6 @@ pub(crate) fn attach_puzzle_level_asset_bundle(
level.ui_background_image_object_key = Some(generated.level_background.object_key);
}
pub(crate) async fn generate_puzzle_initial_ui_background_required(
state: &PuzzleApiState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: &str,
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> {
let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level);
let generated = generate_puzzle_ui_background_image(
state,
request_context,
owner_user_id,
session_id,
target_level.level_name.as_str(),
prompt.as_str(),
)
.await?;
Ok((prompt, generated))
}
pub(crate) async fn generate_puzzle_level_asset_bundle_required(
state: &PuzzleApiState,
request_context: &RequestContext,
@@ -1309,7 +1339,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft_with_external_generation_guard(
session_id.clone(),
session_id,
owner_user_id.clone(),
now,
external_generation_guard.job_id.clone(),
@@ -1318,6 +1348,32 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
)
.await
.map_err(map_puzzle_compile_error)?;
generate_puzzle_initial_cover_from_compiled_session(
state,
request_context,
compiled_session,
owner_user_id,
prompt_text,
reference_image_src,
image_model,
now,
external_generation_guard,
)
.await
}
pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
state: &PuzzleApiState,
request_context: &RequestContext,
compiled_session: PuzzleAgentSessionRecord,
owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
image_model: Option<&str>,
now: i64,
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let draft = compiled_session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -1458,7 +1514,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
let saved_session = state
let (saved_session, save_used_fallback) = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: compiled_session.session_id.clone(),
@@ -1472,7 +1528,40 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)?;
.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)
}
})?;
match state
.spacetime_client()
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
@@ -1509,10 +1598,13 @@ 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 {
session_id,
session_id: compiled_session.session_id.clone(),
owner_user_id,
level_id: Some(target_level.level_id),
candidate_id: selected_candidate_id,
@@ -1729,7 +1821,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
"message": format!("拼图上传图候选序列化失败:{error}"),
}))
})?;
let saved_session = state
let (saved_session, save_used_fallback) = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: compiled_session.session_id.clone(),
@@ -1743,7 +1835,39 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
external_generation_lease_token: external_generation_guard.lease_token.clone(),
})
.await
.map_err(map_puzzle_client_error)?;
.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)
}
})?;
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
match state
.spacetime_client()
@@ -1781,6 +1905,9 @@ 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 {
@@ -1811,7 +1938,6 @@ 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,
@@ -1858,13 +1984,33 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
sync_puzzle_primary_draft_fields_from_level(draft);
}
session.progress_percent = session.progress_percent.max(94);
session.stage = "ready_to_publish".to_string();
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
"ready_to_publish".to_string()
} else {
"image_refining".to_string()
};
session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string());
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
#[cfg(test)]
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
}
pub(crate) fn apply_generated_puzzle_first_level_name_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
target_level_id: &str,
@@ -1917,6 +2063,45 @@ 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,
@@ -1966,3 +2151,45 @@ 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
}