diff --git a/App.vue b/App.vue index be65a46..141d80d 100644 --- a/App.vue +++ b/App.vue @@ -4,6 +4,7 @@ // 桌面加载时整页已是手机尺寸但默认贴左。这里只补两件事: // 1) 宽屏下把页面列居中(见样式); // 2) 跨越 768 阈值缩放窗口时重载一次,让 px 基准与当前视口一致。 + import auth from '@/common/auth.js' let _wide = null function adaptWideScreen() { const w = document.documentElement.clientWidth @@ -20,6 +21,8 @@ onLaunch: function(e) { console.log('App Launch') // #ifdef H5 + // xuchao: 冷启动即抓取 URL 上的身份参数(?openId 直传 / ?order 解码码)入缓存,幂等 + auth.captureIdentity() adaptWideScreen() window.addEventListener('resize', adaptWideScreen) // #endif diff --git a/common/auth.js b/common/auth.js index 39bf033..716f1cb 100644 --- a/common/auth.js +++ b/common/auth.js @@ -1,28 +1,285 @@ // xuchao: 登录层 —— 登录逻辑全部隔离在这里 -// 一期为假登录(开发模式),后端微信登录接口就绪后只改本文件: -// - loginByMock 开发模式:输入测试手机号直接登录 -// - loginByWechat 正式流程:用微信网页授权凭证(code)换用户信息 -// - bindPhoneBySms 正式流程:短信验证码绑定手机号(微信授权拿不到手机号时用) +// 正式流程(公众号菜单直链):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') + return !!(uni.getStorageSync('phone') || uni.getStorageSync('openId')) } -// xuchao: 登录校验 -// - 开发模式:无登录态时静默用首个测试手机号假登录,不跳登录页(本地直接进业务页) -// - 正式流程:跳登录页;登录成功后回跳 redirect 指定的页面 +// 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) => { - if (isLogin()) return true + 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 true + return Promise.resolve(true) } - uni.navigateTo({ - url: '/pages/index/authorization' + (redirect ? '?redirect=' + redirect : '') + // 已授权(缓存有 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 }) - return false } // xuchao: 开发模式假登录 —— 写入与真实登录相同的缓存数据,后续页面逻辑完全一致 @@ -30,9 +287,10 @@ const loginByMock = (phone) => { uni.setStorageSync('phone', phone) uni.setStorageSync('openId', 'mock_openid_' + phone) uni.setStorageSync('userId', '') + uni.setStorageSync('isSubscribe', '1') } -// xuchao: 正式流程占位 —— 后端接口就绪后实现(用 code 换 openId + 用户信息) +// xuchao: 备用流程 —— 微信网页授权(code)换用户信息 const loginByWechat = (code) => { return new Promise((resolve, reject) => { // TODO 后端就绪后替换为:POST /auth/oauth2session {code} → {openId, mobile, token} @@ -40,7 +298,7 @@ const loginByWechat = (code) => { }) } -// xuchao: 正式流程占位 —— 短信验证码绑定手机号 +// xuchao: 备用流程 —— 短信验证码绑定手机号 const bindPhoneBySms = (phone, smsCode) => { return new Promise((resolve, reject) => { // TODO 后端就绪后替换为:短信验证码校验 + 手机号绑定 openId @@ -52,16 +310,26 @@ const bindPhoneBySms = (phone, smsCode) => { 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 -} +} \ No newline at end of file diff --git a/common/config.js b/common/config.js index ac76076..942b702 100644 --- a/common/config.js +++ b/common/config.js @@ -3,11 +3,15 @@ export default { // 后端测试环境地址 apiBaseUrl: 'https://dscp-uat.hn12301.net', // 开发模式:true = 假登录 + 假支付(本地调试用);后端微信接口就绪后改为 false 接真实流程 - devMode: true, + devMode: false, // 产品名 appName: '质惠游', // 测试手机号(测试环境已有数据的账号) testPhones: ['17763797832', '18684839400', '18710146266', '15367920401'], - // 微信公众号网页授权 appid(公众号信息确定后填入) - wxAppid: '' + // 微信公众号 appid —— 微信 JSAPI 支付 appId 兜底(prepayWx 返回为空时使用) + wxAppid: 'wxccfb072fa3ff58fa', + // 公众号网页授权 appid(OAuth 换 openId 用,与支付 appid 可能不同) + oauthAppid: 'wxccfb072fa3ff58fa', + // code 换 openId 接口(公众号授权回调 code → openId) + oauthCode2OpenIdUrl: 'h5-api/auth/oauth2MeiKe' } diff --git a/common/http.api.js b/common/http.api.js index ff975fa..329096e 100644 --- a/common/http.api.js +++ b/common/http.api.js @@ -13,7 +13,7 @@ let queryTicketsOrderPageUrl = 'open-api/tickets/order/queryTicketsOrderPage' let cancelTicketsOrderUrl = 'open-api/tickets/order/cancelTicketsOrder' let refundTicketsOrderXghUrl = 'open-api/tickets/order/refundTicketsOrderXgh' // xuchao: 微信预下单接口路径(后端接口就绪后确认,返回微信支付所需参数) -let prepayWxUrl = 'open-api/tickets/order/prepayWx' +let prepayWxUrl = 'open-api/tickets/order/prepay' const install = (Vue, vm) => { let queryTicketsPage = (params = {}) => vm.$u.post(`${queryTicketsPageUrl}`, params); diff --git a/common/pay.js b/common/pay.js index 55cad7f..2da7462 100644 --- a/common/pay.js +++ b/common/pay.js @@ -27,30 +27,47 @@ const pay = (vm, { orderId, onSuccess, onFail }) => { } // 正式流程:微信 JSAPI 支付 - if (!isWechatBrowser()) { - uni.showToast({ icon: 'none', title: '请在微信中打开完成支付' }) - onFail && onFail({ errMsg: 'not wechat browser' }) + const openId = uni.getStorageSync('openId') + if (!openId) { + uni.showToast({ icon: 'none', title: '缺少登录身份,请重新进入' }) + onFail && onFail({ errMsg: 'no openId' }) return } - const openId = uni.getStorageSync('openId') - vm.$u.api.prepayWx({ orderId, openId }).then(res => { + console.log('[pay] prepay orderId=' + orderId + ' openId=' + openId) + vm.$u.api.prepayWx({ orderId, openId, type: 1 }).then(res => { if (!res) return const p = res.data || {} + console.log('[pay] prepay resp', p) // 后端返回微信支付所需参数后,用此桥接唤起支付(微信浏览器内置,无需额外引入) - WeixinJSBridge.invoke('getBrandWCPayRequest', { - appId: p.appId, - timeStamp: String(p.timeStamp), - nonceStr: p.nonceStr, - package: p.pack || p.packageValue, - signType: p.signType || 'RSA', - paySign: p.paySign - }, bridgeRes => { - if (bridgeRes.err_msg === 'get_brand_wcpay_request:ok') { - onSuccess && onSuccess(bridgeRes) - } else { - onFail && onFail(bridgeRes) - } - }) + // appId 优先取后端返回值,为空时用 config.wxAppid 兜底 + const invokePay = () => { + WeixinJSBridge.invoke('getBrandWCPayRequest', { + appId: p.appId || config.wxAppid, + timeStamp: String(p.timeStamp), + nonceStr: p.nonceStr || p.nonce, + package: p.packageValue || p.pack || p['package'], + signType: p.signType || 'RSA', + paySign: p.paySign + }, bridgeRes => { + if (bridgeRes.err_msg === 'get_brand_wcpay_request:ok') { + onSuccess && onSuccess(bridgeRes) + } else { + onFail && onFail(bridgeRes) + } + }) + } + // 非微信环境 prepay 已拿到参数,唤起桥接这一步才提示需在微信中完成 + if (!isWechatBrowser()) { + uni.showToast({ icon: 'none', title: '请在微信中打开完成支付' }) + onFail && onFail({ errMsg: 'not wechat browser' }) + return + } + // 桥接可能未就绪(冷启动进页立刻支付),挂 WeixinJSBridgeReady 事件等它就绪 + if (typeof WeixinJSBridge === 'undefined') { + document.addEventListener('WeixinJSBridgeReady', invokePay, false) + } else { + invokePay() + } }).catch(() => { onFail && onFail({ errMsg: 'prepay error' }) }) diff --git a/manifest.json b/manifest.json index f294946..a5fc9b2 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,7 @@ "vueVersion" : "2", "h5" : { "router" : { - "base" : "/zhy/" + "base" : "" }, "template" : "index.html", "devServer" : { diff --git a/pages/ticket/ordersure.vue b/pages/ticket/ordersure.vue index 014f12f..13a6abd 100644 --- a/pages/ticket/ordersure.vue +++ b/pages/ticket/ordersure.vue @@ -170,7 +170,7 @@ "contactMobile": "", "contactName": "", "price": "", - "orderSource": "Channel_XGH", + "orderSource": "Channel_MEIKE", "mobile": "" } } diff --git a/pages/ticket/scenic.vue b/pages/ticket/scenic.vue index 965e9bd..a60699b 100644 --- a/pages/ticket/scenic.vue +++ b/pages/ticket/scenic.vue @@ -76,9 +76,11 @@ }, onLoad() { that = this - // xuchao: 未登录则(正式)跳登录页 / (开发)静默假登录 - if (!auth.requireLogin('/pages/ticket/scenic')) return - this.queryTicketsPageMethod() + // xuchao: 异步登录校验 —— 已登录直接进;未登录时用 URL 上的 order 解码码换身份; + // 缺失/失败停留首页 Toast 提示,不跳登录页 + auth.requireLogin('/pages/ticket/scenic').then(ok => { + if (ok) this.queryTicketsPageMethod() + }) }, onReachBottom() { this.queryTicketsPageMethod()