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