feat(auth): 集成认证授权功能并优化API客户端
- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取 - 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理 - 添加了WebSocket连接配置和API基础URL环境变量设置 - 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑 - 集成了MD5加密和认证令牌缓存机制,提升安全性 - 添加了多语言国际化支持,包括英语、中文、马来语和印尼语 - 实现了认证状态管理和本地存储持久化功能 - 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
465
src/features/game/hooks/use-game-realtime-sync.ts
Normal file
465
src/features/game/hooks/use-game-realtime-sync.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import i18n from '@/i18n'
|
||||
import { prefetchAuthToken } from '@/lib/api/api-client'
|
||||
import {
|
||||
GameSocketClient,
|
||||
type GameSocketMessage,
|
||||
} from '@/lib/ws/game-socket-client'
|
||||
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
|
||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
|
||||
import type { GameLobbyUserSnapshotDto, GamePeriodTickDto } from '../api/types'
|
||||
|
||||
const FALLBACK_POLL_INTERVAL_MS = 10_000
|
||||
const GAME_SOCKET_TOPICS = {
|
||||
// 对局状态心跳。每秒推送当前期号、状态、倒计时、runtime_enabled 等。
|
||||
periodTick: 'period.tick',
|
||||
// 本期封盘通知。用于前端立即停止下注。
|
||||
periodLocked: 'period.locked',
|
||||
// 本期开奖通知。用于同步开奖号码、所属期号等阶段结果。
|
||||
periodOpened: 'period.opened',
|
||||
// 本期派彩完成通知。用于结算阶段同步。
|
||||
periodPayout: 'period.payout',
|
||||
// 当前玩家连胜与赔率信息。通常在结算后或演示帧刷新。
|
||||
userStreak: 'user.streak',
|
||||
// 下注成功通知。仅当前用户可见,通常伴随扣款结果。
|
||||
betAccepted: 'bet.accepted',
|
||||
// 余额变化通知。充值、下注、派彩都会走这条流。
|
||||
walletChanged: 'wallet.changed',
|
||||
// 自动托管进度通知。包含托管开关、执行状态等。
|
||||
autoSpinProgress: 'auto.spin.progress',
|
||||
// 大奖命中通知。仅当本期存在中大奖用户时推送。
|
||||
jackpotHit: 'jackpot.hit',
|
||||
// 后台实时页全量快照。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||
adminLiveSnapshot: 'admin.live.snapshot',
|
||||
// 后台开奖结果通知。仅 admin live 页面使用,当前 H5 前台不订阅。
|
||||
adminLiveOpened: 'admin.live.opened',
|
||||
} as const
|
||||
|
||||
// 当前 H5 游戏页实际需要的用户侧事件。
|
||||
// 后台专用事件保持在 GAME_SOCKET_TOPICS 中做口径对齐,但不在这里订阅。
|
||||
const PLAYER_SOCKET_TOPICS = [
|
||||
GAME_SOCKET_TOPICS.periodTick,
|
||||
GAME_SOCKET_TOPICS.userStreak,
|
||||
GAME_SOCKET_TOPICS.periodOpened,
|
||||
GAME_SOCKET_TOPICS.periodLocked,
|
||||
GAME_SOCKET_TOPICS.periodPayout,
|
||||
GAME_SOCKET_TOPICS.betAccepted,
|
||||
GAME_SOCKET_TOPICS.walletChanged,
|
||||
GAME_SOCKET_TOPICS.autoSpinProgress,
|
||||
GAME_SOCKET_TOPICS.jackpotHit,
|
||||
] as const
|
||||
|
||||
const SOCKET_DISCONNECT_DELAY_MS = 150
|
||||
|
||||
let sharedSocketClient: GameSocketClient | null = null
|
||||
let sharedSocketKey: string | null = null
|
||||
let sharedSocketDisconnectTimerId: number | null = null
|
||||
|
||||
function toIsoFromUnixSeconds(seconds: number) {
|
||||
return new Date(seconds * 1000).toISOString()
|
||||
}
|
||||
|
||||
function toSocketLang(language: string | null | undefined) {
|
||||
return language?.startsWith('zh') ? 'zh' : 'en'
|
||||
}
|
||||
|
||||
function toOptionalNumber(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
value: unknown,
|
||||
key: string,
|
||||
): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const nested = (value as Record<string, unknown>)[key]
|
||||
|
||||
return nested && typeof nested === 'object'
|
||||
? (nested as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function extractServerTime(message: GameSocketMessage) {
|
||||
const root = message as Record<string, unknown>
|
||||
|
||||
if (typeof root.server_time === 'number') {
|
||||
return root.server_time
|
||||
}
|
||||
|
||||
const data = getNestedRecord(message, 'data')
|
||||
|
||||
return typeof data?.server_time === 'number' ? data.server_time : null
|
||||
}
|
||||
|
||||
function extractUserSnapshot(
|
||||
message: GameSocketMessage,
|
||||
): GameLobbyUserSnapshotDto | null {
|
||||
const direct = getNestedRecord(message, 'user_snapshot')
|
||||
const nested = getNestedRecord(
|
||||
getNestedRecord(message, 'data'),
|
||||
'user_snapshot',
|
||||
)
|
||||
const source = direct ?? nested
|
||||
|
||||
if (
|
||||
!source ||
|
||||
typeof source.coin !== 'string' ||
|
||||
typeof source.current_streak !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
coin: source.coin,
|
||||
current_streak: source.current_streak,
|
||||
is_jackpot:
|
||||
typeof source.is_jackpot === 'boolean' ? source.is_jackpot : undefined,
|
||||
odds_factor: toOptionalNumber(source.odds_factor),
|
||||
streak_level: toOptionalNumber(source.streak_level),
|
||||
}
|
||||
}
|
||||
|
||||
function extractPeriodTick(
|
||||
message: GameSocketMessage,
|
||||
): GamePeriodTickDto | null {
|
||||
const data = getNestedRecord(message, 'data')
|
||||
const nested = getNestedRecord(data, 'period')
|
||||
const source = nested ?? data
|
||||
|
||||
if (
|
||||
!source ||
|
||||
typeof source.period_no !== 'string' ||
|
||||
typeof source.status !== 'string' ||
|
||||
typeof source.countdown !== 'number' ||
|
||||
typeof source.bet_close_in !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
bet_close_in: source.bet_close_in,
|
||||
countdown: source.countdown,
|
||||
period_id: typeof source.period_id === 'number' ? source.period_id : null,
|
||||
period_no: source.period_no,
|
||||
result_number:
|
||||
typeof source.result_number === 'number' ? source.result_number : null,
|
||||
runtime_enabled:
|
||||
typeof source.runtime_enabled === 'boolean'
|
||||
? source.runtime_enabled
|
||||
: true,
|
||||
server_time:
|
||||
typeof source.server_time === 'number'
|
||||
? source.server_time
|
||||
: Math.floor(Date.now() / 1000),
|
||||
status: source.status as GamePeriodTickDto['status'],
|
||||
}
|
||||
}
|
||||
|
||||
function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
|
||||
const currentRoundState = useGameRoundStore.getState()
|
||||
const currentSessionState = useGameSessionStore.getState()
|
||||
|
||||
useGameRoundStore.getState().hydrateRound({
|
||||
cells: result.snapshot.cells,
|
||||
chips: result.snapshot.chips,
|
||||
history: currentRoundState.history,
|
||||
maxSelectionCount: result.snapshot.maxSelectionCount,
|
||||
round: currentRoundState.round,
|
||||
selections: currentRoundState.selections,
|
||||
trends: currentRoundState.trends,
|
||||
})
|
||||
|
||||
useGameSessionStore.getState().hydrateSession({
|
||||
announcements: result.snapshot.announcements,
|
||||
connection: {
|
||||
...result.snapshot.connection,
|
||||
status: 'connected',
|
||||
transport: 'polling',
|
||||
},
|
||||
dashboard: {
|
||||
...currentSessionState.dashboard,
|
||||
tableLimitMax: result.snapshot.dashboard.tableLimitMax,
|
||||
tableLimitMin: result.snapshot.dashboard.tableLimitMin,
|
||||
},
|
||||
})
|
||||
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (currentUser) {
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
coin: result.userSnapshot.coin,
|
||||
currentStreak: result.userSnapshot.current_streak,
|
||||
isJackpot: result.userSnapshot.is_jackpot,
|
||||
oddsFactor: result.userSnapshot.odds_factor,
|
||||
streakLevel: result.userSnapshot.streak_level,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function applyRealtimeMessage(message: GameSocketMessage) {
|
||||
const serverTime = extractServerTime(message)
|
||||
const period = extractPeriodTick(message)
|
||||
const userSnapshot = extractUserSnapshot(message)
|
||||
|
||||
if (period) {
|
||||
const previousRound = useGameRoundStore.getState().round
|
||||
const round = normalizePeriodTickRound(
|
||||
{
|
||||
...period,
|
||||
server_time: serverTime ?? period.server_time,
|
||||
},
|
||||
previousRound,
|
||||
)
|
||||
|
||||
useGameRoundStore.getState().syncRound({
|
||||
bettingClosesAt: round.bettingClosesAt,
|
||||
id: round.id,
|
||||
phase: round.phase,
|
||||
revealingAt: round.revealingAt,
|
||||
settledAt: round.settledAt,
|
||||
startedAt: round.startedAt,
|
||||
winningCellId: round.winningCellId,
|
||||
})
|
||||
useGameSessionStore.getState().syncDashboard({
|
||||
countdownMs: period.countdown * 1000,
|
||||
updatedAt:
|
||||
serverTime !== null
|
||||
? toIsoFromUnixSeconds(serverTime)
|
||||
: toIsoFromUnixSeconds(period.server_time),
|
||||
})
|
||||
}
|
||||
|
||||
if (userSnapshot) {
|
||||
const currentUser = useAuthStore.getState().currentUser
|
||||
|
||||
if (currentUser) {
|
||||
useAuthStore.getState().setCurrentUser({
|
||||
...currentUser,
|
||||
coin: userSnapshot.coin,
|
||||
currentStreak: userSnapshot.current_streak,
|
||||
isJackpot: userSnapshot.is_jackpot,
|
||||
oddsFactor: userSnapshot.odds_factor,
|
||||
streakLevel: userSnapshot.streak_level,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
lastMessageAt:
|
||||
serverTime !== null
|
||||
? toIsoFromUnixSeconds(serverTime)
|
||||
: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useGameRealtimeSync() {
|
||||
const accessToken = useAuthStore((state) => state.accessToken)
|
||||
const authStatus = useAuthStore((state) => state.status)
|
||||
const shouldConnectRealtime = useGameSessionStore(
|
||||
(state) => state.shouldConnectRealtime,
|
||||
)
|
||||
const socketClientRef = useRef<GameSocketClient | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (sharedSocketDisconnectTimerId !== null) {
|
||||
window.clearTimeout(sharedSocketDisconnectTimerId)
|
||||
sharedSocketDisconnectTimerId = null
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldConnectRealtime ||
|
||||
authStatus !== 'authenticated' ||
|
||||
!accessToken
|
||||
) {
|
||||
sharedSocketDisconnectTimerId = window.setTimeout(() => {
|
||||
sharedSocketClient?.disconnect()
|
||||
sharedSocketClient = null
|
||||
sharedSocketKey = null
|
||||
sharedSocketDisconnectTimerId = null
|
||||
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||
socketClientRef.current = sharedSocketClient
|
||||
return
|
||||
}
|
||||
|
||||
const websocketUrl = import.meta.env.VITE_WEBSOCKET_URL?.trim() || null
|
||||
const socketKey = `${websocketUrl ?? ''}::${accessToken}`
|
||||
|
||||
if (sharedSocketClient && sharedSocketKey === socketKey) {
|
||||
socketClientRef.current = sharedSocketClient
|
||||
|
||||
return () => {
|
||||
sharedSocketDisconnectTimerId = window.setTimeout(() => {
|
||||
sharedSocketClient?.disconnect()
|
||||
sharedSocketClient = null
|
||||
sharedSocketKey = null
|
||||
sharedSocketDisconnectTimerId = null
|
||||
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
sharedSocketClient?.disconnect()
|
||||
|
||||
const socketClient = new GameSocketClient({
|
||||
getContext: async () => {
|
||||
await prefetchAuthToken()
|
||||
|
||||
const authToken = useAuthStore.getState().apiAuthToken
|
||||
|
||||
if (!authToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
token: accessToken,
|
||||
authToken,
|
||||
deviceId: getAuthDeviceId(),
|
||||
lang: toSocketLang(i18n.resolvedLanguage),
|
||||
}
|
||||
},
|
||||
getUrl: () => websocketUrl,
|
||||
onError: (error) => {
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
lastError:
|
||||
'message' in error && typeof error.message === 'string'
|
||||
? error.message
|
||||
: 'WebSocket error',
|
||||
})
|
||||
},
|
||||
onLatencyChange: (latencyMs) => {
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
latencyMs,
|
||||
})
|
||||
},
|
||||
onMessage: (message) => {
|
||||
if (message.event === 'ws.connected') {
|
||||
const serverTime = extractServerTime(message)
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
connectedAt:
|
||||
serverTime !== null
|
||||
? toIsoFromUnixSeconds(serverTime)
|
||||
: new Date().toISOString(),
|
||||
lastError: null,
|
||||
lastMessageAt:
|
||||
serverTime !== null
|
||||
? toIsoFromUnixSeconds(serverTime)
|
||||
: new Date().toISOString(),
|
||||
reconnectAttempt: 0,
|
||||
status: 'connected',
|
||||
transport: 'websocket',
|
||||
})
|
||||
}
|
||||
|
||||
applyRealtimeMessage(message)
|
||||
},
|
||||
onStatusChange: (status, reconnectAttempt) => {
|
||||
const mappedStatus =
|
||||
status === 'idle'
|
||||
? 'idle'
|
||||
: status === 'connected'
|
||||
? 'connected'
|
||||
: status
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
latencyMs: mappedStatus === 'connected' ? undefined : null,
|
||||
reconnectAttempt,
|
||||
status: mappedStatus,
|
||||
transport: websocketUrl ? 'websocket' : 'polling',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
sharedSocketClient = socketClient
|
||||
sharedSocketKey = socketKey
|
||||
socketClientRef.current = socketClient
|
||||
socketClient.subscribe([...PLAYER_SOCKET_TOPICS])
|
||||
void socketClient.connect()
|
||||
|
||||
return () => {
|
||||
sharedSocketDisconnectTimerId = window.setTimeout(() => {
|
||||
if (sharedSocketClient === socketClient) {
|
||||
socketClient.disconnect()
|
||||
sharedSocketClient = null
|
||||
sharedSocketKey = null
|
||||
}
|
||||
|
||||
sharedSocketDisconnectTimerId = null
|
||||
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||
socketClientRef.current = sharedSocketClient
|
||||
}
|
||||
}, [accessToken, authStatus, shouldConnectRealtime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldConnectRealtime || authStatus !== 'authenticated') {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let intervalId = 0
|
||||
|
||||
const pollLobbyState = async () => {
|
||||
const connection = useGameSessionStore.getState().connection
|
||||
|
||||
if (
|
||||
connection.status === 'connected' &&
|
||||
connection.transport === 'websocket'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const result = await getGameLobbyInit()
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
applyLobbySync(result)
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
lastError: null,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
status: 'connected',
|
||||
transport: 'polling',
|
||||
})
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
useGameSessionStore.getState().syncConnection({
|
||||
lastError: error instanceof Error ? error.message : 'Polling failed',
|
||||
status: 'reconnecting',
|
||||
transport: 'polling',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
intervalId = window.setInterval(() => {
|
||||
void pollLobbyState()
|
||||
}, FALLBACK_POLL_INTERVAL_MS)
|
||||
void pollLobbyState()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}, [authStatus, shouldConnectRealtime])
|
||||
}
|
||||
Reference in New Issue
Block a user