fix: 暂存

This commit is contained in:
2026-06-24 08:30:33 +08:00
parent 7c12697579
commit 45f4d69446
2 changed files with 114 additions and 142 deletions

View File

@@ -1,186 +1,179 @@
import { ref } from 'vue' import { ref } from 'vue'
import { fetchEventSource } from '@microsoft/fetch-event-source' import { fetchEventSource } from '@microsoft/fetch-event-source'
import { sseBus } from '@/stores/sseBus' import { sseBus } from '@/stores/sseBus'
import type { SseEvents } from '@/stores/sseBus'
import { useAccountStore } from '@/stores/modules/account' import { useAccountStore } from '@/stores/modules/account'
const SSE_URL = '/api/sse/subscribe'
const MAX_RETRY_COUNT = 5
const BASE_RETRY_DELAY_MS = 3000
const MAX_RETRY_DELAY_MS = 30000
const isConnected = ref(false)
let abortController: AbortController | null = null
let currentToken: string | null = null
let connecting = false
let retryCount = 0
let isRefreshing = false
class SseHttpError extends Error {
constructor(
message: string,
readonly response: Response
) {
super(message)
this.name = 'SseHttpError'
}
}
function isSseHttpError(error: unknown): error is SseHttpError {
return error instanceof SseHttpError
}
function getRetryDelay(): number {
return Math.min(BASE_RETRY_DELAY_MS * Math.pow(2, retryCount - 1), MAX_RETRY_DELAY_MS)
}
function resetConnectionState(): void {
connecting = false
isConnected.value = false
}
/** /**
* SSE 连接管理 composable基于 @microsoft/fetch-event-source * Global SSE connection manager.
* *
* 设计原则: * Module-level state guarantees a single active connection across all callers.
* - 全局唯一:同一时间只有一条活跃的 SSE 连接
* - token 驱动:仅 accessToken 变更时才断开重连
* - 防重复:已连接(相同 token时 connect() 直接跳过
* - 所有业务变更通过 sseBus 事件总线推送,各组件自行订阅
*
* 使用示例:
* ```ts
* const { isConnected, connect, disconnect } = useSse()
* watch(() => accountStore.accessToken, (v) => v ? connect() : disconnect())
* ```
*/ */
export function useSse() { export function useSse() {
/** SSE 连接是否已建立 */
const isConnected = ref(false)
/** 用于取消 inflight 连接的控制器 */
let abortController: AbortController | null = null
/** 当前连接使用的 token用于判断是否需要重连 */
let currentToken: string | null = null
/** 是否正在建立连接(防并发) */
let connecting = false
/** 当前连接的重试计数 */
let retryCount = 0
/** 是否正在刷新 token防并发 */
let isRefreshing = false
/**
* 建立 SSE 连接
*
* 通过 fetch-event-source 发起,设置 saToken 请求头鉴权。
* 连接期间持续接收服务端推送事件,直到 abort 或服务端关闭。
*
* 防重复:如果已用相同 token 连接或正在连接中,直接跳过。
*/
async function connect(): Promise<void> { async function connect(): Promise<void> {
const accountStore = useAccountStore() const accountStore = useAccountStore()
const token = accountStore.accessToken const token = accountStore.accessToken
if (!token) { if (!token) {
console.warn('[SSE] accessToken,跳过连接') console.warn('[SSE] Missing accessToken, skip connection')
return return
} }
// 相同 token 已连接或正在连接中,跳过
if (currentToken === token && (isConnected.value || connecting)) { if (currentToken === token && (isConnected.value || connecting)) {
return return
} }
// 取消旧连接
abort() abort()
connecting = true connecting = true
currentToken = token currentToken = token
abortController = new AbortController() abortController = new AbortController()
// Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe await fetchEventSource(SSE_URL, {
fetchEventSource('/api/sse/subscribe', {
headers: { saToken: token }, headers: { saToken: token },
signal: abortController.signal, signal: abortController.signal,
openWhenHidden: true,
async onopen(response) {
const contentType = response.headers.get('content-type') || ''
if (response.status === 401) {
throw new SseHttpError('SSE unauthorized', response)
}
if (!response.ok) {
throw new SseHttpError(`SSE connection failed: ${response.status}`, response)
}
if (!contentType.includes('text/event-stream')) {
throw new SseHttpError(`Invalid SSE content-type: ${contentType || 'empty'}`, response)
}
/** 连接建立 */
async onopen() {
connecting = false connecting = false
isConnected.value = true isConnected.value = true
retryCount = 0 retryCount = 0
console.log('[SSE] 连接已建立') sseBus.emit('connected', undefined)
console.log('[SSE] Connected')
}, },
/** 收到服务端事件 */ onmessage(message) {
onmessage(msg) { if (!message.event || !message.data) {
// 心跳和 connected 确认消息可能 data 为空 return
if (!msg.data) return }
try { try {
const data = JSON.parse(msg.data) const data = JSON.parse(message.data)
// eslint-disable-next-line @typescript-eslint/no-explicit-any sseBus.emit(message.event as keyof SseEvents, data)
sseBus.emit(msg.event as any, data)
} catch { } catch {
console.warn('[SSE] 事件数据解析失败:', msg.event, msg.data) console.warn('[SSE] Failed to parse event payload:', message.event, message.data)
} }
}, },
/** 连接关闭 */
onclose() { onclose() {
connecting = false resetConnectionState()
isConnected.value = false console.log('[SSE] Closed')
console.log('[SSE] 连接已关闭')
}, },
/** onerror(error) {
* 连接错误处理(必须同步,签名: (err) => number | void resetConnectionState()
*
* 401 → fire-and-forget 刷新 token阻止当前连接重连
* 刷新成功后由 Vue watcher 触发 connect() 自动重连
* 其他错误 → 返回重试延迟ms库自动排队重连
*/
onerror(err) {
connecting = false
isConnected.value = false
// 鉴权失败 → 刷新 token异步 fire-and-forgetonerror 本身保持同步) if (isSseHttpError(error) && error.response.status === 401) {
if (err.response?.status === 401) { void refreshTokenAndReconnect()
if (isRefreshing) { throw error
// 已有刷新在进行中,直接放弃本次连接
throw err
} }
isRefreshing = true retryCount += 1
const store = useAccountStore() if (retryCount >= MAX_RETRY_COUNT) {
if (!store.refreshToken) {
sseBus.emit('error', { message: '登录已过期,请重新登录', retryCount: 0 })
throw err
}
// 异步刷新,不阻塞 onerror 返回
store
.refreshAccessToken()
.then((newToken) => {
if (newToken) {
console.log('[SSE] token 刷新成功,重新建立连接')
retryCount = 0
// connect() 由 App.vue 的 watch 触发accessToken 变更)
}
})
.catch(() => {
console.warn('[SSE] token 刷新失败')
sseBus.emit('error', { message: '实时连接异常,请刷新页面', retryCount: 0 })
})
.finally(() => {
isRefreshing = false
})
throw err // 不自动重连
}
// 其他错误:指数退避重连
retryCount++
if (retryCount >= 5) {
sseBus.emit('error', { sseBus.emit('error', {
message: '实时连接异常,请刷新页面', message: 'Realtime connection failed. Please refresh the page.',
retryCount retryCount
}) })
throw err // 超过阈值,停止重连 throw error
} }
// 返回重试间隔ms指数退避3s, 6s, 12s, 24s最大 30s const delay = getRetryDelay()
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 30000) console.warn(`[SSE] Connection error, retry in ${delay / 1000}s (${retryCount}/${MAX_RETRY_COUNT})`)
console.warn(`[SSE] 连接错误,${delay / 1000}s 后重试 (${retryCount}/5)`)
return delay return delay
} }
}) })
} }
/**
* 取消当前 SSE 连接
*/
function abort(): void { function abort(): void {
if (abortController) { if (abortController) {
abortController.abort() abortController.abort()
abortController = null abortController = null
} }
connecting = false
isConnected.value = false
currentToken = null currentToken = null
retryCount = 0 retryCount = 0
resetConnectionState()
} }
/**
* 断开 SSE 连接
*
* 在用户登出时调用。
*/
function disconnect(): void { function disconnect(): void {
abort() abort()
console.log('[SSE] 连接已断开') console.log('[SSE] Disconnected')
}
async function refreshTokenAndReconnect(): Promise<void> {
if (isRefreshing) {
return
}
const accountStore = useAccountStore()
if (!accountStore.refreshToken) {
sseBus.emit('error', { message: 'Login expired. Please sign in again.', retryCount: 0 })
return
}
isRefreshing = true
try {
const newToken = await accountStore.refreshAccessToken()
if (newToken) {
retryCount = 0
await connect()
}
} catch {
sseBus.emit('error', {
message: 'Realtime connection failed. Please refresh the page.',
retryCount: 0
})
} finally {
isRefreshing = false
}
} }
return { return {

View File

@@ -1,40 +1,19 @@
// webgame-frontend/src/stores/sseBus.ts
import mitt from 'mitt' import mitt from 'mitt'
import type { RoomItem } from '@/api/modules/game' import type { RoomItem } from '@/api/modules/game'
/** export type SseEvents = {
* SSE 推送事件类型映射 connected: undefined
*
* 每个事件类型对应一个数据类型,类型安全。
* connected / error 由 useSse 连接管理发出,其余由服务端 SSE 事件映射而来。
*/
type SseEvents = {
/** SSE 连接成功 */
connected: void
/** SSE 连接异常 */
error: { message: string; retryCount: number } error: { message: string; retryCount: number }
/** 新房间创建 */
room_created: RoomItem room_created: RoomItem
/** 房间状态更新 */
room_updated: RoomItem room_updated: RoomItem
/** 房间移除 */
room_removed: { roomId: string } room_removed: { roomId: string }
/** 被踢出房间 */
kicked: { roomId: string } kicked: { roomId: string }
/** 所在房间游戏开始 */
game_started: { roomId: string } game_started: { roomId: string }
/** 余额变动 */
balance_chg: { balance: number; delta: number } balance_chg: { balance: number; delta: number }
} }
/** /**
* 全局 SSE 事件总线(发布-订阅模式) * Global SSE event bus. The connection layer only dispatches events; feature
* * modules decide how to handle them.
* 示例:
* ```ts
* import { sseBus } from '@/stores/sseBus'
* const off = sseBus.on('room_updated', (room) => { ... })
* onUnmounted(off)
* ```
*/ */
export const sseBus = mitt<SseEvents>() export const sseBus = mitt<SseEvents>()