feat: harden sessions and track downstream acknowledgements
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user