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();