Files
mama-box/js/wx-auth.js
Karlcarlo 06f76b4df4 feat(身份): 同源身份缓存兜底 —— H5「返回首页」裸跳不再丢 openid/unionid (v26092212)
问题(实测孕检日历,第三方应用):页面里的「返回首页」是**页面自己发起的同源跳转**,
宿主的 <web-view src> 属性没变 → _applyIdentity() 不触发 → mama-box 的 URL 上没有身份
→ 只能显示「身份不完整」;而 retryIdentity() 只是 location.replace(自己 + 缓存戳),
同一条没有身份的 URL 重载,**永远救不回来**。而且该应用会主动把 openid/phone 从自己的
URL 上擦掉(防分享链接冒名,理由见它的注释)—— 所以那一跳**不可能**带上身份。

做法(方案已与产品确认;在架 12 个应用实测全在 mp.yizhezhe.com,同源共享 storage):
- js/wx-auth.js:新增 identity()(URL 优先 → 同源 sessionStorage 兜底,命中缓存打一条
  日志便于排查)、rememberIdentity()、goHome();并在**库载入时自动采一次**身份 ——
  接入方无需改代码,只要引了本库(孕检日历就引了,且它是绝对地址 + ETag 兜底,
  我们改库即可自动生效),宿主注入过的身份就会被存下来。
- js/app.js:hashParam() 对 openid/unionid 两键加同样的缓存回落(其余参数不受影响)。

三条纪律(写在 wx-auth.js 顶部,改前先读):
① URL 永远权威:URL 带 openid 就无条件覆盖缓存(否则切号/换人会串号);
② 只在本 origin 有效:外链应用读不到,所以「跳转要带身份」的规范不能删,缓存只兜底;
③ 只存 openid/unionid(身份键),不碰 token/手机号/昵称头像。
选 sessionStorage 而非 localStorage:同一次会话内够用,且随标签页结束即失效,暴露窗口小。

验证(本地同源:/app 模拟第三方页 + /mama-box 同一 origin):
  模拟应用页自动写缓存 → 裸跳 /mama-box/ → 提示条不显示、日志出现、按缓存身份拉数据;
  回归:无缓存且无身份 → 提示条照旧显示(fail-closed 保留);URL 带身份 → 缓存被写下且不显示提示条。
2026-09-22 21:36:52 +08:00

308 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
wx-auth.js · H5 应用通用授权 + 跳商城 共享助手(各 H5 应用引用即可)
------------------------------------------------------------
依赖:页面引入 <script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
用法:在 H5 页面里
<script src="wx-auth.js"></script>
然后:
wxAuth.init(); // 检测环境 + 读取已授权资料(hash) + 回填
wxAuth.isAuthorized(); // 是否已授权(手机号/昵称/头像任一非空)
wxAuth.requestProfile(); // 点「授权登录」→ postMessage 通知宿主页
wxAuth.goMall(); // 点「进商城」→ 跳小程序商城 tabBar 页
wxAuth.goHome(); // 点「返回首页」→ 回 mama-box(带身份;别裸写 location.href)
wxAuth.identity(); // 读 openid/unionid(URL 优先 → 同源缓存兜底)
wxAuth.watchLocation(); // 换页/路由切换自动上报当前位置 → 宿主刷新分享卡片(推荐)
wxAuth.reportLocation({title}); // 手动上报一次当前页(标题/图可选)
分享卡片同步协议(location/share/setTitle/copyLink/requestProfile)见
box_app/docs/h5-auth-standard.md 第四节
回传协议:宿主页授权成功后更新 web-view src 为
<H5地址>?t=<ts>#openid=..&phone=..&nickname=..&avatar=..
(微信 webview 丢 query、不丢 hash,故授权结果放 hash)
============================================================ */
(function (g) {
'use strict';
function readHash(name) {
try {
var h = location.hash.slice(1) + '&' + location.search.slice(1);
var m = h.match(new RegExp('(?:^|[&#])' + name + '=([^&#]*)(?:$|[&#])'));
return m ? decodeURIComponent(m[1]) : '';
} catch (e) { return ''; }
}
/* ============================================================
同源身份缓存 —— openid / unionid 的兜底通道(2026-09-22)
为什么需要:H5 应用里「返回首页」这类跳转是**页面自己发起的同源跳转**,
宿主的 <web-view src> 属性不变,所以宿主那条 _applyIdentity() 注入**不会触发**,
目标页(mama-box)拿到的 URL 里没有 openid → 只能显示「身份不完整」。
⚠️ 而且这一跳**永远不可能带上身份**:孕检日历那类应用会把 openid/phone
从自己的 URL 上主动擦掉(防分享链接冒名顶替,理由见它的注释)——
所以对第三方应用来说,这条缓存是**不改它们代码的唯一出路**。
做法:mp.yizhezhe.com 下所有应用与 mama-box 是**同源**(实测在架 12 个应用
全在该域名),共用一份 sessionStorage。宿主注入过的身份顺手存一份,
读取时 **URL 优先 → 缓存兜底**。
三条纪律(改这个文件前先读):
① **URL 永远权威**:只要 URL 里带 openid 就无条件覆盖缓存 ——
绝不做「有缓存就不读 URL」,否则切号/换人后会串号;
② **只在本 origin 内有效**:App 表允许填外链,外链应用读不到这份缓存,
所以「跳转要带身份」的规范不能删,缓存只是兜底;
③ **只存 openid/unionid**(服务端身份键),不碰 token / 手机号 / 昵称头像。
存 sessionStorage 而不是 localStorage:报告的场景都在同一次会话内,
sessionStorage 够用,且随标签页结束就没了,暴露窗口小得多。
============================================================ */
var ID_KEY = 'mmbox.identity';
var ID_TTL = 12 * 3600 * 1000; // 12h:sessionStorage 本就随会话结束,这层防的是长驻旧值
function rememberIdentity(oid, uid) {
if (!oid) return;
try {
sessionStorage.setItem(ID_KEY, JSON.stringify({ openid: oid, unionid: uid || '', at: Date.now() }));
} catch (e) { /* 隐私模式等:静默,不影响主流程 */ }
}
function cachedIdentity() {
try {
var c = JSON.parse(sessionStorage.getItem(ID_KEY) || 'null');
if (!c || !c.openid) return null;
if (c.at && (Date.now() - c.at) > ID_TTL) return null;
return c;
} catch (e) { return null; }
}
/* 读身份:URL 优先 → 缓存兜底。命中缓存时打一条日志(口径见接入规范第 5 节:
缓存会掩盖「宿主没注入身份」这个本来会显性提示的信号,所以留痕便于排查)。 */
function identity() {
/* ⚠️ 与 mama-box 的 hashParam 同款护栏:历史 bug 会把缓存戳粘进 openid
(abc001?t=123),真 openid 不含 ?,剥掉以兼容被污染的旧链接。 */
var oid = String(readHash('openid') || '').split('?')[0];
var uid = String(readHash('unionid') || '').split('?')[0];
if (oid) { rememberIdentity(oid, uid); return { openid: oid, unionid: uid, from: 'url' }; }
var c = cachedIdentity();
if (c) {
try {
console.log('[wxAuth] URL 未带身份 → 用同源缓存兜底:openid=' + c.openid +
(c.unionid ? ' unionid=' + c.unionid : '') + '(写入于 ' + new Date(c.at).toLocaleTimeString() + ')');
} catch (e) {}
return { openid: c.openid, unionid: c.unionid || '', from: 'cache' };
}
return { openid: '', unionid: '', from: 'none' };
}
/* 载入即采一次:**接入方不需要改任何代码** —— 只要页面引了本库,
宿主注入过的身份就会被存下来(孕检日历那类第三方应用正是靠这一条自己存下身份的)。 */
(function captureAtLoad() { try { identity(); } catch (e) {} })();
var wxAuth = {
MODE: 'browser', // 'webview' | 'wechat-browser' | 'browser'
inWebview: false,
onReady: null,
/* ① 环境检测 + 读回传资料(授权后宿主更新 src,本页重载读到 hash) */
init: function (cb) {
this.onReady = cb || null;
var self = this;
var isWechat = /MicroMessenger/i.test(navigator.userAgent);
function setMode(m) {
self.MODE = m; self.inWebview = (m === 'webview');
if (self.onReady) self.onReady(self);
}
if (window.wx && window.wx.miniProgram) {
var responded = false;
window.wx.miniProgram.getEnv(function (r) {
responded = true;
setMode(r && r.miniprogram ? 'webview' : (isWechat ? 'wechat-browser' : 'browser'));
});
setTimeout(function () { if (!responded) setMode(isWechat ? 'wechat-browser' : 'browser'); }, 800);
} else {
setMode(isWechat ? 'wechat-browser' : 'browser');
}
},
/* ② 读取授权结果(hash 里的 phone/nickname/avatar) */
readAuth: function () {
return { phone: readHash('phone'), nickname: readHash('nickname'), avatar: readHash('avatar') };
},
/* ③ 是否已授权 */
isAuthorized: function () {
var a = this.readAuth();
return !!(a.nickname || a.avatar || a.phone);
},
/* ④ 请求授权:navigateTo → 小程序端独立授权页(默认 pages/annex/auth/index)。
改动说明:微信 web-view 的 postMessage 非实时送达,点击时端上收不到;
改用 wx.miniProgram.navigateTo 实时跳转到授权页,避开该限制。 */
/* ④ 请求授权:环境判定(与 goMall/mama-box 同一套):
① UA 不含 MicroMessenger = 普通浏览器 → 直接提示(秒出,不等超时);
② 微信内 → getEnv 确认是否 webview(纯浏览器加载了 jweixin 时 getEnv
不回调,800ms 超时兜底)→ 是小程序才 navigateTo 授权页。 */
requestProfile: function (authPage) {
var self = this;
function nav() {
/* 把「发起授权的当前 H5 地址」经 navigateTo query 带给授权页(backUrl)——
与 openid 同一条可靠通道(navigateTo onLoad 必达),不依赖 postMessage
(微信只在后退/销毁/分享时批量派发,时序不可靠:宫格 location.href 换应用后,
宿主记的 shareHref 可能还是上一个应用的地址,授权结果被送回错的地方)。
授权页把 backUrl 写进 __AUTH_RESULT__,宿主 onShow 优先用它做回传基址:
从哪个 H5 触发的授权,授权后就回到哪个 H5。 */
var url = authPage || '/pages/annex/auth/index';
var backUrl = '';
try { backUrl = location.href; } catch (e) {}
if (backUrl) {
url += (url.indexOf('?') !== -1 ? '&' : '?') + 'backUrl=' + encodeURIComponent(backUrl);
}
window.wx.miniProgram.navigateTo({
url: url,
/* 页面不存在 / 页面栈超限(>10层) 时 navigateTo 是静默失败 —— 必须自己兜住并提示,
否则用户只看到「正在打开授权页…」后再无反应,无从排查。 */
fail: function (err) {
var m = (err && err.errMsg) || '未知原因';
self.toast('打开授权页失败:' + m);
try { console.warn('[wxAuth] navigateTo fail:', m); } catch (e) {}
}
});
self.toast('正在打开授权页…');
}
if (!/MicroMessenger/i.test(navigator.userAgent)) {
this.toast('请在小程序中完成授权登录');
return;
}
if (window.wx && window.wx.miniProgram) {
var responded = false;
window.wx.miniProgram.getEnv(function (r) {
responded = true;
if (r && r.miniprogram) nav();
else self.toast('请在小程序中完成授权登录');
});
setTimeout(function () {
if (!responded) self.toast('请在小程序中完成授权登录');
}, 800);
} else {
this.toast('请在小程序中完成授权登录');
}
},
/* ⑤ 跳小程序商城(tabBar 页用 switchTab;非 tabBar 用 navigateTo) */
/* ⑤ 跳商城:环境判定与 requestProfile 同一套 —— 仅小程序 webview 内 switchTab,
UA 非微信/微信内非 webview(或 getEnv 不回调)都提示「请在小程序中打开商城」。
默认落点 260918 起为「商城」tab(/pages/index/mall,原装装修首页恢复页),
与 app.js 的 goMall() 保持一致;此前是分类页 /pages/goods_cate/goods_cate。 */
goMall: function (url) {
var self = this;
function go() {
window.wx.miniProgram.switchTab({
url: url || '/pages/index/mall',
fail: function (err) {
var m = (err && err.errMsg) || '未知原因';
self.toast('打开商城失败:' + m);
try { console.warn('[wxAuth] switchTab fail:', m); } catch (e) {}
}
});
}
if (!/MicroMessenger/i.test(navigator.userAgent)) {
this.toast('请在小程序中打开商城');
return;
}
if (window.wx && window.wx.miniProgram) {
var responded = false;
window.wx.miniProgram.getEnv(function (r) {
responded = true;
if (r && r.miniprogram) go();
else self.toast('请在小程序中打开商城');
});
setTimeout(function () {
if (!responded) self.toast('请在小程序中打开商城');
}, 800);
} else {
this.toast('请在小程序中打开商城');
}
},
/* ⑥ 读当前身份(openid/unionid):URL 优先 → 同源缓存兜底。
返回 {openid, unionid, from:'url'|'cache'|'none'}。
用途:本页自己也需要 openid 时(请求后端、拼跳转链接)统一走它,
顺带把身份写进缓存 —— 别在各应用里各写一份读参数的代码。 */
identity: identity,
/* 手动把身份写进缓存(一般不用调:URL 带身份时 identity() 已自动写)。
场景:授权回传后身份只在 hash 里出现一次,若随后要清 URL(防分享泄露),
可先调它存一份。 */
rememberIdentity: rememberIdentity,
/* ⑦ 回 mama-box 首页(H5 方式)。带身份:URL 优先,其次缓存。
⚠️ 「返回首页」这类跳转**不要**裸写 location.href='/mama-box/' ——
宿主不会因此重新注入身份(web-view src 没变),目标页会显示「身份不完整」,
而重试也救不回来。要么调本方法,要么保证链接带上 openid/unionid。
(第三方应用改不了的话,靠 identity() 的缓存兜底也能让首页认出人来。) */
goHome: function () {
var id = identity();
var q = id.openid
? '?openid=' + encodeURIComponent(id.openid) + (id.unionid ? '&unionid=' + encodeURIComponent(id.unionid) : '')
: '';
location.href = 'https://mp.yizhezhe.com/mama-box/' + q;
},
/* ⑧ 展示授权结果到指定元素(可选便捷方法) */
fillAuth: function (nameEl, avatarEl) {
var a = this.readAuth();
if (nameEl && a.nickname) nameEl.textContent = a.nickname;
if (avatarEl && a.avatar) avatarEl.src = a.avatar;
},
/* ⑨ 上报"当前在哪一页"(分享状态同步,不弹 toast、可频繁调用)。
为什么需要:web-view 里的 H5 不能自己拉起分享面板,转发卡片由小程序宿主页产出;
宿主只有 onShareAppMessage(options).webViewUrl 能拿到当前地址,而
onShareTimeline(朋友圈)**没有回调参数**;首页壳又不会随 H5 换页重新 onLoad。
所以应用在「页面/路由切换」时上报一次 location,宿主即可刷新分享图/标题/深链。
协议:{action:'location', href, title?, summary?, imageUrl?} → 详见 docs/h5-auth-standard.md 第四节
opts.imageUrl 给了就以 H5 为准(仅当前应用);不给则用 box_app 后台配置的分享图。 */
reportLocation: function (opts) {
opts = opts || {};
if (!(window.wx && window.wx.miniProgram && window.wx.miniProgram.postMessage)) return false;
try {
window.wx.miniProgram.postMessage({
data: {
action: 'location',
href: opts.href || location.href, // 可传 href:用于"即将跳转到的地址"上报
title: opts.title || '',
summary: opts.summary || '',
imageUrl: opts.imageUrl || ''
}
});
return true;
} catch (e) {
try { console.warn('[wxAuth] reportLocation fail:', e); } catch (e2) {}
return false;
}
},
/* ⑧ 自动监听页面切换并上报(首屏一次 + hashchange/popstate + pushState/replaceState)。
用法(推荐在 init 回调里调用):
wxAuth.watchLocation(function () {
return { title: document.title }; // 可选:本页分享标题/图
});
同一 href 只会发一次(内部去重),SPA 与多页应用都适用。 */
watchLocation: function (getShare) {
var self = this;
var last = '';
function pull() {
if (location.href === last) return;
last = location.href;
var s = (typeof getShare === 'function') ? (getShare() || {}) : {};
self.reportLocation(s);
}
pull(); // 首屏
if (g.addEventListener) {
g.addEventListener('hashchange', pull);
g.addEventListener('popstate', pull);
}
/* history.pushState/replaceState 不触发上面两个事件,需要包一层 */
['pushState', 'replaceState'].forEach(function (fn) {
var orig = g.history && g.history[fn];
if (typeof orig !== 'function') return;
g.history[fn] = function () {
var r = orig.apply(this, arguments);
setTimeout(pull, 0);
return r;
};
});
return self;
},
toast: function (m) {
var t = document.getElementById('toast');
if (!t) return;
t.textContent = m; t.classList.add('show');
clearTimeout(t._tt); t._tt = setTimeout(function () { t.classList.remove('show'); }, 1800);
}
};
g.wxAuth = wxAuth;
})(window);