From a6340250f2cbf14f354e0717287792c01ec706db Mon Sep 17 00:00:00 2001 From: Karlcarlo Date: Mon, 26 Oct 2026 20:04:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(App):=20=E5=AE=BF=E4=B8=BB=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E7=9F=AD=E4=BF=A1=E7=99=BB=E5=BD=95=20+=20app=5Fident?= =?UTF-8?q?ity/app=5Fbind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mixin 新增 _appIdentityFlow:有 token → GET wechat/app_identity; 无 openid → uni.login(weixin) → POST wechat/app_bind → 再取身份 - _silenceAuthOpenid 在 APP-PLUS 下改走该流程(不碰 wx.login) - 首页壳/容器页:未登录时停止自动重试,兜底屏给「登录后继续」; 登录页成功后 reLaunch 回原页(首页 / 容器页带原 url) - App 的 requestProfile 改为直接查共享资料并回传 hash(不弹面板、 不调 getPhoneNumber,规避 web-view 覆盖页内浮层) - api/user.js 新增 appIdentity / appBind Co-authored-by: Cursor --- api/user.js | 13 ++++ mixins/webviewHost.js | 114 +++++++++++++++++++++++++++++++++ pages/annex/web_view/index.vue | 41 ++++++++++-- pages/index/index.vue | 39 +++++++++-- 4 files changed, 197 insertions(+), 10 deletions(-) diff --git a/api/user.js b/api/user.js index ff3a993..9d1e797 100644 --- a/api/user.js +++ b/api/user.js @@ -900,4 +900,17 @@ export function channelApplyInfoApi() { // 采购商切换身份 export function checkIdentityApi(data) { return request.post(`user/switch_identity`, data); +} + +/* ---------- App 身份(2026-10-26,Android 一期) ---------- + 宿主在短信登录后调用: + appIdentity() 取规范 openid(routine 优先 → app → unionid 反查) + appBind({ code }) 新用户:uni.login 拿 code → 绑定到当前 uid + 两个接口都要求登录态(request 自动带 Authori-zation)。 */ +export function appIdentity() { + return request.get('wechat/app_identity'); +} + +export function appBind(data) { + return request.post('wechat/app_bind', data); } \ No newline at end of file diff --git a/mixins/webviewHost.js b/mixins/webviewHost.js index 3831c73..8d4ccc3 100644 --- a/mixins/webviewHost.js +++ b/mixins/webviewHost.js @@ -132,7 +132,78 @@ export default { {auth_login:1, openid, unionid}(新用户,无 token)或 {token, ...}(老用户) 两端都算成功,只要拿到 openid 就落到 store + 缓存。 返回 Promise<{openid, unionid}>;拿不到就 resolve 空串,由调用方决定是否匿名加载。 */ + /* ============================================================ + App(Android 一期)身份链路 —— 2026-10-26 + ------------------------------------------------------------ + App 没有 wx.login / openid;本地身份来源是「短信登录后的 uid」: + ① 有 token → GET /api/wechat/app_identity(routine openid 优先) + ② 没有 openid(全新用户)→ uni.login({provider:'weixin'}) 拿 code + → POST /api/wechat/app_bind 绑定到当前 uid → 再取 app_identity + ③ 没装微信/拒绝授权 → 返回空,页面给「登录后继续 / 重试」 + ⚠️ 不在启动时弹微信:只有走到这里(且确实没有 openid)才授权。 + ============================================================ */ + _appIdentityFlow() { + const needLogin = { openid: '', unionid: '', needLogin: true }; + const empty = { openid: '', unionid: '', needLogin: false }; + let hasToken = false; + try { hasToken = !!(store.state.app.token || uni.getStorageSync(LOGIN_STATUS)); } catch (e) { hasToken = !!store.state.app.token; } + if (!hasToken) return Promise.resolve(needLogin); + + const save = (openid, unionid) => { + if (!openid) return empty; + try { store.commit('SET_OPENID', openid); uni.setStorageSync('OPENID', openid); } catch (e) {} + if (unionid) { + try { store.commit('SET_UNIONID', unionid); uni.setStorageSync('UNIONID', unionid); } catch (e) {} + } + return { openid: openid, unionid: unionid || '', needLogin: false }; + }; + const read = (res) => { + const d = (res && res.data) || {}; + return { openid: d.openid || '', unionid: d.unionid || '', source: d.source || '' }; + }; + return import('@/api/user.js').then((api) => { + return api.appIdentity().then((res) => { + const id = read(res); + console.log('[auth-flow] App app_identity: openid=' + (id.openid ? '有' : '无') + ' source=' + (id.source || '?')); + if (id.openid) return save(id.openid, id.unionid); + return this._weixinLoginCode().then((code) => { + if (!code) { + console.warn('[auth-flow] App:未拿到微信 code(未安装微信/拒绝授权)'); + return empty; + } + return api.appBind({ code: code }).then(() => { + return api.appIdentity().then((r2) => { + const id2 = read(r2); + console.log('[auth-flow] App app_bind 后身份: openid=' + (id2.openid ? '有' : '无')); + return save(id2.openid, id2.unionid); + }); + }); + }); + }); + }).catch((err) => { + console.warn('[auth-flow] App 身份解析失败:', err && (err.message || err)); + return empty; + }); + }, + _weixinLoginCode() { + return new Promise((resolve) => { + // #ifdef APP-PLUS + uni.login({ + provider: 'weixin', + success: (r) => resolve((r && r.code) || ''), + fail: (e) => { console.warn('[auth-flow] uni.login(weixin) 失败:', e); resolve(''); } + }); + // #endif + // #ifndef APP-PLUS + resolve(''); + // #endif + }); + }, _silenceAuthOpenid() { + // #ifdef APP-PLUS + /* App:走短信登录 + app_identity / app_bind(不碰 wx.login) */ + return this._appIdentityFlow(); + // #endif const empty = { openid: '', unionid: '' }; return new Promise((resolve) => { /* ⚠️ 保险丝:wx.login / 接口有可能长时间不回调(弱网、域名没配好)。 @@ -253,11 +324,18 @@ export default { break; } case 'requestProfile': + /* App:没有小程序授权页,也不该弹页内面板(web-view 是原生组件、会盖住)。 + 直接用当前 openid 查共享资料,把 hash 写回 web-view 即可。 */ + // #ifdef APP-PLUS + this._pushAppProfile(m.href); + // #endif + // #ifndef APP-PLUS this.showAuth = true; /* 面板回传基址:H5 在消息里带 href(发起面板时的当前地址)→ 提交后 按它回传。消息送达=面板弹出,href 同一条消息到达,确定性强于 赌 shareHref 的 postMessage 派发时机(宫格换应用后可能还是旧地址)。 */ if (m.href) this._panelBaseUrl = m.href; + // #endif break; case 'setTitle': if (m.title) { @@ -312,6 +390,42 @@ export default { '&avatar=' + encodeURIComponent(r.avatar || ''); }, + /* App:H5 发 requestProfile 时,直接用当前 openid 查共享资料并回传 hash。 + —— 不弹面板、不调 getPhoneNumber(短信登录已验证手机号); + —— 也绕开「App 的 web-view 是原生组件、页内面板会被盖住」的问题。 */ + _pushAppProfile(href) { + const openid = this._hostOpenid(); + if (!openid) return uni.showToast({ title: '未获得 openid,请先登录', icon: 'none' }); + const current = (typeof this._hostCurrentUrl === 'function') ? this._hostCurrentUrl() : ''; + const base = String(href || this.shareHref || current || '').split('#')[0]; + if (!base) return; + uni.showLoading({ title: '获取资料中', mask: true }); + uni.request({ + url: SHARED_AUTH_API + '?openid=' + encodeURIComponent(openid), + method: 'GET', + timeout: 10000, + success: (r) => { + uni.hideLoading(); + const d = r.data || {}; + if (d.code === 200) { + const res = d.result || {}; + this._hostSetWebviewUrl(this.hostAuthUrl(base, { + openid: openid, + phone: res.phone || '', + nickname: res.userName || '', + avatar: res.avatar || '' + })); + } else { + uni.showToast({ title: d.message || '资料获取失败', icon: 'none' }); + } + }, + fail: () => { + uni.hideLoading(); + uni.showToast({ title: '资料请求失败,请检查网络', icon: 'none' }); + } + }); + }, + /** * 读取独立授权页(pages/annex/auth/index)写下的 __AUTH_RESULT__。 * 该页成功后 navigateBack 回到宿主页 —— 两个宿主页都必须在 onShow 调用本方法, diff --git a/pages/annex/web_view/index.vue b/pages/annex/web_view/index.vue index 990cd6e..a507f1a 100644 --- a/pages/annex/web_view/index.vue +++ b/pages/annex/web_view/index.vue @@ -8,10 +8,11 @@ 以前 url 为空时这里是白屏(没有任何占位),所以必须给一块可见的屏, 否则「不加载」会变成「白屏 + 用户不知道该干嘛」。 --> - {{ identityFail ? '🥲' : '🍼' }} - {{ identityFail ? '未获取到微信身份' : '正在获取微信身份' }} - {{ identityFail ? '请检查网络后点下方重试,或退出小程序重新进入' : '马上就好,请稍候(不会加载空数据的页面)' }} - + {{ appNeedLogin ? '🔐' : (identityFail ? '🥲' : '🍼') }} + {{ appNeedLogin ? '登录后继续' : (identityFail ? '未获取到微信身份' : '正在获取微信身份') }} + {{ appNeedLogin ? '使用手机号验证码登录;登录后会获取微信身份并自动打开该应用' : (identityFail ? '请检查网络后点下方重试,或退出小程序重新进入' : '马上就好,请稍候(不会加载空数据的页面)') }} + + @@ -156,6 +157,7 @@ export default { savedUrl: '', identityPending: false, // 正在获取 openid(此期间**不加载 H5**) identityFail: false, // 多次仍未拿到 openid → 停在兜底屏,给「重新获取身份」入口 + appNeedLogin: false, // App:未登录 → 兜底屏给「登录后继续」 _idAttempts: 0, // 自动重试计数 _pendingTarget: '' // 重试时要加载的目标 URL(重试按钮用) } @@ -378,9 +380,23 @@ export default { console.log('[auth-flow] 🔑 容器页取 openid(第 ' + this._idAttempts + '/' + MAX + ' 次),此期间不加载 H5'); /* 2) 第 1 次先等 App.vue onLaunch 那次静默登录(同一次 exchange,别重复换 code); 之后的重试才自己 wx.login + 换 openid。理由与首页壳一致,见 mixins 的 _awaitHostLogin。 */ - const task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid(); - task.then(() => { + let task; + // #ifdef APP-PLUS + task = this._silenceAuthOpenid(); + // #endif + // #ifndef APP-PLUS + task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid(); + // #endif + task.then((r) => { if (this._loadWithCachedIdentity(t)) return; + /* App 未登录:停止自动重试,兜底屏给「登录后继续」 */ + if (r && r.needLogin) { + this.identityPending = false; + this.identityFail = false; + this.appNeedLogin = true; + console.log('[auth-flow] App 未登录(容器页)→ 等待用户登录'); + return; + } if (this._idAttempts < MAX) { setTimeout(() => this._ensureIdentityThenLoad(t), 500); // 退避固定 500ms(原 600×n) return; @@ -396,6 +412,7 @@ export default { if (!cached.openid) return false; this.identityPending = false; this.identityFail = false; + this.appNeedLogin = false; this._idAttempts = 0; this._applyIdentity(t, cached.openid, cached.unionid, getUrlParam(t, 'openid')); return true; @@ -404,9 +421,21 @@ export default { retryIdentity() { this._idAttempts = 0; this.identityFail = false; + this.appNeedLogin = false; this._ensureIdentityThenLoad(this._pendingTarget || this.savedUrl || this.url); }, + /* App:去登录;登录成功 reLaunch 回容器页(带原目标 URL) */ + goAppLogin() { + const back = this._pendingTarget || this.savedUrl || this.url || ''; + try { + /* 必须走 $Cache.set(loginMobile 读的是 $Cache.get(BACK_URL)) */ + this.$Cache.set('login_back_url', + back ? '/pages/annex/web_view/index?url=' + encodeURIComponent(back) : '/pages/index/index'); + } catch (e) {} + uni.navigateTo({ url: '/pages/users/login/index' }); + }, + _applyIdentity(targetUrl, openid, unionid, urlOpenid) { console.log('[auth-flow] _applyIdentity: openid=' + (openid || '(空)') + ', unionid=' + (unionid || '(空)') + ', urlOpenid=' + (urlOpenid || '(无)')); if (urlOpenid && urlOpenid !== openid) { diff --git a/pages/index/index.vue b/pages/index/index.vue index 1d48ee2..1dce871 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -32,7 +32,8 @@ {{ fallbackTitle }} {{ fallbackDesc }} - + + @@ -122,19 +123,21 @@ 别让用户对着「正在加载」干等却不知道在等什么。 */ fallbackTitle() { if (this.loadError) return '页面加载失败'; + if (this.appNeedLogin) return '登录后继续'; if (this.identityFail) return '未获取到微信身份'; if (this.identityPending) return '正在获取微信身份'; return '正在加载'; }, fallbackDesc() { if (this.loadError) return '请检查网络后重试'; + if (this.appNeedLogin) return '使用手机号验证码登录;登录后会获取微信身份并自动进入首页'; if (this.identityFail) return '请检查网络后点下方重试,或退出小程序重新进入'; if (this.identityPending) return '马上就好,请稍候(不会加载空数据的页面)'; if (this.restoring) return '正在回到你上次使用的应用'; return '正在为你打开「兜爸心选」'; }, fallbackEmoji() { - return (this.loadError || this.identityFail) ? '🥲' : '🍼'; + return this.appNeedLogin ? '🔐' : ((this.loadError || this.identityFail) ? '🥲' : '🍼'); }, }, data() { @@ -150,6 +153,7 @@ _noRestore: false, // 本次加载 mama-box 是否要带 #norestore=1(禁止 H5 兜底再把用户送回应用) identityPending: false, // 正在获取 openid(此期间**不加载 H5**,见 fetchIdentityThenLoad) identityFail: false, // 多次仍未拿到 openid → 停在兜底屏,给「重新获取身份」入口 + appNeedLogin: false, // App:没登录(或登录后仍无身份)→ 兜底屏给「登录后继续」 shareInfo: {}, // CRMEB 后台「分享设置」(标题/图,无 App 匹配时兜底) // 授权面板字段由 mixins/webviewHost.js 提供: // showAuth / authNickname / authAvatar / _authCode @@ -620,9 +624,25 @@ 它先落地就直接加载 —— 避免两边各换一次 code 白花 200–300ms (微信 code 一次性;实测 code2session 约 200–300ms)。 之后的重试才自己 wx.login + 换 openid(_silenceAuthOpenid)。 */ - const task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid(); - task.then(() => { + let task; + // #ifdef APP-PLUS + /* App:没有并行的 wx.login,直接走「短信登录态 + app_identity/app_bind」 */ + task = this._silenceAuthOpenid(); + // #endif + // #ifndef APP-PLUS + task = (seq === 0) ? this._awaitHostLogin(1500) : this._silenceAuthOpenid(); + // #endif + task.then((r) => { if (this._loadWithCachedIdentity(base)) return; + /* App 未登录:**停止自动重试**,兜底屏给「登录后继续」按钮 —— + 没有 token 时重试多少次都拿不到身份,继续循环只是让用户干等。 */ + if (r && r.needLogin) { + this.identityPending = false; + this.identityFail = false; + this.appNeedLogin = true; + console.log('[auth-flow] App 未登录 → 等待用户登录(不自动重试)'); + return; + } if (this._idAttempts < MAX) { /* 退避固定 500ms(原来 600×n,最坏 3 次 + 1.8s 退避 ≈ 3.5s 才出失败屏; 现在 2 次 + 0.5s ≈ 1.5s 量级) */ @@ -639,6 +659,7 @@ if (!this._hostOpenid()) return false; this.identityPending = false; this.identityFail = false; + this.appNeedLogin = false; this._idAttempts = 0; this._shownOnce = true; // 沿用本次的 _noRestore(见 loadMamaBox 注释):本轮加载是否需要禁止 mama-box 兜底恢复 @@ -697,6 +718,7 @@ this.requesting = false; this._shownOnce = true; this.identityFail = false; + this.appNeedLogin = false; /* 有身份直接重载;没有就再走一遍「取到才加载」(重置尝试次数,自动重试 3 次) ⚠️ 沿用 this._noRestore:重试首页时同样不许首页 H5 兜底恢复(见 loadMamaBox)。 */ const homeUrl = this._homeBase(); @@ -709,6 +731,15 @@ this.fetchIdentityThenLoad(homeUrl); }, + /* App:去登录。登录页成功后会 reLaunch 回 login_back_url(这里显式设成本页), + 回到首页再走一遍「短信登录态 → app_identity → app_bind」。 */ + goAppLogin() { + /* ⚠️ 必须走 $Cache.set:loginMobile 用 $Cache.get(BACK_URL) 读, + 裸 uni.setStorageSync 会因缺少过期标记被当成「无值」覆盖掉。 */ + try { this.$Cache.set('login_back_url', '/pages/index/index'); } catch (e) {} + uni.navigateTo({ url: '/pages/users/login/index' }); + }, + /* 授权完善资料(postMessage → 原生面板 → 共享授权接口 → hash 回传)与 pages/annex/web_view 共用 mixins/webviewHost.js: handleHostMessage / onChooseHostAvatar / onHostGetPhone /