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

@@ -1,30 +1,43 @@
import { useMemo } from 'react'
import { CHIP_OPTIONS } from '@/constants'
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
import { selectSelectionTotal, useGameRoundStore } from '@/store/game'
const CHIP_IMAGE_MAP = new Map(
CHIP_OPTIONS.map((chip) => [chip.value, chip.src] as const),
)
function formatChipDisplayValue(amount: number) {
if (Number.isInteger(amount)) {
return String(amount)
}
return amount.toFixed(2).replace(/\.?0+$/, '')
}
export function useGameControlVm() {
const chips = useGameRoundStore((state) => state.chips)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
const selections = useGameRoundStore((state) => state.selections)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const selectChip = useGameRoundStore((state) => state.selectChip)
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
const chipItems = useMemo(
() =>
chips.map((chip) => ({
amount: chip.amount,
id: chip.id,
isSelected: chip.id === activeChipId,
src: CHIP_IMAGE_MAP.get(chip.amount) ?? CHIP_OPTIONS[0]?.src ?? '',
valueLabel: String(chip.amount),
})),
[activeChipId, chips],
)
const chipItems = useMemo(() => {
const items = chips.map((chip) => ({
amount: chip.amount,
id: chip.id,
isSelected: chip.id === activeChipId,
src: CHIP_IMAGE_MAP.get(chip.id) ?? CHIP_IMAGE_OPTIONS[0]?.src ?? '',
valueLabel: formatChipDisplayValue(chip.amount),
}))
return items.sort((left, right) => {
if (left.isSelected === right.isSelected) {
return left.id.localeCompare(right.id, undefined, { numeric: true })
}
return left.isSelected ? 1 : -1
})
}, [activeChipId, chips])
const selectedChip =
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
@@ -33,10 +46,11 @@ export function useGameControlVm() {
canClear: selections.length > 0,
onChipSelect: selectChip,
onClearSelections: clearSelections,
maxSelectionCountLabel: maxSelectionCount,
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
selectedChipId: activeChipId,
selectedCountLabel: `${selections.length}/5`,
totalBetAmountLabel: String(totalBetAmount),
selectedCountLabel: selections.length,
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),
chips: chipItems,
}
}

View File

@@ -1,14 +1,20 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useGameRoundStore } from '@/store/game'
import { useTranslation } from 'react-i18next'
function formatSettledTime(iso: string) {
const date = new Date(iso)
import { getGameBetMyOrders } from '@/features/game/api/game-api'
import { useAuthStore } from '@/store/auth'
const GAME_HISTORY_PAGE_SIZE = 20
function formatCreatedTime(timestamp: number, locale: string) {
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) {
return '--'
}
return date.toLocaleString('zh-CN', {
return date.toLocaleString(locale, {
hour12: false,
month: '2-digit',
day: '2-digit',
@@ -18,26 +24,70 @@ function formatSettledTime(iso: string) {
})
}
function formatNumbers(numbers: number[]) {
if (numbers.length === 0) {
return '--'
}
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
}
export function useGameHistoryVm() {
const history = useGameRoundStore((state) => state.history)
const { i18n, t } = useTranslation()
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
const query = useInfiniteQuery({
queryKey: ['game', 'bet-my-orders', accessToken],
enabled: authStatus === 'authenticated' && Boolean(accessToken),
initialPageParam: 1,
queryFn: ({ pageParam }) =>
getGameBetMyOrders({
page: pageParam,
pageSize: GAME_HISTORY_PAGE_SIZE,
}),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.pagination.page + 1
const loadedCount =
lastPage.pagination.page * lastPage.pagination.page_size
return loadedCount < lastPage.pagination.total ? nextPage : undefined
},
})
const items = useMemo(
() =>
history.map((entry) => ({
id: entry.roundId,
payoutMultiplierLabel: `${entry.payoutMultiplier}x`,
roundId: entry.roundId,
settledAtLabel: formatSettledTime(entry.settledAt),
statusLabel: 'settled',
totalPoolAmountLabel: entry.totalPoolAmount.toFixed(2),
winningCellIdLabel: String(entry.winningCellId),
})),
[history],
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((entry) => ({
amountLabel: entry.total_amount,
createdAtLabel: formatCreatedTime(
entry.create_time,
i18n.resolvedLanguage ?? 'en-US',
),
id: entry.order_no,
numbersLabel: formatNumbers(entry.numbers),
orderNo: entry.order_no,
periodNo: entry.period_no,
resultNumberLabel:
entry.result_number === null
? '--'
: String(entry.result_number).padStart(2, '0'),
statusLabel: entry.status,
winAmountLabel: entry.win_amount,
})),
),
[i18n.resolvedLanguage, query.data?.pages],
)
return {
emptyText: 'No history yet',
isEmpty: items.length === 0,
emptyText: t('gameDesktop.history.empty'),
endText: t('gameDesktop.history.end'),
fetchNextPage: query.fetchNextPage,
hasNextPage: query.hasNextPage,
isEmpty: authStatus !== 'authenticated' || items.length === 0,
isFetchingNextPage: query.isFetchingNextPage,
isInitialLoading: query.isLoading,
items,
loadingText: t('gameDesktop.history.loading'),
}
}

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

View File

@@ -1,59 +1,67 @@
import { useMemo } from 'react'
import { getRoundCountdownMs } from '@/features/game/shared/selectors'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const PHASE_META = {
betting: {
description: '(Menerima Taruhan)',
label: 'OPEN',
descriptionKey: 'gameDesktop.status.phase.betting.description',
labelKey: 'gameDesktop.status.phase.betting.label',
toneClassName: 'text-[#78FF7F]',
},
locked: {
description: '(Taruhan Ditutup)',
label: 'LOCKED',
descriptionKey: 'gameDesktop.status.phase.locked.description',
labelKey: 'gameDesktop.status.phase.locked.label',
toneClassName: 'text-[#FFE375]',
},
revealing: {
description: '(Mengundi Hasil)',
label: 'DRAWING',
descriptionKey: 'gameDesktop.status.phase.revealing.description',
labelKey: 'gameDesktop.status.phase.revealing.label',
toneClassName: 'text-[#57E8FF]',
},
settled: {
description: '(Putaran Selesai)',
label: 'SETTLED',
descriptionKey: 'gameDesktop.status.phase.settled.description',
labelKey: 'gameDesktop.status.phase.settled.label',
toneClassName: 'text-[#FF9C6B]',
},
waiting: {
description: '(Menunggu Putaran Berikutnya)',
label: 'WAITING',
descriptionKey: 'gameDesktop.status.phase.waiting.description',
labelKey: 'gameDesktop.status.phase.waiting.label',
toneClassName: 'text-[#A7B6C7]',
},
} as const
export function useGameStatusVm() {
const { t } = useTranslation()
const cells = useGameRoundStore((state) => state.cells)
const round = useGameRoundStore((state) => state.round)
const trends = useGameRoundStore((state) => state.trends)
const dashboard = useGameSessionStore((state) => state.dashboard)
const currentUser = useAuthStore((state) => state.currentUser)
return useMemo(() => {
const oddsValue = cells[0]?.odds ?? '--'
const oddsValue =
typeof currentUser?.oddsFactor === 'number'
? currentUser.oddsFactor
: (cells[0]?.odds ?? '--')
const featuredTrend = trends.find(
(entry) => entry.cellId === dashboard.featuredCellId,
)
const phaseMeta = PHASE_META[round.phase]
const streakValue =
currentUser?.currentStreak ?? featuredTrend?.currentStreak ?? null
return {
acceptingBets: round.phase === 'betting',
countdownMs: getRoundCountdownMs(round),
countdownMs: dashboard.countdownMs,
limitLabel: `${dashboard.tableLimitMin}-${dashboard.tableLimitMax}`,
oddsLabel: `1:${oddsValue}`,
phase: round.phase,
phaseDescription: phaseMeta.description,
phaseLabel: phaseMeta.label,
phaseDescription: t(phaseMeta.descriptionKey),
phaseLabel: t(phaseMeta.labelKey),
phaseToneClassName: phaseMeta.toneClassName,
roundId: round.id,
streakLabel: featuredTrend ? `X${featuredTrend.currentStreak}` : '--',
roundId: round.id || '--',
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
}
}, [cells, dashboard, round, trends])
}, [cells, currentUser, dashboard, round, t, trends])
}