feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
+174
View File
@@ -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}`;
}