- 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
419 lines
10 KiB
TypeScript
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 }
|