diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 461a1652..35c7eac2 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -2098,3 +2098,11 @@ - 处理:`UnifiedCreationPage` 根容器必须保留 `bg-[image:var(--platform-body-fill)]` 和 `overscroll-contain`,内容区必须用 `flex-1 min-h-0` 占满统一页剩余高度;移动端键盘打开时只记录 `data-mobile-keyboard-open`、隐藏底部 dock、设置键盘 inset 和浅色 `--platform-keyboard-exposed-fill`,不要再对 `.platform-viewport-shell` 做全局 `transform`;小程序 `pages/web-view` 的 `page` 和 web-view class 也要用浅色背景。不要只给某个玩法工作台单独加高度补丁。 - 验证:`npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedCreationWorkspace.test.tsx src/mobileViewportKeyboardFocus.test.ts src/index.test.ts miniprogram/pages/web-view/index.style.test.js`;移动端点击拼图、敲木鱼、跳一跳输入框时,页面不应整体弹起,键盘上方应持续显示平台浅色背景。 - 关联:`src/components/unified-creation/UnifiedCreationPage.tsx`、`src/mobileViewportKeyboardFocus.ts`、`src/index.css`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 小程序订阅消息授权不要依赖 web-view bindmessage + +- 现象:拼图点击生成后,H5 以为已经请求了生成结果订阅授权,但小程序没有弹出 `wx.requestSubscribeMessage` 授权框。 +- 原因:`web-view bindmessage` / `wx.miniProgram.postMessage` 不适合承接“当前用户点击后立刻请求授权”的时序,消息可能等到 web-view 后退、分享或销毁时才派发,导致授权请求没有发生在 `compile_puzzle_draft` 前。 +- 处理:H5 在 `compile_puzzle_draft` 前通过微信 JS SDK `miniProgram.navigateTo` 跳转到小程序原生订阅页;原生页由用户按钮触发 `wx.requestSubscribeMessage`,把 `success` 或 `skip` 写入 storage 并回写 `wx_subscribe_result` hash 后返回;H5 等到结果或超时后继续生成。后端发送订阅消息仍只允许在拼图资产成功或失败终态后执行。 +- 验证:`npm exec vitest -- run src/services/wechatMiniProgramSubscribe.test.ts src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx miniprogram/pages/subscribe-message/index.test.js`。 +- 关联:`src/services/wechatMiniProgramSubscribe.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`miniprogram/pages/subscribe-message/index.shared.js`、`miniprogram/pages/web-view/index.js`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 53707450..c4d48d10 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创作生成结果通知`;后端只在拼图资产生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。 +微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED`、`WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID` 和 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;H5 在拼图 `compile_puzzle_draft` 生成动作发起前跳转到小程序原生订阅授权页,请求完成或跳过后再继续生成动作,后端只在拼图资产生成成功或失败终态后用微信登录保存的 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` 指向可发布的本地库。 diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index ded95c27..7aa38701 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 创作生成结果通知:通知发送只允许发生在拼图后台首图 / UI 资产生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 +- 小程序订阅消息用于拼图 AI 创作生成结果通知:H5 在拼图 `compile_puzzle_draft` 生成动作发起前跳转到小程序原生订阅授权页,请求完成或跳过后再继续调用生成 action;通知发送只允许发生在拼图后台首图 / UI 资产生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/miniprogram/app.json b/miniprogram/app.json index 028137f2..dc8c7a3d 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -1,5 +1,9 @@ { - "pages": ["pages/web-view/index", "pages/wechat-pay/index"], + "pages": [ + "pages/web-view/index", + "pages/wechat-pay/index", + "pages/subscribe-message/index" + ], "window": { "navigationBarTitleText": "陶泥儿", "navigationBarBackgroundColor": "#0b0f14", diff --git a/miniprogram/pages/subscribe-message/index.js b/miniprogram/pages/subscribe-message/index.js new file mode 100644 index 00000000..52ce7ea2 --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.js @@ -0,0 +1,10 @@ +/* global Page */ + +const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config'); +const { createSubscribeMessagePage } = require('./index.shared'); + +Page( + createSubscribeMessagePage(null, { + templateId: GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID, + }), +); diff --git a/miniprogram/pages/subscribe-message/index.json b/miniprogram/pages/subscribe-message/index.json new file mode 100644 index 00000000..46298a20 --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "生成通知" +} diff --git a/miniprogram/pages/subscribe-message/index.shared.js b/miniprogram/pages/subscribe-message/index.shared.js new file mode 100644 index 00000000..8a60191f --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.shared.js @@ -0,0 +1,135 @@ +/* global wx, getCurrentPages */ + +const SUBSCRIBE_RESULT_STORAGE_KEY = 'genarrative:wechat-subscribe-result'; + +function appendSubscribeResult(url, result) { + const hashIndex = String(url || '').indexOf('#'); + const baseUrl = + hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || ''); + const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : ''; + const nextHash = rawHash + .split('&') + .filter((part) => part && !part.startsWith('wx_subscribe_result=')) + .concat(`wx_subscribe_result=${encodeURIComponent(result)}`) + .join('&'); + return `${baseUrl}#${nextHash}`; +} + +function buildSubscribeResultValue(requestId, status, reason) { + const segments = [requestId, status]; + if (reason) { + segments.push(encodeURIComponent(reason)); + } + return segments.join(':'); +} + +function notifyPreviousWebView(requestId, status, reason) { + const result = buildSubscribeResultValue(requestId, status, reason); + wx.setStorageSync(SUBSCRIBE_RESULT_STORAGE_KEY, result); + const pages = getCurrentPages(); + const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null; + if (previousPage && typeof previousPage.setData === 'function') { + previousPage.setData({ + webViewUrl: appendSubscribeResult(previousPage.data.webViewUrl, result), + }); + } +} + +function resolveSubscribeStatus(result, templateId) { + return result && result[templateId] === 'accept' + ? 'success' + : 'skip'; +} + +function createSubscribeMessagePage(pageContext, options = {}) { + const templateId = String(options.templateId || '').trim(); + const notifyPageResult = (methodThis, status, reason) => { + const page = pageContext ?? methodThis; + const requestId = page.requestId || ''; + if (!requestId || page.hasNotifiedSubscribeResult) { + return; + } + page.hasNotifiedSubscribeResult = true; + notifyPreviousWebView(requestId, status, reason); + }; + + return { + data: { + title: '接收生成结果通知', + errorMessage: '', + requesting: false, + }, + + onLoad(query) { + const page = pageContext ?? this; + page.requestId = String(query.requestId || ''); + page.hasNotifiedSubscribeResult = false; + }, + + notifyResult(status, reason) { + notifyPageResult(this, status, reason); + }, + + requestSubscribe() { + const page = pageContext ?? this; + const requestId = page.requestId || ''; + if (!requestId) { + page.setData({ + errorMessage: '缺少订阅请求参数。', + }); + return; + } + if (!templateId) { + notifyPageResult(this, 'skip', 'missing_template_id'); + wx.navigateBack(); + return; + } + if (typeof wx.requestSubscribeMessage !== 'function') { + notifyPageResult(this, 'skip', 'unsupported'); + wx.navigateBack(); + return; + } + + page.setData({ + requesting: true, + errorMessage: '', + }); + wx.requestSubscribeMessage({ + tmplIds: [templateId], + success(result) { + notifyPageResult( + page, + resolveSubscribeStatus(result, templateId), + '', + ); + wx.navigateBack(); + }, + fail(error) { + notifyPageResult( + page, + 'skip', + error && error.errMsg ? error.errMsg : 'failed', + ); + wx.navigateBack(); + }, + }); + }, + + handleSkip() { + notifyPageResult(this, 'skip', 'user_skip'); + wx.navigateBack(); + }, + + onUnload() { + notifyPageResult(this, 'skip', 'page_unload'); + }, + }; +} + +module.exports = { + SUBSCRIBE_RESULT_STORAGE_KEY, + appendSubscribeResult, + buildSubscribeResultValue, + createSubscribeMessagePage, + resolveSubscribeStatus, +}; diff --git a/miniprogram/pages/subscribe-message/index.test.js b/miniprogram/pages/subscribe-message/index.test.js new file mode 100644 index 00000000..67b9ede5 --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.test.js @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import subscribeMessageBridge from './index.shared.js'; + +const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; + +const { + SUBSCRIBE_RESULT_STORAGE_KEY, + appendSubscribeResult, + buildSubscribeResultValue, + createSubscribeMessagePage, +} = subscribeMessageBridge; + +describe('subscribe-message mini program bridge', () => { + beforeEach(() => { + globalThis.wx = { + requestSubscribeMessage: vi.fn(), + setStorageSync: vi.fn(), + navigateBack: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => []); + }); + + test('requests subscribe message and notifies previous web-view before returning', () => { + const previousPage = { + data: { webViewUrl: 'https://web.test/#tab=create' }, + setData: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); + globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => { + options.success?.({ + m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept', + }); + }); + const page = createSubscribeMessagePage( + { + setData: vi.fn(), + }, + { templateId: TEST_TEMPLATE_ID }, + ); + page.onLoad({ requestId: 'request-1' }); + + page.requestSubscribe(); + + expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({ + tmplIds: [TEST_TEMPLATE_ID], + success: expect.any(Function), + fail: expect.any(Function), + }); + expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( + SUBSCRIBE_RESULT_STORAGE_KEY, + 'request-1:success', + ); + expect(previousPage.setData).toHaveBeenCalledWith({ + webViewUrl: + 'https://web.test/#tab=create&wx_subscribe_result=request-1%3Asuccess', + }); + expect(globalThis.wx.navigateBack).toHaveBeenCalled(); + }); + + test('skip action notifies previous web-view', () => { + const previousPage = { + data: { webViewUrl: 'https://web.test/' }, + setData: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); + const page = createSubscribeMessagePage( + { + setData: vi.fn(), + }, + { templateId: TEST_TEMPLATE_ID }, + ); + page.onLoad({ requestId: 'request-skip' }); + + page.handleSkip(); + + expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( + SUBSCRIBE_RESULT_STORAGE_KEY, + 'request-skip:skip:user_skip', + ); + expect(previousPage.setData).toHaveBeenCalledWith({ + webViewUrl: + 'https://web.test/#wx_subscribe_result=request-skip%3Askip%3Auser_skip', + }); + expect(globalThis.wx.navigateBack).toHaveBeenCalled(); + }); + + test('appendSubscribeResult replaces stale subscribe hash', () => { + expect( + appendSubscribeResult( + 'https://web.test/#old=1&wx_subscribe_result=old', + 'req:skip', + ), + ).toBe('https://web.test/#old=1&wx_subscribe_result=req%3Askip'); + expect(buildSubscribeResultValue('req-1', 'skip', 'user_cancel')).toBe( + 'req-1:skip:user_cancel', + ); + }); +}); diff --git a/miniprogram/pages/subscribe-message/index.wxml b/miniprogram/pages/subscribe-message/index.wxml new file mode 100644 index 00000000..116d68c4 --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.wxml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/miniprogram/pages/subscribe-message/index.wxss b/miniprogram/pages/subscribe-message/index.wxss new file mode 100644 index 00000000..03d571bf --- /dev/null +++ b/miniprogram/pages/subscribe-message/index.wxss @@ -0,0 +1,58 @@ +.subscribe-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 48rpx; + background: #0b0f14; + box-sizing: border-box; +} + +.subscribe-card { + width: 100%; + max-width: 560rpx; + padding: 36rpx; + border: 1rpx solid rgba(255, 255, 255, 0.14); + border-radius: 12rpx; + background: rgba(255, 255, 255, 0.06); + box-sizing: border-box; +} + +.subscribe-title { + font-size: 34rpx; + font-weight: 600; + line-height: 1.35; + color: #f5f7fb; +} + +.subscribe-text { + margin-top: 16rpx; + font-size: 26rpx; + line-height: 1.55; + color: rgba(245, 247, 251, 0.72); +} + +.subscribe-text--danger { + color: #ffb4a9; +} + +.primary-button, +.ghost-button { + margin-top: 28rpx; + width: 100%; + border-radius: 8rpx; + font-size: 26rpx; + line-height: 2.6; +} + +.primary-button { + background: #f5f7fb; + color: #0b0f14; +} + +.ghost-button { + margin-top: 20rpx; + border: 1rpx solid rgba(255, 255, 255, 0.24); + background: transparent; + color: rgba(245, 247, 251, 0.86); +} diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 491247b7..ea1d3409 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -5,7 +5,6 @@ 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, @@ -16,13 +15,12 @@ const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id'; const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; +const SUBSCRIBE_RESULT_STORAGE_KEY = 'genarrative:wechat-subscribe-result'; const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result'; 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') { @@ -418,36 +416,6 @@ 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); @@ -638,8 +606,10 @@ Page({ } this.consumePayResult(); + this.consumeSubscribeResult(); setTimeout(() => { this.consumePayResult(); + this.consumeSubscribeResult(); }, PAY_RESULT_RECHECK_DELAY_MS); }, @@ -655,6 +625,18 @@ Page({ } }, + consumeSubscribeResult() { + const result = wx.getStorageSync(SUBSCRIBE_RESULT_STORAGE_KEY); + if (result && this.data.webViewUrl) { + wx.removeStorageSync(SUBSCRIBE_RESULT_STORAGE_KEY); + this.setData({ + webViewUrl: appendHashParams(this.data.webViewUrl, { + wx_subscribe_result: result, + }), + }); + } + }, + async handleGetPhoneNumber(event) { if (!this.data.authResult || !this.data.authResult.token) { this.handleRetryLogin(); @@ -745,23 +727,7 @@ Page({ }, handleWebViewMessage(event) { - 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 消息只保留调试输出。 + // 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。 console.info('[web-view] message', event.detail); }, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 59595728..d3956bfc 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -70,7 +70,6 @@ import type { PuzzleAgentSessionSnapshot, SendPuzzleAgentMessageRequest, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; -import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState'; import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate'; import type { PuzzleRunSnapshot, @@ -224,17 +223,12 @@ import { buildSquareHoleGenerationAnchorEntries, buildWoodenFishGenerationAnchorEntries, createMiniGameDraftGenerationState, - resolveMiniGameDraftGenerationStartedAtMs, type MiniGameDraftGenerationKind, type MiniGameDraftGenerationPhase, type MiniGameDraftGenerationState, + resolveMiniGameDraftGenerationStartedAtMs, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; -import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage'; -import { - getUnifiedCreationSpec, - type UnifiedCreationPlayId, -} from '../unified-creation/unifiedCreationSpecs'; import { buildBabyObjectMatchPublicWorkCode, buildBarkBattlePublicWorkCode, @@ -369,6 +363,7 @@ import { publishVisualNovelWork, updateVisualNovelWork, } from '../../services/visual-novel-works'; +import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe'; import { woodenFishClient, type WoodenFishGalleryCardResponse, @@ -421,6 +416,11 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController'; +import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage'; +import { + getUnifiedCreationSpec, + type UnifiedCreationPlayId, +} from '../unified-creation/unifiedCreationSpecs'; import { buildVisualNovelEntryGenerationAnchorEntries, buildVisualNovelEntryGenerationProgress, @@ -439,7 +439,6 @@ import { EDUTAINMENT_HIDDEN_MESSAGE, filterGeneralPublicWorks, } from './platformEdutainmentVisibility'; -import { buildPlatformRecommendedEntries } from './platformRecommendation'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { @@ -471,11 +470,13 @@ import { type PlatformErrorDialogPayload, } from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; +import { buildPlatformRecommendedEntries } from './platformRecommendation'; import { PlatformTaskCompletionDialog, type PlatformTaskCompletionDialogPayload, } from './PlatformTaskCompletionDialog'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; +import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; @@ -6738,7 +6739,7 @@ export function PlatformEntryFlowShellImpl({ }); } }, - beforeExecuteAction: ({ payload, session }) => { + beforeExecuteAction: async ({ payload, session }) => { const formPayload = buildPuzzleFormPayloadFromAction(payload); if (formPayload) { setPuzzleFormDraftPayload(formPayload); @@ -6747,6 +6748,7 @@ export function PlatformEntryFlowShellImpl({ if (payload.action !== 'compile_puzzle_draft') { return; } + await requestGenerationResultSubscribePermission(); markDraftGenerating('puzzle', [ session.sessionId, buildPuzzleResultWorkId(session.sessionId), @@ -7979,6 +7981,7 @@ export function PlatformEntryFlowShellImpl({ try { const actionPayload = buildPuzzleCompileActionFromFormPayload(payload); + await requestGenerationResultSubscribePermission(); const response = await executePuzzleAgentAction( nextSession.sessionId, actionPayload, diff --git a/src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx b/src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx index ded82926..2228948a 100644 --- a/src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx +++ b/src/components/platform-entry/usePlatformCreationAgentFlowController.test.tsx @@ -300,6 +300,91 @@ function ActionCompleteHarness({ ); } +function BeforeActionHarness({ events }: { events: string[] }) { + const hasOpenedRef = useRef(false); + const flow = usePlatformCreationAgentFlowController< + ActionTestSession, + Record, + { session: ActionTestSession }, + TestMessagePayload, + { action: string }, + { session: ActionTestSession } + >({ + client: { + createSession: async () => ({ + session: { + sessionId: 'session-1', + messages: [], + draft: { profileId: 'profile-draft-1' }, + }, + }), + getSession: async () => ({ + session: { + sessionId: 'session-1', + messages: [], + draft: { profileId: 'profile-draft-1' }, + }, + }), + streamMessage: async () => ({ + sessionId: 'session-1', + messages: [], + draft: { profileId: 'profile-draft-1' }, + }), + executeAction: async () => { + events.push('executeAction'); + return { + session: { + sessionId: 'session-1', + messages: [], + draft: { profileId: 'profile-ready-1' }, + }, + }; + }, + selectSession: (response) => response.session, + }, + createPayload: {}, + workspaceStage: 'match3d-agent-workspace', + resultStage: 'match3d-result', + platformStage: 'platform', + isCompileAction: () => true, + resolveErrorMessage: (error, fallback) => + error instanceof Error ? error.message : fallback, + errorMessages: { + open: '打开失败', + restoreMissingSession: '缺少会话', + restore: '恢复失败', + submit: '发送失败', + execute: '执行失败', + }, + enterCreateTab: () => {}, + setSelectionStage: () => {}, + beforeExecuteAction: async () => { + events.push('beforeExecuteAction'); + await Promise.resolve(); + events.push('permissionResolved'); + }, + }); + + useEffect(() => { + if (hasOpenedRef.current) { + return; + } + hasOpenedRef.current = true; + void flow.openWorkspace({}); + }, [flow]); + + return ( + + ); +} + function SessionChangeHarness({ onSessionChanged, }: { @@ -547,6 +632,28 @@ test('creation agent flow suppresses compile result stage for background complet ); }); +test('creation agent flow waits for beforeExecuteAction before network action', async () => { + const events: string[] = []; + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: '执行' })).toBeTruthy(); + }); + + await act(async () => { + screen.getByRole('button', { name: '执行' }).click(); + }); + + await waitFor(() => { + expect(events).toEqual([ + 'beforeExecuteAction', + 'permissionResolved', + 'executeAction', + ]); + }); +}); + test('creation agent flow notifies session changes after open restore and compile', async () => { const onSessionChanged = vi.fn(); diff --git a/src/components/platform-entry/usePlatformCreationAgentFlowController.ts b/src/components/platform-entry/usePlatformCreationAgentFlowController.ts index f89b778e..ffa54272 100644 --- a/src/components/platform-entry/usePlatformCreationAgentFlowController.ts +++ b/src/components/platform-entry/usePlatformCreationAgentFlowController.ts @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { TextStreamOptions } from '../../services/aiTypes'; import type { SelectionStage } from './platformEntryTypes'; @@ -90,7 +90,7 @@ type PlatformCreationAgentFlowControllerOptions< beforeExecuteAction?: (params: { payload: TActionPayload; session: TSession; - }) => void; + }) => void | Promise; onActionError?: (params: { payload: TActionPayload; error: unknown; @@ -211,7 +211,7 @@ export function usePlatformCreationAgentFlowController< setIsBusy(false); } }, - [isBusy, options, resetStreamingReply], + [isBusy, options, resetStreamingReply, setSession], ); const restoreDraft = useCallback( @@ -249,7 +249,7 @@ export function usePlatformCreationAgentFlowController< setIsBusy(false); } }, - [options, resetStreamingReply], + [options, resetStreamingReply, setSession], ); const submitMessage = useCallback( @@ -309,7 +309,13 @@ export function usePlatformCreationAgentFlowController< setIsStreamingReply(false); } }, - [isStreamingReply, options, session, updateStreamingReplyText], + [ + isStreamingReply, + options, + session, + setSession, + updateStreamingReplyText, + ], ); const executeAction = useCallback( @@ -323,7 +329,7 @@ export function usePlatformCreationAgentFlowController< setError(null); try { - options.beforeExecuteAction?.({ payload, session: targetSession }); + await options.beforeExecuteAction?.({ payload, session: targetSession }); const response = await options.client.executeAction( targetSession.sessionId, payload, @@ -358,7 +364,7 @@ export function usePlatformCreationAgentFlowController< setIsBusy(false); } }, - [isBusy, options, session], + [isBusy, options, session, setSession], ); const leaveFlow = useCallback(() => { diff --git a/src/services/payment/paymentPlatform.ts b/src/services/payment/paymentPlatform.ts index 28f15a27..b45f63c0 100644 --- a/src/services/payment/paymentPlatform.ts +++ b/src/services/payment/paymentPlatform.ts @@ -72,8 +72,9 @@ export function isManualMockPaymentChannel(paymentChannel: string) { return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL; } -function isWechatMiniProgramRuntime( - location: Pick | null | undefined, +export function isWechatMiniProgramRuntime( + location: Pick | null | undefined = + typeof window !== 'undefined' ? window.location : null, ) { const params = new URLSearchParams(location?.search ?? ''); return ( diff --git a/src/services/wechatMiniProgramSubscribe.test.ts b/src/services/wechatMiniProgramSubscribe.test.ts new file mode 100644 index 00000000..9631e0d4 --- /dev/null +++ b/src/services/wechatMiniProgramSubscribe.test.ts @@ -0,0 +1,58 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + requestGenerationResultSubscribePermission, +} from './wechatMiniProgramSubscribe'; + +describe('wechatMiniProgramSubscribe', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + window.wx = undefined; + }); + + test('requests generation result subscription permission through native mini program page', async () => { + const navigateTo = vi.fn((options) => { + options.success?.(); + window.setTimeout(() => { + window.location.hash = 'wx_subscribe_result=req-1:success'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }, 0); + }); + window.history.replaceState( + null, + '', + '/creation/puzzle?clientRuntime=wechat_mini_program', + ); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + + const requested = await requestGenerationResultSubscribePermission(); + + expect(requested).toBe(true); + expect(navigateTo).toHaveBeenCalledWith({ + url: expect.stringMatching(/^\/pages\/subscribe-message\/index\?/u), + success: expect.any(Function), + fail: expect.any(Function), + }); + expect(window.location.hash).toBe(''); + }); + + test('skips permission request outside mini program web-view', async () => { + const navigateTo = vi.fn(); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + + const requested = await requestGenerationResultSubscribePermission(); + + expect(requested).toBe(false); + expect(navigateTo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/wechatMiniProgramSubscribe.ts b/src/services/wechatMiniProgramSubscribe.ts new file mode 100644 index 00000000..40158d00 --- /dev/null +++ b/src/services/wechatMiniProgramSubscribe.ts @@ -0,0 +1,160 @@ +import { isWechatMiniProgramRuntime } from './payment/paymentPlatform'; + +const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; +const SUBSCRIBE_RESULT_HASH_KEY = 'wx_subscribe_result'; +const SUBSCRIBE_RESULT_TIMEOUT_MS = 30_000; + +function clearSubscribeResultHash() { + const rawHash = window.location.hash.replace(/^#/, ''); + if (!rawHash.includes(`${SUBSCRIBE_RESULT_HASH_KEY}=`)) { + return; + } + + const params = new URLSearchParams(rawHash); + params.delete(SUBSCRIBE_RESULT_HASH_KEY); + const nextHash = params.toString(); + window.history.replaceState( + null, + '', + `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`, + ); +} + +function readSubscribeResultFromHash() { + const value = new URLSearchParams(window.location.hash.replace(/^#/, '')).get( + SUBSCRIBE_RESULT_HASH_KEY, + ); + if (!value) { + return null; + } + clearSubscribeResultHash(); + return value; +} + +function waitSubscribeResultFromHash(timeoutMs = SUBSCRIBE_RESULT_TIMEOUT_MS) { + const immediateResult = readSubscribeResultFromHash(); + if (immediateResult) { + return Promise.resolve(immediateResult); + } + + return new Promise((resolve) => { + let timer: number | null = null; + const cleanup = () => { + window.removeEventListener('hashchange', handleHashChange); + window.removeEventListener('focus', handleResume); + window.removeEventListener('pageshow', handleResume); + document.removeEventListener('visibilitychange', handleResume); + if (timer !== null) { + window.clearTimeout(timer); + } + }; + const finish = (result: string | null) => { + cleanup(); + resolve(result); + }; + const consume = () => { + const result = readSubscribeResultFromHash(); + if (result) { + finish(result); + return true; + } + return false; + }; + const handleHashChange = () => { + consume(); + }; + const handleResume = () => { + if ( + typeof document !== 'undefined' && + document.visibilityState === 'hidden' + ) { + return; + } + consume(); + }; + + window.addEventListener('hashchange', handleHashChange); + window.addEventListener('focus', handleResume); + window.addEventListener('pageshow', handleResume); + document.addEventListener('visibilitychange', handleResume); + timer = window.setTimeout(() => finish(null), timeoutMs); + }); +} + +function loadWechatJsSdk() { + if ( + !isWechatMiniProgramRuntime() || + typeof window === 'undefined' + ) { + return Promise.reject(new Error('not_mini_program')); + } + if (window.wx?.miniProgram?.navigateTo) { + return Promise.resolve(window.wx); + } + + return new Promise>((resolve, reject) => { + const existingScript = document.querySelector( + `script[src="${WECHAT_JS_SDK_URL}"]`, + ); + const complete = () => { + if (window.wx?.miniProgram?.navigateTo) { + resolve(window.wx); + } else { + reject(new Error('wechat_js_sdk_unavailable')); + } + }; + + if (existingScript) { + existingScript.addEventListener('load', complete, { once: true }); + existingScript.addEventListener('error', () => reject(new Error('wechat_js_sdk_load_failed')), { + once: true, + }); + complete(); + return; + } + + const script = document.createElement('script'); + script.src = WECHAT_JS_SDK_URL; + script.async = true; + script.onload = complete; + script.onerror = () => reject(new Error('wechat_js_sdk_load_failed')); + document.head.appendChild(script); + }); +} + +export async function requestGenerationResultSubscribePermission() { + if (!isWechatMiniProgramRuntime() || typeof window === 'undefined') { + return false; + } + + let wxBridge: NonNullable; + try { + wxBridge = await loadWechatJsSdk(); + } catch { + return false; + } + + const miniProgram = wxBridge.miniProgram; + if (!miniProgram || typeof miniProgram.navigateTo !== 'function') { + return false; + } + + const requestId = `subscribe_generation_result_${Date.now()}`; + const navigated = await new Promise((resolve) => { + miniProgram.navigateTo?.({ + url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`, + success() { + resolve(true); + }, + fail() { + resolve(false); + }, + }); + }); + if (!navigated) { + return false; + } + const resultPromise = waitSubscribeResultFromHash(); + const result = await resultPromise; + return Boolean(result); +}