Files
lislgosms/src/api/core/httpClient.ts
T

182 lines
6.6 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 {
clearSession,
currentRouteForPortal,
dispatchSessionEvent,
hasRecentUserActivity,
portalFromPath,
readSession,
redirectToPortalLogin,
requestReauthentication,
saveSessionRecovery,
type LoginSession,
type Portal,
} from '../session';
type RequestOptions = RequestInit & {
tenantId?: string;
reauthenticationAttempted?: boolean;
suppressSessionRedirect?: boolean;
};
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
const text = await response.text();
if (!text) return {};
try {
return JSON.parse(text) as ApiErrorBody;
} catch {
return { message: text };
}
}
export type SessionTiming = Pick<
LoginSession,
| 'idleTimeoutSeconds'
| 'lockRecoverySeconds'
| 'absoluteExpiresAt'
| 'lastActivityAt'
| 'recentAuthenticationExpiresAt'
>;
// Authentication failures are handled centrally so every domain API keeps the
// same lock, recovery and redirect behavior as the original adminApi facade.
function requestPortal(path: string): Portal | undefined {
return portalFromPath(path);
}
async function handleSessionFailure(response: Response, portal: Portal | undefined, suppressRedirect = false) {
if (!portal) return false;
const session = readSession(portal);
const body = await readErrorBody(response.clone());
if (body.code === 'SESSION_LOCKED' && session) {
dispatchSessionEvent(portal, 'locked', { message: body.message });
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
}
if (suppressRedirect) return false;
if (session) {
saveSessionRecovery(portal, {
returnUrl: currentRouteForPortal(portal),
code: body.code,
message: typeof body.message === 'string' ? body.message : '登录会话已失效,请重新登录',
});
}
clearSession(portal);
dispatchSessionEvent(portal, 'logout', { code: body.code, message: body.message });
redirectToPortalLogin(portal);
throw new Error('登录会话已失效,请重新登录');
}
async function readErrorMessage(response: Response) {
const fallback = `请求失败(${response.status}`;
const text = await response.text();
if (!text) return fallback;
try {
const parsed = JSON.parse(text) as { message?: string | string[]; error?: string };
if (Array.isArray(parsed.message)) return parsed.message.join('');
if (parsed.message) return parsed.message;
if (parsed.error) return parsed.error;
} catch {
return text;
}
return text;
}
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.set('Content-Type', 'application/json');
const portal = requestPortal(path);
const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
if (options.tenantId && !path.startsWith('/client')) {
headers.set('x-tenant-id', options.tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
if (response.status === 401 && !isLoginAttempt) {
await handleSessionFailure(response, portal, options.suppressSessionRedirect);
}
if (response.status === 403 && session && !options.reauthenticationAttempted) {
const body = await readErrorBody(response.clone());
if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') {
await requestReauthentication();
return request<T>(path, { ...options, reauthenticationAttempted: true });
}
}
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
return response.json() as Promise<T>;
}
export async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const headers = new Headers(options.headers);
if (typeof options.body === 'string' && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const portal = requestPortal(path);
const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
if (options.tenantId && !path.startsWith('/client')) {
headers.set('x-tenant-id', options.tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
if (response.status === 401) {
await handleSessionFailure(response, portal, options.suppressSessionRedirect);
}
if (response.status === 403 && session && !options.reauthenticationAttempted) {
const body = await readErrorBody(response.clone());
if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') {
await requestReauthentication();
return requestBlob(path, { ...options, reauthenticationAttempted: true });
}
}
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
return response.blob();
}
export async function requestForm<T>(path: string, form: FormData, reauthenticationAttempted = false): Promise<T> {
const headers = new Headers();
const portal = requestPortal(path);
const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' });
if (response.status === 401) {
await handleSessionFailure(response, portal);
throw new Error('登录会话已失效,请重新登录');
}
if (response.status === 403 && session && !reauthenticationAttempted) {
const body = await readErrorBody(response.clone());
if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') {
await requestReauthentication();
return requestForm<T>(path, form, true);
}
}
if (!response.ok) throw new Error(await readErrorMessage(response));
return response.json() as Promise<T>;
}
export function withQuery(path: string, query: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== '' && value !== 'all') {
params.set(key, String(value));
}
});
const suffix = params.toString() ? `?${params}` : '';
return `${path}${suffix}`;
}
export function fileDownloadUrl(
fileObjectId: string,
disposition: 'attachment' | 'inline' = 'attachment',
portal: Portal = 'admin',
) {
return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
}