diff --git a/apps/backend/src/utils/http.ts b/apps/backend/src/utils/http.ts index 50bece2b..d7a826b2 100644 --- a/apps/backend/src/utils/http.ts +++ b/apps/backend/src/utils/http.ts @@ -56,7 +56,8 @@ export function sendRouteError( logger(scope, fallbackMessage, { statusCode: classification.statusCode, errorCode: classification.errorCode, - error, + // 4xx 属预期失败(鉴权过期、参数错误等),带上完整错误对象只会刷十几行堆栈;仅 5xx 保留排查所需详情。 + ...(classification.statusCode >= 500 ? { error } : {}), }) res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage)) } diff --git a/apps/frontend/src/hooks/useRealtimeEvents.ts b/apps/frontend/src/hooks/useRealtimeEvents.ts index fc6f6b40..f22d5900 100644 --- a/apps/frontend/src/hooks/useRealtimeEvents.ts +++ b/apps/frontend/src/hooks/useRealtimeEvents.ts @@ -21,16 +21,26 @@ type UseRealtimeEventsOptions = { token: string onEvent: (event: RealtimeEvent) => void onConnected?: () => void + /** 鉴权失效(401/403)时触发:连接停止重连,调用方应清理会话并跳转登录页。 */ + onAuthExpired?: () => void } const RECONNECT_DELAY_MS = 3_000 /** 通过 fetch 保持带 Bearer 鉴权的 SSE 连接,避免把登录 token 暴露到 URL。 */ -export function useRealtimeEvents({ url, token, onEvent, onConnected }: UseRealtimeEventsOptions) { +export function useRealtimeEvents({ + url, + token, + onEvent, + onConnected, + onAuthExpired, +}: UseRealtimeEventsOptions) { const onEventRef = useRef(onEvent) const onConnectedRef = useRef(onConnected) + const onAuthExpiredRef = useRef(onAuthExpired) onEventRef.current = onEvent onConnectedRef.current = onConnected + onAuthExpiredRef.current = onAuthExpired useEffect(() => { if (!token) return @@ -50,6 +60,7 @@ export function useRealtimeEvents({ url, token, onEvent, onConnected }: UseRealt Authorization: `Bearer ${token}`, } if (lastEventId > 0) headers['Last-Event-ID'] = String(lastEventId) + let authExpired = false void fetch(url, { method: 'GET', headers, @@ -57,6 +68,12 @@ export function useRealtimeEvents({ url, token, onEvent, onConnected }: UseRealt signal: abortController.signal, }) .then(async (response) => { + if (response.status === 401 || response.status === 403) { + // 会话已失效:重连只会无限 401,交给调用方清理会话跳登录。 + authExpired = true + onAuthExpiredRef.current?.() + return + } if (!response.ok || !response.body) { throw new Error(`realtime_stream_${response.status}`) } @@ -73,7 +90,7 @@ export function useRealtimeEvents({ url, token, onEvent, onConnected }: UseRealt }) .catch(() => undefined) .finally(() => { - if (stopped || document.visibilityState !== 'visible') return + if (authExpired || stopped || document.visibilityState !== 'visible') return reconnectTimer = window.setTimeout(connect, RECONNECT_DELAY_MS) }) } diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 4c42e8ba..de21197f 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -87,6 +87,11 @@ export default function AdminLayout() { url: '/api/v1/admin/realtime', token: getAdminToken(), onEvent: (event) => applyAdminRealtimeEvent(event, queryClient), + // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 + onAuthExpired: () => { + clearAdminSession() + void navigate('/admin/login', { replace: true, state: { from: location } }) + }, }) const menuItems = useMemo(() => { @@ -541,12 +546,7 @@ function isRecord(value: unknown): value is Record { } type NotificationAlertKind = - | 'withdraw' - | 'recharge' - | 'acceptance' - | 'material' - | 'reminder' - | 'default' + 'withdraw' | 'recharge' | 'acceptance' | 'material' | 'reminder' | 'default' function playNotificationSound(kind: NotificationAlertKind) { try { diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 21bd2d30..dea8bedf 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -45,6 +45,11 @@ export default function WorkerLayout() { url: '/api/v1/worker/realtime', token: getWorkerToken(), onEvent: (event) => applyWorkerRealtimeEvent(event, queryClient), + // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 + onAuthExpired: () => { + clearWorkerSession() + void navigate('/', { replace: true }) + }, }) async function submitLogout() {