From ffb71f22761549670fabb18b8e0a30c3207973c5 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 17:32:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=A5=E5=8D=95=E7=AB=AF?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E4=BC=9A=E8=AF=9D=E9=87=8D=E5=A4=8D=E8=AF=B7?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/utils/http.test.ts | 16 ++++++++ apps/backend/src/utils/http.ts | 14 ++++++- apps/frontend/src/layouts/WorkerLayout.tsx | 48 ++++++++++++++++++++-- apps/frontend/src/lib/http.ts | 4 +- apps/frontend/src/utils/worker-auth.ts | 34 +++++++++++++-- apps/frontend/tests/worker-auth.test.ts | 12 ++++++ 6 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/utils/http.test.ts create mode 100644 apps/frontend/tests/worker-auth.test.ts diff --git a/apps/backend/src/utils/http.test.ts b/apps/backend/src/utils/http.test.ts new file mode 100644 index 00000000..fe0fa410 --- /dev/null +++ b/apps/backend/src/utils/http.test.ts @@ -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') +}) diff --git a/apps/backend/src/utils/http.ts b/apps/backend/src/utils/http.ts index d7a826b2..c087a3ef 100644 --- a/apps/backend/src/utils/http.ts +++ b/apps/backend/src/utils/http.ts @@ -1,5 +1,5 @@ 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' @@ -52,7 +52,8 @@ export function sendRouteError( scope = '[route]', ): void { 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, { statusCode: classification.statusCode, errorCode: classification.errorCode, @@ -62,6 +63,15 @@ export function sendRouteError( 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) { return { code: 1, diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 31000706..a085a91f 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -8,12 +8,21 @@ import { import { App, Button, Layout, Menu, Typography } from 'antd' import { useQueryClient } from '@tanstack/react-query' import type { MenuProps } from 'antd' +import { useEffect } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router' import { logoutWorker } from '@/services/worker' import { useIsMobile } from '@/utils/use-is-mobile' -import { clearWorkerSession, getWorkerStatus, getWorkerUsername } from '@/utils/worker-auth' -import { getWorkerToken } from '@/utils/worker-auth' +import { + clearWorkerSession, + expireWorkerSession, + getWorkerSessionExpiryDelay, + getWorkerStatus, + getWorkerToken, + getWorkerTokenExpiresAt, + getWorkerUsername, + isWorkerSessionStorageKey, +} from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech' @@ -41,14 +50,45 @@ export default function WorkerLayout() { const status = getWorkerStatus() 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({ url: '/api/v1/worker/realtime', token: getWorkerToken(), onEvent: (event) => applyWorkerRealtimeEvent(event, queryClient), // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 onAuthExpired: () => { - clearWorkerSession() - void navigate('/', { replace: true }) + expireWorkerSession() }, }) diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index f584b0df..4e15e740 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -1,7 +1,7 @@ import axios from 'axios' import { clearAdminSession, getAdminToken } from '@/utils/admin-auth' import { isWorkerSite } from '@/utils/site' -import { clearWorkerSession, getWorkerToken } from '@/utils/worker-auth' +import { expireWorkerSession, getWorkerToken } from '@/utils/worker-auth' export interface ApiEnvelope { code: number @@ -73,7 +73,7 @@ http.interceptors.response.use( const errorCode = String(error?.response?.data?.errorCode || '').trim() if (status === 401 && shouldClearWorkerSession(errorCode)) { - clearWorkerSession() + expireWorkerSession() if (isWorkerSite() && window.location.pathname !== '/') { window.location.replace('/') diff --git a/apps/frontend/src/utils/worker-auth.ts b/apps/frontend/src/utils/worker-auth.ts index 3252111d..f9b2cae0 100644 --- a/apps/frontend/src/utils/worker-auth.ts +++ b/apps/frontend/src/utils/worker-auth.ts @@ -5,6 +5,14 @@ const WORKER_USERNAME_KEY = 'order-site-worker-username' const WORKER_STATUS_KEY = 'order-site-worker-status' 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() { return localStorage.getItem(WORKER_TOKEN_KEY) || '' } @@ -65,6 +73,27 @@ export function clearWorkerSession() { 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() { const token = getWorkerToken() const expiresAt = getWorkerTokenExpiresAt() @@ -77,7 +106,6 @@ export function hasWorkerSession() { } function isExpired(expiresAt: string) { - if (!expiresAt) return false - const expiresAtMs = Date.parse(expiresAt) - return Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now() + const delay = getWorkerSessionExpiryDelay(expiresAt) + return delay !== null && delay <= 0 } diff --git a/apps/frontend/tests/worker-auth.test.ts b/apps/frontend/tests/worker-auth.test.ts new file mode 100644 index 00000000..567d6ac4 --- /dev/null +++ b/apps/frontend/tests/worker-auth.test.ts @@ -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) +})