Files
ele-HeiXiu/src/services/request.ts
YoungestSongMo 6204c14b88 feat: 重构更新流程 — 无更新不抛异常、用户决定下载、更新详情展示(md文件中的)
【核心重构】
  - getLatestVersion() 不再 throw Error,无更新时返回 { version: APP_VERSION }
  - checkForUpdates() 先自行预检 → 无更新直发 IPC,有更新才交 electron-updater
  - 移除 NO_UPDATE_MESSAGE 常量及所有字符串匹配拦截代码
  - HeiXiuProvider 增加 500ms TTL 缓存,避免预检+electron-updater 重复请求 API

  【用户体验】
  - 检查到新版本后不再自动下载,展示更新内容供用户决定
  - 新增"下载更新"按钮(与"安装更新"分离为两步操作)
  - 设置页新增"查看详情"弹窗,Markdown 格式化渲染更新日志
  - available / downloading / downloaded 三个状态 UI 独立展示

  【Bug 修复】
  - 测试服务器版本排序:字符串序 → 语义版本序(_version_key),0.0.10 正确 > 0.0.9
  - 版本比较:!= → <(_version_key),防止高版本误判为有更新
  - get_best_release fallback 同样改为语义版本排序
  - Windows cmd set 命令尾部空格 → Invalid URL(.trim() 修复)
  - electron-updater 'error' 事件中拦截 NO_UPDATE_MESSAGE → UPDATE_NOT_AVAILABLE

  【文档更新】
  - CHANGELOG.md、PROJECT.md、UpdateA.md 同步更新架构图和流程说明
2026-06-04 18:31:52 +08:00

421 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// Axios 请求封装
//
// 功能:
// - 统一 BaseURL环境变量 VITE_API_BASE_URL
// - 请求拦截器:自动附加 Token
// - 响应拦截器:统一错误处理、业务码校验
// - 类型安全的 GET / POST / PUT / DELETE / UPLOAD
// - 请求超时、取消请求支持
// ============================================================
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
import type { ApiResponse } from './types';
import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types';
import { emit, EVENTS } from '../utils/event-bus';
import { logger } from '../utils/logger';
import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '../utils/safe-storage';
// ---------- 配置 ----------
/** API 基础地址(可通过 Vite 环境变量覆盖) */
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
/** 请求超时时间(毫秒) */
const TIMEOUT = 30_000;
/** localStorage 中 Token 的键名(密文由 safeStorage 保护) */
const TOKEN_KEY = 'ele-heixiu-token';
/** refresh token 接口路径(该接口 401 时不应触发 handle401否则死循环 */
const REFRESH_TOKEN_URL = '/api/v1/auth/refresh';
/** 判断请求是否为 refresh token 接口 */
function isRefreshRequest(config: InternalAxiosRequestConfig): boolean {
return config.url?.includes(REFRESH_TOKEN_URL) ?? false;
}
// ---------- Token 管理 ----------
/**
* 启动时初始化 Token从加密存储解密到内存缓存。
* 应在 AppProvider 挂载时调用login/logout 之前)。
*/
export async function initTokenStore(): Promise<void> {
await initSafeStore(TOKEN_KEY);
}
/** 获取当前内存缓存中的 Token同步高频安全 */
export function getToken(): string | null {
return getSafeStore(TOKEN_KEY);
}
/** 设置 Token异步加密存储 + 更新内存缓存) */
export async function setToken(token: string): Promise<void> {
await setSafeStore(TOKEN_KEY, token);
}
/** 清除 Token同步清除内存缓存 + localStorage */
export function clearToken(): void {
clearSafeStore(TOKEN_KEY);
}
// ---------- Token 刷新由外部注册request.ts 不依赖任何 auth 模块) ----------
let refreshHandler: (() => Promise<string | null>) | null = null;
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
/** 等待刷新完成的请求队列 */
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
/** 由 auth 模块调用,注册 token 刷新回调 */
export function setTokenRefreshHandler(handler: () => Promise<string | null>): void {
refreshHandler = handler;
}
function processQueue(error: Error | null, token: string | null): void {
failedQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token!);
});
failedQueue = [];
}
// ---------- Axios 实例 ----------
const instance: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
// ---------- 请求拦截器 ----------
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 自动附加 Token
const token = getToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
() => {
return Promise.reject(new RequestError(RequestErrorType.NETWORK, '请求配置失败'));
},
);
// ---------- 401 → 刷新 Token + 重试 ----------
function handle401(config: InternalAxiosRequestConfig): Promise<AxiosResponse<ApiResponse>> {
// 已经在刷新中 → 排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(instance(config));
},
reject,
});
});
}
// 没有注册刷新回调 → 直接要求重新登录
if (!refreshHandler) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(new RefreshError());
}
// 开始刷新
isRefreshing = true;
refreshPromise = refreshHandler();
return new Promise((resolve, reject) => {
refreshPromise!
.then((newToken) => {
if (newToken) {
processQueue(null, newToken);
config.headers.Authorization = `Bearer ${newToken}`;
resolve(instance(config));
} else {
const err = new RefreshError();
processQueue(err, null);
clearToken();
emit(EVENTS.AUTH_REQUIRED);
reject(err);
}
isRefreshing = false;
refreshPromise = null;
})
.catch((err) => {
processQueue(err, null);
isRefreshing = false;
refreshPromise = null;
clearToken();
emit(EVENTS.AUTH_REQUIRED);
reject(new RefreshError());
});
});
}
// ---------- 响应拦截器 ----------
instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response;
// HTTP 200 但业务码非 0 → 业务错误
if (data && data.code !== 0) {
// 401 → 尝试刷新 token 并重试(但 refresh 接口自身 401 必须直接拒绝,否则死循环)
if (data.code === 401 && response.config) {
if (isRefreshRequest(response.config)) {
return Promise.reject(new RefreshError());
}
return handle401(response.config);
}
logger.warn('request', `Business error: ${data.message || '请求失败'}`, undefined, {
url: response.config.url,
method: response.config.method?.toUpperCase(),
code: data.code,
traceId: data.traceId,
});
return Promise.reject(
new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
code: data.code,
traceId: data.traceId,
}),
);
}
return response;
},
(error) => {
// 提取请求上下文(所有日志共用)
const reqCtx = {
url: error.config?.url,
method: error.config?.method?.toUpperCase(),
};
// 全局捕获 RefreshError → 自动触发登录 Modal
if (isRefreshError(error)) {
logger.debug('request', 'Auth expired (RefreshError caught)', {
...reqCtx,
errorType: error.name,
errorMessage: error.message,
});
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(error);
}
// 请求已被取消
if (axios.isCancel(error)) {
const cancelErr = new RequestError(RequestErrorType.CANCELLED, '请求已取消');
logger.debug('request', 'Request cancelled', reqCtx);
return Promise.reject(cancelErr);
}
// 超时
if (error.code === 'ECONNABORTED') {
const timeoutErr = new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络');
logger.warn('request', 'Request timeout', timeoutErr, reqCtx);
return Promise.reject(timeoutErr);
}
// 无网络
if (!error.response) {
const netErr = new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接');
logger.warn('request', 'Network error', netErr, reqCtx);
return Promise.reject(netErr);
}
// HTTP 状态码错误
const { status, data } = error.response;
// 401 → 尝试刷新 token 并重试refresh 接口自身 401 直接拒绝,防死循环)
if (status === 401 && error.config) {
if (isRefreshRequest(error.config)) {
const refreshErr = new RefreshError();
logger.debug('request', 'Refresh endpoint returned 401', {
...reqCtx,
errorType: refreshErr.name,
});
return Promise.reject(refreshErr);
}
return handle401(error.config);
}
let errMsg = '请求失败';
switch (status) {
case 400:
errMsg = '请求参数有误';
break;
case 403:
errMsg = '没有访问权限';
break;
case 404:
errMsg = '请求的资源不存在';
break;
case 500:
errMsg = '服务器内部错误';
break;
case 502:
errMsg = '网关错误';
break;
case 503:
errMsg = '服务暂时不可用';
break;
}
const httpErr = new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
httpStatus: status,
code: data?.code,
});
// HTTP 5xx → error 级别4xx → warn 级别
if (status >= 500) {
logger.error('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx);
} else {
logger.warn('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx);
}
return Promise.reject(httpErr);
},
);
// ---------- 请求方法 ----------
/**
* 发起 GET 请求
* @param url - 请求路径
* @param params - 查询参数
* @param config - Axios 配置覆盖
*/
export async function get<T>(
url: string,
params?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<T> {
const res = await instance.get<ApiResponse<T>>(url, { ...config, params });
return res.data.data;
}
/**
* 发起 POST 请求JSON
* @param url - 请求路径
* @param data - 请求体
* @param config - Axios 配置覆盖
*/
export async function post<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const res = await instance.post<ApiResponse<T>>(url, data, config);
return res.data.data;
}
/**
* 发起 PUT 请求JSON
*/
export async function put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const res = await instance.put<ApiResponse<T>>(url, data, config);
return res.data.data;
}
/**
* 发起 DELETE 请求
*/
export async function del<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const res = await instance.delete<ApiResponse<T>>(url, config);
return res.data.data;
}
/**
* 文件上传multipart/form-data
* @param url - 上传接口路径
* @param formData - FormData 对象
* @param onProgress - 上传进度回调 (0~100)
*/
export async function upload<T>(
url: string,
formData: FormData,
onProgress?: (percent: number) => void,
): Promise<T> {
const res = await instance.post<ApiResponse<T>>(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000, // 上传超时 2 分钟
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percent);
}
},
});
return res.data.data;
}
/**
* 文件下载
* @param url - 下载接口路径
* @param filename - 保存的文件名
*/
export async function download(url: string, filename?: string): Promise<void> {
const res = await instance.get(url, {
responseType: 'blob',
});
const blob = new Blob([res.data]);
const downloadUrl = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = downloadUrl;
anchor.download = filename || 'download';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.URL.revokeObjectURL(downloadUrl);
}
/**
* 创建可取消的请求控制器
* 用法:
* const { controller, request } = createCancellableRequest();
* request(get('/some/url')); // 发起请求
* controller.abort(); // 取消请求
*/
export function createCancellableRequest() {
const controller = new AbortController();
return {
controller,
signal: controller.signal,
/** 包裹一个请求 Promise使其可通过 controller.abort() 取消 */
request: <T>(promise: Promise<T>): Promise<T> => {
return new Promise<T>((resolve, reject) => {
promise.then(resolve).catch(reject);
controller.signal.addEventListener('abort', () => {
reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
});
});
},
};
}
// ---------- 导出 axios 实例(供高级场景直接使用) ----------
export { instance as axiosInstance };
export { RequestError, RequestErrorType };