Files
user-system/frontend/admin/src/lib/http/client.ts
Your Name 320aa9476f fix(frontend): ApiResponse data nullability contract
- Change ApiResponse.data from T to T | null to match backend reality
- Add compile-time type contract file (http.typecheck.ts)
- Maintain backward compatibility with existing service calls
- Add test for success response with null data

Refs: review-fix-closure-2026-05-28 ApiResponse nullability
2026-05-29 12:32:09 +08:00

419 lines
10 KiB
TypeScript

import { config } from '@/lib/config'
import { AppError, ErrorType } from '@/lib/errors'
import type { ApiResponse, RequestOptions } from '@/types'
import {
clearRefreshPromise,
clearSession,
endRefreshing,
getAccessToken,
getRefreshPromise,
isAccessTokenExpired,
isRefreshing,
setAccessToken,
setRefreshPromise,
startRefreshing,
} from './auth-session'
import { clearRefreshToken, getRefreshToken, setRefreshToken } from '../storage'
import { CSRF_PROTECTED_METHODS, getCSRFHeaders } from './csrf'
import type { TokenBundle } from '@/types'
const DEFAULT_TIMEOUT = 30_000
let inFlightRefreshBundle: Promise<TokenBundle> | null = null
function isFormDataBody(body: unknown): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData
}
function serializeBody(body: unknown): BodyInit | undefined {
if (body === undefined || body === null) {
return undefined
}
if (isFormDataBody(body)) {
return body
}
return JSON.stringify(body)
}
function resolveApiBaseUrl(): URL {
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
const rawBaseUrl = /^https?:\/\//i.test(config.apiBaseUrl)
? config.apiBaseUrl
: config.apiBaseUrl.startsWith('/')
? config.apiBaseUrl
: `/${config.apiBaseUrl}`
const baseUrl = new URL(rawBaseUrl, origin)
if (!baseUrl.pathname.endsWith('/')) {
baseUrl.pathname = `${baseUrl.pathname}/`
}
return baseUrl
}
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
const url = new URL(path.replace(/^\/+/, ''), resolveApiBaseUrl())
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
url.searchParams.append(key, String(value))
}
}
}
return url.toString()
}
function cleanupSessionOnAuthFailure(): never {
clearRefreshToken()
clearSession()
throw AppError.auth('会话已过期,请重新登录')
}
function createTimeoutSignal(signal?: AbortSignal): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
if (signal) {
signal.addEventListener('abort', () => controller.abort(), { once: true })
}
return {
signal: controller.signal,
cleanup: () => window.clearTimeout(timeoutId),
}
}
async function parseJsonResponse<T>(response: Response): Promise<ApiResponse<T>> {
const raw = await response.json()
// 运行时验证响应结构
if (!isApiResponse(raw)) {
throw new Error('Invalid API response structure: missing required fields')
}
return raw as ApiResponse<T>
}
/**
* 运行时验证 API 响应结构
* 防止后端返回异常格式时导致运行时错误
*/
function isApiResponse(obj: unknown): obj is ApiResponse<unknown> {
if (typeof obj !== 'object' || obj === null) {
return false
}
const r = obj as Record<string, unknown>
// 必须有 code 字段且为数字
if (typeof r.code !== 'number') {
return false
}
// 必须有 message 字段且为字符串
if (typeof r.message !== 'string') {
return false
}
// 如果有 data 字段,应该存在
// (data 可以是 undefined/null/任何类型,但我们允许这些值)
return true
}
async function refreshAccessToken(): Promise<TokenBundle> {
const refreshToken = getRefreshToken()
const body = refreshToken ? JSON.stringify({ refresh_token: refreshToken }) : undefined
const response = await fetch(buildUrl('/auth/refresh'), {
method: 'POST',
credentials: 'include',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body,
})
if (!response.ok) {
return cleanupSessionOnAuthFailure()
}
const result = await parseJsonResponse<TokenBundle>(response)
if (result.code !== 0) {
return cleanupSessionOnAuthFailure()
}
return result.data as TokenBundle
}
async function performTokenRefresh(): Promise<TokenBundle> {
if (inFlightRefreshBundle) {
return inFlightRefreshBundle
}
startRefreshing()
const promise = (async () => {
try {
const tokenBundle = await refreshAccessToken()
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
setRefreshToken(tokenBundle.refresh_token)
return tokenBundle
} finally {
endRefreshing()
clearRefreshPromise()
inFlightRefreshBundle = null
}
})()
inFlightRefreshBundle = promise
setRefreshPromise(
promise.then(
() => undefined,
() => undefined,
),
)
return promise
}
export async function refreshSessionBundle(): Promise<TokenBundle> {
return await performTokenRefresh()
}
async function performRefresh(): Promise<string> {
if (isRefreshing()) {
const promise = getRefreshPromise()
if (promise) {
await promise
}
const token = getAccessToken()
if (!token) {
return cleanupSessionOnAuthFailure()
}
return token
}
const tokenBundle = await performTokenRefresh()
return tokenBundle.access_token
}
async function resolveAuthorizationHeader(auth: boolean): Promise<string | null> {
if (!auth) {
return null
}
let token = getAccessToken()
if (isRefreshing()) {
const promise = getRefreshPromise()
if (promise) {
await promise
token = getAccessToken()
}
}
if (token && isAccessTokenExpired()) {
token = await performRefresh()
}
return token
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const {
method = 'GET',
headers = {},
body,
params,
auth = true,
credentials = 'include',
signal,
} = options
const url = buildUrl(path, params)
const requestHeaders: Record<string, string> = { ...headers }
if (body !== undefined && body !== null && !isFormDataBody(body) && !requestHeaders['Content-Type']) {
requestHeaders['Content-Type'] = 'application/json'
}
if (CSRF_PROTECTED_METHODS.includes(method)) {
Object.assign(requestHeaders, getCSRFHeaders())
}
const authToken = await resolveAuthorizationHeader(auth)
if (authToken) {
requestHeaders.Authorization = `Bearer ${authToken}`
}
const timeout = createTimeoutSignal(signal)
try {
let response = await fetch(url, {
method,
headers: requestHeaders,
body: serializeBody(body),
credentials,
signal: timeout.signal,
})
if (response.status === 401 && auth) {
const refreshedToken = await performRefresh()
requestHeaders.Authorization = `Bearer ${refreshedToken}`
response = await fetch(url, {
method,
headers: requestHeaders,
body: serializeBody(body),
credentials,
signal: timeout.signal,
})
}
if (response.status === 401) {
return cleanupSessionOnAuthFailure()
}
if (!response.ok) {
if (response.status === 403) {
throw AppError.forbidden()
}
if (response.status === 404) {
throw new AppError(404, '请求的资源不存在', {
status: 404,
type: ErrorType.NOT_FOUND,
})
}
throw AppError.network(`请求失败: ${response.status}`)
}
const result = await parseJsonResponse<T>(response)
if (result.code !== 0) {
throw AppError.fromResponse(result, response.status)
}
return result.data!
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw AppError.network('请求超时,请稍后重试')
}
throw error
} finally {
timeout.cleanup()
}
}
export function get<T>(
path: string,
params?: Record<string, string | number | boolean | undefined>,
options?: Omit<RequestOptions, 'method' | 'params' | 'body'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'GET', params })
}
export function post<T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, 'method' | 'body'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'POST', body })
}
export function put<T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, 'method' | 'body'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'PUT', body })
}
export function del<T>(
path: string,
options?: Omit<RequestOptions, 'method'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'DELETE' })
}
async function resolveAuthorizedHeaders(options?: Omit<RequestOptions, 'method' | 'params' | 'body'>): Promise<Record<string, string>> {
const headers: Record<string, string> = { ...(options?.headers ?? {}) }
if (options?.auth !== false) {
const token = await resolveAuthorizationHeader(true)
if (token) {
headers.Authorization = `Bearer ${token}`
}
}
return headers
}
export async function download(
path: string,
params?: Record<string, string | number | boolean | undefined>,
options?: Omit<RequestOptions, 'method' | 'params'>,
): Promise<Blob> {
const url = buildUrl(path, params)
const headers = await resolveAuthorizedHeaders(options)
const timeout = createTimeoutSignal(options?.signal)
try {
let response = await fetch(url, {
headers,
credentials: options?.credentials ?? 'include',
signal: timeout.signal,
})
if (response.status === 401 && options?.auth !== false) {
const refreshedToken = await performRefresh()
headers.Authorization = `Bearer ${refreshedToken}`
response = await fetch(url, {
headers,
credentials: options?.credentials ?? 'include',
signal: timeout.signal,
})
}
if (response.status === 401) {
return cleanupSessionOnAuthFailure()
}
if (!response.ok) {
throw AppError.network(`下载失败: ${response.status}`)
}
return response.blob()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw AppError.network('下载超时,请稍后重试')
}
throw error
} finally {
timeout.cleanup()
}
}
export async function upload<T>(
path: string,
file: File,
fieldName: string = 'file',
additionalData?: Record<string, string>,
options?: Omit<RequestOptions, 'method' | 'body'>,
): Promise<T> {
const formData = new FormData()
formData.append(fieldName, file)
if (additionalData) {
for (const [key, value] of Object.entries(additionalData)) {
formData.append(key, value)
}
}
return request<T>(path, {
...options,
method: 'POST',
body: formData,
})
}
export { request }