feat: complete cmpp gateway delivery recovery workflows

This commit is contained in:
hectorzhao
2026-07-08 16:30:06 +08:00
parent cc628d0214
commit 8144f08652
60 changed files with 8901 additions and 94 deletions
+12
View File
@@ -26,6 +26,8 @@ type SmsChannel = {
corpCode: string;
account: string;
accessNo: string;
desiredConnections: number;
windowSize: number;
passwordCipher?: string;
};
@@ -143,6 +145,8 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
corpCode: channel.enterpriseCode ?? channel.code,
account: channel.account,
accessNo: channel.srcId,
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
windowSize: Number(channel.config?.windowSize ?? 16),
};
}
@@ -163,6 +167,8 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
srcId: channel.accessNo,
rateLimitPerSecond: 100,
unitPrice: Math.round(channel.unitPrice),
desiredConnections: channel.desiredConnections,
windowSize: channel.windowSize,
};
}
@@ -199,6 +205,8 @@ function ChannelFormModal({
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
const [extensionDigits, setExtensionDigits] = useState('0');
const [flowLimit, setFlowLimit] = useState('1-2000');
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
function submit() {
onSubmit({
@@ -220,6 +228,8 @@ function ChannelFormModal({
corpCode,
account,
accessNo,
desiredConnections: Number(desiredConnections) || 1,
windowSize: Number(windowSize) || 16,
passwordCipher: password || undefined,
});
}
@@ -272,6 +282,8 @@ function ChannelFormModal({
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
</div>
<Input label="* 通道流速" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" value={flowLimit} />
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
</div>
</section>
</div>
@@ -0,0 +1,377 @@
import { useCallback, useEffect, useMemo, 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 { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
delivered: 'success',
failed: 'danger',
};
const deliveryTypeLabel: Record<string, string> = {
receipt: '状态回执',
uplink: '上行短信',
};
function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}
footer={<Button onClick={onClose}></Button>}
>
<div className="report-record-detail">
<div className="detail-grid">
<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> 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 className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
</div>
<section className="report-history">
<h3>Payload</h3>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{payloadText}</pre>
</section>
</div>
</Modal>
);
}
export function AdminDownstreamDeliveriesPage() {
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
const [dashboard, setDashboard] = useState<DownstreamDeliveryDashboard | null>(null);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [deliveryType, setDeliveryType] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.getDownstreamDeliveryDashboard({
applicationId,
deliveryType,
}),
adminApi.listDownstreamDeliveries({
keyword,
status,
deliveryType,
applicationId,
page,
pageSize,
}),
adminApi.listEnterpriseApplications(),
])
.then(([dashboardResponse, response, apps]) => {
setDashboard(dashboardResponse);
setRecords(response.items);
setTotal(response.total);
setApplications(apps);
setSelectedIds((current) => current.filter((id) => response.items.some((item) => item.id === id)));
setError('');
})
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
.finally(() => setLoading(false));
}, [applicationId, deliveryType, keyword, page, pageSize, status]);
useEffect(() => {
loadData();
}, [loadData]);
const selectableIds = useMemo(
() => records.filter((item) => item.status !== 'delivered').map((item) => item.id),
[records],
);
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
const summary = dashboard?.summary;
const typeBreakdown = dashboard?.typeBreakdown ?? [];
const retryBuckets = dashboard?.retryBuckets ?? [];
const topApplications = dashboard?.topApplications ?? [];
const columns: Array<TableColumn<DownstreamDeliveryRecord>> = [
{
key: 'select',
title: '选择',
width: '52px',
align: 'center',
render: (record) => (
<input
type="checkbox"
disabled={record.status === 'delivered'}
checked={selectedIds.includes(record.id)}
onChange={(event) => {
setSelectedIds((current) =>
event.target.checked
? [...current, record.id]
: current.filter((item) => item !== record.id),
);
}}
aria-label={`选择${record.id}`}
/>
),
},
{ key: 'createdAt', title: '投递时间', width: '180px', render: (record) => record.createdAt },
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
{ 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: 'retry', title: '重试', width: '90px', align: 'center', render: (record) => record.retryCount },
{ key: 'error', title: '最后错误', render: (record) => record.lastError ?? '-' },
{
key: 'actions',
title: '操作',
width: '170px',
align: 'right',
render: (record) => (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<Button
icon={<RefreshCw size={14} />}
onClick={() => {
adminApi.requeueDownstreamDelivery(record.id)
.then(() => loadData())
.catch((failure: Error) => setError(failure.message || '人工重投失败'));
}}
size="sm"
variant="secondary"
>
</Button>
</div>
),
},
];
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<section className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading">
<div>
<Breadcrumb items={['数据详单', '下游投递记录']} />
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface mini-status-card">
<BarChart3 size={22} />
<div>
<span></span>
<strong>{summary?.total ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<TimerReset size={22} />
<div>
<span></span>
<strong>{summary?.pending ?? 0}</strong>
<small> {summary?.stalledPending ?? 0} </small>
</div>
</div>
<div className="surface mini-status-card">
<CheckCircle2 size={22} />
<div>
<span></span>
<strong>{summary?.delivered ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<AlertTriangle size={22} />
<div>
<span></span>
<strong>{summary?.alertCount ?? 0}</strong>
<small> {summary?.recentFailed ?? 0} </small>
</div>
</div>
</div>
<div className="surface admin-task-filter">
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Select
label="状态"
options={[
{ label: '全部状态', value: 'all' },
{ label: '待投递', value: 'pending' },
{ label: '已投递', value: 'delivered' },
{ label: '最终失败', value: 'failed' },
]}
value={status}
onChange={(event) => {
setStatus(event.target.value);
setPage(1);
}}
/>
<Select
label="类型"
options={[
{ label: '全部类型', value: 'all' },
{ label: '状态回执', value: 'receipt' },
{ label: '上行短信', value: 'uplink' },
]}
value={deliveryType}
onChange={(event) => {
setDeliveryType(event.target.value);
setPage(1);
}}
/>
<Select
label="应用"
options={[
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.id })),
]}
value={applicationId}
onChange={(event) => {
setApplicationId(event.target.value);
setPage(1);
}}
/>
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button
onClick={() => {
setKeyword('');
setStatus('all');
setDeliveryType('all');
setApplicationId('all');
setPage(1);
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="overview-grid">
<div className="surface">
<div className="section-heading">
<h2></h2>
</div>
<div className="downstream-breakdown-table">
<div className="downstream-breakdown-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{typeBreakdown.map((item) => (
<div className="downstream-breakdown-table__row" key={item.deliveryType}>
<strong>{deliveryTypeLabel[item.deliveryType] ?? item.deliveryType}</strong>
<span>{item.total}</span>
<span>{item.pending}</span>
<span>{item.delivered}</span>
<span>{item.failed}</span>
</div>
))}
</div>
</div>
<div className="surface">
<div className="section-heading">
<h2></h2>
</div>
<div className="downstream-bucket-list">
{retryBuckets.map((item) => (
<div className="downstream-bucket-item" key={item.label}>
<span>{item.label}</span>
<strong>{item.count}</strong>
</div>
))}
</div>
</div>
</div>
<div className="surface">
<div className="section-heading">
<h2></h2>
</div>
<div className="downstream-breakdown-table">
<div className="downstream-breakdown-table__head downstream-breakdown-table__head--apps">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{topApplications.length > 0 ? topApplications.map((item) => (
<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.delivered}</span>
<span>{item.alertCount}</span>
</div>
)) : (
<div className="downstream-breakdown-table__empty"></div>
)}
</div>
</div>
<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`
</p>
<div style={{ display: 'flex', gap: 8 }}>
<Button
disabled={selectableIds.length === 0}
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
variant="ghost"
>
{allSelected ? '取消全选当前页' : '全选当前页'}
</Button>
<Button
icon={<RefreshCw size={14} />}
disabled={selectedIds.length === 0}
onClick={() => {
adminApi.batchRequeueDownstreamDeliveries(selectedIds)
.then((result) => {
setError(result.failedCount > 0 ? `批量重投完成,成功 ${result.successCount} 条,失败 ${result.failedCount}` : '');
loadData();
})
.catch((failure: Error) => setError(failure.message || '批量重投失败'));
}}
variant="secondary"
>
</Button>
</div>
</div>
<Table columns={columns} data={records} emptyText={loading ? '加载中...' : '暂无下游投递记录'} pagination={false} rowKey="id" />
<Pagination
total={total}
page={page}
previousDisabled={page <= 1}
nextDisabled={page >= totalPages}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
/>
</div>
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
</section>
);
}
@@ -0,0 +1,345 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Download, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type EnterpriseApplication, type GatewayDownstreamRecoveryStatus } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
const recoveryStatusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
running: 'info',
success: 'success',
failed: 'danger',
waiting_connection: 'warning',
partial: 'warning',
};
const recoveryStatusLabel: Record<string, string> = {
running: '恢复中',
success: '恢复成功',
failed: '恢复失败',
waiting_connection: '等待连接',
partial: '部分成功',
};
const failureCategoryTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
client_disconnected: 'warning',
backoff: 'warning',
lock_contended: 'info',
flush_failed: 'danger',
partial_delivery_failed: 'danger',
lock_lost: 'danger',
unknown: 'neutral',
};
const failureCategoryLabel: Record<string, string> = {
client_disconnected: '客户未连接',
backoff: '退避等待',
lock_contended: '恢复锁占用',
flush_failed: '恢复执行失败',
partial_delivery_failed: '部分投递失败',
lock_lost: '恢复锁丢失',
unknown: '未知原因',
};
function RecoveryDetailModal({ record, onClose }: { record: GatewayDownstreamRecoveryStatus; onClose: () => void }) {
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.account}</p></div>}
footer={<Button onClick={onClose}></Button>}
>
<div className="report-record-detail">
<div className="admin-detail-metric-grid admin-detail-metric-grid--compact">
<div className="surface mini-status-card">
<RefreshCw size={20} />
<div>
<span></span>
<strong>{recoveryStatusLabel[record.state] ?? record.state}</strong>
</div>
</div>
<div className="surface mini-status-card">
<TimerReset size={20} />
<div>
<span></span>
<strong>{record.attemptCount}</strong>
</div>
</div>
<div className="surface mini-status-card">
<CheckCircle2 size={20} />
<div>
<span></span>
<strong>{record.nextRetryAt ?? '-'}</strong>
</div>
</div>
</div>
<div className="detail-grid">
<div><span></span><strong>{record.tenant?.name ?? '-'}</strong></div>
<div><span></span><strong>{record.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{record.account}</strong></div>
<div><span>Gateway </span><strong>{record.gatewayInstanceId ?? '-'}</strong></div>
<div><span></span><strong>{record.lockOwner ?? '-'}</strong></div>
<div><span></span><strong>{record.lockExpiresAt ?? '-'}</strong></div>
<div><span></span><strong>{record.failureCategory ? failureCategoryLabel[record.failureCategory] ?? record.failureCategory : '-'}</strong></div>
<div><span></span><strong>{record.lastAttemptAt ?? '-'}</strong></div>
<div><span></span><strong>{record.lastSuccessAt ?? '-'}</strong></div>
<div><span></span><strong>{record.lastFailureAt ?? '-'}</strong></div>
<div><span></span><strong>{record.createdAt}</strong></div>
<div><span></span><strong>{record.updatedAt}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastSkipReason ?? '-'}</strong></div>
</div>
</div>
</Modal>
);
}
export function AdminDownstreamRecoveryStatusesPage() {
const [items, setItems] = useState<GatewayDownstreamRecoveryStatus[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [summary, setSummary] = useState<{ total: number; running: number; success: number; failed: number; waitingConnection: number; backoff: number; failureCategories: Array<{ category: string; count: number }> } | null>(null);
const [keyword, setKeyword] = useState('');
const [state, setState] = useState('all');
const [failureCategory, setFailureCategory] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState('');
const [detail, setDetail] = useState<GatewayDownstreamRecoveryStatus | null>(null);
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.listDownstreamRecoveryStatuses({ keyword, state, failureCategory, applicationId, page, pageSize }),
adminApi.listEnterpriseApplications(),
])
.then(([response, apps]) => {
setItems(response.items);
setSummary(response.summary);
setTotal(response.total);
setApplications(apps);
setError('');
})
.catch((failure: Error) => {
setItems([]);
setTotal(0);
setSummary(null);
setError(failure.message || '恢复状态加载失败');
})
.finally(() => setLoading(false));
}, [applicationId, failureCategory, keyword, page, pageSize, state]);
useEffect(() => {
loadData();
}, [loadData]);
const columns = useMemo<Array<TableColumn<GatewayDownstreamRecoveryStatus>>>(() => [
{ key: 'updatedAt', title: '更新时间', width: '180px', render: (record) => record.updatedAt },
{ key: 'account', title: '账号', width: '120px', render: (record) => <strong className="admin-task-id">{record.account}</strong> },
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? '-' },
{ key: 'gateway', title: 'Gateway实例', width: '180px', render: (record) => <span className="muted">{record.gatewayInstanceId ?? '-'}</span> },
{ key: 'lockOwner', title: '锁持有', width: '150px', render: (record) => <span className="muted">{record.lockOwner ?? '-'}</span> },
{ key: 'state', title: '状态', width: '120px', render: (record) => <Tag tone={recoveryStatusTone[record.state] ?? 'info'}>{recoveryStatusLabel[record.state] ?? record.state}</Tag> },
{ key: 'failureCategory', title: '失败分类', width: '140px', render: (record) => record.failureCategory ? <Tag tone={failureCategoryTone[record.failureCategory] ?? 'neutral'}>{failureCategoryLabel[record.failureCategory] ?? record.failureCategory}</Tag> : '-' },
{ key: 'attemptCount', title: '尝试次数', width: '96px', align: 'center', render: (record) => record.attemptCount },
{ key: 'nextRetryAt', title: '下次恢复', width: '180px', render: (record) => record.nextRetryAt ?? '-' },
{ key: 'lastError', title: '最后错误/跳过原因', render: (record) => record.lastError ?? record.lastSkipReason ?? '-' },
{
key: 'actions',
title: '操作',
width: '110px',
align: 'right',
render: (record) => (
<Button
icon={<Eye size={14} />}
onClick={() => {
adminApi.getDownstreamRecoveryStatus(record.id)
.then((data) => {
setDetail(data);
setError('');
})
.catch((failure: Error) => setError(failure.message || '恢复状态详情加载失败'));
}}
size="sm"
variant="ghost"
>
</Button>
),
},
], []);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const failureCategoryBreakdown = summary?.failureCategories ?? [];
function resetFilters() {
setKeyword('');
setState('all');
setFailureCategory('all');
setApplicationId('all');
setPage(1);
}
async function exportCurrent() {
setExporting(true);
try {
const blob = await adminApi.exportDownstreamRecoveryStatuses({ keyword, state, failureCategory, applicationId });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
anchor.href = url;
anchor.download = `recovery-statuses-${timestamp}.csv`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '恢复状态导出失败');
} finally {
setExporting(false);
}
}
return (
<section className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading">
<div>
<Breadcrumb items={['发送运维', '恢复状态管理']} />
<h1></h1>
</div>
<Button icon={<Download size={16} />} onClick={exportCurrent} variant="secondary" disabled={exporting}>
{exporting ? '导出中...' : '导出当前筛选'}
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface mini-status-card">
<RefreshCw size={22} />
<div>
<span></span>
<strong>{summary?.total ?? 0}</strong>
<small> PostgreSQL </small>
</div>
</div>
<div className="surface mini-status-card">
<TimerReset size={22} />
<div>
<span></span>
<strong>{summary?.waitingConnection ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<CheckCircle2 size={22} />
<div>
<span></span>
<strong>{summary?.success ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<AlertTriangle size={22} />
<div>
<span>退</span>
<strong>{summary?.backoff ?? 0}</strong>
<small>退</small>
</div>
</div>
</div>
<div className="surface">
<div className="section-heading">
<h2></h2>
<p className="page-inline-hint"></p>
</div>
<div className="downstream-bucket-list downstream-bucket-list--wrap">
{failureCategoryBreakdown.length > 0 ? failureCategoryBreakdown.map((item) => (
<div className="downstream-bucket-item" key={item.category}>
<span>{failureCategoryLabel[item.category] ?? item.category}</span>
<strong>{item.count}</strong>
</div>
)) : (
<div className="downstream-breakdown-table__empty"></div>
)}
</div>
</div>
<div className="surface admin-task-filter">
<Input label="账号 / 企业 / 应用 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Select
label="状态"
options={[
{ label: '全部状态', value: 'all' },
{ label: '恢复中', value: 'running' },
{ label: '恢复成功', value: 'success' },
{ label: '恢复失败', value: 'failed' },
{ label: '等待连接', value: 'waiting_connection' },
{ label: '部分成功', value: 'partial' },
]}
value={state}
onChange={(event) => {
setState(event.target.value);
setPage(1);
}}
/>
<Select
label="失败分类"
options={[
{ label: '全部分类', value: 'all' },
{ label: '客户未连接', value: 'client_disconnected' },
{ label: '退避等待', value: 'backoff' },
{ label: '恢复锁占用', value: 'lock_contended' },
{ label: '恢复执行失败', value: 'flush_failed' },
{ label: '部分投递失败', value: 'partial_delivery_failed' },
{ label: '恢复锁丢失', value: 'lock_lost' },
{ label: '未知原因', value: 'unknown' },
]}
value={failureCategory}
onChange={(event) => {
setFailureCategory(event.target.value);
setPage(1);
}}
/>
<Select
label="应用"
options={[
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.id })),
]}
value={applicationId}
onChange={(event) => {
setApplicationId(event.target.value);
setPage(1);
}}
/>
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="section-heading">
<h2></h2>
<p className="page-inline-hint"></p>
</div>
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无恢复状态'} pagination={false} rowKey="id" />
<Pagination
total={total}
page={page}
previousDisabled={page <= 1}
nextDisabled={page >= totalPages}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
/>
</div>
{detail ? <RecoveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
</section>
);
}
+9
View File
@@ -84,6 +84,7 @@ export function AdminHome() {
const averageSuccessRate = dashboard?.today.successRate ?? 0;
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const sendTrendOption = useMemo(
() => createLineOption({
@@ -253,6 +254,14 @@ export function AdminHome() {
<small>线</small>
</div>
</div>
<div className="mini-status-card">
<RadioTower size={22} />
<div>
<span></span>
<strong>{downstreamAlertCount} </strong>
<small></small>
</div>
</div>
</div>
</div>
</div>
@@ -22,6 +22,9 @@ export function AdminSmsApplicationFormPage() {
const [dailyLimit, setDailyLimit] = useState('100000');
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
const [cmppAccount, setCmppAccount] = useState('');
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
const [cmppWindowSize, setCmppWindowSize] = useState('16');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [ipAddress, setIpAddress] = useState('');
@@ -75,6 +78,9 @@ export function AdminSmsApplicationFormPage() {
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4));
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
setCmppAccount(application.cmppAccount ?? '');
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
setCmppWindowSize(String(application.cmppWindowSize ?? 16));
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
@@ -110,6 +116,9 @@ export function AdminSmsApplicationFormPage() {
dailyLimit: Number(dailyLimit) || undefined,
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
queuePriority,
cmppAccount: cmppAccount.trim() || undefined,
cmppMaxConnections: Number(cmppMaxConnections) || 1,
cmppWindowSize: Number(cmppWindowSize) || 16,
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
templateMismatchMode: mismatchPolicy,
ipAllowlist: parseIpAllowlist(ipAddress),
@@ -167,6 +176,9 @@ export function AdminSmsApplicationFormPage() {
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} />
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
<Input label="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
+40 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord } from '@/api/adminApi';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
const statusLabelMap: Record<string, string> = {
@@ -28,6 +28,8 @@ export function AdminSmsRecordsPage() {
const [contentKeyword, setContentKeyword] = useState('');
const [status, setStatus] = useState('all');
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
const [segmentLoading, setSegmentLoading] = useState(false);
const [error, setError] = useState('');
function loadData() {
@@ -43,6 +45,21 @@ export function AdminSmsRecordsPage() {
loadData();
}, []);
useEffect(() => {
if (!selectedRecord) {
setSegmentAudits([]);
return;
}
setSegmentLoading(true);
adminApi.listMessageSegmentAudits({ messageRecordId: selectedRecord.id })
.then((items) => {
setSegmentAudits(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '分片审计加载失败'))
.finally(() => setSegmentLoading(false));
}, [selectedRecord]);
const filteredRows = useMemo(
() => records.filter((item) => {
const submittedDate = item.queuedAt.slice(0, 10);
@@ -64,6 +81,18 @@ export function AdminSmsRecordsPage() {
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost"></Button> },
];
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
{ key: 'submitId', title: '提交ID', width: '190px', render: (record) => <strong className="admin-task-id">{record.submitId}</strong> },
{ key: 'channel', title: '通道', width: '150px', render: (record) => record.channel?.name ?? record.channelId ?? '-' },
{ key: 'sequenceId', title: 'Sequence', width: '110px', render: (record) => record.sequenceId ?? '-' },
{ key: 'gatewayMessageId', title: 'MsgId', width: '180px', render: (record) => record.gatewayMessageId ?? '-' },
{ key: 'submitStatus', title: '提交状态', width: '110px', render: (record) => <Tag tone={record.submitStatus === 'accepted' ? 'success' : record.submitStatus === 'queued' ? 'info' : 'danger'}>{record.submitStatus}</Tag> },
{ key: 'receiptStatus', title: '回执状态', width: '110px', render: (record) => record.receiptStatus ? <Tag tone={record.receiptStatus === 'delivered' ? 'success' : record.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{record.receiptStatus}</Tag> : '-' },
{ key: 'compensation', title: '补偿', width: '120px', render: (record) => record.compensationType ?? '-' },
{ key: 'error', title: '错误', render: (record) => record.errorMessage ?? record.errorCode ?? '-' },
];
function resetFilters() {
setDateRange({});
setPhoneKeyword('');
@@ -125,6 +154,16 @@ export function AdminSmsRecordsPage() {
<p>{statusLabelMap[selectedRecord.status] ?? selectedRecord.status}</p>
<p>{selectedRecord.errorMessage ?? '-'}</p>
</section>
<section>
<h3></h3>
<Table
columns={segmentColumns}
data={segmentAudits}
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'}
pagination={false}
rowKey="id"
/>
</section>
</div>
) : null}
</Modal>
+105 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsUplinkMessage } from '@/api/adminApi';
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
import {
Breadcrumb,
Button,
@@ -20,19 +20,44 @@ function getTime(value?: string | null) {
return value ? `${value.slice(0, 10)} ${value.slice(11, 19)}` : '-';
}
function matchStatusText(status?: string | null) {
const map: Record<string, string> = {
matched: '已匹配',
ambiguous: '待认领',
unmatched: '未匹配',
};
return status ? (map[status] ?? status) : '-';
}
function candidateStatusText(status?: string | null) {
const map: Record<string, string> = {
pending: '待认领',
claimed: '已认领',
rejected: '已排除',
};
return status ? (map[status] ?? status) : '-';
}
function UplinkDetailModal({
claimError,
claimingId,
detailError,
matchedRecords,
matching,
message,
onClaim,
onClose,
}: {
claimError: string;
claimingId: string;
detailError: string;
matchedRecords: SmsMessageRecord[];
matching: boolean;
message: SmsUplinkMessage;
onClaim: (candidate: SmsUplinkMatchCandidate) => void;
onClose: () => void;
}) {
const candidates = message.matchCandidates ?? [];
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
@@ -69,6 +94,10 @@ function UplinkDetailModal({
<span>ID</span>
<strong>{message.messageId || '-'}</strong>
</div>
<div>
<span></span>
<strong>{matchStatusText(message.matchStatus)}</strong>
</div>
<div className="admin-uplink-info-grid__full">
<span></span>
<strong>{message.content || '-'}</strong>
@@ -76,6 +105,58 @@ function UplinkDetailModal({
</div>
</section>
<section className="admin-uplink-match-section">
<h3></h3>
{claimError ? <p className="form-error">{claimError}</p> : null}
{candidates.length === 0 ? <div className="admin-uplink-empty-match"></div> : null}
{candidates.map((candidate) => (
<article className="admin-uplink-match-card" key={candidate.id}>
<div className="admin-uplink-match-grid">
<div>
<span></span>
<strong>{candidate.tenant?.name ?? candidate.tenantId}</strong>
</div>
<div>
<span></span>
<strong>{candidate.application?.name ?? candidate.applicationId}</strong>
</div>
<div>
<span></span>
<strong>{candidate.matchSource === 'access_number' ? '接入号' : '手机号时间窗'}</strong>
</div>
<div>
<span></span>
<strong>{candidate.confidence}</strong>
</div>
<div>
<span></span>
<strong>{candidateStatusText(candidate.status)}</strong>
</div>
<div>
<span></span>
<strong>{candidate.messageRecord?.messageId ?? '-'}</strong>
</div>
</div>
{candidate.messageRecord ? (
<div className="admin-uplink-match-content">
<span></span>
<p>{candidate.messageRecord.content}</p>
</div>
) : null}
<div className="admin-uplink-candidate-footer">
<span>{candidate.reason ?? '-'}</span>
<Button
disabled={claimingId === candidate.id || candidate.status === 'claimed' || candidate.status === 'rejected'}
onClick={() => onClaim(candidate)}
size="sm"
>
{candidate.status === 'claimed' ? '已认领' : claimingId === candidate.id ? '认领中...' : '认领并推送'}
</Button>
</div>
</article>
))}
</section>
<section className="admin-uplink-match-section">
<h3></h3>
{matching ? <p>...</p> : null}
@@ -125,8 +206,10 @@ export function AdminSmsUplinkRecordsPage() {
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
const [loading, setLoading] = useState(true);
const [matching, setMatching] = useState(false);
const [claimingId, setClaimingId] = useState('');
const [error, setError] = useState('');
const [detailError, setDetailError] = useState('');
const [claimError, setClaimError] = useState('');
function loadData() {
setLoading(true);
@@ -143,6 +226,7 @@ export function AdminSmsUplinkRecordsPage() {
setSelectedMessage(message);
setMatchedRecords([]);
setDetailError('');
setClaimError('');
if (!message.messageId) {
return;
@@ -177,6 +261,22 @@ export function AdminSmsUplinkRecordsPage() {
setContentKeyword('');
}
function handleClaim(candidate: SmsUplinkMatchCandidate) {
if (!selectedMessage) {
return;
}
setClaimingId(candidate.id);
setClaimError('');
adminApi.claimUplinkMatchCandidate(selectedMessage.id, { candidateId: candidate.id })
.then((updated) => {
setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current));
loadData();
})
.catch((reason: Error) => setClaimError(reason.message || '上行认领失败'))
.finally(() => setClaimingId(''));
}
const columns: Array<TableColumn<SmsUplinkMessage>> = [
{
key: 'select',
@@ -190,6 +290,7 @@ export function AdminSmsUplinkRecordsPage() {
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
{ key: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.destId}</strong> },
{ key: 'matchStatus', title: '匹配状态', width: '140px', render: (record) => <strong>{matchStatusText(record.matchStatus)}</strong> },
{
key: 'actions',
title: '操作',
@@ -228,10 +329,13 @@ export function AdminSmsUplinkRecordsPage() {
{selectedMessage ? (
<UplinkDetailModal
claimError={claimError}
claimingId={claimingId}
detailError={detailError}
matchedRecords={matchedRecords}
matching={matching}
message={selectedMessage}
onClaim={handleClaim}
onClose={() => setSelectedMessage(null)}
/>
) : null}