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 { 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(path: string, options: RequestOptions = {}): Promise { 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(path, { ...options, reauthenticationAttempted: true }); } } if (!response.ok) { throw new Error(await readErrorMessage(response)); } return response.json() as Promise; } export async function requestBlob(path: string, options: RequestOptions = {}): Promise { 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(path: string, form: FormData, reauthenticationAttempted = false): Promise { 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(path, form, true); } } if (!response.ok) throw new Error(await readErrorMessage(response)); return response.json() as Promise; } export function withQuery(path: string, query: Record) { 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}`; }