diff --git a/.env.example b/.env.example index 2a1b98e2..4d8a1387 100644 --- a/.env.example +++ b/.env.example @@ -111,6 +111,9 @@ WECHAT_MOCK_DISPLAY_NAME="微信旅人" WECHAT_MOCK_AVATAR_URL="" WECHAT_MINIPROGRAM_MESSAGE_TOKEN="" WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY="" +WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED="true" +WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU" +WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal" # Model name for chat completions. VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 587c3741..53707450 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -55,6 +55,8 @@ 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创作生成结果通知`;后端只在拼图资产生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。 + 如果本地 `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` 指向可发布的本地库。 本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如: diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index 296fd2ed..ded95c27 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -33,6 +33,9 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY=<现网 AppKey> WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey,可选> WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token> WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey> +WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED=true +WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID=m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU +WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE=formal WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 ``` @@ -69,4 +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 创作生成结果通知:通知发送只允许发生在拼图后台首图 / UI 资产生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/miniprogram/config.js b/miniprogram/config.js index c521817f..d884a4d0 100644 --- a/miniprogram/config.js +++ b/miniprogram/config.js @@ -15,6 +15,10 @@ const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65'; // 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断。 const MINI_PROGRAM_ENV = 'release'; +// 中文注释:AI 创作生成结果订阅消息模板,需与微信公众平台后台的模板 ID 保持一致。 +const GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID = + 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; + // 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。 const WEB_VIEW_SOURCE_QUERY = { clientType: 'mini_program', @@ -25,6 +29,7 @@ module.exports = { API_BASE_URL, DEV_API_BASE_URL, DEV_WEB_VIEW_ENTRY_URL, + GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID, MINI_PROGRAM_APP_ID, MINI_PROGRAM_ENV, WEB_VIEW_ENTRY_URL, diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index db8f0233..491247b7 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -5,6 +5,7 @@ const { API_BASE_URL, DEV_API_BASE_URL, DEV_WEB_VIEW_ENTRY_URL, + GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID, MINI_PROGRAM_APP_ID, MINI_PROGRAM_ENV, WEB_VIEW_ENTRY_URL, @@ -20,6 +21,8 @@ const AUTH_ACTION_LOGIN = 'login'; const PAY_RESULT_RECHECK_DELAY_MS = 120; const WEB_VIEW_SHARE_TITLE = '陶泥儿'; const WEB_VIEW_SHARE_PATH = '/pages/web-view/index'; +const SUBSCRIBE_MESSAGE_TYPE = 'genarrative:request-subscribe-message'; +const GENERATION_RESULT_SUBSCRIBE_SCENE = 'generation-result'; function showWebViewShareMenu() { if (typeof wx.showShareMenu !== 'function') { @@ -415,6 +418,36 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) { }); } +function requestGenerationResultSubscribeMessage() { + return new Promise((resolve) => { + if (!GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID) { + resolve({ ok: false, reason: 'missing_template_id' }); + return; + } + if (typeof wx.requestSubscribeMessage !== 'function') { + resolve({ ok: false, reason: 'unsupported' }); + return; + } + + wx.requestSubscribeMessage({ + tmplIds: [GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID], + success(result) { + resolve({ + ok: result[GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID] === 'accept', + result, + }); + }, + fail(error) { + console.warn('[web-view] request subscribe message failed', error); + resolve({ + ok: false, + reason: error && error.errMsg ? error.errMsg : 'failed', + }); + }, + }); + }); +} + async function resolveAuthResult(displayName) { const code = await wxLogin(); const response = await requestMiniProgramLogin(code, displayName); @@ -712,7 +745,23 @@ Page({ }, handleWebViewMessage(event) { - // 中文注释:支付由独立 native 页面承接,web-view 消息只保留调试输出。 + const messages = + event && event.detail && Array.isArray(event.detail.data) + ? event.detail.data + : []; + const shouldRequestSubscribe = messages.some((message) => { + const payload = message && typeof message === 'object' ? message : {}; + return ( + payload.type === SUBSCRIBE_MESSAGE_TYPE && + payload.scene === GENERATION_RESULT_SUBSCRIBE_SCENE + ); + }); + if (shouldRequestSubscribe) { + void requestGenerationResultSubscribeMessage(); + return; + } + + // 中文注释:支付由独立 native 页面承接,其他 web-view 消息只保留调试输出。 console.info('[web-view] message', event.detail); }, diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index d54f8001..1f661268 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -129,6 +129,7 @@ dependencies = [ "platform-llm", "platform-oss", "platform-speech", + "platform-wechat", "reqwest 0.12.28", "ring", "serde", @@ -2508,6 +2509,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "platform-wechat" +version = "0.1.0" +dependencies = [ + "reqwest 0.12.28", + "serde", + "serde_json", + "tracing", + "url", +] + [[package]] name = "png" version = "0.18.1" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 8cbd5eea..cdc461bd 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -37,6 +37,7 @@ members = [ "crates/platform-hyper3d", "crates/platform-image", "crates/platform-llm", + "crates/platform-wechat", "crates/platform-speech", "crates/platform-agent", "crates/shared-contracts", @@ -85,6 +86,7 @@ platform-image = { path = "crates/platform-image", default-features = false } platform-llm = { path = "crates/platform-llm", default-features = false } platform-oss = { path = "crates/platform-oss", default-features = false } platform-speech = { path = "crates/platform-speech", default-features = false } +platform-wechat = { path = "crates/platform-wechat", default-features = false } shared-contracts = { path = "crates/shared-contracts", default-features = false } shared-kernel = { path = "crates/shared-kernel", default-features = false } shared-logging = { path = "crates/shared-logging", default-features = false } diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 0374defc..dc38ad00 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -44,6 +44,7 @@ platform-image = { workspace = true } platform-llm = { workspace = true } platform-oss = { workspace = true } platform-speech = { workspace = true } +platform-wechat = { workspace = true } hmac = { workspace = true } ring = { workspace = true } serde = { workspace = true } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 3fe02061..6ca7896d 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -100,6 +100,10 @@ pub struct AppConfig { pub wechat_mini_program_virtual_payment_sandbox_app_key: Option, pub wechat_mini_program_message_token: Option, pub wechat_mini_program_message_encoding_aes_key: Option, + pub wechat_mini_program_subscribe_message_enabled: bool, + pub wechat_mini_program_generation_result_template_id: Option, + pub wechat_mini_program_subscribe_message_endpoint: String, + pub wechat_mini_program_subscribe_message_state: String, pub wechat_mini_program_virtual_payment_env: u8, pub oss_bucket: Option, pub oss_endpoint: Option, @@ -250,6 +254,13 @@ impl Default for AppConfig { wechat_mini_program_virtual_payment_sandbox_app_key: None, wechat_mini_program_message_token: None, wechat_mini_program_message_encoding_aes_key: None, + wechat_mini_program_subscribe_message_enabled: true, + wechat_mini_program_generation_result_template_id: Some( + "m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU".to_string(), + ), + wechat_mini_program_subscribe_message_endpoint: + "https://api.weixin.qq.com/cgi-bin/message/subscribe/send".to_string(), + wechat_mini_program_subscribe_message_state: "formal".to_string(), wechat_mini_program_virtual_payment_env: 0, oss_bucket: None, oss_endpoint: None, @@ -613,6 +624,26 @@ impl AppConfig { read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_TOKEN"]); config.wechat_mini_program_message_encoding_aes_key = read_first_non_empty_env(&["WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"]); + if let Some(enabled) = + read_first_bool_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"]) + { + config.wechat_mini_program_subscribe_message_enabled = enabled; + } + if let Some(template_id) = + read_first_non_empty_env(&["WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"]) + { + config.wechat_mini_program_generation_result_template_id = Some(template_id); + } + if let Some(endpoint) = + read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENDPOINT"]) + { + config.wechat_mini_program_subscribe_message_endpoint = endpoint; + } + if let Some(state) = + read_first_non_empty_env(&["WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"]) + { + config.wechat_mini_program_subscribe_message_state = state; + } if let Some(env) = read_first_u8_env(&["WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"]) && env <= 1 { @@ -1419,6 +1450,9 @@ mod tests { std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); + std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"); + std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"); + std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"); std::env::set_var("WECHAT_PAY_ENABLED", "true"); std::env::set_var("WECHAT_PAY_PROVIDER", "real"); @@ -1446,6 +1480,12 @@ mod tests { "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", ); + std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED", "true"); + std::env::set_var( + "WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID", + "tmpl-generation-result", + ); + std::env::set_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE", "trial"); std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV", "1"); } @@ -1497,6 +1537,14 @@ mod tests { .as_deref(), Some("sandbox-app-key-001") ); + assert!(config.wechat_mini_program_subscribe_message_enabled); + assert_eq!( + config + .wechat_mini_program_generation_result_template_id + .as_deref(), + Some("tmpl-generation-result") + ); + assert_eq!(config.wechat_mini_program_subscribe_message_state, "trial"); assert_eq!(config.wechat_mini_program_virtual_payment_env, 1); unsafe { @@ -1514,6 +1562,9 @@ mod tests { std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_TOKEN"); std::env::remove_var("WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY"); + std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED"); + std::env::remove_var("WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID"); + std::env::remove_var("WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV"); } } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index bb1098de..aaecd923 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -92,6 +92,7 @@ mod volcengine_speech; mod wechat_auth; mod wechat_pay; mod wechat_provider; +mod wechat_subscribe_message; mod wooden_fish; mod work_author; mod work_play_tracking; diff --git a/server-rs/crates/api-server/src/platform_errors.rs b/server-rs/crates/api-server/src/platform_errors.rs index 2acdd925..6da6f87c 100644 --- a/server-rs/crates/api-server/src/platform_errors.rs +++ b/server-rs/crates/api-server/src/platform_errors.rs @@ -2,6 +2,7 @@ use axum::http::{HeaderValue, StatusCode}; use platform_auth::{AuthPlatformErrorKind, WechatProviderError}; use platform_llm::{LlmError, LlmErrorKind}; use platform_oss::{OssError, OssErrorKind}; +use platform_wechat::{WechatError, WechatErrorKind}; use serde_json::json; use crate::http_error::AppError; @@ -68,6 +69,17 @@ pub fn map_wechat_provider_error(error: WechatProviderError) -> AppError { AppError::from_status(status).with_message(error.to_string()) } +pub fn map_wechat_error(error: WechatError) -> AppError { + let status = match error.kind() { + WechatErrorKind::InvalidConfig => StatusCode::SERVICE_UNAVAILABLE, + WechatErrorKind::RequestFailed + | WechatErrorKind::DeserializeFailed + | WechatErrorKind::Upstream => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_message(error.to_string()) +} + pub fn attach_retry_after(error: AppError, retry_after_seconds: u64) -> AppError { match HeaderValue::from_str(&retry_after_seconds.to_string()) { Ok(value) => error.with_header("retry-after", value), diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index dc1be22a..6aef4f88 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -58,16 +58,15 @@ use spacetime_client::{ PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, - PuzzleGeneratedImageCandidateRecord, - PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, - PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, - PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, - SpacetimeClientError, + 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; @@ -106,6 +105,10 @@ use crate::{ puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json}, request_context::RequestContext, state::{AppState, PuzzleApiState}, + wechat_subscribe_message::{ + GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus, + send_generation_result_subscribe_message_after_completion, + }, work_author::resolve_puzzle_work_author_by_user_id, work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success}, }; diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 873495f7..e3e79aa4 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -617,13 +617,14 @@ pub async fn execute_puzzle_agent_action( let log_session_id = session_id.clone(); let log_owner_user_id = owner_user_id.clone(); async move { + let failed_at_micros = current_utc_micros(); let result = state .spacetime_client() .mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput { session_id, - owner_user_id, + owner_user_id: owner_user_id.clone(), error_message, - failed_at_micros: current_utc_micros(), + failed_at_micros, }) .await; if let Err(error) = result { @@ -634,6 +635,19 @@ pub async fn execute_puzzle_agent_action( message = %error, "拼图草稿失败态回写失败,继续返回原始错误" ); + } else { + send_generation_result_subscribe_message_after_completion( + state.root_state(), + GenerationResultSubscribeMessage { + owner_user_id, + work_name: None, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: failed_at_micros, + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; } } }; @@ -677,10 +691,7 @@ pub async fn execute_puzzle_agent_action( ); state .spacetime_client() - .get_puzzle_agent_session( - compile_session_id.clone(), - owner_user_id.clone(), - ) + .get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone()) .await .map(mark_puzzle_initial_generation_started_snapshot) .map_err(map_puzzle_client_error) @@ -696,10 +707,9 @@ pub async fn execute_puzzle_agent_action( .map_err(map_puzzle_compile_error); match compiled_session { Ok(compiled_session) => { - let response_session = - mark_puzzle_initial_generation_started_snapshot( - compiled_session.clone(), - ); + let response_session = mark_puzzle_initial_generation_started_snapshot( + compiled_session.clone(), + ); let background_state = state.clone(); let background_request_context = request_context.clone(); let background_session_id = compile_session_id.clone(); @@ -708,13 +718,15 @@ pub async fn execute_puzzle_agent_action( let background_reference_image_src = primary_reference_image_src.map(str::to_string); let background_image_model = payload.image_model.clone(); + let background_work_name = compiled_session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()); let background_billing_asset_id = format!("{background_session_id}:compile_puzzle_draft"); tokio::spawn(async move { - let operation_owner_user_id = - background_owner_user_id.clone(); - let background_root_state = - background_state.root_state().clone(); + let operation_owner_user_id = background_owner_user_id.clone(); + let background_root_state = background_state.root_state().clone(); let operation_state = background_state.clone(); let result = execute_billable_asset_operation_with_cost( &background_root_state, @@ -739,6 +751,23 @@ pub async fn execute_puzzle_agent_action( .await; match result { Ok(session) => { + send_generation_result_subscribe_message_after_completion( + &background_root_state, + GenerationResultSubscribeMessage { + owner_user_id: background_owner_user_id.clone(), + work_name: session + .draft + .as_ref() + .map(|draft| draft.work_title.clone()), + status: + GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: + PUZZLE_IMAGE_GENERATION_POINTS_COST, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session.session_id, @@ -748,15 +777,15 @@ pub async fn execute_puzzle_agent_action( } Err(error) => { let error_message = error.body_text(); + let failed_at_micros = current_utc_micros(); let failure_result = background_state .spacetime_client() .mark_puzzle_draft_generation_failed( PuzzleDraftCompileFailureRecordInput { session_id: background_session_id.clone(), - owner_user_id: background_owner_user_id - .clone(), + owner_user_id: background_owner_user_id.clone(), error_message: error_message.clone(), - failed_at_micros: current_utc_micros(), + failed_at_micros, }, ) .await; @@ -768,6 +797,20 @@ pub async fn execute_puzzle_agent_action( message = %mark_error, "拼图首图后台生成失败态回写失败" ); + } else { + send_generation_result_subscribe_message_after_completion( + &background_root_state, + GenerationResultSubscribeMessage { + owner_user_id: background_owner_user_id.clone(), + work_name: background_work_name.clone(), + status: + GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: failed_at_micros, + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; } tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, @@ -778,9 +821,7 @@ pub async fn execute_puzzle_agent_action( ); } } - unregister_puzzle_background_compile_task( - &background_session_id, - ); + unregister_puzzle_background_compile_task(&background_session_id); }); Ok(response_session) } @@ -1428,6 +1469,29 @@ pub async fn execute_puzzle_agent_action( }; let session = session?; + if operation_type == "compile_puzzle_draft" + && session + .draft + .as_ref() + .is_some_and(|draft| draft.generation_status == "ready") + { + send_generation_result_subscribe_message_after_completion( + state.root_state(), + GenerationResultSubscribeMessage { + owner_user_id: owner_user_id.clone(), + work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: if payload.ai_redraw.unwrap_or(true) { + PUZZLE_IMAGE_GENERATION_POINTS_COST + } else { + 0 + }, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } Ok(json_success_body( Some(&request_context), diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index e19693a6..e03ca441 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -10,12 +10,12 @@ use std::{ use axum::extract::FromRef; use module_ai::{AiTaskService, InMemoryAiTaskStore}; +#[cfg(not(test))] +use module_auth::RefreshAuthStoreSnapshotResult; use module_auth::{ AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, RefreshSessionService, WechatAuthService, WechatAuthStateService, }; -#[cfg(not(test))] -use module_auth::RefreshAuthStoreSnapshotResult; use module_runtime::RuntimeSnapshotRecord; #[cfg(test)] use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; @@ -27,6 +27,7 @@ use platform_auth::{ }; use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider}; use platform_oss::{OssClient, OssConfig, OssError}; +use platform_wechat::{WechatClient, WechatConfig}; use serde_json::Value; use shared_contracts::creation_entry_config::CreationEntryConfigResponse; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; @@ -251,6 +252,7 @@ pub struct AppStateInner { wechat_auth_state_service: WechatAuthStateService, wechat_auth_service: WechatAuthService, wechat_provider: WechatProvider, + wechat_client: WechatClient, wechat_pay_client: WechatPayClient, #[cfg_attr(not(test), allow(dead_code))] ai_task_service: AiTaskService, @@ -385,6 +387,7 @@ impl AppState { WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes); let wechat_auth_service = WechatAuthService::new(auth_store.clone()); let wechat_provider = build_wechat_provider(&config); + let wechat_client = build_wechat_client(&config); let wechat_pay_client = WechatPayClient::from_config(&config).map_err(map_wechat_pay_init_error)?; let refresh_session_service = @@ -424,6 +427,7 @@ impl AppState { wechat_auth_state_service, wechat_auth_service, wechat_provider, + wechat_client, wechat_pay_client, ai_task_service, spacetime_client, @@ -776,6 +780,10 @@ impl AppState { &self.wechat_provider } + pub fn wechat_client(&self) -> &WechatClient { + &self.wechat_client + } + pub fn wechat_pay_client(&self) -> &WechatPayClient { &self.wechat_pay_client } @@ -1333,6 +1341,17 @@ fn build_oss_client(config: &AppConfig) -> Result, AppStateIni Ok(Some(OssClient::new(oss_config))) } +fn build_wechat_client(config: &AppConfig) -> WechatClient { + WechatClient::new(WechatConfig { + app_id: config.wechat_mini_program_app_id.clone(), + app_secret: config.wechat_mini_program_app_secret.clone(), + stable_access_token_endpoint: config.wechat_stable_access_token_endpoint.clone(), + subscribe_message_endpoint: config + .wechat_mini_program_subscribe_message_endpoint + .clone(), + }) +} + fn build_llm_client(config: &AppConfig) -> Result, AppStateInitError> { let Some(api_key) = config .llm_api_key diff --git a/server-rs/crates/api-server/src/wechat_subscribe_message.rs b/server-rs/crates/api-server/src/wechat_subscribe_message.rs new file mode 100644 index 00000000..4078c3dd --- /dev/null +++ b/server-rs/crates/api-server/src/wechat_subscribe_message.rs @@ -0,0 +1,197 @@ +use std::collections::BTreeMap; + +use axum::http::StatusCode; +use platform_wechat::WechatSubscribeMessageRequest; +use shared_kernel::format_timestamp_micros; +use tracing::{info, warn}; + +use crate::{http_error::AppError, platform_errors::map_wechat_error, state::AppState}; + +const GENERATION_RESULT_TASK_NAME: &str = "AI创作生成"; +const DEFAULT_WORK_NAME: &str = "AI创作作品"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GenerationResultSubscribeMessageStatus { + Succeeded, + Failed, +} + +#[derive(Clone, Debug)] +pub struct GenerationResultSubscribeMessage { + pub owner_user_id: String, + pub work_name: Option, + pub status: GenerationResultSubscribeMessageStatus, + pub consumed_points: u64, + pub completed_at_micros: i64, + pub page: Option, +} + +pub async fn send_generation_result_subscribe_message_after_completion( + state: &AppState, + message: GenerationResultSubscribeMessage, +) { + if let Err(error) = send_generation_result_subscribe_message(state, message).await { + warn!( + error = %error, + "微信小程序生成结果订阅消息发送失败,已忽略" + ); + } +} + +async fn send_generation_result_subscribe_message( + state: &AppState, + message: GenerationResultSubscribeMessage, +) -> Result<(), AppError> { + if !state.config.wechat_mini_program_subscribe_message_enabled { + return Ok(()); + } + let template_id = state + .config + .wechat_mini_program_generation_result_template_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("微信订阅消息模板 ID 未配置") + })?; + let user = state + .auth_user_service() + .get_user_by_id(&message.owner_user_id) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("读取微信订阅消息用户失败:{error}")) + })? + .ok_or_else(|| { + AppError::from_status(StatusCode::NOT_FOUND).with_message("微信订阅消息用户不存在") + })?; + let openid = user + .wechat_account + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("用户未绑定微信小程序 openid") + })?; + + state + .wechat_client() + .send_subscribe_message(WechatSubscribeMessageRequest { + touser: openid.to_string(), + template_id: template_id.to_string(), + page: message + .page + .clone() + .or_else(|| Some("/pages/web-view/index".to_string())), + miniprogram_state: Some( + normalize_miniprogram_state( + &state.config.wechat_mini_program_subscribe_message_state, + ) + .to_string(), + ), + lang: Some("zh_CN".to_string()), + data: build_generation_result_template_data(&message), + }) + .await + .map_err(map_wechat_error)?; + + info!( + owner_user_id = %message.owner_user_id, + template_id, + "微信小程序生成结果订阅消息已发送" + ); + Ok(()) +} + +fn build_generation_result_template_data( + message: &GenerationResultSubscribeMessage, +) -> BTreeMap { + BTreeMap::from([ + ( + "thing1".to_string(), + truncate_template_value(GENERATION_RESULT_TASK_NAME, 20), + ), + ( + "phrase2".to_string(), + truncate_template_value(message.status.template_status_label(), 5), + ), + ( + "time4".to_string(), + truncate_template_value( + &format_generation_completed_time(message.completed_at_micros), + 20, + ), + ), + ( + "thing5".to_string(), + truncate_template_value( + message.work_name.as_deref().unwrap_or(DEFAULT_WORK_NAME), + 20, + ), + ), + ( + "number6".to_string(), + truncate_template_value(&message.consumed_points.to_string(), 32), + ), + ]) +} + +impl GenerationResultSubscribeMessageStatus { + fn template_status_label(self) -> &'static str { + match self { + Self::Succeeded => "已完成", + Self::Failed => "生成失败", + } + } +} + +fn truncate_template_value(value: &str, max_chars: usize) -> String { + let trimmed = value.trim(); + let mut result = String::new(); + for character in trimmed.chars().take(max_chars) { + result.push(character); + } + if result.is_empty() { + DEFAULT_WORK_NAME.to_string() + } else { + result + } +} + +fn format_generation_completed_time(completed_at_micros: i64) -> String { + format_timestamp_micros(completed_at_micros) + .replace('T', " ") + .split('.') + .next() + .unwrap_or_default() + .to_string() +} + +fn normalize_miniprogram_state(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "developer" | "develop" | "dev" => "developer", + "trial" => "trial", + _ => "formal", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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(), + work_name: Some("首关拼图".to_string()), + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: 1_762_000_000_000_000, + page: None, + }); + + assert_eq!(data.get("phrase2").map(String::as_str), Some("生成失败")); + assert_eq!(data.get("number6").map(String::as_str), Some("0")); + } +} diff --git a/server-rs/crates/platform-wechat/Cargo.toml b/server-rs/crates/platform-wechat/Cargo.toml new file mode 100644 index 00000000..0b0f5db4 --- /dev/null +++ b/server-rs/crates/platform-wechat/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "platform-wechat" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +reqwest = { workspace = true, features = ["json", "rustls-tls"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } diff --git a/server-rs/crates/platform-wechat/src/lib.rs b/server-rs/crates/platform-wechat/src/lib.rs new file mode 100644 index 00000000..0935554e --- /dev/null +++ b/server-rs/crates/platform-wechat/src/lib.rs @@ -0,0 +1,234 @@ +use std::{collections::BTreeMap, error::Error, fmt}; + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::warn; +use url::Url; + +pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str = + "https://api.weixin.qq.com/cgi-bin/stable_token"; +pub const DEFAULT_WECHAT_SUBSCRIBE_MESSAGE_ENDPOINT: &str = + "https://api.weixin.qq.com/cgi-bin/message/subscribe/send"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatConfig { + pub app_id: Option, + pub app_secret: Option, + pub stable_access_token_endpoint: String, + pub subscribe_message_endpoint: String, +} + +#[derive(Clone, Debug)] +pub struct WechatClient { + client: Client, + config: WechatConfig, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatSubscribeMessageRequest { + pub touser: String, + pub template_id: String, + pub page: Option, + pub miniprogram_state: Option, + pub lang: Option, + pub data: BTreeMap, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum WechatError { + InvalidConfig(String), + RequestFailed(String), + DeserializeFailed(String), + Upstream(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WechatErrorKind { + InvalidConfig, + RequestFailed, + DeserializeFailed, + Upstream, +} + +#[derive(Debug, Deserialize)] +struct WechatStableAccessTokenResponse { + access_token: Option, + errcode: Option, + errmsg: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatSubscribeMessageResponse { + errcode: i64, + errmsg: Option, +} + +#[derive(Debug, Serialize)] +struct WechatTemplateDataValue { + value: String, +} + +impl WechatClient { + pub fn new(config: WechatConfig) -> Self { + Self { + client: Client::new(), + config, + } + } + + pub async fn send_subscribe_message( + &self, + request: WechatSubscribeMessageRequest, + ) -> Result<(), WechatError> { + let app_id = self + .config + .app_id + .as_deref() + .and_then(non_empty) + .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppID 未配置".to_string()))?; + let app_secret = self + .config + .app_secret + .as_deref() + .and_then(non_empty) + .ok_or_else(|| WechatError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()))?; + + let access_token = self.request_access_token(app_id, app_secret).await?; + let mut send_url = + Url::parse(&self.config.subscribe_message_endpoint).map_err(|error| { + WechatError::InvalidConfig(format!("微信订阅消息发送地址非法:{error}")) + })?; + send_url + .query_pairs_mut() + .append_pair("access_token", &access_token); + + let data = request + .data + .into_iter() + .map(|(key, value)| (key, WechatTemplateDataValue { value })) + .collect::>(); + let payload = json!({ + "touser": request.touser, + "template_id": request.template_id, + "page": request.page, + "miniprogram_state": request.miniprogram_state, + "lang": request.lang.unwrap_or_else(|| "zh_CN".to_string()), + "data": data, + }); + let response = self + .client + .post(send_url.as_str()) + .json(&payload) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信订阅消息请求失败"); + WechatError::RequestFailed("微信订阅消息请求失败".to_string()) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信订阅消息响应解析失败"); + WechatError::DeserializeFailed("微信订阅消息响应非法".to_string()) + })?; + + if response.errcode != 0 { + return Err(WechatError::Upstream(format!( + "微信订阅消息发送失败:{}", + response.errmsg.unwrap_or_else(|| format!( + "subscribeMessage.send 返回错误 {}", + response.errcode + )) + ))); + } + + Ok(()) + } + + async fn request_access_token( + &self, + app_id: &str, + app_secret: &str, + ) -> Result { + let url = Url::parse(&self.config.stable_access_token_endpoint).map_err(|error| { + WechatError::InvalidConfig(format!("微信 stable_token 地址非法:{error}")) + })?; + let payload = self + .client + .post(url.as_str()) + .json(&json!({ + "grant_type": "client_credential", + "appid": app_id, + "secret": app_secret, + "force_refresh": false, + })) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信 stable_token 请求失败"); + WechatError::RequestFailed("微信 stable_token 请求失败".to_string()) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信 stable_token 响应解析失败"); + WechatError::DeserializeFailed("微信 stable_token 响应非法".to_string()) + })?; + + if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { + return Err(WechatError::Upstream(format!( + "微信 stable_token 返回错误:{}", + payload + .errmsg + .unwrap_or_else(|| format!("errcode={errcode}")) + ))); + } + + payload + .access_token + .and_then(|value| non_empty_owned(value)) + .ok_or_else(|| WechatError::Upstream("微信 stable_token 缺少 access_token".to_string())) + } +} + +impl WechatError { + pub fn kind(&self) -> WechatErrorKind { + match self { + Self::InvalidConfig(_) => WechatErrorKind::InvalidConfig, + Self::RequestFailed(_) => WechatErrorKind::RequestFailed, + Self::DeserializeFailed(_) => WechatErrorKind::DeserializeFailed, + Self::Upstream(_) => WechatErrorKind::Upstream, + } + } +} + +impl fmt::Display for WechatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConfig(message) + | Self::RequestFailed(message) + | Self::DeserializeFailed(message) + | Self::Upstream(message) => f.write_str(message), + } + } +} + +impl Error for WechatError {} + +fn non_empty(value: &str) -> Option<&str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn non_empty_owned(value: String) -> Option { + if value.trim().is_empty() { + None + } else { + Some(value) + } +}