feat: optimize routing and operations views

This commit is contained in:
hectorzhao
2026-07-29 22:32:28 +08:00
parent 500f43f673
commit c0a4317a7e
21 changed files with 718 additions and 86 deletions
@@ -39,6 +39,7 @@ export class AdminOperationsController {
@Query('phoneNumber') phoneNumber?: string,
@Query('contentKeyword') contentKeyword?: string,
@Query('channelKeyword') channelKeyword?: string,
@Query('carrier') carrier?: string,
@Query('queuedAtFrom') queuedAtFrom?: string,
@Query('queuedAtTo') queuedAtTo?: string,
@Query('status') status?: string,
@@ -54,6 +55,7 @@ export class AdminOperationsController {
phoneNumber,
contentKeyword,
channelKeyword,
carrier,
queuedAtFrom,
queuedAtTo,
status,
@@ -70,6 +72,7 @@ export class AdminOperationsController {
@Query('phoneNumber') phoneNumber: string | undefined,
@Query('contentKeyword') contentKeyword: string | undefined,
@Query('channelKeyword') channelKeyword: string | undefined,
@Query('carrier') carrier: string | undefined,
@Query('queuedAtFrom') queuedAtFrom: string | undefined,
@Query('queuedAtTo') queuedAtTo: string | undefined,
@Query('status') status: string | undefined,
@@ -82,6 +85,7 @@ export class AdminOperationsController {
phoneNumber,
contentKeyword,
channelKeyword,
carrier,
queuedAtFrom,
queuedAtTo,
status,
+49 -2
View File
@@ -189,7 +189,7 @@ describe('OperationsService', () => {
}));
});
it('filters send-chain messages by tenant, application, channel, content, date, task, phone, and status', async () => {
it('filters send-chain messages by tenant, application, channel, carrier, content, date, task, phone, and status', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
@@ -202,6 +202,7 @@ describe('OperationsService', () => {
messageId: 'MSG-1',
phoneNumber: '13800000001',
contentKeyword: '验证码',
carrier: 'mobile',
queuedAtFrom: '2026-07-01',
queuedAtTo: '2026-07-02',
status: 'delivered',
@@ -215,6 +216,7 @@ describe('OperationsService', () => {
batchTaskId: 'task-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
carrier: { in: ['mobile', 'cmcc', '移动', '中国移动'] },
status: 'delivered',
content: { contains: '验证码', mode: 'insensitive' },
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
@@ -238,6 +240,34 @@ describe('OperationsService', () => {
});
});
it('treats null and nonstandard carrier values as unrecognized', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await service.listMessages({ carrier: 'unknown' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
AND: [
{
OR: [
{ carrier: null },
{
carrier: {
notIn: [
'mobile', 'cmcc', '移动', '中国移动',
'unicom', 'cucc', '联通', '中国联通',
'telecom', 'ctcc', '电信', '中国电信',
],
},
},
],
},
],
}),
}));
});
it('separates upstream submit failures from post-acceptance delivery failures', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
@@ -369,7 +399,13 @@ describe('OperationsService', () => {
todaySpendCents: 24000n,
balanceCents: 1000000n,
creditCents: 50000n,
}]);
}]).mockResolvedValueOnce([
{ hour: 9, submittedCount: 12n, successCount: 10n },
{ hour: 10, submittedCount: 5n, successCount: 4n },
]).mockResolvedValueOnce([
{ category: 'templates', count: 3n, averageProcessingMs: 90_000n },
{ category: 'signatures', count: 2n, averageProcessingMs: 120_000n },
]);
prisma.cmppDownstreamDelivery.count = jest.fn()
.mockResolvedValueOnce(3)
.mockResolvedValueOnce(2)
@@ -394,6 +430,17 @@ describe('OperationsService', () => {
total: 5,
},
today: expect.objectContaining({ returnedCents: 10 }),
hourlySendTrend: expect.arrayContaining([
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
]),
auditProcessingSpeed: [
{ category: 'enterpriseCertifications', label: '企业认证', count: 0, averageProcessingMs: null },
{ category: 'smsAudits', label: '短信审核', count: 0, averageProcessingMs: null },
{ category: 'templates', label: '模板', count: 3, averageProcessingMs: 90000 },
{ category: 'signatures', label: '签名', count: 2, averageProcessingMs: 120000 },
{ category: 'drainageInfos', label: '引流信息', count: 0, averageProcessingMs: null },
],
enterpriseSpendRanks: [{
tenantId: 'tenant-1',
tenantName: '租户A',
+158 -8
View File
@@ -13,6 +13,7 @@ export interface MessageQuery {
messageId?: string;
phoneNumber?: string;
contentKeyword?: string;
carrier?: string;
status?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
@@ -321,11 +322,14 @@ export class OperationsService {
}
async dashboard(query: { tenantId?: string }) {
const sinceToday = startOfToday();
const businessDay = qualityBusinessDay();
const sinceToday = businessDay.startAt;
const downstreamAlertWindow = downstreamAlertWindows();
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
const todayMessageWhereClause = {
...messageWhereClause,
queuedAt: { gte: sinceToday, lt: businessDay.endAt },
};
const [
taskCount,
messageGroups,
@@ -345,6 +349,8 @@ export class OperationsService {
downstreamStalledPendingCount,
downstreamStalledAckCount,
downstreamRecentFailedCount,
hourlySendRows,
auditSpeedRows,
] = await Promise.all([
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
this.prisma.smsMessageRecord.groupBy({
@@ -451,8 +457,124 @@ export class OperationsService {
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
},
}),
this.prisma.$queryRaw<Array<{
hour: number;
submittedCount: bigint;
successCount: bigint;
}>>(Prisma.sql`
SELECT
EXTRACT(HOUR FROM message."queuedAt" AT TIME ZONE 'Asia/Shanghai')::integer AS hour,
COUNT(*)::bigint AS "submittedCount",
COUNT(*) FILTER (WHERE message.status = 'delivered')::bigint AS "successCount"
FROM "SmsMessageRecord" message
WHERE message."queuedAt" >= ${businessDay.startAt}
AND message."queuedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
GROUP BY 1
ORDER BY 1
`),
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
this.prisma.$queryRaw<Array<{
category: string;
count: bigint;
averageProcessingMs: bigint | null;
}>>(Prisma.sql`
WITH review_samples AS (
SELECT
'enterpriseCertifications'::text AS category,
certification."submittedAt" AS "submittedAt",
certification."reviewedAt" AS "reviewedAt"
FROM "EnterpriseCertification" certification
WHERE certification."reviewedAt" >= ${businessDay.startAt}
AND certification."reviewedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR certification."tenantId" = ${query.tenantId ?? null})
UNION ALL
SELECT
'smsAudits'::text,
task."createdAt",
task."reviewedAt"
FROM "SmsSendTask" task
WHERE task."reviewedAt" >= ${businessDay.startAt}
AND task."reviewedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR task."tenantId" = ${query.tenantId ?? null})
UNION ALL
SELECT
'drainageInfos'::text,
drainage."submittedAt",
drainage."reviewedAt"
FROM "SmsDrainageInfo" drainage
WHERE drainage."reviewedAt" >= ${businessDay.startAt}
AND drainage."reviewedAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR drainage."tenantId" = ${query.tenantId ?? null})
UNION ALL
SELECT
CASE review."targetType"
WHEN 'sms_signature' THEN 'signatures'
WHEN 'sms_template' THEN 'templates'
END,
submission."createdAt",
review."createdAt"
FROM "AuditRecord" review
JOIN LATERAL (
SELECT pending."createdAt"
FROM "AuditRecord" pending
WHERE pending."targetType" = review."targetType"
AND pending."targetId" = review."targetId"
AND pending."statusAfter" = 'pending'
AND pending."createdAt" <= review."createdAt"
ORDER BY pending."createdAt" DESC
LIMIT 1
) submission ON true
WHERE review."targetType" IN ('sms_signature', 'sms_template')
AND review."statusBefore" = 'pending'
AND review."statusAfter" IN ('approved', 'rejected')
AND review."createdAt" >= ${businessDay.startAt}
AND review."createdAt" < ${businessDay.endAt}
AND (${query.tenantId ?? null}::text IS NULL OR review."tenantId" = ${query.tenantId ?? null})
)
SELECT
category,
COUNT(*)::bigint AS count,
ROUND(AVG(EXTRACT(EPOCH FROM ("reviewedAt" - "submittedAt")) * 1000))::bigint AS "averageProcessingMs"
FROM review_samples
WHERE "reviewedAt" >= "submittedAt"
GROUP BY category
`),
]);
const todayTotals = summarizeMessageGroups(todayMessageGroups);
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
const row = hourlyRowsByHour.get(hour);
return {
hour,
label: `${String(hour).padStart(2, '0')}:00`,
submittedCount: Number(row?.submittedCount ?? 0),
successCount: Number(row?.successCount ?? 0),
};
});
const auditSpeedByCategory = new Map(auditSpeedRows.map((row) => [row.category, row]));
const auditProcessingSpeed = [
['enterpriseCertifications', '企业认证'],
['smsAudits', '短信审核'],
['templates', '模板'],
['signatures', '签名'],
['drainageInfos', '引流信息'],
].map(([category, label]) => {
const row = auditSpeedByCategory.get(category);
return {
category,
label,
count: Number(row?.count ?? 0),
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
};
});
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
return {
taskCount,
@@ -473,6 +595,8 @@ export class OperationsService {
gatewayConnections: connectionGroups,
pendingAuditCount: pendingAudits.total,
pendingAudits,
hourlySendTrend,
auditProcessingSpeed,
downstreamDeliverySummary: {
pending: downstreamPendingCount,
failed: downstreamFailedCount,
@@ -507,6 +631,8 @@ export class OperationsService {
gatewayConnections: [],
pendingAuditCount: dashboard.pendingAuditCount,
pendingAudits: dashboard.pendingAudits,
hourlySendTrend: dashboard.hourlySendTrend,
auditProcessingSpeed: dashboard.auditProcessingSpeed,
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
accounts: dashboard.accounts.map(clientAccountView),
enterpriseSpendRanks: dashboard.enterpriseSpendRanks,
@@ -1678,6 +1804,7 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
batchTaskId: query.taskId,
messageId: query.messageId,
phoneNumber: query.phoneNumber,
...carrierWhere(query.carrier),
...statusWhere,
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
@@ -1690,6 +1817,35 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
};
}
const recognizedCarrierValues = [
'mobile', 'cmcc', '移动', '中国移动',
'unicom', 'cucc', '联通', '中国联通',
'telecom', 'ctcc', '电信', '中国电信',
];
function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
if (!carrier) return {};
// Keep historical aliases queryable while treating null and future/nonstandard values as unrecognized.
if (carrier === 'unknown') {
return {
AND: [
{
OR: [
{ carrier: null },
{ carrier: { notIn: recognizedCarrierValues } },
],
},
],
};
}
const valuesByCarrier: Record<string, string[]> = {
mobile: ['mobile', 'cmcc', '移动', '中国移动'],
unicom: ['unicom', 'cucc', '联通', '中国联通'],
telecom: ['telecom', 'ctcc', '电信', '中国电信'],
};
return valuesByCarrier[carrier] ? { carrier: { in: valuesByCarrier[carrier] } } : {};
}
function startOfShanghaiDay(value: string) {
return new Date(`${value}T00:00:00+08:00`);
}
@@ -1735,12 +1891,6 @@ function normalizeGroupBy(groupBy?: string) {
return 'channelId';
}
function startOfToday() {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput {
return {
tenantId,