182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
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}`;
|
||
}
|