From 17a184b0a777b98529bdb59be65de15a9c4411ad Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 26 May 2026 22:02:58 +0800 Subject: [PATCH] fix: settle puzzle failures and profile tasks --- server-rs/crates/api-server/src/puzzle.rs | 23 ++-- .../crates/api-server/src/puzzle/handlers.rs | 46 ++++++- .../crates/module-puzzle/src/application.rs | 60 +++++++++ .../crates/module-puzzle/src/commands.rs | 9 ++ server-rs/crates/spacetime-client/src/lib.rs | 3 +- .../crates/spacetime-client/src/mapper.rs | 3 +- .../spacetime-client/src/mapper/puzzle.rs | 8 ++ .../spacetime-client/src/module_bindings.rs | 6 +- ...uzzle_draft_generation_failed_procedure.rs | 59 +++++++++ ...puzzle_draft_compile_failure_input_type.rs | 18 +++ .../crates/spacetime-client/src/puzzle.rs | 30 +++++ .../crates/spacetime-module/src/puzzle.rs | 93 +++++++++++-- .../RpgEntryHomeView.recharge.test.tsx | 124 ++++++++++++------ src/components/rpg-entry/RpgEntryHomeView.tsx | 35 +++-- 14 files changed, 436 insertions(+), 81 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_draft_generation_failed_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 67453bca..56cc3b73 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -55,17 +55,18 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, - PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, - PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, - PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, - PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, - PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, - PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, - PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, - PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, - PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, - PuzzleWorkUpsertRecordInput, SpacetimeClientError, + PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, + PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, + PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, + PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, + PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, + PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, + PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, + PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, + SpacetimeClientError, }; use std::convert::Infallible; diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 46834284..fe902640 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -606,6 +606,36 @@ pub async fn execute_puzzle_agent_action( ), "拼图 Agent action 开始执行" ); + + let mark_puzzle_compile_failure = |error: &AppError, compile_session_id: &str| { + let state = state.clone(); + let owner_user_id = owner_user_id.clone(); + let error_message = error.body_text(); + let session_id = compile_session_id.to_string(); + let log_session_id = session_id.clone(); + let log_owner_user_id = owner_user_id.clone(); + async move { + let result = state + .spacetime_client() + .mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput { + session_id, + owner_user_id, + error_message, + failed_at_micros: now, + }) + .await; + if let Err(error) = result { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %log_session_id, + owner_user_id = %log_owner_user_id, + message = %error, + "拼图草稿失败态回写失败,继续返回原始错误" + ); + } + } + }; + let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { let ai_redraw = payload.ai_redraw.unwrap_or(true); @@ -666,10 +696,18 @@ pub async fn execute_puzzle_agent_action( now, ) .await - } - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - }); + }; + let session = match session { + Ok(session) => Ok(session), + Err(error) => { + mark_puzzle_compile_failure(&error, &compile_session_id).await; + Err(puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + error, + )) + } + }; ( "compile_puzzle_draft", "首关拼图草稿", diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index a3cdfa8b..e0ee1dda 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -388,6 +388,22 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft draft } +pub fn mark_failed_puzzle_result_draft_generation( + mut draft: PuzzleResultDraft, +) -> PuzzleResultDraft { + if draft.levels.is_empty() { + draft = normalize_puzzle_draft(draft); + } + for level in &mut draft.levels { + if level.generation_status.trim() != "ready" { + level.generation_status = "failed".to_string(); + } + } + sync_primary_level_fields(&mut draft); + draft.generation_status = "failed".to_string(); + draft +} + pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) { if let Some(primary_level) = draft.levels.first() { draft.level_name = primary_level.level_name.clone(); @@ -3212,6 +3228,50 @@ mod tests { ); } + #[test] + fn failed_generation_marks_pending_levels_failed_without_touching_ready_assets() { + let anchor_pack = infer_anchor_pack("雨夜猫街", Some("雨夜猫街")); + let mut draft = compile_result_draft(&anchor_pack, &[]); + draft.generation_status = "generating".to_string(); + draft.levels[0].generation_status = "generating".to_string(); + draft.levels.push(PuzzleDraftLevel { + level_id: "puzzle-level-2".to_string(), + level_name: "第二关".to_string(), + picture_description: "第二关画面".to_string(), + picture_reference: None, + ui_background_prompt: None, + ui_background_image_src: None, + ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, + background_music: None, + candidates: vec![PuzzleGeneratedImageCandidate { + candidate_id: "candidate-1".to_string(), + image_src: "/ready.png".to_string(), + asset_id: "asset-1".to_string(), + prompt: "prompt".to_string(), + actual_prompt: None, + source_type: "generated".to_string(), + selected: true, + }], + selected_candidate_id: Some("candidate-1".to_string()), + cover_image_src: Some("/ready.png".to_string()), + cover_asset_id: Some("asset-1".to_string()), + generation_status: "ready".to_string(), + }); + + let failed = mark_failed_puzzle_result_draft_generation(draft); + + assert_eq!(failed.generation_status, "failed"); + assert_eq!(failed.levels[0].generation_status, "failed"); + assert_eq!(failed.levels[1].generation_status, "ready"); + assert_eq!(failed.levels[1].cover_image_src.as_deref(), Some("/ready.png")); + } + #[test] fn form_seed_keeps_multiline_picture_description() { let anchor_pack = infer_anchor_pack( diff --git a/server-rs/crates/module-puzzle/src/commands.rs b/server-rs/crates/module-puzzle/src/commands.rs index 66cf0b1a..a1be2445 100644 --- a/server-rs/crates/module-puzzle/src/commands.rs +++ b/server-rs/crates/module-puzzle/src/commands.rs @@ -68,6 +68,15 @@ pub struct PuzzleDraftCompileInput { pub compiled_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleDraftCompileFailureInput { + pub session_id: String, + pub owner_user_id: String, + pub error_message: String, + pub failed_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleGeneratedImagesSaveInput { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 5bd54ff4..51e9ca43 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -52,7 +52,8 @@ pub use mapper::{ PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, - PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, + PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index df2d43b4..3a9c0d85 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -99,7 +99,8 @@ pub use self::puzzle::{ PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, - PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, + PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index 0b7d8ec6..46e151f4 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -636,6 +636,14 @@ pub struct PuzzleAgentMessageFinalizeRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleDraftCompileFailureRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub error_message: String, + pub failed_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleGeneratedImagesSaveRecordInput { pub session_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 3aa8dc89..f7f1a2e8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). +// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -468,6 +468,7 @@ pub mod list_visual_novel_runtime_history_procedure; pub mod list_visual_novel_works_procedure; pub mod list_wooden_fish_works_procedure; pub mod mark_profile_recharge_order_paid_and_return_procedure; +pub mod mark_puzzle_draft_generation_failed_procedure; pub mod match_3_d_agent_message_finalize_input_type; pub mod match_3_d_agent_message_row_type; pub mod match_3_d_agent_message_snapshot_type; @@ -597,6 +598,7 @@ pub mod puzzle_audio_asset_type; pub mod puzzle_board_snapshot_type; pub mod puzzle_cell_position_type; pub mod puzzle_creator_intent_type; +pub mod puzzle_draft_compile_failure_input_type; pub mod puzzle_draft_compile_input_type; pub mod puzzle_draft_level_type; pub mod puzzle_event_kind_type; @@ -1497,6 +1499,7 @@ pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_h pub use list_visual_novel_works_procedure::list_visual_novel_works; pub use list_wooden_fish_works_procedure::list_wooden_fish_works; pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; +pub use mark_puzzle_draft_generation_failed_procedure::mark_puzzle_draft_generation_failed; pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow; pub use match_3_d_agent_message_snapshot_type::Match3DAgentMessageSnapshot; @@ -1626,6 +1629,7 @@ pub use puzzle_audio_asset_type::PuzzleAudioAsset; pub use puzzle_board_snapshot_type::PuzzleBoardSnapshot; pub use puzzle_cell_position_type::PuzzleCellPosition; pub use puzzle_creator_intent_type::PuzzleCreatorIntent; +pub use puzzle_draft_compile_failure_input_type::PuzzleDraftCompileFailureInput; pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput; pub use puzzle_draft_level_type::PuzzleDraftLevel; pub use puzzle_event_kind_type::PuzzleEventKind; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_draft_generation_failed_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_draft_generation_failed_procedure.rs new file mode 100644 index 00000000..ae073d5c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_draft_generation_failed_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult; +use super::puzzle_draft_compile_failure_input_type::PuzzleDraftCompileFailureInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct MarkPuzzleDraftGenerationFailedArgs { + pub input: PuzzleDraftCompileFailureInput, +} + +impl __sdk::InModule for MarkPuzzleDraftGenerationFailedArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `mark_puzzle_draft_generation_failed`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait mark_puzzle_draft_generation_failed { + fn mark_puzzle_draft_generation_failed(&self, input: PuzzleDraftCompileFailureInput) { + self.mark_puzzle_draft_generation_failed_then(input, |_, _| {}); + } + + fn mark_puzzle_draft_generation_failed_then( + &self, + input: PuzzleDraftCompileFailureInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl mark_puzzle_draft_generation_failed for super::RemoteProcedures { + fn mark_puzzle_draft_generation_failed_then( + &self, + input: PuzzleDraftCompileFailureInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>( + "mark_puzzle_draft_generation_failed", + MarkPuzzleDraftGenerationFailedArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs new file mode 100644 index 00000000..ccda3ff5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleDraftCompileFailureInput { + pub session_id: String, + pub owner_user_id: String, + pub error_message: String, + pub failed_at_micros: i64, +} + +impl __sdk::InModule for PuzzleDraftCompileFailureInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index f6ddd839..1fb3e62c 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -167,6 +167,36 @@ impl SpacetimeClient { .await } + pub async fn mark_puzzle_draft_generation_failed( + &self, + input: PuzzleDraftCompileFailureRecordInput, + ) -> Result { + let procedure_input = PuzzleDraftCompileFailureInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + error_message: input.error_message, + failed_at_micros: input.failed_at_micros, + }; + + self.call_after_connect( + "mark_puzzle_draft_generation_failed", + move |connection, sender| { + connection + .procedures() + .mark_puzzle_draft_generation_failed_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn save_puzzle_generated_images( &self, input: PuzzleGeneratedImagesSaveRecordInput, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a22ed976..60c2ae83 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -11,20 +11,20 @@ use module_puzzle::{ PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, - PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, - PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, - PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, - PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput, - PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, - PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput, - PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, + PuzzleDraftCompileFailureInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, + PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, + PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, + PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, + PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, + PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed, build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, - normalize_puzzle_draft, normalize_puzzle_levels, normalize_theme_tags, publish_work_profile, - replace_puzzle_level, select_next_profiles, selected_profile_level_after_runtime_level, - selected_puzzle_level, tag_similarity_score, + mark_failed_puzzle_result_draft_generation, normalize_puzzle_draft, normalize_puzzle_levels, + normalize_theme_tags, publish_work_profile, replace_puzzle_level, select_next_profiles, + selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score, }; use module_runtime::RuntimeProfileWalletLedgerSourceType; use module_runtime::visible_runtime_profile_user_tags; @@ -363,6 +363,25 @@ pub fn compile_puzzle_agent_draft( } } +#[spacetimedb::procedure] +pub fn mark_puzzle_draft_generation_failed( + ctx: &mut ProcedureContext, + input: PuzzleDraftCompileFailureInput, +) -> PuzzleAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| mark_puzzle_draft_generation_failed_tx(tx, input.clone())) { + Ok(session) => PuzzleAgentSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => PuzzleAgentSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + /// 保存拼图入口表单草稿。 /// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。 #[spacetimedb::procedure] @@ -999,6 +1018,60 @@ fn compile_puzzle_agent_draft_tx( ) } +fn mark_puzzle_draft_generation_failed_tx( + ctx: &TxContext, + input: PuzzleDraftCompileFailureInput, +) -> Result { + let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros); + let draft = match deserialize_optional_draft(&row.draft_json)? { + Some(draft) => mark_failed_puzzle_result_draft_generation(draft), + None => { + let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?; + let messages = list_session_messages(ctx, &row.session_id); + mark_failed_puzzle_result_draft_generation(compile_result_draft_from_seed( + &anchor_pack, + &messages, + Some(&row.seed_text), + )) + } + }; + upsert_puzzle_draft_work_profile( + ctx, + &row.session_id, + &row.owner_user_id, + &draft, + input.failed_at_micros, + )?; + + replace_puzzle_agent_session( + ctx, + &row, + PuzzleAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent.max(88), + stage: row.stage, + anchor_pack_json: row.anchor_pack_json.clone(), + draft_json: Some(serialize_json(&draft)), + last_assistant_reply: Some(input.error_message), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at, + }, + ); + + get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + fn save_puzzle_form_draft_tx( ctx: &TxContext, input: PuzzleFormDraftSaveInput, diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index f336c6d6..e6646295 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -702,14 +702,22 @@ function mockNarrowMobileLayout() { }); } -function renderProfileView( +function ProfileHomeViewHarness({ onRechargeSuccess = vi.fn(), - profileDashboardOverrides: Partial< + profileDashboardOverrides = {}, + userOverrides = {}, + activeTab = 'profile', + profileTaskRefreshKey = 0, +}: { + onRechargeSuccess?: () => void | Promise; + profileDashboardOverrides?: Partial< NonNullable - > = {}, - userOverrides: Partial = {}, -) { - return render( + >; + userOverrides?: Partial; + activeTab?: RpgEntryHomeViewProps['activeTab']; + profileTaskRefreshKey?: number; +}) { + return ( - , + + ); +} + +function renderProfileView( + onRechargeSuccess = vi.fn(), + profileDashboardOverrides: Partial< + NonNullable + > = {}, + userOverrides: Partial = {}, + profileTaskRefreshKey = 0, +) { + return render( + , ); } @@ -2026,7 +2053,7 @@ test('mobile profile page matches the reference layout sections', async () => { ).toBeTruthy(); expect( shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'), - ).toHaveLength(5); + ).toHaveLength(4); expect( shortcutRegion .querySelector('.platform-profile-shortcut-grid') @@ -2034,7 +2061,6 @@ test('mobile profile page matches the reference layout sections', async () => { ).toBe(true); for (const label of [ '泥点充值', - '邀请好友', '兑换码', '玩家社区', '反馈与建议', @@ -2172,28 +2198,25 @@ test('wallet ledger modal shows empty and error states', async () => { expect(screen.getByText('重新加载')).toBeTruthy(); }); -test('profile invite shortcut shows reward subtitle and invited users', async () => { +test('profile community shortcut shows reward subtitle and invited users', async () => { const user = userEvent.setup(); renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() }); - const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); - expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy(); + expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull(); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy(); - await user.click(inviteButton); + await user.click(communityButton); expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1); - expect( - await screen.findByText('邀请一个用户注册,双方都可以获得30泥点。'), - ).toBeTruthy(); - expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy(); - expect(screen.getByText('成功邀请')).toBeTruthy(); - expect(screen.getByText('被邀请玩家')).toBeTruthy(); - expect(screen.queryByText('已奖')).toBeNull(); - expect(screen.queryByText('今日')).toBeNull(); + expect(screen.getByAltText('玩家社区微信群二维码')).toBeTruthy(); + expect(screen.getByAltText('玩家社区 QQ 群二维码')).toBeTruthy(); + expect(screen.getByText('微信群')).toBeTruthy(); + expect(screen.getByText('QQ群')).toBeTruthy(); + expect(screen.queryByText('成功邀请')).toBeNull(); + expect(screen.queryByText('被邀请玩家')).toBeNull(); }); test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => { @@ -2203,50 +2226,55 @@ test('profile page hides legacy redeem invite secondary shortcut for fresh accou { createdAt: buildFreshProfileCreatedAt() }, ); - const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); await waitFor(() => { expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1); }); - expect(inviteButton).toBeTruthy(); + expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull(); expect(communityButton).toBeTruthy(); expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); expect(screen.queryByRole('button', { name: /填邀请码/u })).toBeNull(); }); test('profile redeem invite shortcut hides after redeemed or one day old', async () => { - const user = userEvent.setup(); - - mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce( - mockBuildReferralCenter({ - invitedUsers: [], - hasRedeemedCode: true, - boundInviterUserId: 'user-2', - boundAt: '2026-05-01T08:00:00Z', - }), - ); const { unmount } = renderProfileView(); - await user.click(screen.getByRole('button', { name: /邀请好友/u })); - await screen.findByText('成功邀请'); const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' }); + expect( + within(firstShortcutRegion).queryByRole('button', { name: /邀请好友/u }), + ).toBeNull(); expect( within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }), ).toBeNull(); - await screen.findByText('1 / 1'); + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1); + }); + await act(async () => { + await Promise.resolve(); + }); unmount(); renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' }); const expiredShortcutRegion = screen.getByRole('region', { name: '常用功能', }); + expect( + within(expiredShortcutRegion).queryByRole('button', { + name: /邀请好友/u, + }), + ).toBeNull(); expect( within(expiredShortcutRegion).queryByRole('button', { name: /填邀请码/u, }), ).toBeNull(); - await screen.findByText('1 / 1'); + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2); + }); + await act(async () => { + await Promise.resolve(); + }); }); test('invite query opens login modal for logged out users', async () => { @@ -2303,6 +2331,22 @@ test('profile redeem invite query modal submits code after login', async () => { expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); }); +test('profile task center reloads when refresh key changes', async () => { + const { rerender } = renderProfileView(); + + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2); + }); +}); + test('opens reward code modal from profile action on mobile', async () => { const user = userEvent.setup(); @@ -2330,8 +2374,8 @@ test('profile page shows legal entries and hides archive shortcuts', async () => ?.classList.contains('platform-profile-shortcut-grid'), ).toBe(true); expect( - within(shortcutRegion).getByRole('button', { name: /邀请好友/u }), - ).toBeTruthy(); + within(shortcutRegion).queryByRole('button', { name: /邀请好友/u }), + ).toBeNull(); expect( within(shortcutRegion).getByRole('button', { name: /玩家社区/u }), ).toBeTruthy(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 851e1ca1..aba29612 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -29,7 +29,6 @@ import { Star, ThumbsUp, Ticket, - UserPlus, UserRound, XCircle, } from 'lucide-react'; @@ -50,7 +49,6 @@ import profileClockImage from '../../../media/profile/_Image (1).png'; import profileGamepadImage from '../../../media/profile/_Image (2).png'; import profileStillLifeImage from '../../../media/profile/_Image (3).png'; import profileCoinsImage from '../../../media/profile/_Image (4).png'; -import profileInviteImage from '../../../media/profile/_Image (5).png'; import profileGiftImage from '../../../media/profile/_Image (6).png'; import profileCommunityImage from '../../../media/profile/_Image (7).png'; import profileFeedbackImage from '../../../media/profile/_Image (8).png'; @@ -4026,6 +4024,7 @@ export function RpgEntryHomeView({ useState(null); const [taskCenterError, setTaskCenterError] = useState(null); const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false); + const taskCenterRequestIdRef = useRef(0); const [claimingTaskId, setClaimingTaskId] = useState(null); const [taskClaimSuccess, setTaskClaimSuccess] = useState(null); const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); @@ -4045,6 +4044,7 @@ export function RpgEntryHomeView({ : readProfileInviteCodeFromLocationSearch(window.location.search), [], ); + const promptedLoginForInviteQueryRef = useRef(false); const autoOpenedInviteQueryRef = useRef(false); const [referralRedeemCode, setReferralRedeemCode] = useState( pendingProfileInviteCode, @@ -4375,12 +4375,15 @@ export function RpgEntryHomeView({ return; } - autoOpenedInviteQueryRef.current = true; if (!authUi?.user) { - authUi?.openLoginModal(); + if (!promptedLoginForInviteQueryRef.current) { + promptedLoginForInviteQueryRef.current = true; + authUi?.openLoginModal(); + } return; } + autoOpenedInviteQueryRef.current = true; setReferralRedeemCode(pendingProfileInviteCode); setReferralError(null); setReferralSuccess(null); @@ -4779,21 +4782,34 @@ export function RpgEntryHomeView({ }; }, [handleWechatPayResult]); const loadTaskCenter = useCallback(() => { + const requestId = ++taskCenterRequestIdRef.current; setTaskCenterError(null); setIsLoadingTaskCenter(true); void getRpgProfileTasks() - .then(setTaskCenter) + .then((center) => { + if (requestId === taskCenterRequestIdRef.current) { + setTaskCenter(center); + } + }) .catch((error: unknown) => { + if (requestId !== taskCenterRequestIdRef.current) { + return; + } setTaskCenter(null); setTaskCenterError( error instanceof Error ? error.message : '读取每日任务失败', ); }) - .finally(() => setIsLoadingTaskCenter(false)); + .finally(() => { + if (requestId === taskCenterRequestIdRef.current) { + setIsLoadingTaskCenter(false); + } + }); }, []); useEffect(() => { if (activeTab !== 'profile' || !isAuthenticated) { + taskCenterRequestIdRef.current += 1; setTaskCenter(null); setTaskCenterError(null); return; @@ -6243,13 +6259,6 @@ export function RpgEntryHomeView({ imageSrc={profileCoinsImage} onClick={openRechargeOrRewardCodeModal} /> - openProfilePopupPanel('invite')} - />