fix: restore daily operations quality metrics

This commit is contained in:
hectorzhao
2026-07-24 18:13:22 +08:00
parent 91f04f5288
commit e12bdf010e
10 changed files with 364 additions and 65 deletions
@@ -94,6 +94,11 @@ export class AdminOperationsController {
return this.operations.statistics({ tenantId, groupBy }); return this.operations.statistics({ tenantId, groupBy });
} }
@Get('send-quality')
sendQuality(@Query('date') date?: string) {
return this.operations.sendQuality(date);
}
@Get('audit-logs') @Get('audit-logs')
auditLogs( auditLogs(
@Query('tenantId') tenantId?: string, @Query('tenantId') tenantId?: string,
@@ -2,6 +2,7 @@ import { OperationsService } from './operations.service';
function createPrismaMock() { function createPrismaMock() {
return { return {
$queryRaw: jest.fn().mockResolvedValue([]),
user: { user: {
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }), 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 () => { it('returns trace details and reconciliation diffs', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
+157 -1
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { moneyToNumber } from '../common/money'; 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 }) { async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
const page = positiveInteger(query.page, 1); const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20)); 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`); 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) { function normalizeGroupBy(groupBy?: string) {
if (groupBy === 'tenant' || groupBy === 'tenantId') { if (groupBy === 'tenant' || groupBy === 'tenantId') {
return 'tenantId'; return 'tenantId';
@@ -1374,6 +1374,9 @@
- 可按企业、应用、通道、任务、手机号追踪发送链路。 - 可按企业、应用、通道、任务、手机号追踪发送链路。
- 可按账务流水和短信记录对账。 - 可按账务流水和短信记录对账。
- 通道列表“今日发送质量”必须按北京时间当天的真实通道提交和回执,展示总数、成功、未知、失败及对应比例,不得使用前端固定零值。
- 运营看板不重复展示通道连接或通道运行数据;按设计基线恢复当天签名发送统计,并按是否关联引流信息分组展示签名、企业、发送总数、成功、未知、失败、成功率和平均到达时长。
- 数据统计的“通道占比”默认统计北京时间当天,可选择单个历史日期重新查询;图表使用真实通道名称和该日期的通道提交量。
- 输出 500 条/秒压测报告。 - 输出 500 条/秒压测报告。
- 输出 Linux 部署方案,至少覆盖 Docker Compose 或 systemd 部署、环境变量、数据库迁移、日志目录、备份恢复、服务健康检查和回滚步骤。 - 输出 Linux 部署方案,至少覆盖 Docker Compose 或 systemd 部署、环境变量、数据库迁移、日志目录、备份恢复、服务健康检查和回滚步骤。
+14 -1
View File
@@ -532,10 +532,23 @@
2. 打开发送监控。 2. 打开发送监控。
3. 按租户和通道过滤。 3. 按租户和通道过滤。
- 预期结果: - 预期结果:
- 看板展示任务数、发送状态分布、上行数、账务聚合。 - 看板展示任务数、发送状态分布、上行数、账务聚合,不重复展示通道连接或通道运行数据
- 看板按当天真实短信记录展示“不含引流/含引流”两组签名统计;每行包含签名、企业、发送总数、成功、未知、失败、成功率和平均到达时长。
- 监控展示最近发送、最近回执、最近上行。 - 监控展示最近发送、最近回执、最近上行。
- 过滤条件生效。 - 过滤条件生效。
### TC-ADMIN-011A 通道今日质量与日期通道占比
- 优先级:P1
- 前置条件:北京时间当天至少两个通道存在 accepted Submit,其中包含成功、失败和未回执记录;另一个历史日期有不同通道分布。
- 步骤:
1. 打开运营端通道列表。
2. 对照 `SmsSubmitRecord``SmsReceiptRecord` 核验“今日总数/今日发送质量”。
3. 打开数据统计,确认默认日期后切换到准备好的历史日期并查询。
- 预期结果:
- 通道列表按真实提交通道展示当天总数、成功、未知、失败及比例,不再全部为前端固定 0。
- 通道占比默认日期为北京时间当天;切换日期后重新请求该日期数据,图例展示真实通道名称且占比随数据变化。
### TC-ADMIN-012 发送链路 Trace ### TC-ADMIN-012 发送链路 Trace
- 优先级:P0 - 优先级:P0
+11
View File
@@ -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、失败和重试业务状态,两者不合并。 - 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开通状态回填历史配置的派生模式,避免旧人工模式继续影响展示或参数复制。 - 新增migration`20260724143000_derive_application_delivery_modes`,按当前CMPP/HTTP开通状态回填历史配置的派生模式,避免旧人工模式继续影响展示或参数复制。
- 已完成定向回归:OpenAPI、短信配置和Gateway事件3 suites/67项通过;SendChain新增长短信非首片失败、仅HTTP投递和CMPP关闭3项通过;Gateway inbound全量通过并覆盖企业侧DELIVER/DELIVER_RESP日志。另一个会话的运营端账户充值回执源码和文档已一并纳入本发布分支,原工作区未覆盖。 - 已完成定向回归: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仅用于证明真实数据和根因。
- 本轮按要求不提交、不推送、不部署;工作区中另一会话的充值回执改动继续原样保留。
+35
View File
@@ -350,6 +350,40 @@ export type DashboardResponse = {
recentRecharges: Array<RechargeOrder>; 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 = { export type RechargeOrder = {
id: string; id: string;
tenantId: string; tenantId: string;
@@ -1395,6 +1429,7 @@ export const adminApi = {
changeUserPassword: (id: string, password: string, operatorId?: string) => changeUserPassword: (id: string, password: string, operatorId?: string) =>
request<ManagedUser>(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }), request<ManagedUser>(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }),
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })), 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 }) => listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
request<OperationLogResponse>(withQuery('/admin/system-logs', query)), 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 }) => listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) =>
+29 -8
View File
@@ -1,25 +1,26 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { BarChart3 } from 'lucide-react'; import { BarChart3 } from 'lucide-react';
import { adminApi, type DashboardResponse } from '@/api/adminApi'; 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'; import { createBarOption, createPieOption } from '@/theme/chartOptions';
export function AdminAnalyticsPage() { export function AdminAnalyticsPage() {
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null); const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [tenantStats, setTenantStats] = useState<Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | 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(''); const [error, setError] = useState('');
function loadData() { function loadData() {
Promise.all([ Promise.all([
adminApi.getDashboard(), adminApi.getDashboard(),
adminApi.listStatistics({ groupBy: 'tenantId' }), adminApi.listStatistics({ groupBy: 'tenantId' }),
adminApi.listStatistics({ groupBy: 'channelId' }), adminApi.getSendQuality(statisticsDate),
]) ])
.then(([dashboardData, tenantData, channelData]) => { .then(([dashboardData, tenantData, qualityData]) => {
setDashboard(dashboardData); setDashboard(dashboardData);
setTenantStats((Array.isArray(tenantData) ? tenantData : []) as Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>); 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(''); setError('');
}) })
.catch((failure: Error) => setError(failure.message || '统计数据加载失败')); .catch((failure: Error) => setError(failure.message || '统计数据加载失败'));
@@ -35,7 +36,7 @@ export function AdminAnalyticsPage() {
}), [tenantStats]); }), [tenantStats]);
const channelOption = useMemo(() => createPieOption({ 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]); }), [channelStats]);
return ( return (
@@ -44,7 +45,16 @@ export function AdminAnalyticsPage() {
<div> <div>
<Breadcrumb items={['数据统计']} /> <Breadcrumb items={['数据统计']} />
</div> </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> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
@@ -81,7 +91,7 @@ export function AdminAnalyticsPage() {
<div className="section-heading"> <div className="section-heading">
<div> <div>
<h2></h2> <h2></h2>
<p className="muted"></p> <p className="muted">{statisticsDate} </p>
</div> </div>
<Tag tone="accent"></Tag> <Tag tone="accent"></Tag>
</div> </div>
@@ -91,3 +101,14 @@ export function AdminAnalyticsPage() {
</section> </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')}`;
}
+17 -12
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; 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 { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
@@ -141,7 +141,11 @@ function resolveChannelStatus(channel: AdminChannel, connections: CmppConnection
return 'connecting'; return 'connecting';
} }
function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[] = channel.connectionStates ?? []): SmsChannel { function mapApiChannel(
channel: AdminChannel,
connections: CmppConnectionState[] = channel.connectionStates ?? [],
quality?: ChannelQualityStat,
): SmsChannel {
return { return {
id: channel.id, id: channel.id,
name: channel.name, name: channel.name,
@@ -149,13 +153,13 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
sendRegion: channel.sendRegion ?? '全国', sendRegion: channel.sendRegion ?? '全国',
unitPrice: channel.unitPrice, unitPrice: channel.unitPrice,
status: resolveChannelStatus(channel, connections), status: resolveChannelStatus(channel, connections),
total: 0, total: quality?.total ?? 0,
successRate: 0, successRate: quality?.successRate ?? 0,
successCount: 0, successCount: quality?.successCount ?? 0,
unknownRate: 0, unknownRate: quality?.unknownRate ?? 0,
unknownCount: 0, unknownCount: quality?.unknownCount ?? 0,
failureRate: 0, failureRate: quality?.failureRate ?? 0,
failureCount: 0, failureCount: quality?.failureCount ?? 0,
gatewayHost: channel.gatewayHost, gatewayHost: channel.gatewayHost,
gatewayPort: String(channel.gatewayPort), gatewayPort: String(channel.gatewayPort),
businessCode: String(channel.config?.serviceId ?? 'SMS'), businessCode: String(channel.config?.serviceId ?? 'SMS'),
@@ -486,13 +490,14 @@ export function AdminChannelsPage() {
const pageSize = 10; const pageSize = 10;
function loadChannels() { function loadChannels() {
adminApi.listChannels() Promise.all([adminApi.listChannels(), adminApi.getSendQuality()])
.then(async (items) => { .then(async ([items, quality]) => {
const visibleChannels = items.filter((item) => item.status !== 'deleted'); const visibleChannels = items.filter((item) => item.status !== 'deleted');
const connections = await Promise.all(visibleChannels.map((channel) => const connections = await Promise.all(visibleChannels.map((channel) =>
adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]), 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(''); setError('');
}) })
.catch((failure: Error) => setError(failure.message || '通道列表加载失败')); .catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
+47 -42
View File
@@ -3,9 +3,7 @@ import {
BarChart3, BarChart3,
DollarSign, DollarSign,
FileCheck2, FileCheck2,
RadioTower,
ShieldCheck, ShieldCheck,
Users,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
@@ -17,7 +15,7 @@ import {
Tag, Tag,
type TableColumn, type TableColumn,
} from '@/components/ui'; } 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 { createBarOption, createLineOption } from '@/theme/chartOptions';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency'; import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
@@ -46,15 +44,15 @@ function formatCount(value: number) {
export function AdminHome() { export function AdminHome() {
const navigate = useNavigate(); const navigate = useNavigate();
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null); const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null); const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
const [channels, setChannels] = useState<Array<{ id: string; name: string; status: string; rateLimitPerSecond: number }>>([]);
useEffect(() => { useEffect(() => {
Promise.all([adminApi.getDashboard(), adminApi.listChannels()]) Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
.then(([nextDashboard, nextChannels]) => { .then(([nextDashboard, nextQuality]) => {
setDashboard(nextDashboard); setDashboard(nextDashboard);
setChannels(nextChannels); setQuality(nextQuality);
}) })
.catch((err) => { .catch((err) => {
setError(err instanceof Error ? err.message : '运营看板加载失败'); setError(err instanceof Error ? err.message : '运营看板加载失败');
@@ -81,7 +79,7 @@ export function AdminHome() {
const totalSend = dashboard?.today.sent ?? 0; const totalSend = dashboard?.today.sent ?? 0;
const averageSuccessRate = dashboard?.today.successRate ?? 0; const averageSuccessRate = dashboard?.today.successRate ?? 0;
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents); 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 downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 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]>> = [ const signatureColumns: Array<TableColumn<SignatureQualityStat>> = [
{ key: 'name', title: '通道名称', render: (record) => <strong>{record.name}</strong> }, { key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status}</Tag> }, { key: 'signatureName', title: '签名', render: (record) => <div><strong>{record.signatureName}</strong><p className="text-caption">{record.tenantName}</p></div> },
{ key: 'rateLimitPerSecond', title: '限速', align: 'right', render: (record) => `${record.rateLimitPerSecond}/s` }, { 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 ( return (
<section className="page-stack admin-dashboard"> <section className="page-stack admin-dashboard">
<div className="overview-hero admin-dashboard-hero"> <div className="overview-hero admin-dashboard-hero">
<div> <div>
<Breadcrumb items={['数据概览']} /> <Breadcrumb items={['数据概览']} />
<p className="muted"></p> <p className="muted"></p>
</div> </div>
<div className="page-actions"> <div className="page-actions">
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost"> <Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
</Button> </Button>
<Button icon={<RadioTower size={16} />} onClick={() => navigate('/admin/channels')}> <Button onClick={() => navigate('/admin/monitor')}>
</Button> </Button>
</div> </div>
</div> </div>
@@ -173,9 +178,9 @@ export function AdminHome() {
<small></small> <small></small>
</div> </div>
<div className="surface metric-card"> <div className="surface metric-card">
<span>线</span> <span></span>
<strong>{activeConnectionCount}</strong> <strong>{activeSignatureCount}</strong>
<small>Gateway </small> <small></small>
</div> </div>
</div> </div>
{error ? <div className="surface ui-table__empty">{error}</div> : null} {error ? <div className="surface ui-table__empty">{error}</div> : null}
@@ -193,6 +198,29 @@ export function AdminHome() {
</div> </div>
</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="surface section-stack">
<div className="section-heading"> <div className="section-heading">
<div> <div>
@@ -206,20 +234,6 @@ export function AdminHome() {
<Table columns={enterpriseColumns} data={enterpriseSpendRanks} rowKey="id" /> <Table columns={enterpriseColumns} data={enterpriseSpendRanks} rowKey="id" />
</div> </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 className="section-heading">
<div> <div>
@@ -263,15 +277,7 @@ export function AdminHome() {
</div> </div>
</div> </div>
<div className="mini-status-card"> <div className="mini-status-card">
<Users size={22} /> <ShieldCheck size={22} />
<div>
<span></span>
<strong>{activeConnectionCount}</strong>
<small>线</small>
</div>
</div>
<div className="mini-status-card">
<RadioTower size={22} />
<div> <div>
<span></span> <span></span>
<strong>{downstreamAlertCount} </strong> <strong>{downstreamAlertCount} </strong>
@@ -280,7 +286,6 @@ export function AdminHome() {
</div> </div>
</div> </div>
</div> </div>
</div>
<Modal <Modal
footer={( footer={(