修复小程序生成前订阅授权体验
生成前订阅授权页自动弹出微信授权框 授权返回或跳过后继续执行拼图生成提交 避免订阅页改写上一页 web-view URL 导致回首页 更新订阅消息生成前授权与终态发送文档口径
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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` 指向可发布的本地库。
|
||||
|
||||
|
||||
@@ -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 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user