diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 1c3e2349..20232dd9 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -2103,6 +2103,6 @@ - 现象:拼图点击生成后,H5 以为已经请求了生成结果订阅授权,但小程序没有弹出 `wx.requestSubscribeMessage` 授权框。 - 原因:`web-view bindmessage` / `wx.miniProgram.postMessage` 不适合承接“当前用户点击后立刻请求授权”的时序,消息可能等到 web-view 后退、分享或销毁时才派发,导致授权请求没有发生在 `compile_puzzle_draft` 前。 -- 处理:H5 在 `compile_puzzle_draft` 前通过微信 JS SDK `miniProgram.navigateTo` 跳转到小程序原生订阅页;原生页进入后立即触发 `wx.requestSubscribeMessage`,用户接受、拒绝或返回后都回到 H5 并继续生成。原生页不要改写上一页 `webViewUrl`,否则 web-view 可能重新加载首页并丢失待提交草稿。后端发送订阅消息仍只允许在拼图资产成功或失败终态后执行。 +- 处理:不要在原生页 `onLoad` 自动触发 `wx.requestSubscribeMessage`,真机会闪页返回且不弹授权框。H5 在 `compile_puzzle_draft` 前应先进入生成进度态并立即发起生成 action,再通过微信 JS SDK `miniProgram.navigateTo` 非阻塞跳转到小程序原生订阅页尝试请求授权;用户接受、拒绝或返回都不能阻塞生成。原生页不要改写上一页 `webViewUrl`,否则 web-view 可能重新加载首页并丢失进度页状态。后端发送订阅消息仍只允许在拼图资产成功或失败终态后执行。 - 验证:`npm run test -- src/services/wechatMiniProgramSubscribe.test.ts 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 6092ecc6..627ddd5e 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` 生成动作发起前跳转到小程序原生订阅授权页,原生页进入后立即调用 `wx.requestSubscribeMessage` 弹出授权框,用户接受、拒绝或返回后都继续生成动作,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前创作状态。后端只在拼图资产生成成功或失败终态后用微信登录保存的 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` 生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在拼图资产生成成功或失败终态后用微信登录保存的 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 461bb710..cd3c56e1 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` 生成动作发起前跳转到小程序原生订阅授权页,原生页进入后立即调用 `wx.requestSubscribeMessage` 弹出授权框,用户接受、拒绝或页面返回后都继续调用生成 action;原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前创作状态。通知发送只允许发生在拼图后台首图 / UI 资产生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 +- 小程序订阅消息用于拼图 AI 创作生成结果通知:H5 在拼图 `compile_puzzle_draft` 生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在拼图后台首图 / UI 资产生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/miniprogram/pages/subscribe-message/index.shared.js b/miniprogram/pages/subscribe-message/index.shared.js index 1274f25a..04107f64 100644 --- a/miniprogram/pages/subscribe-message/index.shared.js +++ b/miniprogram/pages/subscribe-message/index.shared.js @@ -57,14 +57,6 @@ function createSubscribeMessagePage(pageContext, options = {}) { const page = pageContext ?? this; page.requestId = String(query.requestId || ''); page.hasNotifiedSubscribeResult = false; - page.autoRequest = query.autoRequest === '1' || query.autoRequest === true; - if (page.autoRequest) { - setTimeout(() => { - if (!page.hasNotifiedSubscribeResult) { - this.requestSubscribe.call(page); - } - }, 0); - } }, notifyResult(status, reason) { diff --git a/miniprogram/pages/subscribe-message/index.test.js b/miniprogram/pages/subscribe-message/index.test.js index ab10b6b1..0922f933 100644 --- a/miniprogram/pages/subscribe-message/index.test.js +++ b/miniprogram/pages/subscribe-message/index.test.js @@ -79,32 +79,6 @@ describe('subscribe-message mini program bridge', () => { expect(globalThis.wx.navigateBack).toHaveBeenCalled(); }); - test('auto request starts subscribe popup on page load', () => { - vi.useFakeTimers(); - globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => { - options.success?.({ - m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept', - }); - }); - const page = createSubscribeMessagePage( - { - setData: vi.fn(), - }, - { templateId: TEST_TEMPLATE_ID }, - ); - - page.onLoad({ requestId: 'request-auto', autoRequest: '1' }); - vi.runAllTimers(); - - expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({ - tmplIds: [TEST_TEMPLATE_ID], - success: expect.any(Function), - fail: expect.any(Function), - }); - expect(globalThis.wx.navigateBack).toHaveBeenCalled(); - vi.useRealTimers(); - }); - test('appendSubscribeResult replaces stale subscribe hash', () => { expect( appendSubscribeResult( diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index ea1d3409..c7d221dc 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -15,7 +15,6 @@ 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; @@ -606,10 +605,8 @@ Page({ } this.consumePayResult(); - this.consumeSubscribeResult(); setTimeout(() => { this.consumePayResult(); - this.consumeSubscribeResult(); }, PAY_RESULT_RECHECK_DELAY_MS); }, @@ -625,18 +622,6 @@ 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(); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index d3956bfc..bedeb4d1 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -6748,7 +6748,6 @@ export function PlatformEntryFlowShellImpl({ if (payload.action !== 'compile_puzzle_draft') { return; } - await requestGenerationResultSubscribePermission(); markDraftGenerating('puzzle', [ session.sessionId, buildPuzzleResultWorkId(session.sessionId), @@ -6779,6 +6778,7 @@ export function PlatformEntryFlowShellImpl({ error: null, }, })); + void requestGenerationResultSubscribePermission(); }, onActionError: async ({ payload, errorMessage, session, setSession }) => { if (payload.action !== 'compile_puzzle_draft') { @@ -7981,7 +7981,7 @@ export function PlatformEntryFlowShellImpl({ try { const actionPayload = buildPuzzleCompileActionFromFormPayload(payload); - await requestGenerationResultSubscribePermission(); + void requestGenerationResultSubscribePermission(); const response = await executePuzzleAgentAction( nextSession.sessionId, actionPayload, diff --git a/src/services/wechatMiniProgramSubscribe.test.ts b/src/services/wechatMiniProgramSubscribe.test.ts index ad6deed5..753b4e26 100644 --- a/src/services/wechatMiniProgramSubscribe.test.ts +++ b/src/services/wechatMiniProgramSubscribe.test.ts @@ -34,15 +34,35 @@ describe('wechatMiniProgramSubscribe', () => { expect(requested).toBe(true); expect(navigateTo).toHaveBeenCalledWith({ - url: expect.stringMatching( - /^\/pages\/subscribe-message\/index\?.*autoRequest=1/u, - ), + url: expect.stringMatching(/^\/pages\/subscribe-message\/index\?/u), success: expect.any(Function), fail: expect.any(Function), }); + expect(navigateTo.mock.calls[0]?.[0].url).not.toContain('autoRequest=1'); expect(window.location.hash).toBe(''); }); + test('keeps waiting even when native page returns immediately after navigate success', async () => { + const navigateTo = vi.fn((options) => { + window.dispatchEvent(new Event('focus')); + options.success?.(); + }); + window.history.replaceState( + null, + '', + '/creation/puzzle?clientRuntime=wechat_mini_program', + ); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + + const requested = await requestGenerationResultSubscribePermission(); + + expect(requested).toBe(true); + }); + test('still accepts legacy hash result from native mini program page', async () => { const navigateTo = vi.fn((options) => { options.success?.(); diff --git a/src/services/wechatMiniProgramSubscribe.ts b/src/services/wechatMiniProgramSubscribe.ts index 9c78248e..86f0e48e 100644 --- a/src/services/wechatMiniProgramSubscribe.ts +++ b/src/services/wechatMiniProgramSubscribe.ts @@ -2,7 +2,7 @@ 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; +const SUBSCRIBE_RESULT_TIMEOUT_MS = 2_500; const SUBSCRIBE_RESULT_RETURN_FALLBACK_MS = 800; function clearSubscribeResultHash() { @@ -155,9 +155,10 @@ export async function requestGenerationResultSubscribePermission() { } const requestId = `subscribe_generation_result_${Date.now()}`; + const resultPromise = waitSubscribeResultFromHash(); const navigated = await new Promise((resolve) => { miniProgram.navigateTo?.({ - url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result&autoRequest=1`, + url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`, success() { resolve(true); }, @@ -169,7 +170,6 @@ export async function requestGenerationResultSubscribePermission() { if (!navigated) { return false; } - const resultPromise = waitSubscribeResultFromHash(); const result = await resultPromise; return Boolean(result); }