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

@@ -4,14 +4,15 @@ import {
DEFAULT_REQUEST_ACCEPT_HEADER,
DEFAULT_REQUEST_TIMEOUT_MS,
} from '@/constants'
import type { AuthTokenDto } from '@/features/auth/api/types'
import { ApiError } from '@/lib/api/api-error.ts'
import {
handleUnauthorizedSession,
tryRefreshAuthSession,
} from '@/lib/auth/auth-session'
import { useAuthStore } from '@/store/auth'
import { ApiError } from './api-error'
import type { ApiResponse } from './types'
import { md5 } from '@/lib/crypto/md5'
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
import type { ApiResponse } from '@/type'
type RequestOptions = Omit<Options, 'json'>
type JsonRequestOptions<TBody> = RequestOptions & {
@@ -20,7 +21,12 @@ type JsonRequestOptions<TBody> = RequestOptions & {
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
const AUTH_REFRESH_ENDPOINT = 'api/user/refreshToken'
const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000
const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
const appEnv = import.meta.env.VITE_APP_ENV
const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
function normalizeApiBaseUrl(baseUrl: string | undefined) {
@@ -96,6 +102,15 @@ async function toApiError(error: unknown) {
export const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
const authTokenClient = ky.create({
prefix: apiBaseUrl,
retry: 0,
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
headers: {
Accept: DEFAULT_REQUEST_ACCEPT_HEADER,
},
})
const apiClient = ky.create({
prefix: apiBaseUrl,
retry: 0,
@@ -109,6 +124,7 @@ const apiClient = ky.create({
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
request.headers.set('user-token', token)
}
if (shouldLogRequests) {
@@ -128,9 +144,153 @@ const apiClient = ky.create({
},
})
function shouldAttachAuthToken(input: string) {
return input !== AUTH_TOKEN_ENDPOINT
}
function shouldTryRefreshAccessToken(input: string, options?: Options) {
if (
input === AUTH_REFRESH_ENDPOINT ||
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] === true
) {
return false
}
const authState = useAuthStore.getState()
return Boolean(
authState.accessToken &&
authState.accessTokenExpiresAt &&
authState.accessTokenExpiresAt <=
Date.now() + ACCESS_TOKEN_REFRESH_SKEW_MS,
)
}
function unwrapEnvelopeData<T>(response: ApiResponse<T>) {
if (response.code === 1) {
return response.data
}
throw new ApiError({
data: response,
message:
'msg' in response && typeof response.msg === 'string'
? response.msg
: 'message' in response && typeof response.message === 'string'
? response.message
: API_ERROR_MESSAGES.unexpected,
})
}
async function fetchAuthToken() {
try {
const authState = useAuthStore.getState()
if (
authState.apiAuthToken &&
authState.apiAuthTokenExpiresAt &&
authState.apiAuthTokenExpiresAt > Date.now() + AUTH_TOKEN_CACHE_SKEW_MS
) {
return authState.apiAuthToken
}
if (!authSecret) {
throw new ApiError({
message: 'auth.errors.authTokenConfigMissing',
})
}
const deviceId = getAuthDeviceId()
const timestamp = Math.floor(Date.now() / 1000)
const signature = md5(
`device_id=${deviceId}&secret=${authSecret}&timestamp=${timestamp}`,
).toUpperCase()
const response = await authTokenClient
.get(AUTH_TOKEN_ENDPOINT, {
searchParams: {
device_id: deviceId,
secret: authSecret,
signature,
timestamp: String(timestamp),
},
})
.json<ApiResponse<AuthTokenDto>>()
const data = unwrapEnvelopeData(response)
const expiresAt = Date.now() + data.expires_in * 1000
useAuthStore.getState().setApiAuthToken({
expiresAt,
serverTime: data.server_time,
value: data.auth_token,
})
return data.auth_token
} catch (error) {
throw await toApiError(error)
}
}
export async function prefetchAuthToken() {
await fetchAuthToken()
}
function createHeaders(headersInit?: Options['headers']) {
const headers = new Headers()
if (!headersInit) {
return headers
}
if (headersInit instanceof Headers) {
headersInit.forEach((value, key) => {
headers.set(key, value)
})
return headers
}
if (Array.isArray(headersInit)) {
for (const [key, value] of headersInit) {
headers.set(key, value)
}
return headers
}
for (const [key, value] of Object.entries(headersInit)) {
if (typeof value === 'string') {
headers.set(key, value)
}
}
return headers
}
async function buildRequestOptions(input: string, options?: Options) {
const headers = createHeaders(options?.headers)
if (shouldAttachAuthToken(input) && !headers.has('auth-token')) {
headers.set('auth-token', await fetchAuthToken())
}
return {
...options,
headers,
} satisfies Options
}
async function request<TResponse>(input: string, options?: Options) {
try {
const response = await apiClient(input, options)
if (shouldTryRefreshAccessToken(input, options)) {
await tryRefreshAuthSession()
}
const response = await apiClient(
input,
await buildRequestOptions(input, options),
)
const data = await parseResponseBody(response)
return data as TResponse
@@ -138,6 +298,7 @@ async function request<TResponse>(input: string, options?: Options) {
if (
error instanceof HTTPError &&
error.response.status === 401 &&
input !== AUTH_REFRESH_ENDPOINT &&
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
) {

View File

@@ -1,9 +1,4 @@
interface ApiErrorOptions {
message: string
status?: number
data?: unknown
url?: string
}
import type { ApiErrorOptions } from '@/type'
export class ApiError extends Error {
status: number | null

View File

@@ -1,6 +0,0 @@
/** @description 后端统一响应体结构。 */
export interface ApiResponse<T> {
code: number
msg: string
data: T
}

View File

@@ -61,6 +61,14 @@ export async function initializeAuthSession() {
return authInitializationPromise
}
export async function hydrateCurrentUser(initializer: CurrentUserInitializer) {
const currentUser = await initializer()
useAuthStore.getState().setCurrentUser(currentUser)
return currentUser
}
export async function tryRefreshAuthSession() {
if (refreshSessionPromise) {
return refreshSessionPromise
@@ -86,6 +94,8 @@ export async function tryRefreshAuthSession() {
useAuthStore.getState().startSession({
accessToken: nextSession.accessToken,
accessTokenExpiresAt:
nextSession.accessTokenExpiresAt ?? snapshot.accessTokenExpiresAt,
currentUser: nextSession.currentUser ?? snapshot.currentUser,
refreshToken: nextSession.refreshToken ?? snapshot.refreshToken,
})

5
src/lib/crypto/md5.ts Normal file
View File

@@ -0,0 +1,5 @@
import md5Hash from 'md5'
export function md5(value: string) {
return md5Hash(value)
}

113
src/lib/notify.ts Normal file
View File

@@ -0,0 +1,113 @@
import { create } from 'zustand'
const DEFAULT_TOAST_DURATION_MS = 3200
type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
export interface NotifyOptions {
description?: string
duration?: number
}
interface NotificationToast {
description?: string
duration: number
id: string
message: string
type: NotificationType
}
interface NotificationStoreState {
dismissToast: (id: string) => void
pushToast: (toast: NotificationToast) => void
toasts: NotificationToast[]
}
const toastTimers = new Map<string, number>()
export const useNotificationStore = create<NotificationStoreState>()((set) => ({
dismissToast: (id) => {
const timerId = toastTimers.get(id)
if (timerId) {
window.clearTimeout(timerId)
toastTimers.delete(id)
}
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}))
},
pushToast: (toast) => {
set((state) => ({
toasts: [...state.toasts.filter((item) => item.id !== toast.id), toast],
}))
},
toasts: [],
}))
function createToastId() {
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function showToast(
type: NotificationType,
message: string,
options?: NotifyOptions,
) {
const id = createToastId()
const duration = options?.duration ?? DEFAULT_TOAST_DURATION_MS
useNotificationStore.getState().pushToast({
description: options?.description,
duration,
id,
message,
type,
})
if (duration > 0) {
const timerId = window.setTimeout(() => {
useNotificationStore.getState().dismissToast(id)
}, duration)
toastTimers.set(id, timerId)
}
return id
}
export const notify = {
dismiss(id?: string) {
if (id) {
useNotificationStore.getState().dismissToast(id)
return id
}
const { toasts } = useNotificationStore.getState()
for (const toast of toasts) {
useNotificationStore.getState().dismissToast(toast.id)
}
return null
},
error(message: string, options?: NotifyOptions) {
return showToast('error', message, options)
},
info(message: string, options?: NotifyOptions) {
return showToast('info', message, options)
},
loading(message: string, options?: NotifyOptions) {
return showToast('loading', message, options)
},
message(message: string, options?: NotifyOptions) {
return showToast('info', message, options)
},
success(message: string, options?: NotifyOptions) {
return showToast('success', message, options)
},
warning(message: string, options?: NotifyOptions) {
return showToast('warning', message, options)
},
}

View File

@@ -4,3 +4,106 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
type FullscreenCapableElement = HTMLElement & {
mozRequestFullScreen?: () => Promise<void> | void
msRequestFullscreen?: () => Promise<void> | void
webkitRequestFullscreen?: () => Promise<void> | void
}
type FullscreenCapableDocument = Document & {
mozCancelFullScreen?: () => Promise<void> | void
mozFullScreenElement?: Element | null
msExitFullscreen?: () => Promise<void> | void
msFullscreenElement?: Element | null
webkitExitFullscreen?: () => Promise<void> | void
webkitFullscreenElement?: Element | null
}
const FULLSCREEN_CHANGE_EVENTS = [
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange',
] as const
export function isDesktopFullscreen() {
if (typeof document === 'undefined') {
return false
}
const fullscreenDocument = document as FullscreenCapableDocument
return Boolean(
document.fullscreenElement ||
fullscreenDocument.webkitFullscreenElement ||
fullscreenDocument.mozFullScreenElement ||
fullscreenDocument.msFullscreenElement,
)
}
export async function exitDesktopFullscreen() {
if (typeof document === 'undefined') {
return false
}
const fullscreenDocument = document as FullscreenCapableDocument
await Promise.resolve(
document.exitFullscreen?.() ??
fullscreenDocument.webkitExitFullscreen?.() ??
fullscreenDocument.mozCancelFullScreen?.() ??
fullscreenDocument.msExitFullscreen?.(),
)
return !isDesktopFullscreen()
}
export function subscribeDesktopFullscreenChange(listener: () => void) {
if (typeof document === 'undefined') {
return () => {}
}
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
document.addEventListener(eventName, listener)
}
return () => {
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
document.removeEventListener(eventName, listener)
}
}
}
export async function requestDesktopFullscreen(
target: HTMLElement = document.documentElement,
) {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return false
}
if (isDesktopFullscreen()) {
return true
}
const fullscreenTarget = target as FullscreenCapableElement
await Promise.resolve(
fullscreenTarget.requestFullscreen?.() ??
fullscreenTarget.webkitRequestFullscreen?.() ??
fullscreenTarget.mozRequestFullScreen?.() ??
fullscreenTarget.msRequestFullscreen?.(),
)
return isDesktopFullscreen()
}
export async function toggleDesktopFullscreen(
target: HTMLElement = document.documentElement,
) {
if (isDesktopFullscreen()) {
return exitDesktopFullscreen()
}
return requestDesktopFullscreen(target)
}

View File

@@ -0,0 +1,297 @@
type GameSocketContext = {
authToken: string
deviceId: string
lang: string
token: string
}
type GameSocketConnectedMessage = {
connection_id?: string
event: 'ws.connected'
heartbeat_interval?: number
server_time?: number
}
type GameSocketErrorMessage = {
code?: number
event: 'ws.error'
message?: string
}
type GameSocketPongMessage = {
action?: 'pong'
event?: 'pong'
server_time?: number
topic?: 'pong'
}
export type GameSocketMessage =
| GameSocketConnectedMessage
| GameSocketErrorMessage
| GameSocketPongMessage
| ({ event?: string } & Record<string, unknown>)
type GameSocketStatus =
| 'idle'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'disconnected'
type GameSocketClientOptions = {
getContext: () => Promise<GameSocketContext | null>
getUrl: () => string | null
onError?: (message: GameSocketErrorMessage | Error) => void
onLatencyChange?: (latencyMs: number | null) => void
onMessage?: (message: GameSocketMessage) => void
onStatusChange?: (status: GameSocketStatus, reconnectAttempt: number) => void
}
const MAX_RECONNECT_DELAY_MS = 10_000
const LATENCY_PROBE_INTERVAL_MS = 3_000
const LATENCY_PROBE_TIMEOUT_MS = 10_000
function toQueryString(context: GameSocketContext) {
const params = new URLSearchParams({
token: context.token,
auth_token: context.authToken,
device_id: context.deviceId,
lang: context.lang,
})
return params.toString()
}
export class GameSocketClient {
private heartbeatTimerId: number | null = null
private latencyProbeTimerId: number | null = null
private manualClose = false
private readonly options: GameSocketClientOptions
private pendingPingSentAt: number | null = null
private reconnectAttempt = 0
private reconnectTimerId: number | null = null
private socket: WebSocket | null = null
private readonly subscribedTopics = new Set<string>()
constructor(options: GameSocketClientOptions) {
this.options = options
}
async connect() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
return
}
this.clearReconnectTimer()
this.clearHeartbeatTimer()
this.clearLatencyProbeTimer()
const url = this.options.getUrl()
const context = await this.options.getContext()
if (!url || !context) {
this.setStatus('disconnected')
return
}
this.manualClose = false
this.setStatus(this.reconnectAttempt > 0 ? 'reconnecting' : 'connecting')
const socketUrl = new URL(url)
socketUrl.search = toQueryString(context)
const socket = new WebSocket(socketUrl.toString())
this.socket = socket
socket.addEventListener('open', () => {
this.flushSubscriptions()
})
socket.addEventListener('message', (event) => {
this.handleMessage(event.data)
})
socket.addEventListener('error', () => {
this.options.onError?.(new Error('WebSocket connection error'))
})
socket.addEventListener('close', () => {
this.socket = null
this.clearHeartbeatTimer()
this.clearLatencyProbeTimer()
this.pendingPingSentAt = null
this.options.onLatencyChange?.(null)
if (this.manualClose) {
this.setStatus('disconnected')
return
}
this.scheduleReconnect()
})
}
disconnect() {
this.manualClose = true
this.clearReconnectTimer()
this.clearHeartbeatTimer()
this.clearLatencyProbeTimer()
this.pendingPingSentAt = null
this.options.onLatencyChange?.(null)
this.socket?.close()
this.socket = null
this.setStatus('disconnected')
}
// Topics are de-duplicated locally and re-sent automatically after reconnect.
subscribe(topics: string[]) {
for (const topic of topics) {
this.subscribedTopics.add(topic)
}
this.send({
action: 'subscribe',
topics: [...this.subscribedTopics],
})
}
send(payload: Record<string, unknown>) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return
}
this.socket.send(JSON.stringify(payload))
}
private clearHeartbeatTimer() {
if (this.heartbeatTimerId !== null) {
window.clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
}
}
private clearReconnectTimer() {
if (this.reconnectTimerId !== null) {
window.clearTimeout(this.reconnectTimerId)
this.reconnectTimerId = null
}
}
private clearLatencyProbeTimer() {
if (this.latencyProbeTimerId !== null) {
window.clearInterval(this.latencyProbeTimerId)
this.latencyProbeTimerId = null
}
}
private flushSubscriptions() {
if (this.subscribedTopics.size === 0) {
return
}
this.send({
action: 'subscribe',
topics: [...this.subscribedTopics],
})
}
private sendPing() {
const now = performance.now()
if (
this.pendingPingSentAt !== null &&
now - this.pendingPingSentAt < LATENCY_PROBE_TIMEOUT_MS
) {
return
}
this.pendingPingSentAt = performance.now()
this.send({ action: 'ping' })
}
private handlePongMessage() {
if (this.pendingPingSentAt === null) {
return
}
const latencyMs = Math.max(
0,
Math.round(performance.now() - this.pendingPingSentAt),
)
this.pendingPingSentAt = null
this.options.onLatencyChange?.(latencyMs)
}
private handleConnectedMessage(message: GameSocketConnectedMessage) {
this.reconnectAttempt = 0
this.setStatus('connected')
this.options.onLatencyChange?.(null)
this.clearLatencyProbeTimer()
if (message.heartbeat_interval && message.heartbeat_interval > 0) {
this.clearHeartbeatTimer()
this.heartbeatTimerId = window.setInterval(() => {
this.sendPing()
}, message.heartbeat_interval * 1000)
}
this.latencyProbeTimerId = window.setInterval(() => {
this.sendPing()
}, LATENCY_PROBE_INTERVAL_MS)
this.flushSubscriptions()
this.sendPing()
}
private handleMessage(raw: string) {
if (raw.trim() === 'pong') {
this.handlePongMessage()
return
}
let message: GameSocketMessage
try {
message = JSON.parse(raw) as GameSocketMessage
} catch {
this.options.onError?.(new Error('WebSocket message parse failed'))
return
}
if (message.event === 'ws.connected') {
this.handleConnectedMessage(message as GameSocketConnectedMessage)
} else if (message.event === 'ws.error') {
this.options.onError?.(message as GameSocketErrorMessage)
} else if (
message.event === 'pong' ||
('action' in message && message.action === 'pong') ||
('topic' in message && message.topic === 'pong')
) {
this.handlePongMessage()
}
this.options.onMessage?.(message)
}
private scheduleReconnect() {
this.reconnectAttempt += 1
this.setStatus('reconnecting')
const delay = Math.min(
1000 * 2 ** Math.max(0, this.reconnectAttempt - 1),
MAX_RECONNECT_DELAY_MS,
)
this.clearReconnectTimer()
this.reconnectTimerId = window.setTimeout(() => {
void this.connect()
}, delay)
}
private setStatus(status: GameSocketStatus) {
this.options.onStatusChange?.(status, this.reconnectAttempt)
}
}