diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index b7608af..5f4c404 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -222,11 +222,38 @@ describe('OperationsService', () => { lte: new Date('2026-07-02T23:59:59.999+08:00'), }, }, - include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true }, + include: { + tenant: true, + application: true, + channel: true, + submitRecords: true, + receiptRecords: true, + downstreamDeliveries: { + where: { deliveryType: 'receipt' }, + select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, + }, + }, orderBy: { queuedAt: 'desc' }, }); }); + it('separates upstream submit failures from post-acceptance delivery failures', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await service.listMessages({ status: 'submit_failed' }); + expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }], + }), + })); + + await service.listMessages({ status: 'failed' }); + expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ + where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }), + })); + }); + it('returns uplink messages with tenant and channel display data', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); @@ -399,6 +426,9 @@ describe('OperationsService', () => { channelId: 'channel-1', channelName: '通道一', total: 5, + acceptedCount: 4, + submitFailureCount: 1, + submitFailureRate: 20, successCount: 3, unknownCount: 1, failureCount: 1, @@ -420,15 +450,48 @@ describe('OperationsService', () => { failureCount: 1, successRate: 60, averageArrivalMs: 1200, + }]) + .mockResolvedValueOnce([{ + total: 5, + successCount: 3, + unknownCount: 1, + failureCount: 1, + successRate: 60, + }]) + .mockResolvedValueOnce([{ + applicationId: 'app-1', + applicationName: '通知应用', + tenantId: 'tenant-1', + tenantName: '租户A', + total: 5, + successCount: 3, + unknownCount: 1, + failureCount: 1, + successRate: 60, }]); 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 })], + summary: { + total: 5, + successCount: 3, + unknownCount: 1, + failureCount: 1, + successRate: 60, + }, + channels: [expect.objectContaining({ + channelId: 'channel-1', + total: 5, + acceptedCount: 4, + submitFailureCount: 1, + submitFailureRate: 20, + successRate: 60, + })], signatures: [expect.objectContaining({ signatureId: 'signature-1', signatureName: '【测试签名】', hasDrainage: false })], + applications: [expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 })], }); - expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(4); }); it('rejects invalid send quality dates', async () => { diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 7935ba0..6e67942 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -98,7 +98,17 @@ export class OperationsService { listMessages(query: MessageQuery) { return this.prisma.smsMessageRecord.findMany({ where: messageWhere(query), - include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true }, + include: { + tenant: true, + application: true, + channel: true, + submitRecords: true, + receiptRecords: true, + downstreamDeliveries: { + where: { deliveryType: 'receipt' }, + select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, + }, + }, orderBy: { queuedAt: 'desc' }, }); } @@ -356,11 +366,14 @@ export class OperationsService { async sendQuality(date?: string) { const day = qualityBusinessDay(date); - const [channels, signatures] = await Promise.all([ + const [channels, signatures, summaryRows, applications] = await Promise.all([ this.prisma.$queryRaw= COALESCE(submit."submittedAt", submit."createdAt") + WHEN segment_summary.segment_count > 0 + AND segment_summary.delivered_count = segment_summary.segment_count + AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt") + THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 + WHEN segment_summary.segment_count = 0 + AND 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 + COUNT(*)::integer AS segment_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, + COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, + MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at + FROM "SmsMessageSegmentAudit" segment + WHERE segment."submitRecordId" = submit.id + ) segment_summary ON TRUE LEFT JOIN LATERAL ( SELECT MIN(receipt."deliveredAt") AS "deliveredAt" FROM "SmsReceiptRecord" receipt @@ -395,22 +426,37 @@ export class OperationsService { AND receipt."channelId" = submit."channelId" AND receipt."receiptStatus" = 'undelivered' ) failed_receipt ON TRUE - WHERE submit."submitStatus" = 'accepted' + WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout') AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} + ), classified AS ( + SELECT + *, + CASE + WHEN submit_status <> 'accepted' THEN 'submit_failed' + WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure' + WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success' + WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure' + WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success' + ELSE 'unknown' + END AS delivery_status + FROM base ) 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 + COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount", + COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount", + CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'submit_failed') * 100.0 / COUNT(*), 1)::double precision END AS "submitFailureRate", + COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount", + COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount", + COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount", + CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'success') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "successRate", + CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'unknown') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "unknownRate", + CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'failure') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "failureRate", + ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" + FROM classified GROUP BY channel_id ORDER BY COUNT(*) DESC, channel_id `), @@ -479,8 +525,101 @@ export class OperationsService { GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage ORDER BY "successCount" DESC, total DESC, signature.name `), + this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT message.status, message."receiptStatus" AS receipt_status + FROM "SmsMessageRecord" message + WHERE message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + AND COALESCE(message.status, '') <> 'rejected' + ) + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", + COUNT(*) FILTER ( + WHERE 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(*) = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') + * 100.0 / COUNT(*), + 1 + )::double precision + END AS "successRate" + FROM base + `), + this.prisma.$queryRaw>(Prisma.sql` + WITH base AS ( + SELECT + message."applicationId" AS application_id, + message.status, + message."receiptStatus" AS receipt_status + FROM "SmsMessageRecord" message + WHERE message."applicationId" IS NOT NULL + AND message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + AND COALESCE(message.status, '') <> 'rejected' + ) + SELECT + application.id AS "applicationId", + application.name AS "applicationName", + tenant.id AS "tenantId", + tenant.name AS "tenantName", + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", + COUNT(*) FILTER ( + WHERE 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(*) = 0 THEN 0 + ELSE ROUND( + COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') + * 100.0 / COUNT(*), + 1 + )::double precision + END AS "successRate" + FROM base + JOIN "SmsApplication" application ON application.id = base.application_id + JOIN "Tenant" tenant ON tenant.id = application."tenantId" + GROUP BY application.id, application.name, tenant.id, tenant.name + ORDER BY total DESC, application.name + `), ]); - return { date: day.key, channels, signatures }; + const summary = summaryRows[0] ?? { + total: 0, + successCount: 0, + unknownCount: 0, + failureCount: 0, + successRate: 0, + }; + return { date: day.key, summary, channels, signatures, applications }; } async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) { @@ -1057,6 +1196,13 @@ export class OperationsService { } function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { + const statusWhere = query.status === 'submit_failed' + ? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] } + : query.status === 'failed' + ? { status: 'failed', submitStatus: 'accepted' } + : query.status + ? { status: query.status } + : {}; return { tenantId: query.tenantId, applicationId: query.applicationId, @@ -1064,7 +1210,7 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { batchTaskId: query.taskId, messageId: query.messageId, phoneNumber: query.phoneNumber, - status: query.status, + ...statusWhere, ...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}), ...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}), ...(query.queuedAtFrom || query.queuedAtTo ? { diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 7128af9..ddaaaba 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2412,3 +2412,21 @@ git diff --check - 发布后Gateway、API、Nginx、PostgreSQL、Redis和MinIO均active;`12026/17890/3000/6379/5432/9000`监听,API health正常、Redis PONG。`gateway.submit.commands`消费者1、`pending=0`、`lag=0`,3条active供应商通道均为`connected/currentConnections=1/desiredConnections=1`。 - 新路由未登录访问返回受控401,证明路由已加载且认证保护有效;直接调用预发布已部署`OperationsService`并查询真实PostgreSQL返回日期`2026-07-24`、3行通道和4行签名统计。通道总量为10:富泷物业-移动5条(成功0、未知3、失败2),赛邮行业-王斯评中转4条(成功1、未知3、失败0),富泷物业-联通1条(成功1、未知0、失败0),修复后不再是前端硬编码全0。 - 公网首页、运营登录页、客户端登录页和API health均返回HTTP 200,公网CMPP 17890 TCP连接成功。发布以来API、Gateway和Nginx无warning及以上日志。服务器留存的旧管理员凭据已与当前账户密码不一致,接口验收首次登录返回一次401后停止重试,未触发锁定;本轮未绕过认证,登录后页面交互仍需持有当前密码的人工会话复核,未虚报通过。 + +## 2026-07-24 提交失败与送达失败口径拆分(本地未提交、未部署) + +- 预发布只读核对确认三条上游结果均为`rejected / 103`:企业CMPP正式短信`18821203795`由平台生成`PLATFORM:MSG-* / REJECTD`失败回执并已完成企业侧投递;运营端通道测试`13127620092`、`15821447161`没有企业和应用归属,无需生成客户回执。三者业务结果统一归为“提交失败”,是否存在平台通知回执不再改变列表分类。 +- 短信记录列表、CSV、详情和状态筛选按阶段拆分:`submit_failed`或`submitStatus=rejected/timeout`显示“提交失败”;只有供应商已受理且最终失败才显示“送达失败”。详情根据真实平台失败回执和`CmppDownstreamDelivery`状态显示“平台已生成失败回执并通知企业”,独立通道测试显示“运营端通道测试,无需生成客户回执”。 +- 通道当天质量纳入`accepted/rejected/timeout`终态提交,“今日提交”包含全部实际提交结果,并新增“提交失败”;送达成功、回执未知、送达失败只以供应商已受理数为分母。分片回执优先按`SmsMessageSegmentAudit.submitRecordId`聚合,任一分片明确失败即归为送达失败,避免非首片失败被误算为未知。 +- 通道页“查询”按钮绑定`loadChannels()`。风险复核确认该函数只调用通道列表、当天质量和连接状态三个GET接口,服务端均为只读`findMany`/聚合,不会连接、断开、重连通道,不会修改配置或写操作日志;不增加自动轮询。 +- 新SQL在预发布真实库只读执行成功:会员营销-铁布衫为今日提交3、提交失败3、已受理0;富泷物业-移动为已受理5、送达成功0、回执未知2、送达失败3;富泷物业-联通为已受理1、送达成功0、回执未知1;赛邮行业-王斯评中转为已受理4、送达成功1、回执未知3。 +- Node.js v24.14.0下API Operations定向1 suite / 22项、API全量26 suites / 320项、API TypeScript build、前端TypeScript/Vite build通过;全量测试仅出现既有Redis不可用容错告警并以`--forceExit`结束既有异步句柄。前端保留既有约1.93MB单chunk/580.73KB gzip告警。系统默认Node 14执行Jest/Vite时因不支持当前依赖语法却错误返回0,已排除该结果并使用工作区Node 24直接调用本地Jest/TypeScript/Vite可执行文件重跑。 +- 本轮按用户要求仅修改代码并验证,未提交、未推送、未部署;`api/tsconfig.build.tsbuildinfo`和`outputs/`继续作为既有其他会话/构建产物保留。 + +## 2026-07-24 运营看板签名分页与单日企业应用统计(发布前) + +- 运营看板“不含引流”和“含引流”两个今日签名发送统计区改为各占整行;每页展示10个签名,分别维护页码,并支持首页、末页、上一页、下一页和指定页跳转。跨页排名按完整结果集连续计算,不会在每页重新从1开始。 +- 数据统计统一使用日期选择器指定的北京时间单日数据,默认当天;发送量、成功率、企业应用排行和通道占比均来自同一次只读`send-quality`后端快照。指标标题展示实际已加载日期,修改日期但尚未点击查询时不会把旧数据误标成新日期。 +- 数据统计移除“待审核”。发送量和成功率从所选日期全部有效`SmsMessageRecord`聚合;企业排行改为企业应用维度,后端联表返回真实应用名称和所属企业名称,不再把企业ID或应用编号作为图表名称,也不混入历史累计记录。 +- 后端新增单日汇总及企业应用统计回归数据,继续排除平台预校验`rejected`记录;已送达优先于失败判定,`submit_failed/failed/timeout`或失败回执计入失败,其余计入未知,满足`发送总数 = 成功 + 未知 + 失败`。 +- 发布前门禁:Node.js v24.14.0下API Operations定向1 suite / 22项、API全量26 suites / 320项、API TypeScript build、前端TypeScript/Vite生产构建、Prisma generate/validate、Gateway `go test ./...`和`go vet ./...`、`git diff --check`全部通过。Jest保留既有Redis不可用容错告警和`--forceExit`异步句柄提示;前端保留既有约1.93MB单chunk/580.79KB gzip告警。浏览器验收、提交、推送与预发布部署结果待完成后补记。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index faad8e7..5cdf8e2 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -354,6 +354,9 @@ export type ChannelQualityStat = { channelId: string; channelName: string; total: number; + acceptedCount: number; + submitFailureCount: number; + submitFailureRate: number; successCount: number; unknownCount: number; failureCount: number; @@ -378,10 +381,27 @@ export type SignatureQualityStat = { averageArrivalMs?: number | null; }; +export type DailySendSummary = { + total: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; +}; + +export type ApplicationQualityStat = DailySendSummary & { + applicationId: string; + applicationName: string; + tenantId: string; + tenantName: string; +}; + export type SendQualityResponse = { date: string; + summary: DailySendSummary; channels: ChannelQualityStat[]; signatures: SignatureQualityStat[]; + applications: ApplicationQualityStat[]; }; export type RechargeOrder = { @@ -581,7 +601,7 @@ export type ImportPreviewResponse = { export type SmsMessageRecord = { id: string; - tenantId: string; + tenantId?: string | null; batchTaskId?: string | null; applicationId?: string | null; channelId?: string | null; @@ -607,6 +627,13 @@ export type SmsMessageRecord = { application?: { id: string; name: string }; submitRecords?: SmsSubmitRecord[]; receiptRecords?: SmsReceiptRecord[]; + downstreamDeliveries?: Array<{ + id: string; + deliveryType: string; + status: string; + deliveredAt?: string | null; + lastError?: string | null; + }>; }; export type SmsSubmitRecord = { diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 449c8d1..6b3cd68 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -1,26 +1,18 @@ import { useEffect, useMemo, useState } from 'react'; import { BarChart3 } from 'lucide-react'; -import { adminApi, type DashboardResponse } from '@/api/adminApi'; +import { adminApi, type SendQualityResponse } from '@/api/adminApi'; 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(null); - const [tenantStats, setTenantStats] = useState>([]); - const [channelStats, setChannelStats] = useState>([]); + const [quality, setQuality] = useState(null); const [error, setError] = useState(''); function loadData() { - Promise.all([ - adminApi.getDashboard(), - adminApi.listStatistics({ groupBy: 'tenantId' }), - adminApi.getSendQuality(statisticsDate), - ]) - .then(([dashboardData, tenantData, qualityData]) => { - setDashboard(dashboardData); - setTenantStats((Array.isArray(tenantData) ? tenantData : []) as Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>); - setChannelStats(qualityData.channels.map((item) => ({ channelId: item.channelId, channelName: item.channelName, total: item.total }))); + adminApi.getSendQuality(statisticsDate) + .then((qualityData) => { + setQuality(qualityData); setError(''); }) .catch((failure: Error) => setError(failure.message || '统计数据加载失败')); @@ -30,14 +22,15 @@ export function AdminAnalyticsPage() { loadData(); }, []); - const tenantOption = useMemo(() => createBarOption({ - labels: tenantStats.map((item) => item.tenantId ?? '未绑定企业'), - series: [{ name: '发送量', data: tenantStats.map((item) => item._count._all) }], - }), [tenantStats]); + const applicationOption = useMemo(() => createBarOption({ + labels: quality?.applications.map((item) => item.applicationName) ?? [], + series: [{ name: '发送量', data: quality?.applications.map((item) => item.total) ?? [] }], + }), [quality]); const channelOption = useMemo(() => createPieOption({ - data: channelStats.map((item) => ({ name: item.channelName || item.channelId, value: item.total })), - }), [channelStats]); + data: quality?.channels.map((item) => ({ name: item.channelName || item.channelId, value: item.total })) ?? [], + }), [quality]); + const effectiveDate = quality?.date ?? statisticsDate; return (
@@ -60,19 +53,14 @@ export function AdminAnalyticsPage() {
- 今日发送量 - {dashboard?.today.sent.toLocaleString('zh-CN') ?? 0} - 真实消息记录 + {effectiveDate} 发送量 + {quality?.summary.total.toLocaleString('zh-CN') ?? 0} + 所选日期真实消息记录
- 成功率 - {dashboard?.today.successRate ?? 0}% - 今日已回执 -
-
- 待审核 - {dashboard?.pendingAuditCount ?? 0} - 企业/签名/模板/风控 + {effectiveDate} 成功率 + {(quality?.summary.successRate ?? 0).toFixed(1)}% + {quality?.summary.successCount.toLocaleString('zh-CN') ?? 0} 条已送达 / {quality?.summary.total.toLocaleString('zh-CN') ?? 0} 条发送
@@ -80,18 +68,18 @@ export function AdminAnalyticsPage() {
-

企业发送排行

-

按真实短信消息记录聚合。

+

企业应用发送排行

+

{effectiveDate} 当天按企业应用名称聚合真实短信消息记录。

- 企业 + 企业应用
- +

通道占比

-

{statisticsDate} 当天按真实通道提交及回执聚合。

+

{effectiveDate} 当天按真实通道提交及回执聚合。

通道
diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index e9122ed..3df4acc 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -17,6 +17,8 @@ type SmsChannel = { unitPrice: number; status: ChannelStatus; total: number; + submitFailureRate: number; + submitFailureCount: number; successRate: number; successCount: number; unknownRate: number; @@ -154,6 +156,8 @@ function mapApiChannel( unitPrice: channel.unitPrice, status: resolveChannelStatus(channel, connections), total: quality?.total ?? 0, + submitFailureRate: quality?.submitFailureRate ?? 0, + submitFailureCount: quality?.submitFailureCount ?? 0, successRate: quality?.successRate ?? 0, successCount: quality?.successCount ?? 0, unknownRate: quality?.unknownRate ?? 0, @@ -256,6 +260,8 @@ function ChannelFormModal({ unitPrice: yuanToMoneyUnits(unitPrice), status: channel?.status ?? 'connecting', total: channel?.total ?? 0, + submitFailureRate: channel?.submitFailureRate ?? 0, + submitFailureCount: channel?.submitFailureCount ?? 0, successRate: channel?.successRate ?? 0, successCount: channel?.successCount ?? 0, unknownRate: channel?.unknownRate ?? 0, @@ -607,7 +613,7 @@ export function AdminChannelsPage() { setStatus(event.target.value)} options={statusOptions} value={status} />
- +
@@ -618,8 +624,8 @@ export function AdminChannelsPage() { 通道信息 运营商 / 成本 状态 - 今日总数 - 今日发送质量 + 今日提交 + 今日提交 / 送达质量 操作 {visibleChannels.map((channel) => ( @@ -640,9 +646,10 @@ export function AdminChannelsPage() { {channel.total.toLocaleString('zh-CN')}
- = 80 ? 'success' : 'warning'} /> - - = 50 ? 'danger' : 'neutral'} /> + 0 ? 'danger' : 'neutral'} /> + = 80 ? 'success' : 'warning'} /> + + = 50 ? 'danger' : 'neutral'} />
{plainSignatureQuality.length} 个签名 - +
+ = plainSignatureTotalPages} + onPrevious={() => setPlainSignaturePage((page) => Math.max(1, page - 1))} + onNext={() => setPlainSignaturePage((page) => Math.min(plainSignatureTotalPages, page + 1))} + onPageChange={setPlainSignaturePage} + />
@@ -217,7 +252,17 @@ export function AdminHome() {
{drainageSignatureQuality.length} 个签名
-
+
+ = drainageSignatureTotalPages} + onPrevious={() => setDrainageSignaturePage((page) => Math.max(1, page - 1))} + onNext={() => setDrainageSignaturePage((page) => Math.min(drainageSignatureTotalPages, page + 1))} + onPageChange={setDrainageSignaturePage} + /> diff --git a/src/apps/admin/AdminSmsRecordsPage.tsx b/src/apps/admin/AdminSmsRecordsPage.tsx index f1c62bb..a2bb351 100644 --- a/src/apps/admin/AdminSmsRecordsPage.tsx +++ b/src/apps/admin/AdminSmsRecordsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react'; +import { AlertTriangle, Download, Info, MessageSquare, Search, Smartphone } from 'lucide-react'; import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi'; import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui'; import { formatCents } from '@/utils/currency'; @@ -8,8 +8,9 @@ const statusLabelMap: Record = { delivered: '发送成功', queued: '排队中', submitted: '已提交', + submit_failed: '提交失败', unknown: '未知', - failed: '失败', + failed: '送达失败', rejected: '已拒绝', }; @@ -17,6 +18,7 @@ const statusToneMap: Record = delivered: 'success', queued: 'info', submitted: 'info', + submit_failed: 'danger', unknown: 'neutral', failed: 'danger', rejected: 'danger', @@ -26,6 +28,7 @@ const statusDotClassMap: Record = { delivered: 'is-success', queued: 'is-unknown', submitted: 'is-unknown', + submit_failed: 'is-failed', unknown: 'is-unknown', failed: 'is-failed', rejected: 'is-failed', @@ -85,6 +88,36 @@ function getStatusLabel(status?: string | null) { return status ? (statusLabelMap[status] ?? status) : '-'; } +function isSubmitFailure(record: SmsMessageRecord) { + return record.status === 'submit_failed' || ['rejected', 'timeout'].includes(record.submitStatus ?? ''); +} + +function getRecordStatus(record: SmsMessageRecord) { + return isSubmitFailure(record) ? 'submit_failed' : record.status; +} + +function getRecordStatusLabel(record: SmsMessageRecord) { + return getStatusLabel(getRecordStatus(record)); +} + +function getReceiptNotice(record: SmsMessageRecord) { + const hasPlatformFailureReceipt = (record.receiptRecords ?? []).some((receipt) => + receipt.gatewayMessageId.startsWith('PLATFORM:') && receipt.rawStatus === 'REJECTD', + ); + if (hasPlatformFailureReceipt) { + const deliveries = (record.downstreamDeliveries ?? []).filter((item) => item.deliveryType === 'receipt'); + if (deliveries.some((item) => item.status === 'delivered')) { + return '平台已生成失败回执并通知企业'; + } + const deliveryStatuses = Array.from(new Set(deliveries.map((item) => item.status))); + return `平台已生成失败回执,企业通知状态:${deliveryStatuses.join('、') || '待投递'}`; + } + if (!record.tenantId && !record.applicationId && record.messageId.startsWith('MSG-TEST-')) { + return '运营端通道测试,无需生成客户回执'; + } + return null; +} + function getCarrierLabel(carrier?: string | null) { return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-'; } @@ -121,7 +154,8 @@ function buildRouteRows(record: SmsMessageRecord): RouteRow[] { }]; } -function StatusLine({ status }: { status: string }) { +function StatusLine({ record }: { record: SmsMessageRecord }) { + const status = getRecordStatus(record); return ( @@ -149,7 +183,7 @@ function downloadCsv(records: SmsMessageRecord[]) { record.billingUnits, formatCents(record.amountCents), record.channel?.name ?? record.channelId ?? '', - getStatusLabel(record.status), + getRecordStatusLabel(record), getTime(record.deliveredAt), record.content, ]), @@ -176,6 +210,8 @@ function SendDetailModal({ }) { const routeRows = buildRouteRows(record); const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`; + const displayStatus = getRecordStatus(record); + const receiptNotice = getReceiptNotice(record); return ( 关闭} @@ -188,7 +224,7 @@ function SendDetailModal({
最终状态 - {getStatusLabel(record.status)} + {getRecordStatusLabel(record)}
提交状态 @@ -219,6 +255,12 @@ function SendDetailModal({ {sentAccessNumber || '-'}
+ {receiptNotice ? ( +
+ + {receiptNotice} +
+ ) : null}

短信内容

{record.content}

@@ -260,7 +302,7 @@ function SendDetailModal({

状态信息

消息编号{record.messageId}
-
发送状态{getStatusLabel(record.status)}
+
发送状态{getRecordStatusLabel(record)}
提交状态{record.submitStatus ?? '-'}
回执状态{record.receiptStatus ?? '-'}
@@ -377,7 +419,11 @@ export function AdminSmsRecordsPage() { const enterpriseOptions = useMemo(() => { const tenants = new Map(); - records.forEach((record) => tenants.set(record.tenantId, record.tenant?.name ?? record.tenantId)); + records.forEach((record) => { + if (record.tenantId) { + tenants.set(record.tenantId, record.tenant?.name ?? record.tenantId); + } + }); return [{ label: '全部企业', value: 'all' }, ...Array.from(tenants, ([value, label]) => ({ label, value }))]; }, [records]); @@ -441,7 +487,8 @@ export function AdminSmsRecordsPage() { { label: '全部', value: 'all' }, { label: '发送成功', value: 'delivered' }, { label: '未知', value: 'unknown' }, - { label: '失败', value: 'failed' }, + { label: '提交失败', value: 'submit_failed' }, + { label: '送达失败', value: 'failed' }, ]} value={status} /> @@ -460,10 +507,10 @@ export function AdminSmsRecordsPage() {
- {record.tenant?.name ?? record.tenantId} + {record.tenant?.name ?? record.tenantId ?? '运营端通道测试'} {record.application?.name ?? record.applicationId ?? '-'}
- +

{record.content}

diff --git a/src/styles/global.css b/src/styles/global.css index 8a1e739..7094d9b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -5342,7 +5342,7 @@ h3 { } .admin-signature-rank-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); } .admin-workload-grid { @@ -6661,8 +6661,8 @@ h3 { .sms-channel-table__row { display: grid; gap: var(--space-3); - grid-template-columns: minmax(150px, 1.2fr) 88px 86px 96px minmax(210px, 1.35fr) 190px; - min-width: 860px; + grid-template-columns: minmax(150px, 1.2fr) 88px 86px 96px minmax(280px, 1.6fr) 190px; + min-width: 940px; } .sms-channel-table__head { @@ -6740,7 +6740,7 @@ h3 { border: 1px solid var(--color-border); border-radius: var(--radius-md); display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); padding: var(--space-3); } @@ -9293,6 +9293,21 @@ h3 { overflow-wrap: anywhere; } +.admin-sms-detail-notice { + align-items: center; + background: var(--color-selected-soft); + border: 1px solid color-mix(in srgb, var(--color-selected) 28%, transparent); + border-radius: var(--radius-md); + color: var(--color-selected); + display: flex; + gap: var(--space-3); + padding: var(--space-4); +} + +.admin-sms-detail-notice strong { + color: var(--color-text-strong); +} + .admin-sms-send-detail h3 { color: var(--color-text-strong); font-size: var(--font-size-md);