53 Commits

Author SHA1 Message Date
jacy b32c68d56b merge(dev): fix XFX track callback body parse
CI / verify (push) Has been cancelled
2026-08-06 17:51:04 +08:00
jacy 7bc710f139 merge(dev): mini-user share config in system settings
CI / verify (push) Has been cancelled
2026-08-06 15:46:07 +08:00
jacy 91fed31b10 merge(dev): v3.4.14 mini-user store UX and brand config
CI / verify (push) Has been cancelled
2026-08-06 15:22:15 +08:00
jacy 0181af09c5 merge(dev): v3.4.13 partner column, release cascade, client UX
CI / verify (push) Has been cancelled
2026-08-06 01:02:29 +08:00
jacy 291137a15d merge(dev): admin redeem detail UI (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-06 00:27:21 +08:00
jacy 33b868dfac merge(dev): ticket center status Chinese labels
CI / verify (push) Has been cancelled
2026-08-06 00:23:43 +08:00
jacy 37bc297d1a merge(dev): mini-user version display and force exit (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-06 00:15:06 +08:00
jacy 429eaabe6b merge(dev): promo metric logs and HQ timeline (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-06 00:07:02 +08:00
jacy 456b3af461 merge(dev): courier signed callback completes order (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-05 23:46:47 +08:00
jacy 69f52921a7 merge(dev): v3.4.13 logistics enhancements
CI / verify (push) Has been cancelled
2026-08-05 23:34:20 +08:00
jacy 7c9240fc84 merge(dev): logistics detail page polish (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-05 23:14:28 +08:00
jacy 9d49ed5e70 merge(dev): mini-user logistics preview and detail timeline (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-05 23:03:58 +08:00
jacy d0ab838da6 merge(dev): v3.4.13 upload compression and winery T+3 fix
CI / verify (push) Has been cancelled
2026-08-05 22:48:47 +08:00
jacy dc4295d1bd merge(dev): v3.4.13 experience optimizations
CI / verify (push) Has been cancelled
2026-08-05 22:34:44 +08:00
jacy bf501d2374 merge(dev): support ticket batch select and task bind
CI / verify (push) Has been cancelled
2026-08-05 08:08:27 +08:00
jacy b40eb83f27 Merge pull request 'Dev' (#13) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/13
2026-08-05 00:57:33 +08:00
jacy f5e523a425 merge(dev): mini-user store list card redesign
CI / verify (push) Has been cancelled
2026-08-05 00:11:34 +08:00
jacy b21403a47c merge(dev): kb doc edit, validation auto-tickets, support batch ops
CI / verify (push) Has been cancelled
2026-08-05 00:09:07 +08:00
jacy f9a5b6e81a merge(dev): fix h5-shop upload export for v3.4.12
CI / verify (push) Has been cancelled
2026-08-04 23:54:22 +08:00
jacy 1c787ed576 merge(dev): release v3.4.12
CI / verify (push) Has been cancelled
2026-08-04 23:53:48 +08:00
jacy aca3df4bf2 merge(dev): release v3.4.11 dev plan, wecom push and bot fixes
CI / verify (push) Has been cancelled
2026-08-04 22:41:49 +08:00
jacy fa0d928980 merge(dev): release v3.4.10
CI / verify (push) Has been cancelled
2026-08-03 22:54:23 +08:00
jacy 6f427cb553 merge(dev): persona logging and Sentry system config
CI / verify (push) Has been cancelled
2026-08-03 22:31:27 +08:00
jacy a7d55b7293 merge(dev): 走马灯滚出终点调整
CI / verify (push) Has been cancelled
2026-08-03 17:52:18 +08:00
jacy f09e00e16f merge(dev): 门店核销走马灯优化
CI / verify (push) Has been cancelled
2026-08-03 16:34:36 +08:00
jacy aa0e140fec merge(dev): 核销方式标记与统计
CI / verify (push) Has been cancelled
2026-08-03 16:21:47 +08:00
jacy f8d8e2d6d5 merge(dev): 统一门店结算账单
CI / verify (push) Has been cancelled
2026-08-03 16:04:55 +08:00
jacy 9859abba7e merge(dev): 全部门店可提现+企微提醒
CI / verify (push) Has been cancelled
2026-08-03 15:43:34 +08:00
jacy c89560e4f8 merge(dev): 门店手动提现 OPT-010
CI / verify (push) Has been cancelled
2026-08-03 15:32:17 +08:00
jacy ffed560d43 merge(dev): 小程序 Intl 兜底
CI / verify (push) Has been cancelled
2026-08-03 14:54:02 +08:00
jacy 5199b495e0 merge(dev): 门店核销走马灯兼容 H5/小程序
CI / verify (push) Has been cancelled
2026-08-03 14:48:41 +08:00
jacy 82b98aa4e3 merge(dev): 修复 Intl is not defined
CI / verify (push) Has been cancelled
2026-08-03 14:37:48 +08:00
jacy 7dffd40f3f merge(dev): 走马灯 JS 实现
CI / verify (push) Has been cancelled
2026-08-03 14:33:35 +08:00
jacy 0304201e80 merge(dev): 发版默认生产约定
CI / verify (push) Has been cancelled
2026-08-03 14:29:55 +08:00
jacy dd4362c397 merge(dev): 门店详情修复
CI / verify (push) Has been cancelled
2026-08-03 14:29:03 +08:00
jacy 87515decb3 merge(dev): 核销时间格式与门店走马灯
CI / verify (push) Has been cancelled
2026-08-03 14:20:36 +08:00
jacy 28ef916a22 merge(dev): 门店可见白名单
CI / verify (push) Has been cancelled
2026-08-03 13:58:47 +08:00
jacy 39e38aac7e merge(dev): mini-user eight fixes and catalog whitelist
CI / verify (push) Has been cancelled
2026-08-03 13:12:40 +08:00
jacy daf534b4db merge(dev): skip WeCom audit client-error noise
CI / verify (push) Has been cancelled
2026-08-02 21:52:18 +08:00
jacy 118993a01f merge(dev): deploy default DB sync
CI / verify (push) Has been cancelled
2026-08-02 18:12:37 +08:00
jacy 8f361981bf merge(dev): product fulfillment flags and auto SKU
CI / verify (push) Has been cancelled
2026-08-02 18:10:31 +08:00
jacy 4bb6ae20cd merge(dev): fix partner image picker after cancel
CI / verify (push) Has been cancelled
2026-08-02 17:11:31 +08:00
jacy ad757e9ba8 merge(dev): restore camera and fix mine benefit-detail back
CI / verify (push) Has been cancelled
2026-08-02 17:00:58 +08:00
jacy e63b57db19 merge(dev): client error reporting
CI / verify (push) Has been cancelled
2026-08-02 16:27:20 +08:00
jacy fb4432b307 merge(dev): WeCom webhook ops alerts
CI / verify (push) Has been cancelled
2026-08-02 16:02:35 +08:00
jacy ab44515e8b merge(dev_jacy): on-site pickup COMPLETED on pay
CI / verify (push) Has been cancelled
2026-08-02 13:42:17 +08:00
jacy 78365d648c merge(dev_jacy): proxy-order real pay
CI / verify (push) Has been cancelled
2026-08-02 13:28:16 +08:00
jacy e9eddbb26e merge(dev_jacy): shop WeChat unbound tip
CI / verify (push) Has been cancelled
2026-08-02 13:15:50 +08:00
jacy c00ca3620d merge(dev): partner dropdown label company-name
CI / verify (push) Has been cancelled
2026-08-02 13:07:41 +08:00
jacy 4d76ee6d0e merge(dev): WeChat login restore, proxy pay, staging SSL verify
CI / verify (push) Has been cancelled
2026-08-02 12:53:53 +08:00
jacy 2dbe665bdc Merge pull request '合并到main 创建8.1最后一波修改,后面测试和正式就会分离' (#11) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/11
2026-07-31 11:27:55 +08:00
jacy 1bc3bec977 Merge pull request 'Dev Main by v3.4 测试数据删除' (#10) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/10
2026-07-28 17:19:21 +08:00
jacy 81a6e3674b Merge pull request 'Dev' (#9) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/9
2026-07-17 10:50:17 +08:00
13 changed files with 56 additions and 255 deletions
-2
View File
@@ -137,8 +137,6 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
## 环境与发版
| 环境 | 分支 | 目录 | 端口 | 域名 |
@@ -2,10 +2,7 @@ type WechatScanAuthModalProps = {
open: boolean;
loading?: boolean;
error?: string;
/** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */
mode?: 'bind' | 'recover';
onAuthorize: () => void;
onRefresh?: () => void;
onCancel: () => void;
};
@@ -13,43 +10,28 @@ export default function WechatScanAuthModal({
open,
loading,
error,
mode = 'bind',
onAuthorize,
onRefresh,
onCancel,
}: WechatScanAuthModalProps) {
if (!open) return null;
const isRecover = mode === 'recover';
return (
<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-icon">
<span className="material-symbols-outlined shop-fill-icon">
{isRecover ? 'sync_problem' : 'qr_code_scanner'}
</span>
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
</div>
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">
{isRecover ? '扫码能力未就绪' : '微信授权'}
</h2>
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title"></h2>
<p className="shop-scan-auth-desc">
{isRecover
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
使使
</p>
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
<div className="shop-scan-auth-actions">
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
</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}>
{loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
{loading ? '跳转授权中…' : '微信授权'}
</button>
</div>
</div>
-22
View File
@@ -3,9 +3,6 @@ import { isIosDevice } from '@dukang/weixin-sdk';
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
export function markPendingScanAfterAuth(): void {
try {
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
@@ -30,25 +27,6 @@ export function clearPendingScanAfterAuth(): void {
}
}
/** OAuth(含短信登录后绑微信)回跳后,标记下一次扫码需要加长预热 */
export function markScanWarmupAfterAuth(): void {
try {
sessionStorage.setItem(SHOP_SCAN_WARMUP_KEY, '1');
} catch {
/* ignore */
}
}
export function consumeScanWarmupAfterAuth(): boolean {
try {
if (sessionStorage.getItem(SHOP_SCAN_WARMUP_KEY) !== '1') return false;
sessionStorage.removeItem(SHOP_SCAN_WARMUP_KEY);
return true;
} catch {
return false;
}
}
/** OAuth 回跳后延迟再调 scanQRCodeiOS JSSDK 离线校验更慢) */
export function getPostAuthScanDelayMs(): number {
return isIosDevice() ? 1200 : 600;
+3 -9
View File
@@ -2,7 +2,6 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from './weixin';
import { markScanWarmupAfterAuth } from './shop-scan-auth';
import { request, saveWechatSession, type ShopSessionPayload } from './api';
export type ShopAccountProfile = {
@@ -163,19 +162,15 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
markScanWarmupAfterAuth();
const result = await weixinSdk.login();
if (result) return handleShopWechatLoginResult(result);
}
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return 'skipped';
if (!isWechatEnv()) return 'skipped';
// 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
if (session?.account?.hasWechat) return 'skipped';
markScanWarmupAfterAuth();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) return;
await weixinSdk.login();
}
@@ -185,6 +180,5 @@ export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
markScanWarmupAfterAuth();
return weixinSdk.login();
}
+8 -47
View File
@@ -4,7 +4,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
@@ -28,8 +28,6 @@ import {
clearPendingScanAfterAuth,
consumeScanWarmupAfterAuth,
getPostAuthScanDelayMs,
markPendingScanAfterAuth,
@@ -58,9 +56,9 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
if (/invalid signature|config:fail|signature/i.test(msg)) {
if (/invalid signature/i.test(msg)) {
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
}
@@ -68,11 +66,11 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
if (opts?.afterAuth) {
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
}
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
}
@@ -80,12 +78,6 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
}
function isScanRecoverableError(msg: string): boolean {
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
}
export default function HomePage() {
@@ -106,8 +98,6 @@ export default function HomePage() {
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
@@ -200,8 +190,7 @@ export default function HomePage() {
try {
// iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL
if (opts?.postAuthWarmup || isIosDevice()) {
if (opts?.postAuthWarmup) {
weixinSdk.reset();
@@ -237,19 +226,7 @@ export default function HomePage() {
} catch (e) {
const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
setScanMsg(tip);
if (isScanRecoverableError(tip)) {
setAuthModalMode('recover');
setAuthError(tip);
setAuthModalOpen(true);
}
setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup }));
} finally {
@@ -325,19 +302,13 @@ export default function HomePage() {
pendingScanStartedRef.current = false;
setAuthModalMode('bind');
setAuthError('');
setAuthModalOpen(true);
return;
}
const needWarmup = consumeScanWarmupAfterAuth();
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
await runScan();
} catch (e) {
@@ -585,26 +556,16 @@ export default function HomePage() {
open={authModalOpen}
mode={authModalMode}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onRefresh={() => {
window.location.reload();
}}
onCancel={() => {
setAuthModalOpen(false);
setAuthModalMode('bind');
setAuthError('');
clearPendingScanAfterAuth();
+2 -2
View File
@@ -159,9 +159,9 @@ export default function LoginPage() {
});
saveRememberedSession(data);
applySession(data);
if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) {
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin(data);
await bindShopWechatAfterSmsLogin();
return;
}
routeAfterShopLogin(data, navigate);
+5 -16
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
import {
needsStoreSelection,
@@ -11,14 +10,6 @@ import {
type ShopStoreOption,
} from '../lib/api';
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat('/');
return;
}
navigate('/', { replace: true });
}
export default function SelectStorePage() {
const navigate = useNavigate();
const { applySession, store, authenticated } = useStoreSession();
@@ -46,7 +37,7 @@ export default function SelectStorePage() {
async function onSelect(storeId: string) {
if (loadingId) return;
if (storeId === currentStoreId) {
goShopHome(navigate);
navigate('/', { replace: true });
return;
}
setLoadingId(storeId);
@@ -54,7 +45,7 @@ export default function SelectStorePage() {
try {
const session = await selectStore(storeId);
applySession(session);
goShopHome(navigate);
navigate('/', { replace: true });
} catch (e) {
setMsg(e instanceof Error ? e.message : '选店失败');
} finally {
@@ -175,11 +166,9 @@ export function routeAfterShopLogin(
session: ShopSessionPayload,
navigate: (path: string, opts?: { replace?: boolean }) => void,
) {
const path = needsStoreSelection(session) ? '/select-store' : '/';
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat(path);
if (needsStoreSelection(session)) {
navigate('/select-store', { replace: true });
return;
}
navigate(path, { replace: true });
navigate('/', { replace: true });
}
-1
View File
@@ -794,7 +794,6 @@
.shop-scan-auth-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
-34
View File
@@ -1,34 +0,0 @@
# @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,13 +5,10 @@ export {
ensureJssdkReady,
isJssdkReady,
normalizeJssdkPageUrl,
jssdkUrlWithoutHash,
getJssdkSignUrl,
captureIosJssdkEntryUrl,
resetJssdkConfig,
stripOAuthParamsFromLocation,
hardNavigateInWechat,
shouldHardNavigateForJssdk,
} from './jssdk';
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
export {
+23 -70
View File
@@ -1,53 +1,31 @@
import type { WechatJssdkConfig } from '@dukang/shared-types';
import { isIosDevice, isWechatBrowser, isWechatDevTools } from './env';
import { isWechatBrowser, isWechatDevTools } from './env';
import { DEFAULT_JS_API_LIST } from './types';
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
/** 旧版错误地把 SPA 当前 URL 写入 session;清理以免干扰排查 */
const LEGACY_SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
const SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
let scriptPromise: Promise<void> | null = null;
let configured = false;
let configuredUrl: string | null = null;
/**
* iOS 微信 WebViewJSSDK 签名校验用的是「本次 document 加载」的入场 URL(含 query),
* SPA pushState/replaceState 后 location.href 会变,但微信仍按入场 URL 验签。
* OAuth 回跳带 code/state 时也必须按入场完整 query 签名,不可剔除。
* 模块级变量:整页刷新(含 OAuth / location.replace)会重置;同页 SPA 保持不变。
*/
let iosEntryUrl: string | null = null;
/** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */
export function resetJssdkConfig(): void {
configured = false;
configuredUrl = null;
}
/** 仅去 hash,保留全部 query(含 OAuth code/state)— iOS 入场签名必须如此 */
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;
/** 参与 JSSDK 签名的页面 URL:与微信文档一致,取 location.href 去掉 # 后的部分;剔除 OAuth 回调参数 */
export function normalizeJssdkPageUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
url.hash = '';
if (!keepOAuth) {
url.searchParams.delete('code');
url.searchParams.delete('state');
}
url.searchParams.delete('code');
url.searchParams.delete('state');
const query = url.searchParams.toString();
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
} catch {
const noHash = jssdkUrlWithoutHash(rawUrl);
if (keepOAuth) return noHash;
const noHash = rawUrl.split('#')[0];
try {
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
url.searchParams.delete('code');
@@ -64,48 +42,25 @@ function signUrlChanged(prev: string | null, current: string): boolean {
return prev !== current;
}
function clearLegacySignUrlCache(): void {
try {
sessionStorage.removeItem(LEGACY_SIGN_URL_CACHE_KEY);
} catch {
/* ignore */
}
}
/**
* 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次;保留 code/state)。
* Android / 非微信环境为 no-op。
*/
/** 记录最近一次签名 URL;SPA 路由或 ?step= 变化时须重新 wx.config */
export function captureIosJssdkEntryUrl(): void {
if (typeof window === 'undefined') return;
if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return;
clearLegacySignUrlCache();
if (iosEntryUrl) return;
iosEntryUrl = normalizeJssdkPageUrl(window.location.href, { keepOAuthQuery: true });
}
/** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL(含 OAuth query */
export function getJssdkSignUrl(rawUrl?: string): string {
if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) {
captureIosJssdkEntryUrl();
if (iosEntryUrl) return iosEntryUrl;
if (!isWechatBrowser() || isWechatDevTools()) return;
const current = normalizeJssdkPageUrl(window.location.href);
const existing = sessionStorage.getItem(SIGN_URL_CACHE_KEY);
if (!existing) {
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
return;
}
if (signUrlChanged(existing, current)) {
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
resetJssdkConfig();
}
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();
/** 获取参与 JSSDK 签名的 URL(始终为当前页完整 URL,含 query) */
export function getJssdkSignUrl(rawUrl?: string): string {
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''));
}
function isJssdkDebugEnabled(): boolean {
@@ -121,8 +76,6 @@ export function stripOAuthParamsFromLocation(): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
// iOS:须先锁定入场 URL(含 code/state),再 replaceState
captureIosJssdkEntryUrl();
url.searchParams.delete('code');
url.searchParams.delete('state');
const query = url.searchParams.toString();
@@ -173,7 +126,7 @@ export async function initWechatJssdk(options: {
}): Promise<void> {
captureIosJssdkEntryUrl();
const { apiBase, clientApp, getAccessToken } = options;
const pageUrl = getJssdkSignUrl(options.url);
const pageUrl = options.url ?? getJssdkSignUrl();
await loadScript();
if (!window.wx) throw new Error('微信 JSSDK 不可用');
@@ -211,7 +164,7 @@ export async function ensureJssdkReady(options: {
jsApiList?: string[];
}): Promise<void> {
captureIosJssdkEntryUrl();
const pageUrl = getJssdkSignUrl(options.url);
const pageUrl = options.url ?? getJssdkSignUrl();
if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) {
resetJssdkConfig();
}
@@ -83,12 +83,17 @@ export class WechatController {
return this.wechat.createJssdkConfig(pageUrl, actorRef);
}
/**
* 仅去 hash。勿剔除 code/state
* iOS 微信用「document 入场 URL」验签,OAuth 回跳页的 query 必须原样参与签名。
*/
private normalizeJssdkUrl(rawUrl: string): string {
return rawUrl.split('#')[0];
try {
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')
+1 -22
View File
@@ -34,28 +34,7 @@
- 核销记录;今日汇总;到账金额×60%展示
- 营业状态开关;Mine 门店信息
- 套餐:列表编辑→提交 HQ 审核(v3.4.10)
- 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。
- iOS 微信:OAuth 后自动续扫(v3.4.13
## 4. 合伙人端(h5-partner