修复订阅授权返回后生成中断

生成动作先进入拼图进度页并立即发起生成

订阅授权改为非阻塞尝试,避免闪回卡住提交

移除订阅结果回写 web-view URL 导致回首页的逻辑

更新小程序订阅消息授权与发送边界文档
This commit is contained in:
kdletters
2026-06-08 15:01:52 +08:00
parent 8f991a4ac2
commit 3ca5a460f1
9 changed files with 31 additions and 60 deletions

View File

@@ -2103,6 +2103,6 @@
- 现象拼图点击生成后H5 以为已经请求了生成结果订阅授权,但小程序没有弹出 `wx.requestSubscribeMessage` 授权框。 - 现象拼图点击生成后H5 以为已经请求了生成结果订阅授权,但小程序没有弹出 `wx.requestSubscribeMessage` 授权框。
- 原因:`web-view bindmessage` / `wx.miniProgram.postMessage` 不适合承接“当前用户点击后立刻请求授权”的时序,消息可能等到 web-view 后退、分享或销毁时才派发,导致授权请求没有发生在 `compile_puzzle_draft` 前。 - 原因:`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` - 验证:`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` - 关联:`src/services/wechatMiniProgramSubscribe.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``miniprogram/pages/subscribe-message/index.shared.js``miniprogram/pages/web-view/index.js`

View File

@@ -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_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` 指向可发布的本地库。 如果本地 `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` 指向可发布的本地库。

View File

@@ -72,5 +72,5 @@ npm run check:encoding
- 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
- Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 - Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。
- WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底后端收到虚拟支付消息推送并入账后会发布订单更新SSE 先推当前订单快照,再在订单结束时推 `done` - 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 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。

View File

@@ -57,14 +57,6 @@ function createSubscribeMessagePage(pageContext, options = {}) {
const page = pageContext ?? this; const page = pageContext ?? this;
page.requestId = String(query.requestId || ''); page.requestId = String(query.requestId || '');
page.hasNotifiedSubscribeResult = false; 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) { notifyResult(status, reason) {

View File

@@ -79,32 +79,6 @@ describe('subscribe-message mini program bridge', () => {
expect(globalThis.wx.navigateBack).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', () => { test('appendSubscribeResult replaces stale subscribe hash', () => {
expect( expect(
appendSubscribeResult( appendSubscribeResult(

View File

@@ -15,7 +15,6 @@ const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id'; const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id';
const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; 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_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result';
const AUTH_ACTION_LOGIN = 'login'; const AUTH_ACTION_LOGIN = 'login';
const PAY_RESULT_RECHECK_DELAY_MS = 120; const PAY_RESULT_RECHECK_DELAY_MS = 120;
@@ -606,10 +605,8 @@ Page({
} }
this.consumePayResult(); this.consumePayResult();
this.consumeSubscribeResult();
setTimeout(() => { setTimeout(() => {
this.consumePayResult(); this.consumePayResult();
this.consumeSubscribeResult();
}, PAY_RESULT_RECHECK_DELAY_MS); }, 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) { async handleGetPhoneNumber(event) {
if (!this.data.authResult || !this.data.authResult.token) { if (!this.data.authResult || !this.data.authResult.token) {
this.handleRetryLogin(); this.handleRetryLogin();

View File

@@ -6748,7 +6748,6 @@ export function PlatformEntryFlowShellImpl({
if (payload.action !== 'compile_puzzle_draft') { if (payload.action !== 'compile_puzzle_draft') {
return; return;
} }
await requestGenerationResultSubscribePermission();
markDraftGenerating('puzzle', [ markDraftGenerating('puzzle', [
session.sessionId, session.sessionId,
buildPuzzleResultWorkId(session.sessionId), buildPuzzleResultWorkId(session.sessionId),
@@ -6779,6 +6778,7 @@ export function PlatformEntryFlowShellImpl({
error: null, error: null,
}, },
})); }));
void requestGenerationResultSubscribePermission();
}, },
onActionError: async ({ payload, errorMessage, session, setSession }) => { onActionError: async ({ payload, errorMessage, session, setSession }) => {
if (payload.action !== 'compile_puzzle_draft') { if (payload.action !== 'compile_puzzle_draft') {
@@ -7981,7 +7981,7 @@ export function PlatformEntryFlowShellImpl({
try { try {
const actionPayload = buildPuzzleCompileActionFromFormPayload(payload); const actionPayload = buildPuzzleCompileActionFromFormPayload(payload);
await requestGenerationResultSubscribePermission(); void requestGenerationResultSubscribePermission();
const response = await executePuzzleAgentAction( const response = await executePuzzleAgentAction(
nextSession.sessionId, nextSession.sessionId,
actionPayload, actionPayload,

View File

@@ -34,15 +34,35 @@ describe('wechatMiniProgramSubscribe', () => {
expect(requested).toBe(true); expect(requested).toBe(true);
expect(navigateTo).toHaveBeenCalledWith({ expect(navigateTo).toHaveBeenCalledWith({
url: expect.stringMatching( url: expect.stringMatching(/^\/pages\/subscribe-message\/index\?/u),
/^\/pages\/subscribe-message\/index\?.*autoRequest=1/u,
),
success: expect.any(Function), success: expect.any(Function),
fail: expect.any(Function), fail: expect.any(Function),
}); });
expect(navigateTo.mock.calls[0]?.[0].url).not.toContain('autoRequest=1');
expect(window.location.hash).toBe(''); 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 () => { test('still accepts legacy hash result from native mini program page', async () => {
const navigateTo = vi.fn((options) => { const navigateTo = vi.fn((options) => {
options.success?.(); options.success?.();

View File

@@ -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 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_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; const SUBSCRIBE_RESULT_RETURN_FALLBACK_MS = 800;
function clearSubscribeResultHash() { function clearSubscribeResultHash() {
@@ -155,9 +155,10 @@ export async function requestGenerationResultSubscribePermission() {
} }
const requestId = `subscribe_generation_result_${Date.now()}`; const requestId = `subscribe_generation_result_${Date.now()}`;
const resultPromise = waitSubscribeResultFromHash();
const navigated = await new Promise<boolean>((resolve) => { const navigated = await new Promise<boolean>((resolve) => {
miniProgram.navigateTo?.({ 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() { success() {
resolve(true); resolve(true);
}, },
@@ -169,7 +170,6 @@ export async function requestGenerationResultSubscribePermission() {
if (!navigated) { if (!navigated) {
return false; return false;
} }
const resultPromise = waitSubscribeResultFromHash();
const result = await resultPromise; const result = await resultPromise;
return Boolean(result); return Boolean(result);
} }