7ca855f19c
- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
export interface ApiEnvelope<T> {
|
|
code: string;
|
|
message: string;
|
|
data: T;
|
|
requestId: string;
|
|
}
|
|
export interface ApiFailure {
|
|
code: string;
|
|
message: string;
|
|
status: number | undefined;
|
|
requestId: string | undefined;
|
|
}
|
|
export interface ApiClientOptions {
|
|
fetcher?: typeof fetch;
|
|
baseUrl?: string;
|
|
getToken: () => string | null;
|
|
setToken: (token: string | null) => void;
|
|
onUnauthorized?: () => void;
|
|
}
|
|
|
|
export class ApiError extends Error implements ApiFailure {
|
|
public status: number | undefined;
|
|
public requestId: string | undefined;
|
|
constructor(
|
|
public code: string,
|
|
message: string,
|
|
status?: number,
|
|
requestId?: string,
|
|
) {
|
|
super(message);
|
|
this.status = status;
|
|
this.requestId = requestId;
|
|
}
|
|
}
|
|
|
|
export function createApiClient(options: ApiClientOptions) {
|
|
const fetcher = options.fetcher ?? fetch;
|
|
const baseUrl = options.baseUrl ?? "/api/v1";
|
|
let refreshing: Promise<string> | null = null;
|
|
let failedRefresh: { token: string | null; error: unknown } | null = null;
|
|
|
|
async function refresh(
|
|
staleToken: string | null = options.getToken(),
|
|
): Promise<string> {
|
|
// 同一认证失败周期内:只要令牌仍是失败时的令牌(或已被清空),
|
|
// 就直接抛出已记录的失败,不再请求 refresh 接口或重复触发登出。
|
|
if (
|
|
failedRefresh !== null &&
|
|
(staleToken === failedRefresh.token || staleToken === null)
|
|
)
|
|
throw failedRefresh.error;
|
|
if (!refreshing) {
|
|
refreshing = (async () => {
|
|
try {
|
|
const response = await fetcher(`${baseUrl}/auth/token/refresh`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
const body = (await response.json()) as ApiEnvelope<{
|
|
accessToken: string;
|
|
}>;
|
|
if (!response.ok)
|
|
throw new ApiError(
|
|
body.code,
|
|
body.message,
|
|
response.status,
|
|
body.requestId,
|
|
);
|
|
options.setToken(body.data.accessToken);
|
|
failedRefresh = null;
|
|
return body.data.accessToken;
|
|
} catch (error) {
|
|
const failure = networkError(error);
|
|
failedRefresh = { token: staleToken, error: failure };
|
|
options.setToken(null);
|
|
options.onUnauthorized?.();
|
|
throw failure;
|
|
} finally {
|
|
refreshing = null;
|
|
}
|
|
})();
|
|
}
|
|
return refreshing;
|
|
}
|
|
|
|
async function request<T>(
|
|
path: string,
|
|
init: RequestInit = {},
|
|
retry = true,
|
|
): Promise<T> {
|
|
const headers = new Headers(init.headers);
|
|
const token = options.getToken();
|
|
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
if (init.body && !headers.has("content-type"))
|
|
headers.set("content-type", "application/json");
|
|
let response: Response;
|
|
try {
|
|
response = await fetcher(`${baseUrl}${path}`, {
|
|
...init,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
} catch (error) {
|
|
throw networkError(error);
|
|
}
|
|
const body = (await response.json()) as ApiEnvelope<T>;
|
|
if (response.status === 401 && retry && path !== "/auth/token/refresh") {
|
|
await refresh(token);
|
|
return request<T>(path, init, false);
|
|
}
|
|
if (!response.ok)
|
|
throw new ApiError(
|
|
body.code,
|
|
body.message,
|
|
response.status,
|
|
body.requestId,
|
|
);
|
|
return body.data;
|
|
}
|
|
return {
|
|
get: <T>(path: string) => request<T>(path),
|
|
post: <T>(path: string, body?: unknown, headers?: HeadersInit) =>
|
|
request<T>(path, {
|
|
method: "POST",
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
...(headers === undefined ? {} : { headers }),
|
|
}),
|
|
put: <T>(path: string, body: unknown) =>
|
|
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
|
patch: <T>(path: string, body: unknown) =>
|
|
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
|
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
|
refresh,
|
|
};
|
|
}
|
|
function networkError(error: unknown): ApiError {
|
|
if (error instanceof ApiError) return error;
|
|
return new ApiError(
|
|
"NETWORK_ERROR",
|
|
error instanceof Error ? error.message : "网络连接失败",
|
|
);
|
|
}
|