fix(h5-shop): harden iOS WeChat scan after login with hard nav and recover UI

Root cause is JSSDK entry-URL mismatch after SPA post-OAuth, not camera permission. Hard-navigate on iOS, keep OAuth query in sign URL, skip redundant bind OAuth, and prompt refresh/re-auth on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 13:08:13 +08:00
parent 2a9493165b
commit e4e9eb2169
12 changed files with 192 additions and 47 deletions
+2
View File
@@ -137,6 +137,8 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成 订单 Tab:待付款 | 已付款 | 已完成
``` ```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
## 环境与发版 ## 环境与发版
| 环境 | 分支 | 目录 | 端口 | 域名 | | 环境 | 分支 | 目录 | 端口 | 域名 |
@@ -2,7 +2,10 @@ type WechatScanAuthModalProps = {
open: boolean; open: boolean;
loading?: boolean; loading?: boolean;
error?: string; error?: string;
/** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */
mode?: 'bind' | 'recover';
onAuthorize: () => void; onAuthorize: () => void;
onRefresh?: () => void;
onCancel: () => void; onCancel: () => void;
}; };
@@ -10,28 +13,43 @@ export default function WechatScanAuthModal({
open, open,
loading, loading,
error, error,
mode = 'bind',
onAuthorize, onAuthorize,
onRefresh,
onCancel, onCancel,
}: WechatScanAuthModalProps) { }: WechatScanAuthModalProps) {
if (!open) return null; if (!open) return null;
const isRecover = mode === 'recover';
return ( return (
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title"> <div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
<div className="shop-scan-auth-card"> <div className="shop-scan-auth-card">
<div className="shop-scan-auth-icon"> <div className="shop-scan-auth-icon">
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span> <span className="material-symbols-outlined shop-fill-icon">
{isRecover ? 'sync_problem' : 'qr_code_scanner'}
</span>
</div> </div>
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title"></h2> <h2 id="shop-scan-auth-title" className="shop-scan-auth-title">
{isRecover ? '扫码能力未就绪' : '微信授权'}
</h2>
<p className="shop-scan-auth-desc"> <p className="shop-scan-auth-desc">
使使 {isRecover
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
</p> </p>
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>} {error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
<div className="shop-scan-auth-actions"> <div className="shop-scan-auth-actions">
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}> <button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
</button> </button>
{isRecover && onRefresh ? (
<button type="button" className="shop-scan-auth-confirm" onClick={onRefresh} disabled={loading}>
</button>
) : null}
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}> <button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
{loading ? '跳转授权中…' : '微信授权'} {loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
</button> </button>
</div> </div>
</div> </div>
+5 -4
View File
@@ -169,11 +169,12 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
} }
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */ /** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
export async function bindShopWechatAfterSmsLogin(): Promise<void> { export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
const config = await fetchClientConfig(); const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return; if (!isWxAuthorizeEnabled(config)) return 'skipped';
if (!isWechatEnv()) return; if (!isWechatEnv()) return 'skipped';
// OAuth 整页回跳后 iOS 需用入场 URL 重配 JSSDK;标记下次扫码加长预热 // 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
if (session?.account?.hasWechat) return 'skipped';
markScanWarmupAfterAuth(); markScanWarmupAfterAuth();
await weixinSdk.login(); await weixinSdk.login();
} }
+39 -5
View File
@@ -58,9 +58,9 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试'; const msg = e instanceof Error ? e.message : '扫码失败,请重试';
if (/invalid signature/i.test(msg)) { if (/invalid signature|config:fail|signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
} }
@@ -68,11 +68,11 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
if (opts?.afterAuth) { if (opts?.afterAuth) {
return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」'; return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
} }
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)'; return '微信扫码能力未就绪,请刷新页面或重新授权微信';
} }
@@ -80,6 +80,12 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
} }
function isScanRecoverableError(msg: string): boolean {
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
}
export default function HomePage() { export default function HomePage() {
@@ -100,6 +106,8 @@ export default function HomePage() {
const [authModalOpen, setAuthModalOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false);
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
const [authLoading, setAuthLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState(''); const [authError, setAuthError] = useState('');
@@ -229,7 +237,19 @@ export default function HomePage() {
} catch (e) { } catch (e) {
setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup })); const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
setScanMsg(tip);
if (isScanRecoverableError(tip)) {
setAuthModalMode('recover');
setAuthError(tip);
setAuthModalOpen(true);
}
} finally { } finally {
@@ -305,6 +325,10 @@ export default function HomePage() {
pendingScanStartedRef.current = false; pendingScanStartedRef.current = false;
setAuthModalMode('bind');
setAuthError('');
setAuthModalOpen(true); setAuthModalOpen(true);
return; return;
@@ -561,16 +585,26 @@ export default function HomePage() {
open={authModalOpen} open={authModalOpen}
mode={authModalMode}
loading={authLoading} loading={authLoading}
error={authError} error={authError}
onAuthorize={() => void startWechatAuth()} onAuthorize={() => void startWechatAuth()}
onRefresh={() => {
window.location.reload();
}}
onCancel={() => { onCancel={() => {
setAuthModalOpen(false); setAuthModalOpen(false);
setAuthModalMode('bind');
setAuthError(''); setAuthError('');
clearPendingScanAfterAuth(); clearPendingScanAfterAuth();
+2 -2
View File
@@ -159,9 +159,9 @@ export default function LoginPage() {
}); });
saveRememberedSession(data); saveRememberedSession(data);
applySession(data); applySession(data);
if (isWechatEnv() && wxAuthorize) { if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) {
setMsg('登录成功,正在关联微信…'); setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin(); await bindShopWechatAfterSmsLogin(data);
return; return;
} }
routeAfterShopLogin(data, navigate); routeAfterShopLogin(data, navigate);
+16 -5
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { import {
needsStoreSelection, needsStoreSelection,
@@ -10,6 +11,14 @@ import {
type ShopStoreOption, type ShopStoreOption,
} from '../lib/api'; } from '../lib/api';
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat('/');
return;
}
navigate('/', { replace: true });
}
export default function SelectStorePage() { export default function SelectStorePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession, store, authenticated } = useStoreSession(); const { applySession, store, authenticated } = useStoreSession();
@@ -37,7 +46,7 @@ export default function SelectStorePage() {
async function onSelect(storeId: string) { async function onSelect(storeId: string) {
if (loadingId) return; if (loadingId) return;
if (storeId === currentStoreId) { if (storeId === currentStoreId) {
navigate('/', { replace: true }); goShopHome(navigate);
return; return;
} }
setLoadingId(storeId); setLoadingId(storeId);
@@ -45,7 +54,7 @@ export default function SelectStorePage() {
try { try {
const session = await selectStore(storeId); const session = await selectStore(storeId);
applySession(session); applySession(session);
navigate('/', { replace: true }); goShopHome(navigate);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '选店失败'); setMsg(e instanceof Error ? e.message : '选店失败');
} finally { } finally {
@@ -166,9 +175,11 @@ export function routeAfterShopLogin(
session: ShopSessionPayload, session: ShopSessionPayload,
navigate: (path: string, opts?: { replace?: boolean }) => void, navigate: (path: string, opts?: { replace?: boolean }) => void,
) { ) {
if (needsStoreSelection(session)) { const path = needsStoreSelection(session) ? '/select-store' : '/';
navigate('/select-store', { replace: true }); // iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat(path);
return; return;
} }
navigate('/', { replace: true }); navigate(path, { replace: true });
} }
+1
View File
@@ -794,6 +794,7 @@
.shop-scan-auth-actions { .shop-scan-auth-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
+34
View File
@@ -0,0 +1,34 @@
# @dukang/weixin-sdk · 踩坑
## iOS 微信 H5JSSDK 入场 URL(扫码 / 定位 / 选图)
### 症状
- 登录或 OAuth 后立刻调 `scanQRCode` / `getLocation` / `chooseImage` 失败
- 错误类似:`permission value is offline verifying``invalid signature`
- 业务文案常被写成「权限校验尚未完成,请等 1~2 秒」——**多数情况下等无效**
- 关掉 webview 再进(整页重载)立即恢复
### 原因
iOS 微信对 JS-SDK 验签使用的是**本次 document 加载时的 URL**(去掉 `#` 后的完整 URL**含 query**)。
`history.pushState` / `replaceState`(含 React Router)**不会**更新微信内部用于验签的 URL。
典型错误链路:
1. OAuth 回跳:`/login?code=xxx&state=yyy`(入场 URL
2. SPA`navigate('/')`
3. 用当前页 `/` 或「去掉 code 后的 `/login`」去签名 → 与微信内部 URL 不一致 → 失败
### 正确做法
1. **业务跳转(登录成功 / 选店进首页)**iOS 微信内用 `hardNavigateInWechat(path)` / `location.replace`,让目标页成为新的入场 URL。
2. **签名 URL**`getJssdkSignUrl()` 在 iOS 上返回入场 URLOAuth 的 `code/state` **必须保留**参与签名;后端 `jssdk-config` 只去 `#`,不要删 query。
3. **先 `captureIosJssdkEntryUrl()`,再 `stripOAuthParamsFromLocation()`**
4. 失败恢复:引导用户刷新页面或重新走 OAuth,而不是无限「再点一次」。
### 相关 API
- `captureIosJssdkEntryUrl` / `getJssdkSignUrl`
- `shouldHardNavigateForJssdk` / `hardNavigateInWechat`
- `stripOAuthParamsFromLocation`
+3
View File
@@ -5,10 +5,13 @@ export {
ensureJssdkReady, ensureJssdkReady,
isJssdkReady, isJssdkReady,
normalizeJssdkPageUrl, normalizeJssdkPageUrl,
jssdkUrlWithoutHash,
getJssdkSignUrl, getJssdkSignUrl,
captureIosJssdkEntryUrl, captureIosJssdkEntryUrl,
resetJssdkConfig, resetJssdkConfig,
stripOAuthParamsFromLocation, stripOAuthParamsFromLocation,
hardNavigateInWechat,
shouldHardNavigateForJssdk,
} from './jssdk'; } from './jssdk';
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan'; export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
export { export {
+41 -16
View File
@@ -11,9 +11,10 @@ let configured = false;
let configuredUrl: string | null = null; let configuredUrl: string | null = null;
/** /**
* iOS 微信 WebViewJSSDK 签名校验用的是「本次 document 加载」的入场 URL * iOS 微信 WebViewJSSDK 签名校验用的是「本次 document 加载」的入场 URL(含 query
* SPA pushState/replaceState 后 location.href 会变,但微信仍按入场 URL 验签。 * SPA pushState/replaceState 后 location.href 会变,但微信仍按入场 URL 验签。
* 使用模块级变量:整页刷新(含 OAuth 回跳)会重置;同页 SPA 路由保持不变 * OAuth 回跳带 code/state 时也必须按入场完整 query 签名,不可剔除
* 模块级变量:整页刷新(含 OAuth / location.replace)会重置;同页 SPA 保持不变。
*/ */
let iosEntryUrl: string | null = null; let iosEntryUrl: string | null = null;
@@ -23,17 +24,30 @@ export function resetJssdkConfig(): void {
configuredUrl = null; configuredUrl = null;
} }
/** 参与 JSSDK 签名的页面 URL:与微信文档一致,取 location.href 去掉 # 后的部分;剔除 OAuth 回调参数 */ /** 仅去 hash,保留全部 query(含 OAuth code/state)— iOS 入场签名必须如此 */
export function normalizeJssdkPageUrl(rawUrl: string): string { export function jssdkUrlWithoutHash(rawUrl: string): string {
return rawUrl.split('#')[0];
}
/**
* 规范化签名 URL。
* - 默认:去 hash;可保留 code/state(由 keepOAuthQuery 控制)
* - Android / 当前页签名:通常已 stripOAuth 后再签,keepOAuthQuery=false
*/
export function normalizeJssdkPageUrl(rawUrl: string, opts?: { keepOAuthQuery?: boolean }): string {
const keepOAuth = !!opts?.keepOAuthQuery;
try { try {
const url = new URL(rawUrl); const url = new URL(rawUrl);
url.hash = ''; url.hash = '';
url.searchParams.delete('code'); if (!keepOAuth) {
url.searchParams.delete('state'); url.searchParams.delete('code');
url.searchParams.delete('state');
}
const query = url.searchParams.toString(); const query = url.searchParams.toString();
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`; return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
} catch { } catch {
const noHash = rawUrl.split('#')[0]; const noHash = jssdkUrlWithoutHash(rawUrl);
if (keepOAuth) return noHash;
try { try {
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost'); const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
url.searchParams.delete('code'); url.searchParams.delete('code');
@@ -59,7 +73,7 @@ function clearLegacySignUrlCache(): void {
} }
/** /**
* 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次)。 * 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次;保留 code/state)。
* Android / 非微信环境为 no-op。 * Android / 非微信环境为 no-op。
*/ */
export function captureIosJssdkEntryUrl(): void { export function captureIosJssdkEntryUrl(): void {
@@ -67,19 +81,31 @@ export function captureIosJssdkEntryUrl(): void {
if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return; if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return;
clearLegacySignUrlCache(); clearLegacySignUrlCache();
if (iosEntryUrl) return; if (iosEntryUrl) return;
iosEntryUrl = normalizeJssdkPageUrl(window.location.href); iosEntryUrl = normalizeJssdkPageUrl(window.location.href, { keepOAuthQuery: true });
} }
/** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL */ /** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL(含 OAuth query */
export function getJssdkSignUrl(rawUrl?: string): string { export function getJssdkSignUrl(rawUrl?: string): string {
const current = normalizeJssdkPageUrl(
rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''),
);
if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) { if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) {
captureIosJssdkEntryUrl(); captureIosJssdkEntryUrl();
if (iosEntryUrl) return iosEntryUrl; if (iosEntryUrl) return iosEntryUrl;
} }
return current; return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''), {
keepOAuthQuery: false,
});
}
/**
* iOS 微信内业务跳转须用整页导航,使下一页成为新的 JSSDK 入场 URL。
* SPA navigate 会导致扫码/定位等 JSAPI 验签失败。
*/
export function hardNavigateInWechat(path: string): void {
if (typeof window === 'undefined') return;
window.location.replace(path);
}
export function shouldHardNavigateForJssdk(): boolean {
return typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools();
} }
function isJssdkDebugEnabled(): boolean { function isJssdkDebugEnabled(): boolean {
@@ -95,7 +121,7 @@ export function stripOAuthParamsFromLocation(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return; if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
// iOS:须先锁定入场 URL,再 replaceState;否则签名 URL 与微信内部 URL 不一致 // iOS:须先锁定入场 URL(含 code/state,再 replaceState
captureIosJssdkEntryUrl(); captureIosJssdkEntryUrl();
url.searchParams.delete('code'); url.searchParams.delete('code');
url.searchParams.delete('state'); url.searchParams.delete('state');
@@ -147,7 +173,6 @@ export async function initWechatJssdk(options: {
}): Promise<void> { }): Promise<void> {
captureIosJssdkEntryUrl(); captureIosJssdkEntryUrl();
const { apiBase, clientApp, getAccessToken } = options; const { apiBase, clientApp, getAccessToken } = options;
// iOS 忽略调用方传入的「当前页」URL,强制入场 URL,避免登录后 SPA 到首页签错名
const pageUrl = getJssdkSignUrl(options.url); const pageUrl = getJssdkSignUrl(options.url);
await loadScript(); await loadScript();
if (!window.wx) throw new Error('微信 JSSDK 不可用'); if (!window.wx) throw new Error('微信 JSSDK 不可用');
@@ -83,17 +83,12 @@ export class WechatController {
return this.wechat.createJssdkConfig(pageUrl, actorRef); return this.wechat.createJssdkConfig(pageUrl, actorRef);
} }
/**
* 仅去 hash。勿剔除 code/state
* iOS 微信用「document 入场 URL」验签,OAuth 回跳页的 query 必须原样参与签名。
*/
private normalizeJssdkUrl(rawUrl: string): string { private normalizeJssdkUrl(rawUrl: string): string {
try { return rawUrl.split('#')[0];
const parsed = new URL(rawUrl);
parsed.hash = '';
parsed.searchParams.delete('code');
parsed.searchParams.delete('state');
const query = parsed.searchParams.toString();
return `${parsed.origin}${parsed.pathname}${query ? `?${query}` : ''}`;
} catch {
return rawUrl.split('#')[0];
}
} }
@Get('oauth-url') @Get('oauth-url')
+22 -1
View File
@@ -34,7 +34,28 @@
- 核销记录;今日汇总;到账金额×60%展示 - 核销记录;今日汇总;到账金额×60%展示
- 营业状态开关;Mine 门店信息 - 营业状态开关;Mine 门店信息
- 套餐:列表编辑→提交 HQ 审核(v3.4.10) - 套餐:列表编辑→提交 HQ 审核(v3.4.10)
- iOS 微信:OAuth 后自动续扫(v3.4.13 - iOS 微信:OAuth 后自动续扫(v3.4.13;登录后须整页跳转(见下「踩坑」)
### 踩坑 · iOS 微信 H5 扫码(必读,勿再回归)
**现象**:手机号重新登录后点「扫码核销」提示「微信权限校验尚未完成…」;关掉 H5 再进就正常。
**根因(不是系统相机权限)**
1. iOS 微信 WebView 对 JSSDK 验签用的是**本次 document 加载的入场 URL**(含 query),不是 SPA `pushState` 之后的 `location.href`
2. 登录 / OAuth 回跳常落在 `/login?code=…`,再 `navigate('/')` 进首页 → 签名 URL 与微信内部入场 URL 不一致 → `permission value is offline verifying` / invalid signature。
3. 文案「等 1~2 秒再点」只覆盖「权限离线校验偏慢」的一小部分场景;**签名错了等多久都不行**,必须整页刷新或重新授权。
**硬规则(编码)**
| 规则 | 说明 |
|------|------|
| iOS 登录/选店后 | 用 `location.replace(path)``hardNavigateInWechat`),禁止仅 React Router navigate |
| iOS 签名 URL | `getJssdkSignUrl()` = 入场 URL**保留** OAuth `code/state`;后端 `jssdk-config` 勿剔除 |
| 已绑定微信 | 短信登录后**不要**再强制 OAuth(避免反复重置入场 URL) |
| 扫码仍失败 | 弹窗引导「刷新页面」/「重新授权微信」,勿只提示再点一次 |
实现:`packages/weixin-sdk/src/jssdk.ts` · `apps/h5-shop` 登录/选店/HomePage。
## 4. 合伙人端(h5-partner ## 4. 合伙人端(h5-partner