优化构架

This commit is contained in:
2026-08-03 18:49:18 +08:00
parent 1241d11c01
commit 44c33a6280
133 changed files with 651 additions and 603 deletions
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { parseApiJsonResponse, parseJsonWithSafeIntegers } from '@chongchong/shared'
describe('safe integer API parsing', () => {
it('preserves backend long IDs as strings', () => {
expect(parseJsonWithSafeIntegers('{"id":2082674456475418625,"page":1}')).toEqual({
id: '2082674456475418625',
page: 1,
})
})
it('leaves non-JSON responses untouched', () => {
expect(parseApiJsonResponse('plain text')).toBe('plain text')
})
})
+90
View File
@@ -0,0 +1,90 @@
import axios, { AxiosHeaders } from 'axios'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApiResponseError,
getApiErrorDetails,
http,
registerAuthRefreshHandler,
registerAuthSessionExpiredHandler,
unwrapApiResponse,
} from './http'
const originalAdapter = http.defaults.adapter
afterEach(() => {
http.defaults.adapter = originalAdapter
registerAuthRefreshHandler(async () => { throw new Error('refresh handler is not configured') })
registerAuthSessionExpiredHandler(() => undefined)
})
describe('API error diagnostics', () => {
it('treats only body code 200 as success', () => {
expect(unwrapApiResponse({ code: '200', message: 'success', data: { id: '1' } }))
.toEqual({ id: '1' })
expect(() => unwrapApiResponse({ code: '0', message: 'legacy success code', data: null }))
.toThrow(ApiResponseError)
})
it('keeps business request and trace identifiers when unwrapping fails', () => {
expect(() => unwrapApiResponse({
code: 'A0403',
message: '无权限',
data: null,
requestId: 'request-1',
traceId: 'trace-1',
})).toThrow(ApiResponseError)
try {
unwrapApiResponse({ code: 'A0403', message: '无权限', data: null, requestId: 'request-1', traceId: 'trace-1' })
} catch (error) {
expect(getApiErrorDetails(error)).toEqual({
message: '无权限',
code: 'A0403',
requestId: 'request-1',
traceId: 'trace-1',
})
}
})
it('extracts identifiers from an HTTP error envelope', () => {
const error = new axios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, {
status: 503,
statusText: 'Service Unavailable',
headers: {},
config: { headers: new AxiosHeaders() },
data: { code: 'S0503', message: '服务不可用', requestId: 'request-2', traceId: 'trace-2' },
})
expect(getApiErrorDetails(error)).toEqual({
message: '服务不可用',
code: 'S0503',
requestId: 'request-2',
traceId: 'trace-2',
})
})
it('notifies session expiry once when concurrent 401 requests share a failed refresh', async () => {
const refresh = vi.fn(async () => { throw new Error('refresh token expired') })
const expired = vi.fn()
registerAuthRefreshHandler(refresh)
registerAuthSessionExpiredHandler(expired)
http.defaults.adapter = async (config) => {
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
status: 401,
statusText: 'Unauthorized',
headers: {},
config,
data: { code: 'A0401', message: '登录状态已过期' },
})
}
const results = await Promise.allSettled([
http.get('/platform/v1/orders'),
http.get('/platform/v1/warehouses'),
])
expect(results.every((result) => result.status === 'rejected')).toBe(true)
expect(refresh).toHaveBeenCalledTimes(1)
expect(expired).toHaveBeenCalledTimes(1)
})
})
+138
View File
@@ -0,0 +1,138 @@
import axios, { type InternalAxiosRequestConfig } from 'axios'
import { parseApiJsonResponse } from '@chongchong/shared'
export interface ApiEnvelope<T> {
code: string
message: string
data: T
requestId?: string | null
traceId?: string | null
timestamp?: string
}
export class ApiResponseError extends Error {
readonly code: string
readonly status?: number
readonly details?: unknown
readonly requestId?: string
readonly traceId?: string
constructor(message: string, code: string, status?: number, details?: unknown, requestId?: string | null, traceId?: string | null) {
super(message)
this.name = 'ApiResponseError'
this.code = code
this.status = status
this.details = details
this.requestId = requestId || undefined
this.traceId = traceId || undefined
}
}
export interface ApiErrorDetails {
message: string
code?: string
requestId?: string
traceId?: string
}
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 15_000,
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
})
type RetryableRequestConfig = InternalAxiosRequestConfig & { _authRetry?: boolean }
type AuthRefreshHandler = () => Promise<string>
type AuthSessionExpiredHandler = () => void | Promise<void>
let authRefreshHandler: AuthRefreshHandler | null = null
let authSessionExpiredHandler: AuthSessionExpiredHandler | null = null
let activeRefresh: Promise<string> | null = null
export function registerAuthRefreshHandler(handler: AuthRefreshHandler) {
authRefreshHandler = handler
}
export function registerAuthSessionExpiredHandler(handler: AuthSessionExpiredHandler) {
authSessionExpiredHandler = handler
}
http.interceptors.request.use((config) => {
const token = localStorage.getItem('cc-admin-token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
http.interceptors.response.use(
(response) => response.data,
async (error) => {
const config = error.config as RetryableRequestConfig | undefined
const isAuthEndpoint = typeof config?.url === 'string' && config.url.startsWith('/auth/')
if (error.response?.status !== 401 || !config || config._authRetry || isAuthEndpoint || !authRefreshHandler) {
return Promise.reject(error)
}
config._authRetry = true
try {
if (!activeRefresh) {
activeRefresh = authRefreshHandler()
.catch(async (refreshError) => {
try {
await authSessionExpiredHandler?.()
} catch {
// 会话已清理,跳转失败不应覆盖原始认证错误。
}
throw refreshError
})
.finally(() => { activeRefresh = null })
}
const nextToken = await activeRefresh
config.headers.Authorization = `Bearer ${nextToken}`
return http.request(config)
} catch {
return Promise.reject(error)
}
},
)
export function unwrapApiResponse<T>(response: ApiEnvelope<T>): T {
if (response?.code !== '200') {
throw new ApiResponseError(
response?.message || '接口请求失败',
response?.code || 'UNKNOWN',
undefined,
response?.data,
response?.requestId,
response?.traceId,
)
}
return response.data
}
export function getApiErrorDetails(error: unknown, fallback = '接口请求失败'): ApiErrorDetails {
if (error instanceof ApiResponseError) {
return { message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }
}
if (axios.isAxiosError(error)) {
const response = error.response?.data as {
code?: string
message?: string
error?: string
requestId?: string | null
traceId?: string | null
} | undefined
return {
message: response?.message || response?.error || fallback,
code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined),
requestId: response?.requestId || undefined,
traceId: response?.traceId || undefined,
}
}
return { message: error instanceof Error ? error.message : fallback }
}
export function getApiErrorMessage(error: unknown, fallback = '接口请求失败'): string {
return getApiErrorDetails(error, fallback).message
}