feat: add signature quality period trends
This commit is contained in:
@@ -677,6 +677,7 @@ describe('OperationsService', () => {
|
||||
averageArrivalMs: 1200,
|
||||
rowCount: 12,
|
||||
}])
|
||||
.mockResolvedValueOnce([{ count: 4 }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
signatureId: 'signature-1',
|
||||
@@ -727,6 +728,18 @@ describe('OperationsService', () => {
|
||||
averageArrivalMs: 1800,
|
||||
},
|
||||
]);
|
||||
prisma.$queryRaw.mockResolvedValueOnce([
|
||||
{
|
||||
signatureId: 'signature-1',
|
||||
channelId: 'channel-1',
|
||||
channelName: '通道一',
|
||||
carrier: 'mobile',
|
||||
date: '2026-07-24',
|
||||
businessMessageCount: 3,
|
||||
deliveredMessageCount: 2,
|
||||
deliveryRate: 66.7,
|
||||
},
|
||||
]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.signatureQuality({
|
||||
@@ -736,6 +749,7 @@ describe('OperationsService', () => {
|
||||
pageSize: 5,
|
||||
})).resolves.toEqual({
|
||||
date: '2026-07-24',
|
||||
selectedDaySignatureCount: 4,
|
||||
items: [expect.objectContaining({
|
||||
signatureId: 'signature-1',
|
||||
signatureName: '【测试签名】',
|
||||
@@ -763,27 +777,31 @@ describe('OperationsService', () => {
|
||||
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }),
|
||||
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }),
|
||||
],
|
||||
dailyBreakdowns: [
|
||||
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', date: '2026-07-24', businessMessageCount: 3 }),
|
||||
],
|
||||
})],
|
||||
total: 12,
|
||||
page: 2,
|
||||
pageSize: 5,
|
||||
});
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(3);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('does not query channel details when the selected date has no registered signatures', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.$queryRaw.mockResolvedValueOnce([]);
|
||||
prisma.$queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ count: 0 }]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({
|
||||
date: '2026-07-24',
|
||||
selectedDaySignatureCount: 0,
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns trace details and reconciliation diffs', async () => {
|
||||
|
||||
@@ -316,11 +316,13 @@ async sendQuality(date?: string) {
|
||||
}
|
||||
async signatureQuality(query: SignatureQualityQuery) {
|
||||
const day = qualityBusinessDay(query.date);
|
||||
// The selected date is an inclusive business cutoff, so a 30-day window starts 29 full days earlier.
|
||||
const thirtyDayStartAt = new Date(day.startAt.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
|
||||
const keyword = query.keyword?.trim() || null;
|
||||
const keywordPattern = keyword ? `%${keyword}%` : null;
|
||||
const summaries = await this.prisma.$queryRaw<Array<{
|
||||
const [summaries, selectedDaySignatureRows] = await Promise.all([this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
@@ -351,7 +353,7 @@ async signatureQuality(query: SignatureQualityQuery) {
|
||||
END AS arrival_ms
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" >= ${thirtyDayStartAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
)
|
||||
SELECT
|
||||
@@ -413,7 +415,13 @@ async signatureQuality(query: SignatureQualityQuery) {
|
||||
ORDER BY total DESC, signature.name
|
||||
LIMIT ${pageSize}
|
||||
OFFSET ${(page - 1) * pageSize}
|
||||
`);
|
||||
`), this.prisma.$queryRaw<Array<{ count: number }>>(Prisma.sql`
|
||||
SELECT COUNT(DISTINCT message."signatureId")::integer AS count
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IS NOT NULL
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
`)]);
|
||||
const signatureIds = summaries.map((item) => item.signatureId);
|
||||
const drainageBreakdowns = signatureIds.length === 0
|
||||
? []
|
||||
@@ -486,7 +494,7 @@ async signatureQuality(query: SignatureQualityQuery) {
|
||||
) failed_receipt ON TRUE
|
||||
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${thirtyDayStartAt}
|
||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
||||
), classified AS (
|
||||
SELECT
|
||||
@@ -564,11 +572,54 @@ async signatureQuality(query: SignatureQualityQuery) {
|
||||
))::integer AS "averageArrivalMs"
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
AND message."queuedAt" >= ${day.startAt}
|
||||
AND message."queuedAt" >= ${thirtyDayStartAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
GROUP BY message."signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown')
|
||||
ORDER BY message."signatureId", COUNT(*) DESC, carrier
|
||||
`);
|
||||
const dailyBreakdowns = signatureIds.length === 0
|
||||
? []
|
||||
: await this.prisma.$queryRaw<Array<{
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
carrier: string;
|
||||
date: string;
|
||||
businessMessageCount: number;
|
||||
deliveredMessageCount: number;
|
||||
deliveryRate: number;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
message."signatureId" AS "signatureId",
|
||||
COALESCE(message."channelId", 'unassigned') AS "channelId",
|
||||
COALESCE(channel.name, '未分配通道') AS "channelName",
|
||||
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
|
||||
TO_CHAR((message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date,
|
||||
COUNT(*)::integer AS "businessMessageCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE message.status = 'delivered'
|
||||
OR message."receiptStatus" = 'delivered'
|
||||
)::integer AS "deliveredMessageCount",
|
||||
ROUND(
|
||||
COUNT(*) FILTER (
|
||||
WHERE message.status = 'delivered'
|
||||
OR message."receiptStatus" = 'delivered'
|
||||
) * 100.0 / COUNT(*),
|
||||
1
|
||||
)::double precision AS "deliveryRate"
|
||||
FROM "SmsMessageRecord" message
|
||||
LEFT JOIN "SmsChannel" channel ON channel.id = message."channelId"
|
||||
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
|
||||
AND message."queuedAt" >= ${thirtyDayStartAt}
|
||||
AND message."queuedAt" < ${day.endAt}
|
||||
GROUP BY
|
||||
message."signatureId",
|
||||
COALESCE(message."channelId", 'unassigned'),
|
||||
COALESCE(channel.name, '未分配通道'),
|
||||
COALESCE(NULLIF(message.carrier, ''), 'unknown'),
|
||||
TO_CHAR((message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD')
|
||||
ORDER BY message."signatureId", date, "channelId", carrier
|
||||
`);
|
||||
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
|
||||
const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId);
|
||||
const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns);
|
||||
@@ -578,10 +629,12 @@ async signatureQuality(query: SignatureQualityQuery) {
|
||||
carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId),
|
||||
breakdowns: signatureBreakdowns,
|
||||
drainageBreakdowns: signatureDrainageBreakdowns,
|
||||
dailyBreakdowns: dailyBreakdowns.filter((item) => item.signatureId === summary.signatureId),
|
||||
};
|
||||
});
|
||||
return {
|
||||
date: day.key,
|
||||
selectedDaySignatureCount: selectedDaySignatureRows[0]?.count ?? 0,
|
||||
items,
|
||||
total: summaries[0]?.rowCount ?? 0,
|
||||
page,
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
"group": "quality",
|
||||
"isPrivate": false,
|
||||
"signatureSha256": "6b9c3761674cb0d01000caca5e12cf91a6e526757592ffb783fc673c178b204c",
|
||||
"bodySha256": "a7203deb5a8c7c3b6f6afaf83a55bb1ff45a0bf198f86a2158868c4b8c759b2d"
|
||||
"bodySha256": "d07df712d2adadd05462914715f284e0215afbd0309fe276f78d2ac90a5aa0b7"
|
||||
},
|
||||
{
|
||||
"name": "auditLogs",
|
||||
|
||||
@@ -1998,3 +1998,10 @@
|
||||
- 运营端“短信通道组管理”在通道组名称条件之外增加“通道”筛选。选定一个通道后,只展示成员配置中真实包含该`channelId`的未删除通道组;与通道组名称同时输入时按两个条件取交集。
|
||||
- 通道选项必须来自真实通道API,不使用静态列表、Mock或localStorage。页面首次加载时通道组与通道两个独立请求并行执行;选项同时展示通道名称和编码,已删除通道明确标记“已删除”。未加入任何通道组的真实通道仍可选择,选中后结果为零而不得隐藏该选项。
|
||||
- 通道选择控件必须为通用下拉可搜索控件,支持按通道名称或编码搜索;“全部通道”表示不按通道限制,点击“重置”必须同时清空通道组名称和通道条件并回到第一页。
|
||||
|
||||
## 签名发送质量多周期趋势(2026-08-09)
|
||||
|
||||
- 运营端原“数据统计”菜单更名为“签名发送质量”,路由保持`/admin/analytics`兼容。日期控件保留并明确为“统计截止日”;近3日、近7日、近30日均包含所选截止日当天,例如截止日为8月6日时,近3日为8月4日至8月6日。
|
||||
- 页面增加“当天发送签名数量”指标,按所选截止日内存在真实`SmsMessageRecord`且关联已登记`signatureId`的不同签名去重统计;搜索关键字不改变该全局当天指标。
|
||||
- 签名列表候选范围为截至所选日期的近30日,截止日当天没有发送但此前29日有发送的签名仍须出现。签名详情按“签名 × 消息最终归属通道 × 号码运营商”展示近3/7/30日的发送业务条数、到达业务条数和到达率。业务条数以`SmsMessageRecord`为唯一计数单位,长短信分片和同一短信的通道补发尝试不得重复放大;到达率为到达业务条数除以发送业务条数,分母为0时显示0%。
|
||||
- 点击任一通道、运营商和周期组合的“查看趋势”,必须展示该周期每个自然日的发送业务条数、到达业务条数和到达率折线;无数据日期补0,首尾日期与所选周期严格一致并包含截止日当天。数据必须来自真实后端和PostgreSQL,不得使用Mock、静态数据或localStorage。
|
||||
|
||||
@@ -4000,6 +4000,12 @@ npm run verify:phase8
|
||||
| TC-ANALYTICS-SIGNATURE-011 | 点击“查看明细” | 打开右侧详情抽屉,展示运营商概览及通道×运营商矩阵;Esc、关闭按钮和遮罩均可关闭 |
|
||||
| TC-ANALYTICS-SIGNATURE-012 | 所选日期没有已登记签名发送 | 返回真实空状态,不显示演示或历史日期数据 |
|
||||
| TC-ANALYTICS-SIGNATURE-013 | 同一运营商的一条业务短信首通道失败后切换通道并最终送达 | 运营商概览计1条业务短信、最终成功率为100%;通道矩阵仍分别记录两次真实提交 |
|
||||
| TC-ANALYTICS-SIGNATURE-014 | 统计截止日选择8月6日,查看近3/7/30日 | 统计范围分别为8月4日至6日、7月31日至8月6日、7月8日至8月6日,三段均包含8月6日当天 |
|
||||
| TC-ANALYTICS-SIGNATURE-015 | 同一签名同一天存在长短信分片和跨通道补发,再查看通道×运营商多周期质量 | 每个业务消息按最终归属通道只计1条,不按分片或提交尝试重复计数;发送数、到达数和到达率与真实消息记录一致 |
|
||||
| TC-ANALYTICS-SIGNATURE-016 | 点击任一通道、运营商的近3日“查看趋势” | 折线固定展示含截止日在内的3个自然日,逐日包含发送业务条数、到达业务条数和到达率,无数据日期显示0 |
|
||||
| TC-ANALYTICS-SIGNATURE-017 | 当天多个消息使用同一签名、另有消息使用第二个签名,并输入签名搜索条件 | “当天发送签名数量”为2且不受搜索条件影响;未关联`signatureId`的消息不计入 |
|
||||
| TC-ANALYTICS-SIGNATURE-018 | 检查运营端导航、面包屑和日期控件 | 菜单与面包屑显示“签名发送质量”,日期控件语义为“统计截止日”,原`/admin/analytics`路由仍可访问 |
|
||||
| TC-ANALYTICS-SIGNATURE-019 | 某签名在截止日当天无发送、但此前29日内有发送 | 该签名仍出现在近30日列表并可查看对应周期数据;“当天发送签名数量”不计入该签名 |
|
||||
# 列表后端分页与响应性能测试(2026-07-28)
|
||||
|
||||
## TC-LIST-PERF-001 短信记录真实后端分页
|
||||
|
||||
@@ -3372,3 +3372,12 @@ git diff --check
|
||||
- 从预生产服务器公网复核:运营登录、客户登录、API health和客户Swagger均为HTTP 200;API独立域名根路径、管理页面和管理通道组接口均为404,主站未认证通道组接口为401。生产前端包已包含“输入通道名称或编码搜索”,运行API源码已包含模板任务快照继续处理逻辑。
|
||||
- 9条active供应商通道发布重启后6条为`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”3条仍返回`connect response status: auth failed`。本轮未修改其账号、密码、启停状态或连接参数,只保留并报告供应商真实返回。
|
||||
- 发布依赖审计仍报告根项目3项high、API项目3项moderate和4项high;专用安全缓解门禁通过,未执行可能破坏兼容性的自动升级。本次没有执行任何真实模板、签名、通道或通道组删除,未发送、补发或重投短信,未修改企业余额、客户连接或供应商通道配置。
|
||||
|
||||
## 2026-08-09 签名发送质量多周期趋势(本地验证完成)
|
||||
|
||||
- 运营端菜单及面包屑更名为“签名发送质量”,日期控件明确为统计截止日。近3/7/30日全部包含截止日当天;前端以自然日补齐趋势日期,不因数据库无记录而省略日期点。
|
||||
- `GET /api/admin/operations/signature-quality`将签名列表候选及列表概览扩展为截至所选日期的近30日,并增加当天发送签名去重数,以及当前分页签名近30日按最终通道、运营商、自然日聚合的真实业务短信统计。截止日当天无发送但此前29日有发送的签名仍可查询;统计直接读取`SmsMessageRecord`,不按长短信分片或`SmsSubmitRecord`补发尝试重复计数。
|
||||
- 签名详情新增“通道 × 运营商业务质量”表,逐组合展示近3/7/30日发送业务条数、到达业务条数和到达率;点击周期打开双轴折线图,展示逐日发送、到达和到达率。原单日运营商概览、通道提交矩阵和按引流切分继续保留,避免改变既有提交尝试诊断口径。
|
||||
- 页面新增“当天发送签名数量”指标,按截止日内关联已登记签名的不同`signatureId`去重,且不受列表关键字筛选影响。未增加数据库字段或migration。
|
||||
- Node.js v24下运营统计专项1 suite / 28 tests通过;API正式TypeScript构建、前端TypeScript及Vite v8.1.5生产构建通过(2535 modules,仅保留既有约2.04MB单chunk提示);`operations-r2`结构契约已按本次明确修改的`signatureQuality`行为更新并重新通过。发布与预生产只读验收结果待完成后补记。
|
||||
- 发布前通过SSH对预生产PostgreSQL执行同口径只读探测:北京时间2026-08-09当天共有3个已登记签名、218条业务消息;近30日按签名、最终通道、运营商和北京时间自然日分组SQL成功执行并返回真实数据,未修改数据库或触发短信发送。
|
||||
|
||||
@@ -65,6 +65,17 @@ export type SignatureCarrierBusinessQualityStat = {
|
||||
averageArrivalMs?: number | null;
|
||||
};
|
||||
|
||||
export type SignatureChannelCarrierDailyQualityStat = {
|
||||
signatureId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
carrier: string;
|
||||
date: string;
|
||||
businessMessageCount: number;
|
||||
deliveredMessageCount: number;
|
||||
deliveryRate: number;
|
||||
};
|
||||
|
||||
export type SignatureChannelQualityItem = {
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
@@ -83,10 +94,12 @@ export type SignatureChannelQualityItem = {
|
||||
carrierOverview: SignatureCarrierBusinessQualityStat[];
|
||||
breakdowns: SignatureChannelCarrierQualityStat[];
|
||||
drainageBreakdowns: SignatureChannelCarrierDrainageQualityStat[];
|
||||
dailyBreakdowns: SignatureChannelCarrierDailyQualityStat[];
|
||||
};
|
||||
|
||||
export type SignatureChannelQualityResponse = {
|
||||
date: string;
|
||||
selectedDaySignatureCount: number;
|
||||
items: SignatureChannelQualityItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
adminApi,
|
||||
type SendQualityResponse,
|
||||
type SignatureChannelCarrierQualityStat,
|
||||
type SignatureChannelCarrierDailyQualityStat,
|
||||
type SignatureChannelQualityItem,
|
||||
type SignatureChannelQualityResponse,
|
||||
} from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { createBarOption, createPieOption } from '@/theme/chartOptions';
|
||||
import { createBarOption, createPieOption, createQualityTrendOption } from '@/theme/chartOptions';
|
||||
import { successRateClassName } from '@/utils/successRate';
|
||||
|
||||
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
|
||||
@@ -25,6 +26,13 @@ const carrierLabels: Record<string, string> = {
|
||||
unknown: '未知',
|
||||
};
|
||||
|
||||
type TrendSelection = {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
carrier: string;
|
||||
days: 3 | 7 | 30;
|
||||
};
|
||||
|
||||
export function AdminAnalyticsPage() {
|
||||
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
|
||||
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
|
||||
@@ -104,48 +112,48 @@ export function AdminAnalyticsPage() {
|
||||
},
|
||||
{
|
||||
key: 'businessTotal',
|
||||
title: '业务短信',
|
||||
title: '近30日业务短信',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => record.total.toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
key: 'channelSubmitTotal',
|
||||
title: '通道提交',
|
||||
title: '近30日通道提交',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => record.channelSubmitTotal.toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
key: 'successCount',
|
||||
title: '送达成功',
|
||||
title: '近30日送达',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => <span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'failureCount',
|
||||
title: '送达失败',
|
||||
title: '近30日送达失败',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => <span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'submitFailureCount',
|
||||
title: '提交失败',
|
||||
title: '近30日提交失败',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => <span className="quality-number quality-number--warning">{record.submitFailureCount.toLocaleString('zh-CN')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'successRate',
|
||||
title: '成功率',
|
||||
title: '近30日成功率',
|
||||
width: '170px',
|
||||
render: (record) => <QualityRate value={record.successRate} />,
|
||||
},
|
||||
{
|
||||
key: 'averageArrivalMs',
|
||||
title: '平均到达时间',
|
||||
title: '近30日平均到达',
|
||||
width: '140px',
|
||||
align: 'right',
|
||||
render: (record) => formatDuration(record.averageArrivalMs),
|
||||
@@ -175,11 +183,11 @@ export function AdminAnalyticsPage() {
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['数据统计']} />
|
||||
<Breadcrumb items={['签名发送质量']} />
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<Input
|
||||
aria-label="统计日期"
|
||||
aria-label="统计截止日"
|
||||
max={shanghaiDateKey()}
|
||||
onChange={(event) => setStatisticsDate(event.target.value)}
|
||||
type="date"
|
||||
@@ -198,6 +206,11 @@ export function AdminAnalyticsPage() {
|
||||
<strong>{quality?.summary.total.toLocaleString('zh-CN') ?? 0}</strong>
|
||||
<small>所选日期真实消息记录</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>当天发送签名数量</span>
|
||||
<strong>{signatureQuality?.selectedDaySignatureCount.toLocaleString('zh-CN') ?? 0}</strong>
|
||||
<small>{effectiveDate} 有业务短信记录的已登记签名去重数</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>{effectiveDate} 成功率</span>
|
||||
<strong>{(quality?.summary.successRate ?? 0).toFixed(1)}%</strong>
|
||||
@@ -232,11 +245,11 @@ export function AdminAnalyticsPage() {
|
||||
<div className="signature-quality-card__heading">
|
||||
<div>
|
||||
<div className="section-heading__title">
|
||||
<h2>签名通道发送质量</h2>
|
||||
<h2>签名发送质量</h2>
|
||||
<Tag tone="info">已登记签名</Tag>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。
|
||||
统计截止日为 {signatureQuality?.date ?? effectiveDate},近 3/7/30 日均包含截止日当天;明细按业务短信最终归属通道与运营商统计。
|
||||
</p>
|
||||
</div>
|
||||
<div className="signature-quality-card__query">
|
||||
@@ -254,12 +267,12 @@ export function AdminAnalyticsPage() {
|
||||
</div>
|
||||
<div className="signature-quality-card__note">
|
||||
<strong>统计说明:</strong>
|
||||
业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
|
||||
业务短信按消息记录去重;近 3 日、近 7 日、近 30 日分别覆盖截止日及之前 2、6、29 个自然日。
|
||||
</div>
|
||||
<Table
|
||||
columns={signatureColumns}
|
||||
data={signatureQuality?.items ?? []}
|
||||
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
|
||||
emptyText={loading ? '正在加载签名统计…' : '截至所选日期的近30日暂无已登记签名发送数据'}
|
||||
pagination={false}
|
||||
rowKey="signatureId"
|
||||
/>
|
||||
@@ -298,6 +311,7 @@ function SignatureQualityDrawer({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [matrixMode, setMatrixMode] = useState<'overall' | 'drainage'>('overall');
|
||||
const [trendSelection, setTrendSelection] = useState<TrendSelection | null>(null);
|
||||
const carriers = item.carrierOverview
|
||||
.map((carrier) => ({
|
||||
...carrier,
|
||||
@@ -318,6 +332,10 @@ function SignatureQualityDrawer({
|
||||
.map((entry) => [entry.channelId, entry.channelName])).entries()]
|
||||
.map(([channelId, channelName]) => ({ channelId, channelName }));
|
||||
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
|
||||
const businessQualityRows = [...new Map(item.dailyBreakdowns.map((entry) => [
|
||||
`${entry.channelId}:${normalizeCarrier(entry.carrier)}`,
|
||||
{ channelId: entry.channelId, channelName: entry.channelName, carrier: normalizeCarrier(entry.carrier) },
|
||||
])).values()];
|
||||
|
||||
return (
|
||||
<div className="signature-quality-drawer__backdrop" onMouseDown={(event) => {
|
||||
@@ -335,17 +353,56 @@ function SignatureQualityDrawer({
|
||||
|
||||
<div className="signature-quality-drawer__body">
|
||||
<div className="signature-quality-overview">
|
||||
<QualityMetric label="业务短信" value={item.total.toLocaleString('zh-CN')} />
|
||||
<QualityMetric label="通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
|
||||
<QualityMetric label="最终成功率" value={`${item.successRate.toFixed(1)}%`} valueClassName={successRateClassName(item.successRate)} />
|
||||
<QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} />
|
||||
<QualityMetric label="近30日业务短信" value={item.total.toLocaleString('zh-CN')} />
|
||||
<QualityMetric label="近30日通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
|
||||
<QualityMetric label="近30日最终成功率" value={`${item.successRate.toFixed(1)}%`} valueClassName={successRateClassName(item.successRate)} />
|
||||
<QualityMetric label="近30日平均到达时间" value={formatDuration(item.averageArrivalMs)} />
|
||||
</div>
|
||||
|
||||
<section className="signature-quality-section">
|
||||
<div className="signature-quality-section__heading">
|
||||
<div>
|
||||
<h3>通道 × 运营商业务质量</h3>
|
||||
<p>发送和到达均按业务短信去重;点击任一周期查看包含统计截止日的逐日趋势。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="signature-business-quality-table">
|
||||
<table>
|
||||
<thead><tr><th>通道</th><th>运营商</th>{([3, 7, 30] as const).map((days) => <th key={days}>近 {days} 日</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{businessQualityRows.map((row) => (
|
||||
<tr key={`${row.channelId}:${row.carrier}`}>
|
||||
<th>{row.channelName}</th>
|
||||
<td><Tag tone={carrierTagTone(row.carrier)}>{carrierLabel(row.carrier)}</Tag></td>
|
||||
{([3, 7, 30] as const).map((days) => {
|
||||
const metric = aggregateBusinessQuality(item.dailyBreakdowns, row.channelId, row.carrier, date, days);
|
||||
return (
|
||||
<td key={days}>
|
||||
<button
|
||||
className="signature-business-quality-metric"
|
||||
onClick={() => setTrendSelection({ ...row, days })}
|
||||
type="button"
|
||||
>
|
||||
<strong>{metric.sent.toLocaleString('zh-CN')} / {metric.delivered.toLocaleString('zh-CN')}</strong>
|
||||
<span className={successRateClassName(metric.rate)}>{metric.rate.toFixed(1)}%</span>
|
||||
<small>查看趋势</small>
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{businessQualityRows.length === 0 ? <tr><td colSpan={5}>近 30 日暂无业务短信数据</td></tr> : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="signature-quality-section">
|
||||
<div className="signature-quality-section__heading">
|
||||
<div>
|
||||
<h3>运营商概览</h3>
|
||||
<p>按真实业务短信去重统计;补发不会重复计数,成功率取短信最终状态。</p>
|
||||
<p>按近30日真实业务短信去重统计;补发不会重复计数,成功率取短信最终状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="signature-carrier-grid">
|
||||
@@ -370,7 +427,7 @@ function SignatureQualityDrawer({
|
||||
<div>
|
||||
<h3>通道 × 运营商矩阵</h3>
|
||||
<p>{matrixMode === 'overall'
|
||||
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
|
||||
? '整体口径展示近30日该组合全部真实提交;“—”表示该期间没有真实提交。'
|
||||
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}</p>
|
||||
</div>
|
||||
<div className="page-actions"><Button onClick={() => setMatrixMode('overall')} size="sm" variant={matrixMode === 'overall' ? 'primary' : 'ghost'}>整体统计</Button><Button onClick={() => setMatrixMode('drainage')} size="sm" variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}>按引流切分</Button></div>
|
||||
@@ -431,11 +488,84 @@ function SignatureQualityDrawer({
|
||||
平均到达时间从该通道提交受理开始计算,到该通道全部成功回执完成为止,仅统计成功送达的提交尝试。
|
||||
</p>
|
||||
</div>
|
||||
{trendSelection ? (
|
||||
<SignatureQualityTrend
|
||||
date={date}
|
||||
entries={item.dailyBreakdowns}
|
||||
onClose={() => setTrendSelection(null)}
|
||||
selection={trendSelection}
|
||||
signatureName={item.signatureName}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureQualityTrend({
|
||||
date,
|
||||
entries,
|
||||
onClose,
|
||||
selection,
|
||||
signatureName,
|
||||
}: {
|
||||
date: string;
|
||||
entries: SignatureChannelCarrierDailyQualityStat[];
|
||||
onClose: () => void;
|
||||
selection: TrendSelection;
|
||||
signatureName: string;
|
||||
}) {
|
||||
const dates = inclusiveDateKeys(date, selection.days);
|
||||
const points = dates.map((dateKey) => entries.find((entry) => (
|
||||
entry.date === dateKey
|
||||
&& entry.channelId === selection.channelId
|
||||
&& normalizeCarrier(entry.carrier) === selection.carrier
|
||||
)));
|
||||
const option = createQualityTrendOption({
|
||||
labels: dates.map((dateKey) => dateKey.slice(5)),
|
||||
sent: points.map((point) => point?.businessMessageCount ?? 0),
|
||||
delivered: points.map((point) => point?.deliveredMessageCount ?? 0),
|
||||
rates: points.map((point) => point?.deliveryRate ?? 0),
|
||||
});
|
||||
return (
|
||||
<div className="signature-quality-trend" role="dialog" aria-modal="true" aria-label="签名发送质量趋势">
|
||||
<div className="signature-quality-trend__heading">
|
||||
<div><h3>{signatureName} · 近 {selection.days} 日趋势</h3><p>{selection.channelName} · {carrierLabel(selection.carrier)} · {dates[0]} 至 {date}(含截止日)</p></div>
|
||||
<button aria-label="关闭趋势图" onClick={onClose} type="button"><X size={18} /></button>
|
||||
</div>
|
||||
<Chart height={360} option={option} />
|
||||
<p className="muted">折线分别展示每日发送业务条数、到达业务条数和到达率;无发送时到达率按 0% 展示。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateBusinessQuality(
|
||||
entries: SignatureChannelCarrierDailyQualityStat[],
|
||||
channelId: string,
|
||||
carrier: string,
|
||||
date: string,
|
||||
days: 3 | 7 | 30,
|
||||
) {
|
||||
const includedDates = new Set(inclusiveDateKeys(date, days));
|
||||
const relevant = entries.filter((entry) => (
|
||||
entry.channelId === channelId
|
||||
&& normalizeCarrier(entry.carrier) === carrier
|
||||
&& includedDates.has(entry.date)
|
||||
));
|
||||
const sent = relevant.reduce((sum, entry) => sum + entry.businessMessageCount, 0);
|
||||
const delivered = relevant.reduce((sum, entry) => sum + entry.deliveredMessageCount, 0);
|
||||
return { sent, delivered, rate: sent === 0 ? 0 : delivered * 100 / sent };
|
||||
}
|
||||
|
||||
function inclusiveDateKeys(endDate: string, days: number) {
|
||||
const [year, month, day] = endDate.split('-').map(Number);
|
||||
const end = new Date(Date.UTC(year, month - 1, day));
|
||||
return Array.from({ length: days }, (_, index) => {
|
||||
const current = new Date(end.getTime() - (days - index - 1) * 24 * 60 * 60 * 1000);
|
||||
return current.toISOString().slice(0, 10);
|
||||
});
|
||||
}
|
||||
|
||||
function QualityMetric({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) {
|
||||
return (
|
||||
<div className="signature-quality-metric">
|
||||
|
||||
@@ -105,7 +105,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
{ label: '运营看板', to: '/admin', icon: Gauge },
|
||||
{ label: '发送监控', to: '/admin/monitor', icon: Activity },
|
||||
{ label: '网关异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle },
|
||||
{ label: '数据统计', to: '/admin/analytics', icon: BarChart3 },
|
||||
{ label: '签名发送质量', to: '/admin/analytics', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6767,6 +6767,101 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.signature-business-quality-table {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.signature-business-quality-table table {
|
||||
border-collapse: collapse;
|
||||
min-width: 820px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signature-business-quality-table th,
|
||||
.signature-business-quality-table td {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--space-3);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.signature-business-quality-table thead th {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.signature-business-quality-table tbody tr:last-child > * {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.signature-business-quality-metric {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.signature-business-quality-metric strong {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.signature-business-quality-metric span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.signature-business-quality-metric small {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.signature-business-quality-metric:hover small {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.signature-quality-trend {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
left: 50%;
|
||||
max-width: 880px;
|
||||
padding: var(--space-5);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(88%, 880px);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.signature-quality-trend__heading {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.signature-quality-trend__heading h3,
|
||||
.signature-quality-trend__heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signature-quality-trend__heading p {
|
||||
color: var(--color-text-muted);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.signature-quality-trend__heading button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.signature-carrier-grid {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -31,6 +31,30 @@ export function createLineOption(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function createQualityTrendOption(params: {
|
||||
labels: string[];
|
||||
sent: number[];
|
||||
delivered: number[];
|
||||
rates: number[];
|
||||
}): EChartsOption {
|
||||
return {
|
||||
color: [...chartPalette],
|
||||
grid: { left: 12, right: 18, top: 42, bottom: 8, containLabel: true },
|
||||
legend: { top: 0, right: 0, textStyle: { color: themeColors.textMuted } },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: params.labels, ...axisStyle },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '业务条数', minInterval: 1, ...axisStyle },
|
||||
{ type: 'value', name: '到达率', min: 0, max: 100, ...axisStyle, axisLabel: { formatter: '{value}%', color: themeColors.textMuted } },
|
||||
],
|
||||
series: [
|
||||
{ name: '发送业务条数', data: params.sent, type: 'line', smooth: true, symbolSize: 6, lineStyle: { width: 3 } },
|
||||
{ name: '到达业务条数', data: params.delivered, type: 'line', smooth: true, symbolSize: 6, lineStyle: { width: 3 } },
|
||||
{ name: '到达率', data: params.rates, type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 6, lineStyle: { width: 3 } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createBarOption(params: {
|
||||
labels: string[];
|
||||
series: Array<{ name: string; data: number[] }>;
|
||||
|
||||
Reference in New Issue
Block a user