From 7609c37d5f3444ed853e050f6556f464e2d2a072 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 16:32:25 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=BE=AE=E4=BF=A1=E5=A3=B3?= =?UTF-8?q?=E6=A1=A5=E6=8E=A5=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 miniprogram/shell 承接微信 Page 生命周期与 wx 容器调用 收窄微信页面入口为 shell 页面工厂装配 更新三端 host-bridge/shell 目录门禁与认证 smoke 补充微信 shell 测试与 CommonJS 测试加载工具 同步宿主壳架构文档和项目共享记忆 补齐 api-server 外部生成测试 fixture 更新时间字段 --- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 5 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- .../host-bridge/wechatHostBridgePayment.js | 33 +- .../wechatHostBridgePayment.test.js | 46 +- .../wechatHostBridgeSubscribeMessage.js | 4 +- .../wechatHostBridgeSubscribeMessage.test.js | 31 +- miniprogram/pages/share-grid/index.js | 207 +----- miniprogram/pages/subscribe-message/index.js | 2 +- miniprogram/pages/web-view/index.js | 698 +---------------- miniprogram/pages/wechat-pay/index.js | 4 +- miniprogram/shell/wechatShellPayment.js | 42 ++ miniprogram/shell/wechatShellPayment.test.js | 69 ++ miniprogram/shell/wechatShellShareGrid.js | 212 ++++++ .../shell/wechatShellShareGrid.test.js | 49 ++ .../shell/wechatShellSubscribeMessage.js | 11 + .../shell/wechatShellSubscribeMessage.test.js | 60 ++ miniprogram/shell/wechatShellWebView.js | 703 ++++++++++++++++++ miniprogram/shell/wechatShellWebView.test.js | 109 +++ miniprogram/test-utils/loadCommonJsModule.js | 31 + scripts/check-native-shells.mjs | 36 +- .../check-wechat-miniprogram-auth-smoke.mjs | 13 +- scripts/miniprogram-web-view-auth.test.ts | 102 ++- .../src/external_generation_worker.rs | 1 + 24 files changed, 1449 insertions(+), 1022 deletions(-) create mode 100644 miniprogram/shell/wechatShellPayment.js create mode 100644 miniprogram/shell/wechatShellPayment.test.js create mode 100644 miniprogram/shell/wechatShellShareGrid.js create mode 100644 miniprogram/shell/wechatShellShareGrid.test.js create mode 100644 miniprogram/shell/wechatShellSubscribeMessage.js create mode 100644 miniprogram/shell/wechatShellSubscribeMessage.test.js create mode 100644 miniprogram/shell/wechatShellWebView.js create mode 100644 miniprogram/shell/wechatShellWebView.test.js create mode 100644 miniprogram/test-utils/loadCommonJsModule.js diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4f9a1c31a..062206a03 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -85,6 +85,7 @@ - 2026-06-18 移动壳 WebView 安全开关:Expo 移动壳 WebView 必须显式禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;同源主站页面才能留在带 HostBridge 的 WebView 内,外链只通过受控协议离开容器交给系统。配置检查和移动壳导航测试会拒绝这些边界被放宽。 - 2026-06-18 移动壳 WebView 默认下载边界:Expo WebView 内网页自动下载和 `` 直接落盘默认关闭;壳层注入脚本阻断 download 链接,iOS `onFileDownload` 只丢弃不落盘,Android 包配置通过 `blockedPermissions` 移除外部存储读写、管理外部存储和请求安装包权限。移动端文本、图片、音频保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等 HostBridge 受控导出能力进入系统分享 / 保存面板。 - 2026-06-18 移动壳 HostBridge 消息来源校验:Expo 移动壳 `onMessage` 必须根据 `event.nativeEvent.url` 校验消息来源,只有同源主站页面能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域、协议降级和危险协议页面消息直接丢弃,不返回宿主能力错误细节。该规则与 WebView 导航留壳规则共用同源判断,配置检查和移动壳导航测试会拒绝移除。 +- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 0b1601317..f6b1319ab 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -50,7 +50,8 @@ apps/ mobile-shell/ # Expo + React Native App 壳 desktop-shell/ # Tauri 桌面 App 壳 miniprogram/ - host-bridge/ # 微信小程序宿主桥接逻辑,页面只做装配 + host-bridge/ # 微信小程序协议归一、支付 / 订阅 / 分享结果编解码 + shell/ # 微信小程序 Page 生命周期、wx.* 容器调用和页面工厂 packages/ shared/ src/contracts/ @@ -63,7 +64,7 @@ src/ 已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。 -三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,可测试桥接逻辑统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。 +三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/wechatShell*.js`,页面入口只做 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。 ## HostBridge 消息协议 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 959a6c3d1..b1172d9d1 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -37,7 +37,7 @@ AI H5 sandbox -> parent HostBridge adapter ``` -桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的可测试桥接逻辑统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 +桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 ## 首批能力 diff --git a/miniprogram/host-bridge/wechatHostBridgePayment.js b/miniprogram/host-bridge/wechatHostBridgePayment.js index d25f527ed..0426a8bc4 100644 --- a/miniprogram/host-bridge/wechatHostBridgePayment.js +++ b/miniprogram/host-bridge/wechatHostBridgePayment.js @@ -176,44 +176,13 @@ function notifyPreviousWebView(requestId, orderId, payResult) { } } -function createWechatPayPage(pageContext) { - return { - data: { - title: '正在拉起支付', - errorMessage: '', - }, - - async onLoad(query) { - const requestId = String(query.requestId || ''); - const orderId = String(query.orderId || ''); - const payParams = parsePayParams(query.payParams); - if (!requestId || !payParams) { - const page = pageContext ?? this; - page.setData({ - title: '支付失败', - errorMessage: '缺少支付参数。', - }); - return; - } - - const payResult = await requestWechatPayment(payParams); - notifyPreviousWebView(requestId, orderId, payResult); - wx.navigateBack(); - }, - - handleBack() { - wx.navigateBack(); - }, - }; -} - module.exports = { canUseVirtualPayment, PAY_RESULT_STORAGE_KEY, appendPayResult, buildPayResultValue, - createWechatPayPage, normalizePayError, + notifyPreviousWebView, parsePayParams, safeCompareVersion, requestWechatPayment, diff --git a/miniprogram/host-bridge/wechatHostBridgePayment.test.js b/miniprogram/host-bridge/wechatHostBridgePayment.test.js index d636b5efe..21dbfa0e7 100644 --- a/miniprogram/host-bridge/wechatHostBridgePayment.test.js +++ b/miniprogram/host-bridge/wechatHostBridgePayment.test.js @@ -1,15 +1,17 @@ +import path from 'node:path'; + import { beforeEach, describe, expect, test, vi } from 'vitest'; -import wechatPayBridge from './wechatHostBridgePayment.js'; +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; -const { - appendPayResult, - createWechatPayPage, - parsePayParams, - requestWechatPayment, -} = wechatPayBridge; +const payBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/wechatHostBridgePayment.js', +); describe('wechat-pay mini program payment bridge', () => { + let wechatPayBridge; + beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {}); globalThis.wx = { @@ -20,9 +22,11 @@ describe('wechat-pay mini program payment bridge', () => { navigateBack: vi.fn(), }; globalThis.getCurrentPages = vi.fn(() => []); + wechatPayBridge = loadCommonJsModule(payBridgePath); }); test('routes virtual payloads to wx.requestVirtualPayment', async () => { + const { requestWechatPayment } = wechatPayBridge; globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => { options.success?.({ errMsg: 'requestVirtualPayment:ok' }); }); @@ -49,6 +53,7 @@ describe('wechat-pay mini program payment bridge', () => { }); test('routes goods virtual payloads to wx.requestVirtualPayment', async () => { + const { requestWechatPayment } = wechatPayBridge; globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => { options.success?.({ errMsg: 'requestVirtualPayment:ok' }); }); @@ -75,6 +80,7 @@ describe('wechat-pay mini program payment bridge', () => { }); test('keeps ordinary requestPayment payloads on wx.requestPayment', async () => { + const { requestWechatPayment } = wechatPayBridge; globalThis.wx.requestPayment.mockImplementationOnce((options) => { options.success?.(); }); @@ -101,6 +107,7 @@ describe('wechat-pay mini program payment bridge', () => { }); test('maps virtual payment cancel errCode to cancel result', async () => { + const { requestWechatPayment } = wechatPayBridge; const payError = { errCode: -2, errMsg: 'requestVirtualPayment:fail cancel', @@ -129,30 +136,17 @@ describe('wechat-pay mini program payment bridge', () => { ); }); - test('page notifies previous web-view after virtual payment', async () => { + test('notifies previous web-view after virtual payment', () => { + const { notifyPreviousWebView } = wechatPayBridge; const previousPage = { data: { webViewUrl: 'https://web.test/#tab=profile' }, setData: vi.fn(), }; globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); - globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => { - options.success?.({ errMsg: 'requestVirtualPayment:ok' }); - }); - const page = createWechatPayPage({ - setData: vi.fn(), - }); - await page.onLoad({ - requestId: 'request-1', - orderId: 'order-1', - payParams: encodeURIComponent( - JSON.stringify({ - mode: 'short_series_coin', - signData: '{}', - paySig: 'pay-sig', - signature: 'user-sig', - }), - ), + notifyPreviousWebView('request-1', 'order-1', { + status: 'success', + errorMessage: '', }); expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( @@ -162,10 +156,10 @@ describe('wechat-pay mini program payment bridge', () => { expect(previousPage.setData).toHaveBeenCalledWith({ webViewUrl: 'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1', }); - expect(globalThis.wx.navigateBack).toHaveBeenCalled(); }); test('parsePayParams and appendPayResult keep existing behavior', () => { + const { appendPayResult, parsePayParams } = wechatPayBridge; expect(parsePayParams(encodeURIComponent('{"paySign":"sig"}'))).toEqual({ paySign: 'sig', }); diff --git a/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js b/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js index 04107f64d..18a391abe 100644 --- a/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js +++ b/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js @@ -34,7 +34,7 @@ function resolveSubscribeStatus(result, templateId) { : 'skip'; } -function createSubscribeMessagePage(pageContext, options = {}) { +function createSubscribeMessagePageController(pageContext, options = {}) { const templateId = String(options.templateId || '').trim(); const notifyPageResult = (methodThis, status, reason) => { const page = pageContext ?? methodThis; @@ -123,6 +123,6 @@ module.exports = { SUBSCRIBE_RESULT_STORAGE_KEY, appendSubscribeResult, buildSubscribeResultValue, - createSubscribeMessagePage, + createSubscribeMessagePageController, resolveSubscribeStatus, }; diff --git a/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js b/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js index b69a1253b..2db75b73c 100644 --- a/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js +++ b/miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js @@ -1,17 +1,19 @@ +import path from 'node:path'; + import { beforeEach, describe, expect, test, vi } from 'vitest'; -import subscribeMessageBridge from './wechatHostBridgeSubscribeMessage.js'; +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; -const { - SUBSCRIBE_RESULT_STORAGE_KEY, - appendSubscribeResult, - buildSubscribeResultValue, - createSubscribeMessagePage, -} = subscribeMessageBridge; +const subscribeBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js', +); describe('subscribe-message mini program bridge', () => { + let subscribeMessageBridge; + beforeEach(() => { globalThis.wx = { requestSubscribeMessage: vi.fn(), @@ -19,9 +21,14 @@ describe('subscribe-message mini program bridge', () => { navigateBack: vi.fn(), }; globalThis.getCurrentPages = vi.fn(() => []); + subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath); }); test('requests subscribe message and stores result before returning', () => { + const { + SUBSCRIBE_RESULT_STORAGE_KEY, + createSubscribeMessagePageController, + } = subscribeMessageBridge; const previousPage = { data: { webViewUrl: 'https://web.test/#tab=create' }, setData: vi.fn(), @@ -32,7 +39,7 @@ describe('subscribe-message mini program bridge', () => { m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept', }); }); - const page = createSubscribeMessagePage( + const page = createSubscribeMessagePageController( { setData: vi.fn(), }, @@ -56,12 +63,16 @@ describe('subscribe-message mini program bridge', () => { }); test('skip action notifies previous web-view', () => { + const { + SUBSCRIBE_RESULT_STORAGE_KEY, + createSubscribeMessagePageController, + } = subscribeMessageBridge; const previousPage = { data: { webViewUrl: 'https://web.test/' }, setData: vi.fn(), }; globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); - const page = createSubscribeMessagePage( + const page = createSubscribeMessagePageController( { setData: vi.fn(), }, @@ -80,6 +91,8 @@ describe('subscribe-message mini program bridge', () => { }); test('appendSubscribeResult replaces stale subscribe hash', () => { + const { appendSubscribeResult, buildSubscribeResultValue } = + subscribeMessageBridge; expect( appendSubscribeResult( 'https://web.test/#old=1&wx_subscribe_result=old', diff --git a/miniprogram/pages/share-grid/index.js b/miniprogram/pages/share-grid/index.js index 61922c831..79e22a6e1 100644 --- a/miniprogram/pages/share-grid/index.js +++ b/miniprogram/pages/share-grid/index.js @@ -1,206 +1,5 @@ -/* global Page, wx */ -/* eslint-disable no-console */ +/* global Page */ -const { - buildShareGridTileFileName, - buildShareGridTilePlan, - normalizeShareGridQuery, -} = require('../../host-bridge/wechatHostBridgeShareGrid'); +const { createWechatShareGridPage } = require('../../shell/wechatShellShareGrid'); -function downloadImage(imageUrl) { - return new Promise((resolve, reject) => { - wx.downloadFile({ - url: imageUrl, - success(response) { - if (response.statusCode >= 200 && response.statusCode < 300) { - resolve(response.tempFilePath); - return; - } - reject(new Error(`封面下载失败:${response.statusCode}`)); - }, - fail(error) { - reject(new Error(error.errMsg || '封面下载失败')); - }, - }); - }); -} - -function getImageInfo(src) { - return new Promise((resolve, reject) => { - wx.getImageInfo({ - src, - success: resolve, - fail(error) { - reject(new Error(error.errMsg || '读取封面失败')); - }, - }); - }); -} - -function getCanvasNode(page) { - return new Promise((resolve, reject) => { - wx.createSelectorQuery() - .in(page) - .select('#share-grid-canvas') - .fields({ node: true, size: true }) - .exec((results) => { - const canvas = results && results[0] && results[0].node; - if (canvas) { - resolve(canvas); - return; - } - reject(new Error('切图画布初始化失败')); - }); - }); -} - -function canvasToTempFilePath(canvas, width, height) { - return new Promise((resolve, reject) => { - wx.canvasToTempFilePath({ - canvas, - width, - height, - destWidth: width, - destHeight: height, - fileType: 'png', - success(response) { - resolve(response.tempFilePath); - }, - fail(error) { - reject(new Error(error.errMsg || '导出切图失败')); - }, - }); - }); -} - -function saveImageToAlbum(filePath) { - return new Promise((resolve, reject) => { - wx.saveImageToPhotosAlbum({ - filePath, - success() { - resolve(); - }, - fail(error) { - reject(new Error(error.errMsg || '保存到相册失败')); - }, - }); - }); -} - -function copyTempFileWithName(tempFilePath, fileName) { - const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager(); - const userDataPath = wx.env && wx.env.USER_DATA_PATH; - if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') { - return Promise.resolve(tempFilePath); - } - - const targetPath = `${userDataPath}/${fileName}`; - return new Promise((resolve) => { - fileSystem.copyFile({ - srcPath: tempFilePath, - destPath: targetPath, - success() { - resolve(targetPath); - }, - fail() { - resolve(tempFilePath); - }, - }); - }); -} - -async function saveGridTiles(page, params, localImagePath, imageInfo) { - const canvas = await getCanvasNode(page); - const context = canvas.getContext('2d'); - const image = canvas.createImage(); - await new Promise((resolve, reject) => { - image.onload = resolve; - image.onerror = () => reject(new Error('封面绘制失败')); - image.src = localImagePath; - }); - - const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height); - for (const tile of plan) { - canvas.width = tile.sourceWidth; - canvas.height = tile.sourceHeight; - context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight); - context.drawImage( - image, - tile.sourceX, - tile.sourceY, - tile.sourceWidth, - tile.sourceHeight, - 0, - 0, - tile.sourceWidth, - tile.sourceHeight, - ); - - const tempFilePath = await canvasToTempFilePath( - canvas, - tile.sourceWidth, - tile.sourceHeight, - ); - const namedFilePath = await copyTempFileWithName( - tempFilePath, - buildShareGridTileFileName(params, tile.index), - ); - await saveImageToAlbum(namedFilePath); - page.setData({ - savedCount: tile.index + 1, - }); - } -} - -Page({ - data: { - errorMessage: '', - loading: true, - savedCount: 0, - title: '九宫切图', - }, - - async onLoad(query = {}) { - const params = normalizeShareGridQuery(query); - this._shareGridParams = params; - this.setData({ - errorMessage: '', - loading: true, - savedCount: 0, - title: params.title, - }); - - if (!params.imageUrl) { - this.setData({ - errorMessage: '缺少封面图。', - loading: false, - }); - return; - } - - try { - const localImagePath = await downloadImage(params.imageUrl); - const imageInfo = await getImageInfo(localImagePath); - await saveGridTiles(this, params, localImagePath, imageInfo); - this.setData({ - loading: false, - savedCount: 9, - }); - wx.showToast({ - title: '已保存', - icon: 'success', - }); - } catch (error) { - console.error('[share-grid] save failed', error); - this.setData({ - errorMessage: - error && error.message ? error.message : '九宫切图保存失败。', - loading: false, - }); - } - }, - - handleBack() { - wx.navigateBack(); - }, -}); +Page(createWechatShareGridPage()); diff --git a/miniprogram/pages/subscribe-message/index.js b/miniprogram/pages/subscribe-message/index.js index a5349d410..bd6c9d521 100644 --- a/miniprogram/pages/subscribe-message/index.js +++ b/miniprogram/pages/subscribe-message/index.js @@ -3,7 +3,7 @@ const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config'); const { createSubscribeMessagePage, -} = require('../../host-bridge/wechatHostBridgeSubscribeMessage'); +} = require('../../shell/wechatShellSubscribeMessage'); Page( createSubscribeMessagePage(null, { diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 68becaf67..273d2bb03 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -1,697 +1,5 @@ -/* global Page, wx */ -/* eslint-disable no-console */ +/* global Page */ -const { - API_BASE_URL, - DEV_API_BASE_URL, - DEV_WEB_VIEW_ENTRY_URL, - MINI_PROGRAM_APP_ID, - MINI_PROGRAM_ENV, - WEB_VIEW_ENTRY_URL, - WEB_VIEW_SOURCE_QUERY, -} = require('../../config'); -const { - appendHashParams, - buildWebViewSharePath, - buildWebViewShareTimelineQuery, - resolveShareTargetFromWebViewMessage, - resolveWebViewUrlFromRuntimeConfig, -} = require('../../host-bridge/wechatHostBridgeWebView'); +const { createWechatWebViewPage } = require('../../shell/wechatShellWebView'); -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 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 = '陶泥儿'; - -function showWebViewShareMenu() { - if (typeof wx.showShareMenu !== 'function') { - return; - } - - wx.showShareMenu({ - withShareTicket: true, - menus: ['shareAppMessage', 'shareTimeline'], - }); -} - -function resolveNativeShareQuery(page) { - return ( - (page && page._currentShareTarget) || - (page && page._lastLaunchQuery) || - {} - ); -} - -function buildWebViewShareAppMessage(query = {}) { - return { - title: WEB_VIEW_SHARE_TITLE, - path: buildWebViewSharePath(query), - }; -} - -function buildWebViewShareTimeline(query = {}) { - return { - title: WEB_VIEW_SHARE_TITLE, - query: buildWebViewShareTimelineQuery(query), - }; -} - -function isConfiguredEntryUrl(value) { - const trimmed = String(value || '').trim(); - return /^https:\/\/[^/]+/i.test(trimmed); -} - -function trimTrailingSlash(value) { - return String(value || '').trim().replace(/\/+$/u, ''); -} - -function isConfiguredApiBaseUrl(value) { - return /^https:\/\/[^/]+/i.test(String(value || '').trim()); -} - -function parseBooleanQueryFlag(value) { - return value === true || value === '1' || value === 'true' || value === 'yes'; -} - -function normalizeNicknameInput(value) { - return String(value || '').trim(); -} - -function normalizeNicknameForMatch(value) { - return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase(); -} - -function isPhoneLikeDisplayName(value) { - const normalized = normalizeNicknameForMatch(value); - if (!normalized) { - return false; - } - - const digits = normalized.replace(/\D/gu, ''); - return ( - /^(\+?86)?1\d{10}$/u.test(normalized) || - /^1\d{2}\*{4}\d{4}$/u.test(normalized) || - (/[*x]/iu.test(normalized) && digits.length >= 7) || - digits.length >= 11 - ); -} - -function isDefaultDisplayName(value, publicUserCode) { - const normalized = normalizeNicknameForMatch(value); - const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode); - if (!normalized) { - return true; - } - - return ( - normalized === '微信旅人' || - normalized === '玩家' || - normalized === normalizedPublicUserCode || - /^sy-\d{8}$/iu.test(normalized) || - /^user[_-]/iu.test(normalized) || - isPhoneLikeDisplayName(normalized) - ); -} - -function shouldRequestNicknameAfterLogin(authResult) { - const user = authResult && authResult.user ? authResult.user : {}; - const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName); - if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) { - return false; - } - - return ( - authResult && - (authResult.created || - isDefaultDisplayName(user.displayName, user.publicUserCode) || - (wechatDisplayName && - isDefaultDisplayName(wechatDisplayName, user.publicUserCode))) - ); -} - -function normalizeMiniProgramEnv(value) { - const normalized = String(value || '').trim().toLowerCase(); - if (normalized === 'release') { - return 'release'; - } - if (normalized === 'trial') { - return 'trial'; - } - if ( - normalized === 'develop' || - normalized === 'development' || - normalized === 'dev' - ) { - return 'dev'; - } - return ''; -} - -function readMiniProgramEnvVersion() { - if (typeof wx.getAccountInfoSync !== 'function') { - return ''; - } - try { - const accountInfo = wx.getAccountInfoSync(); - return ( - accountInfo && - accountInfo.miniProgram && - accountInfo.miniProgram.envVersion - ); - } catch (error) { - console.warn('[web-view] read mini program env failed', error); - return ''; - } -} - -function resolveMiniProgramRuntimeConfig() { - const miniProgramEnv = - normalizeMiniProgramEnv(readMiniProgramEnvVersion()) || - normalizeMiniProgramEnv(MINI_PROGRAM_ENV) || - 'release'; - const useReleaseChannel = miniProgramEnv === 'release'; - const webViewEntryUrl = useReleaseChannel - ? WEB_VIEW_ENTRY_URL - : DEV_WEB_VIEW_ENTRY_URL || WEB_VIEW_ENTRY_URL; - const apiBaseUrl = useReleaseChannel - ? API_BASE_URL - : DEV_API_BASE_URL || API_BASE_URL; - const sourceQuery = { - ...WEB_VIEW_SOURCE_QUERY, - }; - if (!useReleaseChannel) { - sourceQuery.miniProgramEnv = miniProgramEnv; - } - - return { - apiBaseUrl, - miniProgramEnv, - sourceQuery, - webViewEntryUrl, - }; -} - -function shouldStartAuthFromQuery(query) { - return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN; -} - -function shouldReturnToPreviousPage(query) { - return String((query && query.returnTo) || '').trim() === 'previous'; -} - -function resolveWebViewUrl(authResult, launchQuery = {}) { - const runtimeConfig = resolveMiniProgramRuntimeConfig(); - const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); - if (!isConfiguredEntryUrl(entryUrl)) { - return ''; - } - - return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, { - ...runtimeConfig, - webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(), - }); -} - -function persistAuthResult(authResult) { - wx.setStorageSync(AUTH_RESULT_STORAGE_KEY, JSON.stringify(authResult)); -} - -function consumeAuthResult() { - const rawValue = wx.getStorageSync(AUTH_RESULT_STORAGE_KEY); - if (!rawValue) { - return null; - } - - wx.removeStorageSync(AUTH_RESULT_STORAGE_KEY); - try { - const parsed = JSON.parse(String(rawValue)); - if (!parsed || typeof parsed !== 'object') { - return null; - } - - const token = String(parsed.token || '').trim(); - if (!token) { - return null; - } - - return { - token, - bindingStatus: String(parsed.bindingStatus || 'pending_bind_phone'), - }; - } catch (error) { - console.error('[web-view] parse auth result failed', error); - return null; - } -} - -function getClientInstanceId() { - const stored = wx.getStorageSync(CLIENT_INSTANCE_STORAGE_KEY); - if (stored) { - return String(stored); - } - - const nextId = `wxmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; - wx.setStorageSync(CLIENT_INSTANCE_STORAGE_KEY, nextId); - return nextId; -} - -function resolveClientPlatform() { - const info = wx.getSystemInfoSync(); - const platform = String(info.platform || '').toLowerCase(); - if (platform === 'ios') { - return 'ios'; - } - if (platform === 'android') { - return 'android'; - } - return 'unknown'; -} - -function wxLogin() { - return new Promise((resolve, reject) => { - wx.login({ - success(result) { - if (result.code) { - resolve(result.code); - return; - } - reject(new Error('微信登录未返回 code')); - }, - fail(error) { - reject(new Error(error.errMsg || '微信登录失败')); - }, - }); - }); -} - -function requestMiniProgramLogin(code, displayName) { - return new Promise((resolve, reject) => { - const runtimeConfig = resolveMiniProgramRuntimeConfig(); - const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); - if (!isConfiguredApiBaseUrl(apiBaseUrl)) { - reject(new Error('请先配置 API_BASE_URL')); - return; - } - - wx.request({ - url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`, - method: 'POST', - data: { - code, - ...(displayName ? { displayName } : {}), - }, - header: { - 'content-type': 'application/json', - 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, - 'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME, - 'x-client-platform': resolveClientPlatform(), - 'x-client-instance-id': getClientInstanceId(), - 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, - 'x-mini-program-env': runtimeConfig.miniProgramEnv, - }, - success(response) { - if (response.statusCode >= 200 && response.statusCode < 300) { - resolve(response.data); - return; - } - const message = - response.data && - response.data.error && - response.data.error.message - ? response.data.error.message - : `微信登录失败:${response.statusCode}`; - reject(new Error(message)); - }, - fail(error) { - reject(new Error(error.errMsg || '微信登录请求失败')); - }, - }); - }); -} - -function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) { - return new Promise((resolve, reject) => { - const runtimeConfig = resolveMiniProgramRuntimeConfig(); - const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); - if (!isConfiguredApiBaseUrl(apiBaseUrl)) { - reject(new Error('请先配置 API_BASE_URL')); - return; - } - - wx.request({ - url: `${apiBaseUrl}/api/auth/wechat/bind-phone`, - method: 'POST', - data: { - wechatPhoneCode, - ...(displayName ? { displayName } : {}), - }, - header: { - authorization: `Bearer ${authToken}`, - 'content-type': 'application/json', - 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, - 'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME, - 'x-client-platform': resolveClientPlatform(), - 'x-client-instance-id': getClientInstanceId(), - 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, - 'x-mini-program-env': runtimeConfig.miniProgramEnv, - }, - success(response) { - if (response.statusCode >= 200 && response.statusCode < 300) { - resolve(response.data); - return; - } - const message = - response.data && - response.data.error && - response.data.error.message - ? response.data.error.message - : `绑定手机号失败:${response.statusCode}`; - reject(new Error(message)); - }, - fail(error) { - reject(new Error(error.errMsg || '绑定手机号请求失败')); - }, - }); - }); -} - -async function resolveAuthResult(displayName) { - const code = await wxLogin(); - const response = await requestMiniProgramLogin(code, displayName); - if (!response || !response.token) { - throw new Error('服务器未返回登录态'); - } - return { - token: response.token, - bindingStatus: response.bindingStatus || 'pending_bind_phone', - user: response.user || null, - created: response.created === true, - }; -} - -Page({ - data: { - authResult: null, - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: true, - nicknameInput: '', - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage: false, - webViewUrl: '', - }, - - async onLoad(query = {}) { - this._lastLaunchQuery = query; - showWebViewShareMenu(); - const runtimeConfig = resolveMiniProgramRuntimeConfig(); - // 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。 - if (!isConfiguredEntryUrl(runtimeConfig.webViewEntryUrl)) { - this.setData({ - errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。', - loading: false, - webViewUrl: '', - }); - return; - } - - const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired); - const returnToPreviousPage = shouldReturnToPreviousPage(query); - if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) { - this.setData({ - authResult: null, - bindingPhone: false, - errorMessage: '', - loading: false, - phoneBindingRequired: false, - returnToPreviousPage: false, - webViewUrl: resolveWebViewUrl(null, query), - }); - return; - } - - if (!isConfiguredApiBaseUrl(runtimeConfig.apiBaseUrl)) { - this.setData({ - errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。', - loading: false, - webViewUrl: '', - }); - return; - } - - this.setData({ - authResult: null, - bindingPhone: false, - errorMessage: '', - loggingIn: true, - loading: true, - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: '', - }); - await this.startAuthFlow(returnToPreviousPage, ''); - }, - - handleNicknameInput(event) { - this.setData({ - nicknameInput: event.detail ? event.detail.value : '', - }); - }, - - async handleStartLogin() { - const displayName = normalizeNicknameInput(this.data.nicknameInput); - if (!displayName) { - this.setData({ - errorMessage: '请先选择或填写微信昵称。', - }); - return; - } - - this.setData({ - errorMessage: '', - loggingIn: true, - }); - await this.startAuthFlow(this.data.returnToPreviousPage, displayName); - }, - - async startAuthFlow(returnToPreviousPage, displayName) { - try { - const authResult = await resolveAuthResult(displayName); - if (!displayName && shouldRequestNicknameAfterLogin(authResult)) { - this.setData({ - authResult, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: true, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: '', - }); - return; - } - - if (authResult.bindingStatus === 'pending_bind_phone') { - this.setData({ - authResult, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: true, - returnToPreviousPage, - webViewUrl: '', - }); - return; - } - - if (returnToPreviousPage) { - persistAuthResult(authResult); - this.setData({ - authResult, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: '', - }); - wx.navigateBack(); - return; - } - - this.setData({ - authResult, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), - }); - } catch (error) { - this.setData({ - authResult: null, - errorMessage: - error && error.message ? error.message : '微信登录失败,请稍后重试。', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage, - webViewUrl: '', - }); - } - }, - - onShow() { - const authResult = consumeAuthResult(); - if (authResult) { - this.setData({ - authResult, - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), - }); - } - - this.consumePayResult(); - setTimeout(() => { - this.consumePayResult(); - }, PAY_RESULT_RECHECK_DELAY_MS); - }, - - consumePayResult() { - const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY); - if (result && this.data.webViewUrl) { - wx.removeStorageSync(PAY_RESULT_STORAGE_KEY); - this.setData({ - webViewUrl: appendHashParams(this.data.webViewUrl, { - wx_pay_result: result, - }), - }); - } - }, - - async handleGetPhoneNumber(event) { - if (!this.data.authResult || !this.data.authResult.token) { - this.handleRetryLogin(); - return; - } - - const detail = event.detail || {}; - if (!detail.code) { - this.setData({ - errorMessage: detail.errMsg || '需要授权手机号后才能完成绑定。', - }); - return; - } - - this.setData({ - bindingPhone: true, - errorMessage: '', - }); - try { - const response = await requestMiniProgramBindPhone( - this.data.authResult.token, - detail.code, - normalizeNicknameInput(this.data.nicknameInput), - ); - if (!response || !response.token) { - throw new Error('服务器未返回绑定后的登录态'); - } - const nextAuthResult = { - token: response.token, - bindingStatus: 'active', - }; - if (this.data.returnToPreviousPage) { - persistAuthResult(nextAuthResult); - this.setData({ - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - }); - wx.navigateBack(); - return; - } - this.setData({ - authResult: nextAuthResult, - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: false, - nicknameRequired: false, - phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl( - nextAuthResult, - this._lastLaunchQuery || {}, - ), - }); - } catch (error) { - this.setData({ - bindingPhone: false, - errorMessage: - error && error.message - ? error.message - : '绑定手机号失败,请稍后重试。', - }); - } - }, - - handleRetryLogin() { - this.setData({ - authResult: null, - bindingPhone: false, - errorMessage: '', - loggingIn: false, - loading: true, - nicknameInput: '', - nicknameRequired: false, - phoneBindingRequired: false, - returnToPreviousPage: false, - webViewUrl: '', - }); - this.onLoad(this._lastLaunchQuery || { authAction: AUTH_ACTION_LOGIN }); - }, - - handleWebViewLoad(event) { - console.info('[web-view] loaded', event.detail); - }, - - handleWebViewError(event) { - console.error('[web-view] load failed', event.detail); - }, - - handleWebViewMessage(event) { - const shareTarget = resolveShareTargetFromWebViewMessage(event.detail); - if (shareTarget) { - this._currentShareTarget = shareTarget; - } - // 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。 - console.info('[web-view] message', event.detail); - }, - - onShareAppMessage() { - return buildWebViewShareAppMessage(resolveNativeShareQuery(this)); - }, - - onShareTimeline() { - return buildWebViewShareTimeline(resolveNativeShareQuery(this)); - }, -}); +Page(createWechatWebViewPage()); diff --git a/miniprogram/pages/wechat-pay/index.js b/miniprogram/pages/wechat-pay/index.js index 7521bf0b9..8006a204c 100644 --- a/miniprogram/pages/wechat-pay/index.js +++ b/miniprogram/pages/wechat-pay/index.js @@ -1,3 +1,5 @@ -const { createWechatPayPage } = require('../../host-bridge/wechatHostBridgePayment'); +/* global Page */ + +const { createWechatPayPage } = require('../../shell/wechatShellPayment'); Page(createWechatPayPage()); diff --git a/miniprogram/shell/wechatShellPayment.js b/miniprogram/shell/wechatShellPayment.js new file mode 100644 index 000000000..8f51aa5a2 --- /dev/null +++ b/miniprogram/shell/wechatShellPayment.js @@ -0,0 +1,42 @@ +/* global wx */ + +const { + notifyPreviousWebView, + parsePayParams, + requestWechatPayment, +} = require('../host-bridge/wechatHostBridgePayment'); + +function createWechatPayPage(pageContext) { + return { + data: { + title: '正在拉起支付', + errorMessage: '', + }, + + async onLoad(query) { + const requestId = String(query.requestId || ''); + const orderId = String(query.orderId || ''); + const payParams = parsePayParams(query.payParams); + if (!requestId || !payParams) { + const page = pageContext ?? this; + page.setData({ + title: '支付失败', + errorMessage: '缺少支付参数。', + }); + return; + } + + const payResult = await requestWechatPayment(payParams); + notifyPreviousWebView(requestId, orderId, payResult); + wx.navigateBack(); + }, + + handleBack() { + wx.navigateBack(); + }, + }; +} + +module.exports = { + createWechatPayPage, +}; diff --git a/miniprogram/shell/wechatShellPayment.test.js b/miniprogram/shell/wechatShellPayment.test.js new file mode 100644 index 000000000..603b592ca --- /dev/null +++ b/miniprogram/shell/wechatShellPayment.test.js @@ -0,0 +1,69 @@ +import path from 'node:path'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const payBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/wechatHostBridgePayment.js', +); +const paymentShellPath = path.resolve( + process.cwd(), + 'miniprogram/shell/wechatShellPayment.js', +); + +describe('wechat payment shell page', () => { + let createWechatPayPage; + + beforeEach(() => { + globalThis.wx = { + getSystemInfoSync: vi.fn(() => ({ SDKVersion: '2.32.0' })), + navigateBack: vi.fn(), + requestPayment: vi.fn(), + requestVirtualPayment: vi.fn(), + setStorageSync: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => []); + const wechatPayBridge = loadCommonJsModule(payBridgePath); + const wechatPaymentShell = loadCommonJsModule(paymentShellPath, { + '../host-bridge/wechatHostBridgePayment': wechatPayBridge, + }); + createWechatPayPage = wechatPaymentShell.createWechatPayPage; + }); + + test('stores payment result and returns to previous web-view', async () => { + const previousPage = { + data: { webViewUrl: 'https://web.test/#tab=profile' }, + setData: vi.fn(), + }; + globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]); + globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => { + options.success?.({ errMsg: 'requestVirtualPayment:ok' }); + }); + const page = createWechatPayPage({ setData: vi.fn() }); + + await page.onLoad({ + requestId: 'request-1', + orderId: 'order-1', + payParams: encodeURIComponent( + JSON.stringify({ + mode: 'short_series_coin', + signData: '{}', + paySig: 'pay-sig', + signature: 'user-sig', + }), + ), + }); + + expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith( + 'genarrative:wechat-pay-result', + 'request-1:success:order-1', + ); + expect(previousPage.setData).toHaveBeenCalledWith({ + webViewUrl: + 'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1', + }); + expect(globalThis.wx.navigateBack).toHaveBeenCalled(); + }); +}); diff --git a/miniprogram/shell/wechatShellShareGrid.js b/miniprogram/shell/wechatShellShareGrid.js new file mode 100644 index 000000000..562b5b67e --- /dev/null +++ b/miniprogram/shell/wechatShellShareGrid.js @@ -0,0 +1,212 @@ +/* global wx */ +/* eslint-disable no-console */ + +const { + buildShareGridTileFileName, + buildShareGridTilePlan, + normalizeShareGridQuery, +} = require('../host-bridge/wechatHostBridgeShareGrid'); + +function downloadImage(imageUrl) { + return new Promise((resolve, reject) => { + wx.downloadFile({ + url: imageUrl, + success(response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(response.tempFilePath); + return; + } + reject(new Error(`封面下载失败:${response.statusCode}`)); + }, + fail(error) { + reject(new Error(error.errMsg || '封面下载失败')); + }, + }); + }); +} + +function getImageInfo(src) { + return new Promise((resolve, reject) => { + wx.getImageInfo({ + src, + success: resolve, + fail(error) { + reject(new Error(error.errMsg || '读取封面失败')); + }, + }); + }); +} + +function getCanvasNode(page) { + return new Promise((resolve, reject) => { + wx.createSelectorQuery() + .in(page) + .select('#share-grid-canvas') + .fields({ node: true, size: true }) + .exec((results) => { + const canvas = results && results[0] && results[0].node; + if (canvas) { + resolve(canvas); + return; + } + reject(new Error('切图画布初始化失败')); + }); + }); +} + +function canvasToTempFilePath(canvas, width, height) { + return new Promise((resolve, reject) => { + wx.canvasToTempFilePath({ + canvas, + width, + height, + destWidth: width, + destHeight: height, + fileType: 'png', + success(response) { + resolve(response.tempFilePath); + }, + fail(error) { + reject(new Error(error.errMsg || '导出切图失败')); + }, + }); + }); +} + +function saveImageToAlbum(filePath) { + return new Promise((resolve, reject) => { + wx.saveImageToPhotosAlbum({ + filePath, + success() { + resolve(); + }, + fail(error) { + reject(new Error(error.errMsg || '保存到相册失败')); + }, + }); + }); +} + +function copyTempFileWithName(tempFilePath, fileName) { + const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager(); + const userDataPath = wx.env && wx.env.USER_DATA_PATH; + if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') { + return Promise.resolve(tempFilePath); + } + + const targetPath = `${userDataPath}/${fileName}`; + return new Promise((resolve) => { + fileSystem.copyFile({ + srcPath: tempFilePath, + destPath: targetPath, + success() { + resolve(targetPath); + }, + fail() { + resolve(tempFilePath); + }, + }); + }); +} + +async function saveGridTiles(page, params, localImagePath, imageInfo) { + const canvas = await getCanvasNode(page); + const context = canvas.getContext('2d'); + const image = canvas.createImage(); + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = () => reject(new Error('封面绘制失败')); + image.src = localImagePath; + }); + + const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height); + for (const tile of plan) { + canvas.width = tile.sourceWidth; + canvas.height = tile.sourceHeight; + context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight); + context.drawImage( + image, + tile.sourceX, + tile.sourceY, + tile.sourceWidth, + tile.sourceHeight, + 0, + 0, + tile.sourceWidth, + tile.sourceHeight, + ); + + const tempFilePath = await canvasToTempFilePath( + canvas, + tile.sourceWidth, + tile.sourceHeight, + ); + const namedFilePath = await copyTempFileWithName( + tempFilePath, + buildShareGridTileFileName(params, tile.index), + ); + await saveImageToAlbum(namedFilePath); + page.setData({ + savedCount: tile.index + 1, + }); + } +} + +function createWechatShareGridPage() { + return { + data: { + errorMessage: '', + loading: true, + savedCount: 0, + title: '九宫切图', + }, + + async onLoad(query = {}) { + const params = normalizeShareGridQuery(query); + this._shareGridParams = params; + this.setData({ + errorMessage: '', + loading: true, + savedCount: 0, + title: params.title, + }); + + if (!params.imageUrl) { + this.setData({ + errorMessage: '缺少封面图。', + loading: false, + }); + return; + } + + try { + const localImagePath = await downloadImage(params.imageUrl); + const imageInfo = await getImageInfo(localImagePath); + await saveGridTiles(this, params, localImagePath, imageInfo); + this.setData({ + loading: false, + savedCount: 9, + }); + wx.showToast({ + title: '已保存', + icon: 'success', + }); + } catch (error) { + console.error('[share-grid] save failed', error); + this.setData({ + errorMessage: + error && error.message ? error.message : '九宫切图保存失败。', + loading: false, + }); + } + }, + + handleBack() { + wx.navigateBack(); + }, + }; +} + +module.exports = { + createWechatShareGridPage, +}; diff --git a/miniprogram/shell/wechatShellShareGrid.test.js b/miniprogram/shell/wechatShellShareGrid.test.js new file mode 100644 index 000000000..1a64c48c3 --- /dev/null +++ b/miniprogram/shell/wechatShellShareGrid.test.js @@ -0,0 +1,49 @@ +import path from 'node:path'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const shareGridBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/wechatHostBridgeShareGrid.js', +); +const shareGridShellPath = path.resolve( + process.cwd(), + 'miniprogram/shell/wechatShellShareGrid.js', +); + +let createWechatShareGridPage; + +function createPage() { + const page = createWechatShareGridPage(); + return { + ...page, + data: { ...page.data }, + setData(patch) { + Object.assign(this.data, patch); + }, + }; +} + +describe('wechat share-grid shell page', () => { + beforeEach(() => { + globalThis.wx = { + navigateBack: vi.fn(), + }; + const shareGridBridge = loadCommonJsModule(shareGridBridgePath); + const shareGridShell = loadCommonJsModule(shareGridShellPath, { + '../host-bridge/wechatHostBridgeShareGrid': shareGridBridge, + }); + createWechatShareGridPage = shareGridShell.createWechatShareGridPage; + }); + + test('shows a clear error when imageUrl is missing', async () => { + const page = createPage(); + + await page.onLoad({}); + + expect(page.data.loading).toBe(false); + expect(page.data.errorMessage).toBe('缺少封面图。'); + }); +}); diff --git a/miniprogram/shell/wechatShellSubscribeMessage.js b/miniprogram/shell/wechatShellSubscribeMessage.js new file mode 100644 index 000000000..9973fc64d --- /dev/null +++ b/miniprogram/shell/wechatShellSubscribeMessage.js @@ -0,0 +1,11 @@ +const { + createSubscribeMessagePageController, +} = require('../host-bridge/wechatHostBridgeSubscribeMessage'); + +function createSubscribeMessagePage(pageContext, options = {}) { + return createSubscribeMessagePageController(pageContext, options); +} + +module.exports = { + createSubscribeMessagePage, +}; diff --git a/miniprogram/shell/wechatShellSubscribeMessage.test.js b/miniprogram/shell/wechatShellSubscribeMessage.test.js new file mode 100644 index 000000000..911b7c65d --- /dev/null +++ b/miniprogram/shell/wechatShellSubscribeMessage.test.js @@ -0,0 +1,60 @@ +import path from 'node:path'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU'; + +const subscribeBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js', +); +const subscribeShellPath = path.resolve( + process.cwd(), + 'miniprogram/shell/wechatShellSubscribeMessage.js', +); + +describe('wechat subscribe-message shell page', () => { + let createSubscribeMessagePage; + + beforeEach(() => { + globalThis.wx = { + navigateBack: vi.fn(), + requestSubscribeMessage: vi.fn(), + setStorageSync: vi.fn(), + }; + const subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath); + const subscribeMessageShell = loadCommonJsModule(subscribeShellPath, { + '../host-bridge/wechatHostBridgeSubscribeMessage': subscribeMessageBridge, + }); + createSubscribeMessagePage = + subscribeMessageShell.createSubscribeMessagePage; + }); + + test('requests generation-result subscribe template and stores result', () => { + globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => { + options.success?.({ + [TEST_TEMPLATE_ID]: '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( + 'genarrative:wechat-subscribe-result', + 'request-1:success', + ); + expect(globalThis.wx.navigateBack).toHaveBeenCalled(); + }); +}); diff --git a/miniprogram/shell/wechatShellWebView.js b/miniprogram/shell/wechatShellWebView.js new file mode 100644 index 000000000..b0ccaeb79 --- /dev/null +++ b/miniprogram/shell/wechatShellWebView.js @@ -0,0 +1,703 @@ +/* global wx */ +/* eslint-disable no-console */ + +const { + API_BASE_URL, + DEV_API_BASE_URL, + DEV_WEB_VIEW_ENTRY_URL, + MINI_PROGRAM_APP_ID, + MINI_PROGRAM_ENV, + WEB_VIEW_ENTRY_URL, + WEB_VIEW_SOURCE_QUERY, +} = require('../config'); +const { + appendHashParams, + buildWebViewSharePath, + buildWebViewShareTimelineQuery, + resolveShareTargetFromWebViewMessage, + resolveWebViewUrlFromRuntimeConfig, +} = require('../host-bridge/wechatHostBridgeWebView'); + +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 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 = '陶泥儿'; + +function showWebViewShareMenu() { + if (typeof wx.showShareMenu !== 'function') { + return; + } + + wx.showShareMenu({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'], + }); +} + +function resolveNativeShareQuery(page) { + return ( + (page && page._currentShareTarget) || + (page && page._lastLaunchQuery) || + {} + ); +} + +function buildWebViewShareAppMessage(query = {}) { + return { + title: WEB_VIEW_SHARE_TITLE, + path: buildWebViewSharePath(query), + }; +} + +function buildWebViewShareTimeline(query = {}) { + return { + title: WEB_VIEW_SHARE_TITLE, + query: buildWebViewShareTimelineQuery(query), + }; +} + +function isConfiguredEntryUrl(value) { + const trimmed = String(value || '').trim(); + return /^https:\/\/[^/]+/i.test(trimmed); +} + +function trimTrailingSlash(value) { + return String(value || '').trim().replace(/\/+$/u, ''); +} + +function isConfiguredApiBaseUrl(value) { + return /^https:\/\/[^/]+/i.test(String(value || '').trim()); +} + +function parseBooleanQueryFlag(value) { + return value === true || value === '1' || value === 'true' || value === 'yes'; +} + +function normalizeNicknameInput(value) { + return String(value || '').trim(); +} + +function normalizeNicknameForMatch(value) { + return normalizeNicknameInput(value).replace(/\s+/gu, '').toLowerCase(); +} + +function isPhoneLikeDisplayName(value) { + const normalized = normalizeNicknameForMatch(value); + if (!normalized) { + return false; + } + + const digits = normalized.replace(/\D/gu, ''); + return ( + /^(\+?86)?1\d{10}$/u.test(normalized) || + /^1\d{2}\*{4}\d{4}$/u.test(normalized) || + (/[*x]/iu.test(normalized) && digits.length >= 7) || + digits.length >= 11 + ); +} + +function isDefaultDisplayName(value, publicUserCode) { + const normalized = normalizeNicknameForMatch(value); + const normalizedPublicUserCode = normalizeNicknameForMatch(publicUserCode); + if (!normalized) { + return true; + } + + return ( + normalized === '微信旅人' || + normalized === '玩家' || + normalized === normalizedPublicUserCode || + /^sy-\d{8}$/iu.test(normalized) || + /^user[_-]/iu.test(normalized) || + isPhoneLikeDisplayName(normalized) + ); +} + +function shouldRequestNicknameAfterLogin(authResult) { + const user = authResult && authResult.user ? authResult.user : {}; + const wechatDisplayName = normalizeNicknameInput(user.wechatDisplayName); + if (wechatDisplayName && !isDefaultDisplayName(wechatDisplayName, user.publicUserCode)) { + return false; + } + + return ( + authResult && + (authResult.created || + isDefaultDisplayName(user.displayName, user.publicUserCode) || + (wechatDisplayName && + isDefaultDisplayName(wechatDisplayName, user.publicUserCode))) + ); +} + +function normalizeMiniProgramEnv(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'release') { + return 'release'; + } + if (normalized === 'trial') { + return 'trial'; + } + if ( + normalized === 'develop' || + normalized === 'development' || + normalized === 'dev' + ) { + return 'dev'; + } + return ''; +} + +function readMiniProgramEnvVersion() { + if (typeof wx.getAccountInfoSync !== 'function') { + return ''; + } + try { + const accountInfo = wx.getAccountInfoSync(); + return ( + accountInfo && + accountInfo.miniProgram && + accountInfo.miniProgram.envVersion + ); + } catch (error) { + console.warn('[web-view] read mini program env failed', error); + return ''; + } +} + +function resolveMiniProgramRuntimeConfig() { + const miniProgramEnv = + normalizeMiniProgramEnv(readMiniProgramEnvVersion()) || + normalizeMiniProgramEnv(MINI_PROGRAM_ENV) || + 'release'; + const useReleaseChannel = miniProgramEnv === 'release'; + const webViewEntryUrl = useReleaseChannel + ? WEB_VIEW_ENTRY_URL + : DEV_WEB_VIEW_ENTRY_URL || WEB_VIEW_ENTRY_URL; + const apiBaseUrl = useReleaseChannel + ? API_BASE_URL + : DEV_API_BASE_URL || API_BASE_URL; + const sourceQuery = { + ...WEB_VIEW_SOURCE_QUERY, + }; + if (!useReleaseChannel) { + sourceQuery.miniProgramEnv = miniProgramEnv; + } + + return { + apiBaseUrl, + miniProgramEnv, + sourceQuery, + webViewEntryUrl, + }; +} + +function shouldStartAuthFromQuery(query) { + return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN; +} + +function shouldReturnToPreviousPage(query) { + return String((query && query.returnTo) || '').trim() === 'previous'; +} + +function resolveWebViewUrl(authResult, launchQuery = {}) { + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); + if (!isConfiguredEntryUrl(entryUrl)) { + return ''; + } + + return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, { + ...runtimeConfig, + webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(), + }); +} + +function persistAuthResult(authResult) { + wx.setStorageSync(AUTH_RESULT_STORAGE_KEY, JSON.stringify(authResult)); +} + +function consumeAuthResult() { + const rawValue = wx.getStorageSync(AUTH_RESULT_STORAGE_KEY); + if (!rawValue) { + return null; + } + + wx.removeStorageSync(AUTH_RESULT_STORAGE_KEY); + try { + const parsed = JSON.parse(String(rawValue)); + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const token = String(parsed.token || '').trim(); + if (!token) { + return null; + } + + return { + token, + bindingStatus: String(parsed.bindingStatus || 'pending_bind_phone'), + }; + } catch (error) { + console.error('[web-view] parse auth result failed', error); + return null; + } +} + +function getClientInstanceId() { + const stored = wx.getStorageSync(CLIENT_INSTANCE_STORAGE_KEY); + if (stored) { + return String(stored); + } + + const nextId = `wxmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + wx.setStorageSync(CLIENT_INSTANCE_STORAGE_KEY, nextId); + return nextId; +} + +function resolveClientPlatform() { + const info = wx.getSystemInfoSync(); + const platform = String(info.platform || '').toLowerCase(); + if (platform === 'ios') { + return 'ios'; + } + if (platform === 'android') { + return 'android'; + } + return 'unknown'; +} + +function wxLogin() { + return new Promise((resolve, reject) => { + wx.login({ + success(result) { + if (result.code) { + resolve(result.code); + return; + } + reject(new Error('微信登录未返回 code')); + }, + fail(error) { + reject(new Error(error.errMsg || '微信登录失败')); + }, + }); + }); +} + +function requestMiniProgramLogin(code, displayName) { + return new Promise((resolve, reject) => { + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); + if (!isConfiguredApiBaseUrl(apiBaseUrl)) { + reject(new Error('请先配置 API_BASE_URL')); + return; + } + + wx.request({ + url: `${apiBaseUrl}/api/auth/wechat/miniprogram-login`, + method: 'POST', + data: { + code, + ...(displayName ? { displayName } : {}), + }, + header: { + 'content-type': 'application/json', + 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, + 'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME, + 'x-client-platform': resolveClientPlatform(), + 'x-client-instance-id': getClientInstanceId(), + 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, + 'x-mini-program-env': runtimeConfig.miniProgramEnv, + }, + success(response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(response.data); + return; + } + const message = + response.data && + response.data.error && + response.data.error.message + ? response.data.error.message + : `微信登录失败:${response.statusCode}`; + reject(new Error(message)); + }, + fail(error) { + reject(new Error(error.errMsg || '微信登录请求失败')); + }, + }); + }); +} + +function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) { + return new Promise((resolve, reject) => { + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); + if (!isConfiguredApiBaseUrl(apiBaseUrl)) { + reject(new Error('请先配置 API_BASE_URL')); + return; + } + + wx.request({ + url: `${apiBaseUrl}/api/auth/wechat/bind-phone`, + method: 'POST', + data: { + wechatPhoneCode, + ...(displayName ? { displayName } : {}), + }, + header: { + authorization: `Bearer ${authToken}`, + 'content-type': 'application/json', + 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, + 'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME, + 'x-client-platform': resolveClientPlatform(), + 'x-client-instance-id': getClientInstanceId(), + 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, + 'x-mini-program-env': runtimeConfig.miniProgramEnv, + }, + success(response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(response.data); + return; + } + const message = + response.data && + response.data.error && + response.data.error.message + ? response.data.error.message + : `绑定手机号失败:${response.statusCode}`; + reject(new Error(message)); + }, + fail(error) { + reject(new Error(error.errMsg || '绑定手机号请求失败')); + }, + }); + }); +} + +async function resolveAuthResult(displayName) { + const code = await wxLogin(); + const response = await requestMiniProgramLogin(code, displayName); + if (!response || !response.token) { + throw new Error('服务器未返回登录态'); + } + return { + token: response.token, + bindingStatus: response.bindingStatus || 'pending_bind_phone', + user: response.user || null, + created: response.created === true, + }; +} + +function createWechatWebViewPage() { + return { + data: { + authResult: null, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: true, + nicknameInput: '', + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage: false, + webViewUrl: '', + }, + + async onLoad(query = {}) { + this._lastLaunchQuery = query; + showWebViewShareMenu(); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + // 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。 + if (!isConfiguredEntryUrl(runtimeConfig.webViewEntryUrl)) { + this.setData({ + errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。', + loading: false, + webViewUrl: '', + }); + return; + } + + const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired); + const returnToPreviousPage = shouldReturnToPreviousPage(query); + if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) { + this.setData({ + authResult: null, + bindingPhone: false, + errorMessage: '', + loading: false, + phoneBindingRequired: false, + returnToPreviousPage: false, + webViewUrl: resolveWebViewUrl(null, query), + }); + return; + } + + if (!isConfiguredApiBaseUrl(runtimeConfig.apiBaseUrl)) { + this.setData({ + errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。', + loading: false, + webViewUrl: '', + }); + return; + } + + this.setData({ + authResult: null, + bindingPhone: false, + errorMessage: '', + loggingIn: true, + loading: true, + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + await this.startAuthFlow(returnToPreviousPage, ''); + }, + + handleNicknameInput(event) { + this.setData({ + nicknameInput: event.detail ? event.detail.value : '', + }); + }, + + async handleStartLogin() { + const displayName = normalizeNicknameInput(this.data.nicknameInput); + if (!displayName) { + this.setData({ + errorMessage: '请先选择或填写微信昵称。', + }); + return; + } + + this.setData({ + errorMessage: '', + loggingIn: true, + }); + await this.startAuthFlow(this.data.returnToPreviousPage, displayName); + }, + + async startAuthFlow(returnToPreviousPage, displayName) { + try { + const authResult = await resolveAuthResult(displayName); + if (!displayName && shouldRequestNicknameAfterLogin(authResult)) { + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: true, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + return; + } + + if (authResult.bindingStatus === 'pending_bind_phone') { + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: true, + returnToPreviousPage, + webViewUrl: '', + }); + return; + } + + if (returnToPreviousPage) { + persistAuthResult(authResult); + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + wx.navigateBack(); + return; + } + + this.setData({ + authResult, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), + }); + } catch (error) { + this.setData({ + authResult: null, + errorMessage: + error && error.message ? error.message : '微信登录失败,请稍后重试。', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage, + webViewUrl: '', + }); + } + }, + + onShow() { + const authResult = consumeAuthResult(); + if (authResult) { + this.setData({ + authResult, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), + }); + } + + this.consumePayResult(); + setTimeout(() => { + this.consumePayResult(); + }, PAY_RESULT_RECHECK_DELAY_MS); + }, + + consumePayResult() { + const result = wx.getStorageSync(PAY_RESULT_STORAGE_KEY); + if (result && this.data.webViewUrl) { + wx.removeStorageSync(PAY_RESULT_STORAGE_KEY); + this.setData({ + webViewUrl: appendHashParams(this.data.webViewUrl, { + wx_pay_result: result, + }), + }); + } + }, + + async handleGetPhoneNumber(event) { + if (!this.data.authResult || !this.data.authResult.token) { + this.handleRetryLogin(); + return; + } + + const detail = event.detail || {}; + if (!detail.code) { + this.setData({ + errorMessage: detail.errMsg || '需要授权手机号后才能完成绑定。', + }); + return; + } + + this.setData({ + bindingPhone: true, + errorMessage: '', + }); + try { + const response = await requestMiniProgramBindPhone( + this.data.authResult.token, + detail.code, + normalizeNicknameInput(this.data.nicknameInput), + ); + if (!response || !response.token) { + throw new Error('服务器未返回绑定后的登录态'); + } + const nextAuthResult = { + token: response.token, + bindingStatus: 'active', + }; + if (this.data.returnToPreviousPage) { + persistAuthResult(nextAuthResult); + this.setData({ + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + }); + wx.navigateBack(); + return; + } + this.setData({ + authResult: nextAuthResult, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: false, + nicknameRequired: false, + phoneBindingRequired: false, + webViewUrl: resolveWebViewUrl( + nextAuthResult, + this._lastLaunchQuery || {}, + ), + }); + } catch (error) { + this.setData({ + bindingPhone: false, + errorMessage: + error && error.message + ? error.message + : '绑定手机号失败,请稍后重试。', + }); + } + }, + + handleRetryLogin() { + this.setData({ + authResult: null, + bindingPhone: false, + errorMessage: '', + loggingIn: false, + loading: true, + nicknameInput: '', + nicknameRequired: false, + phoneBindingRequired: false, + returnToPreviousPage: false, + webViewUrl: '', + }); + this.onLoad(this._lastLaunchQuery || { authAction: AUTH_ACTION_LOGIN }); + }, + + handleWebViewLoad(event) { + console.info('[web-view] loaded', event.detail); + }, + + handleWebViewError(event) { + console.error('[web-view] load failed', event.detail); + }, + + handleWebViewMessage(event) { + const shareTarget = resolveShareTargetFromWebViewMessage(event.detail); + if (shareTarget) { + this._currentShareTarget = shareTarget; + } + // 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。 + console.info('[web-view] message', event.detail); + }, + + onShareAppMessage() { + return buildWebViewShareAppMessage(resolveNativeShareQuery(this)); + }, + + onShareTimeline() { + return buildWebViewShareTimeline(resolveNativeShareQuery(this)); + }, + }; +} + +module.exports = { + createWechatWebViewPage, +}; diff --git a/miniprogram/shell/wechatShellWebView.test.js b/miniprogram/shell/wechatShellWebView.test.js new file mode 100644 index 000000000..e193c45f0 --- /dev/null +++ b/miniprogram/shell/wechatShellWebView.test.js @@ -0,0 +1,109 @@ +import path from 'node:path'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; + +const webViewBridgePath = path.resolve( + process.cwd(), + 'miniprogram/host-bridge/wechatHostBridgeWebView.js', +); +const webViewShellPath = path.resolve( + process.cwd(), + 'miniprogram/shell/wechatShellWebView.js', +); +const config = { + API_BASE_URL: 'https://www.genarrative.world', + DEV_API_BASE_URL: 'https://dev.genarrative.world', + DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world', + MINI_PROGRAM_APP_ID: 'wx-test-app', + MINI_PROGRAM_ENV: 'release', + WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world', + WEB_VIEW_SOURCE_QUERY: { + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', + }, +}; + +let createWechatWebViewPage; + +function createPage() { + const page = createWechatWebViewPage(); + return { + ...page, + data: { ...page.data }, + setData(patch) { + Object.assign(this.data, patch); + }, + }; +} + +describe('wechat web-view shell page', () => { + beforeEach(() => { + globalThis.wx = { + getAccountInfoSync: vi.fn(() => ({ + miniProgram: { envVersion: 'release' }, + })), + getStorageSync: vi.fn(() => ''), + getSystemInfoSync: vi.fn(() => ({ platform: 'ios' })), + login: vi.fn(), + navigateBack: vi.fn(), + removeStorageSync: vi.fn(), + request: vi.fn(), + setStorageSync: vi.fn(), + showShareMenu: vi.fn(), + }; + const webViewBridge = loadCommonJsModule(webViewBridgePath); + const webViewShell = loadCommonJsModule(webViewShellPath, { + '../config': config, + '../host-bridge/wechatHostBridgeWebView': webViewBridge, + }); + createWechatWebViewPage = webViewShell.createWechatWebViewPage; + }); + + test('opens anonymous H5 web-view without eager login', async () => { + const page = createPage(); + + await page.onLoad({}); + + expect(globalThis.wx.login).not.toHaveBeenCalled(); + expect(globalThis.wx.request).not.toHaveBeenCalled(); + expect(page.data.loading).toBe(false); + expect(page.data.webViewUrl).toBe( + 'https://www.genarrative.world?clientType=mini_program&clientRuntime=wechat_mini_program', + ); + expect(globalThis.wx.showShareMenu).toHaveBeenCalledWith({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'], + }); + }); + + test('stores latest H5 share target for native share menu', () => { + const page = createPage(); + page.handleWebViewMessage({ + detail: { + data: [ + { + data: { + type: 'genarrative:share-target', + payload: { + targetPath: '/works/detail', + work: 'BB-12345678', + title: '汪汪声浪', + }, + }, + }, + ], + }, + }); + + expect(page.onShareAppMessage()).toEqual({ + title: '陶泥儿', + path: '/pages/web-view/index?targetPath=%2Fworks%2Fdetail&work=BB-12345678', + }); + expect(page.onShareTimeline()).toEqual({ + title: '陶泥儿', + query: 'targetPath=%2Fworks%2Fdetail&work=BB-12345678', + }); + }); +}); diff --git a/miniprogram/test-utils/loadCommonJsModule.js b/miniprogram/test-utils/loadCommonJsModule.js new file mode 100644 index 000000000..baec1cf34 --- /dev/null +++ b/miniprogram/test-utils/loadCommonJsModule.js @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +export function loadCommonJsModule(filePath, requireMap = {}) { + const module = { exports: {} }; + const sandbox = { + clearTimeout, + console, + getCurrentPages(...args) { + return globalThis.getCurrentPages(...args); + }, + module, + exports: module.exports, + require(requestPath) { + if (Object.prototype.hasOwnProperty.call(requireMap, requestPath)) { + return requireMap[requestPath]; + } + throw new Error(`Unexpected require: ${requestPath}`); + }, + setTimeout, + URL, + URLSearchParams, + wx: globalThis.wx, + }; + + vm.runInNewContext(readFileSync(filePath, 'utf8'), sandbox, { + filename: filePath, + }); + + return module.exports; +} diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 01657effc..2109e7733 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -17,6 +17,16 @@ const expectedWechatHostBridgeFiles = [ 'wechatHostBridgeWebView.js', 'wechatHostBridgeWebView.test.js', ]; +const expectedWechatShellFiles = [ + 'wechatShellPayment.js', + 'wechatShellPayment.test.js', + 'wechatShellShareGrid.js', + 'wechatShellShareGrid.test.js', + 'wechatShellSubscribeMessage.js', + 'wechatShellSubscribeMessage.test.js', + 'wechatShellWebView.js', + 'wechatShellWebView.test.js', +]; const expectedMobileHostBridgeFiles = [ 'mobileHostBridge.test.ts', 'mobileHostBridge.ts', @@ -210,15 +220,33 @@ function assertHostBridgeLayerLayout() { 'wechat host bridge files', ); - for (const pagePath of [ + const wechatShellFiles = fs + .readdirSync('miniprogram/shell', { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(); + assertSameList( + wechatShellFiles, + expectedWechatShellFiles, + 'wechat shell files', + ); + + const wechatPagePaths = [ 'miniprogram/pages/web-view/index.js', 'miniprogram/pages/wechat-pay/index.js', 'miniprogram/pages/share-grid/index.js', 'miniprogram/pages/subscribe-message/index.js', - ]) { + ]; + for (const pagePath of wechatPagePaths) { const source = fs.readFileSync(pagePath, 'utf8'); - if (source.includes("require('./index.shared')")) { - throw new Error(`${pagePath} must import from miniprogram/host-bridge`); + if (!source.includes("require('../../shell/")) { + throw new Error(`${pagePath} must import from miniprogram/shell`); + } + if ( + source.includes("require('../../host-bridge/") || + source.includes("require('./index.shared')") + ) { + throw new Error(`${pagePath} must not import bridge logic directly`); } } diff --git a/scripts/check-wechat-miniprogram-auth-smoke.mjs b/scripts/check-wechat-miniprogram-auth-smoke.mjs index 373e0327a..7f7951a7a 100644 --- a/scripts/check-wechat-miniprogram-auth-smoke.mjs +++ b/scripts/check-wechat-miniprogram-auth-smoke.mjs @@ -54,22 +54,27 @@ if (failures.length > 0) { console.log('\n[wechat-miniprogram-auth-smoke] 通过'); function checkMiniProgramShell() { - const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js'); + const shellPath = join(repoRoot, 'miniprogram', 'shell', 'wechatShellWebView.js'); + const webViewBridgePath = join(repoRoot, 'miniprogram', 'host-bridge', 'wechatHostBridgeWebView.js'); + const pagePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js'); const shellTemplatePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.wxml'); const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts'); + ensureNeedles(pagePath, ["require('../../shell/wechatShellWebView')"]); ensureNeedles(shellPath, [ '/api/auth/wechat/miniprogram-login', '/api/auth/wechat/bind-phone', "'x-client-type': MINI_PROGRAM_CLIENT_TYPE", "'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME", - 'auth_provider', - 'auth_token', - 'auth_binding_status', 'bindingStatus', 'pending_bind_phone', 'wechatPhoneCode', ]); + ensureNeedles(webViewBridgePath, [ + 'auth_provider', + 'auth_token', + 'auth_binding_status', + ]); ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']); diff --git a/scripts/miniprogram-web-view-auth.test.ts b/scripts/miniprogram-web-view-auth.test.ts index ccf24a855..8d56a94c4 100644 --- a/scripts/miniprogram-web-view-auth.test.ts +++ b/scripts/miniprogram-web-view-auth.test.ts @@ -5,12 +5,17 @@ import vm from 'node:vm'; import { beforeEach, describe, expect, test, vi } from 'vitest'; const repoRoot = process.cwd(); -const pageScriptPath = join( +const shellScriptPath = join( repoRoot, 'miniprogram', - 'pages', - 'web-view', - 'index.js', + 'shell', + 'wechatShellWebView.js', +); +const webViewHostBridgePath = join( + repoRoot, + 'miniprogram', + 'host-bridge', + 'wechatHostBridgeWebView.js', ); type MiniProgramPage = { @@ -43,45 +48,28 @@ function loadWebViewPage( wxMock: ReturnType, configOverrides: Record = {}, ) { - let pageConfig: Record | null = null; - const source = readFileSync(pageScriptPath, 'utf8'); - const sandbox = { - console, - getCurrentPages: () => [], - module: { exports: {} }, - Page(config: Record) { - pageConfig = config; + (globalThis as unknown as { wx: ReturnType }).wx = + wxMock; + const webViewBridge = loadCommonJsModule(webViewHostBridgePath, {}); + const shellModule = loadCommonJsModule(shellScriptPath, { + '../config': { + API_BASE_URL: 'https://www.genarrative.world/', + DEV_API_BASE_URL: 'https://dev.genarrative.world/', + DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/', + MINI_PROGRAM_APP_ID: 'wx-test-app', + MINI_PROGRAM_ENV: 'release', + WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/', + WEB_VIEW_SOURCE_QUERY: { + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', + }, + ...configOverrides, }, - setTimeout(callback: () => void) { - callback(); - return 1; - }, - require(requestPath: string) { - if (requestPath === '../../config') { - return { - API_BASE_URL: 'https://www.genarrative.world/', - DEV_API_BASE_URL: 'https://dev.genarrative.world/', - DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/', - MINI_PROGRAM_APP_ID: 'wx-test-app', - MINI_PROGRAM_ENV: 'release', - WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/', - WEB_VIEW_SOURCE_QUERY: { - clientType: 'mini_program', - clientRuntime: 'wechat_mini_program', - }, - ...configOverrides, - }; - } - throw new Error(`Unexpected require: ${requestPath}`); - }, - wx: wxMock, + '../host-bridge/wechatHostBridgeWebView': webViewBridge, + }) as { + createWechatWebViewPage: () => Record; }; - - vm.runInNewContext(source, sandbox, { filename: pageScriptPath }); - - if (!pageConfig) { - throw new Error('web-view page did not call Page()'); - } + const pageConfig = shellModule.createWechatWebViewPage(); const page = { ...pageConfig, @@ -94,6 +82,34 @@ function loadWebViewPage( return page; } +function loadCommonJsModule( + filePath: string, + requireMap: Record, +) { + const source = readFileSync(filePath, 'utf8'); + const module = { exports: {} as Record }; + const sandbox = { + console, + getCurrentPages: () => [], + module, + exports: module.exports, + setTimeout(callback: () => void) { + callback(); + return 1; + }, + require(requestPath: string) { + if (Object.prototype.hasOwnProperty.call(requireMap, requestPath)) { + return requireMap[requestPath]; + } + throw new Error(`Unexpected require: ${requestPath}`); + }, + wx: globalThis.wx, + }; + + vm.runInNewContext(source, sandbox, { filename: filePath }); + return module.exports; +} + describe('mini-program web-view auth page', () => { beforeEach(() => { vi.clearAllMocks(); @@ -264,6 +280,10 @@ describe('mini-program web-view auth page', () => { data: { token: 'jwt-pending-wechat', bindingStatus: 'pending_bind_phone', + user: { + displayName: '陶泥玩家', + publicUserCode: 'SY-12345678', + }, }, }); }); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index ec90016af..4d1252770 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -744,6 +744,7 @@ mod tests { started_at: Some("2026-06-03T00:00:00Z".to_string()), completed_at: None, updated_at: "2026-06-03T00:00:00Z".to_string(), + updated_at_micros: 1_780_444_800_000_000, lease_token: lease_token.map(ToOwned::to_owned), } }