This commit is contained in:
2026-04-17 16:31:32 +08:00
parent adadb3bf1d
commit 2476655b28
116 changed files with 3875 additions and 583 deletions

View File

@@ -1,5 +1,12 @@
import type { ApiResponse } from "../types/api";
import { getAccessToken } from "./storage";
/**
* @description Web 端统一 HTTP 客户端;自动注入 Authorization、401 触发 refresh token 轮换、并发刷新合并、刷新失败跳登录
* @filename http.ts
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
import type { ApiResponse } from "@/types";
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from "@/utils/storage";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
const DEFAULT_TIMEOUT = 10000;
@@ -11,8 +18,11 @@ interface RequestOptions {
body?: unknown;
headers?: Record<string, string>;
timeout?: number;
skipAuthRefresh?: boolean;
}
let refreshPromise: Promise<string | null> | null = null;
function isApiResponse(payload: unknown): payload is ApiResponse<unknown> {
if (typeof payload !== "object" || payload === null) {
return false;
@@ -28,6 +38,10 @@ function buildUrl(path: string) {
return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
function isAuthTokenPath(path: string) {
return path.startsWith("/auth/tokens");
}
async function parseResponse(response: Response) {
if (response.status === 204) {
return null;
@@ -59,6 +73,17 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
});
const payload = await parseResponse(response);
if (response.status === 401 && !options.skipAuthRefresh && !isAuthTokenPath(path)) {
const nextAccessToken = await tryRefreshAccessToken();
if (nextAccessToken) {
return request<T>(path, { ...options, skipAuthRefresh: true });
}
clearTokens();
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.replace("/login");
}
throw new Error("登录已过期,请重新登录");
}
if (!response.ok) {
const message =
typeof payload === "object" && payload !== null && "message" in payload
@@ -76,6 +101,48 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
}
}
async function tryRefreshAccessToken(): Promise<string | null> {
if (refreshPromise) {
return refreshPromise;
}
const refreshToken = getRefreshToken();
if (!refreshToken) {
return null;
}
refreshPromise = (async () => {
try {
const response = await fetch(buildUrl("/auth/tokens/refresh"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ refreshToken })
});
const payload = await parseResponse(response);
if (!response.ok) {
return null;
}
if (!isApiResponse(payload) || payload.code !== 0 || !payload.data) {
return null;
}
const tokenData = payload.data as { accessToken?: string; refreshToken?: string };
if (!tokenData.accessToken || !tokenData.refreshToken) {
return null;
}
setTokens(tokenData.accessToken, tokenData.refreshToken);
return tokenData.accessToken;
} catch (error) {
return null;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
export const http = {
get<T>(path: string, options?: Omit<RequestOptions, "method" | "body">) {
return request<T>(path, { ...options, method: "GET" });

View File

@@ -0,0 +1,2 @@
export * from "./http";
export * from "./storage";

View File

@@ -1,13 +1,34 @@
const ACCESS_TOKEN_KEY = "k12study.access-token";
const REFRESH_TOKEN_KEY = "k12study.refresh-token";
export function getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken() {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
}
export function setRefreshToken(token: string) {
localStorage.setItem(REFRESH_TOKEN_KEY, token);
}
export function setTokens(accessToken: string, refreshToken: string) {
setAccessToken(accessToken);
setRefreshToken(refreshToken);
}
export function clearAccessToken() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
}
export function clearRefreshToken() {
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export function clearTokens() {
clearAccessToken();
clearRefreshToken();
}