release: prepare RealeseV2.3

This commit is contained in:
hectorzhao
2026-08-06 10:48:36 +08:00
parent 57b58f1c40
commit 8ad8e61793
37 changed files with 997 additions and 64 deletions
+4 -1
View File
@@ -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)),
+1 -1
View File
@@ -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[];
};
+11 -2
View File
@@ -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 };
+32
View File
@@ -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>
);
}
+13 -1
View File
@@ -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>
+6 -1
View File
@@ -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
View File
@@ -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>
);
}
+4 -3
View File
@@ -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-IdX-Event-TypeX-TimestampX-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>
+9 -6
View File
@@ -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 },
],
},
+7 -2
View File
@@ -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(/\/$/, '');
}