From 327a03672a3953df7c21fc623464e2ffb079130d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 30 Aug 2026 21:19:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=86=BB=E7=BB=93=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=8D=B3=E6=97=B6=E4=B8=8B=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/router/router.go | 3 + frontend/src/App.vue | 2 + frontend/src/features/auth/api/auth.ts | 4 +- .../auth/composables/useSessionMonitor.ts | 117 ++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 frontend/src/features/auth/composables/useSessionMonitor.ts diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 5eb043e..aea3188 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -427,6 +427,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { authRoutes.POST("/password/reset", authHandler.ResetPassword) authRoutes.POST("/refresh", authHandler.Refresh) authRoutes.POST("/logout", authHandler.Logout) + if chatHubHandler != nil { + authRoutes.GET("/events", requireAuth, chatHubHandler.UserEvents) + } } api.GET("/me", requireAuth, userHandler.Me) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e27b114..d3d860b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,10 +5,12 @@ import { computed } from 'vue' import { RouterView, useRoute } from 'vue-router' import InAppBrowserPrompt from './components/InAppBrowserPrompt.vue' +import { useSessionMonitor } from './features/auth/composables/useSessionMonitor' import AdminLayout from './layouts/AdminLayout.vue' import AppLayout from './layouts/AppLayout.vue' const route = useRoute() +useSessionMonitor() const layout = computed(() => { if (route.path === '/m' || route.path.startsWith('/m/')) { return 'blank' diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index 0e88ea2..8406864 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -80,8 +80,8 @@ export async function resetPassword(phone: string, code: string, newPassword: st await apiClient.post('/auth/password/reset', { phone, code, new_password: newPassword }) } -export async function fetchMe() { - const { data } = await apiClient.get>('/me') +export async function fetchMe(options?: { silent?: boolean }) { + const { data } = await apiClient.get>('/me', options) return data.data } diff --git a/frontend/src/features/auth/composables/useSessionMonitor.ts b/frontend/src/features/auth/composables/useSessionMonitor.ts new file mode 100644 index 0000000..1c4676e --- /dev/null +++ b/frontend/src/features/auth/composables/useSessionMonitor.ts @@ -0,0 +1,117 @@ +import { onBeforeUnmount, onMounted, watch } from 'vue' + +import { fetchMe } from '@/features/auth/api/auth' +import { getAccessToken, type AuthScope } from '@/shared/utils/authStorage' +import { useSessionStore } from '@/stores/session' + +const scope: AuthScope = 'user' +const reconnectDelay = 3000 +const verificationInterval = 5000 + +// 保持一个轻量级会话通道,使后台冻结能在没有页面请求时立即让用户退出。 +export function useSessionMonitor() { + const session = useSessionStore() + let source: EventSource | null = null + let reconnectTimer: number | null = null + let verificationTimer: number | null = null + let checking = false + let stopped = false + + function closeSource() { + source?.close() + source = null + } + + function scheduleReconnect() { + if (stopped || reconnectTimer !== null || !session.isLoggedIn) return + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null + connect() + }, reconnectDelay) + } + + async function verifySession() { + if (stopped || checking || !session.isLoggedIn) return + checking = true + try { + await fetchMe({ silent: true }) + } catch { + // 认证失败由 axios 拦截器清理令牌并跳转;网络错误等待下一次校验。 + } finally { + checking = false + } + } + + function connect() { + if (stopped || source || !session.isLoggedIn) return + const token = getAccessToken(scope) + if (!token) return + + source = new EventSource(`/api/auth/events?token=${encodeURIComponent(token)}`, { + withCredentials: true, + }) + source.addEventListener('connected', () => undefined) + source.onerror = () => { + closeSource() + void verifySession() + scheduleReconnect() + } + } + + function stop() { + closeSource() + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer) + reconnectTimer = null + } + if (verificationTimer !== null) { + window.clearInterval(verificationTimer) + verificationTimer = null + } + } + + function start() { + stop() + if (!session.isLoggedIn) return + connect() + void verifySession() + verificationTimer = window.setInterval(verifySession, verificationInterval) + } + + function handleAuthStorageChanged(event: Event) { + const detail = (event as CustomEvent<{ scope?: AuthScope }>).detail + if (detail?.scope !== scope || stopped) return + if (session.isLoggedIn) { + start() + } else { + stop() + } + } + + function handleVisibilityChange() { + if (document.visibilityState === 'visible') void verifySession() + } + + onMounted(() => { + stopped = false + start() + window.addEventListener('auth-storage-changed', handleAuthStorageChanged) + document.addEventListener('visibilitychange', handleVisibilityChange) + }) + + onBeforeUnmount(() => { + stopped = true + stop() + window.removeEventListener('auth-storage-changed', handleAuthStorageChanged) + document.removeEventListener('visibilitychange', handleVisibilityChange) + }) + + watch( + () => session.isLoggedIn, + loggedIn => { + if (stopped) return + if (loggedIn) start() + else stop() + } + ) +}