first commit
This commit is contained in:
185
src/api/auth.ts
Normal file
185
src/api/auth.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
export interface AuthUser {
|
||||
id?: string | number
|
||||
username?: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
export interface AuthSessionInput {
|
||||
token: string
|
||||
tokenType?: string
|
||||
refreshToken?: string
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string
|
||||
tokenType?: string
|
||||
refreshToken?: string
|
||||
expiresIn?: number
|
||||
user?: AuthUser
|
||||
}
|
||||
|
||||
interface ApiErrorPayload {
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').trim().replace(/\/$/, '')
|
||||
const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH ?? '/api/v1/auth/login'
|
||||
const REGISTER_PATH = import.meta.env.VITE_REGISTER_PATH ?? '/api/v1/auth/register'
|
||||
const REFRESH_PATH = import.meta.env.VITE_REFRESH_PATH ?? '/api/v1/auth/refresh'
|
||||
const LOGIN_BEARER_TOKEN = (import.meta.env.VITE_LOGIN_BEARER_TOKEN ?? '').trim()
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
if (!API_BASE_URL) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
// Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login
|
||||
try {
|
||||
const baseUrl = new URL(API_BASE_URL)
|
||||
const basePath = baseUrl.pathname.replace(/\/$/, '')
|
||||
if (basePath && normalizedPath.startsWith(`${basePath}/`)) {
|
||||
return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}`
|
||||
}
|
||||
} catch {
|
||||
// API_BASE_URL may be a relative path; fallback to direct join.
|
||||
}
|
||||
|
||||
return `${API_BASE_URL}${normalizedPath}`
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message ?? payload.error ?? '请求失败,请稍后再试')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function createAuthHeader(token: string, tokenType = 'Bearer'): string {
|
||||
const normalizedToken = token.trim()
|
||||
if (/^\S+\s+\S+/.test(normalizedToken)) {
|
||||
return normalizedToken
|
||||
}
|
||||
|
||||
return `${tokenType || 'Bearer'} ${normalizedToken}`
|
||||
}
|
||||
|
||||
function extractToken(payload: Record<string, unknown>): string {
|
||||
const candidate =
|
||||
payload.token ??
|
||||
payload.accessToken ??
|
||||
payload.access_token ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.token ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.accessToken ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.access_token
|
||||
|
||||
if (typeof candidate !== 'string' || candidate.length === 0) {
|
||||
throw new Error('登录成功,但后端未返回 token 字段')
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
function extractTokenType(payload: Record<string, unknown>): string | undefined {
|
||||
const candidate =
|
||||
payload.token_type ??
|
||||
payload.tokenType ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.token_type ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.tokenType
|
||||
|
||||
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
||||
}
|
||||
|
||||
function extractRefreshToken(payload: Record<string, unknown>): string | undefined {
|
||||
const candidate =
|
||||
payload.refresh_token ??
|
||||
payload.refreshToken ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.refresh_token ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.refreshToken
|
||||
|
||||
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
||||
}
|
||||
|
||||
function extractExpiresIn(payload: Record<string, unknown>): number | undefined {
|
||||
const candidate =
|
||||
payload.expires_in ??
|
||||
payload.expiresIn ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.expires_in ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.expiresIn
|
||||
|
||||
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined
|
||||
}
|
||||
|
||||
function extractUser(payload: Record<string, unknown>): AuthUser | undefined {
|
||||
const user = payload.user ?? (payload.data as Record<string, unknown> | undefined)?.user
|
||||
return typeof user === 'object' && user !== null ? (user as AuthUser) : undefined
|
||||
}
|
||||
|
||||
function parseAuthResult(payload: Record<string, unknown>): AuthResult {
|
||||
return {
|
||||
token: extractToken(payload),
|
||||
tokenType: extractTokenType(payload),
|
||||
refreshToken: extractRefreshToken(payload),
|
||||
expiresIn: extractExpiresIn(payload),
|
||||
user: extractUser(payload),
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(input: {
|
||||
username: string
|
||||
phone: string
|
||||
email: string
|
||||
password: string
|
||||
}): Promise<void> {
|
||||
await request<Record<string, unknown>>(buildUrl(REGISTER_PATH), input)
|
||||
}
|
||||
|
||||
export async function login(input: { loginId: string; password: string }): Promise<AuthResult> {
|
||||
const payload = await request<Record<string, unknown>>(
|
||||
buildUrl(LOGIN_PATH),
|
||||
{
|
||||
login_id: input.loginId,
|
||||
password: input.password,
|
||||
},
|
||||
LOGIN_BEARER_TOKEN ? { Authorization: `Bearer ${LOGIN_BEARER_TOKEN}` } : undefined,
|
||||
)
|
||||
return parseAuthResult(payload)
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(input: AuthSessionInput): Promise<AuthResult> {
|
||||
if (!input.refreshToken) {
|
||||
throw new Error('缺少 refresh_token,无法刷新登录状态')
|
||||
}
|
||||
|
||||
const payload = await request<Record<string, unknown>>(
|
||||
buildUrl(REFRESH_PATH),
|
||||
{
|
||||
refreshToken: input.refreshToken,
|
||||
},
|
||||
{
|
||||
Authorization: createAuthHeader(input.token, input.tokenType),
|
||||
},
|
||||
)
|
||||
|
||||
return parseAuthResult(payload)
|
||||
}
|
||||
146
src/api/authed-request.ts
Normal file
146
src/api/authed-request.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { refreshAccessToken } from './auth'
|
||||
|
||||
export interface AuthSession {
|
||||
token: string
|
||||
tokenType?: string
|
||||
refreshToken?: string
|
||||
expiresIn?: number
|
||||
}
|
||||
|
||||
export interface AuthedRequestOptions {
|
||||
method: 'GET' | 'POST'
|
||||
path: string
|
||||
auth: AuthSession
|
||||
body?: Record<string, unknown>
|
||||
onAuthUpdated?: (next: AuthSession) => void
|
||||
}
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface ApiErrorPayload {
|
||||
code?: number
|
||||
msg?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class AuthExpiredError extends Error {
|
||||
constructor(message = '登录状态已过期,请重新登录') {
|
||||
super(message)
|
||||
this.name = 'AuthExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').trim().replace(/\/$/, '')
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
if (!API_BASE_URL) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = new URL(API_BASE_URL)
|
||||
const basePath = baseUrl.pathname.replace(/\/$/, '')
|
||||
if (basePath && normalizedPath.startsWith(`${basePath}/`)) {
|
||||
return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}`
|
||||
}
|
||||
} catch {
|
||||
// API_BASE_URL may be a relative path.
|
||||
}
|
||||
|
||||
return `${API_BASE_URL}${normalizedPath}`
|
||||
}
|
||||
|
||||
function createAuthHeader(token: string, tokenType = 'Bearer'): string {
|
||||
const normalizedToken = token.trim()
|
||||
if (/^\S+\s+\S+/.test(normalizedToken)) {
|
||||
return normalizedToken
|
||||
}
|
||||
|
||||
return `${tokenType || 'Bearer'} ${normalizedToken}`
|
||||
}
|
||||
|
||||
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiEnvelope<T> & ApiErrorPayload
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.msg ?? payload.message ?? payload.error ?? '请求失败,请稍后再试')
|
||||
}
|
||||
|
||||
if (typeof payload.code === 'number' && payload.code !== 0) {
|
||||
throw new Error(payload.msg ?? '接口返回失败')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function runRequest<T>(
|
||||
options: Omit<AuthedRequestOptions, 'auth'> & { session: AuthSession },
|
||||
): Promise<{ response: Response; parsed: ApiEnvelope<T> }> {
|
||||
const response = await fetch(buildUrl(options.path), {
|
||||
method: options.method,
|
||||
headers: {
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : undefined),
|
||||
Authorization: createAuthHeader(options.session.token, options.session.tokenType),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
response,
|
||||
parsed: { code: 401, msg: 'unauthorized', data: {} as T },
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = await parseEnvelope<T>(response)
|
||||
return { response, parsed }
|
||||
}
|
||||
|
||||
export async function authedRequest<T>(options: AuthedRequestOptions): Promise<T> {
|
||||
const first = await runRequest<T>({ ...options, session: options.auth })
|
||||
if (first.response.status !== 401) {
|
||||
return first.parsed.data
|
||||
}
|
||||
|
||||
if (!options.auth.refreshToken) {
|
||||
throw new AuthExpiredError()
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshed = await refreshAccessToken({
|
||||
token: options.auth.token,
|
||||
tokenType: options.auth.tokenType,
|
||||
refreshToken: options.auth.refreshToken,
|
||||
})
|
||||
|
||||
const nextAuth: AuthSession = {
|
||||
token: refreshed.token,
|
||||
tokenType: refreshed.tokenType ?? options.auth.tokenType,
|
||||
refreshToken: refreshed.refreshToken ?? options.auth.refreshToken,
|
||||
expiresIn: refreshed.expiresIn,
|
||||
}
|
||||
options.onAuthUpdated?.(nextAuth)
|
||||
|
||||
const second = await runRequest<T>({ ...options, session: nextAuth })
|
||||
if (second.response.status === 401) {
|
||||
throw new AuthExpiredError()
|
||||
}
|
||||
|
||||
return second.parsed.data
|
||||
} catch (error) {
|
||||
if (error instanceof AuthExpiredError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new AuthExpiredError()
|
||||
}
|
||||
}
|
||||
71
src/api/mahjong.ts
Normal file
71
src/api/mahjong.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { authedRequest, type AuthSession } from './authed-request'
|
||||
|
||||
export interface RoomItem {
|
||||
room_id: string
|
||||
name: string
|
||||
game_type: string
|
||||
owner_id: string
|
||||
max_players: number
|
||||
player_count: number
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RoomListResult {
|
||||
items: RoomItem[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const ROOM_CREATE_PATH =
|
||||
import.meta.env.VITE_ROOM_CREATE_PATH ?? '/api/v1/game/mahjong/room/create'
|
||||
const ROOM_LIST_PATH = import.meta.env.VITE_ROOM_LIST_PATH ?? '/api/v1/game/mahjong/room/list'
|
||||
const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahjong/room/join'
|
||||
|
||||
export async function createRoom(
|
||||
auth: AuthSession,
|
||||
input: { name: string; gameType: string; maxPlayers: number },
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<RoomItem> {
|
||||
return authedRequest<RoomItem>({
|
||||
method: 'POST',
|
||||
path: ROOM_CREATE_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
body: {
|
||||
name: input.name,
|
||||
game_type: input.gameType,
|
||||
max_players: input.maxPlayers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function listRooms(
|
||||
auth: AuthSession,
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<RoomListResult> {
|
||||
return authedRequest<RoomListResult>({
|
||||
method: 'GET',
|
||||
path: ROOM_LIST_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
export async function joinRoom(
|
||||
auth: AuthSession,
|
||||
input: { roomId: string },
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<void> {
|
||||
await authedRequest<Record<string, never> | RoomItem>({
|
||||
method: 'POST',
|
||||
path: ROOM_JOIN_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
body: {
|
||||
room_id: input.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
24
src/api/user.ts
Normal file
24
src/api/user.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { authedRequest, type AuthSession } from './authed-request'
|
||||
|
||||
export interface UserInfo {
|
||||
userID?: string
|
||||
username?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
nickname?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const USER_INFO_PATH = import.meta.env.VITE_USER_INFO_PATH ?? '/api/v1/user/info'
|
||||
|
||||
export async function getUserInfo(
|
||||
auth: AuthSession,
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<UserInfo> {
|
||||
return authedRequest<UserInfo>({
|
||||
method: 'GET',
|
||||
path: USER_INFO_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user