release: prepare RealeseV2.3
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
|
||||
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, ProtocolInteractionLogResponse, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
|
||||
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProtocolInteractionLogResponse, ReceiptAnomalyResponse, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
|
||||
|
||||
// Read-heavy operations endpoints are isolated from configuration mutations.
|
||||
export const adminOperationsApi = {
|
||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||
getPendingAudits: (tenantId?: string) => request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
|
||||
getSendQuality: (date?: string) => request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
||||
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query)),
|
||||
@@ -52,6 +53,8 @@ export const adminOperationsApi = {
|
||||
request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
|
||||
requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) =>
|
||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listReceiptAnomalies: (query: { tenantId?: string; applicationId?: string; channelId?: string; anomalyType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<ReceiptAnomalyResponse>(withQuery('/admin/operations/receipt-anomalies', query)),
|
||||
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
|
||||
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) =>
|
||||
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
|
||||
|
||||
@@ -17,7 +17,7 @@ export type AdminChannel = {
|
||||
rateLimitPerSecond: number;
|
||||
unitPrice: number;
|
||||
status: string;
|
||||
config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; [key: string]: unknown } | null;
|
||||
config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; longMessageReceiptMode?: 'per_segment' | 'message_level'; [key: string]: unknown } | null;
|
||||
connectionStates?: CmppConnectionState[];
|
||||
};
|
||||
|
||||
|
||||
@@ -127,6 +127,15 @@ export type UserPayload = {
|
||||
operatorId?: string;
|
||||
};
|
||||
|
||||
export type PendingAuditCounts = {
|
||||
enterpriseCertifications: number;
|
||||
smsAudits: number;
|
||||
templates: number;
|
||||
signatures: number;
|
||||
drainageInfos: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type DashboardResponse = {
|
||||
taskCount: number;
|
||||
messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>;
|
||||
@@ -136,7 +145,7 @@ export type DashboardResponse = {
|
||||
transactions: { _count: { _all: number }; _sum: { amountCents?: number | null } };
|
||||
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
|
||||
pendingAuditCount: number;
|
||||
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number };
|
||||
pendingAudits: PendingAuditCounts;
|
||||
hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>;
|
||||
auditProcessingSpeed: Array<{ category: string; label: string; count: number; averageProcessingMs: number | null }>;
|
||||
downstreamDeliverySummary?: {
|
||||
@@ -382,7 +391,7 @@ export type HttpApiConfig = {
|
||||
allowClientTest: boolean;
|
||||
};
|
||||
|
||||
export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; config: HttpApiConfig | null; ipAllowlist: string[] };
|
||||
export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; publicOrigin?: string; config: HttpApiConfig | null; ipAllowlist: string[] };
|
||||
|
||||
export type HttpApiCredential = { id: string; name: string; accessKey: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; expiresAt?: string | null; lastUsedAt?: string | null; lastUsedIp?: string | null; createdAt: string };
|
||||
|
||||
|
||||
@@ -571,6 +571,38 @@ export type GatewaySubmitExceptionResponse = PagedResponse<GatewaySubmitExceptio
|
||||
};
|
||||
};
|
||||
|
||||
export type ReceiptAnomaly = {
|
||||
id: string;
|
||||
anomalyKey: string;
|
||||
anomalyType: 'aggregate_success_then_failure' | string;
|
||||
status: 'pending' | 'resolved' | 'ignored' | string;
|
||||
previousStatus?: string | null;
|
||||
incomingStatus?: string | null;
|
||||
rawStatus?: string | null;
|
||||
errorCode?: string | null;
|
||||
detail?: Record<string, unknown> | null;
|
||||
occurrenceCount: number;
|
||||
firstOccurredAt: string;
|
||||
lastOccurredAt: string;
|
||||
resolvedAt?: string | null;
|
||||
resolutionNote?: string | null;
|
||||
tenant?: Pick<TenantOption, 'id' | 'name' | 'code' | 'status'> | null;
|
||||
application?: Pick<EnterpriseApplication, 'id' | 'tenantId' | 'name' | 'status'> | null;
|
||||
channel?: Pick<AdminChannel, 'id' | 'code' | 'name' | 'status'> | null;
|
||||
messageRecord?: { messageId: string; phoneNumber: string; status: string } | null;
|
||||
submitRecord?: { submitId: string; submitStatus: string } | null;
|
||||
receiptRecord?: { gatewayMessageId: string; receiptStatus: string; rawStatus: string; deliveredAt: string } | null;
|
||||
};
|
||||
|
||||
export type ReceiptAnomalyResponse = PagedResponse<ReceiptAnomaly> & {
|
||||
summary: {
|
||||
pending: number;
|
||||
resolved: number;
|
||||
ignored: number;
|
||||
oldestPendingAt?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type DownstreamRecoveryStatusResponse = PagedResponse<GatewayDownstreamRecoveryStatus> & {
|
||||
summary: {
|
||||
total: number;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { BookOpen, Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
|
||||
import { clientApi, type ClientSmsApplication, type HttpApiConfigResponse, type HttpApiCredential, type HttpApiRequestLog, type HttpWebhookDelivery, type HttpWebhookEndpoint } from '@/api/adminApi';
|
||||
import { Button, Input, Select, Tabs, Tag } from '@/components/ui';
|
||||
import { copyText } from '@/utils/clipboard';
|
||||
import { formatHttpApiParams } from '@/utils/interfaceParams';
|
||||
import { formatHttpApiParams, httpApiPublicOrigin } from '@/utils/interfaceParams';
|
||||
|
||||
export function ClientHttpApiPage() {
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
@@ -80,9 +80,10 @@ export function ClientHttpApiPage() {
|
||||
}
|
||||
|
||||
const api = config?.config;
|
||||
const publicApiOrigin = httpApiPublicOrigin(config, window.location.origin);
|
||||
const overview = <div className="page-stack">
|
||||
{!api?.enabled ? <p className="form-error">当前应用尚未由运营端开通 HTTP 接口。</p> : null}
|
||||
<div className="surface" style={{ padding: 18 }}><div className="section-heading"><div><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">基础地址:{window.location.origin}/api/openapi/v1</p></div><Button disabled={!api?.enabled} icon={<Copy size={14} />} onClick={() => void copyHttpParams()} size="sm">{paramsCopied ? '已复制' : '复制HTTP参数'}</Button></div><div className="table-actions">
|
||||
<div className="surface" style={{ padding: 18 }}><div className="section-heading"><div><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">基础地址:{publicApiOrigin}/api/openapi/v1</p></div><Button disabled={!api?.enabled} icon={<Copy size={14} />} onClick={() => void copyHttpParams()} size="sm">{paramsCopied ? '已复制' : '复制HTTP参数'}</Button></div><div className="table-actions">
|
||||
<Tag tone={api?.sendEnabled ? 'success' : 'info'}>单条发送 {api?.sendEnabled ? '已开通' : '未开通'}</Tag>
|
||||
<Tag tone={api?.messageQueryEnabled ? 'success' : 'info'}>状态查询 {api?.messageQueryEnabled ? '已开通' : '未开通'}</Tag>
|
||||
<Tag tone={api?.uplinkQueryEnabled ? 'success' : 'info'}>上行查询 {api?.uplinkQueryEnabled ? '已开通' : '未开通'}</Tag>
|
||||
@@ -105,7 +106,7 @@ export function ClientHttpApiPage() {
|
||||
TIMESTAMP
|
||||
NONCE
|
||||
SHA256(rawBody)`}</pre><p>使用访问密钥执行 HMAC-SHA256,输出小写十六进制。单发还必须携带 <code>Idempotency-Key</code>。</p></div>
|
||||
<div className="surface" style={{ padding: 18 }}><h3>接口清单</h3><pre>{`POST /api/openapi/v1/sms/messages\nGET /api/openapi/v1/sms/messages/{messageId}\nGET /api/openapi/v1/sms/uplinks\nGET /api/openapi/v1/sms/uplinks/{uplinkId}`}</pre><p className="muted">完整 OpenAPI 文档:<a href="/api/client-docs" rel="noreferrer" target="_blank">/api/client-docs</a></p></div>
|
||||
<div className="surface" style={{ padding: 18 }}><h3>接口清单</h3><pre>{`POST /api/openapi/v1/sms/messages\nGET /api/openapi/v1/sms/messages/{messageId}\nGET /api/openapi/v1/sms/uplinks\nGET /api/openapi/v1/sms/uplinks/{uplinkId}`}</pre><p className="muted">完整 OpenAPI 文档:<a href={`${publicApiOrigin}/api/client-docs`} rel="noreferrer" target="_blank">{publicApiOrigin}/api/client-docs</a></p></div>
|
||||
<div className="surface" style={{ padding: 18 }}><h3>回调验签</h3><p>回调请求头包含 X-Event-Id、X-Event-Type、X-Timestamp、X-Signature。签名原文为 <code>TIMESTAMP + '\\n' + rawBody</code>,同样使用 HMAC-SHA256。客户系统必须按 X-Event-Id 幂等。</p></div></div>;
|
||||
|
||||
const logsPanel = <div className="page-stack"><div className="surface" style={{ padding: 16 }}><h3>最近调用</h3>{requests.map((item) => <p key={item.id}><code>{item.requestId}</code> · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} · {item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}</p>)}{requests.length === 0 ? <p className="muted">暂无调用记录。</p> : null}</div>
|
||||
|
||||
@@ -38,19 +38,22 @@ import type { LoginSession } from '@/api/session';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
|
||||
const EMPTY_PENDING_AUDITS = { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 };
|
||||
|
||||
export function AdminLayout() {
|
||||
return <PortalSessionBoundary portal="admin">{(session) => <AdminAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
|
||||
}
|
||||
|
||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
|
||||
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
||||
const loadPendingAuditCount = useCallback(() => {
|
||||
adminApi.getDashboard()
|
||||
.then((dashboard) => {
|
||||
setPendingAudits(dashboard.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
|
||||
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
|
||||
adminApi.getPendingAudits()
|
||||
.then((counts) => {
|
||||
setPendingAudits(counts);
|
||||
})
|
||||
.catch(() => {
|
||||
setPendingAudits({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
|
||||
setPendingAudits(EMPTY_PENDING_AUDITS);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -94,7 +97,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
items: [
|
||||
{ label: '运营看板', to: '/admin', icon: Gauge },
|
||||
{ label: '发送监控', to: '/admin/monitor', icon: Activity },
|
||||
{ label: 'Gateway提交异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle },
|
||||
{ label: '网关异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle },
|
||||
{ label: '数据统计', to: '/admin/analytics', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -18,13 +18,14 @@ const deliveryModeLabels: Record<string, string> = {
|
||||
|
||||
export function formatHttpApiParams(response: HttpApiConfigResponse, origin: string) {
|
||||
const config = response.config;
|
||||
const baseUrl = `${origin.replace(/\/$/, '')}/api/openapi/v1`;
|
||||
const publicOrigin = httpApiPublicOrigin(response, origin);
|
||||
const baseUrl = `${publicOrigin}/api/openapi/v1`;
|
||||
return [
|
||||
`应用名称: ${response.applicationName ?? response.applicationId}`,
|
||||
`AppID: ${response.applicationId}`,
|
||||
`HTTP接口: ${config?.enabled ? '开通' : '关闭'}`,
|
||||
`基础地址: ${baseUrl}`,
|
||||
`接口文档: ${origin.replace(/\/$/, '')}/api/client-docs`,
|
||||
`接口文档: ${publicOrigin}/api/client-docs`,
|
||||
`接口能力: ${capabilityLabels.filter(([key]) => config?.[key]).map(([, label]) => label).join('、') || '无'}`,
|
||||
`QPS限制: ${config?.qpsLimit ?? '-'}`,
|
||||
`签名时间容差: ${config?.timestampToleranceSeconds ?? '-'}秒`,
|
||||
@@ -33,3 +34,7 @@ export function formatHttpApiParams(response: HttpApiConfigResponse, origin: str
|
||||
`上行投递方式: ${config?.uplinkDeliveryMode ? deliveryModeLabels[config.uplinkDeliveryMode] ?? config.uplinkDeliveryMode : '-'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function httpApiPublicOrigin(response: HttpApiConfigResponse | null | undefined, fallbackOrigin: string) {
|
||||
return (response?.publicOrigin ?? fallbackOrigin).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user