diff --git a/src/App.vue b/src/App.vue index c4eba21b..dc125078 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,10 +2,10 @@ import { mapState } from 'pinia'; import { onLaunch, onShow, onError } from '@dcloudio/uni-app'; import { ShoproInit } from './sheep'; -import http from './common/request'; import * as msgType from './common/messageType'; import * as enums from './common/enums'; import * as wsApi from './common/wssocket'; +import chatTokenManager from './common/chat-token-manager'; import CHAT_ENV from '@/chat-env.js'; import useChatStore from '@/chat-store/chatStore.js'; @@ -64,6 +64,7 @@ export default { }, // 初始化聊天 async init() { + this.startChatTokenCheck(); this.reconnecting = false; this.isExit = false; try { @@ -431,6 +432,7 @@ export default { exit() { console.log('exit'); this.isExit = true; + chatTokenManager.stop(); wsApi.close(3099); uni.removeStorageSync('loginInfo'); uni.reLaunch({ @@ -444,27 +446,15 @@ export default { // this.audioTip.src = "/static/audio/tip.wav"; // this.audioTip.play(); }, - // 刷新token - refreshToken(loginInfo) { - return new Promise((resolve, reject) => { - if (!loginInfo || !loginInfo.refreshToken) { - reject(); - return; - } - http({ - url: '/refreshToken', - method: 'PUT', - header: { - refreshToken: loginInfo.refreshToken, - }, - }) - .then((newLoginInfo) => { - uni.setStorageSync('loginInfo', newLoginInfo); - resolve(); - }) - .catch((e) => { - reject(e); - }); + startChatTokenCheck() { + // 轮询只处理聊天 token;仅凭证明确失效时退出,网络错误等待下次检查。 + chatTokenManager.start({ + onRefreshError: (error) => { + const code = error?.code ?? error?.data?.code; + if (code == 400 || code == 401) { + this.exit(); + } + }, }); }, reconnectWs() { @@ -542,7 +532,9 @@ export default { this.closeSplashscreen(0); return; } - this.refreshToken(loginInfo) + this.startChatTokenCheck(); + chatTokenManager + .refreshAccessToken(loginInfo) .then(() => { // 聊天初始化 this.init(); @@ -559,6 +551,10 @@ export default { // console.log('onError', error); // }, onShow() { + // #ifndef MP-WEIXIN + // H5 页面重新可见或 App 回到前台时,立即检查并重新建立轮询。 + this.startChatTokenCheck(); + // #endif // #ifdef APP-PLUS // 获取urlSchemes参数 const args = plus.runtime.arguments; @@ -570,6 +566,12 @@ export default { }); // #endif }, + onHide() { + // #ifndef MP-WEIXIN + // 进入后台后停止轮询,避免无效请求和重复定时器。 + chatTokenManager.stop(); + // #endif + }, }; diff --git a/src/common/chat-token-manager.js b/src/common/chat-token-manager.js new file mode 100644 index 00000000..3e6e4d33 --- /dev/null +++ b/src/common/chat-token-manager.js @@ -0,0 +1,159 @@ +import CHAT_ENV from '@/chat-env.js'; + +const TOKEN_REFRESH_THRESHOLD_SECONDS = 5 * 60; +const TOKEN_CHECK_INTERVAL_MS = 30 * 1000; + +// 统一管理聊天 token 的定时检查和刷新,供请求、上传及 App 生命周期共同复用。 +class ChatTokenManager { + constructor() { + this.timer = null; + // 所有调用共享同一个刷新 Promise,避免并发刷新后 token 相互覆盖。 + this.refreshPromise = null; + this.onRefreshError = null; + // 用于忽略定时器停止后才返回的旧检查结果。 + this.monitorVersion = 0; + } + + // 启动时立即检查一次,之后每 30 秒检查一次;重复调用不会创建多个定时器。 + start(options = {}) { + this.onRefreshError = options.onRefreshError || null; + + const loginInfo = uni.getStorageSync('loginInfo'); + if (!loginInfo?.refreshToken) { + this.stop(); + return; + } + + if (this.timer) { + return; + } + + const monitorVersion = ++this.monitorVersion; + this.runCheck(monitorVersion); + this.timer = setInterval(() => { + this.runCheck(monitorVersion); + }, TOKEN_CHECK_INTERVAL_MS); + } + + stop() { + this.monitorVersion += 1; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.onRefreshError = null; + } + + runCheck(monitorVersion = this.monitorVersion) { + this.checkAccessToken().catch((error) => { + if (monitorVersion !== this.monitorVersion) { + return; + } + console.warn('定时检查聊天 token 失败', error); + this.onRefreshError?.(error); + }); + } + + checkAccessToken() { + const loginInfo = uni.getStorageSync('loginInfo'); + if (!loginInfo?.refreshToken) { + this.stop(); + return Promise.resolve(null); + } + + // accessToken 缺失或即将过期时才请求刷新,正常状态不产生网络请求。 + if (!loginInfo.accessToken || this.isAccessTokenExpiringSoon(loginInfo.accessToken)) { + return this.refreshAccessToken(loginInfo); + } + + return Promise.resolve(loginInfo); + } + + isAccessTokenExpiringSoon(accessToken) { + if (!accessToken) { + return false; + } + + try { + // JWT 第二段是 payload,H5 和 App 均通过 uni API 完成 Base64 解码。 + const payload = accessToken.split('.')[1]; + if (!payload) { + return false; + } + + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const paddedBase64 = base64.padEnd(Math.ceil(base64.length / 4) * 4, '='); + const bytes = new Uint8Array(uni.base64ToArrayBuffer(paddedBase64)); + let decodedPayload = ''; + bytes.forEach((byte) => { + decodedPayload += String.fromCharCode(byte); + }); + + const expMatch = decodedPayload.match(/"exp"\s*:\s*(\d+)/); + if (!expMatch) { + return false; + } + + const remainingSeconds = Number(expMatch[1]) - Math.floor(Date.now() / 1000); + return remainingSeconds <= TOKEN_REFRESH_THRESHOLD_SECONDS; + } catch (error) { + console.warn('解析聊天 accessToken 过期时间失败', error); + return false; + } + } + + refreshAccessToken(loginInfo = uni.getStorageSync('loginInfo')) { + if (!loginInfo?.refreshToken) { + return Promise.reject(new Error('缺少聊天 refreshToken')); + } + + if (this.refreshPromise) { + return this.refreshPromise; + } + + const refreshToken = loginInfo.refreshToken; + this.refreshPromise = this.requestRefreshToken(refreshToken) + .then((res) => { + if (!res || res.data?.code != 200) { + throw res?.data || new Error('刷新聊天 token 失败'); + } + + // 刷新期间用户可能退出或重新登录,旧请求结果不能覆盖新的登录状态。 + const currentLoginInfo = uni.getStorageSync('loginInfo'); + if (currentLoginInfo?.refreshToken !== refreshToken) { + if (currentLoginInfo?.accessToken) { + return currentLoginInfo; + } + throw new Error('聊天登录状态已变化,忽略本次 token 刷新结果'); + } + + const newLoginInfo = res.data.data; + uni.setStorageSync('loginInfo', newLoginInfo); + return newLoginInfo; + }) + .finally(() => { + this.refreshPromise = null; + }); + + return this.refreshPromise; + } + + requestRefreshToken(refreshToken) { + return new Promise((resolve, reject) => { + uni.request({ + method: 'PUT', + url: CHAT_ENV.BASE_URL + '/refreshToken', + header: { + refreshToken, + }, + timeout: 10000, + success: resolve, + fail: reject, + }); + }); + } +} + +export { ChatTokenManager, TOKEN_CHECK_INTERVAL_MS, TOKEN_REFRESH_THRESHOLD_SECONDS }; + +export default new ChatTokenManager(); diff --git a/src/common/request.js b/src/common/request.js index d286f7af..3a109ff2 100644 --- a/src/common/request.js +++ b/src/common/request.js @@ -1,11 +1,5 @@ import CHAT_ENV from '@/chat-env.js'; - -const TOKEN_REFRESH_THRESHOLD_SECONDS = 5 * 60; - -// 等待同一次 token 刷新结果的请求队列 -let refreshWaitList = []; -// 是否正在刷新中 -let isRefreshToken = false; +import chatTokenManager from './chat-token-manager'; const request = (options) => { const header = options.header || {}; @@ -23,9 +17,10 @@ const request = (options) => { async success(res) { if (res.data.code == 200) { const currentLoginInfo = uni.getStorageSync('loginInfo'); - if (isAccessTokenExpiringSoon(currentLoginInfo?.accessToken)) { + // 与全局轮询共享刷新锁,避免请求成功检查和定时检查同时刷新。 + if (chatTokenManager.isAccessTokenExpiringSoon(currentLoginInfo?.accessToken)) { try { - await refreshAccessToken(currentLoginInfo); + await chatTokenManager.refreshAccessToken(currentLoginInfo); } catch (error) { console.log('刷新token失败', error); getApp().$vm.exit(); @@ -49,7 +44,7 @@ const request = (options) => { } try { - await refreshAccessToken(currentLoginInfo); + await chatTokenManager.refreshAccessToken(currentLoginInfo); // 重新发送刚才的请求 return resolve(request(options)); } catch (error) { @@ -78,80 +73,4 @@ const request = (options) => { }); }; -const isAccessTokenExpiringSoon = (accessToken) => { - if (!accessToken) { - return false; - } - - try { - const payload = accessToken.split('.')[1]; - const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); - const paddedBase64 = base64.padEnd(Math.ceil(base64.length / 4) * 4, '='); - const bytes = new Uint8Array(uni.base64ToArrayBuffer(paddedBase64)); - let decodedPayload = ''; - bytes.forEach((byte) => { - decodedPayload += String.fromCharCode(byte); - }); - const expMatch = decodedPayload.match(/"exp"\s*:\s*(\d+)/); - if (!expMatch) { - return false; - } - const remainingSeconds = Number(expMatch[1]) - Math.floor(Date.now() / 1000); - console.log("remainingSeconds:", remainingSeconds); - - return remainingSeconds <= TOKEN_REFRESH_THRESHOLD_SECONDS; - } catch (error) { - console.warn('解析 accessToken 过期时间失败', error); - return false; - } -}; - -const refreshAccessToken = (loginInfo) => { - if (!loginInfo?.refreshToken) { - return Promise.reject(new Error('缺少 refreshToken')); - } - if (isRefreshToken) { - return new Promise((resolve, reject) => { - refreshWaitList.push({ resolve, reject }); - }); - } - isRefreshToken = true; - return reqRefreshToken(loginInfo) - .then((res) => { - if (!res || res.data.code != 200) { - throw res?.data || new Error('刷新token失败'); - } - const newInfo = res.data.data; - uni.setStorageSync('loginInfo', newInfo); - refreshWaitList.forEach(({ resolve }) => resolve(newInfo)); - return newInfo; - }) - .catch((error) => { - refreshWaitList.forEach(({ reject }) => reject(error)); - throw error; - }) - .finally(() => { - refreshWaitList = []; - isRefreshToken = false; - }); -}; - -const reqRefreshToken = (loginInfo) => { - return new Promise(function (resolve, reject) { - uni.request({ - method: 'PUT', - url: CHAT_ENV.BASE_URL + '/refreshToken', - header: { - refreshToken: loginInfo.refreshToken, - }, - success: (res) => { - resolve(res); - }, - fail: (res) => { - reject(res); - }, - }); - }); -}; - export default request; diff --git a/src/components/file-upload/file-upload.vue b/src/components/file-upload/file-upload.vue index 3a5b4205..9ff7fa22 100644 --- a/src/components/file-upload/file-upload.vue +++ b/src/components/file-upload/file-upload.vue @@ -16,6 +16,7 @@ diff --git a/src/pages/profile/feedback.vue b/src/pages/profile/feedback.vue index 9a60b290..9ada7351 100644 --- a/src/pages/profile/feedback.vue +++ b/src/pages/profile/feedback.vue @@ -7,10 +7,10 @@ - 问题描述 - + 图片 diff --git a/src/pages/tabbar/profile.vue b/src/pages/tabbar/profile.vue index 7f8437b3..620d62f4 100644 --- a/src/pages/tabbar/profile.vue +++ b/src/pages/tabbar/profile.vue @@ -24,7 +24,7 @@ - ID: {{ userInfo.id }} + ID: {{ userInfo.userCode }} diff --git a/src/sheep/components/user-card/user-card.vue b/src/sheep/components/user-card/user-card.vue index f79260eb..26ea75de 100644 --- a/src/sheep/components/user-card/user-card.vue +++ b/src/sheep/components/user-card/user-card.vue @@ -17,9 +17,9 @@ {{ - member.appRefreshDate - ? formatRelativeTime(member.appRefreshDate) + ' 在线' - : '离线' + data.onLine + ? ' 在线' + : formatRelativeTime(member.appRefreshDate) }} diff --git a/src/sheep/platform/h5-shell.js b/src/sheep/platform/h5-shell.js index ec101410..6577a4a4 100644 --- a/src/sheep/platform/h5-shell.js +++ b/src/sheep/platform/h5-shell.js @@ -1,5 +1,5 @@ const H5_STATUS_BAR_QUERY_KEYS = ['statusBarHeight', 'safeTop', 'safeAreaTop', 'appStatusBarHeight']; -const H5_MIN_STATUS_BAR_HEIGHT = 22; +const H5_MIN_STATUS_BAR_HEIGHT = 54; const H5_MAX_STATUS_BAR_HEIGHT = 200; let currentStatusBarHeight = H5_MIN_STATUS_BAR_HEIGHT; let statusBarStyleObserver = null; diff --git a/src/sheep/store/user.js b/src/sheep/store/user.js index ab683c7d..661f8149 100644 --- a/src/sheep/store/user.js +++ b/src/sheep/store/user.js @@ -8,6 +8,7 @@ import UserApi from '@/sheep/api/member/user'; import PayWalletApi from '@/sheep/api/pay/wallet'; import OrderApi from '@/sheep/api/trade/order'; import CouponApi from '@/sheep/api/promotion/coupon'; +import chatTokenManager from '@/common/chat-token-manager'; // 默认用户信息 const defaultUserInfo = { @@ -128,6 +129,7 @@ const user = defineStore({ this.userWallet = clone(defaultUserWallet); this.numData = cloneDeep(defaultNumData); // 清空聊天相关 + chatTokenManager.stop(); uni.removeStorageSync('loginInfo'); // 清空购物车的缓存 cart().emptyList();