feat: harden sessions and track downstream acknowledgements
This commit is contained in:
+95
-19
@@ -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
@@ -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
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user