154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
type ApiEnv = {
|
||
NEXT_PUBLIC_API_URL?: string;
|
||
NODE_ENV?: string;
|
||
};
|
||
|
||
export function resolveApiBase(env: ApiEnv = process.env) {
|
||
const fallback = env.NODE_ENV === 'production' ? '/api/v1' : 'http://localhost:3001/api/v1';
|
||
const configured = (env.NEXT_PUBLIC_API_URL || fallback).replace(/\/$/, '');
|
||
if (env.NODE_ENV === 'production' && isLocalOnlyApiUrl(configured)) {
|
||
return '/api/v1';
|
||
}
|
||
return configured;
|
||
}
|
||
|
||
const API_BASE = resolveApiBase();
|
||
const AUTH_SESSION_KEY = 'ftb_auth_session';
|
||
const AUTH_PERSIST_KEY = 'ftb_auth_persist';
|
||
|
||
function isLocalOnlyApiUrl(value: string) {
|
||
try {
|
||
const url = new URL(value);
|
||
return ['localhost', '127.0.0.1', '0.0.0.0'].includes(url.hostname);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
let apiAvailable: boolean | null = null;
|
||
let probePromise: Promise<boolean> | null = null;
|
||
|
||
function getErrorMessage(body: unknown, status: number) {
|
||
if (typeof body === 'object' && body !== null && 'message' in body) {
|
||
const message = (body as { message?: unknown }).message;
|
||
if (typeof message === 'string') return message;
|
||
if (Array.isArray(message)) return message.join(', ');
|
||
}
|
||
return `Request failed: ${status}`;
|
||
}
|
||
|
||
export class ApiRequestError extends Error {
|
||
constructor(
|
||
public readonly status: number,
|
||
public readonly body: unknown,
|
||
) {
|
||
super(getErrorMessage(body, status));
|
||
this.name = 'ApiRequestError';
|
||
}
|
||
}
|
||
|
||
async function checkApi(): Promise<boolean> {
|
||
if (apiAvailable === true) return true;
|
||
if (probePromise) return probePromise;
|
||
probePromise = (async () => {
|
||
try {
|
||
// 探测一个不依赖数据库的端点(/config/ai 总是返回 200,只要 NestJS 起来了)
|
||
const res = await fetch(`${API_BASE}/config/ai`, { method: 'GET', signal: AbortSignal.timeout(1500) });
|
||
apiAvailable = res.ok ? true : null;
|
||
} catch {
|
||
apiAvailable = null;
|
||
} finally {
|
||
probePromise = null;
|
||
}
|
||
return apiAvailable === true;
|
||
})();
|
||
return probePromise;
|
||
}
|
||
|
||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||
const available = await checkApi();
|
||
if (!available) throw new Error('API 不可用');
|
||
const res = await fetch(`${API_BASE}${path}`, {
|
||
headers: { 'Content-Type': 'application/json', ...resolveAuthHeaders(), ...options?.headers },
|
||
...options,
|
||
}).catch(() => null);
|
||
if (!res) {
|
||
apiAvailable = null;
|
||
throw new Error('API 不可用');
|
||
}
|
||
if (!res.ok) {
|
||
const error = await res.json().catch(() => ({}));
|
||
throw new ApiRequestError(res.status, error);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
export const api = {
|
||
get: <T>(path: string) => request<T>(path),
|
||
post: <T>(path: string, data: unknown) =>
|
||
request<T>(path, { method: 'POST', body: JSON.stringify(data) }),
|
||
put: <T>(path: string, data: unknown) =>
|
||
request<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
|
||
patch: <T>(path: string, data: unknown) =>
|
||
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
|
||
// 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求)
|
||
const controller = new AbortController();
|
||
const tid = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const res = await fetch(`${API_BASE}${path}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...resolveAuthHeaders() },
|
||
body: JSON.stringify(data),
|
||
signal: controller.signal,
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new ApiRequestError(res.status, err);
|
||
}
|
||
return res.json();
|
||
} finally {
|
||
clearTimeout(tid);
|
||
}
|
||
},
|
||
};
|
||
|
||
export function __resetApiAvailabilityForTests() {
|
||
apiAvailable = null;
|
||
probePromise = null;
|
||
}
|
||
|
||
function resolveAuthHeaders(): Record<string, string> {
|
||
const user = readStoredAuthUser();
|
||
if (!user?.id) return {};
|
||
return {
|
||
'x-ftb-user-id': user.id,
|
||
...(user.roleId ? { 'x-ftb-user-role-id': user.roleId } : {}),
|
||
...(user.name ? { 'x-ftb-user-name': encodeURIComponent(user.name) } : {}),
|
||
...(user.username ? { 'x-ftb-user-username': user.username } : {}),
|
||
...(user.email ? { 'x-ftb-user-email': user.email } : {}),
|
||
};
|
||
}
|
||
|
||
function readStoredAuthUser(): { id?: string; roleId?: string; name?: string; username?: string; email?: string } | null {
|
||
if (typeof sessionStorage === 'undefined') return null;
|
||
const raw = safeStorageGet(sessionStorage, AUTH_SESSION_KEY)
|
||
?? (typeof localStorage === 'undefined' ? null : safeStorageGet(localStorage, AUTH_PERSIST_KEY));
|
||
if (!raw) return null;
|
||
try {
|
||
const value = JSON.parse(raw);
|
||
return value && typeof value === 'object' ? value : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function safeStorageGet(storage: Pick<Storage, 'getItem'>, key: string): string | null {
|
||
try {
|
||
return storage.getItem(key);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|