修复接单端过期会话重复请求
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { resolveRouteErrorLogLevel } from './http.js'
|
||||||
|
|
||||||
|
test('expected worker session expiry is debug-level', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveRouteErrorLogLevel({ statusCode: 401, errorCode: 'worker_auth_expired' }),
|
||||||
|
'debug',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
resolveRouteErrorLogLevel({ statusCode: 401, errorCode: 'worker_auth_invalid' }),
|
||||||
|
'warn',
|
||||||
|
)
|
||||||
|
assert.equal(resolveRouteErrorLogLevel({ statusCode: 500, errorCode: 'internal_error' }), 'error')
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { logError, logWarn } from './logger.js'
|
import { logDebug, logError, logWarn } from './logger.js'
|
||||||
|
|
||||||
const RESPONSE_TIME_ZONE = 'Asia/Shanghai'
|
const RESPONSE_TIME_ZONE = 'Asia/Shanghai'
|
||||||
|
|
||||||
@@ -52,7 +52,8 @@ export function sendRouteError(
|
|||||||
scope = '[route]',
|
scope = '[route]',
|
||||||
): void {
|
): void {
|
||||||
const classification = classifyRouteError(error)
|
const classification = classifyRouteError(error)
|
||||||
const logger = classification.statusCode >= 500 ? logError : logWarn
|
const logLevel = resolveRouteErrorLogLevel(classification)
|
||||||
|
const logger = logLevel === 'error' ? logError : logLevel === 'debug' ? logDebug : logWarn
|
||||||
logger(scope, fallbackMessage, {
|
logger(scope, fallbackMessage, {
|
||||||
statusCode: classification.statusCode,
|
statusCode: classification.statusCode,
|
||||||
errorCode: classification.errorCode,
|
errorCode: classification.errorCode,
|
||||||
@@ -62,6 +63,15 @@ export function sendRouteError(
|
|||||||
res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage))
|
res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRouteErrorLogLevel(classification: {
|
||||||
|
statusCode: number
|
||||||
|
errorCode: string
|
||||||
|
}): 'debug' | 'warn' | 'error' {
|
||||||
|
if (classification.statusCode >= 500) return 'error'
|
||||||
|
if (classification.errorCode === 'worker_auth_expired') return 'debug'
|
||||||
|
return 'warn'
|
||||||
|
}
|
||||||
|
|
||||||
export function buildNotFoundPayload(req: Request) {
|
export function buildNotFoundPayload(req: Request) {
|
||||||
return {
|
return {
|
||||||
code: 1,
|
code: 1,
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ import {
|
|||||||
import { App, Button, Layout, Menu, Typography } from 'antd'
|
import { App, Button, Layout, Menu, Typography } from 'antd'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router'
|
import { Outlet, useLocation, useNavigate } from 'react-router'
|
||||||
|
|
||||||
import { logoutWorker } from '@/services/worker'
|
import { logoutWorker } from '@/services/worker'
|
||||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||||
import { clearWorkerSession, getWorkerStatus, getWorkerUsername } from '@/utils/worker-auth'
|
import {
|
||||||
import { getWorkerToken } from '@/utils/worker-auth'
|
clearWorkerSession,
|
||||||
|
expireWorkerSession,
|
||||||
|
getWorkerSessionExpiryDelay,
|
||||||
|
getWorkerStatus,
|
||||||
|
getWorkerToken,
|
||||||
|
getWorkerTokenExpiresAt,
|
||||||
|
getWorkerUsername,
|
||||||
|
isWorkerSessionStorageKey,
|
||||||
|
} from '@/utils/worker-auth'
|
||||||
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
|
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
|
||||||
import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner'
|
import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner'
|
||||||
import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech'
|
import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech'
|
||||||
@@ -41,14 +50,45 @@ export default function WorkerLayout() {
|
|||||||
const status = getWorkerStatus()
|
const status = getWorkerStatus()
|
||||||
const currentKey = resolveSelectedKey(location.pathname)
|
const currentKey = resolveSelectedKey(location.pathname)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const navigateToLogin = () => {
|
||||||
|
if (window.location.pathname !== '/') {
|
||||||
|
void navigate('/', { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleSessionExpired = () => navigateToLogin()
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (isWorkerSessionStorageKey(event.key) && event.newValue === null) {
|
||||||
|
expireWorkerSession()
|
||||||
|
navigateToLogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('worker-session-expired', handleSessionExpired)
|
||||||
|
window.addEventListener('storage', handleStorage)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('worker-session-expired', handleSessionExpired)
|
||||||
|
window.removeEventListener('storage', handleStorage)
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const delay = getWorkerSessionExpiryDelay(getWorkerTokenExpiresAt())
|
||||||
|
if (delay === null) return
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
expireWorkerSession()
|
||||||
|
}, delay)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useRealtimeEvents({
|
useRealtimeEvents({
|
||||||
url: '/api/v1/worker/realtime',
|
url: '/api/v1/worker/realtime',
|
||||||
token: getWorkerToken(),
|
token: getWorkerToken(),
|
||||||
onEvent: (event) => applyWorkerRealtimeEvent(event, queryClient),
|
onEvent: (event) => applyWorkerRealtimeEvent(event, queryClient),
|
||||||
// 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。
|
// 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。
|
||||||
onAuthExpired: () => {
|
onAuthExpired: () => {
|
||||||
clearWorkerSession()
|
expireWorkerSession()
|
||||||
void navigate('/', { replace: true })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { clearAdminSession, getAdminToken } from '@/utils/admin-auth'
|
import { clearAdminSession, getAdminToken } from '@/utils/admin-auth'
|
||||||
import { isWorkerSite } from '@/utils/site'
|
import { isWorkerSite } from '@/utils/site'
|
||||||
import { clearWorkerSession, getWorkerToken } from '@/utils/worker-auth'
|
import { expireWorkerSession, getWorkerToken } from '@/utils/worker-auth'
|
||||||
|
|
||||||
export interface ApiEnvelope<T> {
|
export interface ApiEnvelope<T> {
|
||||||
code: number
|
code: number
|
||||||
@@ -73,7 +73,7 @@ http.interceptors.response.use(
|
|||||||
const errorCode = String(error?.response?.data?.errorCode || '').trim()
|
const errorCode = String(error?.response?.data?.errorCode || '').trim()
|
||||||
|
|
||||||
if (status === 401 && shouldClearWorkerSession(errorCode)) {
|
if (status === 401 && shouldClearWorkerSession(errorCode)) {
|
||||||
clearWorkerSession()
|
expireWorkerSession()
|
||||||
|
|
||||||
if (isWorkerSite() && window.location.pathname !== '/') {
|
if (isWorkerSite() && window.location.pathname !== '/') {
|
||||||
window.location.replace('/')
|
window.location.replace('/')
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ const WORKER_USERNAME_KEY = 'order-site-worker-username'
|
|||||||
const WORKER_STATUS_KEY = 'order-site-worker-status'
|
const WORKER_STATUS_KEY = 'order-site-worker-status'
|
||||||
const WORKER_DEVICE_ID_KEY = 'order-site-worker-device-id'
|
const WORKER_DEVICE_ID_KEY = 'order-site-worker-device-id'
|
||||||
|
|
||||||
|
const WORKER_SESSION_STORAGE_KEYS = new Set([
|
||||||
|
WORKER_TOKEN_KEY,
|
||||||
|
WORKER_EXPIRES_AT_KEY,
|
||||||
|
WORKER_ID_KEY,
|
||||||
|
WORKER_USERNAME_KEY,
|
||||||
|
WORKER_STATUS_KEY,
|
||||||
|
])
|
||||||
|
|
||||||
export function getWorkerToken() {
|
export function getWorkerToken() {
|
||||||
return localStorage.getItem(WORKER_TOKEN_KEY) || ''
|
return localStorage.getItem(WORKER_TOKEN_KEY) || ''
|
||||||
}
|
}
|
||||||
@@ -65,6 +73,27 @@ export function clearWorkerSession() {
|
|||||||
localStorage.removeItem(WORKER_STATUS_KEY)
|
localStorage.removeItem(WORKER_STATUS_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */
|
||||||
|
export function expireWorkerSession() {
|
||||||
|
const hadSession = Boolean(getWorkerToken() || getWorkerTokenExpiresAt())
|
||||||
|
clearWorkerSession()
|
||||||
|
if (hadSession && typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('worker-session-expired'))
|
||||||
|
}
|
||||||
|
return hadSession
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkerSessionStorageKey(key: string | null) {
|
||||||
|
return Boolean(key && WORKER_SESSION_STORAGE_KEYS.has(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkerSessionExpiryDelay(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 hasWorkerSession() {
|
export function hasWorkerSession() {
|
||||||
const token = getWorkerToken()
|
const token = getWorkerToken()
|
||||||
const expiresAt = getWorkerTokenExpiresAt()
|
const expiresAt = getWorkerTokenExpiresAt()
|
||||||
@@ -77,7 +106,6 @@ export function hasWorkerSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isExpired(expiresAt: string) {
|
function isExpired(expiresAt: string) {
|
||||||
if (!expiresAt) return false
|
const delay = getWorkerSessionExpiryDelay(expiresAt)
|
||||||
const expiresAtMs = Date.parse(expiresAt)
|
return delay !== null && delay <= 0
|
||||||
return Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { getWorkerSessionExpiryDelay } from '../src/utils/worker-auth.ts'
|
||||||
|
|
||||||
|
test('worker session expiry delay handles valid, expired, and invalid timestamps', () => {
|
||||||
|
const now = Date.parse('2026-08-30T09:00:00.000Z')
|
||||||
|
assert.equal(getWorkerSessionExpiryDelay('2026-08-30T09:00:05.000Z', now), 5_000)
|
||||||
|
assert.equal(getWorkerSessionExpiryDelay('2026-08-30T08:59:59.000Z', now), 0)
|
||||||
|
assert.equal(getWorkerSessionExpiryDelay('not-a-date', now), null)
|
||||||
|
assert.equal(getWorkerSessionExpiryDelay('', now), null)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user