feat(auth): 登录态改为滑动续期(后台 24h/7d,打手 7d/30d)

This commit is contained in:
yml2213
2026-10-25 13:28:58 +08:00
parent 81edf530db
commit 2fb7aae13f
31 changed files with 510 additions and 64 deletions
+42 -2
View File
@@ -46,11 +46,14 @@ import {
} from '@/services/admin'
import {
clearAdminSession,
expireAdminSession,
getAdminDisplayName,
getAdminRole,
getAdminSessionExpiryDelay,
getAdminToken,
getAdminTokenExpiresAt,
getAdminUsername,
isAdminSessionStorageKey,
setAdminDisplayName,
setAdminPermissions,
} from '@/utils/admin-auth'
@@ -77,7 +80,7 @@ export default function AdminLayout() {
)
const role = getAdminRole()
const username = getAdminUsername() || 'admin'
const expiresAt = getAdminTokenExpiresAt()
const [expiresAt, setExpiresAt] = useState(() => getAdminTokenExpiresAt())
const [soundEnabled, setSoundEnabled] = useState(
() => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1',
)
@@ -96,6 +99,43 @@ export default function AdminLayout() {
}
}, [adminSessionQuery.data])
useEffect(() => {
const navigateToLogin = () => {
if (!window.location.hash.startsWith('#/admin/login')) {
void navigate('/admin/login', { replace: true })
}
}
const handleSessionExpired = () => navigateToLogin()
const handleSessionRenewed = () => setExpiresAt(getAdminTokenExpiresAt())
const handleStorage = (event: StorageEvent) => {
if (!isAdminSessionStorageKey(event.key)) return
if (event.newValue === null) {
expireAdminSession()
navigateToLogin()
return
}
handleSessionRenewed()
}
window.addEventListener('admin-session-expired', handleSessionExpired)
window.addEventListener('admin-session-renewed', handleSessionRenewed)
window.addEventListener('storage', handleStorage)
return () => {
window.removeEventListener('admin-session-expired', handleSessionExpired)
window.removeEventListener('admin-session-renewed', handleSessionRenewed)
window.removeEventListener('storage', handleStorage)
}
}, [navigate])
useEffect(() => {
const delay = getAdminSessionExpiryDelay(expiresAt)
if (delay === null) return
const timer = window.setTimeout(() => {
expireAdminSession()
}, delay)
return () => window.clearTimeout(timer)
}, [expiresAt])
const displayName =
adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username
@@ -121,7 +161,7 @@ export default function AdminLayout() {
onEvent: (event) => applyAdminRealtimeEvent(event, queryClient),
// 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。
onAuthExpired: () => {
clearAdminSession()
expireAdminSession()
void navigate('/admin/login', { replace: true, state: { from: location } })
},
})
+15 -4
View File
@@ -9,7 +9,7 @@ import {
import { App, Button, Layout, Menu, Typography } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import type { MenuProps } from 'antd'
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router'
import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/services/worker'
@@ -29,6 +29,7 @@ import {
getWorkerToken,
getWorkerTokenExpiresAt,
getWorkerUsername,
isWorkerSessionExpiryKey,
isWorkerSessionStorageKey,
} from '@/utils/worker-auth'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
@@ -100,6 +101,8 @@ export default function WorkerLayout() {
[afterSalesPendingCount],
)
const [sessionExpiresAt, setSessionExpiresAt] = useState(() => getWorkerTokenExpiresAt())
useEffect(() => {
const navigateToLogin = () => {
if (window.location.pathname !== '/') {
@@ -107,30 +110,38 @@ export default function WorkerLayout() {
}
}
const handleSessionExpired = () => navigateToLogin()
const handleSessionRenewed = () => setSessionExpiresAt(getWorkerTokenExpiresAt())
const handleStorage = (event: StorageEvent) => {
if (isWorkerSessionStorageKey(event.key) && event.newValue === null) {
if (!isWorkerSessionStorageKey(event.key)) return
if (event.newValue === null) {
expireWorkerSession()
navigateToLogin()
return
}
if (isWorkerSessionExpiryKey(event.key)) {
handleSessionRenewed()
}
}
window.addEventListener('worker-session-expired', handleSessionExpired)
window.addEventListener('worker-session-renewed', handleSessionRenewed)
window.addEventListener('storage', handleStorage)
return () => {
window.removeEventListener('worker-session-expired', handleSessionExpired)
window.removeEventListener('worker-session-renewed', handleSessionRenewed)
window.removeEventListener('storage', handleStorage)
}
}, [navigate])
useEffect(() => {
const delay = getWorkerSessionExpiryDelay(getWorkerTokenExpiresAt())
const delay = getWorkerSessionExpiryDelay(sessionExpiresAt)
if (delay === null) return
const timer = window.setTimeout(() => {
expireWorkerSession()
}, delay)
return () => window.clearTimeout(timer)
}, [])
}, [sessionExpiresAt])
useRealtimeEvents({
url: '/api/v1/worker/realtime',
+36 -3
View File
@@ -1,7 +1,7 @@
import axios from 'axios'
import { clearAdminSession, getAdminToken } from '@/utils/admin-auth'
import { expireAdminSession, getAdminToken, setAdminSessionExpiresAt } from '@/utils/admin-auth'
import { isWorkerSite } from '@/utils/site'
import { expireWorkerSession, getWorkerToken } from '@/utils/worker-auth'
import { expireWorkerSession, getWorkerToken, setWorkerSessionExpiresAt } from '@/utils/worker-auth'
export interface ApiEnvelope<T> {
code: number
@@ -20,6 +20,7 @@ const http = axios.create({
http.interceptors.response.use(
(response) => {
applySessionExpiryHeaders(response)
const data = response.data as ApiEnvelope<unknown>
// Business-level error: HTTP 200 but code !== 0
@@ -36,6 +37,7 @@ http.interceptors.response.use(
return response.data
},
(error) => {
applySessionExpiryHeaders(error?.response)
const responseMessage =
typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : ''
const fallbackMessage = resolveFallbackHttpMessage(error)
@@ -57,7 +59,7 @@ http.interceptors.response.use(
const errorCode = String(error?.response?.data?.errorCode || '').trim()
if (status === 401 && shouldClearAdminSession(errorCode)) {
clearAdminSession()
expireAdminSession()
if (
window.location.hash.startsWith('#/admin') &&
@@ -105,6 +107,37 @@ http.interceptors.request.use((config) => {
return config
})
/**
* 服务端每次校验会话都会回传滑动续期后的空闲到期时间;
* 这里同步到 localStorage,WorkerLayout / AdminLayout 的定时器会自动重排。
*/
function applySessionExpiryHeaders(
response:
| {
config?: { url?: string }
headers?: Record<string, unknown> & { get?: (name: string) => unknown }
}
| undefined,
) {
if (!response) return
const url = String(response.config?.url || '')
if (!url.startsWith('/api/v1/admin') && !url.startsWith('/api/v1/worker')) return
const headers = response.headers
const raw =
typeof headers?.get === 'function'
? headers.get('x-session-expires-at')
: headers?.['x-session-expires-at']
const expiresAt = String(raw || '').trim()
if (!expiresAt) return
if (url.startsWith('/api/v1/admin')) {
setAdminSessionExpiresAt(expiresAt)
} else {
setWorkerSessionExpiresAt(expiresAt)
}
}
function resolveFallbackHttpMessage(error: unknown) {
const message = String((error as { message?: string })?.message ?? '')
+1
View File
@@ -127,6 +127,7 @@ export function fetchWorkerSession() {
return apiGet<{
authenticated: boolean
expiresAt: string
absoluteExpiresAt?: string
worker: WorkerUser
}>('/api/v1/worker/auth/session')
}
+2
View File
@@ -4,6 +4,7 @@ export type AdminUserStatus = 'active' | 'disabled'
export interface AdminLoginResponse {
token: string
expiresAt: string
absoluteExpiresAt?: string
user: {
userId: number
username: string
@@ -41,6 +42,7 @@ export interface AdminDeviceStatusResponse {
export interface AdminSessionSummary {
authenticated: boolean
expiresAt: string
absoluteExpiresAt?: string
user: {
userId: number
username: string
@@ -721,6 +721,7 @@ export type Pagination = {
export type WorkerLoginResponse = {
token: string
expiresAt: string
absoluteExpiresAt?: string
worker: WorkerUser
passwordWeak?: boolean
}
+41
View File
@@ -6,6 +6,16 @@ const ADMIN_DISPLAY_NAME_KEY = 'order-site-admin-display-name'
const ADMIN_ROLE_KEY = 'order-site-admin-role'
const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions'
const ADMIN_SESSION_STORAGE_KEYS = new Set([
ADMIN_TOKEN_KEY,
ADMIN_EXPIRES_AT_KEY,
ADMIN_USER_ID_KEY,
ADMIN_USERNAME_KEY,
ADMIN_DISPLAY_NAME_KEY,
ADMIN_ROLE_KEY,
ADMIN_PERMISSIONS_KEY,
])
const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin' | 'finance', number> = {
support: 1,
operator: 2,
@@ -143,6 +153,37 @@ export function clearAdminSession() {
localStorage.removeItem(ADMIN_PERMISSIONS_KEY)
}
/** 滑动续期:只更新本地空闲到期时间,Token 本身不重签(exp 是绝对上限)。 */
export function setAdminSessionExpiresAt(expiresAt: string) {
const normalized = String(expiresAt || '').trim()
if (!normalized || normalized === getAdminTokenExpiresAt()) return
localStorage.setItem(ADMIN_EXPIRES_AT_KEY, normalized)
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('admin-session-renewed'))
}
}
/** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */
export function expireAdminSession() {
const hadSession = Boolean(getAdminToken() || getAdminTokenExpiresAt())
clearAdminSession()
if (hadSession && typeof window !== 'undefined') {
window.dispatchEvent(new Event('admin-session-expired'))
}
return hadSession
}
export function isAdminSessionStorageKey(key: string | null) {
return Boolean(key && ADMIN_SESSION_STORAGE_KEYS.has(key))
}
export function getAdminSessionExpiryDelay(expiresAt: string, now = Date.now()) {
if (!expiresAt) return null
const expiresAtMs = Date.parse(expiresAt)
if (!Number.isFinite(expiresAtMs)) return null
return Math.max(0, expiresAtMs - now)
}
export function hasAdminSession() {
const token = getAdminToken()
const expiresAt = getAdminTokenExpiresAt()
+14
View File
@@ -73,6 +73,16 @@ export function clearWorkerSession() {
localStorage.removeItem(WORKER_STATUS_KEY)
}
/** 滑动续期:只更新本地空闲到期时间,Token 本身不重签(exp 是绝对上限)。 */
export function setWorkerSessionExpiresAt(expiresAt: string) {
const normalized = String(expiresAt || '').trim()
if (!normalized || normalized === getWorkerTokenExpiresAt()) return
localStorage.setItem(WORKER_EXPIRES_AT_KEY, normalized)
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('worker-session-renewed'))
}
}
/** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */
export function expireWorkerSession() {
const hadSession = Boolean(getWorkerToken() || getWorkerTokenExpiresAt())
@@ -87,6 +97,10 @@ export function isWorkerSessionStorageKey(key: string | null) {
return Boolean(key && WORKER_SESSION_STORAGE_KEYS.has(key))
}
export function isWorkerSessionExpiryKey(key: string | null) {
return key === WORKER_EXPIRES_AT_KEY
}
export function getWorkerSessionExpiryDelay(expiresAt: string, now = Date.now()) {
if (!expiresAt) return null
const expiresAtMs = Date.parse(expiresAt)