Files
smart-go/web/lib/api/client.ts
T
2026-04-23 18:58:13 +08:00

112 lines
3.4 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.
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 } : {};
}
/** 业务 JSONHTTP 200 时解析 envelope401 时共享 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;
}