Files
2e595c7e72
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
refactor(data): 收口关系表运行时数据源
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读
- 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移
- 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-09 14:59:49 +08:00

172 lines
5.7 KiB
TypeScript
Raw Permalink 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();
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}`;
}
function isAbortLikeError(error: unknown) {
const value = error as { name?: unknown; message?: unknown };
const text = `${typeof value?.name === 'string' ? value.name : ''} ${typeof value?.message === 'string' ? value.message : String(error ?? '')}`;
return /abort|aborted|timeout|timed out/i.test(text);
}
function formatTimeoutMs(timeoutMs: number) {
if (timeoutMs >= 60000) return `${Math.ceil(timeoutMs / 60000)} 分钟`;
return `${Math.ceil(timeoutMs / 1000)}`;
}
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' }),
deleteWithBody: <T>(path: string, data: unknown) =>
request<T>(path, { method: 'DELETE', body: JSON.stringify(data) }),
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();
} catch (e) {
if (isAbortLikeError(e)) {
throw new Error(`请求超时:服务在${formatTimeoutMs(timeoutMs)}内没有返回,请稍后重试。`);
}
throw e;
} 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;
}
}