diff --git a/backend/internal/modules/chat/repository.go b/backend/internal/modules/chat/repository.go index 88e480e..19afa0c 100644 --- a/backend/internal/modules/chat/repository.go +++ b/backend/internal/modules/chat/repository.go @@ -191,14 +191,15 @@ func (r *Repository) EnsureSupportConversation(userID uint64) (*ConversationDTO, Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). Where("c.type = ? AND cp.participant_type = ? AND cp.participant_id = ?", "general_support", "user", userID). Order("c.id ASC"). - First(&existing).Error - if err == nil { + Limit(1). + Find(&existing).Error + if err != nil { + return err + } + if existing.ID > 0 { conversationID = existing.ID return nil } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } now := time.Now() conversation := model.ChatConversation{ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f119a59..23896ed 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -73,7 +73,7 @@ function rejectPendingRequests(scope: AuthScope, error: unknown) { pendingRequests.forEach(({ reject }) => reject(error)) } -async function refreshToken(scope: AuthScope): Promise { +export async function refreshAccessToken(scope: AuthScope): Promise { const refreshToken = getRefreshToken(scope) if (!refreshToken) throw new Error('no refresh token') @@ -153,7 +153,7 @@ apiClient.interceptors.response.use( state.refreshing = true try { - const newToken = await refreshToken(scope) + const newToken = await refreshAccessToken(scope) resolvePendingRequests(scope, newToken) originalRequest._retry = true originalRequest.headers.Authorization = `Bearer ${newToken}` diff --git a/frontend/src/composables/useChatSSE.ts b/frontend/src/composables/useChatSSE.ts index 625002b..d93f5af 100644 --- a/frontend/src/composables/useChatSSE.ts +++ b/frontend/src/composables/useChatSSE.ts @@ -1,4 +1,5 @@ import { onBeforeUnmount, ref, type Ref } from 'vue' +import { refreshAccessToken } from '@/api/client' import { getAccessToken, type AuthScope } from '@/utils/authStorage' export interface SSEMessage { @@ -29,6 +30,7 @@ export function useChatSSE(scope: AuthScope, endpoint: string) { let source: EventSource | null = null let reconnectTimer: ReturnType | null = null let stopped = false + let refreshing = false const handlers: EventHandler[] = [] function onEvent(handler: EventHandler) { @@ -36,6 +38,7 @@ export function useChatSSE(scope: AuthScope, endpoint: string) { } function connect() { + if (stopped || source) return const token = getAccessToken(scope) if (!token) return @@ -60,18 +63,27 @@ export function useChatSSE(scope: AuthScope, endpoint: string) { } catch { /* ignore */ } }) - source.onerror = () => { + source.onerror = async () => { connected.value = false source?.close() source = null - if (!stopped) { - reconnectTimer = setTimeout(connect, reconnectDelay) + if (stopped || refreshing) return + + refreshing = true + try { + await refreshAccessToken(scope) + if (!stopped) { + reconnectTimer = setTimeout(connect, reconnectDelay) + } + } catch { + closeSource() + } finally { + refreshing = false } } } - function disconnect() { - stopped = true + function closeSource() { if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null @@ -81,6 +93,21 @@ export function useChatSSE(scope: AuthScope, endpoint: string) { connected.value = false } + function handleAuthStorageChanged(event: Event) { + const detail = (event as CustomEvent<{ scope?: AuthScope }>).detail + if (detail?.scope !== scope || stopped) return + closeSource() + connect() + } + + function disconnect() { + stopped = true + closeSource() + window.removeEventListener('auth-storage-changed', handleAuthStorageChanged) + } + + window.addEventListener('auth-storage-changed', handleAuthStorageChanged) + onBeforeUnmount(() => { disconnect() })