From 11c5e3edf4a4ad3a660d1ed2f041a0d393c92d06 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:21:05 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=88=9B=E4=BD=9C=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=AE=A2=E9=98=85=E6=B6=88=E6=81=AF=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 订阅消息任务名称改为玩法模板名。 拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说在草稿生成成功或失败终态发送通知。 订阅消息泥点字段按本次生成结算后的实际扣除展示,失败退款后显示0。 更新微信订阅消息运维和支付方案文档口径。 --- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...【技术方案】微信虚拟支付接入-2026-05-26.md | 2 +- server-rs/crates/api-server/src/jump_hop.rs | 103 ++++++++++++++---- server-rs/crates/api-server/src/match3d.rs | 4 + .../crates/api-server/src/match3d/draft.rs | 69 ++++++++---- .../crates/api-server/src/puzzle/handlers.rs | 4 + .../crates/api-server/src/square_hole.rs | 57 +++++++++- .../crates/api-server/src/visual_novel.rs | 51 ++++++++- .../src/wechat_subscribe_message.rs | 26 ++++- .../crates/api-server/src/wooden_fish.rs | 87 +++++++++++++-- 10 files changed, 349 insertions(+), 56 deletions(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 14db7a20..ac61896e 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -55,7 +55,7 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 -微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED`、`WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID` 和 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;H5 在拼图 `compile_puzzle_draft` 生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在拼图资产生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。模板 `time4` 字段固定发送北京时间 `YYYY-MM-DD HH:mm`,不要使用内部微秒时间戳、秒级时间戳或带时区后缀的 RFC3339 字符串,否则微信会返回 `argument invalid! data.time4.value invalid`。 +微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED`、`WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID` 和 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;H5 在生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在玩法草稿生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。模板 `thing1` 字段发送玩法模板名,例如 `拼图`、`敲木鱼`、`抓大鹅`;`number6` 字段发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`。模板 `time4` 字段固定发送北京时间 `YYYY-MM-DD HH:mm`,不要使用内部微秒时间戳、秒级时间戳或带时区后缀的 RFC3339 字符串,否则微信会返回 `argument invalid! data.time4.value invalid`。当前已接入拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说的草稿生成终态;分槽素材生成或发布动作不得直接复用生成结果通知,避免一次作品生成产生多条订阅消息。 如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。 diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index af959025..b91c8647 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -72,5 +72,5 @@ npm run check:encoding - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 - Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 - WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底;后端收到虚拟支付消息推送并入账后会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 `done`。 -- 小程序订阅消息用于拼图 AI 创作生成结果通知:H5 在拼图 `compile_puzzle_draft` 生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在拼图后台首图 / UI 资产生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm`。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 +- 小程序订阅消息用于 AI 创作生成结果通知:H5 在生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在玩法草稿生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。模板 `thing1` 发送玩法模板名,`number6` 发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`;模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm`。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 55914d7e..0c08129a 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -45,6 +45,10 @@ use crate::{ }, request_context::RequestContext, state::AppState, + wechat_subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; @@ -150,27 +154,86 @@ pub async fn execute_jump_hop_action( let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let mut payload = payload; - maybe_generate_jump_hop_assets( - &state, - &request_context, - session_id.as_str(), - owner_user_id.as_str(), - &mut payload, - ) - .await?; - let response = state - .spacetime_client() - .execute_jump_hop_action(session_id, owner_user_id, payload) - .await - .map_err(|error| { - jump_hop_error_response( - &request_context, - JUMP_HOP_CREATION_PROVIDER, - map_jump_hop_client_error(error), - ) - })?; + let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft); + let generation_points_cost = if is_compile_draft { + resolve_jump_hop_generation_points_cost(&state).await + } else { + 0 + }; + let result = async { + maybe_generate_jump_hop_assets( + &state, + &request_context, + session_id.as_str(), + owner_user_id.as_str(), + &mut payload, + ) + .await?; + state + .spacetime_client() + .execute_jump_hop_action(session_id, owner_user_id.clone(), payload) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + }) + } + .await; - Ok(json_success_body(Some(&request_context), response)) + match result { + Ok(response) => { + if is_compile_draft && response.session.status == JumpHopGenerationStatus::Ready { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()), + work_name: response + .session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + Ok(json_success_body(Some(&request_context), response)) + } + Err(response) => { + if is_compile_draft && response.status().is_server_error() { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()), + work_name: None, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + Err(response) + } + } +} + +async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 { + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + JUMP_HOP_TEMPLATE_ID, + u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ) + .await } pub async fn publish_jump_hop_work( diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 5f4f2e22..a37c44a1 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -84,6 +84,10 @@ use crate::{ vector_engine_audio_generation::{ GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation, }, + wechat_subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent"; diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index 103bf594..eb99ec38 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -323,27 +323,56 @@ pub(super) async fn compile_match3d_draft_for_session( ) .await; - if let Err(response) = result.as_ref() - && response.status().is_server_error() - { - let failure_message = match3d_response_failure_message(response); - persist_failed_match3d_draft_generation( - state, - request_context, - authenticated, - compile_session_id, - compile_owner_user_id, - compile_profile_id, - compile_initial_game_name, - compile_requested_summary, - compile_initial_tags, - compile_requested_cover_image_src, - failure_message, - ) - .await; + match result { + Ok((session, generated_item_assets)) => { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id: compile_owner_user_id.clone(), + task_name: Some("抓大鹅".to_string()), + work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + Ok((session, generated_item_assets)) + } + Err(response) if response.status().is_server_error() => { + let failure_message = match3d_response_failure_message(&response); + persist_failed_match3d_draft_generation( + state, + request_context, + authenticated, + compile_session_id, + compile_owner_user_id.clone(), + compile_profile_id, + compile_initial_game_name.clone(), + compile_requested_summary, + compile_initial_tags, + compile_requested_cover_image_src, + failure_message, + ) + .await; + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id: compile_owner_user_id, + task_name: Some("抓大鹅".to_string()), + work_name: Some(compile_initial_game_name), + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + Err(response) + } + Err(response) => Err(response), } - - result } #[allow(clippy::too_many_arguments)] diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index ec10fb5c..2fa1a265 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -641,6 +641,7 @@ pub async fn execute_puzzle_agent_action( state.root_state(), GenerationResultSubscribeMessage { owner_user_id, + task_name: Some("拼图".to_string()), work_name: None, status: GenerationResultSubscribeMessageStatus::Failed, consumed_points: 0, @@ -768,6 +769,7 @@ pub async fn execute_puzzle_agent_action( &background_root_state, GenerationResultSubscribeMessage { owner_user_id: background_owner_user_id.clone(), + task_name: Some("拼图".to_string()), work_name: session .draft .as_ref() @@ -814,6 +816,7 @@ pub async fn execute_puzzle_agent_action( &background_root_state, GenerationResultSubscribeMessage { owner_user_id: background_owner_user_id.clone(), + task_name: Some("拼图".to_string()), work_name: background_work_name.clone(), status: GenerationResultSubscribeMessageStatus::Failed, @@ -1491,6 +1494,7 @@ pub async fn execute_puzzle_agent_action( state.root_state(), GenerationResultSubscribeMessage { owner_user_id: owner_user_id.clone(), + task_name: Some("拼图".to_string()), work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()), status: GenerationResultSubscribeMessageStatus::Succeeded, consumed_points: operation_consumed_points, diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index 45f0e05a..d8ed28b4 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -81,12 +81,18 @@ use crate::{ SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn, }, state::AppState, + wechat_subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent"; const SQUARE_HOLE_WORKS_PROVIDER: &str = "square-hole-works"; const SQUARE_HOLE_RUNTIME_PROVIDER: &str = "square-hole-runtime"; +const SQUARE_HOLE_TEMPLATE_ID: &str = "square-hole"; +const SQUARE_HOLE_TEMPLATE_NAME: &str = "方洞"; const SQUARE_HOLE_DEFAULT_THEME: &str = "纸箱"; const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能"; const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12; @@ -1112,14 +1118,21 @@ async fn compile_square_hole_draft_for_session( .as_ref() .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); - state + let resolved_game_name = game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))); + let generation_points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + SQUARE_HOLE_TEMPLATE_ID, + u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ) + .await; + let result = state .spacetime_client() .compile_square_hole_draft(SquareHoleCompileDraftRecordInput { session_id, - owner_user_id, + owner_user_id: owner_user_id.clone(), profile_id: build_prefixed_uuid_id(SQUARE_HOLE_PROFILE_ID_PREFIX), author_display_name: resolve_author_display_name(state, authenticated), - game_name: game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))), + game_name: resolved_game_name.clone(), summary_text: summary, tags_json, cover_image_src, @@ -1132,7 +1145,43 @@ async fn compile_square_hole_draft_for_session( SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) - }) + }); + match result { + Ok(session) => { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()), + work_name: session.draft.as_ref().map(|draft| draft.game_name.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + Ok(session) + } + Err(response) => { + if response.status().is_server_error() { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(SQUARE_HOLE_TEMPLATE_NAME.to_string()), + work_name: resolved_game_name, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + Err(response) + } + } } mod visual_assets; diff --git a/server-rs/crates/api-server/src/visual_novel.rs b/server-rs/crates/api-server/src/visual_novel.rs index 08c1749b..cc25deda 100644 --- a/server-rs/crates/api-server/src/visual_novel.rs +++ b/server-rs/crates/api-server/src/visual_novel.rs @@ -35,6 +35,10 @@ use crate::{ prompt::visual_novel as vn_prompt, request_context::RequestContext, state::AppState, + wechat_subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_author::resolve_work_author_by_user_id, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; @@ -1743,8 +1747,15 @@ async fn compile_visual_novel_session_inner( current_utc_iso().as_str(), ); let projection = project_draft_for_work(&draft, &profile_id)?; + let notification_work_name = projection.work_title.clone(); + let generation_points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + VISUAL_NOVEL_RUNTIME_KIND, + u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ) + .await; let author = resolve_work_author_by_user_id(state, &owner_user_id, None, None); - let compiled_session = state + let compile_result = state .spacetime_client() .compile_visual_novel_work_profile(VisualNovelWorkCompileRecordInput { session_id: session_id.clone(), @@ -1761,7 +1772,43 @@ async fn compile_visual_novel_session_inner( .await .map_err(|error| { visual_novel_error_response(request_context, map_spacetime_error(error)) - })?; + }); + let compiled_session = match compile_result { + Ok(session) => { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id: owner_user_id.clone(), + task_name: Some("视觉小说".to_string()), + work_name: Some(notification_work_name.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + session + } + Err(response) => { + if response.status().is_server_error() { + send_generation_result_subscribe_message_after_completion( + state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some("视觉小说".to_string()), + work_name: Some(notification_work_name), + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + return Err(response); + } + }; let work = state .spacetime_client() .get_visual_novel_work_detail(profile_id, owner_user_id) diff --git a/server-rs/crates/api-server/src/wechat_subscribe_message.rs b/server-rs/crates/api-server/src/wechat_subscribe_message.rs index 5ff638fe..8946afaf 100644 --- a/server-rs/crates/api-server/src/wechat_subscribe_message.rs +++ b/server-rs/crates/api-server/src/wechat_subscribe_message.rs @@ -19,6 +19,7 @@ pub enum GenerationResultSubscribeMessageStatus { #[derive(Clone, Debug)] pub struct GenerationResultSubscribeMessage { pub owner_user_id: String, + pub task_name: Option, pub work_name: Option, pub status: GenerationResultSubscribeMessageStatus, pub consumed_points: u64, @@ -110,7 +111,13 @@ fn build_generation_result_template_data( BTreeMap::from([ ( "thing1".to_string(), - truncate_template_value(GENERATION_RESULT_TASK_NAME, 20), + truncate_template_value( + message + .task_name + .as_deref() + .unwrap_or(GENERATION_RESULT_TASK_NAME), + 20, + ), ), ( "phrase2".to_string(), @@ -192,6 +199,7 @@ mod tests { fn failed_generation_result_template_uses_failed_status_and_zero_points() { let data = build_generation_result_template_data(&GenerationResultSubscribeMessage { owner_user_id: "user-1".to_string(), + task_name: Some("拼图".to_string()), work_name: Some("首关拼图".to_string()), status: GenerationResultSubscribeMessageStatus::Failed, consumed_points: 0, @@ -207,6 +215,7 @@ mod tests { fn generation_result_template_time_uses_wechat_time_format() { let data = build_generation_result_template_data(&GenerationResultSubscribeMessage { owner_user_id: "user-1".to_string(), + task_name: Some("拼图".to_string()), work_name: Some("首关拼图".to_string()), status: GenerationResultSubscribeMessageStatus::Succeeded, consumed_points: 15, @@ -219,4 +228,19 @@ mod tests { Some("1970-01-01 08:00") ); } + + #[test] + fn generation_result_template_uses_task_template_name() { + let data = build_generation_result_template_data(&GenerationResultSubscribeMessage { + owner_user_id: "user-1".to_string(), + task_name: Some("敲木鱼".to_string()), + work_name: Some("功德木鱼".to_string()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: 10, + completed_at_micros: 0, + page: None, + }); + + assert_eq!(data.get("thing1").map(String::as_str), Some("敲木鱼")); + } } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index a0e60220..e89e8c34 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -43,6 +43,10 @@ use crate::{ platform_errors::map_oss_error, request_context::RequestContext, state::AppState, + wechat_subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, }; const WOODEN_FISH_PROVIDER: &str = "wooden-fish"; @@ -147,6 +151,15 @@ pub async fn execute_wooden_fish_action( wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let author_display_name = resolve_author_display_name(&state, &authenticated); + let is_compile_draft = matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + ); + let generation_points_cost = if is_compile_draft { + resolve_wooden_fish_generation_points_cost(&state).await + } else { + 0 + }; let result = execute_wooden_fish_action_with_generated_assets( &state, &request_context, @@ -160,21 +173,55 @@ pub async fn execute_wooden_fish_action( .as_ref() .err() .is_some_and(|response| response.status().is_server_error()) - && matches!( - payload.action_type, - shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft - ) + && is_compile_draft { - mark_wooden_fish_generation_failed( + let failed_at_micros = current_utc_micros(); + let work_name = + resolve_wooden_fish_notification_work_name(&state, &session_id, &owner_user_id).await; + if mark_wooden_fish_generation_failed( &state, &request_context, &session_id, owner_user_id.as_str(), author_display_name.as_str(), ) - .await; + .await + { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id: owner_user_id.clone(), + task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()), + work_name, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: failed_at_micros, + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } } let response = result?; + if is_compile_draft && response.session.status == WoodenFishGenerationStatus::Ready { + send_generation_result_subscribe_message_after_completion( + &state, + GenerationResultSubscribeMessage { + owner_user_id, + task_name: Some(WOODEN_FISH_TEMPLATE_NAME.to_string()), + work_name: response + .session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: generation_points_cost, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } Ok(json_success_body(Some(&request_context), response)) } @@ -588,13 +635,37 @@ async fn execute_wooden_fish_action_with_generated_assets( }) } +async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 { + crate::creation_entry_config::resolve_creation_entry_mud_point_cost( + state, + WOODEN_FISH_TEMPLATE_ID, + u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST), + ) + .await +} + +async fn resolve_wooden_fish_notification_work_name( + state: &AppState, + session_id: &str, + owner_user_id: &str, +) -> Option { + state + .spacetime_client() + .get_wooden_fish_session(session_id.to_string(), owner_user_id.to_string()) + .await + .ok() + .and_then(|session| session.draft) + .map(|draft| draft.work_title) + .filter(|value| !value.trim().is_empty()) +} + async fn mark_wooden_fish_generation_failed( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, author_display_name: &str, -) { +) -> bool { if let Err(error) = state .spacetime_client() .mark_wooden_fish_generation_failed( @@ -612,7 +683,9 @@ async fn mark_wooden_fish_generation_failed( error = %error, "敲木鱼草稿生成失败后的状态回写失败" ); + return false; } + true } fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset {