feat: harden sessions and track downstream acknowledgements

This commit is contained in:
hectorzhao
2026-07-14 14:18:43 +08:00
parent 3d37adcc9f
commit 8c03663f24
43 changed files with 1733 additions and 150 deletions
+95 -19
View File
@@ -1,9 +1,22 @@
import { clearSession, getSessionTenantId, readSession, type LoginSession } from './session';
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, requestReauthentication, type LoginSession } from './session';
type RequestOptions = RequestInit & {
tenantId?: string;
reauthenticationAttempted?: boolean;
};
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
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 const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
async function readErrorMessage(response: Response) {
@@ -27,19 +40,30 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
const headers = new Headers(options.headers);
headers.set('Content-Type', 'application/json');
const session = readSession();
if (session?.accessToken) {
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
}
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 });
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
if (response.status === 401 && session) {
const body = await readErrorBody(response.clone());
if (body.code === 'SESSION_LOCKED') {
dispatchSessionEvent('locked', { message: body.message });
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
}
clearSession();
dispatchSessionEvent('logout', { code: body.code, message: body.message });
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
throw new Error('登录会话已失效,请重新登录');
}
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));
}
@@ -49,19 +73,30 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const headers = new Headers(options.headers);
const session = readSession();
if (session?.accessToken) {
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
}
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 });
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
if (response.status === 401 && session) {
const body = await readErrorBody(response.clone());
if (body.code === 'SESSION_LOCKED') {
dispatchSessionEvent('locked', { message: body.message });
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
}
clearSession();
dispatchSessionEvent('logout', { code: body.code, message: body.message });
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
throw new Error('登录会话已失效,请重新登录');
}
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));
}
@@ -689,6 +724,8 @@ export type EnterpriseApplication = {
queuePriority?: 'normal' | 'priority' | string | null;
maxPhonesPerTask?: number | null;
templateMismatchMode?: string | null;
downstreamReceiptRetryEnabled?: boolean | null;
downstreamUplinkRetryEnabled?: boolean | null;
cmppAccount?: string | null;
cmppEnterpriseCode?: string | null;
interfaceEnabled?: boolean | null;
@@ -775,7 +812,15 @@ export type DownstreamDeliveryRecord = {
status: string;
payload: Record<string, unknown>;
retryCount: number;
retryEnabled: boolean;
nextRetryAt?: string | null;
sentAt?: string | null;
acknowledgedAt?: string | null;
ackDeadlineAt?: string | null;
ackResult?: number | null;
ackSequenceId?: string | null;
ackMessageId?: string | null;
connectionId?: string | null;
deliveredAt?: string | null;
lastError?: string | null;
createdAt: string;
@@ -796,9 +841,13 @@ export type DownstreamDeliveryDashboard = {
summary: {
total: number;
pending: number;
awaitingAck: number;
delivered: number;
failed: number;
unconfirmed: number;
rejected: number;
stalledPending: number;
stalledAck: number;
recentFailed: number;
alertCount: number;
};
@@ -806,8 +855,11 @@ export type DownstreamDeliveryDashboard = {
deliveryType: string;
total: number;
pending: number;
awaitingAck: number;
delivered: number;
failed: number;
unconfirmed: number;
rejected: number;
}>;
retryBuckets: Array<{
label: string;
@@ -817,7 +869,10 @@ export type DownstreamDeliveryDashboard = {
applicationId: string;
name: string;
pending: number;
awaitingAck: number;
failed: number;
unconfirmed: number;
rejected: number;
delivered: number;
alertCount: number;
}>;
@@ -881,6 +936,11 @@ export const adminApi = {
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
touchSession: () => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/touch', { method: 'POST', body: '{}' }),
lockSession: () => request<{ locked: boolean }>('/auth/session/lock', { method: 'POST', body: '{}' }),
unlockSession: (password: string) => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/unlock', { method: 'POST', body: JSON.stringify({ password }) }),
reauthenticate: (password: string) => request<Pick<LoginSession, 'recentAuthenticationExpiresAt'>>('/auth/reauthenticate', { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }),
logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST', body: '{}' }),
changeOwnPassword: (body: { currentPassword: string; password: string }) =>
request<ManagedUser>('/auth/password', { method: 'POST', body: JSON.stringify(body) }),
listTenants: () => request<TenantOption[]>('/admin/tenants'),
@@ -912,9 +972,9 @@ export const adminApi = {
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
getEnterpriseApplication: (id: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeApplicationStatus: (id: string, status: string, reason?: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
@@ -1103,13 +1163,21 @@ export const adminApi = {
}
const headers = new Headers();
const session = readSession();
if (session?.accessToken) {
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
}
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
if (tenantId) {
headers.set('x-tenant-id', tenantId);
}
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form });
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
if (response.status === 401 && session) {
const error = await readErrorBody(response.clone());
if (error.code === 'SESSION_LOCKED') {
dispatchSessionEvent('locked', { message: error.message });
} else {
clearSession();
dispatchSessionEvent('logout', { code: error.code, message: error.message });
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
}
}
if (!response.ok) {
throw new Error(await response.text());
}
@@ -1210,13 +1278,21 @@ export const clientApi = {
}
const headers = new Headers();
const session = readSession();
if (session?.accessToken) {
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
}
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
if (tenantId) {
headers.set('x-tenant-id', tenantId);
}
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form });
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
if (response.status === 401 && session) {
const error = await readErrorBody(response.clone());
if (error.code === 'SESSION_LOCKED') {
dispatchSessionEvent('locked', { message: error.message });
} else {
clearSession();
dispatchSessionEvent('logout', { code: error.code, message: error.message });
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
}
}
if (!response.ok) {
throw new Error(await response.text());
}