You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
301 lines
7.7 KiB
TypeScript
301 lines
7.7 KiB
TypeScript
import axios, { type AxiosRequestConfig } from "axios";
|
|
import { showErrorNative } from "../host/dialog";
|
|
import { getSession, setSession } from "../host/session";
|
|
import type { SessionState } from "../host/types";
|
|
|
|
export const API_QUEUE_CALLER_PATH = "/api/queue/caller";
|
|
const DEFAULT_API_PORT = 8845;
|
|
const AUTH_LOGIN_PATH = "/auth/login";
|
|
const AUTH_REFRESH_PATH = "/auth/refresh";
|
|
|
|
type ApiEnvelope<T = unknown> = {
|
|
code: number;
|
|
msg?: string;
|
|
message?: string;
|
|
data: T;
|
|
};
|
|
|
|
type RefreshTokenResponse = {
|
|
queueToken?: string;
|
|
refreshToken?: string;
|
|
};
|
|
|
|
type RetryableAxiosRequestConfig = AxiosRequestConfig & {
|
|
_retry?: boolean;
|
|
_skipAuthRefresh?: boolean;
|
|
};
|
|
|
|
let refreshTokenPromise: Promise<string | null> | null = null;
|
|
|
|
/**
|
|
* 根据配置中的服务器地址拼出后端 baseURL。
|
|
*/
|
|
export function buildBaseUrlFromServerIp(serverIp: string): string {
|
|
const raw = serverIp.trim();
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
|
|
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
|
return `${raw.replace(/\/$/, "")}${API_QUEUE_CALLER_PATH}`;
|
|
}
|
|
|
|
const hostPort = raw.replace(/^\/+/, "");
|
|
const hasPort = /:\d+$/.test(hostPort) || /^\[.+\]:\d+$/.test(hostPort);
|
|
if (hasPort) {
|
|
return `http://${hostPort}${API_QUEUE_CALLER_PATH}`;
|
|
}
|
|
|
|
return `http://${hostPort}:${DEFAULT_API_PORT}${API_QUEUE_CALLER_PATH}`;
|
|
}
|
|
|
|
const instance = axios.create({
|
|
baseURL: "",
|
|
timeout: 10000,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
function isApiSuccessCode(code: unknown): boolean {
|
|
return code === 200 || code === 0;
|
|
}
|
|
|
|
function getApiMessage(payload: unknown): string {
|
|
const data = (payload ?? {}) as { message?: unknown; msg?: unknown };
|
|
if (typeof data.message === "string" && data.message.trim() !== "") {
|
|
return data.message;
|
|
}
|
|
if (typeof data.msg === "string" && data.msg.trim() !== "") {
|
|
return data.msg;
|
|
}
|
|
return "请求失败";
|
|
}
|
|
|
|
function isAuthEndpoint(url?: string): boolean {
|
|
if (!url) {
|
|
return false;
|
|
}
|
|
return url.includes(AUTH_LOGIN_PATH) || url.includes(AUTH_REFRESH_PATH);
|
|
}
|
|
|
|
function setAuthHeader(config: AxiosRequestConfig, queueToken: string): void {
|
|
const headers = (config.headers ?? {}) as Record<string, string>;
|
|
headers.Authorization = `Bearer ${queueToken}`;
|
|
config.headers = headers;
|
|
}
|
|
|
|
async function refreshQueueToken(): Promise<string | null> {
|
|
if (refreshTokenPromise) {
|
|
return refreshTokenPromise;
|
|
}
|
|
|
|
refreshTokenPromise = (async () => {
|
|
const sessionState = await getSession();
|
|
const refreshToken = sessionState.refreshToken;
|
|
if (!refreshToken) {
|
|
return null;
|
|
}
|
|
|
|
const response = await axios.request<ApiEnvelope<RefreshTokenResponse>>({
|
|
baseURL: instance.defaults.baseURL,
|
|
timeout: instance.defaults.timeout,
|
|
method: "POST",
|
|
url: AUTH_REFRESH_PATH,
|
|
data: { refreshToken },
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
const payload = response.data;
|
|
if (!isApiSuccessCode(payload?.code) || !payload?.data?.queueToken) {
|
|
return null;
|
|
}
|
|
|
|
const refreshedQueueToken = payload.data.queueToken;
|
|
await setSession({
|
|
...sessionState,
|
|
queueToken: refreshedQueueToken,
|
|
refreshToken: payload.data.refreshToken ?? refreshToken,
|
|
});
|
|
|
|
return refreshedQueueToken;
|
|
})();
|
|
|
|
try {
|
|
return await refreshTokenPromise;
|
|
} finally {
|
|
refreshTokenPromise = null;
|
|
}
|
|
}
|
|
|
|
function canRefreshAndRetry(config?: RetryableAxiosRequestConfig): boolean {
|
|
if (!config) {
|
|
return false;
|
|
}
|
|
if (config._retry || config._skipAuthRefresh) {
|
|
return false;
|
|
}
|
|
return !isAuthEndpoint(config.url);
|
|
}
|
|
|
|
async function retryWithRefreshedToken(config: RetryableAxiosRequestConfig): Promise<any> {
|
|
const nextToken = await refreshQueueToken();
|
|
if (!nextToken) {
|
|
throw new Error("会话已过期,请重新登录");
|
|
}
|
|
|
|
const retryConfig: RetryableAxiosRequestConfig = {
|
|
...config,
|
|
_retry: true,
|
|
};
|
|
setAuthHeader(retryConfig, nextToken);
|
|
return instance.request(retryConfig);
|
|
}
|
|
|
|
/**
|
|
* 将配置中的服务器地址应用到 axios 实例。
|
|
*/
|
|
export function applyServerIpToHttp(serverIp: string): void {
|
|
if (import.meta.env.DEV) {
|
|
instance.defaults.baseURL = API_QUEUE_CALLER_PATH;
|
|
return;
|
|
}
|
|
|
|
instance.defaults.baseURL = buildBaseUrlFromServerIp(serverIp);
|
|
}
|
|
|
|
instance.interceptors.request.use(
|
|
async (config) => {
|
|
if (!instance.defaults.baseURL) {
|
|
throw new Error("未配置服务器地址,请先完成服务地址设置");
|
|
}
|
|
|
|
let token: string | null = null;
|
|
|
|
try {
|
|
const sessionState: SessionState = await getSession();
|
|
token = sessionState.queueToken;
|
|
} catch {
|
|
token = null;
|
|
}
|
|
|
|
if (token && !isAuthEndpoint(config.url)) {
|
|
setAuthHeader(config, token);
|
|
}
|
|
|
|
return config;
|
|
},
|
|
async (error) => {
|
|
throw error;
|
|
},
|
|
);
|
|
|
|
instance.interceptors.response.use(
|
|
async (response) => {
|
|
const { data } = response;
|
|
const payload = data as ApiEnvelope;
|
|
|
|
if (!isApiSuccessCode(payload?.code)) {
|
|
const requestConfig = response.config as RetryableAxiosRequestConfig;
|
|
if (payload?.code === 401 && canRefreshAndRetry(requestConfig)) {
|
|
return retryWithRefreshedToken(requestConfig);
|
|
}
|
|
|
|
const message = getApiMessage(payload);
|
|
await showErrorNative(message);
|
|
throw new Error(message);
|
|
}
|
|
|
|
return payload.data;
|
|
},
|
|
async (error) => {
|
|
if (error.response) {
|
|
const requestConfig = error.config as RetryableAxiosRequestConfig;
|
|
if (error.response.status === 401 && canRefreshAndRetry(requestConfig)) {
|
|
try {
|
|
return await retryWithRefreshedToken(requestConfig);
|
|
} catch {
|
|
await showErrorNative("未授权,请重新登录");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
switch (error.response.status) {
|
|
case 400:
|
|
await showErrorNative("请求错误");
|
|
break;
|
|
case 401:
|
|
await showErrorNative("未授权,请重新登录");
|
|
break;
|
|
case 403:
|
|
await showErrorNative("拒绝访问");
|
|
break;
|
|
case 404:
|
|
await showErrorNative("请求地址不存在");
|
|
break;
|
|
case 500:
|
|
await showErrorNative("服务器内部错误");
|
|
break;
|
|
case 502:
|
|
await showErrorNative("网关错误");
|
|
break;
|
|
case 503:
|
|
await showErrorNative("服务不可用");
|
|
break;
|
|
default:
|
|
await showErrorNative("请求失败");
|
|
}
|
|
} else if (error.request) {
|
|
await showErrorNative("网络连接异常");
|
|
} else {
|
|
await showErrorNative(error.message);
|
|
}
|
|
|
|
throw error;
|
|
},
|
|
);
|
|
|
|
export const http = {
|
|
/**
|
|
* 发送原生 axios 请求。
|
|
*/
|
|
request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
|
|
return instance.request(config);
|
|
},
|
|
|
|
/**
|
|
* 发送 GET 请求。
|
|
*/
|
|
get<T = unknown>(url: string, params?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
return instance.get(url, { params, ...config });
|
|
},
|
|
|
|
/**
|
|
* 发送 POST 请求。
|
|
*/
|
|
post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
return instance.post(url, data, config);
|
|
},
|
|
|
|
/**
|
|
* 发送 PUT 请求。
|
|
*/
|
|
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
return instance.put(url, data, config);
|
|
},
|
|
|
|
/**
|
|
* 发送 DELETE 请求。
|
|
*/
|
|
delete<T = unknown>(url: string, params?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
return instance.delete(url, { params, ...config });
|
|
},
|
|
|
|
/**
|
|
* 发送 PATCH 请求。
|
|
*/
|
|
patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
return instance.patch(url, data, config);
|
|
},
|
|
};
|