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());
}
+45 -2
View File
@@ -12,10 +12,13 @@ export type SessionUser = {
};
export type LoginSession = {
accessToken: string;
tokenType: string;
portal: Portal;
user: SessionUser;
idleTimeoutSeconds: number;
lockRecoverySeconds: number;
absoluteExpiresAt: string;
lastActivityAt: string;
recentAuthenticationExpiresAt: string;
};
const sessionKey = 'cmpp-auth-session';
@@ -37,6 +40,46 @@ export function clearSession() {
window.localStorage.removeItem(sessionKey);
}
export function updateSessionTiming(timing: Partial<Omit<LoginSession, 'portal' | 'user'>>) {
const current = readSession();
if (current) writeSession({ ...current, ...timing });
}
let lastUserActivityAt = Date.now();
let reauthenticationHandler: (() => Promise<void>) | undefined;
export function markUserActivity() {
lastUserActivityAt = Date.now();
}
export function getLastUserActivityAt() {
return lastUserActivityAt;
}
export function hasRecentUserActivity() {
return Date.now() - lastUserActivityAt < 60_000;
}
export function setReauthenticationHandler(handler?: () => Promise<void>) {
reauthenticationHandler = handler;
}
export function requestReauthentication() {
if (!reauthenticationHandler) return Promise.reject(new Error('请重新验证当前密码后再操作'));
return reauthenticationHandler();
}
export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', detail?: Record<string, unknown>) {
window.dispatchEvent(new CustomEvent(`cmpp-session-${type}`, { detail }));
try {
const channel = new BroadcastChannel('cmpp-session');
channel.postMessage({ type, detail });
channel.close();
} catch {
// BroadcastChannel is an enhancement; the current tab still receives the DOM event.
}
}
export function getSessionTenantId() {
return readSession()?.user.tenantId ?? undefined;
}
@@ -5,8 +5,20 @@ import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
awaiting_ack: 'info',
delivered: 'success',
failed: 'danger',
unconfirmed: 'warning',
rejected: 'danger',
};
const statusLabel: Record<string, string> = {
pending: '待首次投递',
awaiting_ack: '等待客户端确认',
delivered: '客户端已确认',
failed: '投递失败',
unconfirmed: '客户端未确认',
rejected: '客户端拒绝',
};
const deliveryTypeLabel: Record<string, string> = {
@@ -30,11 +42,17 @@ function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRe
<div><span></span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
<div><span></span><strong>{record.application?.name ?? record.applicationId}</strong></div>
<div><span></span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
<div><span></span><strong>{record.status}</strong></div>
<div><span></span><strong>{statusLabel[record.status] ?? record.status}</strong></div>
<div><span> ID</span><strong>{record.messageId ?? '-'}</strong></div>
<div><span></span><strong>{record.retryCount}</strong></div>
<div><span></span><strong>{record.nextRetryAt ?? '-'}</strong></div>
<div><span></span><strong>{record.deliveredAt ?? '-'}</strong></div>
<div><span></span><strong>{record.retryEnabled ? '开启' : '关闭'}</strong></div>
<div><span></span><strong>{record.sentAt ?? '-'}</strong></div>
<div><span></span><strong>{record.acknowledgedAt ?? '-'}</strong></div>
<div><span>ACK Result</span><strong>{record.ackResult ?? '-'}</strong></div>
<div><span>ACK Sequence_Id</span><strong>{record.ackSequenceId ?? '-'}</strong></div>
<div><span>ACK Msg_Id</span><strong>{record.ackMessageId ?? '-'}</strong></div>
<div><span> ID</span><strong>{record.connectionId ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
</div>
<section className="report-history">
@@ -96,7 +114,7 @@ export function AdminDownstreamDeliveriesPage() {
}, [loadData]);
const selectableIds = useMemo(
() => records.filter((item) => item.status !== 'delivered').map((item) => item.id),
() => records.filter((item) => ['pending', 'failed', 'unconfirmed', 'rejected'].includes(item.status)).map((item) => item.id),
[records],
);
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
@@ -114,7 +132,7 @@ export function AdminDownstreamDeliveriesPage() {
render: (record) => (
<input
type="checkbox"
disabled={record.status === 'delivered'}
disabled={!['pending', 'failed', 'unconfirmed', 'rejected'].includes(record.status)}
checked={selectedIds.includes(record.id)}
onChange={(event) => {
setSelectedIds((current) =>
@@ -132,7 +150,7 @@ export function AdminDownstreamDeliveriesPage() {
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? '-' },
{ key: 'type', title: '类型', width: '110px', render: (record) => deliveryTypeLabel[record.deliveryType] ?? record.deliveryType },
{ key: 'messageId', title: '消息 ID', width: '180px', render: (record) => <strong className="admin-task-id">{record.messageId ?? '-'}</strong> },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusTone[record.status] ?? 'info'}>{record.status}</Tag> },
{ key: 'status', title: '状态', width: '150px', render: (record) => <Tag tone={statusTone[record.status] ?? 'info'}>{statusLabel[record.status] ?? record.status}</Tag> },
{ key: 'retry', title: '重试', width: '90px', align: 'center', render: (record) => record.retryCount },
{ key: 'error', title: '最后错误', render: (record) => record.lastError ?? '-' },
{
@@ -144,8 +162,10 @@ export function AdminDownstreamDeliveriesPage() {
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<Button
disabled={!['pending', 'failed', 'unconfirmed', 'rejected'].includes(record.status)}
icon={<RefreshCw size={14} />}
onClick={() => {
if (!window.confirm('重投可能导致下游业务重复处理,确认继续吗?')) return;
adminApi.requeueDownstreamDelivery(record.id)
.then(() => loadData())
.catch((failure: Error) => setError(failure.message || '人工重投失败'));
@@ -192,9 +212,9 @@ export function AdminDownstreamDeliveriesPage() {
<div className="surface mini-status-card">
<CheckCircle2 size={22} />
<div>
<span></span>
<span></span>
<strong>{summary?.delivered ?? 0}</strong>
<small></small>
<small> Result=0 CMPP_DELIVER_RESP</small>
</div>
</div>
<div className="surface mini-status-card">
@@ -214,7 +234,10 @@ export function AdminDownstreamDeliveriesPage() {
options={[
{ label: '全部状态', value: 'all' },
{ label: '待投递', value: 'pending' },
{ label: '已投递', value: 'delivered' },
{ label: '等待客户端确认', value: 'awaiting_ack' },
{ label: '客户端已确认', value: 'delivered' },
{ label: '客户端未确认', value: 'unconfirmed' },
{ label: '客户端拒绝', value: 'rejected' },
{ label: '最终失败', value: 'failed' },
]}
value={status}
@@ -271,19 +294,23 @@ export function AdminDownstreamDeliveriesPage() {
<h2></h2>
</div>
<div className="downstream-breakdown-table">
<div className="downstream-breakdown-table__head">
<div className="downstream-breakdown-table__head downstream-breakdown-table__head--ack">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span>/</span>
<span></span>
</div>
{typeBreakdown.map((item) => (
<div className="downstream-breakdown-table__row" key={item.deliveryType}>
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--ack" key={item.deliveryType}>
<strong>{deliveryTypeLabel[item.deliveryType] ?? item.deliveryType}</strong>
<span>{item.total}</span>
<span>{item.pending}</span>
<span>{item.awaitingAck}</span>
<span>{item.delivered}</span>
<span>{item.unconfirmed + item.rejected}</span>
<span>{item.failed}</span>
</div>
))}
@@ -312,7 +339,7 @@ export function AdminDownstreamDeliveriesPage() {
<div className="downstream-breakdown-table__head downstream-breakdown-table__head--apps">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
@@ -320,7 +347,7 @@ export function AdminDownstreamDeliveriesPage() {
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--apps" key={item.applicationId}>
<strong>{item.name}</strong>
<span>{item.pending}</span>
<span>{item.failed}</span>
<span>{item.failed + item.unconfirmed + item.rejected}</span>
<span>{item.delivered}</span>
<span>{item.alertCount}</span>
</div>
@@ -333,7 +360,7 @@ export function AdminDownstreamDeliveriesPage() {
<div className="surface admin-task-table-card report-task-table-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
{selectedIds.length} `pending/failed`
{selectedIds.length}
</p>
<div style={{ display: 'flex', gap: 8 }}>
<Button
@@ -347,6 +374,7 @@ export function AdminDownstreamDeliveriesPage() {
icon={<RefreshCw size={14} />}
disabled={selectedIds.length === 0}
onClick={() => {
if (!window.confirm(`即将重投 ${selectedIds.length} 条记录,可能导致下游业务重复处理,确认继续吗?`)) return;
adminApi.batchRequeueDownstreamDeliveries(selectedIds)
.then((result) => {
setError(result.failedCount > 0 ? `批量重投完成,成功 ${result.successCount} 条,失败 ${result.failedCount}` : '');
@@ -31,6 +31,8 @@ export function AdminSmsApplicationFormPage() {
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true);
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
const [ipAddress, setIpAddress] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [mobileGroupId, setMobileGroupId] = useState('');
@@ -90,6 +92,8 @@ export function AdminSmsApplicationFormPage() {
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
setDownstreamReceiptRetryEnabled(application.downstreamReceiptRetryEnabled !== false);
setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false);
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
const activeRules = routeRules.filter((rule) => (
@@ -131,6 +135,8 @@ export function AdminSmsApplicationFormPage() {
cmppMaxConnections: Number(cmppMaxConnections) || 1,
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
templateMismatchMode: mismatchPolicy,
downstreamReceiptRetryEnabled,
downstreamUplinkRetryEnabled,
ipAllowlist: parseIpAllowlist(ipAddress),
};
@@ -256,6 +262,23 @@ export function AdminSmsApplicationFormPage() {
/>
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
<button className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)} type="button">
<span />
{downstreamReceiptRetryEnabled ? '开启' : '关闭'}
</button>
<button className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)} type="button">
<span />
{downstreamUplinkRetryEnabled ? '开启' : '关闭'}
</button>
</div>
<div className="admin-app-form-tip">
<Info size={17} />
<span> CMPP_DELIVER_RESP </span>
</div>
</div>
</div>
</section>
+190 -4
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
ChevronDown,
@@ -12,7 +12,15 @@ import {
} from 'lucide-react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { adminApi } from '@/api/adminApi';
import { clearSession } from '@/api/session';
import {
clearSession,
dispatchSessionEvent,
getLastUserActivityAt,
markUserActivity,
readSession,
setReauthenticationHandler,
updateSessionTiming,
} from '@/api/session';
import { Button, Input, Modal } from '@/components/ui';
export type ShellNavItem = {
@@ -64,6 +72,18 @@ export function AppShell({
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSaving, setPasswordSaving] = useState(false);
const [idleWarningSeconds, setIdleWarningSeconds] = useState<number | null>(null);
const [locked, setLocked] = useState(false);
const [unlockPassword, setUnlockPassword] = useState('');
const [unlockError, setUnlockError] = useState('');
const [unlocking, setUnlocking] = useState(false);
const [reauthenticationOpen, setReauthenticationOpen] = useState(false);
const [reauthenticationPassword, setReauthenticationPassword] = useState('');
const [reauthenticationError, setReauthenticationError] = useState('');
const [reauthenticating, setReauthenticating] = useState(false);
const reauthenticationResolve = useRef<(() => void) | null>(null);
const reauthenticationReject = useRef<((error: Error) => void) | null>(null);
const lockRequested = useRef(false);
const navigate = useNavigate();
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
const auditTotal = useMemo(
@@ -85,6 +105,7 @@ export function AppShell({
try {
await adminApi.changeOwnPassword({ currentPassword, password: newPassword });
clearSession();
dispatchSessionEvent('logout', { message: '密码修改成功,请重新登录' });
navigate(loginPath, { replace: true });
} catch (error) {
setPasswordError(error instanceof Error ? error.message : '修改密码失败');
@@ -93,6 +114,139 @@ export function AppShell({
}
}
async function logout() {
try {
await adminApi.logout();
} finally {
clearSession();
dispatchSessionEvent('logout');
navigate(loginPath, { replace: true });
}
}
async function continueSession() {
try {
const timing = await adminApi.touchSession();
updateSessionTiming(timing);
markUserActivity();
setIdleWarningSeconds(null);
} catch {
setLocked(true);
}
}
async function unlockSession() {
if (!unlockPassword) {
setUnlockError('请输入当前密码');
return;
}
setUnlocking(true);
setUnlockError('');
try {
const timing = await adminApi.unlockSession(unlockPassword);
updateSessionTiming(timing);
markUserActivity();
setLocked(false);
lockRequested.current = false;
setUnlockPassword('');
setIdleWarningSeconds(null);
dispatchSessionEvent('unlocked');
} catch (error) {
setUnlockError(error instanceof Error ? error.message : '解锁失败');
} finally {
setUnlocking(false);
}
}
async function confirmReauthentication() {
if (!reauthenticationPassword) {
setReauthenticationError('请输入当前密码');
return;
}
setReauthenticating(true);
setReauthenticationError('');
try {
const timing = await adminApi.reauthenticate(reauthenticationPassword);
updateSessionTiming(timing);
reauthenticationResolve.current?.();
reauthenticationResolve.current = null;
reauthenticationReject.current = null;
setReauthenticationOpen(false);
setReauthenticationPassword('');
} catch (error) {
setReauthenticationError(error instanceof Error ? error.message : '身份验证失败');
} finally {
setReauthenticating(false);
}
}
useEffect(() => {
const activityEvents = ['pointerdown', 'keydown', 'touchstart', 'scroll'] as const;
const onActivity = () => markUserActivity();
activityEvents.forEach((eventName) => window.addEventListener(eventName, onActivity, { passive: true }));
const onLocked = () => setLocked(true);
const onUnlocked = () => { setLocked(false); markUserActivity(); };
const onLogout = () => { clearSession(); navigate(loginPath, { replace: true }); };
window.addEventListener('cmpp-session-locked', onLocked);
window.addEventListener('cmpp-session-unlocked', onUnlocked);
window.addEventListener('cmpp-session-logout', onLogout);
let channel: BroadcastChannel | undefined;
try {
channel = new BroadcastChannel('cmpp-session');
channel.onmessage = (event: MessageEvent<{ type?: string }>) => {
if (event.data?.type === 'locked') onLocked();
if (event.data?.type === 'unlocked') onUnlocked();
if (event.data?.type === 'logout') onLogout();
};
} catch {
channel = undefined;
}
setReauthenticationHandler(() => new Promise<void>((resolve, reject) => {
reauthenticationResolve.current = resolve;
reauthenticationReject.current = reject;
setReauthenticationPassword('');
setReauthenticationError('');
setReauthenticationOpen(true);
}));
const timer = window.setInterval(() => {
const session = readSession();
if (!session) return;
const now = Date.now();
if (now >= Date.parse(session.absoluteExpiresAt)) {
clearSession();
dispatchSessionEvent('logout', { code: 'SESSION_ABSOLUTE_TIMEOUT' });
navigate(loginPath, { replace: true });
return;
}
const remaining = session.idleTimeoutSeconds * 1000 - (now - getLastUserActivityAt());
if (remaining <= 0 && !lockRequested.current) {
lockRequested.current = true;
setLocked(true);
setIdleWarningSeconds(null);
void adminApi.lockSession().catch(() => undefined);
} else if (remaining <= 5 * 60 * 1000) {
setIdleWarningSeconds(Math.ceil(remaining / 1000));
} else {
setIdleWarningSeconds(null);
}
}, 1000);
return () => {
activityEvents.forEach((eventName) => window.removeEventListener(eventName, onActivity));
window.removeEventListener('cmpp-session-locked', onLocked);
window.removeEventListener('cmpp-session-unlocked', onUnlocked);
window.removeEventListener('cmpp-session-logout', onLogout);
channel?.close();
window.clearInterval(timer);
setReauthenticationHandler(undefined);
reauthenticationReject.current?.(new Error('身份验证已取消'));
};
}, [loginPath, navigate]);
useEffect(() => {
if (auditTotal <= 0 || typeof window === 'undefined') {
return;
@@ -228,9 +382,8 @@ export function AppShell({
</button>
<button onClick={() => {
clearSession();
setUserMenuOpen(false);
navigate(loginPath, { replace: true });
void logout();
}} role="menuitem" type="button">
<LogOut size={16} />
退
@@ -258,6 +411,39 @@ export function AppShell({
{passwordError ? <p className="form-error">{passwordError}</p> : null}
</div>
</Modal>
<Modal
footer={<><Button onClick={() => void logout()} variant="ghost">退</Button><Button disabled={unlocking} onClick={() => void unlockSession()}>{unlocking ? '解锁中...' : '解锁'}</Button></>}
onClose={() => undefined}
open={locked}
title="会话已安全锁定"
>
<p>使 4 </p>
<Input label="当前密码" onChange={(event) => setUnlockPassword(event.target.value)} type="password" value={unlockPassword} />
{unlockError ? <p className="login-error">{unlockError}</p> : null}
</Modal>
<Modal
footer={<><Button onClick={() => setIdleWarningSeconds(null)} variant="ghost"></Button><Button onClick={() => void continueSession()}>使</Button></>}
onClose={() => setIdleWarningSeconds(null)}
open={!locked && idleWarningSeconds !== null}
title="会话即将锁定"
>
<p> {formatCountdown(idleWarningSeconds ?? 0)} 使</p>
</Modal>
<Modal
footer={<><Button onClick={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }} variant="ghost"></Button><Button disabled={reauthenticating} onClick={() => void confirmReauthentication()}>{reauthenticating ? '验证中...' : '确认身份'}</Button></>}
onClose={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }}
open={reauthenticationOpen}
title="敏感操作身份验证"
>
<p> 30 </p>
<Input label="当前密码" onChange={(event) => setReauthenticationPassword(event.target.value)} type="password" value={reauthenticationPassword} />
{reauthenticationError ? <p className="login-error">{reauthenticationError}</p> : null}
</Modal>
</div>
);
}
function formatCountdown(seconds: number) {
const minutes = Math.floor(seconds / 60);
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
}
+5
View File
@@ -1059,6 +1059,11 @@ h3 {
grid-template-columns: minmax(180px, 1.4fr) repeat(4, minmax(72px, 0.65fr));
}
.downstream-breakdown-table__head--ack,
.downstream-breakdown-table__row--ack {
grid-template-columns: minmax(120px, 1.2fr) repeat(6, minmax(72px, 0.65fr));
}
.downstream-breakdown-table__row {
border-bottom: 1px solid var(--color-border);
}