112 lines
3.4 KiB
TypeScript
112 lines
3.4 KiB
TypeScript
import { getPublicApiOrigin } from '@/lib/env';
|
||
import { emit401Unauthorized, emit403Forbidden } from '@/lib/notify/auth-events';
|
||
import { refreshTokensShared } from '@/lib/api/refresh-flight';
|
||
import { ApiError, type ApiEnvelope } from '@/lib/api/types';
|
||
|
||
type GetTokens = () => { accessToken: string | null; refreshToken: string | null };
|
||
type SetTokens = (access: string | null, refresh: string | null) => void;
|
||
|
||
let tokenBridge: { get: GetTokens; set: SetTokens } | null = null;
|
||
|
||
/** 由 auth store 在客户端挂载时注册,避免循环依赖。 */
|
||
export function registerTokenBridge(get: GetTokens, set: SetTokens) {
|
||
tokenBridge = { get, set };
|
||
}
|
||
|
||
let tenantIdGetter: () => string | null = () => null;
|
||
|
||
/** 由 tenant store 注册,请求 `/api/v1` 时附加 `X-Tenant-ID`(可选)。 */
|
||
export function registerTenantHeaderBridge(get: () => string | null) {
|
||
tenantIdGetter = get;
|
||
}
|
||
|
||
function authHeader(): HeadersInit {
|
||
const t = tokenBridge?.get().accessToken;
|
||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||
}
|
||
|
||
function tenantHeader(): HeadersInit {
|
||
const tid = tenantIdGetter?.();
|
||
return tid ? { 'X-Tenant-ID': tid } : {};
|
||
}
|
||
|
||
/** 业务 JSON:HTTP 200 时解析 envelope;401 时共享 refresh 后重试一次(登录请求勿走此逻辑)。 */
|
||
export async function apiJson<T>(
|
||
path: string,
|
||
init: RequestInit & { skipRefreshRetry?: boolean } = {}
|
||
): Promise<T> {
|
||
const { skipRefreshRetry, ...req } = init;
|
||
const url = `${getPublicApiOrigin()}${path.startsWith('/') ? path : `/${path}`}`;
|
||
const method = (req.method || 'GET').toUpperCase();
|
||
const hasBody = req.body != null && req.body !== '';
|
||
const jsonHeaders: HeadersInit =
|
||
method !== 'GET' && method !== 'HEAD' && hasBody ? { 'Content-Type': 'application/json' } : {};
|
||
const baseHeaders = {
|
||
...jsonHeaders,
|
||
...authHeader(),
|
||
...tenantHeader(),
|
||
...(req.headers || {}),
|
||
};
|
||
|
||
const first = await fetch(url, {
|
||
...req,
|
||
headers: baseHeaders,
|
||
credentials: 'include',
|
||
});
|
||
|
||
if (first.status === 401 && !skipRefreshRetry && tokenBridge) {
|
||
const ok = await refreshTokensShared(
|
||
() => tokenBridge!.get().refreshToken,
|
||
(a, r) => tokenBridge!.set(a, r)
|
||
);
|
||
if (ok) {
|
||
const second = await fetch(url, {
|
||
...req,
|
||
headers: {
|
||
...jsonHeaders,
|
||
...authHeader(),
|
||
...tenantHeader(),
|
||
...(req.headers || {}),
|
||
},
|
||
credentials: 'include',
|
||
});
|
||
return handleEnvelope<T>(second);
|
||
}
|
||
}
|
||
|
||
return handleEnvelope<T>(first);
|
||
}
|
||
|
||
async function handleEnvelope<T>(res: Response): Promise<T> {
|
||
const text = await res.text();
|
||
let body: unknown = null;
|
||
try {
|
||
body = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
throw new ApiError('响应不是合法 JSON', res.status, text);
|
||
}
|
||
|
||
if (res.status === 401) {
|
||
const msg = (body as { msg?: string })?.msg || '未授权';
|
||
emit401Unauthorized(msg);
|
||
throw new ApiError(msg, 401, body);
|
||
}
|
||
if (res.status === 403) {
|
||
const msg = (body as { msg?: string })?.msg || '禁止访问';
|
||
emit403Forbidden(msg);
|
||
throw new ApiError(msg, 403, body);
|
||
}
|
||
if (!res.ok) {
|
||
throw new ApiError(`HTTP ${res.status}`, res.status, body);
|
||
}
|
||
|
||
const env = body as ApiEnvelope<T>;
|
||
if (typeof env?.code !== 'number') {
|
||
return body as T;
|
||
}
|
||
if (env.code !== 200) {
|
||
throw new ApiError(env.msg || '业务失败', res.status, env);
|
||
}
|
||
return env.data as T;
|
||
}
|