diff --git a/docs/README.md b/docs/README.md index fc92fc35..7ee654e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ 微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。 +微信小程序壳、未来原生 App 壳、固定内置玩法与 AI H5 沙箱之间的宿主能力边界见 [【前端架构】宿主壳能力统一协议-2026-06-17.md](./【前端架构】宿主壳能力统一协议-2026-06-17.md)。 + 本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。 生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1424f58e..873ed5ee 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-17 H5 宿主壳能力统一走 HostBridge + +- 背景:主站同时运行在普通浏览器、微信小程序 `web-view` 和未来可能出现的原生 App WebView 中;登录、支付、分享、订阅授权和运行态分享目标同步曾散落在业务组件与服务文件里,后续新增宿主壳会导致同一业务重复分叉。 +- 决策:前端宿主运行态识别、微信小程序 JS SDK 加载、原生页跳转、支付跳转、登录跳转、九宫切图和 `postMessage` 统一收口到 `src/services/host-bridge/hostBridge.ts`,业务层优先调用 `getHostRuntime`、`requestHostLogin`、`requestHostPayment`、`navigateHostNativePage`、`setHostShareTarget` 和 `openHostShareGrid`。`authService`、分享服务、订阅授权和个人中心充值可保留兼容导出或业务编排,但不再自行加载微信 JS SDK 或直接判断 `wx.miniProgram`。固定内置玩法不走代码包下载流程;AI 生成 H5 沙箱后续单独定义受限 `GameBridge`,不得直接暴露完整 `HostBridge`。 +- 影响范围:`src/services/host-bridge/`、`src/services/authService.ts`、`src/services/payment/paymentPlatform.ts`、`src/services/wechatMiniProgramShareGrid.ts`、`src/services/wechatMiniProgramShareTarget.ts`、`src/services/wechatMiniProgramSubscribe.ts`、`src/components/platform-entry/usePlatformProfileCenterController.ts`、微信小程序壳和未来原生 App 壳接入。 +- 验证方式:微信小程序首点登录仍打开原生登录页;小程序支付仍跳转 `/pages/wechat-pay/index` 并保留 hash 回灌确认;订阅授权仍跳转 `/pages/subscribe-message/index` 且返回不阻断生成;普通浏览器分享、H5 支付和 Native 二维码支付不受影响。前端验证运行 HostBridge、auth、payment、分享、订阅和个人中心充值相关定向测试,并执行 `npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 + ## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust - 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md new file mode 100644 index 00000000..10c3927f --- /dev/null +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -0,0 +1,68 @@ +# 宿主壳能力统一协议 + +更新时间:`2026-06-17` + +## 背景 + +当前主站已经同时运行在普通浏览器、微信小程序 `web-view` 和后续可能出现的原生 App WebView 中。登录、支付、分享、订阅授权、运行态分享目标同步等能力散落在业务组件和服务文件里,后续如果新增原生 App 壳,容易出现同一业务按宿主重复分叉。 + +本方案先建立 `HostBridge` 宿主壳协议,把浏览器、微信小程序壳和未来原生 App 壳统一成能力 adapter。固定玩法运行态仍作为平台内置 runtime,AI 生成 H5 仍走独立 sandbox 和受限 GameBridge;二者都不能直接拿完整宿主壳能力。 + +## 目标 + +1. H5 业务层只判断宿主能力,不直接散落判断 `wx.miniProgram`、`MicroMessenger`、`clientRuntime`。 +2. 微信小程序壳先作为 `wechat_mini_program` adapter 接入,保留现有登录、支付、分享、订阅授权行为。 +3. 未来原生 App 壳只新增 `native_app` adapter,不重写 H5 业务。 +4. 固定玩法继续读取作品数据、素材、运行态 snapshot 和后端裁决结果,不走代码包下载流程。 +5. AI H5 sandbox 只能通过受限 GameBridge 请求资产、上报事件和提交候选结果,不暴露登录、支付、token、完整用户资料。 + +## 非目标 + +- 不重写 React 主站和现有玩法 runtime。 +- 不把固定玩法迁成远程代码包。 +- 不在本阶段实现 React Native / Expo 壳。 +- 不改变支付到账、任务、排行榜、发布、统计等后端裁决口径。 + +## 分层 + +```text +H5 业务层 + -> HostBridge 能力接口 + -> browserHostBridge + -> wechatMiniProgramHostBridge + -> nativeAppHostBridge(预留) + +AI H5 sandbox + -> GameBridge 受限协议 + -> parent HostBridge adapter +``` + +## 首批能力 + +- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`。 +- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。 +- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。 +- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。 +- `openHostShareGrid()`:微信小程序九宫格切图页。 +- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。 + +## 迁移顺序 + +1. 新增 `src/services/host-bridge/`,沉淀宿主运行态识别和微信小程序 JS SDK 加载,并暴露通用 HostBridge 能力接口。 +2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。 +3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。 +4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉。 + +## 验收 + +- 微信小程序首点登录仍能打开原生登录页。 +- 小程序分享链接仍生成 `/pages/web-view/index?targetPath=/works/detail&work=...`。 +- 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。 +- 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。 +- 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。 + +## 后续 + +- 设计 `native_app` 的 `postMessage` 消息格式和回包超时策略。 +- 为 AI H5 sandbox 单独定义 GameBridge,禁止直接依赖 HostBridge。 +- 将宿主能力、支付渠道和分享策略补充进移动端发布检查清单。 diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 74201447..70a275c1 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -26,8 +26,6 @@ const authMocks = vi.hoisted(() => ({ getAuthAuditLogs: vi.fn(), getAuthRiskBlocks: vi.fn(), getAuthSessions: vi.fn(), - isWechatMiniProgramWebViewRuntime: vi.fn(() => false), - requestWechatMiniProgramPhoneLogin: vi.fn(), revokeAuthSessions: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), @@ -62,14 +60,10 @@ vi.mock('../../services/authService', () => ({ getCurrentAuthUser: authMocks.getCurrentAuthUser, getAuthSessions: authMocks.getAuthSessions, getCaptchaChallengeFromError: vi.fn(() => null), - isWechatMiniProgramWebViewRuntime: - authMocks.isWechatMiniProgramWebViewRuntime, liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: authMocks.logoutAllAuthSessions, logoutAuthUser: authMocks.logoutAuthUser, - requestWechatMiniProgramPhoneLogin: - authMocks.requestWechatMiniProgramPhoneLogin, redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode, resetPassword: authMocks.resetPassword, revokeAuthSessions: authMocks.revokeAuthSessions, @@ -78,6 +72,21 @@ vi.mock('../../services/authService', () => ({ startWechatLogin: authMocks.startWechatLogin, })); +const hostBridgeMocks = vi.hoisted(() => ({ + getHostRuntime: vi.fn(() => ({ + kind: 'browser', + clientType: null as string | null, + clientRuntime: null as string | null, + miniProgramEnv: null as string | null, + })), + requestHostLogin: vi.fn(), +})); + +vi.mock('../../services/host-bridge/hostBridge', () => ({ + getHostRuntime: hostBridgeMocks.getHostRuntime, + requestHostLogin: hostBridgeMocks.requestHostLogin, +})); + vi.mock('../../hooks/useGameSettings', () => ({ useGameSettings: () => ({ musicVolume: 0.42, @@ -165,8 +174,13 @@ beforeEach(() => { expiresInSeconds: 300, }); authMocks.startWechatLogin.mockResolvedValue(undefined); - authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false); - authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true); + hostBridgeMocks.getHostRuntime.mockReturnValue({ + kind: 'browser', + clientType: null, + clientRuntime: null, + miniProgramEnv: null, + }); + hostBridgeMocks.requestHostLogin.mockResolvedValue(true); }); afterEach(() => { @@ -445,7 +459,12 @@ test('auth gate opens a login modal for protected actions and resumes after logi test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => { const user = userEvent.setup(); - authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true); + hostBridgeMocks.getHostRuntime.mockReturnValue({ + kind: 'wechat_mini_program', + clientType: null, + clientRuntime: 'wechat_mini_program', + miniProgramEnv: null, + }); authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone', 'wechat'], }); @@ -459,13 +478,11 @@ test('auth gate uses mini program auth bridge instead of opening login modal in await user.click(await screen.findByRole('button', { name: '进入作品' })); await waitFor(() => { - expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes( - 1, - ); + expect(hostBridgeMocks.requestHostLogin).toHaveBeenCalledTimes(1); }); expect(authMocks.startWechatLogin).not.toHaveBeenCalled(); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); - expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled(); + expect(hostBridgeMocks.getHostRuntime).toHaveBeenCalled(); }); test('login modal requires first-time legal consent before sms login', async () => { diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 87161bac..5ceb39fa 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -1,3 +1,5 @@ +/* eslint-disable react-refresh/only-export-components */ + import { type ReactNode, useCallback, @@ -32,19 +34,21 @@ import { getAuthSessions, getCaptchaChallengeFromError, getCurrentAuthUser, - isWechatMiniProgramWebViewRuntime, liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, logoutAuthUser, redeemRegistrationInviteCode, - requestWechatMiniProgramPhoneLogin, resetPassword, revokeAuthSessions, sendPhoneLoginCode, setStoredLastLoginPhone, startWechatLogin, } from '../../services/authService'; +import { + getHostRuntime, + requestHostLogin, +} from '../../services/host-bridge/hostBridge'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { AccountModal } from './AccountModal'; import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext'; @@ -328,7 +332,7 @@ export function AuthGate({ children }: AuthGateProps) { const requestMiniProgramLogin = useCallback(() => { setWechatLoading(true); setError(''); - void requestWechatMiniProgramPhoneLogin() + void requestHostLogin() .catch((miniProgramError) => { setError( miniProgramError instanceof Error @@ -349,7 +353,7 @@ export function AuthGate({ children }: AuthGateProps) { } pendingProtectedActionRef.current = postLoginAction ?? null; - if (isWechatMiniProgramWebViewRuntime()) { + if (getHostRuntime().kind === 'wechat_mini_program') { setShowLoginModal(false); requestMiniProgramLogin(); return; diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index d763296c..669704f3 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -7,7 +7,7 @@ import type { AuthLoginMethod, } from '../../services/authService'; import { getStoredLastLoginPhone } from '../../services/authService'; -import { isWechatMiniProgramWebViewRuntime } from '../../services/authService'; +import { getHostRuntime } from '../../services/host-bridge/hostBridge'; import { LegalDocumentModal } from '../common/LegalDocumentModal'; import { getLegalDocument, @@ -96,7 +96,7 @@ export function LoginScreen({ const passwordLoginEnabled = true; const phoneLoginEnabled = true; const wechatLoginEnabled = availableLoginMethods.includes('wechat'); - const miniProgramRuntime = isWechatMiniProgramWebViewRuntime(); + const miniProgramRuntime = getHostRuntime().kind === 'wechat_mini_program'; const [activeLoginTab, setActiveLoginTab] = useState('phone'); useEffect(() => { diff --git a/src/components/common/PublishShareModal.test.tsx b/src/components/common/PublishShareModal.test.tsx index 51c5c7d4..5253e682 100644 --- a/src/components/common/PublishShareModal.test.tsx +++ b/src/components/common/PublishShareModal.test.tsx @@ -10,7 +10,6 @@ import { import { afterEach, describe, expect, test, vi } from 'vitest'; import * as clipboardService from '../../services/clipboard'; -import * as shareGridService from '../../services/wechatMiniProgramShareGrid'; import { PublishShareModal } from './PublishShareModal'; import { buildMiniProgramPublishSharePath, @@ -24,10 +23,6 @@ import { vi.mock('../../services/clipboard', () => ({ copyTextToClipboard: vi.fn(), })); -vi.mock('../../services/wechatMiniProgramShareGrid', () => ({ - canUseWechatMiniProgramShareGrid: vi.fn(() => false), - openWechatMiniProgramShareGridPage: vi.fn(), -})); const payload: PublishShareModalPayload = { title: '暖灯猫街', @@ -39,10 +34,8 @@ const payload: PublishShareModalPayload = { afterEach(() => { vi.clearAllMocks(); - vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue( - false, - ); window.history.replaceState(null, '', '/'); + window.wx = undefined; }); describe('PublishShareModal', () => { @@ -141,8 +134,10 @@ describe('PublishShareModal', () => { }); test('shows the mini program grid action only inside mini program runtime', () => { - vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue( - true, + window.history.replaceState( + null, + '', + '/?clientRuntime=wechat_mini_program', ); render( diff --git a/src/components/common/PublishShareModal.tsx b/src/components/common/PublishShareModal.tsx index e9bda83d..e5e21026 100644 --- a/src/components/common/PublishShareModal.tsx +++ b/src/components/common/PublishShareModal.tsx @@ -2,20 +2,20 @@ import { Check, Copy, Download, Grid3X3, Link2 } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { resolveAssetReadUrl } from '../../services/assetReadUrlService'; -import { isWechatMiniProgramWebViewRuntime } from '../../services/authService'; import { copyTextToClipboard } from '../../services/clipboard'; import { - canUseWechatMiniProgramShareGrid, - openWechatMiniProgramShareGridPage, -} from '../../services/wechatMiniProgramShareGrid'; + canUseHostShareGrid, + getHostRuntime, + openHostShareGrid, +} from '../../services/host-bridge/hostBridge'; import { useAuthUi } from '../auth/AuthUiContext'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; +import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal'; import { downloadPublishShareCardImage } from './publishShareCardImage'; import { buildPublishShareCopyUrl, type PublishShareModalPayload, } from './publishShareModalModel'; -import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal'; type PublishShareModalProps = { open: boolean; @@ -59,7 +59,8 @@ export function PublishShareModal({ () => payload ? buildPublishShareCopyUrl(payload, { - miniProgramRuntime: isWechatMiniProgramWebViewRuntime(), + miniProgramRuntime: + getHostRuntime().kind === 'wechat_mini_program', }) : '', [payload], @@ -68,7 +69,7 @@ export function PublishShareModal({ const coverImageSrc = resolvePayloadCoverImageSrc(payload); const workTypeLabel = resolvePayloadWorkTypeLabel(payload); const showMiniProgramGridButton = - canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc); + canUseHostShareGrid() && Boolean(coverImageSrc); useEffect( () => () => { @@ -151,7 +152,7 @@ export function PublishShareModal({ setGridState('idle'); void resolveMiniProgramGridCover() .then((resolvedCoverImageSrc) => - openWechatMiniProgramShareGridPage({ + openHostShareGrid({ imageUrl: resolvedCoverImageSrc, title, publicWorkCode: payload.publicWorkCode, diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 7b169384..546ef2ba 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -1,7 +1,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { AuthUser } from '../../services/authService'; +import { + type ConfirmWechatProfileRechargeOrderResponse, + type ProfileRechargeCenterResponse, + type ProfileRechargeProduct, + type ProfileReferralInviteCenterResponse, + type ProfileTaskCenterResponse, + type ProfileWalletLedgerResponse, + type RedeemProfileRewardCodeResponse, + type WechatNativePayment, +} from '../../../packages/shared/src/contracts/runtime'; import { refreshStoredAccessToken } from '../../services/apiClient'; +import type { AuthUser } from '../../services/authService'; +import { requestHostPayment } from '../../services/host-bridge/hostBridge'; import { resolveProfileRechargeProductPaymentChannel, WECHAT_H5_PAYMENT_CHANNEL, @@ -21,18 +32,6 @@ import { redeemRpgProfileRewardCode, watchWechatRpgProfileRechargeOrder, } from '../../services/rpg-entry/rpgProfileClient'; -import { - type ConfirmWechatProfileRechargeOrderResponse, - type ProfileRechargeCenterResponse, - type ProfileRechargeProduct, - type ProfileReferralInviteCenterResponse, - type ProfileTaskCenterResponse, - type ProfileWalletLedgerResponse, - type RedeemProfileRewardCodeResponse, - type WechatMiniProgramPayParams, - type WechatMiniProgramVirtualPayParams, - type WechatNativePayment, -} from '../../../packages/shared/src/contracts/runtime'; import { type CopyFeedbackState, useCopyFeedback, @@ -42,7 +41,6 @@ const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000; const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000; const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; -const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250; const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000; @@ -167,83 +165,6 @@ function readWechatPayResultFromHash(): WechatPayResult | null { }; } -function loadWechatJsSdk() { - if (typeof window === 'undefined') { - return Promise.reject(new Error('请在微信小程序内完成支付')); - } - if (window.wx?.miniProgram?.navigateTo) { - return Promise.resolve(window.wx); - } - - return new Promise>((resolve, reject) => { - const existingScript = document.querySelector( - `script[src="${WECHAT_JS_SDK_URL}"]`, - ); - const complete = () => { - if (window.wx?.miniProgram?.navigateTo) { - resolve(window.wx); - } else { - reject(new Error('请在微信小程序内完成支付')); - } - }; - - if (existingScript) { - existingScript.addEventListener('load', complete, { once: true }); - existingScript.addEventListener( - 'error', - () => reject(new Error('请在微信小程序内完成支付')), - { once: true }, - ); - complete(); - return; - } - - const script = document.createElement('script'); - script.src = WECHAT_JS_SDK_URL; - script.async = true; - script.onload = complete; - script.onerror = () => reject(new Error('请在微信小程序内完成支付')); - document.head.appendChild(script); - }); -} - -async function requestWechatMiniProgramPayment( - payload: - | WechatMiniProgramPayParams - | WechatMiniProgramVirtualPayParams - | null - | undefined, - orderId: string, -): Promise { - if (!payload) { - return Promise.reject(new Error('请在微信小程序内完成支付')); - } - const wxBridge = await loadWechatJsSdk(); - const miniProgram = wxBridge.miniProgram; - if (!miniProgram || typeof miniProgram.navigateTo !== 'function') { - return Promise.reject(new Error('请在微信小程序内完成支付')); - } - const navigateTo = miniProgram.navigateTo; - - const requestId = `wechat_pay_${orderId}_${Date.now()}`; - return new Promise((resolve, reject) => { - navigateTo({ - url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`, - success() { - resolve(); - }, - fail(error) { - console.error('[wechat-pay] navigateTo failed', error); - reject( - error instanceof Error - ? error - : new Error('请在微信小程序内完成支付'), - ); - }, - }); - }); -} - function waitWechatPayConfirmDelay(delayMs: number) { return new Promise((resolve) => { window.setTimeout(resolve, delayMs); @@ -601,10 +522,13 @@ export function usePlatformProfileCenterController({ if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { pendingWechatRechargeOrderIdRef.current = response.order.orderId; setRechargeCenter(response.center); - await requestWechatMiniProgramPayment( - response.wechatMiniProgramPayParams, - response.order.orderId, - ); + const paymentHandled = await requestHostPayment({ + payload: response.wechatMiniProgramPayParams, + orderId: response.order.orderId, + }); + if (!paymentHandled) { + throw new Error('请在微信小程序内完成支付'); + } return; } if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) { diff --git a/src/main.tsx b/src/main.tsx index cca97716..ba59c431 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,7 +10,7 @@ import {lockMobileViewportZoom} from './mobileViewportZoomLock'; import {resolveAppRoute} from './routing/appRoutes'; import {RouteImageReadyGate} from './routing/RouteImageReadyGate'; import {RouteLoadingScreen} from './routing/RouteLoadingScreen'; -import {isWechatMiniProgramWebViewRuntime} from './services/authService'; +import {getHostRuntime} from './services/host-bridge/hostBridge'; type AppRoot = ReturnType; @@ -28,7 +28,7 @@ if (!rootElement) { } function markWechatMiniProgramRuntime() { - if (isWechatMiniProgramWebViewRuntime()) { + if (getHostRuntime().kind === 'wechat_mini_program') { document.documentElement.dataset.wechatMiniProgramRuntime = 'true'; } } diff --git a/src/services/authService.ts b/src/services/authService.ts index a8c2b7ba..22f7dee0 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -39,6 +39,10 @@ import { export type { AuthUser } from '../../packages/shared/src/contracts/auth'; export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth'; +export { + isWechatMiniProgramWebViewRuntime, + requestHostLogin as requestWechatMiniProgramPhoneLogin, +} from './host-bridge/hostBridge'; export type AuthSessionSnapshot = { user: import('../../packages/shared/src/contracts/auth').AuthUser | null; @@ -55,10 +59,6 @@ export type ConsumedAuthCallback = { error: string | null; }; -const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; -const MINI_PROGRAM_AUTH_PAGE_URL = - '/pages/web-view/index?authAction=login&returnTo=previous'; - // 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测, // 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。 const PUBLIC_AUTH_REQUEST_OPTIONS = { @@ -84,98 +84,6 @@ export function clearRuntimeGuestTokenCache() { runtimeGuestTokenCache.value = null; } -export function isWechatMiniProgramWebViewRuntime() { - if (typeof window === 'undefined') { - return false; - } - - const params = new URLSearchParams(window.location.search || ''); - const userAgent = - typeof navigator === 'undefined' ? '' : navigator.userAgent || ''; - const normalizedUserAgent = userAgent.toLowerCase(); - - return ( - params.get('clientRuntime') === 'wechat_mini_program' || - params.get('clientType') === 'mini_program' || - (normalizedUserAgent.includes('micromessenger') && - normalizedUserAgent.includes('miniprogram')) || - Boolean(window.wx?.miniProgram?.postMessage) - ); -} - -function loadWechatMiniProgramBridge() { - if (typeof window === 'undefined') { - return Promise.reject(new Error('请在微信小程序内完成登录')); - } - - if (window.wx?.miniProgram?.navigateTo) { - return Promise.resolve(window.wx); - } - - return new Promise>((resolve, reject) => { - const existingScript = document.querySelector( - `script[src="${WECHAT_JS_SDK_URL}"]`, - ); - const complete = () => { - if (window.wx?.miniProgram?.navigateTo) { - resolve(window.wx); - } else { - reject(new Error('请在微信小程序内完成登录')); - } - }; - - if (existingScript) { - if (window.wx?.miniProgram?.navigateTo) { - complete(); - return; - } - - existingScript.addEventListener('load', complete, { once: true }); - existingScript.addEventListener( - 'error', - () => reject(new Error('请在微信小程序内完成登录')), - { once: true }, - ); - return; - } - - const script = document.createElement('script'); - script.src = WECHAT_JS_SDK_URL; - script.async = true; - script.onload = complete; - script.onerror = () => reject(new Error('请在微信小程序内完成登录')); - document.head.appendChild(script); - }); -} - -export async function requestWechatMiniProgramPhoneLogin() { - if (!isWechatMiniProgramWebViewRuntime()) { - return false; - } - - const wxBridge = await loadWechatMiniProgramBridge(); - const miniProgram = wxBridge.miniProgram; - const navigateTo = miniProgram?.navigateTo; - if (typeof navigateTo !== 'function') { - return false; - } - - await new Promise((resolve, reject) => { - navigateTo({ - url: MINI_PROGRAM_AUTH_PAGE_URL, - success() { - resolve(); - }, - fail(error) { - reject( - new Error(error?.errMsg || '请在微信小程序内完成登录'), - ); - }, - }); - }); - return true; -} - export async function ensureRuntimeGuestToken() { if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) { return runtimeGuestTokenCache.value!; diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts new file mode 100644 index 00000000..45b7077b --- /dev/null +++ b/src/services/host-bridge/hostBridge.test.ts @@ -0,0 +1,230 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + canUseHostShareGrid, + getHostRuntime, + isWechatMiniProgramWebViewRuntime, + navigateHostNativePage, + openHostShareGrid, + openWechatMiniProgramShareGridPage, + postWechatMiniProgramMessage, + requestHostLogin, + requestHostPayment, + requestWechatMiniProgramPayment, + requestWechatMiniProgramPhoneLogin, + resolveHostRuntime, + setHostShareTarget, +} from './hostBridge'; + +afterEach(() => { + vi.restoreAllMocks(); + window.history.replaceState(null, '', '/'); + window.wx = undefined; +}); + +describe('hostBridge', () => { + test('识别微信小程序、原生 App 和普通浏览器宿主', () => { + expect( + getHostRuntime({ + location: { search: '?clientRuntime=wechat_mini_program' }, + }).kind, + ).toBe('wechat_mini_program'); + + expect( + resolveHostRuntime({ + navigator: { + userAgent: + 'Mozilla/5.0 iPhone MicroMessenger/8.0 miniProgram', + }, + }).kind, + ).toBe('wechat_mini_program'); + + expect( + resolveHostRuntime({ + location: { search: '?clientRuntime=native_app' }, + }).kind, + ).toBe('native_app'); + + expect(resolveHostRuntime({ location: { search: '' } }).kind).toBe( + 'browser', + ); + }); + + test('通过微信小程序原生页请求登录', async () => { + const navigateTo = vi.fn((options) => { + options.success?.(); + }); + window.history.replaceState( + null, + '', + '/?clientRuntime=wechat_mini_program', + ); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + + const requested = await requestHostLogin(); + + expect(requested).toBe(true); + expect(navigateTo).toHaveBeenCalledWith({ + url: '/pages/web-view/index?authAction=login&returnTo=previous', + success: expect.any(Function), + fail: expect.any(Function), + }); + + await expect(requestWechatMiniProgramPhoneLogin()).resolves.toBe(true); + }); + + test('通过微信小程序原生页请求支付', async () => { + const navigateTo = vi.fn((options) => { + options.success?.(); + }); + vi.spyOn(Date, 'now').mockReturnValue(123456); + window.history.replaceState( + null, + '', + '/?clientRuntime=wechat_mini_program', + ); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + + const handled = await requestHostPayment({ + payload: { + timeStamp: '1', + nonceStr: 'nonce', + package: 'prepay_id=1', + signType: 'RSA', + paySign: 'sign', + }, + orderId: 'order-1', + }); + + expect(handled).toBe(true); + const url = navigateTo.mock.calls[0]?.[0].url; + expect(url).toContain('/pages/wechat-pay/index?'); + const parsed = new URL(url, 'https://mini.test'); + expect(parsed.searchParams.get('requestId')).toBe( + 'wechat_pay_order-1_123456', + ); + expect(parsed.searchParams.get('orderId')).toBe('order-1'); + expect(parsed.searchParams.get('payParams')).toContain('prepay_id=1'); + + await expect( + requestWechatMiniProgramPayment( + { + timeStamp: '1', + nonceStr: 'nonce', + package: 'prepay_id=1', + signType: 'RSA', + paySign: 'sign', + }, + 'order-1', + ), + ).resolves.toBeUndefined(); + }); + + test('普通浏览器不处理宿主登录、支付和原生页跳转', async () => { + await expect(requestHostLogin()).resolves.toBe(false); + await expect( + requestHostPayment({ + payload: { + timeStamp: '1', + nonceStr: 'nonce', + package: 'prepay_id=1', + signType: 'RSA', + paySign: 'sign', + }, + orderId: 'order-1', + }), + ).resolves.toBe(false); + await expect(navigateHostNativePage('/pages/test/index')).resolves.toBe( + false, + ); + await expect( + requestHostPayment({ + payload: null, + orderId: 'order-1', + }), + ).resolves.toBe(false); + }); + + test('打开微信小程序九宫切图页并补齐绝对图片地址', async () => { + const navigateTo = vi.fn((options) => { + options.success?.(); + }); + window.history.replaceState( + null, + '', + '/?clientRuntime=wechat_mini_program', + ); + window.wx = { + miniProgram: { + navigateTo, + }, + }; + + expect(canUseHostShareGrid()).toBe(true); + const opened = await openHostShareGrid({ + imageUrl: '/cover.png', + title: '暖灯猫街', + publicWorkCode: 'PZ-00000001', + }); + + expect(opened).toBe(true); + const url = navigateTo.mock.calls[0]?.[0].url; + const parsed = new URL(url, 'https://mini.test'); + expect(parsed.pathname).toBe('/pages/share-grid/index'); + expect(parsed.searchParams.get('imageUrl')).toBe( + `${window.location.origin}/cover.png`, + ); + expect(parsed.searchParams.get('title')).toBe('暖灯猫街'); + + await expect( + openWechatMiniProgramShareGridPage({ + imageUrl: '/cover.png', + title: '暖灯猫街', + publicWorkCode: 'PZ-00000001', + }), + ).resolves.toBe(true); + }); + + test('向微信小程序宿主同步消息', () => { + const postMessage = vi.fn(); + window.history.replaceState( + null, + '', + '/?clientRuntime=wechat_mini_program', + ); + window.wx = { + miniProgram: { + postMessage, + }, + }; + + expect( + setHostShareTarget({ + type: 'test', + }), + ).toBe(true); + expect(postMessage).toHaveBeenCalledWith({ + data: { + type: 'test', + }, + }); + + expect(postWechatMiniProgramMessage({ type: 'compat' })).toBe(true); + }); + + test('普通浏览器不开放微信小程序能力', () => { + expect(isWechatMiniProgramWebViewRuntime()).toBe(false); + expect(canUseHostShareGrid()).toBe(false); + expect(setHostShareTarget({ type: 'test' })).toBe(false); + }); +}); diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts new file mode 100644 index 00000000..14ce2e9c --- /dev/null +++ b/src/services/host-bridge/hostBridge.ts @@ -0,0 +1,340 @@ +import type { + WechatMiniProgramPayParams, + WechatMiniProgramVirtualPayParams, +} from '../../../packages/shared/src/contracts/runtime'; + +const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; +const MINI_PROGRAM_AUTH_PAGE_URL = + '/pages/web-view/index?authAction=login&returnTo=previous'; +const MINI_PROGRAM_PAY_PAGE_URL = '/pages/wechat-pay/index'; +const MINI_PROGRAM_SHARE_GRID_PAGE_URL = '/pages/share-grid/index'; + +export type HostRuntimeKind = + | 'browser' + | 'wechat_mini_program' + | 'native_app'; + +export type HostRuntimeSnapshot = { + kind: HostRuntimeKind; + clientType: string | null; + clientRuntime: string | null; + miniProgramEnv: string | null; +}; + +export type HostRuntimeContext = { + location?: Pick | null; + navigator?: Partial> | null; + wx?: Window['wx'] | null; +}; + +export type HostNativePageNavigationOptions = { + errorMessage?: string; + beforeNavigate?: () => void; +}; + +export type HostPaymentRequest = { + payload: + | WechatMiniProgramPayParams + | WechatMiniProgramVirtualPayParams + | null + | undefined; + orderId: string; +}; + +export type HostShareGridRequest = { + imageUrl: string; + title: string; + publicWorkCode: string; +}; + +function resolveLocation(context: HostRuntimeContext) { + return ( + context.location ?? (typeof window !== 'undefined' ? window.location : null) + ); +} + +function resolveNavigator(context: HostRuntimeContext) { + return ( + context.navigator ?? + (typeof navigator !== 'undefined' ? navigator : null) + ); +} + +function resolveWxBridge(context: HostRuntimeContext) { + return context.wx ?? (typeof window !== 'undefined' ? window.wx : null); +} + +function hasWechatMiniProgramBridge(wxBridge: Window['wx'] | null | undefined) { + return Boolean( + wxBridge?.miniProgram?.postMessage || wxBridge?.miniProgram?.navigateTo, + ); +} + +function isWechatMiniProgramUserAgent(userAgent: string) { + const normalizedUserAgent = userAgent.toLowerCase(); + return ( + normalizedUserAgent.includes('micromessenger') && + normalizedUserAgent.includes('miniprogram') + ); +} + +export function resolveHostRuntime( + context: HostRuntimeContext = {}, +): HostRuntimeSnapshot { + const location = resolveLocation(context); + const params = new URLSearchParams(location?.search ?? ''); + const clientType = params.get('clientType'); + const clientRuntime = params.get('clientRuntime'); + const miniProgramEnv = params.get('miniProgramEnv'); + const navigatorLike = resolveNavigator(context); + const wxBridge = resolveWxBridge(context); + + if ( + clientRuntime === 'wechat_mini_program' || + clientType === 'mini_program' || + isWechatMiniProgramUserAgent(navigatorLike?.userAgent ?? '') || + hasWechatMiniProgramBridge(wxBridge) + ) { + return { + kind: 'wechat_mini_program', + clientType, + clientRuntime, + miniProgramEnv, + }; + } + + if (clientRuntime === 'native_app' || clientType === 'native_app') { + return { + kind: 'native_app', + clientType, + clientRuntime, + miniProgramEnv, + }; + } + + return { + kind: 'browser', + clientType, + clientRuntime, + miniProgramEnv, + }; +} + +export function getHostRuntime(context: HostRuntimeContext = {}) { + return resolveHostRuntime(context); +} + +export function isWechatMiniProgramWebViewRuntime( + context: HostRuntimeContext = {}, +) { + return resolveHostRuntime(context).kind === 'wechat_mini_program'; +} + +export function isNativeAppRuntime(context: HostRuntimeContext = {}) { + return resolveHostRuntime(context).kind === 'native_app'; +} + +export function loadWechatMiniProgramBridge( + errorMessage = '请在微信小程序内完成操作', +) { + if ( + typeof window === 'undefined' || + !isWechatMiniProgramWebViewRuntime() + ) { + return Promise.reject(new Error(errorMessage)); + } + + if (window.wx?.miniProgram?.navigateTo) { + return Promise.resolve(window.wx); + } + + return new Promise>((resolve, reject) => { + const existingScript = document.querySelector( + `script[src="${WECHAT_JS_SDK_URL}"]`, + ); + const complete = () => { + if (window.wx?.miniProgram?.navigateTo) { + resolve(window.wx); + } else { + reject(new Error(errorMessage)); + } + }; + + if (existingScript) { + if (window.wx?.miniProgram?.navigateTo) { + complete(); + return; + } + + existingScript.addEventListener('load', complete, { once: true }); + existingScript.addEventListener( + 'error', + () => reject(new Error(errorMessage)), + { once: true }, + ); + return; + } + + const script = document.createElement('script'); + script.src = WECHAT_JS_SDK_URL; + script.async = true; + script.onload = complete; + script.onerror = () => reject(new Error(errorMessage)); + document.head.appendChild(script); + }); +} + +export async function navigateWechatMiniProgramPage( + url: string, + errorMessage = '请在微信小程序内完成操作', + options: Pick = {}, +) { + const wxBridge = await loadWechatMiniProgramBridge(errorMessage); + const navigateTo = wxBridge.miniProgram?.navigateTo; + if (typeof navigateTo !== 'function') { + throw new Error(errorMessage); + } + + await new Promise((resolve, reject) => { + options.beforeNavigate?.(); + navigateTo({ + url, + success() { + resolve(); + }, + fail(error) { + reject(new Error(error?.errMsg || errorMessage)); + }, + }); + }); +} + +export async function navigateHostNativePage( + url: string, + options: HostNativePageNavigationOptions = {}, +) { + const runtime = getHostRuntime(); + if (runtime.kind !== 'wechat_mini_program') { + return false; + } + + await navigateWechatMiniProgramPage(url, options.errorMessage, { + beforeNavigate: options.beforeNavigate, + }); + return true; +} + +export async function requestHostLogin() { + return await navigateHostNativePage(MINI_PROGRAM_AUTH_PAGE_URL, { + errorMessage: '请在微信小程序内完成登录', + }); +} + +export async function requestWechatMiniProgramPhoneLogin() { + return await requestHostLogin(); +} + +export async function requestHostPayment({ + payload, + orderId, +}: HostPaymentRequest) { + if (getHostRuntime().kind !== 'wechat_mini_program') { + return false; + } + + if (!payload) { + throw new Error('请在微信小程序内完成支付'); + } + + const requestId = `wechat_pay_${orderId}_${Date.now()}`; + const searchParams = new URLSearchParams({ + requestId, + orderId, + payParams: JSON.stringify(payload), + }); + + await navigateWechatMiniProgramPage( + `${MINI_PROGRAM_PAY_PAGE_URL}?${searchParams.toString()}`, + '请在微信小程序内完成支付', + ); + return true; +} + +export async function requestWechatMiniProgramPayment( + payload: + | WechatMiniProgramPayParams + | WechatMiniProgramVirtualPayParams + | null + | undefined, + orderId: string, +) { + const handled = await requestHostPayment({ payload, orderId }); + if (!handled) { + throw new Error('请在微信小程序内完成支付'); + } +} + +function buildAbsoluteUrl(value: string) { + if (typeof window === 'undefined') { + return value; + } + + return new URL(value, window.location.origin).href; +} + +export function canUseHostShareGrid(context: HostRuntimeContext = {}) { + return getHostRuntime(context).kind === 'wechat_mini_program'; +} + +export function canUseWechatMiniProgramShareGrid() { + return canUseHostShareGrid(); +} + +export async function openHostShareGrid(params: HostShareGridRequest) { + const imageUrl = params.imageUrl.trim(); + if (!imageUrl || !canUseHostShareGrid()) { + return false; + } + + const searchParams = new URLSearchParams({ + imageUrl: buildAbsoluteUrl(imageUrl), + title: params.title.trim() || '我的作品', + publicWorkCode: params.publicWorkCode.trim(), + }); + + try { + return await navigateHostNativePage( + `${MINI_PROGRAM_SHARE_GRID_PAGE_URL}?${searchParams.toString()}`, + { + errorMessage: 'wechat_js_sdk_unavailable', + }, + ); + } catch { + return false; + } +} + +export async function openWechatMiniProgramShareGridPage( + params: HostShareGridRequest, +) { + return await openHostShareGrid(params); +} + +export function setHostShareTarget(message: unknown) { + if ( + typeof window === 'undefined' || + getHostRuntime().kind !== 'wechat_mini_program' || + typeof window.wx?.miniProgram?.postMessage !== 'function' + ) { + return false; + } + + window.wx.miniProgram.postMessage({ + data: message, + }); + return true; +} + +export function postWechatMiniProgramMessage(message: unknown) { + return setHostShareTarget(message); +} diff --git a/src/services/payment/paymentPlatform.test.ts b/src/services/payment/paymentPlatform.test.ts index 10f82284..6326218a 100644 --- a/src/services/payment/paymentPlatform.test.ts +++ b/src/services/payment/paymentPlatform.test.ts @@ -43,6 +43,18 @@ describe('resolveProfileRechargePaymentChannel', () => { ).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL); }); + test('小程序桥未就绪但 UA 已带 miniProgram 时选择 wechat_mp_virtual', () => { + expect( + resolveProfileRechargePaymentChannel({ + location: { search: '' }, + navigator: { + userAgent: + 'Mozilla/5.0 iPhone MicroMessenger/8.0.49 miniProgram', + }, + }), + ).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL); + }); + test('移动网页选择 wechat_h5', () => { expect( resolveProfileRechargePaymentChannel({ diff --git a/src/services/payment/paymentPlatform.ts b/src/services/payment/paymentPlatform.ts index b45f63c0..432199e8 100644 --- a/src/services/payment/paymentPlatform.ts +++ b/src/services/payment/paymentPlatform.ts @@ -1,3 +1,5 @@ +import { getHostRuntime } from '../host-bridge/hostBridge'; + export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp'; export const WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL = 'wechat_mp_virtual'; export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5'; @@ -31,7 +33,7 @@ export function shouldShowRechargeEntry( context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null); return ( - isWechatMiniProgramRuntime(location) || + isWechatMiniProgramRuntime(location, navigatorLike) || isWechatBrowserRuntime(navigatorLike) ); } @@ -50,7 +52,7 @@ export function resolveProfileRechargePaymentChannel( ? window.matchMedia.bind(window) : null); - if (isWechatMiniProgramRuntime(location)) { + if (isWechatMiniProgramRuntime(location, navigatorLike)) { return WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL; } @@ -75,12 +77,13 @@ export function isManualMockPaymentChannel(paymentChannel: string) { export function isWechatMiniProgramRuntime( location: Pick | null | undefined = typeof window !== 'undefined' ? window.location : null, + navigatorLike: Partial | null | undefined = + typeof navigator !== 'undefined' ? navigator : null, ) { - const params = new URLSearchParams(location?.search ?? ''); - return ( - params.get('clientRuntime') === 'wechat_mini_program' || - params.get('clientType') === 'mini_program' - ); + return getHostRuntime({ + location, + navigator: navigatorLike, + }).kind === 'wechat_mini_program'; } function isWechatBrowserRuntime( diff --git a/src/services/wechatMiniProgramShareGrid.ts b/src/services/wechatMiniProgramShareGrid.ts index 549fcded..ff383e83 100644 --- a/src/services/wechatMiniProgramShareGrid.ts +++ b/src/services/wechatMiniProgramShareGrid.ts @@ -1,96 +1,4 @@ -import { isWechatMiniProgramWebViewRuntime } from './authService'; - -const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; -const SHARE_GRID_PAGE_URL = '/pages/share-grid/index'; - -function loadWechatMiniProgramBridge() { - if ( - typeof window === 'undefined' || - !isWechatMiniProgramWebViewRuntime() - ) { - return Promise.reject(new Error('not_mini_program')); - } - - if (window.wx?.miniProgram?.navigateTo) { - return Promise.resolve(window.wx); - } - - return new Promise>((resolve, reject) => { - const existingScript = document.querySelector( - `script[src="${WECHAT_JS_SDK_URL}"]`, - ); - const complete = () => { - if (window.wx?.miniProgram?.navigateTo) { - resolve(window.wx); - } else { - reject(new Error('wechat_js_sdk_unavailable')); - } - }; - - if (existingScript) { - existingScript.addEventListener('load', complete, { once: true }); - existingScript.addEventListener( - 'error', - () => reject(new Error('wechat_js_sdk_load_failed')), - { once: true }, - ); - complete(); - return; - } - - const script = document.createElement('script'); - script.src = WECHAT_JS_SDK_URL; - script.async = true; - script.onload = complete; - script.onerror = () => reject(new Error('wechat_js_sdk_load_failed')); - document.head.appendChild(script); - }); -} - -function buildAbsoluteUrl(value: string) { - if (typeof window === 'undefined') { - return value; - } - - return new URL(value, window.location.origin).href; -} - -export function canUseWechatMiniProgramShareGrid() { - return isWechatMiniProgramWebViewRuntime(); -} - -export async function openWechatMiniProgramShareGridPage(params: { - imageUrl: string; - title: string; - publicWorkCode: string; -}) { - const imageUrl = params.imageUrl.trim(); - if (!imageUrl) { - return false; - } - - const wxBridge = await loadWechatMiniProgramBridge(); - const miniProgram = wxBridge.miniProgram; - if (!miniProgram?.navigateTo) { - return false; - } - - const searchParams = new URLSearchParams({ - imageUrl: buildAbsoluteUrl(imageUrl), - title: params.title.trim() || '我的作品', - publicWorkCode: params.publicWorkCode.trim(), - }); - const url = `${SHARE_GRID_PAGE_URL}?${searchParams.toString()}`; - - return await new Promise((resolve) => { - miniProgram.navigateTo?.({ - url, - success() { - resolve(true); - }, - fail() { - resolve(false); - }, - }); - }); -} +export { + canUseHostShareGrid as canUseWechatMiniProgramShareGrid, + openHostShareGrid as openWechatMiniProgramShareGridPage, +} from './host-bridge/hostBridge'; diff --git a/src/services/wechatMiniProgramShareTarget.ts b/src/services/wechatMiniProgramShareTarget.ts index 407da3e0..bc879833 100644 --- a/src/services/wechatMiniProgramShareTarget.ts +++ b/src/services/wechatMiniProgramShareTarget.ts @@ -1,4 +1,4 @@ -import { isWechatMiniProgramWebViewRuntime } from './authService'; +import { setHostShareTarget } from './host-bridge/hostBridge'; const MESSAGE_TYPE = 'genarrative:share-target'; @@ -38,14 +38,6 @@ export function buildWechatMiniProgramShareTargetMessage( export function postWechatMiniProgramShareTarget( target: WechatMiniProgramShareTarget | null | undefined, ) { - if ( - typeof window === 'undefined' || - !isWechatMiniProgramWebViewRuntime() || - typeof window.wx?.miniProgram?.postMessage !== 'function' - ) { - return false; - } - const message = buildWechatMiniProgramShareTargetMessage(target); if (!message) { return false; @@ -53,8 +45,5 @@ export function postWechatMiniProgramShareTarget( // 中文注释:微信 web-view 会在分享等时机把 postMessage 数据交给原生页, // 小程序页据此把右上角系统分享指向当前推荐作品。 - window.wx.miniProgram.postMessage({ - data: message, - }); - return true; + return setHostShareTarget(message); } diff --git a/src/services/wechatMiniProgramSubscribe.test.ts b/src/services/wechatMiniProgramSubscribe.test.ts index 753b4e26..7386d3f3 100644 --- a/src/services/wechatMiniProgramSubscribe.test.ts +++ b/src/services/wechatMiniProgramSubscribe.test.ts @@ -89,7 +89,18 @@ describe('wechatMiniProgramSubscribe', () => { }); test('skips permission request outside mini program web-view', async () => { - const navigateTo = vi.fn(); + const requested = await requestGenerationResultSubscribePermission(); + + expect(requested).toBe(false); + }); + + test('uses an already available mini program bridge even without query marker', async () => { + const navigateTo = vi.fn((options) => { + options.success?.(); + window.setTimeout(() => { + window.dispatchEvent(new Event('focus')); + }, 0); + }); window.wx = { miniProgram: { navigateTo, @@ -98,7 +109,11 @@ describe('wechatMiniProgramSubscribe', () => { const requested = await requestGenerationResultSubscribePermission(); - expect(requested).toBe(false); - expect(navigateTo).not.toHaveBeenCalled(); + expect(requested).toBe(true); + expect(navigateTo).toHaveBeenCalledWith({ + url: expect.stringMatching(/^\/pages\/subscribe-message\/index\?/u), + success: expect.any(Function), + fail: expect.any(Function), + }); }); }); diff --git a/src/services/wechatMiniProgramSubscribe.ts b/src/services/wechatMiniProgramSubscribe.ts index 86f0e48e..a4915889 100644 --- a/src/services/wechatMiniProgramSubscribe.ts +++ b/src/services/wechatMiniProgramSubscribe.ts @@ -1,6 +1,8 @@ -import { isWechatMiniProgramRuntime } from './payment/paymentPlatform'; +import { + getHostRuntime, + navigateHostNativePage, +} from './host-bridge/hostBridge'; -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 = 2_500; const SUBSCRIBE_RESULT_RETURN_FALLBACK_MS = 800; @@ -96,80 +98,33 @@ function waitSubscribeResultFromHash(timeoutMs = SUBSCRIBE_RESULT_TIMEOUT_MS) { }); } -function loadWechatJsSdk() { +export async function requestGenerationResultSubscribePermission() { if ( - !isWechatMiniProgramRuntime() || + getHostRuntime().kind !== 'wechat_mini_program' || typeof window === 'undefined' ) { - return Promise.reject(new Error('not_mini_program')); - } - if (window.wx?.miniProgram?.navigateTo) { - return Promise.resolve(window.wx); - } - - return new Promise>((resolve, reject) => { - const existingScript = document.querySelector( - `script[src="${WECHAT_JS_SDK_URL}"]`, - ); - const complete = () => { - if (window.wx?.miniProgram?.navigateTo) { - resolve(window.wx); - } else { - reject(new Error('wechat_js_sdk_unavailable')); - } - }; - - if (existingScript) { - existingScript.addEventListener('load', complete, { once: true }); - existingScript.addEventListener('error', () => reject(new Error('wechat_js_sdk_load_failed')), { - once: true, - }); - complete(); - return; - } - - const script = document.createElement('script'); - script.src = WECHAT_JS_SDK_URL; - script.async = true; - script.onload = complete; - script.onerror = () => reject(new Error('wechat_js_sdk_load_failed')); - document.head.appendChild(script); - }); -} - -export async function requestGenerationResultSubscribePermission() { - if (!isWechatMiniProgramRuntime() || typeof window === 'undefined') { - return false; - } - - let wxBridge: NonNullable; - try { - wxBridge = await loadWechatJsSdk(); - } catch { - return false; - } - - const miniProgram = wxBridge.miniProgram; - if (!miniProgram || typeof miniProgram.navigateTo !== 'function') { return false; } const requestId = `subscribe_generation_result_${Date.now()}`; - const resultPromise = waitSubscribeResultFromHash(); - const navigated = await new Promise((resolve) => { - miniProgram.navigateTo?.({ - url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`, - success() { - resolve(true); + let resultPromise: Promise | null = null; + try { + const navigated = await navigateHostNativePage( + `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`, + { + errorMessage: 'wechat_js_sdk_unavailable', + beforeNavigate() { + resultPromise = waitSubscribeResultFromHash(); + }, }, - fail() { - resolve(false); - }, - }); - }); - if (!navigated) { + ); + if (!navigated) { + return false; + } + } catch { return false; } - const result = await resultPromise; + + const result = await (resultPromise ?? Promise.resolve(null)); return Boolean(result); }