feat(auth): 集成认证授权功能并优化API客户端

- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取
- 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理
- 添加了WebSocket连接配置和API基础URL环境变量设置
- 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑
- 集成了MD5加密和认证令牌缓存机制,提升安全性
- 添加了多语言国际化支持,包括英语、中文、马来语和印尼语
- 实现了认证状态管理和本地存储持久化功能
- 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
JiaJun
2026-05-16 09:03:55 +08:00
parent 6aaf90a6ac
commit 5dd4e31db4
81 changed files with 6086 additions and 627 deletions

View 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])
}