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 @@
+
+
+ {{title}}
+
+ {{errorMessage}}
+
+
+
+
+
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);
+}