From 59b5bd1f83d61791dec335d2060e6590cc81b482 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:48:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=89=8D=E8=AE=A2=E9=98=85=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成前订阅授权页自动弹出微信授权框 授权返回或跳过后继续执行拼图生成提交 避免订阅页改写上一页 web-view URL 导致回首页 更新订阅消息生成前授权与终态发送文档口径 --- .hermes/shared-memory/pitfalls.md | 4 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...【技术方案】微信虚拟支付接入-2026-05-26.md | 2 +- .../pages/subscribe-message/index.shared.js | 17 +++++---- .../pages/subscribe-message/index.test.js | 36 ++++++++++++++---- .../wechatMiniProgramSubscribe.test.ts | 38 ++++++++++++++++--- src/services/wechatMiniProgramSubscribe.ts | 19 +++++++++- 7 files changed, 90 insertions(+), 28 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 35c7eac2..1c3e2349 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`,把 `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`。 +- 处理:H5 在 `compile_puzzle_draft` 前通过微信 JS SDK `miniProgram.navigateTo` 跳转到小程序原生订阅页;原生页进入后立即触发 `wx.requestSubscribeMessage`,用户接受、拒绝或返回后都回到 H5 并继续生成。原生页不要改写上一页 `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 c4d48d10..6092ecc6 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` 生成动作发起前跳转到小程序原生订阅授权页,请求完成或跳过后再继续生成动作,后端只在拼图资产生成成功或失败终态后用微信登录保存的 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` 生成动作发起前跳转到小程序原生订阅授权页,原生页进入后立即调用 `wx.requestSubscribeMessage` 弹出授权框,用户接受、拒绝或返回后都继续生成动作,且原生页不改写上一页 `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 7aa38701..461bb710 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;通知发送只允许发生在拼图后台首图 / UI 资产生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 +- 小程序订阅消息用于拼图 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`,应与当前发布环境一致。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/miniprogram/pages/subscribe-message/index.shared.js b/miniprogram/pages/subscribe-message/index.shared.js index 8a60191f..1274f25a 100644 --- a/miniprogram/pages/subscribe-message/index.shared.js +++ b/miniprogram/pages/subscribe-message/index.shared.js @@ -1,4 +1,4 @@ -/* global wx, getCurrentPages */ +/* global wx */ const SUBSCRIBE_RESULT_STORAGE_KEY = 'genarrative:wechat-subscribe-result'; @@ -26,13 +26,6 @@ function buildSubscribeResultValue(requestId, status, reason) { 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) { @@ -64,6 +57,14 @@ 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 67b9ede5..ab10b6b1 100644 --- a/miniprogram/pages/subscribe-message/index.test.js +++ b/miniprogram/pages/subscribe-message/index.test.js @@ -21,7 +21,7 @@ describe('subscribe-message mini program bridge', () => { globalThis.getCurrentPages = vi.fn(() => []); }); - test('requests subscribe message and notifies previous web-view before returning', () => { + test('requests subscribe message and stores result before returning', () => { const previousPage = { data: { webViewUrl: 'https://web.test/#tab=create' }, setData: vi.fn(), @@ -51,10 +51,7 @@ describe('subscribe-message mini program bridge', () => { SUBSCRIBE_RESULT_STORAGE_KEY, 'request-1:success', ); - expect(previousPage.setData).toHaveBeenCalledWith({ - webViewUrl: - 'https://web.test/#tab=create&wx_subscribe_result=request-1%3Asuccess', - }); + expect(previousPage.setData).not.toHaveBeenCalled(); expect(globalThis.wx.navigateBack).toHaveBeenCalled(); }); @@ -78,11 +75,34 @@ describe('subscribe-message mini program bridge', () => { 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(previousPage.setData).not.toHaveBeenCalled(); + 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', () => { diff --git a/src/services/wechatMiniProgramSubscribe.test.ts b/src/services/wechatMiniProgramSubscribe.test.ts index 9631e0d4..ad6deed5 100644 --- a/src/services/wechatMiniProgramSubscribe.test.ts +++ b/src/services/wechatMiniProgramSubscribe.test.ts @@ -12,7 +12,38 @@ describe('wechatMiniProgramSubscribe', () => { window.wx = undefined; }); - test('requests generation result subscription permission through native mini program page', async () => { + test('requests generation result subscription permission through native mini program page and resumes generation after return', async () => { + const navigateTo = vi.fn((options) => { + options.success?.(); + window.setTimeout(() => { + window.dispatchEvent(new Event('focus')); + }, 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\?.*autoRequest=1/u, + ), + success: expect.any(Function), + fail: expect.any(Function), + }); + expect(window.location.hash).toBe(''); + }); + + test('still accepts legacy hash result from native mini program page', async () => { const navigateTo = vi.fn((options) => { options.success?.(); window.setTimeout(() => { @@ -34,11 +65,6 @@ describe('wechatMiniProgramSubscribe', () => { 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(''); }); diff --git a/src/services/wechatMiniProgramSubscribe.ts b/src/services/wechatMiniProgramSubscribe.ts index 40158d00..9c78248e 100644 --- a/src/services/wechatMiniProgramSubscribe.ts +++ b/src/services/wechatMiniProgramSubscribe.ts @@ -3,6 +3,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_RETURN_FALLBACK_MS = 800; function clearSubscribeResultHash() { const rawHash = window.location.hash.replace(/^#/, ''); @@ -39,6 +40,7 @@ function waitSubscribeResultFromHash(timeoutMs = SUBSCRIBE_RESULT_TIMEOUT_MS) { return new Promise((resolve) => { let timer: number | null = null; + let resumeFallbackTimer: number | null = null; const cleanup = () => { window.removeEventListener('hashchange', handleHashChange); window.removeEventListener('focus', handleResume); @@ -47,6 +49,9 @@ function waitSubscribeResultFromHash(timeoutMs = SUBSCRIBE_RESULT_TIMEOUT_MS) { if (timer !== null) { window.clearTimeout(timer); } + if (resumeFallbackTimer !== null) { + window.clearTimeout(resumeFallbackTimer); + } }; const finish = (result: string | null) => { cleanup(); @@ -70,7 +75,17 @@ function waitSubscribeResultFromHash(timeoutMs = SUBSCRIBE_RESULT_TIMEOUT_MS) { ) { return; } - consume(); + if (consume()) { + return; + } + // 中文注释:订阅授权只影响后续通知,不应阻断生成;原生页返回但没有 hash + // 回灌时,按已返回处理,让原本的生成提交流程继续执行。 + if (resumeFallbackTimer === null) { + resumeFallbackTimer = window.setTimeout( + () => finish('returned'), + SUBSCRIBE_RESULT_RETURN_FALLBACK_MS, + ); + } }; window.addEventListener('hashchange', handleHashChange); @@ -142,7 +157,7 @@ export async function requestGenerationResultSubscribePermission() { 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`, + url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result&autoRequest=1`, success() { resolve(true); },