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,42 +1,77 @@
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { AUTH_STORAGE_KEY } from '@/constants'
import { APP_PREFERENCES_STORAGE_KEY, AUTH_STORAGE_KEY } from '@/constants'
/**@description 未登录 | 已登录 | 正在从存储恢复数据 */
export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
export interface AuthUser {
createTime?: number
channelId?: number
coin?: string
currentStreak?: number
email?: string
headImage?: string
id: string
isJackpot?: boolean
lastBetPeriodNo?: string
name?: string
oddsFactor?: number
phone?: string
registerInviteCode?: string
riskFlags?: number
roles?: string[]
streakLevel?: number
username?: string
uuid?: string
}
export interface AuthSessionInput {
accessToken: string
accessTokenExpiresAt?: number | null
currentUser?: AuthUser | null
refreshToken?: string | null
}
interface PersistedAuthState {
accessToken: string | null
/** @description 用户登录态 `user-token` 的绝对过期时间戳(毫秒)。 */
accessTokenExpiresAt: number | null
/** @description `/api/v1/authToken` 返回的服务端时间戳(秒),用于后续校时或服务端时间基准判断。 */
apiAuthServerTime: number | null
apiAuthToken: string | null
/** @description 接口鉴权 `auth-token` 的绝对过期时间戳(毫秒)。 */
apiAuthTokenExpiresAt: number | null
currentUser: AuthUser | null
refreshToken: string | null
}
interface PersistedAppPreferenceState {
appLanguage: string | null
deviceId: string | null
}
interface AuthState extends PersistedAuthState {
clearApiAuthToken: () => void
clearAccessToken: () => void
clearSession: () => void
finishHydration: () => void
isHydrated: boolean
lastUnauthorizedAt: string | null
markUnauthorized: () => void
setApiAuthToken: (token: {
expiresAt: number
serverTime: number
value: string
}) => void
setAccessToken: (token: string) => void
setCurrentUser: (user: AuthUser | null) => void
startSession: (session: AuthSessionInput) => void
status: AuthStatus
updateTokens: (tokens: {
accessToken: string
accessTokenExpiresAt?: number | null
refreshToken?: string | null
}) => void
}
@@ -47,10 +82,25 @@ function resolveAuthStatus(accessToken: string | null): AuthStatus {
const initialPersistedState: PersistedAuthState = {
accessToken: null,
accessTokenExpiresAt: null,
apiAuthServerTime: null,
apiAuthToken: null,
apiAuthTokenExpiresAt: null,
refreshToken: null,
currentUser: null,
}
function generateDeviceId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return `web_${crypto.randomUUID()}`
}
return `web_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
@@ -58,9 +108,24 @@ export const useAuthStore = create<AuthState>()(
status: 'restoring',
isHydrated: false,
lastUnauthorizedAt: null,
setApiAuthToken: ({ expiresAt, serverTime, value }) => {
set({
apiAuthServerTime: serverTime,
apiAuthToken: value,
apiAuthTokenExpiresAt: expiresAt,
})
},
clearApiAuthToken: () => {
set({
apiAuthServerTime: null,
apiAuthToken: null,
apiAuthTokenExpiresAt: null,
})
},
setAccessToken: (token) => {
set({
accessToken: token,
accessTokenExpiresAt: null,
status: 'authenticated',
isHydrated: true,
})
@@ -73,20 +138,24 @@ export const useAuthStore = create<AuthState>()(
},
startSession: ({
accessToken,
accessTokenExpiresAt = null,
currentUser = null,
refreshToken = null,
}) => {
set({
accessToken,
accessTokenExpiresAt,
currentUser,
refreshToken,
status: 'authenticated',
isHydrated: true,
})
},
updateTokens: ({ accessToken, refreshToken }) => {
updateTokens: ({ accessToken, accessTokenExpiresAt, refreshToken }) => {
set((state) => ({
accessToken,
accessTokenExpiresAt:
accessTokenExpiresAt ?? state.accessTokenExpiresAt,
refreshToken: refreshToken ?? state.refreshToken,
status: 'authenticated',
isHydrated: true,
@@ -101,6 +170,7 @@ export const useAuthStore = create<AuthState>()(
clearAccessToken: () => {
set({
accessToken: null,
accessTokenExpiresAt: null,
status: 'anonymous',
isHydrated: true,
})
@@ -126,6 +196,10 @@ export const useAuthStore = create<AuthState>()(
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
accessToken: state.accessToken,
accessTokenExpiresAt: state.accessTokenExpiresAt,
apiAuthServerTime: state.apiAuthServerTime,
apiAuthToken: state.apiAuthToken,
apiAuthTokenExpiresAt: state.apiAuthTokenExpiresAt,
currentUser: state.currentUser,
refreshToken: state.refreshToken,
}),
@@ -141,3 +215,53 @@ export const useAuthStore = create<AuthState>()(
},
),
)
interface AppPreferenceStoreState extends PersistedAppPreferenceState {
getOrCreateDeviceId: () => string
setAppLanguage: (language: string) => void
}
export const useAppPreferenceStore = create<AppPreferenceStoreState>()(
persist(
(set, get) => ({
appLanguage: null,
deviceId: null,
getOrCreateDeviceId: () => {
const deviceId = get().deviceId
if (deviceId) {
return deviceId
}
const nextDeviceId = generateDeviceId()
set({ deviceId: nextDeviceId })
return nextDeviceId
},
setAppLanguage: (language) => {
set({ appLanguage: language })
},
}),
{
name: APP_PREFERENCES_STORAGE_KEY,
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
appLanguage: state.appLanguage,
deviceId: state.deviceId,
}),
},
),
)
export function getAuthDeviceId() {
return useAppPreferenceStore.getState().getOrCreateDeviceId()
}
export function getStoredAppLanguage() {
return useAppPreferenceStore.getState().appLanguage
}
export function setStoredAppLanguage(language: string) {
useAppPreferenceStore.getState().setAppLanguage(language)
}

View File

@@ -22,7 +22,13 @@ import {
type GameRoundSlice = Pick<
GameBootstrapSnapshot,
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
| 'cells'
| 'chips'
| 'history'
| 'maxSelectionCount'
| 'round'
| 'selections'
| 'trends'
>
export interface GameRoundStoreState extends GameRoundSlice {
@@ -47,6 +53,7 @@ function createInitialRoundState(): GameRoundSlice & { activeChipId: string } {
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
@@ -68,6 +75,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
@@ -79,8 +87,19 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
getChipById(state.chips, state.activeChipId) ??
state.chips.find((chip) => chip.isDefault) ??
state.chips[0]
const hasExistingSelection = state.selections.some(
(selection) => selection.cellId === cellId,
)
const selectedCellCount = new Set(
state.selections.map((selection) => selection.cellId),
).size
if (!activeChip || state.round.phase !== 'betting') {
if (
!activeChip ||
state.round.phase !== 'betting' ||
hasExistingSelection ||
selectedCellCount >= state.maxSelectionCount
) {
return state
}
@@ -165,7 +184,13 @@ export const selectSelectionsByCell = (state: GameRoundStoreState) =>
export type GameRoundStore = typeof useGameRoundStore
export type GameRoundStoreData = Pick<
GameRoundStoreState,
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
| 'cells'
| 'chips'
| 'history'
| 'maxSelectionCount'
| 'round'
| 'selections'
| 'trends'
>
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }

View File

@@ -22,6 +22,9 @@ export interface GameSessionStoreState extends GameSessionSlice {
dismissAnnouncement: (announcementId: string) => void
hydrateSession: (snapshot: GameSessionSlice) => void
markAnnouncementRead: (announcementId: string) => void
requestRealtimeConnection: () => void
resetRealtimeConnectionRequest: () => void
shouldConnectRealtime: boolean
setConnectionLatency: (latencyMs: number | null) => void
setConnectionStatus: (status: ConnectionStatus) => void
syncConnection: (patch: Partial<ConnectionState>) => void
@@ -40,6 +43,7 @@ function createInitialSessionState(): GameSessionSlice {
export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
...createInitialSessionState(),
shouldConnectRealtime: false,
dismissAnnouncement: (announcementId) => {
set((state) => ({
announcements: {
@@ -57,7 +61,10 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
}))
},
hydrateSession: (snapshot) => {
set(snapshot)
set((state) => ({
...snapshot,
shouldConnectRealtime: state.shouldConnectRealtime,
}))
},
markAnnouncementRead: (announcementId) => {
set((state) => ({
@@ -69,6 +76,12 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
},
}))
},
requestRealtimeConnection: () => {
set({ shouldConnectRealtime: true })
},
resetRealtimeConnectionRequest: () => {
set({ shouldConnectRealtime: false })
},
setConnectionLatency: (latencyMs) => {
set((state) => ({
connection: {

View File

@@ -1,2 +1,3 @@
export * from './auth'
export * from './game'
export * from './modal'

1
src/store/modal/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './modal-store'

View File

@@ -0,0 +1,60 @@
import { create } from 'zustand'
import type { WithdrawTopupType } from '@/type'
export const MODAL_KEYS = [
/**@description 桌面端登录弹窗*/
'desktopLogin',
/**@description 桌面端注册弹窗*/
'desktopRegister',
/**@description 桌面端用户信息弹窗*/
'desktopUserInfo',
/**@description 桌面端公告弹窗*/
'desktopNotice',
/**@description 桌面端自动托管弹窗*/
'desktopAutoSetting',
/**@description 桌面端充值提现前置选择弹窗*/
'desktopProcedures',
/**@description 桌面端充值/提现弹窗*/
'desktopWithdrawTopup',
] as const
export type ModalKey = (typeof MODAL_KEYS)[number]
type ModalVisibilityMap = Record<ModalKey, boolean>
const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = {
desktopLogin: false,
desktopRegister: false,
desktopUserInfo: false,
desktopNotice: false,
desktopAutoSetting: false,
desktopProcedures: false,
desktopWithdrawTopup: false,
}
export interface ModalStoreState {
modals: ModalVisibilityMap
withdrawTopupType: WithdrawTopupType
closeAllModals: () => void
setModalOpen: (key: ModalKey, open: boolean) => void
setWithdrawTopupType: (type: WithdrawTopupType) => void
}
export const useModalStore = create<ModalStoreState>()((set) => ({
modals: INITIAL_MODAL_VISIBILITY,
withdrawTopupType: 'withdraw',
closeAllModals: () => {
set({ modals: INITIAL_MODAL_VISIBILITY })
},
setModalOpen: (key, open) => {
set((state) => ({
modals: {
...state.modals,
[key]: open,
},
}))
},
setWithdrawTopupType: (type) => {
set({ withdrawTopupType: type })
},
}))