fix: restore daily operations quality metrics
This commit is contained in:
@@ -94,6 +94,11 @@ export class AdminOperationsController {
|
||||
return this.operations.statistics({ tenantId, groupBy });
|
||||
}
|
||||
|
||||
@Get('send-quality')
|
||||
sendQuality(@Query('date') date?: string) {
|
||||
return this.operations.sendQuality(date);
|
||||
}
|
||||
|
||||
@Get('audit-logs')
|
||||
auditLogs(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { OperationsService } from './operations.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
user: {
|
||||
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }),
|
||||
},
|
||||
@@ -391,6 +392,50 @@ describe('OperationsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns real daily channel and signature quality for the selected Shanghai date', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.$queryRaw
|
||||
.mockResolvedValueOnce([{
|
||||
channelId: 'channel-1',
|
||||
channelName: '通道一',
|
||||
total: 5,
|
||||
successCount: 3,
|
||||
unknownCount: 1,
|
||||
failureCount: 1,
|
||||
successRate: 60,
|
||||
unknownRate: 20,
|
||||
failureRate: 20,
|
||||
averageArrivalMs: 1200,
|
||||
}])
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'signature-1:plain',
|
||||
signatureId: 'signature-1',
|
||||
signatureName: '【测试签名】',
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '租户A',
|
||||
hasDrainage: false,
|
||||
total: 5,
|
||||
successCount: 3,
|
||||
unknownCount: 1,
|
||||
failureCount: 1,
|
||||
successRate: 60,
|
||||
averageArrivalMs: 1200,
|
||||
}]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
|
||||
date: '2026-07-24',
|
||||
channels: [expect.objectContaining({ channelId: 'channel-1', total: 5, successRate: 60 })],
|
||||
signatures: [expect.objectContaining({ signatureId: 'signature-1', signatureName: '【测试签名】', hasDrainage: false })],
|
||||
});
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects invalid send quality dates', async () => {
|
||||
const service = new OperationsService(createPrismaMock() as never);
|
||||
await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效');
|
||||
});
|
||||
|
||||
it('returns trace details and reconciliation diffs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
@@ -354,6 +354,135 @@ export class OperationsService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendQuality(date?: string) {
|
||||
const day = qualityBusinessDay(date);
|
||||
const [channels, signatures] = await Promise.all([
|
||||
this.prisma.$queryRaw<Array<{
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
unknownRate: number;
|
||||
failureRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
submit."channelId" AS channel_id,
|
||||
channel.name AS channel_name,
|
||||
receipt."deliveredAt" AS delivered_at,
|
||||
failed_receipt."failedAt" AS failed_at,
|
||||
CASE
|
||||
WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
||||
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) receipt ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MIN(receipt."deliveredAt") AS "failedAt"
|
||||
FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'undelivered'
|
||||
) failed_receipt ON TRUE
|
||||
WHERE submit."submitStatus" = 'accepted'
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
)
|
||||
SELECT
|
||||
channel_id AS "channelId",
|
||||
MAX(channel_name) AS "channelName",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE delivered_at IS NOT NULL)::integer AS "successCount",
|
||||
COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NOT NULL)::integer AS "failureCount",
|
||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NOT NULL) * 100.0 / COUNT(*), 1)::double precision END AS "successRate",
|
||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL) * 100.0 / COUNT(*), 1)::double precision END AS "unknownRate",
|
||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NOT NULL) * 100.0 / COUNT(*), 1)::double precision END AS "failureRate",
|
||||
ROUND(AVG(arrival_ms) FILTER (WHERE arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM base
|
||||
GROUP BY channel_id
|
||||
ORDER BY COUNT(*) DESC, channel_id
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
id: string;
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
hasDrainage: boolean;
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs: number | null;
|
||||
}>>(Prisma.sql`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
message."signatureId" AS signature_id,
|
||||
(message."drainageInfoId" IS NOT NULL) AS has_drainage,
|
||||
message.status,
|
||||
message."receiptStatus" AS receipt_status,
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
AND message."submittedAt" IS NOT NULL
|
||||
AND message."deliveredAt" >= message."submittedAt"
|
||||
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
|
||||
END AS arrival_ms
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
)
|
||||
SELECT
|
||||
signature.id || ':' || CASE WHEN base.has_drainage THEN 'drainage' ELSE 'plain' END AS id,
|
||||
signature.id AS "signatureId",
|
||||
signature.name AS "signatureName",
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
base.has_drainage AS "hasDrainage",
|
||||
COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected')::integer AS total,
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'rejected'
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected') = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0
|
||||
/ COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected'),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||
FROM base
|
||||
JOIN "SmsSignature" signature ON signature.id = base.signature_id
|
||||
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
|
||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
|
||||
ORDER BY "successCount" DESC, total DESC, signature.name
|
||||
`),
|
||||
]);
|
||||
return { date: day.key, channels, signatures };
|
||||
}
|
||||
|
||||
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
|
||||
@@ -955,6 +1084,33 @@ function endOfShanghaiDay(value: string) {
|
||||
return new Date(`${value}T23:59:59.999+08:00`);
|
||||
}
|
||||
|
||||
function qualityBusinessDay(value?: string) {
|
||||
const key = value || shanghaiDateKey();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) {
|
||||
throw new BadRequestException('统计日期格式必须为 YYYY-MM-DD');
|
||||
}
|
||||
const startAt = startOfShanghaiDay(key);
|
||||
if (Number.isNaN(startAt.getTime()) || shanghaiDateKey(startAt) !== key) {
|
||||
throw new BadRequestException('统计日期无效');
|
||||
}
|
||||
return {
|
||||
key,
|
||||
startAt,
|
||||
endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function shanghaiDateKey(value = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(value);
|
||||
const byType = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
|
||||
}
|
||||
|
||||
function normalizeGroupBy(groupBy?: string) {
|
||||
if (groupBy === 'tenant' || groupBy === 'tenantId') {
|
||||
return 'tenantId';
|
||||
|
||||
@@ -1374,6 +1374,9 @@
|
||||
|
||||
- 可按企业、应用、通道、任务、手机号追踪发送链路。
|
||||
- 可按账务流水和短信记录对账。
|
||||
- 通道列表“今日发送质量”必须按北京时间当天的真实通道提交和回执,展示总数、成功、未知、失败及对应比例,不得使用前端固定零值。
|
||||
- 运营看板不重复展示通道连接或通道运行数据;按设计基线恢复当天签名发送统计,并按是否关联引流信息分组展示签名、企业、发送总数、成功、未知、失败、成功率和平均到达时长。
|
||||
- 数据统计的“通道占比”默认统计北京时间当天,可选择单个历史日期重新查询;图表使用真实通道名称和该日期的通道提交量。
|
||||
- 输出 500 条/秒压测报告。
|
||||
- 输出 Linux 部署方案,至少覆盖 Docker Compose 或 systemd 部署、环境变量、数据库迁移、日志目录、备份恢复、服务健康检查和回滚步骤。
|
||||
|
||||
|
||||
@@ -532,10 +532,23 @@
|
||||
2. 打开发送监控。
|
||||
3. 按租户和通道过滤。
|
||||
- 预期结果:
|
||||
- 看板展示任务数、发送状态分布、上行数、账务聚合。
|
||||
- 看板展示任务数、发送状态分布、上行数、账务聚合,不重复展示通道连接或通道运行数据。
|
||||
- 看板按当天真实短信记录展示“不含引流/含引流”两组签名统计;每行包含签名、企业、发送总数、成功、未知、失败、成功率和平均到达时长。
|
||||
- 监控展示最近发送、最近回执、最近上行。
|
||||
- 过滤条件生效。
|
||||
|
||||
### TC-ADMIN-011A 通道今日质量与日期通道占比
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:北京时间当天至少两个通道存在 accepted Submit,其中包含成功、失败和未回执记录;另一个历史日期有不同通道分布。
|
||||
- 步骤:
|
||||
1. 打开运营端通道列表。
|
||||
2. 对照 `SmsSubmitRecord` 和 `SmsReceiptRecord` 核验“今日总数/今日发送质量”。
|
||||
3. 打开数据统计,确认默认日期后切换到准备好的历史日期并查询。
|
||||
- 预期结果:
|
||||
- 通道列表按真实提交通道展示当天总数、成功、未知、失败及比例,不再全部为前端固定 0。
|
||||
- 通道占比默认日期为北京时间当天;切换日期后重新请求该日期数据,图例展示真实通道名称且占比随数据变化。
|
||||
|
||||
### TC-ADMIN-012 发送链路 Trace
|
||||
|
||||
- 优先级:P0
|
||||
|
||||
@@ -2391,3 +2391,14 @@ git diff --check
|
||||
- Gateway补充企业侧真实`CMPP_DELIVER`发送成功/失败以及`CMPP_DELIVER_RESP`接收结果通讯日志,API白名单允许`platform_to_client + deliver_receipt/deliver_uplink`和`client_to_platform + deliver_resp`。通讯日志只描述真实协议报文;`CmppDownstreamDelivery`继续保存排队、发送、ACK、失败和重试业务状态,两者不合并。
|
||||
- 新增migration`20260724143000_derive_application_delivery_modes`,按当前CMPP/HTTP开通状态回填历史配置的派生模式,避免旧人工模式继续影响展示或参数复制。
|
||||
- 已完成定向回归:OpenAPI、短信配置和Gateway事件3 suites/67项通过;SendChain新增长短信非首片失败、仅HTTP投递和CMPP关闭3项通过;Gateway inbound全量通过并覆盖企业侧DELIVER/DELIVER_RESP日志。另一个会话的运营端账户充值回执源码和文档已一并纳入本发布分支,原工作区未覆盖。
|
||||
|
||||
## 2026-07-24 通道今日质量、看板签名统计与日期通道占比(本地未提交、未部署)
|
||||
|
||||
- 根因确认:运营端通道列表的`mapApiChannel`把今日总数、成功/未知/失败数量和比例全部固定为0,页面从未请求后端质量数据;预发布只读SQL确认当天实际已有3个通道共10条accepted提交,并非数据库无数据。
|
||||
- 新增只读`GET /api/admin/operations/send-quality?date=YYYY-MM-DD`,按北京时间单日从真实`SmsSubmitRecord/SmsReceiptRecord`聚合通道提交质量,并从真实`SmsMessageRecord/SmsSignature/Tenant`聚合签名发送质量;日期无效返回受控400。
|
||||
- 通道列表接入当天真实质量;运营看板移除通道运行表格、在线连接指标和平台连接健康度,恢复设计基线`2f3c274a`中的“不含引流/含引流”双签名统计结构,展示发送总数、成功、未知、失败、成功率和平均到达时长。
|
||||
- 数据统计的通道占比默认北京时间当天,支持选择单个历史日期查询,图例使用真实通道名称而非内部ID。
|
||||
- 预发布只读核验当天事实:富泷物业-移动5条、赛邮行业-王斯评中转4条、富泷物业-联通1条;另有4个签名存在当天发送记录,证明原页面全0为前端硬编码缺陷。
|
||||
- Node.js v24.14.0下API Operations定向1 suite / 21项通过;整合远端最新回执链路后,API全量26 suites / 319项、API TypeScript build、前端TypeScript/Vite build、Gateway `go test ./...`和`go vet ./...`、Prisma generate/validate及`git diff --check`通过;前端保留既有约1.93MB单chunk/580.26KB gzip警告,Jest仍用`--forceExit`结束既有异步句柄。
|
||||
- 本机PostgreSQL未监听,真实服务类本地集成因`ECONNREFUSED`未通过;应用内浏览器中的预发布管理员会话已安全锁定,未输入密码或绕过认证,因此未将修复后登录页面交互记为通过。新接口尚未部署,预发布只读SQL仅用于证明真实数据和根因。
|
||||
- 本轮按要求不提交、不推送、不部署;工作区中另一会话的充值回执改动继续原样保留。
|
||||
|
||||
@@ -350,6 +350,40 @@ export type DashboardResponse = {
|
||||
recentRecharges: Array<RechargeOrder>;
|
||||
};
|
||||
|
||||
export type ChannelQualityStat = {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
unknownRate: number;
|
||||
failureRate: number;
|
||||
averageArrivalMs?: number | null;
|
||||
};
|
||||
|
||||
export type SignatureQualityStat = {
|
||||
id: string;
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
hasDrainage: boolean;
|
||||
total: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
averageArrivalMs?: number | null;
|
||||
};
|
||||
|
||||
export type SendQualityResponse = {
|
||||
date: string;
|
||||
channels: ChannelQualityStat[];
|
||||
signatures: SignatureQualityStat[];
|
||||
};
|
||||
|
||||
export type RechargeOrder = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -1395,6 +1429,7 @@ export const adminApi = {
|
||||
changeUserPassword: (id: string, password: string, operatorId?: string) =>
|
||||
request<ManagedUser>(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }),
|
||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||
getSendQuality: (date?: string) => request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||
listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { adminApi, type DashboardResponse } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Chart, Tag } from '@/components/ui';
|
||||
import { Breadcrumb, Button, Chart, Input, Tag } from '@/components/ui';
|
||||
import { createBarOption, createPieOption } from '@/theme/chartOptions';
|
||||
|
||||
export function AdminAnalyticsPage() {
|
||||
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
|
||||
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
||||
const [tenantStats, setTenantStats] = useState<Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>>([]);
|
||||
const [channelStats, setChannelStats] = useState<Array<{ channelId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>>([]);
|
||||
const [channelStats, setChannelStats] = useState<Array<{ channelId: string; channelName: string; total: number }>>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
Promise.all([
|
||||
adminApi.getDashboard(),
|
||||
adminApi.listStatistics({ groupBy: 'tenantId' }),
|
||||
adminApi.listStatistics({ groupBy: 'channelId' }),
|
||||
adminApi.getSendQuality(statisticsDate),
|
||||
])
|
||||
.then(([dashboardData, tenantData, channelData]) => {
|
||||
.then(([dashboardData, tenantData, qualityData]) => {
|
||||
setDashboard(dashboardData);
|
||||
setTenantStats((Array.isArray(tenantData) ? tenantData : []) as Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>);
|
||||
setChannelStats((Array.isArray(channelData) ? channelData : []) as Array<{ channelId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>);
|
||||
setChannelStats(qualityData.channels.map((item) => ({ channelId: item.channelId, channelName: item.channelName, total: item.total })));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '统计数据加载失败'));
|
||||
@@ -35,7 +36,7 @@ export function AdminAnalyticsPage() {
|
||||
}), [tenantStats]);
|
||||
|
||||
const channelOption = useMemo(() => createPieOption({
|
||||
data: channelStats.map((item) => ({ name: item.channelId ?? '未分配通道', value: item._count._all })),
|
||||
data: channelStats.map((item) => ({ name: item.channelName || item.channelId, value: item.total })),
|
||||
}), [channelStats]);
|
||||
|
||||
return (
|
||||
@@ -44,7 +45,16 @@ export function AdminAnalyticsPage() {
|
||||
<div>
|
||||
<Breadcrumb items={['数据统计']} />
|
||||
</div>
|
||||
<Button icon={<BarChart3 size={16} />} onClick={loadData} variant="ghost">刷新统计</Button>
|
||||
<div className="page-actions">
|
||||
<Input
|
||||
aria-label="通道占比统计日期"
|
||||
max={shanghaiDateKey()}
|
||||
onChange={(event) => setStatisticsDate(event.target.value)}
|
||||
type="date"
|
||||
value={statisticsDate}
|
||||
/>
|
||||
<Button icon={<BarChart3 size={16} />} onClick={loadData} variant="ghost">查询统计</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
@@ -81,7 +91,7 @@ export function AdminAnalyticsPage() {
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>通道占比</h2>
|
||||
<p className="muted">按通道消息记录聚合。</p>
|
||||
<p className="muted">{statisticsDate} 当天按真实通道提交及回执聚合。</p>
|
||||
</div>
|
||||
<Tag tone="accent">通道</Tag>
|
||||
</div>
|
||||
@@ -91,3 +101,14 @@ export function AdminAnalyticsPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function shanghaiDateKey(value = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(value);
|
||||
const byType = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelQualityStat, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
@@ -141,7 +141,11 @@ function resolveChannelStatus(channel: AdminChannel, connections: CmppConnection
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[] = channel.connectionStates ?? []): SmsChannel {
|
||||
function mapApiChannel(
|
||||
channel: AdminChannel,
|
||||
connections: CmppConnectionState[] = channel.connectionStates ?? [],
|
||||
quality?: ChannelQualityStat,
|
||||
): SmsChannel {
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
@@ -149,13 +153,13 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
|
||||
sendRegion: channel.sendRegion ?? '全国',
|
||||
unitPrice: channel.unitPrice,
|
||||
status: resolveChannelStatus(channel, connections),
|
||||
total: 0,
|
||||
successRate: 0,
|
||||
successCount: 0,
|
||||
unknownRate: 0,
|
||||
unknownCount: 0,
|
||||
failureRate: 0,
|
||||
failureCount: 0,
|
||||
total: quality?.total ?? 0,
|
||||
successRate: quality?.successRate ?? 0,
|
||||
successCount: quality?.successCount ?? 0,
|
||||
unknownRate: quality?.unknownRate ?? 0,
|
||||
unknownCount: quality?.unknownCount ?? 0,
|
||||
failureRate: quality?.failureRate ?? 0,
|
||||
failureCount: quality?.failureCount ?? 0,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: String(channel.gatewayPort),
|
||||
businessCode: String(channel.config?.serviceId ?? 'SMS'),
|
||||
@@ -486,13 +490,14 @@ export function AdminChannelsPage() {
|
||||
const pageSize = 10;
|
||||
|
||||
function loadChannels() {
|
||||
adminApi.listChannels()
|
||||
.then(async (items) => {
|
||||
Promise.all([adminApi.listChannels(), adminApi.getSendQuality()])
|
||||
.then(async ([items, quality]) => {
|
||||
const visibleChannels = items.filter((item) => item.status !== 'deleted');
|
||||
const connections = await Promise.all(visibleChannels.map((channel) =>
|
||||
adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]),
|
||||
));
|
||||
setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index])));
|
||||
const qualityByChannel = new Map(quality.channels.map((item) => [item.channelId, item]));
|
||||
setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index], qualityByChannel.get(item.id))));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
BarChart3,
|
||||
DollarSign,
|
||||
FileCheck2,
|
||||
RadioTower,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -17,7 +15,7 @@ import {
|
||||
Tag,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { adminApi, type DashboardResponse } from '@/api/adminApi';
|
||||
import { adminApi, type DashboardResponse, type SendQualityResponse, type SignatureQualityStat } from '@/api/adminApi';
|
||||
import { createBarOption, createLineOption } from '@/theme/chartOptions';
|
||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||
|
||||
@@ -46,15 +44,15 @@ function formatCount(value: number) {
|
||||
export function AdminHome() {
|
||||
const navigate = useNavigate();
|
||||
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
||||
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
|
||||
const [channels, setChannels] = useState<Array<{ id: string; name: string; status: string; rateLimitPerSecond: number }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.getDashboard(), adminApi.listChannels()])
|
||||
.then(([nextDashboard, nextChannels]) => {
|
||||
Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
|
||||
.then(([nextDashboard, nextQuality]) => {
|
||||
setDashboard(nextDashboard);
|
||||
setChannels(nextChannels);
|
||||
setQuality(nextQuality);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : '运营看板加载失败');
|
||||
@@ -81,7 +79,7 @@ export function AdminHome() {
|
||||
const totalSend = dashboard?.today.sent ?? 0;
|
||||
const averageSuccessRate = dashboard?.today.successRate ?? 0;
|
||||
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
|
||||
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
|
||||
const activeSignatureCount = new Set(quality?.signatures.map((item) => item.signatureId) ?? []).size;
|
||||
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
||||
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
|
||||
|
||||
@@ -133,25 +131,32 @@ export function AdminHome() {
|
||||
},
|
||||
];
|
||||
|
||||
const channelColumns: Array<TableColumn<(typeof channels)[number]>> = [
|
||||
{ key: 'name', title: '通道名称', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status}</Tag> },
|
||||
{ key: 'rateLimitPerSecond', title: '限速', align: 'right', render: (record) => `${record.rateLimitPerSecond}/s` },
|
||||
const signatureColumns: Array<TableColumn<SignatureQualityStat>> = [
|
||||
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
|
||||
{ key: 'signatureName', title: '签名', render: (record) => <div><strong>{record.signatureName}</strong><p className="text-caption">{record.tenantName}</p></div> },
|
||||
{ key: 'total', title: '发送总数', align: 'right', render: (record) => formatCount(record.total) },
|
||||
{ key: 'successCount', title: '成功', align: 'right', render: (record) => formatCount(record.successCount) },
|
||||
{ key: 'unknownCount', title: '未知', align: 'right', render: (record) => formatCount(record.unknownCount) },
|
||||
{ key: 'failureCount', title: '失败', align: 'right', render: (record) => formatCount(record.failureCount) },
|
||||
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate.toFixed(1)}%` },
|
||||
{ key: 'averageArrivalMs', title: '平均到达', align: 'right', render: (record) => record.averageArrivalMs === null || record.averageArrivalMs === undefined ? '-' : `${(record.averageArrivalMs / 1000).toFixed(1)}秒` },
|
||||
];
|
||||
const plainSignatureQuality = quality?.signatures.filter((item) => !item.hasDrainage) ?? [];
|
||||
const drainageSignatureQuality = quality?.signatures.filter((item) => item.hasDrainage) ?? [];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-dashboard">
|
||||
<div className="overview-hero admin-dashboard-hero">
|
||||
<div>
|
||||
<Breadcrumb items={['数据概览']} />
|
||||
<p className="muted">按业务口径查看平台发送、签名、消费、审核和通道运行情况。</p>
|
||||
<p className="muted">按业务口径查看平台发送、签名、消费和审核情况。</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
|
||||
处理审核
|
||||
</Button>
|
||||
<Button icon={<RadioTower size={16} />} onClick={() => navigate('/admin/channels')}>
|
||||
查看通道
|
||||
<Button onClick={() => navigate('/admin/monitor')}>
|
||||
查看发送监控
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,9 +178,9 @@ export function AdminHome() {
|
||||
<small>来自今日消息金额聚合</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>通道在线连接</span>
|
||||
<strong>{activeConnectionCount}</strong>
|
||||
<small>Gateway 连接状态回写</small>
|
||||
<span>今日活跃签名</span>
|
||||
<strong>{activeSignatureCount}</strong>
|
||||
<small>当天有真实发送记录的签名</small>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
||||
@@ -193,6 +198,29 @@ export function AdminHome() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-grid admin-signature-rank-grid">
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>今日签名发送统计 - 不含引流</h2>
|
||||
<p className="muted">按签名汇总当天真实发送、回执和平均到达时长。</p>
|
||||
</div>
|
||||
<Tag tone="info">{plainSignatureQuality.length} 个签名</Tag>
|
||||
</div>
|
||||
<Table columns={signatureColumns} data={plainSignatureQuality} emptyText="今日暂无不含引流的签名发送记录" rowKey="id" />
|
||||
</div>
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>今日签名发送统计 - 含引流</h2>
|
||||
<p className="muted">独立展示关联引流信息的签名发送效果。</p>
|
||||
</div>
|
||||
<Tag tone="accent">{drainageSignatureQuality.length} 个签名</Tag>
|
||||
</div>
|
||||
<Table columns={signatureColumns} data={drainageSignatureQuality} emptyText="今日暂无含引流的签名发送记录" rowKey="id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
@@ -206,21 +234,7 @@ export function AdminHome() {
|
||||
<Table columns={enterpriseColumns} data={enterpriseSpendRanks} rowKey="id" />
|
||||
</div>
|
||||
|
||||
<div className="overview-grid">
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>通道运行</h2>
|
||||
<p className="muted">核心通道成功率和延迟。</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/admin/channels')} size="sm" variant="ghost">
|
||||
管理通道
|
||||
</Button>
|
||||
</div>
|
||||
<Table columns={channelColumns} data={channels} rowKey="id" />
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>运营状态</h2>
|
||||
@@ -263,15 +277,7 @@ export function AdminHome() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mini-status-card">
|
||||
<Users size={22} />
|
||||
<div>
|
||||
<span>平台健康度</span>
|
||||
<strong>{activeConnectionCount}</strong>
|
||||
<small>在线连接数。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mini-status-card">
|
||||
<RadioTower size={22} />
|
||||
<ShieldCheck size={22} />
|
||||
<div>
|
||||
<span>下游投递告警</span>
|
||||
<strong>{downstreamAlertCount} 条</strong>
|
||||
@@ -279,7 +285,6 @@ export function AdminHome() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
Reference in New Issue
Block a user