release: prepare RealeseV2.3
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Eye, RefreshCw, RotateCcw, Search } from 'lucide-react';
|
||||
import { adminApi, type AdminChannel, type EnterpriseApplication, type GatewaySubmitException } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { ReceiptAnomalyPanel } from './gateway-exceptions/ReceiptAnomalyPanel';
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
@@ -45,7 +46,7 @@ function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
|
||||
open
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>Gateway提交异常详情</h2><p>{record.messageId ?? record.streamMessageId}</p></div>}
|
||||
title={<div className="template-modal-title"><h2>提交异常详情</h2><p>{record.messageId ?? record.streamMessageId}</p></div>}
|
||||
footer={(
|
||||
<div className="modal-footer-actions">
|
||||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||||
@@ -139,7 +140,7 @@ function RequeueModal({ record, submitting, onClose, onSubmit }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminGatewaySubmitExceptionsPage() {
|
||||
function GatewaySubmitExceptionPanel() {
|
||||
const [items, setItems] = useState<GatewaySubmitException[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
@@ -175,7 +176,7 @@ export function AdminGatewaySubmitExceptionsPage() {
|
||||
.catch((failure: Error) => {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setError(failure.message || 'Gateway提交异常加载失败');
|
||||
setError(failure.message || '提交异常加载失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [applicationId, channelId, keyword, page, status]);
|
||||
@@ -215,9 +216,9 @@ export function AdminGatewaySubmitExceptionsPage() {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<section className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
|
||||
<div className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['运营概览', 'Gateway提交异常']} /><h1>Gateway提交异常</h1><p className="page-inline-hint">仅处理Gateway连续失败且尚未取得明确上游结果的提交命令。</p></div>
|
||||
<div><h2>提交异常</h2><p className="page-inline-hint">展示 Gateway 消费提交命令连续处理失败、且尚未取得明确供应商提交结果的死信数据。这不等同于供应商拒绝或终端送达失败;只有向通道确认原短信未被接收后,才能人工重新入队。</p></div>
|
||||
<Button icon={<RefreshCw size={16} />} onClick={loadData} variant="secondary">刷新</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
@@ -239,11 +240,30 @@ export function AdminGatewaySubmitExceptionsPage() {
|
||||
<div><h2>提交异常记录</h2><p className="page-inline-hint">详情中的 Gateway 命令已由后端脱敏,不返回通道密码。</p></div>
|
||||
<Tag tone="warning">{total} 条</Tag>
|
||||
</div>
|
||||
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无Gateway提交异常'} pagination={false} rowKey="id" />
|
||||
<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}
|
||||
{requeueRecord ? <RequeueModal record={requeueRecord} submitting={submitting} onClose={() => setRequeueRecord(null)} onSubmit={submitRequeue} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminGatewaySubmitExceptionsPage() {
|
||||
const [activeTab, setActiveTab] = useState('submit');
|
||||
return (
|
||||
<section className="page-stack gateway-exception-page">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['运营概览', '网关异常']} /><h1>网关异常</h1><p className="page-inline-hint">集中查看网关提交链路和供应商回执链路的可追踪异常数据。</p></div>
|
||||
</div>
|
||||
<Tabs
|
||||
items={[
|
||||
{ label: '提交异常', value: 'submit', content: <GatewaySubmitExceptionPanel /> },
|
||||
{ label: '回执异常', value: 'receipt', content: <ReceiptAnomalyPanel /> },
|
||||
]}
|
||||
onChange={setActiveTab}
|
||||
value={activeTab}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { Button, Input, Modal, Select } from '@/components/ui';
|
||||
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { carrierLabelMap, cmppVersionOptions, regionOptions } from './channelModel';
|
||||
import type { Carrier, ChannelModalState, SmsChannel } from './channelTypes';
|
||||
import type { Carrier, ChannelModalState, LongMessageReceiptMode, SmsChannel } from './channelTypes';
|
||||
|
||||
export function ChannelFormModal({
|
||||
modal,
|
||||
@@ -33,6 +33,7 @@ export function ChannelFormModal({
|
||||
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
||||
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30));
|
||||
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
|
||||
const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(channel?.longMessageReceiptMode ?? 'per_segment');
|
||||
|
||||
function submit() {
|
||||
if (!isValidMoneyInput(unitPrice)) {
|
||||
@@ -67,6 +68,7 @@ export function ChannelFormModal({
|
||||
heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30,
|
||||
heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3,
|
||||
extensionDigits: Number(extensionDigits),
|
||||
longMessageReceiptMode,
|
||||
rateLimitPerSecond: Number(flowLimit),
|
||||
passwordCipher: password || undefined,
|
||||
});
|
||||
@@ -136,6 +138,16 @@ export function ChannelFormModal({
|
||||
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
||||
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
|
||||
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
|
||||
<Select
|
||||
label="* 长短信成功回执口径"
|
||||
onChange={(event) => setLongMessageReceiptMode(event.target.value as LongMessageReceiptMode)}
|
||||
options={[
|
||||
{ label: '逐分片回执(默认)', value: 'per_segment' },
|
||||
{ label: '整条级回执(一条成功代表全部成功)', value: 'message_level' },
|
||||
]}
|
||||
value={longMessageReceiptMode}
|
||||
/>
|
||||
<p className="page-inline-hint">仅在供应商明确约定长短信成功只返回一条整条级回执时选择“整条级”,否则保持逐分片。</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -121,6 +121,7 @@ export function mapApiChannel(
|
||||
heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30),
|
||||
heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3),
|
||||
extensionDigits: Number(channel.config?.extensionDigits ?? 0),
|
||||
longMessageReceiptMode: channel.config?.longMessageReceiptMode === 'message_level' ? 'message_level' : 'per_segment',
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
};
|
||||
}
|
||||
@@ -148,6 +149,10 @@ export function buildChannelPayload(channel: SmsChannel, passwordCipher?: string
|
||||
windowSize: channel.windowSize,
|
||||
heartbeatIntervalSeconds: channel.heartbeatIntervalSeconds,
|
||||
heartbeatMissThreshold: channel.heartbeatMissThreshold,
|
||||
config: { extensionDigits: channel.extensionDigits, serviceId: channel.businessCode },
|
||||
config: {
|
||||
extensionDigits: channel.extensionDigits,
|
||||
serviceId: channel.businessCode,
|
||||
longMessageReceiptMode: channel.longMessageReceiptMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChannelConnectionLogResponse } from '@/api/adminApi';
|
||||
|
||||
export type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||
export type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
||||
export type LongMessageReceiptMode = 'per_segment' | 'message_level';
|
||||
|
||||
export type SmsChannel = {
|
||||
id: string;
|
||||
@@ -31,6 +32,7 @@ export type SmsChannel = {
|
||||
heartbeatIntervalSeconds: number;
|
||||
heartbeatMissThreshold: number;
|
||||
extensionDigits: number;
|
||||
longMessageReceiptMode: LongMessageReceiptMode;
|
||||
rateLimitPerSecond: number;
|
||||
passwordCipher?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Eye, RefreshCw, Search } from 'lucide-react';
|
||||
import { adminApi, type AdminChannel, type EnterpriseApplication, type ReceiptAnomaly } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
const anomalyTypeLabel: Record<string, string> = {
|
||||
aggregate_success_then_failure: '整条成功后又收到失败',
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
resolved: '已处理',
|
||||
ignored: '已忽略',
|
||||
};
|
||||
|
||||
const statusTone: Record<string, 'neutral' | 'success' | 'warning' | 'danger'> = {
|
||||
pending: 'danger',
|
||||
resolved: 'success',
|
||||
ignored: 'neutral',
|
||||
};
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function maskPhone(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
|
||||
}
|
||||
|
||||
function ReceiptAnomalyDetailModal({ record, onClose }: { record: ReceiptAnomaly; onClose: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>回执异常详情</h2><p>{record.messageRecord?.messageId ?? record.anomalyKey}</p></div>}
|
||||
>
|
||||
<div className="page-stack report-record-detail gateway-exception-detail">
|
||||
<div className="detail-grid">
|
||||
<div><span>异常类型</span><strong>{anomalyTypeLabel[record.anomalyType] ?? record.anomalyType}</strong></div>
|
||||
<div><span>处理状态</span><strong>{statusLabel[record.status] ?? record.status}</strong></div>
|
||||
<div><span>企业</span><strong>{record.tenant?.name ?? '-'}</strong></div>
|
||||
<div><span>应用</span><strong>{record.application?.name ?? '-'}</strong></div>
|
||||
<div><span>通道</span><strong>{record.channel?.name ?? '-'}</strong></div>
|
||||
<div><span>手机号</span><strong>{maskPhone(record.messageRecord?.phoneNumber)}</strong></div>
|
||||
<div><span>MessageId</span><strong>{record.messageRecord?.messageId ?? '-'}</strong></div>
|
||||
<div><span>SubmitId</span><strong>{record.submitRecord?.submitId ?? '-'}</strong></div>
|
||||
<div><span>原平台终态</span><strong>{record.previousStatus ?? '-'}</strong></div>
|
||||
<div><span>新回执状态</span><strong>{record.incomingStatus ?? '-'}</strong></div>
|
||||
<div><span>供应商原始状态</span><strong>{record.rawStatus ?? '-'}</strong></div>
|
||||
<div><span>错误码</span><strong>{record.errorCode ?? '-'}</strong></div>
|
||||
<div><span>首次发生</span><strong>{formatTime(record.firstOccurredAt)}</strong></div>
|
||||
<div><span>最近发生</span><strong>{formatTime(record.lastOccurredAt)}</strong></div>
|
||||
<div><span>重复次数</span><strong>{record.occurrenceCount}</strong></div>
|
||||
<div><span>原始回执 Msg_Id</span><strong>{record.receiptRecord?.gatewayMessageId ?? '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>异常唯一键</span><strong>{record.anomalyKey}</strong></div>
|
||||
<div className="detail-grid__wide"><span>判定依据</span><strong>平台已根据整条级成功回执形成终态,同一提交尝试后续又收到明确失败回执。平台保留成功终态,不自动改写已推送给客户的结果。</strong></div>
|
||||
</div>
|
||||
<div className="gateway-exception-command">
|
||||
<div className="section-heading"><h3>结构化判定信息</h3><p className="page-inline-hint">供应商原始报文请到“系统日志 → 通讯交互日志”按 MessageId 或 Msg_Id 追溯。</p></div>
|
||||
<pre>{JSON.stringify(record.detail ?? {}, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReceiptAnomalyPanel() {
|
||||
const [items, setItems] = useState<ReceiptAnomaly[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [summary, setSummary] = useState({ pending: 0, resolved: 0, ignored: 0, oldestPendingAt: null as string | null });
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [anomalyType, setAnomalyType] = useState('all');
|
||||
const [applicationId, setApplicationId] = useState('all');
|
||||
const [channelId, setChannelId] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<ReceiptAnomaly | null>(null);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listEnterpriseApplications(), adminApi.listChannels()])
|
||||
.then(([appItems, channelItems]) => {
|
||||
setApplications(appItems);
|
||||
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '筛选条件加载失败'));
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
adminApi.listReceiptAnomalies({ keyword, status, anomalyType, applicationId, channelId, page, pageSize })
|
||||
.then((response) => {
|
||||
setItems(response.items);
|
||||
setTotal(response.total);
|
||||
setSummary({ ...response.summary, oldestPendingAt: response.summary.oldestPendingAt ?? null });
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setError(failure.message || '回执异常加载失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [anomalyType, applicationId, channelId, keyword, page, status]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ReceiptAnomaly>>>(() => [
|
||||
{ key: 'lastOccurredAt', title: '最近发生', width: '170px', render: (record) => formatTime(record.lastOccurredAt) },
|
||||
{ key: 'messageId', title: '消息编号', width: '180px', render: (record) => <strong className="admin-task-id">{record.messageRecord?.messageId ?? '-'}</strong> },
|
||||
{ key: 'tenant', title: '企业 / 应用', width: '190px', render: (record) => <div><strong>{record.tenant?.name ?? '-'}</strong><small className="table-cell-note">{record.application?.name ?? '-'}</small></div> },
|
||||
{ key: 'channel', title: '通道', width: '170px', render: (record) => <div>{record.channel?.name ?? '-'}<small className="table-cell-note">{record.channel?.code ?? '-'}</small></div> },
|
||||
{ key: 'type', title: '异常类型', render: (record) => <div><strong>{anomalyTypeLabel[record.anomalyType] ?? record.anomalyType}</strong><small className="table-cell-note">{record.previousStatus ?? '-'} → {record.incomingStatus ?? '-'}</small></div> },
|
||||
{ key: 'count', title: '次数', width: '70px', align: 'center', render: (record) => record.occurrenceCount },
|
||||
{ key: 'status', title: '状态', width: '100px', render: (record) => <Tag tone={statusTone[record.status] ?? 'neutral'}>{statusLabel[record.status] ?? record.status}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '90px', align: 'right', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button> },
|
||||
], []);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<div className="page-stack admin-sms-task-page report-record-page">
|
||||
<div className="page-heading">
|
||||
<div><h2>回执异常</h2><p className="page-inline-hint">展示供应商回执与平台已确定终态互相矛盾的数据,例如整条级成功后又收到同一提交尝试的失败回执。此处保存异常判定和处理状态;真实回执在回执表,原始 CMPP 报文在通讯交互日志。</p></div>
|
||||
<Button icon={<RefreshCw size={16} />} onClick={loadData} variant="secondary">刷新</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface mini-status-card"><AlertTriangle size={22} /><div><span>待处理</span><strong>{summary.pending}</strong><small>需要核对供应商回执口径。</small></div></div>
|
||||
<div className="surface mini-status-card"><Clock3 size={22} /><div><span>最早待处理</span><strong className="gateway-exception-time">{formatTime(summary.oldestPendingAt)}</strong><small>长时间未核对应优先处理。</small></div></div>
|
||||
<div className="surface mini-status-card"><CheckCircle2 size={22} /><div><span>已处理</span><strong>{summary.resolved}</strong><small>已完成供应商或业务核对。</small></div></div>
|
||||
<div className="surface mini-status-card"><RefreshCw size={22} /><div><span>异常总数</span><strong>{total}</strong><small>当前筛选条件下的记录。</small></div></div>
|
||||
</div>
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="消息编号 / 状态码" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="MessageId、SubmitId、原始状态" value={keyword} />
|
||||
<Select label="状态" options={[{ label: '全部状态', value: 'all' }, { label: '待处理', value: 'pending' }, { label: '已处理', value: 'resolved' }, { label: '已忽略', value: 'ignored' }]} value={status} onChange={(event) => { setStatus(event.target.value); setPage(1); }} />
|
||||
<Select label="异常类型" options={[{ label: '全部类型', value: 'all' }, { label: '整条成功后又收到失败', value: 'aggregate_success_then_failure' }]} value={anomalyType} onChange={(event) => { setAnomalyType(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); }} />
|
||||
<Select label="通道" options={[{ label: '全部通道', value: 'all' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]} value={channelId} onChange={(event) => { setChannelId(event.target.value); setPage(1); }} />
|
||||
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}>查询</Button></div>
|
||||
</div>
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<div className="section-heading gateway-exception-list-heading"><div><h2>回执异常记录</h2><p className="page-inline-hint">详情仅展示结构化判定信息,不用它替代原始通讯报文。</p></div><Tag tone="warning">{total} 条</Tag></div>
|
||||
<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 ? <ReceiptAnomalyDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user