From d9c32261fc60aaa4d8424158fcc17f251677dfa7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 10:47:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8B=BC=E5=9B=BE=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=98=9F=E5=88=97=E4=B8=8E=E8=8D=89=E7=A8=BF=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐拼图队列状态回传,生成页展示排队/运行状态 修复草稿进入时生成中与结果页路由恢复逻辑 避免新增空白关卡立即自动保存并保留编辑弹窗 恢复稳定拼图 profile 缺失时的同源 session 草稿投影 让外部生成 worker/controller 跳过 HTTP 认证快照恢复 补充外部生成 worker 与拼图草稿排障记录 --- docs/project-memory/shared-memory/pitfalls.md | 12 ++ .../crates/api-server/src/creative_agent.rs | 58 +++++++ .../src/external_generation_worker.rs | 1 + server-rs/crates/api-server/src/main.rs | 45 ++++- server-rs/crates/api-server/src/puzzle.rs | 29 +++- .../crates/api-server/src/puzzle/handlers.rs | 21 ++- .../shared-contracts/src/puzzle_agent.rs | 3 + .../crates/spacetime-module/src/puzzle.rs | 154 +++++++++++++++++- .../PlatformEntryFlowShellImpl.tsx | 145 +++++++++++++---- .../platformDraftGenerationShelfModel.test.ts | 52 ++++++ .../puzzle-result/PuzzleResultView.test.tsx | 67 +++++++- .../puzzle-result/PuzzleResultView.tsx | 134 +++++++++++---- 12 files changed, 628 insertions(+), 93 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 42aff25b8..505b177e2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 或 failed;inline 模式下不应产生新的 `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 候选图。 diff --git a/server-rs/crates/api-server/src/creative_agent.rs b/server-rs/crates/api-server/src/creative_agent.rs index 65dc28126..f83344861 100644 --- a/server-rs/crates/api-server/src/creative_agent.rs +++ b/server-rs/crates/api-server/src/creative_agent.rs @@ -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 = 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!("一只猫在雨夜灯牌下回头") + ); + } } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index ec90016af..fe110c2ed 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -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), } } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 38e0f23b9..90af7ba2f 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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 { + 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 + )); + } } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index cc9d0237e..e7f5ca4b1 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -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, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index e5e24ddad..721dc729a 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -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), }, diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index b417ca801..6d585f5b6 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -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, + #[serde(default)] + pub queue_state: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 8b253e7e7..2a568294e 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1874,12 +1874,7 @@ fn update_puzzle_work_tx( ctx: &TxContext, input: PuzzleWorkUpsertInput, ) -> Result { - 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 { + 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 { + 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 { + // 中文注释: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); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index f35b2358c..ba5eccb4f 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -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'); } }, [ diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts index ac5118783..7d3d54f73 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -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', diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index ad6d3514c..6fc05b575 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -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( + {}} + onExecuteAction={() => {}} + />, + ); + + openPuzzleLevelsTab(); + fireEvent.click(screen.getByRole('button', { name: /新增关卡/u })); + expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy(); + + rerender( + {}} + 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(); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index ea2ee1e9c..684e872da 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -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;