diff --git a/src/api/auth.js b/src/api/auth.js
index 966d4e7..da78f63 100644
--- a/src/api/auth.js
+++ b/src/api/auth.js
@@ -1,6 +1,8 @@
import { API_ROOT } from '../config/runtime.js'
-function authRequest(path, data) {
+const pendingRefreshes = new Map()
+
+function authRequest(path, data, accessToken = '') {
return new Promise((resolve, reject) => {
const url = `${API_ROOT}${path}`
console.info('[Overseas PDA auth] request', { method: 'POST', url })
@@ -8,6 +10,8 @@ function authRequest(path, data) {
url,
method: 'POST',
data,
+ header: { Accept: 'application/json', 'Content-Type': 'application/json',
+ ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
success(response) {
const body = response.data || {}
console.info('[Overseas PDA auth] response', { url, statusCode: response.statusCode, code: body.code,
@@ -15,9 +19,10 @@ function authRequest(path, data) {
contentType: response.header?.['Content-Type'] || response.header?.['content-type'],
requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'],
traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] })
- if (String(body.code ?? response.statusCode) !== '200') {
+ if (response.statusCode !== 200 || String(body.code) !== '200') {
const error = new Error(body.message || '认证服务请求失败')
error.code = body.code || response.statusCode
+ error.statusCode = response.statusCode
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
reject(error)
@@ -40,5 +45,16 @@ export function login(loginId, password) {
}
export function refresh(refreshToken) {
- return authRequest('/auth/refresh', { refreshToken })
+ if (pendingRefreshes.has(refreshToken)) return pendingRefreshes.get(refreshToken)
+ const pending = authRequest('/auth/refresh', { refreshToken }).finally(() => {
+ if (pendingRefreshes.get(refreshToken) === pending) pendingRefreshes.delete(refreshToken)
+ })
+ pendingRefreshes.set(refreshToken, pending)
+ return pending
+}
+
+export function getPendingRefresh(refreshToken) { return pendingRefreshes.get(refreshToken) || null }
+
+export function logout(accessToken, refreshToken) {
+ return authRequest('/auth/logout', { refreshToken }, accessToken)
}
diff --git a/src/api/logout-session.js b/src/api/logout-session.js
new file mode 100644
index 0000000..35500f2
--- /dev/null
+++ b/src/api/logout-session.js
@@ -0,0 +1,53 @@
+import { logout, refresh, getPendingRefresh } from './auth.js'
+import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js'
+
+function replacementForCapturedSession(captured, tokens) {
+ if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) {
+ throw new Error('退出刷新响应缺少会话凭据')
+ }
+ const previous = decodeAccessToken(captured.accessToken)
+ const replacement = decodeAccessToken(tokens.accessToken)
+ if (!Number.isFinite(Number(replacement?.exp)) || Number(replacement.exp) * 1000 <= Date.now()) {
+ throw new Error('退出刷新凭据无效或已过期')
+ }
+ for (const name of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) {
+ if (previous[name] == null || String(previous[name]) !== String(replacement[name])) {
+ throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销')
+ }
+ }
+ return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
+}
+
+// Active user logout only. Ordinary authentication failures continue to use local signOut.
+export function logoutCurrentSession() {
+ const captured = {
+ accessToken: session.token, refreshToken: session.refreshToken,
+ pendingRefresh: getPendingRefresh(session.refreshToken)
+ }
+ signOut()
+ const clearedRevision = getSessionRevision()
+ const revoke = async () => {
+ if (!captured.accessToken || !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' }
+ let credentials = captured
+ let refreshed = false
+ if (captured.pendingRefresh) {
+ // Auth rotates access/refresh credentials. Revoke the captured refresh's result,
+ // while the business refresh wrapper's revision guard prevents local restoration.
+ refreshed = true
+ credentials = replacementForCapturedSession(captured, await captured.pendingRefresh)
+ }
+ try {
+ await logout(credentials.accessToken, credentials.refreshToken)
+ } catch (error) {
+ if (error.network || error.statusCode !== 401 || refreshed) throw error
+ // Only an explicit expired-access HTTP 401 permits one refresh and one revoke.
+ credentials = replacementForCapturedSession(captured, await refresh(captured.refreshToken))
+ await logout(credentials.accessToken, credentials.refreshToken)
+ }
+ return { revoked: true }
+ }
+ return revoke().then(result => ({ ...result, clearedRevision }), error => {
+ error.logoutRevision = clearedRevision
+ throw error
+ })
+}
diff --git a/src/pages/profile/profile.vue b/src/pages/profile/profile.vue
index d9c39a4..500a025 100644
--- a/src/pages/profile/profile.vue
+++ b/src/pages/profile/profile.vue
@@ -13,7 +13,7 @@
- 退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。
+ 退出会撤销当前登录会话并清空本地作业草稿;未提交作业数据不得跨账号保留。
设置功能将在后续版本开放
@@ -22,7 +22,8 @@
import PopupHeader from '@/components/PopupHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js'
- import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js'
+ import { applyWarehouseProfile, session, requireSession, getSessionRevision } from '@/stores/session.js'
+ import { logoutCurrentSession } from '@/api/logout-session.js'
import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
components: { PopupHeader, PortalHeader },
@@ -66,7 +67,19 @@
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
- logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) }
+ logout() {
+ if (this._logoutPromise) return this._logoutPromise
+ this.closeLogout()
+ const operation = logoutCurrentSession()
+ this.user = null
+ uni.reLaunch({ url: '/pages/login/login' })
+ this._logoutPromise = operation.catch(error => {
+ if (getSessionRevision() === error.logoutRevision) {
+ uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none' })
+ }
+ })
+ return this._logoutPromise
+ }
}
}
diff --git a/tests/user-logout.test.mjs b/tests/user-logout.test.mjs
new file mode 100644
index 0000000..26c8430
--- /dev/null
+++ b/tests/user-logout.test.mjs
@@ -0,0 +1,116 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import { PDA_ROLE, PDA_AUTHORITY, session, signInWithTokens, signOut, getSessionRevision } from '../src/stores/session.js'
+import { receiving } from '../src/stores/receiving.js'
+import { refresh, getPendingRefresh } from '../src/api/auth.js'
+import { overseasRequest } from '../src/api/overseas.js'
+
+const helperUrl = new URL('../src/api/logout-session.js', import.meta.url)
+const logoutHelper = fs.existsSync(helperUrl) ? await import(helperUrl) : {}
+const jwt = claims => [Buffer.from('{"alg":"none"}').toString('base64url'), Buffer.from(JSON.stringify(claims)).toString('base64url'), 'signature'].join('.')
+function tokens(version='old', identity={}) { return {accessToken:jwt({user_id:42,membership_id:84,tenant_id:0,app_code:'PLATFORM',overseas_warehouse_id:900,exp:Math.floor(Date.now()/1000)+3600,version,...identity}),refreshToken:`refresh-${version}`,roles:[PDA_ROLE],authorities:[PDA_AUTHORITY]} }
+const ok = data => ({statusCode:200,data:{code:'200',data},header:{}})
+function prepare(){const values=new Map(),calls=[],launches=[],toasts=[];globalThis.uni={setStorageSync:(k,v)=>values.set(k,JSON.parse(JSON.stringify(v))),getStorageSync:k=>values.get(k),removeStorageSync:k=>values.delete(k),reLaunch:o=>launches.push(o.url),showToast:o=>toasts.push(o.title)};signOut();const original=tokens();signInWithTokens(original);receiving.current={containerNo:'ORIGINAL'};return {values,calls,launches,toasts,original}}
+function page(){const source=fs.readFileSync(new URL('../src/pages/profile/profile.vue',import.meta.url),'utf8').match(/