统一 H5 宿主壳能力协议
新增 HostBridge 通用宿主能力服务与测试 迁移登录支付分享订阅入口到通用 HostBridge API 保留微信小程序旧接口兼容包装 补充宿主壳协议文档与项目记忆
This commit is contained in:
@@ -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)。
|
||||
|
||||
@@ -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。
|
||||
|
||||
68
docs/【前端架构】宿主壳能力统一协议-2026-06-17.md
Normal file
68
docs/【前端架构】宿主壳能力统一协议-2026-06-17.md
Normal file
@@ -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。
|
||||
- 将宿主能力、支付渠道和分享策略补充进移动端发布检查清单。
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<LoginTab>('phone');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`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<void> {
|
||||
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<void>((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<void>((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) {
|
||||
|
||||
@@ -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<typeof createRoot>;
|
||||
|
||||
@@ -28,7 +28,7 @@ if (!rootElement) {
|
||||
}
|
||||
|
||||
function markWechatMiniProgramRuntime() {
|
||||
if (isWechatMiniProgramWebViewRuntime()) {
|
||||
if (getHostRuntime().kind === 'wechat_mini_program') {
|
||||
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`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<void>((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!;
|
||||
|
||||
230
src/services/host-bridge/hostBridge.test.ts
Normal file
230
src/services/host-bridge/hostBridge.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
340
src/services/host-bridge/hostBridge.ts
Normal file
340
src/services/host-bridge/hostBridge.ts
Normal file
@@ -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<Location, 'search'> | null;
|
||||
navigator?: Partial<Pick<Navigator, 'userAgent'>> | 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<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`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<HostNativePageNavigationOptions, 'beforeNavigate'> = {},
|
||||
) {
|
||||
const wxBridge = await loadWechatMiniProgramBridge(errorMessage);
|
||||
const navigateTo = wxBridge.miniProgram?.navigateTo;
|
||||
if (typeof navigateTo !== 'function') {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
await new Promise<void>((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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<Location, 'search'> | null | undefined =
|
||||
typeof window !== 'undefined' ? window.location : null,
|
||||
navigatorLike: Partial<PaymentPlatformNavigator> | 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(
|
||||
|
||||
@@ -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<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`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<boolean>((resolve) => {
|
||||
miniProgram.navigateTo?.({
|
||||
url,
|
||||
success() {
|
||||
resolve(true);
|
||||
},
|
||||
fail() {
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
export {
|
||||
canUseHostShareGrid as canUseWechatMiniProgramShareGrid,
|
||||
openHostShareGrid as openWechatMiniProgramShareGridPage,
|
||||
} from './host-bridge/hostBridge';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`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<Window['wx']>;
|
||||
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<boolean>((resolve) => {
|
||||
miniProgram.navigateTo?.({
|
||||
url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`,
|
||||
success() {
|
||||
resolve(true);
|
||||
let resultPromise: Promise<string | null> | 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user