Files
h5-meike/common/auth.js
T
2026-08-18 09:22:32 +08:00

335 lines
13 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.
// 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
}