165 lines
6.4 KiB
TypeScript
165 lines
6.4 KiB
TypeScript
import axios from 'axios';
|
||
import type {
|
||
AxiosInstance,
|
||
AxiosRequestConfig,
|
||
AxiosResponse,
|
||
InternalAxiosRequestConfig,
|
||
} from 'axios';
|
||
import { envConfig } from '@/config/env';
|
||
import { authToken } from '@/services/authToken';
|
||
|
||
declare module 'axios' {
|
||
export interface AxiosRequestConfig {
|
||
/** 401 刷新重放标记,防止无限重试 */
|
||
_retried?: boolean;
|
||
}
|
||
}
|
||
|
||
const apiClient: AxiosInstance = axios.create({
|
||
baseURL: envConfig.apiBaseUrl,
|
||
timeout: 30000,
|
||
// 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送,
|
||
// 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。
|
||
});
|
||
|
||
// access token 仅在内存中短暂持有(docs/api/conventions.md §1),每次请求动态附加 Bearer 头
|
||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||
const token = authToken.get();
|
||
if (token) {
|
||
config.headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
return config;
|
||
});
|
||
|
||
// 认证接口自身的失败不进入刷新流程
|
||
const AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout'];
|
||
|
||
let refreshTask: Promise<string | null> | null = null;
|
||
|
||
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
|
||
function refreshAccessToken(): Promise<string | null> {
|
||
refreshTask ??= apiClient
|
||
.post('/auth/refresh')
|
||
.then((res) => {
|
||
const token = unwrapEnvelope<{ access_token: string }>(res).access_token;
|
||
authToken.set(token);
|
||
return token;
|
||
})
|
||
.catch(() => {
|
||
authToken.clear();
|
||
return null;
|
||
})
|
||
.finally(() => {
|
||
refreshTask = null;
|
||
});
|
||
return refreshTask;
|
||
}
|
||
|
||
apiClient.interceptors.response.use(
|
||
(response) => response,
|
||
async (error) => {
|
||
if (envConfig.debug) {
|
||
console.error('请求失败:', error.response?.data || error.message);
|
||
}
|
||
const config = error.config as AxiosRequestConfig | undefined;
|
||
const url = config?.url ?? '';
|
||
const isAuthPath = AUTH_PATHS.some((path) => url.includes(path));
|
||
// access token 过期时用 refresh cookie 换新 token 并重放一次;
|
||
// 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源)
|
||
if (error.response?.status === 401 && config && !config._retried && !isAuthPath) {
|
||
config._retried = true;
|
||
const token = await refreshAccessToken();
|
||
if (token) {
|
||
return apiClient.request(config);
|
||
}
|
||
window.location.assign('/login');
|
||
}
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
|
||
/**
|
||
* 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体
|
||
* (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身;
|
||
* 列表接口使用 getList 保留分页 meta。
|
||
*/
|
||
function unwrapEnvelope<T>(response: AxiosResponse): T {
|
||
const body = response.data as { data?: unknown } | undefined;
|
||
if (body === null || body === undefined) {
|
||
return undefined as T;
|
||
}
|
||
return body.data as T;
|
||
}
|
||
|
||
/** 列表响应信封为 { data: T[], meta: PaginationMeta }(docs/api/conventions.md §3),meta 需要原样保留 */
|
||
function unwrapListEnvelope<T, M>(response: AxiosResponse): { data: T[]; meta: M } {
|
||
const body = response.data as { data?: unknown; meta?: unknown } | undefined;
|
||
return {
|
||
data: Array.isArray(body?.data) ? (body.data as T[]) : [],
|
||
meta: (body?.meta ?? {}) as M,
|
||
};
|
||
}
|
||
|
||
export const api = {
|
||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||
apiClient.get<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||
|
||
getList: <T = unknown, M = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||
apiClient.get(url, config).then((res) => unwrapListEnvelope<T, M>(res)),
|
||
|
||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||
|
||
put: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||
apiClient.put<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||
|
||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||
|
||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||
apiClient.delete<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||
};
|
||
|
||
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
|
||
export function apiErrorMessage(error: unknown): string {
|
||
if (axios.isAxiosError(error)) {
|
||
const data = error.response?.data as
|
||
{ error?: { message?: unknown }; message?: unknown } | undefined;
|
||
if (data?.error?.message) return String(data.error.message);
|
||
if (data?.message) return String(data.message);
|
||
if (error.response) return `请求失败(HTTP ${error.response.status})`;
|
||
return '网络连接失败,请稍后重试';
|
||
}
|
||
return error instanceof Error ? error.message : String(error);
|
||
}
|
||
|
||
/** 提取业务错误码(如 VERSION_CONFLICT、DEFAULT_STORE_REPLACEMENT_REQUIRED),无则返回 null */
|
||
export function apiErrorCode(error: unknown): string | null {
|
||
if (!axios.isAxiosError(error)) return null;
|
||
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error?.code;
|
||
return typeof code === 'string' ? code : null;
|
||
}
|
||
|
||
export interface ApiErrorDetail {
|
||
field: string;
|
||
code: string;
|
||
message: string;
|
||
}
|
||
|
||
/** 提取错误信封中的字段级明细 details: [{ field, code, message }](docs/api/conventions.md §4) */
|
||
export function apiErrorDetails(error: unknown): ApiErrorDetail[] {
|
||
if (!axios.isAxiosError(error)) return [];
|
||
const details = (error.response?.data as { error?: { details?: unknown } } | undefined)?.error
|
||
?.details;
|
||
if (!Array.isArray(details)) return [];
|
||
return details.filter(
|
||
(item): item is ApiErrorDetail =>
|
||
!!item &&
|
||
typeof item === 'object' &&
|
||
typeof (item as ApiErrorDetail).field === 'string' &&
|
||
typeof (item as ApiErrorDetail).message === 'string'
|
||
);
|
||
}
|
||
|
||
export default apiClient;
|