修复拼图生成队列与草稿恢复

补齐拼图队列状态回传,生成页展示排队/运行状态

修复草稿进入时生成中与结果页路由恢复逻辑

避免新增空白关卡立即自动保存并保留编辑弹窗

恢复稳定拼图 profile 缺失时的同源 session 草稿投影

让外部生成 worker/controller 跳过 HTTP 认证快照恢复

补充外部生成 worker 与拼图草稿排障记录
This commit is contained in:
2026-06-19 10:47:56 +08:00
parent e3258f6f1d
commit d9c32261fc
12 changed files with 628 additions and 93 deletions
@@ -80,6 +80,14 @@
- 验证:`systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'` 能看到 controller 和 worker 实例;queue 模式下任务被 claim 后 `worker_id``lease_expires_at` 会更新,完成后 session 进入 ready 或 failedinline 模式下不应产生新的 `external_generation_job`
- 关联:`deploy/systemd/genarrative-external-generation-worker@.service``deploy/systemd/genarrative-external-generation-controller.service``deploy/env/external-generation-controller.env.example``server-rs/crates/spacetime-module/src/external_generation.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 外部生成 worker 不应等待 HTTP 认证快照恢复
- 现象:`genarrative-external-generation-worker@1.service` 在 systemd 中显示 active,但 `external_generation_job` 长时间保持 `pending`worker 日志每 5 秒出现 `export_auth_store_snapshot_from_tables` 订阅失败,例如缺少 `public_work_gallery_entry` 公开 read model 表。
- 原因:独立 worker / controller 是非 HTTP 角色,不承接用户登录态恢复;如果启动路径复用 HTTP `api-server` 的认证快照恢复,SpacetimeDB 认证投影或公开 read model 漂移会把 worker claim 循环挡在启动前。
- 处理:`GENARRATIVE_PROCESS_ROLE=external-generation-worker``external-generation-controller` 启动时只构建空 auth store 的 `AppState`,不调用 SpacetimeDB 认证快照导出;只有 `api` / `all` 这类 HTTP 角色需要在启动时恢复认证快照并在依赖不可用时重试或进入 503 降级。
- 验证:重启 worker 后日志应先出现“非 HTTP 进程跳过 SpacetimeDB 认证快照恢复”,随后出现 `external generation worker 已启动`;同一时间窗口不应再因为 `export_auth_store_snapshot_from_tables` 缺表而阻止 job claim。HTTP `api-server` 的认证恢复日志和 503 降级语义保持不变。
- 关联:`server-rs/crates/api-server/src/main.rs``server-rs/crates/api-server/src/external_generation_worker.rs``server-rs/crates/api-server/src/external_generation_worker_controller.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 外部生成 worker 业务写回必须同事务校验 lease guard
- 现象:worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
@@ -1800,6 +1808,10 @@
2026-05-22 补充:结果页关卡详情的“关卡测试”不能把单关 `draft` 传给父级再调用 `updatePuzzleWork``updatePuzzleWork` 会同步 `puzzle_work_profile.levels_json` 和 source session 草稿,单关快照会把整份多关卡草稿覆盖成一个关卡,退出重进后只剩最后测试的关卡且序号表现为第一关。修复口径是 `PuzzleResultView` 始终传完整 `syncedDraft`,额外用 `{ levelId }` 指定起始关卡;父级持久化完整 levels 后调用 `startLocalPuzzleRun(item, levelId)`
2026-06-18 补充:结果页点击“新增关卡”只是在本地打开一个空白占位关卡,不应立刻进入自动保存。空白占位如果被写入 `/api/runtime/puzzle/works/{profile_id}`,在作品 profile 投影尚未稳定存在时会触发 `update_puzzle_work` 404,并且后续 session/draft 回读可能把当前详情弹窗关闭。修复口径是自动保存比较和 payload 过滤掉“后端基线中不存在且完全空白”的本地关卡;用户填写名称、描述、参考图或开始生成后再保存。`mergeDraftEditStateWithIncomingState(...)` 还要保留本地空白占位,避免 incoming draft 刷新时移除正在编辑的弹窗。
2026-06-18 补充:改造流的 `creative_agent` 草稿写回会用 `puzzle-session-*` 派生出的 `puzzle-profile-*` 调用 `update_puzzle_work`;如果前置 `create_puzzle_agent_session` 已写入 `puzzle_agent_session`,但派生的 `puzzle_work_profile` 草稿投影缺失,写回会报“拼图作品不存在”。首图生成或结果页保存也可能踩到同一缺口。修复口径是在 SpacetimeDB `update_puzzle_work_tx` 里只对稳定 `puzzle-profile-*` 反推同源 `puzzle-session-*`,确认 owner 匹配、session 未发布且有 draft 后恢复 draft profile,再继续更新;不要在前端重试或凭空创建任意 profile,也不要恢复已发布 session。
## 拼图上传图关闭 AI 重绘不要走首图生图
- 现象:用户在拼图入口页或结果页关卡详情上传图片并关闭 AI 重绘后,生成页仍显示“生成拼图首图”,或者后端仍调用 `generate_puzzle_image_candidates` 生成第一张 1:1 候选图。
@@ -1431,4 +1431,62 @@ mod tests {
assert_eq!(next["workTitle"], json!("轻松家庭拼图"));
assert_eq!(next["levels"][0]["pictureDescription"], json!("旧图面"));
}
#[test]
fn draft_edit_writeback_uses_stable_puzzle_profile_from_target_session() {
let target_session_id = "puzzle-session-b1cf819ea2d944c4a7e34ca668f8a97c";
let profile_id = build_puzzle_result_profile_id(target_session_id);
let draft = json!({
"workTitle": "雨夜猫街",
"workDescription": "在灯牌下寻找猫的拼图",
"summary": "雨夜猫街拼图",
"themeTags": ["", "雨夜"],
"coverImageSrc": "/generated-puzzle-assets/cat.png",
"coverAssetId": "asset-cat",
"levels": [{
"levelId": "puzzle-level-1",
"levelName": "灯牌下的猫",
"pictureDescription": "一只猫在雨夜灯牌下回头",
"pictureReference": null,
"selectedCandidateId": "candidate-1",
"coverImageSrc": "/generated-puzzle-assets/cat.png",
"coverAssetId": "asset-cat",
"generationStatus": "ready",
"candidates": []
}]
});
let input = build_puzzle_work_update_from_draft(profile_id, "user-1".to_string(), &draft)
.expect("writeback payload should build");
assert_eq!(
input.profile_id,
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c"
);
assert_eq!(input.owner_user_id, "user-1");
assert_eq!(input.work_title, "雨夜猫街");
assert_eq!(input.work_description, "在灯牌下寻找猫的拼图");
assert_eq!(input.level_name, "灯牌下的猫");
assert_eq!(input.summary, "雨夜猫街拼图");
assert_eq!(input.theme_tags, vec!["".to_string(), "雨夜".to_string()]);
assert_eq!(
input.cover_image_src.as_deref(),
Some("/generated-puzzle-assets/cat.png")
);
assert_eq!(input.cover_asset_id.as_deref(), Some("asset-cat"));
let levels: Vec<Value> = serde_json::from_str(
input
.levels_json
.as_deref()
.expect("levels should be serialized"),
)
.expect("levels json should parse");
assert_eq!(levels[0]["level_id"], json!("puzzle-level-1"));
assert_eq!(levels[0]["level_name"], json!("灯牌下的猫"));
assert_eq!(
levels[0]["picture_description"],
json!("一只猫在雨夜灯牌下回头")
);
}
}
@@ -744,6 +744,7 @@ mod tests {
started_at: Some("2026-06-03T00:00:00Z".to_string()),
completed_at: None,
updated_at: "2026-06-03T00:00:00Z".to_string(),
updated_at_micros: 1_748_908_800_000_000,
lease_token: lease_token.map(ToOwned::to_owned),
}
}
+36 -9
View File
@@ -116,7 +116,7 @@ use tracing::{error, info, warn};
use crate::{
app::{build_router, build_spacetime_unavailable_router},
config::AppConfig,
config::{AppConfig, ProcessRole},
external_generation_worker::run_external_generation_worker,
external_generation_worker_controller::run_external_generation_worker_controller,
state::{AppState, AppStateInitError},
@@ -181,13 +181,11 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
let process_role = config.process_role;
let state = restore_app_state_for_startup(config)
.await
.map_err(|error| {
io::Error::other(format!(
"初始化 external generation worker 状态失败:{error}"
))
})?;
let state = build_non_http_app_state_for_startup(config).map_err(|error| {
io::Error::other(format!(
"初始化 external generation worker 状态失败:{error}"
))
})?;
spawn_app_state_background_workers(&state);
info!(
process_role = process_role.as_str(),
@@ -205,6 +203,22 @@ async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
}
}
fn build_non_http_app_state_for_startup(
config: AppConfig,
) -> Result<AppState, state::AppStateInitError> {
let process_role = config.process_role;
debug_assert!(!should_restore_auth_store_for_startup(process_role));
info!(
process_role = process_role.as_str(),
"非 HTTP 进程跳过 SpacetimeDB 认证快照恢复"
);
AppState::new_with_empty_auth_store(config)
}
fn should_restore_auth_store_for_startup(process_role: ProcessRole) -> bool {
process_role.runs_http()
}
async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
let bind_address = config.bind_socket_addr();
let listen_backlog = config.listen_backlog;
@@ -520,8 +534,9 @@ fn is_valid_env_key(key: &str) -> bool {
mod tests {
use super::{
AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from,
strip_env_value,
should_restore_auth_store_for_startup, strip_env_value,
};
use crate::config::ProcessRole;
#[test]
fn strip_env_value_removes_wrapping_quotes() {
@@ -567,4 +582,16 @@ mod tests {
fn startup_dependency_retry_interval_is_short_enough_for_service_recovery() {
assert_eq!(AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), 5);
}
#[test]
fn auth_store_startup_restore_is_limited_to_http_roles() {
assert!(should_restore_auth_store_for_startup(ProcessRole::Api));
assert!(should_restore_auth_store_for_startup(ProcessRole::All));
assert!(!should_restore_auth_store_for_startup(
ProcessRole::ExternalGenerationWorker
));
assert!(!should_restore_auth_store_for_startup(
ProcessRole::ExternalGenerationController
));
}
}
+28 -1
View File
@@ -25,6 +25,7 @@ use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use serde_json::{Value, json};
use shared_contracts::{
creation_audio::CreationAudioAsset,
external_generation::{ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord},
puzzle_agent::{
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse,
@@ -52,7 +53,7 @@ use shared_contracts::{
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, PuzzleAgentMessageRecord,
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, PuzzleAgentMessageRecord,
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput,
@@ -238,6 +239,32 @@ fn mark_puzzle_initial_generation_started_snapshot(
session
}
fn map_puzzle_queue_job_status(
job: &ExternalGenerationJobRecord,
fallback_progress: u32,
) -> ExternalGenerationJobStatusRecord {
let (status, phase_detail, default_progress) = match job.status.as_str() {
"completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100),
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35),
"failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0),
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8),
};
ExternalGenerationJobStatusRecord {
operation_id: job.job_id.clone(),
status,
phase_label: job.request_label.clone(),
phase_detail: phase_detail.to_string(),
progress: fallback_progress
.max(default_progress)
.min(100)
.try_into()
.unwrap_or(100),
error: job.last_error_message.clone(),
updated_at_micros: job.updated_at_micros,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ExternalGenerationWriteLeaseGuard {
pub(crate) job_id: Option<String>,
@@ -718,6 +718,7 @@ pub async fn execute_puzzle_agent_action(
phase_detail: "首关草稿生成已在后台处理中。".to_string(),
progress: session.progress_percent.max(10),
error: None,
queue_state: None,
},
session: map_puzzle_agent_session_response(session),
},
@@ -774,6 +775,7 @@ pub async fn execute_puzzle_agent_action(
},
progress: 100,
error: None,
queue_state: None,
},
session: map_puzzle_agent_session_response(session),
},
@@ -861,7 +863,7 @@ pub async fn execute_puzzle_agent_action(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: job.job_id,
operation_id: job.job_id.clone(),
operation_type: "compile_puzzle_draft".to_string(),
status: status.to_string(),
phase_label: "首关拼图草稿".to_string(),
@@ -871,7 +873,8 @@ pub async fn execute_puzzle_agent_action(
"首关草稿编译已进入后台队列。".to_string()
},
progress,
error: job.last_error_message,
error: job.last_error_message.clone(),
queue_state: Some(map_puzzle_queue_job_status(&job, progress)),
},
session: map_puzzle_agent_session_response(session),
},
@@ -1005,6 +1008,7 @@ pub async fn execute_puzzle_agent_action(
phase_detail: "关卡图片生成已完成。".to_string(),
progress: 100,
error: None,
queue_state: None,
},
session: map_puzzle_agent_session_response(session),
},
@@ -1071,13 +1075,14 @@ pub async fn execute_puzzle_agent_action(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: job.job_id,
operation_id: job.job_id.clone(),
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,
error: job.last_error_message.clone(),
queue_state: Some(map_puzzle_queue_job_status(&job, progress)),
},
session: map_puzzle_agent_session_response(session),
},
@@ -1145,6 +1150,7 @@ pub async fn execute_puzzle_agent_action(
phase_detail: "拼图 UI 背景图生成已完成。".to_string(),
progress: 100,
error: None,
queue_state: None,
},
session: map_puzzle_agent_session_response(session),
},
@@ -1211,13 +1217,14 @@ pub async fn execute_puzzle_agent_action(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: job.job_id,
operation_id: job.job_id.clone(),
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,
error: job.last_error_message.clone(),
queue_state: Some(map_puzzle_queue_job_status(&job, progress)),
},
session: map_puzzle_agent_session_response(session),
},
@@ -1390,6 +1397,7 @@ pub async fn execute_puzzle_agent_action(
phase_detail: "拼图作品已发布到广场。".to_string(),
progress: 100,
error: None,
queue_state: None,
},
session: map_puzzle_agent_session_response(session),
},
@@ -1416,6 +1424,7 @@ pub async fn execute_puzzle_agent_action(
phase_detail: phase_detail.to_string(),
progress: 100,
error: None,
queue_state: None,
},
session: map_puzzle_agent_session_response(session),
},
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::creation_audio::CreationAudioAsset;
use crate::external_generation::ExternalGenerationJobStatusRecord;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -280,6 +281,8 @@ pub struct PuzzleAgentOperationResponse {
pub progress: u32,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
+148 -6
View File
@@ -1874,12 +1874,7 @@ fn update_puzzle_work_tx(
ctx: &TxContext,
input: PuzzleWorkUpsertInput,
) -> Result<PuzzleWorkProfile, String> {
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "拼图作品不存在".to_string())?;
let row = find_or_recover_puzzle_work_profile_row_for_update(ctx, &input)?;
if row.owner_user_id != input.owner_user_id {
return Err("无权修改该拼图作品".to_string());
}
@@ -1957,6 +1952,97 @@ fn update_puzzle_work_tx(
)
}
fn find_or_recover_puzzle_work_profile_row_for_update(
ctx: &TxContext,
input: &PuzzleWorkUpsertInput,
) -> Result<PuzzleWorkProfileRow, String> {
if let Some(row) = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
{
return Ok(row);
}
let recovery = resolve_stable_puzzle_work_profile_recovery_ids(&input.profile_id)
.ok_or_else(|| "拼图作品不存在".to_string())?;
let row = get_owned_session_row(ctx, &recovery.session_id, &input.owner_user_id).map_err(
|error| {
if error.contains("无权") {
"无权修改该拼图作品".to_string()
} else {
"拼图作品不存在".to_string()
}
},
)?;
if row.stage == PuzzleAgentStage::Published
|| row.published_profile_id.as_deref() == Some(input.profile_id.as_str())
{
return Err("拼图作品不存在".to_string());
}
let recovered_profile_id = recovery.profile_id.clone();
let draft = deserialize_draft_required(&row.draft_json)?;
let profile = build_recovered_puzzle_work_profile_from_session(
recovery,
&input.owner_user_id,
&draft,
input.updated_at_micros,
)?;
upsert_puzzle_work_profile(ctx, profile)?;
ctx.db
.puzzle_work_profile()
.profile_id()
.find(&recovered_profile_id)
.ok_or_else(|| "拼图作品不存在".to_string())
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PuzzleWorkProfileRecoveryIds {
work_id: String,
profile_id: String,
session_id: String,
}
fn resolve_stable_puzzle_work_profile_recovery_ids(
profile_id: &str,
) -> Option<PuzzleWorkProfileRecoveryIds> {
let normalized_profile_id = profile_id.trim();
let stable_suffix = normalized_profile_id.strip_prefix("puzzle-profile-")?;
if stable_suffix.trim().is_empty() {
return None;
}
let session_id = format!("puzzle-session-{stable_suffix}");
let (work_id, derived_profile_id) = build_puzzle_work_ids_from_session_id(&session_id);
if derived_profile_id != normalized_profile_id {
return None;
}
Some(PuzzleWorkProfileRecoveryIds {
work_id,
profile_id: derived_profile_id,
session_id,
})
}
fn build_recovered_puzzle_work_profile_from_session(
recovery: PuzzleWorkProfileRecoveryIds,
owner_user_id: &str,
draft: &PuzzleResultDraft,
updated_at_micros: i64,
) -> Result<PuzzleWorkProfile, String> {
// 中文注释:update 可能晚于草稿投影落库执行;只恢复稳定 session 派生的草稿 profile。
create_work_profile(
recovery.work_id,
recovery.profile_id,
owner_user_id.to_string(),
Some(recovery.session_id),
"陶泥儿主".to_string(),
draft,
updated_at_micros,
)
.map_err(|error| error.to_string())
}
fn sync_puzzle_source_session_draft_from_work(
ctx: &TxContext,
work_row: &PuzzleWorkProfileRow,
@@ -4243,6 +4329,62 @@ mod tests {
assert_eq!(draft.work_title, "我的猫街合集");
}
#[test]
fn stable_puzzle_profile_id_recovers_source_session_ids() {
let ids = resolve_stable_puzzle_work_profile_recovery_ids(
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c",
)
.expect("stable puzzle profile should be recoverable");
assert_eq!(
ids.session_id,
"puzzle-session-b1cf819ea2d944c4a7e34ca668f8a97c"
);
assert_eq!(ids.work_id, "puzzle-work-b1cf819ea2d944c4a7e34ca668f8a97c");
assert_eq!(
ids.profile_id,
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c"
);
assert!(resolve_stable_puzzle_work_profile_recovery_ids("").is_none());
assert!(resolve_stable_puzzle_work_profile_recovery_ids("onboarding-profile-1").is_none());
assert!(resolve_stable_puzzle_work_profile_recovery_ids("puzzle-profile-").is_none());
}
#[test]
fn recovered_puzzle_work_profile_keeps_draft_identity() {
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
let draft = compile_result_draft_from_seed(
&anchor_pack,
&[],
Some("画面描述:一只猫在雨夜灯牌下回头。"),
);
let recovery = resolve_stable_puzzle_work_profile_recovery_ids(
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c",
)
.expect("stable ids should resolve");
let profile =
build_recovered_puzzle_work_profile_from_session(recovery, "user-1", &draft, 1_000)
.expect("profile should recover from draft");
assert_eq!(
profile.work_id,
"puzzle-work-b1cf819ea2d944c4a7e34ca668f8a97c"
);
assert_eq!(
profile.profile_id,
"puzzle-profile-b1cf819ea2d944c4a7e34ca668f8a97c"
);
assert_eq!(
profile.source_session_id.as_deref(),
Some("puzzle-session-b1cf819ea2d944c4a7e34ca668f8a97c")
);
assert_eq!(profile.owner_user_id, "user-1");
assert_eq!(profile.publication_status, PuzzlePublicationStatus::Draft);
assert_eq!(profile.updated_at_micros, 1_000);
assert_eq!(profile.levels.len(), draft.levels.len());
}
#[test]
fn failed_generation_returns_result_draft_stage_instead_of_refining() {
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
@@ -165,6 +165,7 @@ import {
fetchCreationEntryConfig,
} from '../../services/creationEntryConfigService';
import {
type CreationUrlState,
clearCreationUrlState,
readCreationUrlState,
writeCreationUrlState,
@@ -499,6 +500,7 @@ import {
type PendingDraftShelfKind,
type PendingDraftShelfMap,
type PendingDraftShelfMetadata,
buildPuzzleDraftOpenNoticeKeys,
resolveBigFishDraftOpenIntent,
resolveJumpHopDraftOpenIntent,
resolveMatch3DDraftOpenIntent,
@@ -909,6 +911,29 @@ function pushPuzzleResultHistoryEntry(
writeCreationUrlState(buildPuzzleCreationUrlState(session));
}
function pushPuzzleGeneratingHistoryEntry(
session: PuzzleAgentSessionSnapshot | null,
fallbackState?: CreationUrlState,
) {
pushAppHistoryPath('/creation/puzzle/generating');
writeCreationUrlState(
session ? buildPuzzleCreationUrlState(session) : (fallbackState ?? {}),
);
}
function buildPuzzleCreationUrlStateFromWork(
item: PuzzleWorkSummary,
): CreationUrlState {
const sourceSessionId = item.sourceSessionId?.trim() || null;
return {
sessionId: sourceSessionId,
profileId:
item.profileId?.trim() || buildPuzzleResultProfileId(sourceSessionId),
workId: item.workId?.trim() || buildPuzzleResultWorkId(sourceSessionId),
};
}
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const PUZZLE_BACKGROUND_ACTION_POLL_INTERVAL_MS = 3000;
const PUZZLE_BACKGROUND_ACTION_MAX_POLL_ATTEMPTS = 160;
@@ -4247,6 +4272,7 @@ export function PlatformEntryFlowShellImpl({
);
activePuzzleGenerationSessionIdRef.current =
response.session.sessionId;
pushPuzzleGeneratingHistoryEntry(response.session);
setSelectionStage('puzzle-generating');
markDraftGenerating('puzzle', [
response.session.sessionId,
@@ -4391,6 +4417,7 @@ export function PlatformEntryFlowShellImpl({
);
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = session.sessionId;
pushPuzzleGeneratingHistoryEntry(session);
setSelectionStage('puzzle-generating');
const nextGenerationState = createPuzzleDraftGenerationStateFromPayload(
formPayload ?? buildPuzzleFormPayloadFromSession(session),
@@ -5657,6 +5684,7 @@ export function PlatformEntryFlowShellImpl({
}
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = nextSession.sessionId;
pushPuzzleGeneratingHistoryEntry(nextSession);
setSelectionStage('puzzle-generating');
try {
@@ -12060,6 +12088,66 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setSelectedPuzzleDetail(null);
const restorePuzzleGeneratingSession = (
latestSession: PuzzleAgentSessionSnapshot,
) => {
const payload = buildPuzzleFormPayloadFromSession(latestSession);
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
latestSession.updatedAt,
);
const baseGenerationState =
createMiniGameDraftGenerationStateForRestoredDraft(
'puzzle',
{
puzzleAiRedraw: payload.aiRedraw ?? true,
puzzleProgressPercent:
latestSession.draft && !latestSession.draft.formDraft
? latestSession.progressPercent
: undefined,
},
startedAtMs,
);
const generationState = mergePuzzleSessionProgressIntoGenerationState(
baseGenerationState,
latestSession,
);
puzzleFlow.setSession(latestSession);
setPuzzleFormDraftPayload(payload);
setPuzzleGenerationState(generationState);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[latestSession.sessionId]: {
session: latestSession,
payload,
generationState,
error: null,
},
}));
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = latestSession.sessionId;
pushPuzzleGeneratingHistoryEntry(latestSession);
setSelectionStage('puzzle-generating');
};
const openPuzzleReadyResultSession = (
latestSession: PuzzleAgentSessionSnapshot,
) => {
const payload = buildPuzzleFormPayloadFromSession(latestSession);
puzzleFlow.setSession(latestSession);
setPuzzleFormDraftPayload(payload);
setPuzzleGenerationState(null);
setPuzzleBackgroundCompileTasks((current) => {
const next = { ...current };
delete next[latestSession.sessionId];
return next;
});
enterCreateTab();
selectionStageRef.current = 'puzzle-result';
activePuzzleGenerationSessionIdRef.current = null;
markDraftNoticeSeen(buildPuzzleDraftOpenNoticeKeys(item));
pushPuzzleResultHistoryEntry(latestSession);
setSelectionStage('puzzle-result');
};
if (openIntent.type === 'open-published-detail') {
await openPuzzleDetail(item.profileId, { tab: 'create' });
@@ -12125,6 +12213,10 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
pushPuzzleGeneratingHistoryEntry(
failedSession,
buildPuzzleCreationUrlStateFromWork(item),
);
setPuzzleGenerationState(failedGenerationState);
setSelectionStage('puzzle-generating');
return;
@@ -12139,6 +12231,10 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
pushPuzzleGeneratingHistoryEntry(
puzzleSession ?? backgroundTask?.session ?? null,
buildPuzzleCreationUrlStateFromWork(item),
);
setPuzzleGenerationState(rebasedGenerationState);
if (backgroundTask) {
setPuzzleBackgroundCompileTasks((current) => ({
@@ -12169,6 +12265,7 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
pushPuzzleGeneratingHistoryEntry(rebasedTask.session);
setSelectionStage('puzzle-generating');
return;
}
@@ -12177,42 +12274,14 @@ export function PlatformEntryFlowShellImpl({
try {
const { session: latestSession } =
await getPuzzleAgentSession(sourceSessionId);
const payload = buildPuzzleFormPayloadFromSession(latestSession);
const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs(
latestSession.updatedAt,
);
const baseGenerationState =
createMiniGameDraftGenerationStateForRestoredDraft(
'puzzle',
{
puzzleAiRedraw: payload.aiRedraw ?? true,
puzzleProgressPercent:
latestSession.draft && !latestSession.draft.formDraft
? latestSession.progressPercent
: undefined,
},
startedAtMs,
);
const generationState = mergePuzzleSessionProgressIntoGenerationState(
baseGenerationState,
latestSession,
);
puzzleFlow.setSession(latestSession);
setPuzzleFormDraftPayload(payload);
setPuzzleGenerationState(generationState);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[latestSession.sessionId]: {
session: latestSession,
payload,
generationState,
error: null,
},
}));
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = sourceSessionId;
setSelectionStage('puzzle-generating');
if (
!isPuzzleFormOnlyDraft(latestSession) &&
isPuzzleCompileActionReady(latestSession)
) {
openPuzzleReadyResultSession(latestSession);
return;
}
restorePuzzleGeneratingSession(latestSession);
return;
} catch (error) {
setPuzzleError(
@@ -12238,6 +12307,10 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('puzzle-agent-workspace');
} else {
setPuzzleFormDraftPayload(null);
pushPuzzleResultHistoryEntry(restoredSession);
selectionStageRef.current = 'puzzle-result';
activePuzzleGenerationSessionIdRef.current = null;
setSelectionStage('puzzle-result');
}
},
[
@@ -99,6 +99,58 @@ describe('platformDraftGenerationShelfModel', () => {
});
});
test('resolvePuzzleDraftOpenIntent ignores stale generating notice after puzzle draft is ready', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork({
generationStatus: 'ready',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
levels: [
{
levelId: 'level-1',
levelName: '金色数字舞台',
pictureDescription: '123456',
pictureReference: null,
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
},
],
}),
notices: {
'puzzle:puzzle-session-base': {
status: 'generating',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-draft',
});
});
test('resolvePuzzleDraftOpenIntent restores generating page from notice even before persisted status catches up', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork({
generationStatus: 'idle',
}),
notices: {
'puzzle:puzzle-session-base': {
status: 'generating',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-generating',
});
});
test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => {
const item = buildMatch3DWork({
publicationStatus: 'published',
@@ -595,7 +595,7 @@ describe('PuzzleResultView', () => {
);
});
test('adds and deletes levels from the list', async () => {
test('keeps a blank new level local until the creator edits it', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
@@ -621,25 +621,36 @@ describe('PuzzleResultView', () => {
expect(
within(dialog).queryByRole('button', { name: /关卡测试/u }),
).toBeNull();
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
expect(screen.queryByText('保存中')).toBeNull();
expect(puzzleWorksService.updatePuzzleWork).not.toHaveBeenCalled();
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
target: { value: '钟楼猫街' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: expect.arrayContaining([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelName: '' }),
expect.objectContaining({ levelName: '钟楼猫街' }),
]),
}),
);
fireEvent.click(screen.getByLabelText('删除关卡 第2关'));
expect(screen.queryByText('第2关')).toBeNull();
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
fireEvent.click(screen.getByLabelText('删除关卡 钟楼猫街'));
expect(screen.queryByText('钟楼猫街')).toBeNull();
await act(async () => {
await vi.runAllTimersAsync();
@@ -657,6 +668,48 @@ describe('PuzzleResultView', () => {
);
});
test('keeps the blank level dialog open across incoming draft refreshes', async () => {
vi.useFakeTimers();
const base = createSession();
const { rerender } = render(
<PuzzleResultView
session={base}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
rerender(
<PuzzleResultView
session={{
...base,
updatedAt: '2026-04-26T10:00:01.000Z',
}}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(
(within(dialog).getByLabelText('关卡名称') as HTMLInputElement).value,
).toBe('');
expect(screen.getByLabelText('删除关卡 第2关')).toBeTruthy();
expect(screen.queryByText('保存中')).toBeNull();
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).not.toHaveBeenCalled();
});
test('generates image for a newly added level with the current levels snapshot', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
const onExecuteAction = vi.fn();
+106 -28
View File
@@ -166,6 +166,96 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
);
}
function normalizeOptionalPuzzleLevelText(value?: string | null) {
return value?.trim() || null;
}
function normalizePuzzleLevelForSave(level: PuzzleDraftLevel) {
return {
...level,
levelName: level.levelName.trim(),
pictureDescription: level.pictureDescription.trim(),
pictureReference: normalizeOptionalPuzzleLevelText(level.pictureReference),
uiBackgroundPrompt: normalizeOptionalPuzzleLevelText(
level.uiBackgroundPrompt,
),
uiBackgroundImageSrc: normalizeOptionalPuzzleLevelText(
level.uiBackgroundImageSrc,
),
uiBackgroundImageObjectKey: normalizeOptionalPuzzleLevelText(
level.uiBackgroundImageObjectKey,
),
levelSceneImageSrc: normalizeOptionalPuzzleLevelText(
level.levelSceneImageSrc,
),
levelSceneImageObjectKey: normalizeOptionalPuzzleLevelText(
level.levelSceneImageObjectKey,
),
uiSpritesheetImageSrc: normalizeOptionalPuzzleLevelText(
level.uiSpritesheetImageSrc,
),
uiSpritesheetImageObjectKey: normalizeOptionalPuzzleLevelText(
level.uiSpritesheetImageObjectKey,
),
levelBackgroundImageSrc: normalizeOptionalPuzzleLevelText(
level.levelBackgroundImageSrc,
),
levelBackgroundImageObjectKey: normalizeOptionalPuzzleLevelText(
level.levelBackgroundImageObjectKey,
),
generationStatus: level.generationStatus || 'idle',
};
}
function normalizeDraftEditStateForSave(state: DraftEditState): DraftEditState {
return {
...state,
workTitle: state.workTitle.trim(),
workDescription: state.workDescription.trim(),
themeTags: normalizeThemeTagInput(state.themeTags.join('')),
levels: state.levels.map(normalizePuzzleLevelForSave),
};
}
function isBlankPuzzleLevel(level: PuzzleDraftLevel) {
return (
!level.levelName.trim() &&
!level.pictureDescription.trim() &&
!level.pictureReference?.trim() &&
!level.uiBackgroundPrompt?.trim() &&
!level.uiBackgroundImageSrc?.trim() &&
!level.uiBackgroundImageObjectKey?.trim() &&
!level.levelSceneImageSrc?.trim() &&
!level.levelSceneImageObjectKey?.trim() &&
!level.uiSpritesheetImageSrc?.trim() &&
!level.uiSpritesheetImageObjectKey?.trim() &&
!level.levelBackgroundImageSrc?.trim() &&
!level.levelBackgroundImageObjectKey?.trim() &&
!level.selectedCandidateId?.trim() &&
!level.coverImageSrc?.trim() &&
!level.coverAssetId?.trim() &&
!level.backgroundMusic &&
level.candidates.length === 0 &&
(level.generationStatus || 'idle') === 'idle'
);
}
function removeUnsavedBlankPuzzleLevels(
state: DraftEditState,
originalState: DraftEditState,
) {
const originalLevelIds = new Set(
originalState.levels.map((level) => level.levelId),
);
return {
...state,
levels: state.levels.filter(
(level) =>
originalLevelIds.has(level.levelId) || !isBlankPuzzleLevel(level),
),
};
}
function buildFallbackLevelFromDraft(
draft: PuzzleResultDraft,
): PuzzleDraftLevel {
@@ -261,13 +351,20 @@ function mergeDraftEditStateWithIncomingState(
incomingLevel?.generationStatus !== 'generating'
);
});
const hasLocalBlankLevel = currentState.levels.some(
(level) =>
!incomingLevelsById.has(level.levelId) && isBlankPuzzleLevel(level),
);
if (!shouldPreserveLocalEdits) {
if (!shouldPreserveLocalEdits && !hasLocalBlankLevel) {
return incomingState;
}
const mergedLevels = currentState.levels.map((level) => {
const incomingLevel = incomingLevelsById.get(level.levelId);
if (!incomingLevel && isBlankPuzzleLevel(level)) {
return level;
}
if (
!incomingLevel ||
level.generationStatus !== 'generating' ||
@@ -1411,36 +1508,17 @@ export function PuzzleResultView({
return;
}
const normalizedState: DraftEditState = {
...editState,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
themeTags: normalizeThemeTagInput(editState.themeTags.join('')),
levels: editState.levels.map((level) => ({
...level,
levelName: level.levelName.trim(),
pictureDescription: level.pictureDescription.trim(),
pictureReference: level.pictureReference?.trim() || null,
uiBackgroundPrompt: level.uiBackgroundPrompt?.trim() || null,
uiBackgroundImageSrc: level.uiBackgroundImageSrc?.trim() || null,
uiBackgroundImageObjectKey:
level.uiBackgroundImageObjectKey?.trim() || null,
levelSceneImageSrc: level.levelSceneImageSrc?.trim() || null,
levelSceneImageObjectKey:
level.levelSceneImageObjectKey?.trim() || null,
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc?.trim() || null,
uiSpritesheetImageObjectKey:
level.uiSpritesheetImageObjectKey?.trim() || null,
levelBackgroundImageSrc: level.levelBackgroundImageSrc?.trim() || null,
levelBackgroundImageObjectKey:
level.levelBackgroundImageObjectKey?.trim() || null,
generationStatus: level.generationStatus || 'idle',
})),
};
const originalState =
savedEditStateRef.current ?? createDraftEditState(draft);
const normalizedOriginalState =
normalizeDraftEditStateForSave(originalState);
const normalizedState = removeUnsavedBlankPuzzleLevels(
normalizeDraftEditStateForSave(editState),
normalizedOriginalState,
);
const changed =
JSON.stringify(normalizedState) !== JSON.stringify(originalState);
JSON.stringify(normalizedState) !==
JSON.stringify(normalizedOriginalState);
if (!changed || normalizedState.levels.length <= 0) {
return;