feat: 增强下游重投与签名质量检测

This commit is contained in:
hectorzhao
2026-08-12 17:05:53 +08:00
parent 1d8d6701a6
commit 4994841709
23 changed files with 960 additions and 67 deletions
+2 -2
View File
@@ -407,7 +407,7 @@ function UnreportedSignaturesCard({
<div className="signature-quality-card__heading">
<div>
<div className="section-heading__title"><h2></h2><Tag tone="warning"></Tag></div>
<p className="muted">{data?.date ?? '所选日期'} </p>
<p className="muted">{data?.date ?? '所选日期'} </p>
</div>
<div className="signature-quality-card__query">
<Input
@@ -420,7 +420,7 @@ function UnreportedSignaturesCard({
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary"></Button>
</div>
</div>
<div className="signature-quality-card__note"><strong></strong></div>
<div className="signature-quality-card__note"><strong></strong></div>
<Table
columns={columns}
data={data?.items ?? []}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type DownstreamRequeuePreview, type DownstreamRequeueTask, type EnterpriseApplication } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
@@ -153,7 +153,7 @@ export function AdminDownstreamDeliveriesPage() {
const [deliveryType, setDeliveryType] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -164,6 +164,28 @@ export function AdminDownstreamDeliveriesPage() {
const [requeueBusy, setRequeueBusy] = useState(false);
const [requeueResult, setRequeueResult] = useState<RequeueResult | null>(null);
const requeueInFlightRef = useRef(false);
const [taskPreview, setTaskPreview] = useState<DownstreamRequeuePreview | null>(null);
const [taskPreviewBusy, setTaskPreviewBusy] = useState(false);
const [taskReason, setTaskReason] = useState('');
const [taskRate, setTaskRate] = useState(10);
const [taskCreateBusy, setTaskCreateBusy] = useState(false);
const [requeueTasks, setRequeueTasks] = useState<DownstreamRequeueTask[]>([]);
const [selectedTask, setSelectedTask] = useState<DownstreamRequeueTask | null>(null);
const currentTaskFilter = useCallback(() => ({
keyword: keyword || undefined,
status,
deliveryType,
applicationId,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
}), [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, status]);
const loadRequeueTasks = useCallback(() => {
adminApi.listDownstreamRequeueTasks({ page: 1, pageSize: 10 })
.then((response) => setRequeueTasks(response.items))
.catch(() => undefined);
}, []);
const loadData = useCallback(() => {
setLoading(true);
@@ -202,6 +224,39 @@ export function AdminDownstreamDeliveriesPage() {
loadData();
}, [loadData]);
useEffect(() => {
loadRequeueTasks();
const timer = window.setInterval(loadRequeueTasks, 3000);
return () => window.clearInterval(timer);
}, [loadRequeueTasks]);
const openTaskPreview = async () => {
setTaskPreviewBusy(true);
setError('');
try {
setTaskPreview(await adminApi.previewDownstreamRequeueTask(currentTaskFilter()));
setTaskReason('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '后台重投范围预检失败');
} finally {
setTaskPreviewBusy(false);
}
};
const createRequeueTask = async () => {
if (!taskPreview || taskReason.trim().length < 5) return;
setTaskCreateBusy(true);
try {
await adminApi.createDownstreamRequeueTask({ filter: taskPreview.filter, snapshotAt: taskPreview.snapshotAt, reason: taskReason.trim(), ratePerSecond: taskRate, consecutiveFailureLimit: 10 });
setTaskPreview(null);
loadRequeueTasks();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '后台重投任务创建失败');
} finally {
setTaskCreateBusy(false);
}
};
const replayableStatuses = useMemo(() => new Set(['pending', 'delivered', 'failed', 'unconfirmed', 'rejected']), []);
const selectableIds = useMemo(
() => records.filter((item) => replayableStatuses.has(item.status)).map((item) => item.id),
@@ -455,6 +510,17 @@ export function AdminDownstreamDeliveriesPage() {
<strong>{selectedIds.length}</strong>
</p>
<div>
<label className="downstream-page-size">
<select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); setSelectedIds([]); }}>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
</select>
</label>
<Button disabled={taskPreviewBusy} onClick={() => void openTaskPreview()} variant="secondary">
{taskPreviewBusy ? '范围预检中…' : '按筛选条件重投'}
</Button>
<Button
disabled={selectableIds.length === 0}
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
@@ -548,7 +614,62 @@ export function AdminDownstreamDeliveriesPage() {
/>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="section-heading">
<div><h2></h2><p className="muted"></p></div>
<Button onClick={loadRequeueTasks} variant="ghost"></Button>
</div>
<div className="downstream-requeue-task-list">
{requeueTasks.map((task) => (
<article key={task.id}>
<div><strong>{task.taskNo}</strong><span>{formatDateTime(task.createdAt)}</span></div>
<div><span>{task.application?.name ?? '多个应用'}</span><small>{task.reason}</small></div>
<div><Tag tone={task.status === 'completed' ? 'success' : task.status === 'paused' ? 'warning' : task.status === 'partial_completed' ? 'danger' : 'info'}>{task.status}</Tag><span>{task.successCount + task.failedCount + task.skippedCount}/{task.totalCount}</span></div>
<div className="downstream-requeue-task-list__actions">
<Button onClick={() => void adminApi.getDownstreamRequeueTask(task.id).then(setSelectedTask)} size="sm" variant="ghost"></Button>
{task.status === 'running' || task.status === 'queued' ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'pause').then(loadRequeueTasks)} size="sm" variant="secondary"></Button> : null}
{task.status === 'paused' ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'resume').then(loadRequeueTasks)} size="sm" variant="secondary"></Button> : null}
{['queued', 'running', 'paused'].includes(task.status) ? <Button onClick={() => void adminApi.changeDownstreamRequeueTaskStatus(task.id, 'terminate').then(loadRequeueTasks)} size="sm" variant="warning"></Button> : null}
</div>
</article>
))}
{requeueTasks.length === 0 ? <p className="muted"></p> : null}
</div>
</div>
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
{taskPreview ? (
<Modal open onClose={() => !taskCreateBusy && setTaskPreview(null)} title="创建下游后台重投任务" size="xl" footer={<><Button disabled={taskCreateBusy} onClick={() => setTaskPreview(null)} variant="ghost"></Button><Button disabled={taskCreateBusy || taskPreview.replayableCount === 0 || taskReason.trim().length < 5} onClick={() => void createRequeueTask()} variant="warning">{taskCreateBusy ? '创建中…' : '确认创建任务'}</Button></>}>
<div className="downstream-requeue-preview">
<div className="detail-grid">
<div><span></span><strong>{taskPreview.matchedCount}</strong></div>
<div><span></span><strong>{taskPreview.replayableCount}</strong></div>
<div><span></span><strong>{taskPreview.skippedCount}</strong></div>
<div><span></span><strong>{taskPreview.applicationCount}</strong></div>
<div><span></span><strong>{taskPreview.oldestCreatedAt ? formatDateTime(taskPreview.oldestCreatedAt) : '-'}</strong></div>
<div><span></span><strong>{formatDateTime(taskPreview.snapshotAt)}</strong></div>
</div>
<p className="muted">{Object.entries(taskPreview.statusCounts).map(([key, value]) => `${statusLabel[key] ?? key} ${value}`).join('') || '无'}</p>
<Select label="执行速度" value={String(taskRate)} onChange={(event) => setTaskRate(Number(event.target.value))} options={[{ label: '平稳(每应用10条/秒)', value: '10' }, { label: '快速(每应用20条/秒)', value: '20' }, { label: '低速(每应用5条/秒)', value: '5' }]} />
<label className="field"><span> *</span><textarea value={taskReason} onChange={(event) => setTaskReason(event.target.value)} placeholder="请填写事故原因、工单号或处理说明(至少5个字)" rows={3} /></label>
<div className="downstream-requeue-warning"><AlertTriangle size={20} /><strong></strong></div>
</div>
</Modal>
) : null}
{selectedTask ? (
<Modal open onClose={() => setSelectedTask(null)} title={`后台重投任务 ${selectedTask.taskNo}`} size="xl" footer={<Button onClick={() => setSelectedTask(null)}></Button>}>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{selectedTask.status}</strong></div><div><span></span><strong>{selectedTask.reason}</strong></div>
<div><span></span><strong>{selectedTask.totalCount}</strong></div><div><span></span><strong>{selectedTask.successCount}</strong></div>
<div><span></span><strong>{selectedTask.failedCount}</strong></div><div><span></span><strong>{selectedTask.skippedCount}</strong></div>
<div><span></span><strong>{selectedTask.waitingCount}</strong></div><div><span></span><strong>{selectedTask.ratePerSecond}/</strong></div>
</div>
<h3></h3>
<div className="downstream-requeue-detail-items">{(selectedTask.recentItems ?? []).map((item) => <div key={item.id}><span>{item.delivery.messageId ?? '-'}</span><Tag tone={item.status === 'success' ? 'success' : item.status === 'failed' ? 'danger' : item.status === 'skipped' ? 'warning' : 'info'}>{item.status}</Tag><span>{item.skipReason ?? item.errorMessage ?? '-'}</span></div>)}</div>
</div>
</Modal>
) : null}
{requeueTarget ? (
<Modal
footer={requeueResult ? (
@@ -18,6 +18,13 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
resolved: 'success',
};
const resolvedStatusLabel: Record<string, string> = {
manually_resolved: '人工标记已处理',
accepted: '上游已受理',
rejected: '上游已拒绝',
timeout: '上游提交超时',
};
function formatTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
@@ -34,10 +41,11 @@ function commandValue(record: GatewaySubmitException, key: string) {
return value == null ? '' : String(value);
}
function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
function ExceptionDetailModal({ record, onClose, onRequestRequeue, onRequestResolve }: {
record: GatewaySubmitException;
onClose: () => void;
onRequestRequeue: () => void;
onRequestResolve: () => void;
}) {
const phone = record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber');
const content = record.messageState?.content ?? commandValue(record, 'content');
@@ -50,6 +58,7 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
footer={(
<div className="modal-footer-actions">
<Button onClick={onClose} variant="ghost"></Button>
{record.status === 'pending' ? <Button icon={<CheckCircle2 size={15} />} onClick={onRequestResolve} variant="secondary"></Button> : null}
{record.status === 'pending' ? <Button icon={<RotateCcw size={15} />} onClick={onRequestRequeue}></Button> : null}
</div>
)}
@@ -76,7 +85,7 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
<div><span></span><strong>{formatTime(record.createdAt)}</strong></div>
<div><span></span><strong>{formatTime(record.lastRetriedAt)}</strong></div>
<div><span></span><strong>{formatTime(record.resolvedAt)}</strong></div>
<div><span></span><strong>{record.resolvedStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.resolvedStatus ? (resolvedStatusLabel[record.resolvedStatus] ?? record.resolvedStatus) : '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{content || '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.failureCode}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.failureMessage}</strong></div>
@@ -90,6 +99,39 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
);
}
function ResolveModal({ record, submitting, onClose, onSubmit }: {
record: GatewaySubmitException;
submitting: boolean;
onClose: () => void;
onSubmit: () => void;
}) {
return (
<Modal
open
onClose={onClose}
size="md"
title="确认标记为已处理"
footer={(
<div className="modal-footer-actions">
<Button disabled={submitting} onClick={onClose} variant="ghost"></Button>
<Button disabled={submitting} icon={<CheckCircle2 size={15} />} onClick={onSubmit}>
{submitting ? '处理中...' : '确认已处理'}
</Button>
</div>
)}
>
<div className="downstream-requeue-confirm">
<CheckCircle2 aria-hidden="true" size={24} />
<div>
<p></p>
<small>{record.messageId ?? record.streamMessageId}</small>
<strong></strong>
</div>
</div>
</Modal>
);
}
function RequeueModal({ record, submitting, onClose, onSubmit }: {
record: GatewaySubmitException;
submitting: boolean;
@@ -156,6 +198,8 @@ function GatewaySubmitExceptionPanel() {
const [error, setError] = useState('');
const [detail, setDetail] = useState<GatewaySubmitException | null>(null);
const [requeueRecord, setRequeueRecord] = useState<GatewaySubmitException | null>(null);
const [resolveRecord, setResolveRecord] = useState<GatewaySubmitException | null>(null);
const [resolving, setResolving] = useState(false);
const pageSize = 10;
const loadData = useCallback(() => {
@@ -192,7 +236,7 @@ function GatewaySubmitExceptionPanel() {
{ key: 'failure', title: '异常原因', render: (record) => <div><strong>{record.failureCode}</strong><small className="table-cell-note">{record.failureMessage}</small></div> },
{ key: 'attempts', title: '尝试', width: '80px', align: 'center', render: (record) => `${record.attempts}/${record.maxAttempts}` },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusTone[record.status] ?? 'neutral'}>{statusLabel[record.status] ?? record.status}</Tag> },
{ key: 'actions', title: '操作', width: '105px', align: 'right', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
{ key: 'actions', title: '操作', width: '190px', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>{record.status === 'pending' ? <Button icon={<CheckCircle2 size={14} />} onClick={() => setResolveRecord(record)} size="sm" variant="secondary"></Button> : null}</div> },
], []);
async function submitRequeue(reason: string) {
@@ -214,6 +258,22 @@ function GatewaySubmitExceptionPanel() {
}
}
async function submitResolve() {
if (!resolveRecord) return;
setResolving(true);
try {
await adminApi.resolveGatewaySubmitException(resolveRecord.id);
setResolveRecord(null);
setDetail(null);
setError('');
loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '标记已处理失败');
} finally {
setResolving(false);
}
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
@@ -243,8 +303,9 @@ function GatewaySubmitExceptionPanel() {
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无提交异常'} pagination={false} rowKey="id" />
<Pagination total={total} page={page} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} nextDisabled={page >= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} />
</div>
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} /> : null}
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} onRequestResolve={() => setResolveRecord(detail)} /> : null}
{requeueRecord ? <RequeueModal record={requeueRecord} submitting={submitting} onClose={() => setRequeueRecord(null)} onSubmit={submitRequeue} /> : null}
{resolveRecord ? <ResolveModal record={resolveRecord} submitting={resolving} onClose={() => setResolveRecord(null)} onSubmit={() => void submitResolve()} /> : null}
</div>
);
}