Files
ftb-project-management/apps/web/lib/api.ts
2026-07-06 18:07:55 +08:00

119 lines
3.7 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.

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();
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', ...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' },
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;
}