修复小程序生成前订阅授权体验

生成前订阅授权页自动弹出微信授权框

授权返回或跳过后继续执行拼图生成提交

避免订阅页改写上一页 web-view URL 导致回首页

更新订阅消息生成前授权与终态发送文档口径
This commit is contained in:
kdletters
2026-06-08 13:48:49 +08:00
parent 3a918687c5
commit 59b5bd1f83
7 changed files with 90 additions and 28 deletions

View File

@@ -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`

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

View File

@@ -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 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。

View File

@@ -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) {

View File

@@ -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', () => {

View File

@@ -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('');
});

View File

@@ -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<string | null>((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<boolean>((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);
},