diff --git a/src/App.vue b/src/App.vue index 23f32603..7258d325 100644 --- a/src/App.vue +++ b/src/App.vue @@ -79,9 +79,9 @@ export default { }, // 初始化聊天 async init() { + this.isExit = false; this.startChatTokenCheck(); this.reconnecting = false; - this.isExit = false; try { // 加载数据 await this.loadStore() @@ -112,9 +112,10 @@ export default { }); wsApi.onMessage((cmd, msgInfo) => { if (cmd == 2) { - // 异地登录,强制下线 + const reason = typeof msgInfo === 'string' ? msgInfo : msgInfo?.reason || '聊天登录状态已失效'; + chatTokenManager.stopForAccount(reason); uni.showModal({ - content: '您已在其他地方登录,将被强制下线', + content: reason, showCancel: false, }); this.exit(); @@ -465,6 +466,9 @@ export default { // this.audioTip.play(); }, startChatTokenCheck() { + if (this.isExit) { + return; + } // IM 双 token 都过期时,使用仍有效的主站登录态重新签发。 chatTokenManager.start({ reissueToken: reissueChatToken, @@ -535,6 +539,12 @@ export default { // #endif async onLaunch() { + uni.$on('chat-account-unavailable', (message) => { + if (!this.isExit) { + this.exit(); + uni.showToast({ title: message || '聊天账号已停用', icon: 'none' }); + } + }); // 加载Shopro底层依赖 await ShoproInit(); // #ifdef H5 diff --git a/src/common/__tests__/chat-token-manager.test.mjs b/src/common/__tests__/chat-token-manager.test.mjs new file mode 100644 index 00000000..1bd480c4 --- /dev/null +++ b/src/common/__tests__/chat-token-manager.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +async function fixture() { + const storage = new Map([ + ['token', 'business-a'], + ['loginInfo', { accessToken: 'im-a', refreshToken: 'refresh-a', accessTokenExpiresAt: Date.now() + 3600000 }], + ]); + const events = []; + const requests = []; + let intervals = 0; + const source = (await readFile(new URL('../chat-token-manager.js', import.meta.url), 'utf8')) + .replace("import CHAT_ENV from '@/chat-env.js';", 'const CHAT_ENV = { BASE_URL: "http://localhost" };') + .replace(/export \{[^}]+\};/g, '') + .replace('export default new ChatTokenManager();', 'globalThis.manager = new ChatTokenManager();'); + const context = vm.createContext({ + console, + setInterval: () => ++intervals, + clearInterval: () => { intervals--; }, + uni: { + getStorageSync: key => storage.get(key), + removeStorageSync: key => storage.delete(key), + setStorageSync: (key, value) => storage.set(key, value), + $emit: (name, value) => events.push([name, value]), + request: request => requests.push(request), + }, + }); + vm.runInContext(source, context); + return { manager: context.manager, storage, events, requests, intervals: () => intervals }; +} + +test('unavailable account stops polling and blocks refresh until a new business login', async () => { + const context = await fixture(); + const { manager, storage, events } = context; + manager.start(); + assert.equal(context.intervals(), 1); + assert.equal(manager.handleAccountUnavailable({ code: 10005, message: '账号已被禁用' }, 'im-a', 'business-a'), true); + assert.equal(context.intervals(), 0); + manager.start(); + assert.equal(context.intervals(), 0); + await assert.rejects(manager.refreshAccessToken(), error => error.code === 10005); + assert.equal(context.requests.length, 0); + assert.equal(events.length, 1); + storage.set('token', 'business-b'); + storage.set('loginInfo', { accessToken: 'im-b', refreshToken: 'refresh-b', accessTokenExpiresAt: Date.now() + 3600000 }); + manager.start(); + assert.equal(context.intervals(), 1); + manager.stop(); +}); + +test('late rejection from an old business login preserves the new session', async () => { + const { manager, storage, requests, events } = await fixture(); + const refresh = manager.refreshAccessToken(); + storage.set('token', 'business-b'); + storage.set('loginInfo', { accessToken: 'im-b', refreshToken: 'refresh-b' }); + requests[0].success({ data: { code: 10005, message: '旧账号停用' } }); + await assert.rejects(refresh); + assert.equal(storage.get('loginInfo').accessToken, 'im-b'); + assert.equal(events.length, 0); + assert.equal(manager.accountUnavailableError(), null); +}); + +test('a server forced logout invalidates the in-flight refresh result', async () => { + const { manager, storage, requests } = await fixture(); + const refresh = manager.refreshAccessToken(); + manager.stopForAccount('账号已注销'); + requests[0].success({ data: { code: 200, data: { accessToken: 'late', refreshToken: 'late-refresh' } } }); + await assert.rejects(refresh, /聊天登录状态已停止/); + assert.equal(storage.has('loginInfo'), false); + await assert.rejects(manager.refreshAccessToken(), error => error.code === 10005); +}); + +test('business reissue reports an IM ban and stops further credential retries', async () => { + const { manager, storage, events } = await fixture(); + storage.delete('loginInfo'); + let attempts = 0; + manager.setReissueToken(async () => { + attempts++; + throw { code: 1004018005, msg: '聊天账号已封禁,请联系客服' }; + }); + await assert.rejects(manager.refreshAccessToken()); + await assert.rejects(manager.refreshAccessToken(), error => error.code === 10005); + assert.equal(attempts, 1); + assert.equal(events[0][1], '聊天账号已封禁,请联系客服'); +}); + +test('late initial reissue cannot replace credentials from a new business login', async () => { + const { manager, storage } = await fixture(); + storage.delete('loginInfo'); + let complete; + manager.setReissueToken(() => new Promise(resolve => { complete = resolve; })); + const reissue = manager.refreshAccessToken(); + await Promise.resolve(); + storage.set('token', 'business-b'); + storage.set('loginInfo', { accessToken: 'im-b', refreshToken: 'refresh-b' }); + complete({ accessToken: 'old-im-a', refreshToken: 'old-refresh-a' }); + assert.equal((await reissue).accessToken, 'im-b'); + assert.equal(storage.get('loginInfo').accessToken, 'im-b'); +}); diff --git a/src/common/chat-token-manager.js b/src/common/chat-token-manager.js index 80cc3dbb..ada09dff 100644 --- a/src/common/chat-token-manager.js +++ b/src/common/chat-token-manager.js @@ -14,10 +14,15 @@ class ChatTokenManager { this.reissueToken = null; // 用于忽略定时器停止后才返回的旧检查结果。 this.monitorVersion = 0; + this.blockedBusinessToken = null; + this.accountError = null; } // 启动时立即检查一次,之后每 30 秒检查一次;重复调用不会创建多个定时器。 start(options = {}) { + if (this.accountUnavailableError()) { + return; + } this.onRefreshError = options.onRefreshError || null; if (options.reissueToken) { this.reissueToken = options.reissueToken; @@ -50,6 +55,39 @@ class ChatTokenManager { this.onRefreshError = null; } + accountUnavailableError() { + const token = uni.getStorageSync('token') || ''; + if (this.blockedBusinessToken !== null && this.blockedBusinessToken === token) { + return this.accountError; + } + this.blockedBusinessToken = null; + this.accountError = null; + return null; + } + + stopForAccount(message) { + this.blockedBusinessToken = uni.getStorageSync('token') || ''; + this.accountError = Object.assign(new Error(message || '聊天账号已停用'), { code: 10005 }); + this.stop(); + uni.removeStorageSync('loginInfo'); + } + + handleAccountUnavailable(error, accessToken, businessToken = uni.getStorageSync('token')) { + const code = error?.code ?? error?.data?.code; + const currentAccessToken = uni.getStorageSync('loginInfo')?.accessToken; + if (![10005, 1004018005].includes(Number(code)) || businessToken !== uni.getStorageSync('token') + || (accessToken && currentAccessToken && accessToken !== currentAccessToken)) { + return false; + } + const alreadyBlocked = Boolean(this.accountUnavailableError()); + const message = error?.message || error?.msg || error?.data?.message || error?.data?.msg; + this.stopForAccount(message); + if (!alreadyBlocked) { + uni.$emit('chat-account-unavailable', message); + } + return true; + } + setReissueToken(reissueToken) { this.reissueToken = reissueToken; } @@ -127,11 +165,16 @@ class ChatTokenManager { } refreshAccessToken(loginInfo = uni.getStorageSync('loginInfo')) { + const unavailable = this.accountUnavailableError(); + if (unavailable) { + return Promise.reject(unavailable); + } if (this.refreshPromise) { return this.refreshPromise; } const refreshVersion = this.monitorVersion; + const businessToken = uni.getStorageSync('token'); const accessToken = loginInfo?.accessToken; const refreshToken = loginInfo?.refreshToken; const refreshRequest = refreshToken @@ -160,9 +203,9 @@ class ChatTokenManager { // 刷新期间用户可能退出或重新登录,旧请求结果不能覆盖新的登录状态。 const currentLoginInfo = uni.getStorageSync('loginInfo'); - const loginChanged = refreshToken + const loginChanged = businessToken !== uni.getStorageSync('token') || (refreshToken ? currentLoginInfo?.refreshToken !== refreshToken - : accessToken && currentLoginInfo?.accessToken !== accessToken; + : accessToken && currentLoginInfo?.accessToken !== accessToken); if (loginChanged) { if (currentLoginInfo?.accessToken) { return currentLoginInfo; @@ -172,6 +215,10 @@ class ChatTokenManager { return this.saveTokenInfo(newLoginInfo); }) + .catch((error) => { + this.handleAccountUnavailable(error, accessToken, businessToken); + throw error; + }) .finally(() => { this.refreshPromise = null; }); diff --git a/src/common/request.js b/src/common/request.js index fc64ef01..fab3bd00 100644 --- a/src/common/request.js +++ b/src/common/request.js @@ -6,6 +6,7 @@ const MAX_AUTH_RETRY_COUNT = 2; const request = (options, authRetryCount = 0) => { const header = options.header || {}; const loginInfo = uni.getStorageSync('loginInfo'); + const businessToken = uni.getStorageSync('token'); if (loginInfo) { header.accessToken = loginInfo.accessToken; } @@ -29,6 +30,8 @@ const request = (options, authRetryCount = 0) => { } } return resolve(res.data.data); + } else if (chatTokenManager.handleAccountUnavailable(res.data, header.accessToken, businessToken)) { + return reject(res.data); } else if (res.data.code == 400 || res.data.code == 401) { console.log('token失效,尝试重新获取'); const currentLoginInfo = uni.getStorageSync('loginInfo');