// xuchao: 登录层 —— 登录逻辑全部隔离在这里 // 正式流程(公众号菜单直链):openId 走公众号网页授权(OAuth)获取,其他身份数据由 order 解码(getUserInfo)解析 // - captureIdentity 冷启动抓 ?openId= / ?order= / ?code=(授权回调)入缓存 // - oauthAuthorize 无 openId 时跳公众号授权页,微信带回 code // - code2OpenId code 换 openId(POST h5-api/oauth2MeiKe),写缓存 openId // - loginByOrder POST open-api/meike/getUserInfo {code: order} 解码,写缓存 phone/channel 等(不覆盖已存在的 openId) // - loginByMock 开发模式:输入测试手机号直接登录 // - loginByWechat 备用:微信网页授权凭证(code)换用户信息(短信绑定前置) // - bindPhoneBySms 备用:短信验证码绑定手机号 import config from '@/common/config.js' // userInfo 解码接口 const userInfoUrl = 'open-api/meike/getUserInfo' // 是否已登录(本地缓存里有手机号或 openId 即视为已登录) const isLogin = () => { return !!(uni.getStorageSync('phone') || uni.getStorageSync('openId')) } // xuchao: 从 URL 抓取身份参数(?openId=xxx 直传、?order=xxx 需解码、?code=xxx 公众号授权回调)并写入缓存。 // 兼容两种传法:查询串(?order= 在 # 前)与 hash 内参数(?order= 在 # 后,uni-app H5 hash 路由常见)。 // URL 上有参数即覆盖缓存(新入口的菜单直链每次带新解码码,必须拿最新的)。 // order 值是 base64(含 + / =):地址栏/网关常把 '+' 编成 %20(空格),而 base64 不含空格, // 解析后需把空格还原为 '+',再在请求侧 encodeURIComponent(+ → %2B)发送。 const captureIdentity = () => { // #ifdef H5 const q = (location.search || '') + (location.hash || '') const mOpen = q.match(/[?&]openId=([^&]+)/) if (mOpen && mOpen[1]) { uni.setStorageSync('openId', decodeURIComponent(mOpen[1])) } const mOrder = q.match(/[?&]order=([^&]+)/) if (mOrder && mOrder[1]) { const order = decodeURIComponent(mOrder[1]).replace(/\s+/g, '+') uni.setStorageSync('order', order) console.log('[auth] order recovered len=' + order.length + ' hasPlus=' + order.indexOf('+') > -1) } // xuchao: 公众号授权回调 code —— 与已消费的码对比,新码才换 openId const mCode = q.match(/[?&]code=([^&]+)/) if (mCode && mCode[1]) { uni.setStorageSync('oauthCode', decodeURIComponent(mCode[1])) console.log('[auth] oauth code recovered len=' + mCode[1].length) } console.log('[auth] captureIdentity orderInUrl=' + !!mOrder + ' codeInUrl=' + !!mCode + ' href=' + location.href) // #endif } // 兼容别名 const captureOpenId = captureIdentity // xuchao: order 解码码换用户身份 —— POST getUserInfo,code 放请求体 // 响应:{data:{phone, openId, isSubscribe, channel}, errcode, errmsg, traceid}(openId 大写,兼容小写 openid) // 带 in-flight 守卫:一次冷启动只发一次,避免重复消费一次性解码码 // 注意:openId 以公众号授权(OAuth)为准,这里只解析其他身份数据;openId 为空时才兜底写入 let loggingIn = null const loginByOrder = (order) => { if (loggingIn) return loggingIn loggingIn = new Promise((resolve, reject) => { const url = config.apiBaseUrl + '/' + userInfoUrl console.log('[auth] loginByOrder ->', url) uni.request({ url: url, method: 'POST', header: { 'Content-Type': 'application/json' }, data: { code: order }, success: (res) => { loggingIn = null const r = res.data || {} console.log('[auth] getUserInfo resp', r) if (r.errcode !== undefined && r.errcode != 0) { reject(new Error(r.errmsg || '解码失败')) return } // 取业务数据:兼容 {data:{...}} 包裹与扁平两种结构 const d = (r.data && (r.data.openId || r.data.openid)) ? r.data : r const openId = d.openId || d.openid // openId 以 OAuth 为准:未授权拿到时才用解码结果兜底 if (!uni.getStorageSync('openId')) { if (!openId) { reject(new Error('解码结果缺少用户身份')) return } uni.setStorageSync('openId', openId) } uni.setStorageSync('phone', d.phone || '') if (d.channel) uni.setStorageSync('channel', d.channel) if (d.userId) uni.setStorageSync('userId', d.userId) // d.isSubscribe=0 if (d.isSubscribe !== undefined && d.isSubscribe !== null && d.isSubscribe !== '') { uni.setStorageSync('isSubscribe', d.isSubscribe) } uni.setStorageSync('order', order) uni.setStorageSync('decodedOrder', order) resolve(true) }, fail: (err) => { loggingIn = null console.log('[auth] getUserInfo fail', err) reject(new Error('网络异常,请稍后重试')) } }) }) return loggingIn } // xuchao: 公众号网页授权 —— 静默授权(snsapi_base)跳转,redirect_uri 为当前完整 URL(含 order 参数), // 授权完成微信带 code 跳回,captureIdentity 抓到后由 code2OpenId 换 openId。 // 授权域名需在公众号后台「网页授权域名」中配置,否则微信会拦截。 const oauthAuthorize = () => { // #ifdef H5 const redirect = encodeURIComponent(location.href.split('#')[0] + location.hash) const url = 'https://open.weixin.qq.com/connect/oauth2/authorize' + '?appid=' + config.oauthAppid + '&redirect_uri=' + redirect + '&response_type=code&scope=snsapi_base&state=#wechat_redirect' console.log('[auth] oauthAuthorize ->', url) location.replace(url) // #endif } // xuchao: 授权回调 code 换 openId —— POST h5-api/oauth2MeiKe {code} → {data:{openId}} // 带 in-flight 守卫:一次冷启动只换一次 let oauthing = null const code2OpenId = (code) => { if (oauthing) return oauthing oauthing = new Promise((resolve, reject) => { const url = config.apiBaseUrl + '/' + config.oauthCode2OpenIdUrl console.log('[auth] code2OpenId ->', url) uni.request({ url: url, method: 'POST', header: { 'Content-Type': 'application/json' }, data: { code: code }, success: (res) => { oauthing = null const r = res.data || {} console.log('[auth] oauth2MeiKe resp', r) if (r.errcode !== undefined && r.errcode != 0) { reject(new Error(r.errmsg || '授权失败')) return } const d = (r.data && (r.data.openId || r.data.openid)) ? r.data : r const openId = d.openId || d.openid if (!openId) { reject(new Error('授权结果缺少 openId')) return } uni.setStorageSync('openId', openId) uni.setStorageSync('decodedOauthCode', code) resolve(true) }, fail: (err) => { oauthing = null console.log('[auth] oauth2MeiKe fail', err) reject(new Error('网络异常,请稍后重试')) } }) }) return oauthing } // xuchao: 登录校验(异步) // 优先级(已登录态优先,避免重开页面重复授权/解码): // - 开发模式 → 静默假登录后放行 // - 缓存已有 openId → 直接复用,仅当有「新 order」时调 getUserInfo 刷新身份数据,绝不再走公众号授权 // - 无 openId 但有授权回调 code → 调 code2OpenId 换 openId;失败提示并停留当前页 // - 无 openId 且无 code → 跳公众号授权页(oauthAuthorize) // - order 缺失/解码失败 → Toast 提示,停留当前页(不跳登录页) const requireLogin = (redirect) => { captureIdentity() const openId = uni.getStorageSync('openId') const order = uni.getStorageSync('order') const decodedOrder = uni.getStorageSync('decodedOrder') || '' const freshOrder = !!(order && order !== decodedOrder) const oauthCode = uni.getStorageSync('oauthCode') const decodedOauthCode = uni.getStorageSync('decodedOauthCode') || '' const freshCode = !!(oauthCode && oauthCode !== decodedOauthCode) console.log('[auth] requireLogin isLogin=' + isLogin() + ' hasOrder=' + !!order + ' freshOrder=' + freshOrder + ' hasCode=' + !!oauthCode + ' freshCode=' + freshCode + ' openId=' + !!openId) // 开发模式:静默假登录 if (config.devMode) { loginByMock(config.testPhones[0]) return Promise.resolve(true) } // 已授权(缓存有 openId):复用登录态,不走授权;仅处理新 order 的身份数据刷新 if (openId) { return resolveOrder(order, freshOrder) } // 未授权:有授权回调 code → 换 openId if (freshCode) { return code2OpenId(oauthCode).then(() => { // 换到 openId 后,仍可能有新 order 需解码 if (isLogin()) return resolveOrder(order, freshOrder) return Promise.resolve(false) }).catch((err) => { uni.removeStorageSync('oauthCode') uni.showToast({ icon: 'none', title: err.message || '授权失败,请稍后重试' }) return Promise.resolve(false) }) } // 未授权且无授权回调 → 跳公众号授权页(页面将重定向,返回 pending) oauthAuthorize() return new Promise(() => {}) } // xuchao: 权益判断 —— isSubscribe==1 有权益;0 无权益(弹窗阻断,弹窗不可关闭) const hasEntitlement = () => { return uni.getStorageSync('isSubscribe') == 1 } // xuchao: 无权益弹窗 —— 全屏遮罩 + 居中对话框,无取消按钮、确认后重新弹出,实现"不可关闭" // 用原生 DOM 实现(而非 uni.showModal):该 H5 运行时 uni-modal 无内置样式不可见,且原生弹窗无法真正锁死 let noRightModalShown = false const showNoRightModal = () => { if (noRightModalShown) return noRightModalShown = true // #ifdef H5 const overlay = document.createElement('div') overlay.id = '__mm-no-right-modal' overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;' + 'background:rgba(15,23,42,0.55);display:flex;align-items:center;justify-content:center;' const box = document.createElement('div') box.style.cssText = 'width:75%;max-width:320px;background:#fff;border-radius:16px;padding:40px 32px 32px;' + 'text-align:center;box-shadow:0 24px 48px rgba(15,23,42,0.25);' const title = document.createElement('div') title.textContent = '提示' title.style.cssText = 'font-size:17px;font-weight:600;color:#0F172A;' const content = document.createElement('div') content.textContent = '您暂时没有权益,请获取权益之后重新进入' content.style.cssText = 'font-size:14px;color:#0F172A;line-height:1.6;margin-top:12px;' const btn = document.createElement('div') btn.textContent = '知道了' btn.style.cssText = 'margin-top:20px;height:44px;line-height:44px;border-radius:8px;color:#fff;' + 'font-size:15px;font-weight:600;background:linear-gradient(135deg,#0052FF,#4D7CFF);' + 'cursor:pointer;' // 确认后不关闭,而是重新弹出(保证弹窗锁死) btn.addEventListener('click', () => { noRightModalShown = false showNoRightModal() }) box.appendChild(title) box.appendChild(content) box.appendChild(btn) overlay.appendChild(box) document.body.appendChild(overlay) // #endif } // xuchao: order 解码(其他身份数据)—— 新 order 才调接口;解码后校验权益 const resolveOrder = (order, freshOrder) => { if (isLogin() && !freshOrder) { if (!hasEntitlement()) { showNoRightModal() return Promise.resolve(false) } return Promise.resolve(true) } if (!order) { uni.showToast({ icon: 'none', title: '缺少登录凭证' }) return Promise.resolve(false) } return loginByOrder(order).then(() => { if (!hasEntitlement()) { showNoRightModal() return false } return true }).catch((err) => { uni.showToast({ icon: 'none', title: err.message || '登录失败' }) return false }) } // xuchao: 开发模式假登录 —— 写入与真实登录相同的缓存数据,后续页面逻辑完全一致 const loginByMock = (phone) => { uni.setStorageSync('phone', phone) uni.setStorageSync('openId', 'mock_openid_' + phone) uni.setStorageSync('userId', '') uni.setStorageSync('isSubscribe', '1') } // xuchao: 备用流程 —— 微信网页授权(code)换用户信息 const loginByWechat = (code) => { return new Promise((resolve, reject) => { // TODO 后端就绪后替换为:POST /auth/oauth2session {code} → {openId, mobile, token} reject(new Error('微信登录接口未接入')) }) } // xuchao: 备用流程 —— 短信验证码绑定手机号 const bindPhoneBySms = (phone, smsCode) => { return new Promise((resolve, reject) => { // TODO 后端就绪后替换为:短信验证码校验 + 手机号绑定 openId reject(new Error('短信绑定接口未接入')) }) } // 退出登录 const logout = () => { uni.removeStorageSync('phone') uni.removeStorageSync('openId') uni.removeStorageSync('order') uni.removeStorageSync('decodedOrder') uni.removeStorageSync('oauthCode') uni.removeStorageSync('decodedOauthCode') uni.removeStorageSync('userId') uni.removeStorageSync('isSubscribe') uni.removeStorageSync('token') } export default { isLogin, requireLogin, captureIdentity, captureOpenId, oauthAuthorize, code2OpenId, loginByOrder, loginByMock, loginByWechat, bindPhoneBySms, logout, devMode: config.devMode }