feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
clearSession,
|
||||
currentRouteForPortal,
|
||||
dispatchSessionEvent,
|
||||
getSessionTenantId,
|
||||
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');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', 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);
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user