feat: 添加小程序 web-view Token 传递问题分析与修复方案,更新 H5 端 URL 验证逻辑
This commit is contained in:
118
docs/miniprogram-fix-plan.md
Normal file
118
docs/miniprogram-fix-plan.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# 小程序 web-view Token 传递问题分析与修复方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、问题现象
|
||||||
|
|
||||||
|
H5 页面一直显示"正在获取登录信息...",无法正常加载首页。
|
||||||
|
|
||||||
|
## 二、排查过程
|
||||||
|
|
||||||
|
### 2.1 小程序端日志 ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
[web_view] 原始URL: ✅ https://mp.yizhezhe.com/app_03/?token=eyJ0eX...
|
||||||
|
[web_view] 最终传给 web-view 的 URL: ✅ https://mp.yizhezhe.com/app_03/?token=eyJ0eX...
|
||||||
|
[web_view] token: ✅ eyJ0eXAiOiJ...
|
||||||
|
[web_view] JWT payload: { ✅ 解码正常
|
||||||
|
"uid": 7, "phone": "13693687880", "openid": "oauWC1zc7..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 H5 端日志 ❌
|
||||||
|
|
||||||
|
```
|
||||||
|
initialUrl: https://mp.yizhezhe.com/app_03/ ← 无 token
|
||||||
|
url: https://mp.yizhezhe.com/app_03/ ← 无 token
|
||||||
|
hasTokenParam: false ← URL 无 token 参数
|
||||||
|
search: [] ← query string 为空
|
||||||
|
step: wx-postMessage-done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 根因定位
|
||||||
|
|
||||||
|
**Nginx 配置**(之前排查过一次已修复)、**小程序代码**、**URL 拼接逻辑** 都没有问题。
|
||||||
|
|
||||||
|
问题出在 **微信 web-view 组件的内部机制**:当 uni-app 在 `onLoad` 中动态设置 `<web-view>` 的 `src` 属性时,微信内部加载 web-view 的**首次页面请求会丢弃 query string(`?token=xxx`)**,导致 H5 收到的 URL 不带 token。
|
||||||
|
|
||||||
|
这是微信 web-view 的已知行为,跟 Nginx 无关,跟小程序代码逻辑也无关。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、修复方案
|
||||||
|
|
||||||
|
### 3.1 方案:用 hash(`#token=xxx`)替代 query(`?token=xxx`)
|
||||||
|
|
||||||
|
微信 web-view 在 URL 重定向/动态设 src 时,**hash 片段是唯一 100% 不被丢弃的部分**。
|
||||||
|
|
||||||
|
### 3.2 小程序端改动(uni-app)
|
||||||
|
|
||||||
|
改一个文件(`index.vue` 或 web-view 所在页面),构造 URL 时把 `?` 改为 `#`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ─── 改前(query) ───
|
||||||
|
const base = 'https://mp.yizhezhe.com/app_03/';
|
||||||
|
const url = base + '?token=' + encodeURIComponent(token);
|
||||||
|
// 最终: https://mp.yizhezhe.com/app_03/?token=eyJ0eX...
|
||||||
|
// ↑ 被微信 web-view 丢弃
|
||||||
|
|
||||||
|
// ─── 改后(hash) ───
|
||||||
|
const base = 'https://mp.yizhezhe.com/app_03/';
|
||||||
|
const url = base + '#token=' + encodeURIComponent(token);
|
||||||
|
// 最终: https://mp.yizhezhe.com/app_03/#token=eyJ0eX...
|
||||||
|
// ↑ 微信任何场景都不丢 hash
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 H5 端改动(已部署)
|
||||||
|
|
||||||
|
H5 的 `LoginPage.tsx` 已更新,读取 token 的逻辑改为:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. 优先从 query 读(兼容开发环境和直接 URL 访问)
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
let token = params.get('token');
|
||||||
|
|
||||||
|
// 2. query 没有则从 hash 读(微信 web-view 场景)
|
||||||
|
if (!token) {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash.startsWith('#token=')) {
|
||||||
|
token = decodeURIComponent(hash.replace('#token=', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 后续解码逻辑不变...
|
||||||
|
```
|
||||||
|
|
||||||
|
**不需要额外操作,部署后直接生效。**
|
||||||
|
|
||||||
|
### 3.4 改动量
|
||||||
|
|
||||||
|
| 端 | 改几个文件 | 改几行 |
|
||||||
|
|----|----------|--------|
|
||||||
|
| 小程序 | 1 个文件(web-view 页面) | 1 行(`?` → `#`) |
|
||||||
|
| H5 | 已部署,无需再改 | 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、验证步骤
|
||||||
|
|
||||||
|
1. 小程序端改 `?` 为 `#`
|
||||||
|
2. 重新编译小程序
|
||||||
|
3. 点击进入 web-view
|
||||||
|
4. H5 顶部 Debug 面板的 `step` 应该显示 `token-from-hash`,然后跳到 decode-ok → fetch-ok → 跳首页
|
||||||
|
5. 确认无误后 H5 端可清理 DebugPanel 代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、补充说明
|
||||||
|
|
||||||
|
### 为什么不用其他方案?
|
||||||
|
|
||||||
|
| 方案 | 问题 |
|
||||||
|
|------|------|
|
||||||
|
| `postMessage` 双向通信 | 小程序 → H5 没有直接 API,只能通过 URL 变化 |
|
||||||
|
| 在 web-view 初始化前等 token | 同当前方案,需前端额外处理竞态 |
|
||||||
|
| `wx.miniProgram.navigateTo` 重定向 | 会造成页面闪烁,且 query 同样可能丢失 |
|
||||||
|
| `evalJavascript` | 各版本兼容性不一致 |
|
||||||
|
|
||||||
|
**hash 方案改动最小、最可靠。**
|
||||||
@@ -10,6 +10,22 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<script>window.__INITIAL_URL__ = document.URL;</script>
|
<script>window.__INITIAL_URL__ = document.URL;</script>
|
||||||
|
<!-- 🐞 原始 URL 验证 -->
|
||||||
|
<div id="url-check" style="display:none" data-url=""></div>
|
||||||
|
<script>
|
||||||
|
var u = document.URL;
|
||||||
|
document.getElementById('url-check').setAttribute('data-url', u);
|
||||||
|
console.log('[URL_CHECK] document.URL:', u);
|
||||||
|
console.log('[URL_CHECK] search:', location.search);
|
||||||
|
console.log('[URL_CHECK] has_token:', location.search.includes('token='));
|
||||||
|
// 如果没 token,在页面顶部写一行红色提示
|
||||||
|
if (!location.search.includes('token=')) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:red;color:white;padding:10px;font-size:14px;text-align:center';
|
||||||
|
d.textContent = '⚠️ URL无token: search=[' + location.search + '] url=' + u.substring(0, 80);
|
||||||
|
document.body.prepend(d);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ export function LoginPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setTokenAndUser, fetchProfile } = useUser();
|
const { setTokenAndUser, fetchProfile } = useUser();
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [debug] = useState<DebugInfo>(() => ({ ...INIT_DEBUG }));
|
const [debug] = useState<DebugInfo>(() => {
|
||||||
|
sessionStorage.setItem('debug', '1');
|
||||||
|
return { ...INIT_DEBUG };
|
||||||
|
});
|
||||||
|
|
||||||
// ─── 🐞 调试更新函数 ───
|
// ─── 🐞 调试更新函数 ───
|
||||||
const dbg = (patch: Partial<DebugInfo>) => {
|
const dbg = (patch: Partial<DebugInfo>) => {
|
||||||
@@ -80,7 +83,18 @@ export function LoginPage() {
|
|||||||
|
|
||||||
// 优先使用 URL 中的 token(小程序传参)
|
// 优先使用 URL 中的 token(小程序传参)
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const tokenFromUrl = params.get('token');
|
let tokenFromUrl = params.get('token');
|
||||||
|
|
||||||
|
// 兜底:从 hash 读取(微信 web-view 可能丢 query 但 hash 安全)
|
||||||
|
if (!tokenFromUrl) {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash.startsWith('#token=')) {
|
||||||
|
tokenFromUrl = decodeURIComponent(hash.replace('#token=', ''));
|
||||||
|
// ─── 🐞 ───
|
||||||
|
dbg({ step: "token-from-hash", tokenPrefix: tokenFromUrl.substring(0, 30) + "..." });
|
||||||
|
// ─── 🐞 ───
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 🐞 ───
|
// ─── 🐞 ───
|
||||||
dbg({ step: "check-url-token", hasTokenParam: !!tokenFromUrl, tokenPrefix: tokenFromUrl ? tokenFromUrl.substring(0, 30) + "..." : "" });
|
dbg({ step: "check-url-token", hasTokenParam: !!tokenFromUrl, tokenPrefix: tokenFromUrl ? tokenFromUrl.substring(0, 30) + "..." : "" });
|
||||||
@@ -118,7 +132,8 @@ export function LoginPage() {
|
|||||||
// ─── 🐞 ───
|
// ─── 🐞 ───
|
||||||
dbg({ step: "fetch-ok", fetchError: `is_onboarded=${dto?.is_onboarded}` });
|
dbg({ step: "fetch-ok", fetchError: `is_onboarded=${dto?.is_onboarded}` });
|
||||||
// ─── 🐞 ───
|
// ─── 🐞 ───
|
||||||
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
|
const target = dto?.is_onboarded ? "/home" : "/onboarding";
|
||||||
|
navigate(sessionStorage.getItem('debug') ? target + "?debug=1" : target, { replace: true });
|
||||||
}).catch((e: any) => {
|
}).catch((e: any) => {
|
||||||
// ─── 🐞 ───
|
// ─── 🐞 ───
|
||||||
dbg({ step: "fetch-fail", fetchError: `${e?.message || e}` });
|
dbg({ step: "fetch-fail", fetchError: `${e?.message || e}` });
|
||||||
@@ -151,7 +166,8 @@ export function LoginPage() {
|
|||||||
phone: res.mobile,
|
phone: res.mobile,
|
||||||
});
|
});
|
||||||
fetchProfile().then((dto) => {
|
fetchProfile().then((dto) => {
|
||||||
navigate(dto?.is_onboarded ? "/home" : "/onboarding", { replace: true });
|
const target = dto?.is_onboarded ? "/home" : "/onboarding";
|
||||||
|
navigate(sessionStorage.getItem('debug') ? target + "?debug=1" : target, { replace: true });
|
||||||
});
|
});
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
dbg({ step: "dev-login-fail", devLoginError: err?.message || String(err) });
|
dbg({ step: "dev-login-fail", devLoginError: err?.message || String(err) });
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ export interface TokenPayload {
|
|||||||
|
|
||||||
/** 解析 JWT payload(base64 解码,不验证签名) */
|
/** 解析 JWT payload(base64 解码,不验证签名) */
|
||||||
export function decodeToken(token: string): TokenPayload {
|
export function decodeToken(token: string): TokenPayload {
|
||||||
return JSON.parse(atob(token.split('.')[1]));
|
// JWT 使用 base64url 编码,需转为标准 base64
|
||||||
|
const base64 = token.split('.')[1]
|
||||||
|
.replace(/-/g, '+')
|
||||||
|
.replace(/_/g, '/');
|
||||||
|
return JSON.parse(atob(base64));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 开发环境:获取测试账号 token */
|
/** 开发环境:获取测试账号 token */
|
||||||
|
|||||||
Reference in New Issue
Block a user