From 5bcdbb2a03637b1ab1aeda59a4e1db9cc9fc4243 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Wed, 9 Sep 2026 23:15:42 +0800 Subject: [PATCH] fix: correct operational statistics and form interactions --- api/src/channels/channel-reporting.service.ts | 5 +- api/src/channels/channels.helpers.ts | 182 ++- api/src/channels/channels.service.spec.ts | 1101 ++++++++++++----- .../alert-history.spec.ts | 45 + .../alert-history.ts | 68 + .../infrastructure-monitoring.controller.ts | 29 +- .../infrastructure-monitoring.service.ts | 338 +++-- api/src/operations/operations.service.spec.ts | 2 +- .../operations/queries/dashboard.queries.ts | 85 +- .../sending-monitor/alert-read-filter.spec.ts | 27 + .../sending-monitor/sending-monitor.module.ts | 10 +- .../first-version-development-requirements.md | 5 + docs/operations-fixes-20260909.md | 17 + ...theus-system-monitoring-design-20260814.md | 7 + docs/system-functional-test-cases.md | 17 + docs/testing-progress.md | 18 + docs/ui-design-guidelines.md | 5 + .../admin/infrastructure-monitoring.api.ts | 18 + src/apps/admin/AdminAnalyticsPage.css | 17 + src/apps/admin/AdminAnalyticsPage.test.tsx | 41 + src/apps/admin/AdminAnalyticsPage.tsx | 46 +- .../admin/AdminSignatureRetirementPage.tsx | 25 +- .../AdminSmsApplicationFormPage.test.tsx | 46 + .../admin/AdminSmsApplicationFormPage.tsx | 533 ++++++-- src/apps/admin/channels/ChannelFormModal.tsx | 4 +- .../admin/sending-monitor/MonitorDetails.tsx | 45 +- src/apps/admin/sending-monitor/monitorApi.ts | 4 +- .../admin/sms-records/SendDetailModal.tsx | 201 ++- .../AdminSystemMonitoringPage.tsx | 649 ++++++++-- .../system-monitoring/AlertHistory.test.tsx | 21 + .../admin/system-monitoring/AlertHistory.tsx | 97 ++ src/components/ui/Modal.close.test.tsx | 28 +- src/components/ui/Modal.tsx | 13 +- 33 files changed, 2920 insertions(+), 829 deletions(-) create mode 100644 api/src/infrastructure-monitoring/alert-history.spec.ts create mode 100644 api/src/infrastructure-monitoring/alert-history.ts create mode 100644 api/src/sending-monitor/alert-read-filter.spec.ts create mode 100644 docs/operations-fixes-20260909.md create mode 100644 src/apps/admin/AdminSmsApplicationFormPage.test.tsx create mode 100644 src/apps/admin/system-monitoring/AlertHistory.test.tsx create mode 100644 src/apps/admin/system-monitoring/AlertHistory.tsx diff --git a/api/src/channels/channel-reporting.service.ts b/api/src/channels/channel-reporting.service.ts index a991b5b..a1dad2a 100644 --- a/api/src/channels/channel-reporting.service.ts +++ b/api/src/channels/channel-reporting.service.ts @@ -194,6 +194,7 @@ export class ChannelReportingService { SELECT submit."channelId" AS channel_id, message."signatureId" AS signature_id, + message.carrier AS carrier, message."drainageInfoId" AS drainage_info_id, submit."submitStatus" AS submit_status, COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at, @@ -244,6 +245,7 @@ export class ChannelReportingService { SELECT channel_id AS "channelId", signature_id AS "signatureId", + carrier, drainage_info_id AS "drainageInfoId", COUNT(*) FILTER ( WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt} @@ -270,7 +272,7 @@ export class ChannelReportingService { )::integer AS "failureCount", MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt" FROM base - GROUP BY channel_id, signature_id, drainage_info_id + GROUP BY channel_id, signature_id, drainage_info_id, carrier `); return tasks.map((task) => { @@ -278,6 +280,7 @@ export class ChannelReportingService { (row) => row.channelId === task.channelId && row.signatureId === task.signatureId && + (!task.carrier || row.carrier === task.carrier) && ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId), ); const deliveryStats = summarizeChannelReportDelivery(taskRows); diff --git a/api/src/channels/channels.helpers.ts b/api/src/channels/channels.helpers.ts index 248f114..ab1e90f 100644 --- a/api/src/channels/channels.helpers.ts +++ b/api/src/channels/channels.helpers.ts @@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomUUID } from 'crypto'; import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status'; -import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +import type { CreateChannelGroupItemDto, TestChannelDto } from './channels.contracts'; export function summarizeReportStatuses(statuses: string[]) { return summarizeCommonReportStatuses(statuses); @@ -141,7 +141,11 @@ export function buildChannelTestSubmitCommand({ account: channel.account, passwordCipher: channel.passwordCipher, cmppVersion: channel.cmppVersion, - desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'), + desiredConnections: getPositiveRuntimeInteger( + getConfigValue(channel.config, 'desiredConnections'), + 1, + 'desiredConnections', + ), windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'), heartbeatIntervalSeconds: getPositiveRuntimeInteger( getConfigValue(channel.config, 'heartbeatIntervalSeconds'), @@ -254,23 +258,22 @@ export function getRuntimeConfigInteger( return Number.isInteger(value) && value > 0 ? value : fallback; } -export function channelConnectionSettingsChanged( - before: ChannelConnectionSettings, - after: ChannelConnectionSettings, -) { - return before.gatewayHost !== after.gatewayHost - || before.gatewayPort !== after.gatewayPort - || before.account !== after.account - || before.passwordCipher !== after.passwordCipher - || before.cmppVersion !== after.cmppVersion - || getRuntimeConfigInteger(before.config, 'desiredConnections', 1) - !== getRuntimeConfigInteger(after.config, 'desiredConnections', 1) - || getRuntimeConfigInteger(before.config, 'windowSize', 16) - !== getRuntimeConfigInteger(after.config, 'windowSize', 16) - || getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) - !== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) - || getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) - !== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD); +export function channelConnectionSettingsChanged(before: ChannelConnectionSettings, after: ChannelConnectionSettings) { + return ( + before.gatewayHost !== after.gatewayHost || + before.gatewayPort !== after.gatewayPort || + before.account !== after.account || + before.passwordCipher !== after.passwordCipher || + before.cmppVersion !== after.cmppVersion || + getRuntimeConfigInteger(before.config, 'desiredConnections', 1) !== + getRuntimeConfigInteger(after.config, 'desiredConnections', 1) || + getRuntimeConfigInteger(before.config, 'windowSize', 16) !== + getRuntimeConfigInteger(after.config, 'windowSize', 16) || + getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) !== + getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) || + getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) !== + getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) + ); } export function channelGroupAuditSnapshot(group: { @@ -320,19 +323,49 @@ export function normalizeChannelRuntimeConfig( heartbeatIntervalSeconds?: number, heartbeatMissThreshold?: number, ) { - const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig) - ? existingConfig as Record - : {}; - const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) - ? incomingConfig - : {}; + const existing = + existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig) + ? (existingConfig as Record) + : {}; + const incoming = + incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) ? incomingConfig : {}; const base = { ...existing, ...incoming }; - base.desiredConnections = boundedRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 8, 1, 'desiredConnections'); + base.desiredConnections = boundedRuntimeInteger( + desiredConnections ?? base.desiredConnections, + 1, + 8, + 1, + 'desiredConnections', + ); base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize'); - base.connectionWarmupSeconds = boundedRuntimeInteger(base.connectionWarmupSeconds, 0, 300, 30, 'connectionWarmupSeconds'); - base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(base.connectionDrainTimeoutSeconds, 1, 600, 60, 'connectionDrainTimeoutSeconds'); - base.submitResponseTimeoutSeconds = boundedRuntimeInteger(base.submitResponseTimeoutSeconds, 1, 300, 60, 'submitResponseTimeoutSeconds'); - base.connectionFailureCooldownSeconds = boundedRuntimeInteger(base.connectionFailureCooldownSeconds, 1, 300, 30, 'connectionFailureCooldownSeconds'); + base.connectionWarmupSeconds = boundedRuntimeInteger( + base.connectionWarmupSeconds, + 0, + 300, + 30, + 'connectionWarmupSeconds', + ); + base.connectionDrainTimeoutSeconds = boundedRuntimeInteger( + base.connectionDrainTimeoutSeconds, + 1, + 600, + 60, + 'connectionDrainTimeoutSeconds', + ); + base.submitResponseTimeoutSeconds = boundedRuntimeInteger( + base.submitResponseTimeoutSeconds, + 1, + 300, + 60, + 'submitResponseTimeoutSeconds', + ); + base.connectionFailureCooldownSeconds = boundedRuntimeInteger( + base.connectionFailureCooldownSeconds, + 1, + 300, + 30, + 'connectionFailureCooldownSeconds', + ); base.heartbeatIntervalSeconds = getPositiveRuntimeInteger( heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, @@ -423,13 +456,19 @@ export function getPositiveIntegerEnv(name: string, fallback: number) { } export function parseReceiptContent(content: string, delimiter?: ',' | '\t') { - const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const lines = content + .replace(/^\uFEFF/, '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); if (lines.length === 0) { throw new BadRequestException('Receipt file is empty'); } const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ','); const firstCells = splitReceiptLine(lines[0], separator); - const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase())); + const hasHeader = firstCells.some((cell) => + ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()), + ); const header = hasHeader ? firstCells : []; const rows = hasHeader ? lines.slice(1) : lines; const statusIndex = findReceiptStatusIndex(header); @@ -445,7 +484,7 @@ export function parseReceiptContent(content: string, delimiter?: ',' | '\t') { failedCount += 1; } return { - rowNumber: (hasHeader ? index + 2 : index + 1), + rowNumber: hasHeader ? index + 2 : index + 1, phone: cells[0] ?? '', status: normalizedStatus, rawStatus, @@ -504,10 +543,39 @@ export function findReceiptStatusIndex(header: string[]) { export function normalizeReceiptStatus(value: string) { const normalized = value.trim().toLowerCase(); - if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) { + if ( + [ + 'success', + 'succeeded', + 'approved', + 'completed', + 'ok', + 'pass', + 'passed', + '通过', + '成功', + '已完成', + '报备成功', + ].includes(normalized) + ) { return 'success'; } - if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) { + if ( + [ + 'failed', + 'fail', + 'rejected', + 'reject', + 'error', + 'no', + 'denied', + '驳回', + '失败', + '不通过', + '拒绝', + '报备失败', + ].includes(normalized) + ) { return 'failed'; } return 'failed'; @@ -524,6 +592,7 @@ export function deriveReceiptStatus(rowCount: number, successCount: number, fail } export type ChannelReportDeliveryRow = { + carrier: string | null; channelId: string; signatureId: string; drainageInfoId: string | null; @@ -557,10 +626,13 @@ export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) }; } -export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick< - ChannelReportDeliveryRow, - 'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount' ->) { +export function sumReportDelivery( + rows: ChannelReportDeliveryRow[], + key: keyof Pick< + ChannelReportDeliveryRow, + 'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount' + >, +) { return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0); } @@ -580,7 +652,11 @@ export function currentShanghaiDayRange(now = new Date()) { return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) }; } -export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) { +export function normalizeRetryTimeLimitMinutes( + minutes: number | undefined, + hours: number | undefined, + fallbackMinutes: number, +) { const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60); if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) { throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320'); @@ -588,7 +664,12 @@ export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hour return value; } -export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) { +export function normalizeSpreadsheetSize( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number, +) { if (value === undefined || !Number.isFinite(value)) return fallback; return Math.min(maximum, Math.max(minimum, Math.round(value))); } @@ -602,7 +683,9 @@ export function normalizeBusinessCarrier(carrier?: string | null) { } export function normalizeChannelCarrier(carrier?: string | null) { - const value = String(carrier ?? '').trim().toLowerCase(); + const value = String(carrier ?? '') + .trim() + .toLowerCase(); if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile'; if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom'; if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom'; @@ -631,12 +714,18 @@ export function legacyCarrierFromCapabilities(carriers: string[]) { return 'multi'; } -export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string, carriers?: string[] | null) { +export function isChannelCarrierCompatible( + channelCarrier: string | null | undefined, + groupCarrier: string, + carriers?: string[] | null, +) { return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier)); } export function normalizeRegion(region?: string | null) { - return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); + return String(region ?? '') + .replace(/省|市|自治区|壮族|回族|维吾尔/g, '') + .trim(); } export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) { @@ -646,7 +735,10 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite export function validateGroupItems( groupCarrier: string, items: Array>, - channels: Map, + channels: Map< + string, + { id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null } + >, ) { const channelIds = new Set(); const provinces = new Set(); diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index cb92bb0..85e59d4 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -20,15 +20,26 @@ jest.mock('bullmq', () => ({ })), })); -jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({ - xadd: mockRedisXadd, - set: mockRedisSet, - eval: mockRedisEval, - disconnect: mockRedisDisconnect, -}))); +jest.mock('ioredis', () => + jest.fn().mockImplementation(() => ({ + xadd: mockRedisXadd, + set: mockRedisSet, + eval: mockRedisEval, + disconnect: mockRedisDisconnect, + })), +); function createPrismaMock() { - const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', reportType: 'signature', status: 'pending' }; + const reportTask = { + id: 'report-task-1', + tenantId: 'tenant-1', + signatureId: 'sig-1', + channelId: 'channel-1', + carrier: 'mobile', + approvalScope: 'carrier_specific', + reportType: 'signature', + status: 'pending', + }; const channel = { id: 'channel-1', code: 'CMPP-A', @@ -49,30 +60,48 @@ function createPrismaMock() { config: { serviceId: 'SMS' }, sendRegion: '山东', connectionStates: [{ id: 'state-1', connectionId: 'conn-a', status: 'connected', currentConnections: 1 }], - reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }], + reportFields: [ + { + code: 'license', + name: '营业执照', + fieldType: 'file', + required: true, + description: null, + sortOrder: 1, + status: 'active', + }, + ], }; return { $queryRaw: jest.fn().mockResolvedValue([]), - $transaction: jest.fn((callback) => callback({ - smsChannel: { - create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })), - }, - smsChannelGroup: { - update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), - findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }), - }, - smsChannelGroupItem: { - deleteMany: jest.fn(), - createMany: jest.fn(), - }, - signatureReportMaterial: { - findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]), - createMany: jest.fn(), - }, - operationLog: { - create: jest.fn(), - }, - })), + $transaction: jest.fn((callback) => + callback({ + smsChannel: { + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })), + }, + smsChannelGroup: { + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), + findUnique: jest + .fn() + .mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }), + }, + smsChannelGroupItem: { + deleteMany: jest.fn(), + createMany: jest.fn(), + }, + signatureReportMaterial: { + findMany: jest + .fn() + .mockResolvedValue([ + { signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }, + ]), + createMany: jest.fn(), + }, + operationLog: { + create: jest.fn(), + }, + }), + ), smsChannel: { findMany: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })), @@ -82,7 +111,17 @@ function createPrismaMock() { channelHealthMetric: { findMany: jest.fn() }, smsChannelGroup: { findMany: jest.fn(), - findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] }), + findUnique: jest.fn().mockResolvedValue({ + id: 'group-1', + code: 'G-MOBILE', + name: '移动组', + carrier: 'mobile', + status: 'active', + retryEnabled: true, + retryTimeLimitHours: 72, + retryTimeLimitMinutes: 4320, + items: [], + }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }), }, @@ -103,14 +142,36 @@ function createPrismaMock() { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })), }, drainageField: { - findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }), - findMany: jest.fn().mockResolvedValue([{ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }]), + findUnique: jest.fn().mockResolvedValue({ + id: 'library-1', + code: 'license', + name: '营业执照', + fieldType: 'file', + required: true, + status: 'active', + description: '执照文件', + }), + findMany: jest.fn().mockResolvedValue([ + { + id: 'library-1', + code: 'license', + name: '营业执照', + fieldType: 'file', + required: true, + status: 'active', + description: '执照文件', + }, + ]), }, drainageDetectionRule: { findMany: jest.fn().mockResolvedValue([]), }, signatureReportMaterial: { - findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]), + findMany: jest + .fn() + .mockResolvedValue([ + { signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }, + ]), createMany: jest.fn(), upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })), }, @@ -168,12 +229,54 @@ function createPrismaMock() { }, operationLog: { create: jest.fn(), - findMany: jest.fn().mockResolvedValue([{ id: 'log-1', action: 'cmpp_connection.heartbeat', resourceId: 'channel-1:conn-a', detail: {}, createdAt: new Date() }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'log-1', + action: 'cmpp_connection.heartbeat', + resourceId: 'channel-1:conn-a', + detail: {}, + createdAt: new Date(), + }, + ]), }, }; } describe('ChannelsService', () => { + it('separates three-network totals without assigning unknown carrier traffic', async () => { + const prisma = createPrismaMock(); + prisma.channelSignatureReportTask.findMany.mockResolvedValue( + ['mobile', 'unicom', 'telecom'].map((carrier) => ({ + id: carrier, + channelId: 'c', + signatureId: 's', + reportType: 'signature', + carrier, + })), + ); + prisma.$queryRaw.mockResolvedValue( + ['mobile', 'unicom', 'telecom', null].map((carrier, i) => ({ + carrier, + channelId: 'c', + signatureId: 's', + drainageInfoId: null, + total: i + 1, + acceptedCount: i + 1, + successCount: i + 1, + failureCount: 0, + unknownCount: 0, + submitFailureCount: 0, + lastSuccessfulSentAt: null, + })), + ); + const rows = await new ChannelsService(prisma as never).listReportTasks(); + expect(rows).toEqual( + [1, 2, 3].map((total) => expect.objectContaining({ deliveryStats: expect.objectContaining({ total }) })), + ); + expect(prisma.$queryRaw.mock.calls[0][0].sql).toContain( + 'GROUP BY channel_id, signature_id, drainage_info_id, carrier', + ); + }); it('sorts all filtered channels by today submit count before pagination', async () => { const prisma = createPrismaMock(); const candidates = [ @@ -208,9 +311,7 @@ describe('ChannelsService', () => { { id: 'channel-a', name: 'A通道' }, { id: 'channel-c', name: 'B通道' }, ]; - prisma.smsChannel.findMany - .mockResolvedValueOnce(candidates) - .mockResolvedValueOnce(candidates); + prisma.smsChannel.findMany.mockResolvedValueOnce(candidates).mockResolvedValueOnce(candidates); prisma.$queryRaw.mockResolvedValue([]); const service = new ChannelsService(prisma as never); @@ -223,7 +324,14 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - await service.createReportField({ channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'signature', code: 'ignored', name: 'ignored', fieldType: 'string' }); + await service.createReportField({ + channelId: 'channel-1', + drainageFieldId: 'library-1', + reportType: 'signature', + code: 'ignored', + name: 'ignored', + fieldType: 'string', + }); expect(prisma.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -240,22 +348,66 @@ describe('ChannelsService', () => { it('replaces one report type while preserving legacy both fields for the opposite type', async () => { const prisma = createPrismaMock(); - const legacyBoth = { id: 'legacy-1', channelId: 'channel-1', drainageFieldId: 'library-1', reportType: 'both', code: 'license', name: '营业执照', exportName: '旧表头', fieldType: 'file', required: true, description: null, sortOrder: 10, columnWidth: 18, imageWidth: 120, imageHeight: 80, defaultValue: null, transform: null, status: 'active', createdAt: new Date(), updatedAt: new Date() }; + const legacyBoth = { + id: 'legacy-1', + channelId: 'channel-1', + drainageFieldId: 'library-1', + reportType: 'both', + code: 'license', + name: '营业执照', + exportName: '旧表头', + fieldType: 'file', + required: true, + description: null, + sortOrder: 10, + columnWidth: 18, + imageWidth: 120, + imageHeight: 80, + defaultValue: null, + transform: null, + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + }; const tx = { channelReportField: { - findMany: jest.fn().mockResolvedValueOnce([legacyBoth]).mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'signature-field' }]), + findMany: jest + .fn() + .mockResolvedValueOnce([legacyBoth]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'signature-field' }]), deleteMany: jest.fn(), - create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `created-${data.reportType}`, ...data })), + create: jest + .fn() + .mockImplementation(({ data }) => Promise.resolve({ id: `created-${data.reportType}`, ...data })), }, }; prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new ChannelsService(prisma as never); - await service.replaceReportFields('channel-1', 'signature', { fields: [{ drainageFieldId: 'library-1', exportName: '新签名表头', required: true }] }); + await service.replaceReportFields('channel-1', 'signature', { + fields: [{ drainageFieldId: 'library-1', exportName: '新签名表头', required: true }], + }); - expect(tx.channelReportField.deleteMany).toHaveBeenCalledWith({ where: { channelId: 'channel-1', reportType: { in: ['signature', 'both'] } } }); - expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'drainage', exportName: '旧表头' }) }); - expect(tx.channelReportField.create).toHaveBeenCalledWith({ data: expect.objectContaining({ channelId: 'channel-1', code: 'license', reportType: 'signature', exportName: '新签名表头' }) }); + expect(tx.channelReportField.deleteMany).toHaveBeenCalledWith({ + where: { channelId: 'channel-1', reportType: { in: ['signature', 'both'] } }, + }); + expect(tx.channelReportField.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + channelId: 'channel-1', + code: 'license', + reportType: 'drainage', + exportName: '旧表头', + }), + }); + expect(tx.channelReportField.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + channelId: 'channel-1', + code: 'license', + reportType: 'signature', + exportName: '新签名表头', + }), + }); }); it('lists report tasks for one real channel', async () => { @@ -290,7 +442,13 @@ describe('ChannelsService', () => { it('adds real today delivery statistics and the latest successful send time to report tasks', async () => { const prisma = createPrismaMock(); - const task = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }; + const task = { + id: 'report-task-1', + tenantId: 'tenant-1', + signatureId: 'sig-1', + channelId: 'channel-1', + status: 'approved', + }; const taskChannel = { id: 'channel-1', code: 'CMPP-A', name: '主通道' }; prisma.channelSignatureReportTask.findMany.mockResolvedValue([ { ...task, reportType: 'signature', drainageItemId: null, channel: taskChannel }, @@ -326,30 +484,34 @@ describe('ChannelsService', () => { const result = await service.listReportTasks(undefined, undefined, 'channel-1'); - expect(result[0]).toEqual(expect.objectContaining({ - deliveryStats: { - total: 8, - acceptedCount: 7, - submitFailureCount: 1, - submitFailureRate: 12.5, - successCount: 5, - successRate: 71.4, - unknownCount: 1, - unknownRate: 14.3, - failureCount: 1, - failureRate: 14.3, - }, - lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'), - })); - expect(result[1]).toEqual(expect.objectContaining({ - deliveryStats: expect.objectContaining({ - total: 3, - submitFailureCount: 0, - successCount: 3, - successRate: 100, + expect(result[0]).toEqual( + expect.objectContaining({ + deliveryStats: { + total: 8, + acceptedCount: 7, + submitFailureCount: 1, + submitFailureRate: 12.5, + successCount: 5, + successRate: 71.4, + unknownCount: 1, + unknownRate: 14.3, + failureCount: 1, + failureRate: 14.3, + }, + lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'), }), - lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'), - })); + ); + expect(result[1]).toEqual( + expect.objectContaining({ + deliveryStats: expect.objectContaining({ + total: 3, + submitFailureCount: 0, + successCount: 3, + successRate: 100, + }), + lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'), + }), + ); }); it.each(['enterprise_signature', 'report_task'] as const)( @@ -361,29 +523,78 @@ describe('ChannelsService', () => { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), }, - smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' }) }, - smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) }, + smsChannel: { + findUnique: jest + .fn() + .mockResolvedValue({ id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' }), + }, + smsDrainageInfo: { + findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }), + }, channelSignatureReportTask: { - findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'reporting' }), - update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved' }), + findFirst: jest.fn().mockResolvedValue({ + id: 'task-1', + signatureId: 'sig-1', + channelId: 'channel-1', + carrier: 'mobile', + approvalScope: 'carrier_specific', + status: 'reporting', + }), + update: jest.fn().mockResolvedValue({ + id: 'task-1', + signatureId: 'sig-1', + channelId: 'channel-1', + carrier: 'mobile', + approvalScope: 'carrier_specific', + status: 'approved', + }), create: jest.fn(), - findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' } }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'task-1', + channelId: 'channel-1', + carrier: 'mobile', + approvalScope: 'carrier_specific', + status: 'approved', + channel: { id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' }, + }, + ]), }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) }, - channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) }, + channelRouteRule: { + findMany: jest + .fn() + .mockResolvedValue([ + { group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }, + ]), + }, }; prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new ChannelsService(prisma as never); - await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }], reason: '运营商确认', sourceEntry })).resolves.toEqual([ - expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }), - ]); - expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry }) }); + await expect( + service.changeReportTaskStatuses({ + items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }], + reason: '运营商确认', + sourceEntry, + }), + ).resolves.toEqual([expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' })]); + expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'manual_status_change', + statusBefore: 'reporting', + statusAfter: 'approved', + sourceEntry, + }), + }); expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: expect.objectContaining({ status: 'approved', approvedAt: expect.any(Date) }), }); - expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } }); + expect(tx.smsSignature.update).toHaveBeenCalledWith({ + where: { id: 'sig-1' }, + data: { reportStatus: 'approved' }, + }); }, ); @@ -394,14 +605,28 @@ describe('ChannelsService', () => { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), }, - smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' }) }, + smsChannel: { + findUnique: jest.fn().mockResolvedValue({ + id: 'channel-1', + carrier: 'all', + carriers: ['mobile', 'unicom', 'telecom'], + status: 'active', + }), + }, smsDrainageInfo: { findUnique: jest.fn() }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), update: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'task-new', ...data })), findMany: jest.fn().mockResolvedValue([ - { id: 'task-new', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' } }, + { + id: 'task-new', + channelId: 'channel-1', + carrier: 'mobile', + approvalScope: 'carrier_specific', + status: 'approved', + channel: { id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' }, + }, ]), }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) }, @@ -435,9 +660,18 @@ describe('ChannelsService', () => { update: jest.fn(), }, smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) }, - smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) }, + smsDrainageInfo: { + findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }), + }, channelSignatureReportTask: { - findFirst: jest.fn().mockResolvedValue({ id: 'drainage-task-1', signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'reporting' }), + findFirst: jest.fn().mockResolvedValue({ + id: 'drainage-task-1', + signatureId: 'sig-1', + channelId: 'channel-1', + reportType: 'drainage', + drainageItemId: 'drain-1', + status: 'reporting', + }), update: jest.fn().mockResolvedValue({ id: 'drainage-task-1', status: 'approved' }), create: jest.fn(), }, @@ -446,12 +680,44 @@ describe('ChannelsService', () => { prisma.$transaction.mockImplementation((callback) => callback(tx)); const service = new ChannelsService(prisma as never); - await expect(service.changeReportTaskStatuses({ - items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }], - reason: '引流信息已报备', - })).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]); - expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', carrier: null } }); - expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) }); + await expect( + service.changeReportTaskStatuses({ + items: [ + { + signatureId: 'sig-1', + channelId: 'channel-1', + reportType: 'drainage', + drainageItemId: 'drain-1', + status: 'approved', + }, + ], + reason: '引流信息已报备', + }), + ).resolves.toEqual([ + { + signatureId: 'sig-1', + reportType: 'drainage', + drainageItemId: 'drain-1', + channelId: 'channel-1', + status: 'approved', + }, + ]); + expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ + where: { + signatureId: 'sig-1', + channelId: 'channel-1', + reportType: 'drainage', + drainageItemId: 'drain-1', + carrier: null, + }, + }); + expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'manual_status_change', + statusBefore: 'reporting', + statusAfter: 'approved', + }), + }); expect(tx.smsSignature.update).not.toHaveBeenCalled(); }); @@ -471,7 +737,9 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - await expect(service.createChannel({ name: '缺字段通道' } as never)).rejects.toThrow('Missing required channel fields'); + await expect(service.createChannel({ name: '缺字段通道' } as never)).rejects.toThrow( + 'Missing required channel fields', + ); expect(prisma.smsChannel.create).not.toHaveBeenCalled(); }); @@ -481,22 +749,30 @@ describe('ChannelsService', () => { prisma.smsChannel.findMany.mockResolvedValue([activeChannel]); const service = new ChannelsService(prisma as never); - await (service as unknown as { reconnectActiveChannelsAfterGatewayRestart(): Promise }) - .reconnectActiveChannelsAfterGatewayRestart(); + await ( + service as unknown as { reconnectActiveChannelsAfterGatewayRestart(): Promise } + ).reconnectActiveChannelsAfterGatewayRestart(); expect(prisma.smsChannel.findMany).toHaveBeenCalledWith({ where: { status: 'active' } }); - expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({ - channelId: 'channel-1', - reason: 'gateway_restarted', - channel: expect.objectContaining({ rateLimitPerSecond: 100 }), - }), expect.objectContaining({ - jobId: expect.stringMatching(/^gateway-connect-channel-1-/), - removeOnComplete: 1000, - removeOnFail: 1000, - })); - expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ - body: expect.stringContaining('"reason":"gateway_restarted"'), - })); + expect(mockQueueAdd).toHaveBeenCalledWith( + 'connect-channel', + expect.objectContaining({ + channelId: 'channel-1', + reason: 'gateway_restarted', + channel: expect.objectContaining({ rateLimitPerSecond: 100 }), + }), + expect.objectContaining({ + jobId: expect.stringMatching(/^gateway-connect-channel-1-/), + removeOnComplete: 1000, + removeOnFail: 1000, + }), + ); + expect(mockFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:8090/connections/connect', + expect.objectContaining({ + body: expect.stringContaining('"reason":"gateway_restarted"'), + }), + ); }); it('uses the direct Gateway control path when the Redis marker queue is unavailable', async () => { @@ -504,19 +780,24 @@ describe('ChannelsService', () => { mockQueueAdd.mockRejectedValueOnce(new Error('redis unavailable')); const service = new ChannelsService(prisma as never); - await expect(service.createChannel({ - code: 'CMPP-DIRECT', - name: '直连控制测试', - gatewayHost: '127.0.0.1', - gatewayPort: 17890, - account: 'sp', - passwordCipher: 'secret', - srcId: '10690000', - status: 'active', - })).resolves.toEqual(expect.objectContaining({ id: 'channel-1' })); - expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ - method: 'POST', - })); + await expect( + service.createChannel({ + code: 'CMPP-DIRECT', + name: '直连控制测试', + gatewayHost: '127.0.0.1', + gatewayPort: 17890, + account: 'sp', + passwordCipher: 'secret', + srcId: '10690000', + status: 'active', + }), + ).resolves.toEqual(expect.objectContaining({ id: 'channel-1' })); + expect(mockFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:8090/connections/connect', + expect.objectContaining({ + method: 'POST', + }), + ); }); it('reuses a supplier state created concurrently by another API instance', async () => { @@ -527,16 +808,18 @@ describe('ChannelsService', () => { prisma.cmppConnectionState.create.mockRejectedValueOnce({ code: 'P2002' }); const service = new ChannelsService(prisma as never); - await expect(service.createChannel({ - code: 'CMPP-CONCURRENT', - name: '并发状态测试', - gatewayHost: '127.0.0.1', - gatewayPort: 17890, - account: 'sp', - passwordCipher: 'secret', - srcId: '10690000', - status: 'active', - })).resolves.toEqual(expect.objectContaining({ id: 'channel-1' })); + await expect( + service.createChannel({ + code: 'CMPP-CONCURRENT', + name: '并发状态测试', + gatewayHost: '127.0.0.1', + gatewayPort: 17890, + account: 'sp', + passwordCipher: 'secret', + srcId: '10690000', + status: 'active', + }), + ).resolves.toEqual(expect.objectContaining({ id: 'channel-1' })); expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({ where: { id: 'state-concurrent' }, data: expect.objectContaining({ status: 'connecting' }), @@ -560,8 +843,19 @@ describe('ChannelsService', () => { rateLimitPerSecond: 750, config: { extensionDigits: 4 }, }); - await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 }); - await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' }); + await service.createGroup({ + code: 'G-MOBILE', + name: '移动组', + carrier: 'mobile', + retryEnabled: true, + retryTimeLimitMinutes: 750, + }); + await service.createRouteRule({ + tenantId: 'tenant-1', + applicationId: 'app-1', + groupId: 'group-1', + carrier: 'mobile', + }); expect(prisma.smsChannel.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -570,7 +864,12 @@ describe('ChannelsService', () => { rateLimitPerSecond: 750, sendRegion: '全国', status: 'active', - config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4, serviceId: 'SMS' }), + config: expect.objectContaining({ + desiredConnections: 2, + windowSize: 32, + extensionDigits: 4, + serviceId: 'SMS', + }), }), }); expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({ @@ -582,23 +881,35 @@ describe('ChannelsService', () => { currentConnections: 0, }), }); - expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({ - messageType: 'ConnectChannel', - channelId: 'channel-1', - connectionId: 'channel-1:primary', - reason: 'channel_created', - channel: expect.objectContaining({ cmppVersion: '2.0' }), - }), expect.objectContaining({ - jobId: expect.stringMatching(/^gateway-connect-channel-1-/), - removeOnComplete: 1000, - removeOnFail: 1000, - })); - expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('"messageType":"ConnectChannel"'), - })); + expect(mockQueueAdd).toHaveBeenCalledWith( + 'connect-channel', + expect.objectContaining({ + messageType: 'ConnectChannel', + channelId: 'channel-1', + connectionId: 'channel-1:primary', + reason: 'channel_created', + channel: expect.objectContaining({ cmppVersion: '2.0' }), + }), + expect.objectContaining({ + jobId: expect.stringMatching(/^gateway-connect-channel-1-/), + removeOnComplete: 1000, + removeOnFail: 1000, + }), + ); + expect(mockFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:8090/connections/connect', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"messageType":"ConnectChannel"'), + }), + ); expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }), + data: expect.objectContaining({ + carrier: 'mobile', + retryEnabled: true, + retryTimeLimitHours: 13, + retryTimeLimitMinutes: 750, + }), }); expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -654,16 +965,18 @@ describe('ChannelsService', () => { expect(prisma.smsChannel.create).toHaveBeenCalledWith({ data: expect.objectContaining({ cmppVersion: '3.0' }), }); - await expect(service.createChannel({ - code: 'BAD', - name: '非法版本', - gatewayHost: '127.0.0.1', - gatewayPort: 17890, - account: 'sp', - passwordCipher: 'secret', - srcId: '10690000', - cmppVersion: '1.0', - })).rejects.toThrow('cmppVersion must be 2.0 or 3.0'); + await expect( + service.createChannel({ + code: 'BAD', + name: '非法版本', + gatewayHost: '127.0.0.1', + gatewayPort: 17890, + account: 'sp', + passwordCipher: 'secret', + srcId: '10690000', + cmppVersion: '1.0', + }), + ).rejects.toThrow('cmppVersion must be 2.0 or 3.0'); }); it('rejects invalid channel rate limits and extension digit counts', async () => { @@ -679,34 +992,46 @@ describe('ChannelsService', () => { srcId: '10690000', }; - await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000'); - await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow('extensionDigits must be an integer between 0 and 20'); - await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow('serviceId must contain 1 to 10 ASCII characters'); - await expect(service.createChannel({ ...channel, config: { longMessageReceiptMode: 'unknown' } })).rejects.toThrow('longMessageReceiptMode must be per_segment or message_level'); + await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow( + 'rateLimitPerSecond must be between 1 and 2000', + ); + await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow( + 'extensionDigits must be an integer between 0 and 20', + ); + await expect(service.createChannel({ ...channel, config: { serviceId: '业务代码' } })).rejects.toThrow( + 'serviceId must contain 1 to 10 ASCII characters', + ); + await expect(service.createChannel({ ...channel, config: { longMessageReceiptMode: 'unknown' } })).rejects.toThrow( + 'longMessageReceiptMode must be per_segment or message_level', + ); }); it('updates CMPP channel configuration without requiring password changes', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - await expect(service.updateChannel('channel-1', { - name: '主通道-编辑', - gatewayHost: '10.0.0.1', - gatewayPort: 27890, - carrier: 'all', - sendRegion: '全国', - account: 'sp-new', - srcId: '10690001', - desiredConnections: 3, - windowSize: 64, - rateLimitPerSecond: 320, - config: { extensionDigits: 2 }, - unitPrice: 4, - })).resolves.toEqual(expect.objectContaining({ - id: 'channel-1', - name: '主通道-编辑', - gatewayHost: '10.0.0.1', - })); + await expect( + service.updateChannel('channel-1', { + name: '主通道-编辑', + gatewayHost: '10.0.0.1', + gatewayPort: 27890, + carrier: 'all', + sendRegion: '全国', + account: 'sp-new', + srcId: '10690001', + desiredConnections: 3, + windowSize: 64, + rateLimitPerSecond: 320, + config: { extensionDigits: 2 }, + unitPrice: 4, + }), + ).resolves.toEqual( + expect.objectContaining({ + id: 'channel-1', + name: '主通道-编辑', + gatewayHost: '10.0.0.1', + }), + ); expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, @@ -727,9 +1052,12 @@ describe('ChannelsService', () => { resourceId: 'channel-1', }), }); - expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ - method: 'POST', - })); + expect(mockFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:8090/connections/connect', + expect.objectContaining({ + method: 'POST', + }), + ); }); it('does not request a reconnect when only non-connection channel fields change', async () => { @@ -779,9 +1107,11 @@ describe('ChannelsService', () => { await service.updateChannel('channel-1', { config: { extensionDigits: 15 } }); - expect(prisma.smsChannel.update).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ config: expect.objectContaining({ extensionDigits: 15 }) }), - })); + expect(prisma.smsChannel.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ config: expect.objectContaining({ extensionDigits: 15 }) }), + }), + ); }); it('persists a message-level long-message receipt mode without requesting a reconnect', async () => { @@ -790,11 +1120,13 @@ describe('ChannelsService', () => { await service.updateChannel('channel-1', { config: { longMessageReceiptMode: 'message_level' } }); - expect(prisma.smsChannel.update).toHaveBeenCalledWith(expect.objectContaining({ - data: expect.objectContaining({ - config: expect.objectContaining({ longMessageReceiptMode: 'message_level' }), + expect(prisma.smsChannel.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + config: expect.objectContaining({ longMessageReceiptMode: 'message_level' }), + }), }), - })); + ); expect(mockFetch).not.toHaveBeenCalled(); }); @@ -802,7 +1134,9 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - await expect(service.updateChannel('channel-1', { gatewayPort: 70000 })).rejects.toThrow('gatewayPort must be an integer between 1 and 65535'); + await expect(service.updateChannel('channel-1', { gatewayPort: 70000 })).rejects.toThrow( + 'gatewayPort must be an integer between 1 and 65535', + ); expect(prisma.smsChannel.update).not.toHaveBeenCalled(); }); @@ -810,8 +1144,15 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - await expect(service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', channelId: 'channel-1' })) - .rejects.toThrow('Route rules can only bind channel groups'); + await expect( + service.createRouteRule({ + tenantId: 'tenant-1', + applicationId: 'app-1', + groupId: 'group-1', + carrier: 'mobile', + channelId: 'channel-1', + }), + ).rejects.toThrow('Route rules can only bind channel groups'); expect(prisma.channelRouteRule.create).not.toHaveBeenCalled(); }); @@ -819,16 +1160,30 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - expect(() => service.createGroup({ code: 'G-ALL', name: '三网组', carrier: 'all' })).toThrow('carrier must be mobile, unicom, or telecom'); + expect(() => service.createGroup({ code: 'G-ALL', name: '三网组', carrier: 'all' })).toThrow( + 'carrier must be mobile, unicom, or telecom', + ); - await service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东', priority: 10 }); + await service.addGroupItem({ + groupId: 'group-1', + channelId: 'channel-1', + carrier: 'mobile', + province: '山东', + priority: 10, + }); expect(prisma.smsChannelGroupItem.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东' }), + data: expect.objectContaining({ + groupId: 'group-1', + channelId: 'channel-1', + carrier: 'mobile', + province: '山东', + }), }); expect(prisma.smsChannelGroupItem.create.mock.calls[0][0].data).not.toHaveProperty('rateLimitPerSecond'); - await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'unicom' })) - .rejects.toThrow('Channel group items must use the same carrier'); + await expect( + service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'unicom' }), + ).rejects.toThrow('Channel group items must use the same carrier'); const compatibleChannel = { id: 'channel-1', @@ -850,15 +1205,17 @@ describe('ChannelsService', () => { sendRegion: '山东', }; prisma.smsChannel.findUnique.mockResolvedValueOnce({ ...compatibleChannel, carrier: 'telecom' }); - await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-x', carrier: 'mobile' })) - .rejects.toThrow('Channel carrier is not compatible'); + await expect( + service.addGroupItem({ groupId: 'group-1', channelId: 'channel-x', carrier: 'mobile' }), + ).rejects.toThrow('Channel carrier is not compatible'); prisma.smsChannel.findUnique.mockResolvedValue(compatibleChannel); prisma.smsChannelGroupItem.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'province-item', province: '山东' }); - await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-2', carrier: 'mobile', province: '山东' })) - .rejects.toThrow('同一通道组内同一省份只能配置一个通道'); + await expect( + service.addGroupItem({ groupId: 'group-1', channelId: 'channel-2', carrier: 'mobile', province: '山东' }), + ).rejects.toThrow('同一通道组内同一省份只能配置一个通道'); }); it('rejects province routes with mismatched channel sendRegion and duplicate national priorities', async () => { @@ -866,15 +1223,17 @@ describe('ChannelsService', () => { const service = new ChannelsService(prisma as never); prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-henan', carrier: 'all', sendRegion: '河南' }); - await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-henan', carrier: 'mobile', province: '山东' })) - .rejects.toThrow('Province route must use a channel with the same sendRegion'); + await expect( + service.addGroupItem({ groupId: 'group-1', channelId: 'channel-henan', carrier: 'mobile', province: '山东' }), + ).rejects.toThrow('Province route must use a channel with the same sendRegion'); prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-national', carrier: 'all', sendRegion: '全国' }); prisma.smsChannelGroupItem.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'national-priority-1', province: null, priority: 1 }); - await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-national', carrier: 'mobile', priority: 1 })) - .rejects.toThrow('同一通道组内全国通道优先级不能重复'); + await expect( + service.addGroupItem({ groupId: 'group-1', channelId: 'channel-national', carrier: 'mobile', priority: 1 }), + ).rejects.toThrow('同一通道组内全国通道优先级不能重复'); }); it('updates channel groups and replaces items with backend validation', async () => { @@ -913,8 +1272,24 @@ describe('ChannelsService', () => { retryEnabled: true, retryTimeLimitMinutes: 750, items: [ - { channelId: 'channel-national', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel: { code: 'CMPP-N', name: '全国通道' } }, - { channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10, weight: 1, isBackup: false, channel: { code: 'CMPP-SD', name: '山东通道' } }, + { + channelId: 'channel-national', + carrier: 'mobile', + province: null, + priority: 1, + weight: 1, + isBackup: false, + channel: { code: 'CMPP-N', name: '全国通道' }, + }, + { + channelId: 'channel-sd', + carrier: 'mobile', + province: '山东', + priority: 10, + weight: 1, + isBackup: false, + channel: { code: 'CMPP-SD', name: '山东通道' }, + }, ], }), }, @@ -945,28 +1320,35 @@ describe('ChannelsService', () => { }), }); - await expect(service.updateGroup('group-1', { - carrier: 'mobile', - items: [ - { channelId: 'channel-sd', carrier: 'mobile', priority: 1 }, - { channelId: 'channel-national', carrier: 'mobile', priority: 1 }, - ], - })).rejects.toThrow('同一通道组内全国通道优先级不能重复'); + await expect( + service.updateGroup('group-1', { + carrier: 'mobile', + items: [ + { channelId: 'channel-sd', carrier: 'mobile', priority: 1 }, + { channelId: 'channel-national', carrier: 'mobile', priority: 1 }, + ], + }), + ).rejects.toThrow('同一通道组内全国通道优先级不能重复'); }); it('requires route rule carrier to match the channel group carrier', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - await expect(service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'unicom' })) - .rejects.toThrow('Route rule carrier must match the channel group carrier'); + await expect( + service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'unicom' }), + ).rejects.toThrow('Route rule carrier must match the channel group carrier'); expect(prisma.channelRouteRule.create).not.toHaveBeenCalled(); }); it('counts distinct normal and deleted applications, channels, and queued supplier submits before deletion', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ id: 'group-1', name: '移动主通道组', items: [{ id: 'item-1' }, { id: 'item-2' }] }); + prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ + id: 'group-1', + name: '移动主通道组', + items: [{ id: 'item-1' }, { id: 'item-2' }], + }); prisma.channelRouteRule.findMany.mockResolvedValueOnce([ { applicationId: 'app-active' }, { applicationId: 'app-active' }, @@ -999,10 +1381,12 @@ describe('ChannelsService', () => { const operationLogCreate = jest.fn(); prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }]); prisma.smsApplication.findMany.mockResolvedValue([{ id: 'app-1', status: 'active' }]); - prisma.$transaction.mockImplementationOnce((callback) => callback({ - smsChannelGroup: { update: groupUpdate }, - operationLog: { create: operationLogCreate }, - })); + prisma.$transaction.mockImplementationOnce((callback) => + callback({ + smsChannelGroup: { update: groupUpdate }, + operationLog: { create: operationLogCreate }, + }), + ); await expect(service.deleteGroup('group-1')).resolves.toEqual({ id: 'group-1', status: 'deleted' }); expect(groupUpdate).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: { status: 'deleted' } }); @@ -1032,7 +1416,9 @@ describe('ChannelsService', () => { }); expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith({ - where: { signatureId_channelId_fieldCode: { signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license' } }, + where: { + signatureId_channelId_fieldCode: { signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license' }, + }, update: { fieldValue: '营业执照', fileObjectId: 'file-1' }, create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license' }), }); @@ -1042,7 +1428,13 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', createdById: 'user-1' }); + await service.createReportTask({ + tenantId: 'tenant-1', + signatureId: 'sig-1', + channelId: 'channel-1', + carrier: 'mobile', + createdById: 'user-1', + }); await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 }); await service.importReportReceipt('report-task-1', { fileName: 'receipt.csv', @@ -1104,7 +1496,11 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - await service.changeChannelStatus('channel-1', { status: 'disabled', operatorId: 'admin-1', reason: 'maintenance' }); + await service.changeChannelStatus('channel-1', { + status: 'disabled', + operatorId: 'admin-1', + reason: 'maintenance', + }); expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'disabled' } }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ @@ -1124,23 +1520,33 @@ describe('ChannelsService', () => { status: 'connecting', }), }); - expect(mockQueueAdd).toHaveBeenCalledWith('connect-channel', expect.objectContaining({ - messageType: 'ConnectChannel', - channelId: 'channel-1', - reason: 'channel_enabled', - }), expect.objectContaining({ - jobId: expect.stringMatching(/^gateway-connect-channel-1-/), - removeOnComplete: 1000, - removeOnFail: 1000, - })); - expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('"reason":"channel_enabled"'), - })); - expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('"reason":"channel_disabled"'), - })); + expect(mockQueueAdd).toHaveBeenCalledWith( + 'connect-channel', + expect.objectContaining({ + messageType: 'ConnectChannel', + channelId: 'channel-1', + reason: 'channel_enabled', + }), + expect.objectContaining({ + jobId: expect.stringMatching(/^gateway-connect-channel-1-/), + removeOnComplete: 1000, + removeOnFail: 1000, + }), + ); + expect(mockFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:8090/connections/connect', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"reason":"channel_enabled"'), + }), + ); + expect(mockFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:8090/connections/disconnect', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"reason":"channel_disabled"'), + }), + ); }); it('reconnects active failed channels and disconnects inactive live channels during reconciliation', async () => { @@ -1150,29 +1556,33 @@ describe('ChannelsService', () => { ...(await prisma.smsChannel.findUnique()), id: 'channel-active', status: 'active', - connectionStates: [{ - id: 'state-active', - connectionId: 'channel-active:primary', - status: 'failed', - currentConnections: 0, - desiredConnections: 1, - lastHeartbeatAt: null, - nextReconnectAt: new Date('2026-07-23T10:00:00.000Z'), - }], + connectionStates: [ + { + id: 'state-active', + connectionId: 'channel-active:primary', + status: 'failed', + currentConnections: 0, + desiredConnections: 1, + lastHeartbeatAt: null, + nextReconnectAt: new Date('2026-07-23T10:00:00.000Z'), + }, + ], }, { ...(await prisma.smsChannel.findUnique()), id: 'channel-disabled', status: 'disabled', - connectionStates: [{ - id: 'state-disabled', - connectionId: 'channel-disabled:primary', - status: 'connected', - currentConnections: 1, - desiredConnections: 1, - lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'), - nextReconnectAt: null, - }], + connectionStates: [ + { + id: 'state-disabled', + connectionId: 'channel-disabled:primary', + status: 'connected', + currentConnections: 1, + desiredConnections: 1, + lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'), + nextReconnectAt: null, + }, + ], }, ]); const service = new ChannelsService(prisma as never); @@ -1180,51 +1590,69 @@ describe('ChannelsService', () => { const result = await service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z')); expect(result).toEqual({ scanned: 2, reconnectRequested: 1, disconnectRequested: 1 }); - expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/connect', expect.objectContaining({ - body: expect.stringContaining('"reason":"automatic_reconnect"'), - })); - expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:8090/connections/disconnect', expect.objectContaining({ - body: expect.stringContaining('"reason":"inactive_channel_reconcile"'), - })); + expect(mockFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:8090/connections/connect', + expect.objectContaining({ + body: expect.stringContaining('"reason":"automatic_reconnect"'), + }), + ); + expect(mockFetch).toHaveBeenCalledWith( + 'http://127.0.0.1:8090/connections/disconnect', + expect.objectContaining({ + body: expect.stringContaining('"reason":"inactive_channel_reconcile"'), + }), + ); expect(mockRedisSet).toHaveBeenCalledTimes(2); expect(mockRedisEval).toHaveBeenCalledTimes(2); }); it('does not reconnect a fresh healthy supplier connection', async () => { const prisma = createPrismaMock(); - prisma.smsChannel.findMany.mockResolvedValue([{ - ...(await prisma.smsChannel.findUnique()), - status: 'active', - config: { desiredConnections: 1, heartbeatIntervalSeconds: 30, heartbeatMissThreshold: 3 }, - connectionStates: [{ - connectionId: 'channel-1:primary', - status: 'connected', - currentConnections: 1, - desiredConnections: 1, - lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'), - nextReconnectAt: null, - }], - }]); + prisma.smsChannel.findMany.mockResolvedValue([ + { + ...(await prisma.smsChannel.findUnique()), + status: 'active', + config: { desiredConnections: 1, heartbeatIntervalSeconds: 30, heartbeatMissThreshold: 3 }, + connectionStates: [ + { + connectionId: 'channel-1:primary', + status: 'connected', + currentConnections: 1, + desiredConnections: 1, + lastHeartbeatAt: new Date('2026-07-23T10:59:55.000Z'), + nextReconnectAt: null, + }, + ], + }, + ]); const service = new ChannelsService(prisma as never); - await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'))) - .resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 }); + await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'))).resolves.toEqual({ + scanned: 1, + reconnectRequested: 0, + disconnectRequested: 0, + }); expect(mockFetch).not.toHaveBeenCalled(); expect(mockRedisSet).not.toHaveBeenCalled(); }); it('skips duplicate reconciliation when another API instance owns the Redis lease', async () => { const prisma = createPrismaMock(); - prisma.smsChannel.findMany.mockResolvedValue([{ - ...(await prisma.smsChannel.findUnique()), - status: 'active', - connectionStates: [], - }]); + prisma.smsChannel.findMany.mockResolvedValue([ + { + ...(await prisma.smsChannel.findUnique()), + status: 'active', + connectionStates: [], + }, + ]); mockRedisSet.mockResolvedValueOnce(null); const service = new ChannelsService(prisma as never); - await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'))) - .resolves.toEqual({ scanned: 1, reconnectRequested: 0, disconnectRequested: 0 }); + await expect(service.reconcileGatewayConnections(new Date('2026-07-23T11:00:00.000Z'))).resolves.toEqual({ + scanned: 1, + reconnectRequested: 0, + disconnectRequested: 0, + }); expect(mockFetch).not.toHaveBeenCalled(); expect(mockRedisEval).not.toHaveBeenCalled(); }); @@ -1308,12 +1736,14 @@ describe('ChannelsService', () => { operatorId: 'admin-1', }); - expect(result).toEqual(expect.objectContaining({ - channelId: 'channel-1', - status: 'submit_queued', - submitted: 1, - testNo: expect.stringMatching(/^CHTEST-/), - })); + expect(result).toEqual( + expect.objectContaining({ + channelId: 'channel-1', + status: 'submit_queued', + submitted: 1, + testNo: expect.stringMatching(/^CHTEST-/), + }), + ); expect(prisma.tenant.findFirst).not.toHaveBeenCalled(); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ @@ -1331,13 +1761,16 @@ describe('ChannelsService', () => { submitStatus: 'queued', }), }); - expect(mockQueueAdd).toHaveBeenCalledWith('submit-command', expect.objectContaining({ - messageType: 'SubmitCommand', - channelId: 'channel-1', - phoneNumber: '18821203795', - content: '【安徽航天信息】您的验证码是070926,有效时间30分钟。', - upstream: expect.objectContaining({ cmppVersion: '2.0', gatewayHost: '127.0.0.1' }), - })); + expect(mockQueueAdd).toHaveBeenCalledWith( + 'submit-command', + expect.objectContaining({ + messageType: 'SubmitCommand', + channelId: 'channel-1', + phoneNumber: '18821203795', + content: '【安徽航天信息】您的验证码是070926,有效时间30分钟。', + upstream: expect.objectContaining({ cmppVersion: '2.0', gatewayHost: '127.0.0.1' }), + }), + ); expect(mockRedisXadd).toHaveBeenCalledWith( 'gateway.submit.commands', '*', @@ -1364,10 +1797,12 @@ describe('ChannelsService', () => { }); const service = new ChannelsService(prisma as never); - await expect(service.testChannel('channel-1', { - phoneNumber: '18821203795', - content: '测试短信', - })).rejects.toThrow('通道当前没有可用 CMPP 连接'); + await expect( + service.testChannel('channel-1', { + phoneNumber: '18821203795', + content: '测试短信', + }), + ).rejects.toThrow('通道当前没有可用 CMPP 连接'); expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); expect(mockRedisXadd).not.toHaveBeenCalled(); }); @@ -1393,7 +1828,14 @@ describe('ChannelsService', () => { where: { applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a' }, }); expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1', connectionId: 'conn-a', status: 'connected', lastError: null }), + data: expect.objectContaining({ + tenantId: 'tenant-1', + applicationId: 'app-1', + channelId: 'channel-1', + connectionId: 'conn-a', + status: 'connected', + lastError: null, + }), }); expect(prisma.cmppConnectionState.findMany).toHaveBeenCalledWith({ where: { channelId: 'channel-1' }, @@ -1418,17 +1860,19 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); const now = new Date('2026-07-06T10:00:45.000Z'); - prisma.cmppConnectionState.findMany.mockResolvedValueOnce([{ - id: 'conn-state-1', - tenantId: 'tenant-1', - applicationId: null, - channelId: 'channel-1', - connectionId: 'channel-1:primary', - status: 'connecting', - desiredConnections: 1, - currentConnections: 0, - updatedAt: new Date('2026-07-06T10:00:00.000Z'), - }]); + prisma.cmppConnectionState.findMany.mockResolvedValueOnce([ + { + id: 'conn-state-1', + tenantId: 'tenant-1', + applicationId: null, + channelId: 'channel-1', + connectionId: 'channel-1:primary', + status: 'connecting', + desiredConnections: 1, + currentConnections: 0, + updatedAt: new Date('2026-07-06T10:00:00.000Z'), + }, + ]); await expect(service.markTimedOutConnectingChannels(now)).resolves.toEqual({ checked: 1, failed: 1 }); @@ -1477,19 +1921,24 @@ describe('ChannelsService', () => { it('does not write timeout logs when a connecting state is already changed by gateway callback', async () => { const prisma = createPrismaMock(); prisma.cmppConnectionState.updateMany.mockResolvedValueOnce({ count: 0 }); - prisma.cmppConnectionState.findMany.mockResolvedValueOnce([{ - id: 'conn-state-1', - tenantId: 'tenant-1', - applicationId: null, - channelId: 'channel-1', - connectionId: 'channel-1:primary', - desiredConnections: 1, - currentConnections: 0, - updatedAt: new Date('2026-07-06T10:00:00.000Z'), - }]); + prisma.cmppConnectionState.findMany.mockResolvedValueOnce([ + { + id: 'conn-state-1', + tenantId: 'tenant-1', + applicationId: null, + channelId: 'channel-1', + connectionId: 'channel-1:primary', + desiredConnections: 1, + currentConnections: 0, + updatedAt: new Date('2026-07-06T10:00:00.000Z'), + }, + ]); const service = new ChannelsService(prisma as never); - await expect(service.markTimedOutConnectingChannels(new Date('2026-07-06T10:00:45.000Z'))).resolves.toEqual({ checked: 1, failed: 0 }); + await expect(service.markTimedOutConnectingChannels(new Date('2026-07-06T10:00:45.000Z'))).resolves.toEqual({ + checked: 1, + failed: 0, + }); expect(prisma.operationLog.create).not.toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'cmpp_connection.failed' }), diff --git a/api/src/infrastructure-monitoring/alert-history.spec.ts b/api/src/infrastructure-monitoring/alert-history.spec.ts new file mode 100644 index 0000000..503895d --- /dev/null +++ b/api/src/infrastructure-monitoring/alert-history.spec.ts @@ -0,0 +1,45 @@ +import { alertHistoryRange, mergeAlertHistory } from './alert-history'; + +describe('historical alert observation cycles', () => { + it('uses seven Shanghai calendar days and rejects invalid or excessive dates', () => { + expect(alertHistoryRange(undefined, undefined, new Date('2026-09-09T16:30:00Z'))).toMatchObject({ + startDate: '2026-09-04', + endDate: '2026-09-10', + }); + for (const [from, to] of [ + ['2026-02-30', '2026-03-01'], + ['2026-09-09', '2026-09-08'], + ['2026-07-01', '2026-09-09'], + ]) { + expect(() => alertHistoryRange(from, to)).toThrow(); + } + }); + it('retains distinct cycles, merges daily boundaries and excludes stale/nonpositive samples', () => { + const result = new Map(); + const metric = { __name__: 'ALERTS_FOR_STATE', alertname: 'CPUHigh', instance: 'host', severity: 'warning' }; + mergeAlertHistory( + result, + [ + { + metric, + values: [ + [110, '100'], + [120, '100'], + [130, '0'], + [140, 'NaN'], + [150, '145'], + [200, '145'], + ], + }, + ], + 110, + 200, + ); + mergeAlertHistory(result, [{ metric, values: [[160, '145']] }], 110, 200); + expect(result.size).toBe(2); + expect([...result.values()].map((item) => item.lastObservedAt)).toEqual([ + new Date(120000).toISOString(), + new Date(160000).toISOString(), + ]); + }); +}); diff --git a/api/src/infrastructure-monitoring/alert-history.ts b/api/src/infrastructure-monitoring/alert-history.ts new file mode 100644 index 0000000..e56bf05 --- /dev/null +++ b/api/src/infrastructure-monitoring/alert-history.ts @@ -0,0 +1,68 @@ +import { BadRequestException } from '@nestjs/common'; +import { createHash } from 'node:crypto'; + +export function alertHistoryRange(from?: string, to?: string, now = new Date()) { + const dateKey = (date: Date) => new Date(date.getTime() + 8 * 3600_000).toISOString().slice(0, 10); + const endDate = to || dateKey(now); + const startDate = from || dateKey(new Date(now.getTime() - 6 * 86400_000)); + const parse = (value: string) => { + const result = new Date(`${value}T00:00:00+08:00`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(result.getTime()) || dateKey(result) !== value) { + throw new BadRequestException('告警日期无效'); + } + return result.getTime() / 1000; + }; + const start = parse(startDate); + const end = parse(endDate) + 86400; + if (end <= start || end - start > 31 * 86400) throw new BadRequestException('告警日期范围须为1至31天'); + return { startDate, endDate, start, end: Math.min(end, now.getTime() / 1000) }; +} + +export type AlertHistoryItem = { + id: string; + name: string; + severity: string; + service: string; + instance: string; + startedAt: string; + firstObservedAt: string; + lastObservedAt: string; +}; + +// ALERTS_FOR_STATE stores activeAt as the sample value, separating repeated trigger cycles. +// Observation boundaries are not claimed as exact recovery times. +export function mergeAlertHistory( + target: Map, + series: Array<{ metric: Record; values?: [number, string][] }>, + start: number, + end: number, +) { + for (const { metric, values } of series) { + const labels = Object.entries(metric) + .filter(([key]) => key !== '__name__') + .sort(([a], [b]) => a.localeCompare(b)); + const fingerprint = createHash('sha256').update(JSON.stringify(labels)).digest('hex'); + for (const [time, rawActiveAt] of values ?? []) { + const activeAt = Number(rawActiveAt); + if (time < start || time >= end || !Number.isFinite(activeAt) || activeAt <= 0 || activeAt > time) continue; + const id = `${fingerprint}:${activeAt}`; + const observed = new Date(time * 1000).toISOString(); + const item = target.get(id); + if (item) { + if (observed < item.firstObservedAt) item.firstObservedAt = observed; + if (observed > item.lastObservedAt) item.lastObservedAt = observed; + } else { + target.set(id, { + id, + name: metric.alertname || '未命名告警', + severity: metric.severity || 'info', + service: metric.service || '', + instance: metric.instance || '', + startedAt: new Date(activeAt * 1000).toISOString(), + firstObservedAt: observed, + lastObservedAt: observed, + }); + } + } + } +} diff --git a/api/src/infrastructure-monitoring/infrastructure-monitoring.controller.ts b/api/src/infrastructure-monitoring/infrastructure-monitoring.controller.ts index d06467c..21e484b 100644 --- a/api/src/infrastructure-monitoring/infrastructure-monitoring.controller.ts +++ b/api/src/infrastructure-monitoring/infrastructure-monitoring.controller.ts @@ -8,7 +8,10 @@ import { InfrastructureMonitoringService } from './infrastructure-monitoring.ser @ApiTags('infrastructure-monitoring') @Controller('admin/infrastructure-monitoring') export class InfrastructureMonitoringController { - constructor(private readonly monitoring: InfrastructureMonitoringService, private readonly settings: InfrastructureAlertSettingsService) {} + constructor( + private readonly monitoring: InfrastructureMonitoringService, + private readonly settings: InfrastructureAlertSettingsService, + ) {} @Get('overview') overview(@Query('range') range?: string, @CurrentSessionUserId() userId?: string) { @@ -16,19 +19,35 @@ export class InfrastructureMonitoringController { } @Get('notification-summary') - notificationSummary(@CurrentSessionUserId() userId?: string) { return this.monitoring.notificationSummary(userId); } + notificationSummary(@CurrentSessionUserId() userId?: string) { + return this.monitoring.notificationSummary(userId); + } + + @Get('alert-history') + alertHistory(@Query('from') from?: string, @Query('to') to?: string, @Query('page') page?: string) { + return this.monitoring.alertHistory(from, to, page); + } @Post('alerts/:fingerprint/read') - markAlertRead(@Param('fingerprint') fingerprint: string, @Body('activeAt') activeAt: unknown, @CurrentSessionUserId() userId: string) { + markAlertRead( + @Param('fingerprint') fingerprint: string, + @Body('activeAt') activeAt: unknown, + @CurrentSessionUserId() userId: string, + ) { return this.monitoring.markAlertRead(fingerprint, activeAt, userId); } @Get('alert-thresholds') - alertThresholds() { return this.settings.get(); } + alertThresholds() { + return this.settings.get(); + } @Put('alert-thresholds') @RequireRecentAuthentication() - updateAlertThresholds(@Body() body: { configVersion?: number; thresholds?: unknown }, @CurrentSessionUserId() operatorId?: string) { + updateAlertThresholds( + @Body() body: { configVersion?: number; thresholds?: unknown }, + @CurrentSessionUserId() operatorId?: string, + ) { return this.settings.update(body, operatorId); } } diff --git a/api/src/infrastructure-monitoring/infrastructure-monitoring.service.ts b/api/src/infrastructure-monitoring/infrastructure-monitoring.service.ts index 1ef753a..e7205d1 100644 --- a/api/src/infrastructure-monitoring/infrastructure-monitoring.service.ts +++ b/api/src/infrastructure-monitoring/infrastructure-monitoring.service.ts @@ -1,9 +1,22 @@ -import { BadRequestException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; import { createHash } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; -import { compareMountpoints, FILESYSTEM_LABELS, FILESYSTEM_SELECTOR, FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics'; +import { alertHistoryRange, mergeAlertHistory, type AlertHistoryItem } from './alert-history'; +import { + compareMountpoints, + FILESYSTEM_LABELS, + FILESYSTEM_SELECTOR, + FILESYSTEM_USAGE_PERCENT, + filesystemIdentity, +} from './filesystem-metrics'; import type { InfrastructureAlert, InfrastructureMetricPoint, @@ -57,7 +70,8 @@ const QUERIES = { uptimeSeconds: 'time() - node_boot_time_seconds', lastSampleAt: 'max(timestamp(node_uname_info))', // PromQL字符串本身需要两个反斜杠才能把正则的“\.”传给RE2;TypeScript字面量因此需要写四个。 - services: 'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})', + services: + 'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})', } as const; const SERVICE_DEFINITIONS = [ @@ -70,38 +84,62 @@ const SERVICE_DEFINITIONS = [ ] as const; const SERVICE_METRIC_DEFINITIONS = [ - { key: 'api', name: 'API服务', metrics: [ - ['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'], - ['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'], - ['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'], - ['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'], - ] }, - { key: 'gateway', name: 'Gateway服务', metrics: [ - ['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'], - ['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'], - ['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'], - ['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'], - ] }, - { key: 'postgresql', name: 'PostgreSQL', metrics: [ - ['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'], - ['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'], - ] }, - { key: 'redis', name: 'Redis', metrics: [ - ['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'], - ['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'], - ['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'], - ['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'], - ] }, - { key: 'minio', name: 'MinIO', metrics: [ - ['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'], - ['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'], - ['objects', '对象数', 'cmpp:service_minio:objects', 'count'], - ['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'], - ] }, - { key: 'nginx', name: 'Nginx', metrics: [ - ['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'], - ['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'], - ] }, + { + key: 'api', + name: 'API服务', + metrics: [ + ['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'], + ['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'], + ['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'], + ['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'], + ], + }, + { + key: 'gateway', + name: 'Gateway服务', + metrics: [ + ['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'], + ['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'], + ['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'], + ['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'], + ], + }, + { + key: 'postgresql', + name: 'PostgreSQL', + metrics: [ + ['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'], + ['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'], + ], + }, + { + key: 'redis', + name: 'Redis', + metrics: [ + ['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'], + ['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'], + ['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'], + ['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'], + ], + }, + { + key: 'minio', + name: 'MinIO', + metrics: [ + ['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'], + ['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'], + ['objects', '对象数', 'cmpp:service_minio:objects', 'count'], + ['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'], + ], + }, + { + key: 'nginx', + name: 'Nginx', + metrics: [ + ['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'], + ['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'], + ], + }, ] as const; const SERVICE_METRICS_QUERY = '{__name__=~"cmpp:service_.*"}'; @@ -165,7 +203,10 @@ export class InfrastructureMonitoringService { private readonly prometheusUrl: string; private readonly queryTimeoutMs: number; - constructor(config: ConfigService, private readonly prisma: PrismaService) { + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + ) { this.prometheusUrl = normalizePrometheusUrl(config.get('PROMETHEUS_URL')); this.queryTimeoutMs = Math.min(15_000, Math.max(1_000, Number(config.get('PROMETHEUS_QUERY_TIMEOUT_MS') ?? 5_000))); } @@ -202,7 +243,7 @@ export class InfrastructureMonitoringService { activeAlerts: alerts.length, }, metrics: instant.metrics, - trends: { ...trends.metrics, diskUsagePercent: rootDisk ? trends.disks.get(rootDisk.id) ?? [] : [] }, + trends: { ...trends.metrics, diskUsagePercent: rootDisk ? (trends.disks.get(rootDisk.id) ?? []) : [] }, disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })), services, serviceMetrics, @@ -210,18 +251,28 @@ export class InfrastructureMonitoringService { }; } catch (error) { // 页面必须整体清空陈旧指标,但服务端仍需留下不含PromQL/地址/凭据的根因摘要便于运维诊断。 - this.logger.warn(`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`); + this.logger.warn( + `Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`, + ); return this.unavailable(range, collectedAt); } } async notificationSummary(userId?: string) { try { - const alerts = await this.attachReadState(this.parseAlerts(await this.getJson('/api/v1/alerts')), userId); + const alerts = await this.attachReadState( + this.parseAlerts(await this.getJson('/api/v1/alerts')), + userId, + ); const unreadAlerts = alerts.filter((item) => !item.acknowledged); - return { count: unreadAlerts.length, criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length }; + return { + count: unreadAlerts.length, + criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length, + }; } catch (error) { - this.logger.warn(`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`); + this.logger.warn( + `Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`, + ); throw new ServiceUnavailableException('Prometheus活动告警当前不可用'); } } @@ -231,12 +282,21 @@ export class InfrastructureMonitoringService { const activeAt = new Date(String(rawActiveAt ?? '')); if (!Number.isFinite(activeAt.getTime())) throw new BadRequestException('告警开始时间无效'); const activeAlerts = this.parseAlerts(await this.getJson('/api/v1/alerts')); - const current = activeAlerts.find((item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime()); + const current = activeAlerts.find( + (item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime(), + ); if (!current) throw new NotFoundException('该次活动告警已结束或已重新触发,请刷新后重试'); const readAt = new Date(); - const log = () => this.prisma.operationLog.create({ - data: { userId, action: 'monitoring.alert_marked_read', resource: 'infrastructure_alert', resourceId: fingerprint, detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity } }, - }); + const log = () => + this.prisma.operationLog.create({ + data: { + userId, + action: 'monitoring.alert_marked_read', + resource: 'infrastructure_alert', + resourceId: fingerprint, + detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity }, + }, + }); let read; try { [read] = await this.prisma.$transaction([ @@ -245,15 +305,57 @@ export class InfrastructureMonitoringService { ]); } catch (error) { if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error; - const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({ where: { fingerprint_userId: { fingerprint, userId } } }); + const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({ + where: { fingerprint_userId: { fingerprint, userId } }, + }); // 同一次触发重复点击不更新readAt也不重复写日志;activeAt变化才代表同指纹的新触发周期。 if (existing.activeAt.getTime() === activeAt.getTime()) read = existing; - else [read] = await this.prisma.$transaction([ - this.prisma.infrastructureAlertRead.update({ where: { fingerprint_userId: { fingerprint, userId } }, data: { activeAt, readAt } }), - log(), - ]); + else + [read] = await this.prisma.$transaction([ + this.prisma.infrastructureAlertRead.update({ + where: { fingerprint_userId: { fingerprint, userId } }, + data: { activeAt, readAt }, + }), + log(), + ]); } - return { fingerprint, activeAt: read.activeAt.toISOString(), acknowledged: true, acknowledgedAt: read.readAt.toISOString() }; + return { + fingerprint, + activeAt: read.activeAt.toISOString(), + acknowledged: true, + acknowledgedAt: read.readAt.toISOString(), + }; + } + + async alertHistory(from?: string, to?: string, rawPage?: string) { + const range = alertHistoryRange(from, to); + const page = rawPage === undefined ? 1 : Number(rawPage); + if (!Number.isSafeInteger(page) || page < 1) throw new BadRequestException('告警页码无效'); + const history = new Map(); + try { + // Daily raw range vectors retain short events that a coarse query_range step would miss. + for (let start = range.start; start < range.end; start += 86400) { + const end = Math.min(start + 86400, range.end); + const response = await this.getJson('/api/v1/query', { + query: `ALERTS_FOR_STATE[${Math.ceil(end - start)}s]`, + time: String(end), + }); + mergeAlertHistory(history, response.data?.result ?? [], range.start, range.end); + } + } catch { + throw new ServiceUnavailableException('历史告警查询失败,请稍后重试'); + } + const items = [...history.values()].sort( + (a, b) => b.startedAt.localeCompare(a.startedAt) || a.id.localeCompare(b.id), + ); + return { + items: items.slice((page - 1) * 25, page * 25), + total: items.length, + page, + pageSize: 25, + startDate: range.startDate, + endDate: range.endDate, + }; } private parseRange(value?: string): InfrastructureMonitoringRange { @@ -264,12 +366,21 @@ export class InfrastructureMonitoringService { private async loadInstantMetrics() { const keys = Object.keys(emptyMetrics()) as Array; - const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]); + const responses = await Promise.all([ + ...keys.map((key) => this.query(QUERIES[key])), + this.query(QUERIES.lastSampleAt), + ]); const metrics = emptyMetrics(); - keys.forEach((key, index) => { if (!key.startsWith('disk')) metrics[key] = vectorValue(responses[index]); }); + keys.forEach((key, index) => { + if (!key.startsWith('disk')) metrics[key] = vectorValue(responses[index]); + }); const diskSamples = (key: keyof typeof metrics) => responses[keys.indexOf(key)].data?.result ?? []; - const usage = new Map(diskSamples('diskUsagePercent').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])])); - const available = new Map(diskSamples('diskAvailableBytes').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])])); + const usage = new Map( + diskSamples('diskUsagePercent').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]), + ); + const available = new Map( + diskSamples('diskAvailableBytes').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]), + ); const groups = new Map(); for (const item of diskSamples('diskTotalBytes')) { if (!item.metric.device || !item.metric.mountpoint || (finiteNumber(item.value?.[1]) ?? 0) <= 0) continue; @@ -278,18 +389,32 @@ export class InfrastructureMonitoringService { group.push(item); groups.set(id, group); } - const disks = [...groups].map(([id, items]) => { - const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints); - const metric = items[0].metric; - return { - id, instance: metric.instance ?? '', device: metric.device, filesystem: metric.fstype ?? '', - mountpoint: mountpoints[0], mountpoints, - // Never sum aliases. Max/min also tolerate slight sampling differences. - totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)), - availableBytes: available.get(id) ?? null, usagePercent: usage.get(id) ?? null, - }; - }) - .sort((left, right) => left.instance.localeCompare(right.instance) || (left.mountpoint === '/' ? -1 : right.mountpoint === '/' ? 1 : left.mountpoint.localeCompare(right.mountpoint))); + const disks = [...groups] + .map(([id, items]) => { + const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints); + const metric = items[0].metric; + return { + id, + instance: metric.instance ?? '', + device: metric.device, + filesystem: metric.fstype ?? '', + mountpoint: mountpoints[0], + mountpoints, + // Never sum aliases. Max/min also tolerate slight sampling differences. + totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)), + availableBytes: available.get(id) ?? null, + usagePercent: usage.get(id) ?? null, + }; + }) + .sort( + (left, right) => + left.instance.localeCompare(right.instance) || + (left.mountpoint === '/' + ? -1 + : right.mountpoint === '/' + ? 1 + : left.mountpoint.localeCompare(right.mountpoint)), + ); const rootDisk = disks.find((disk) => disk.mountpoints.includes('/')); metrics.diskUsagePercent = rootDisk?.usagePercent ?? null; metrics.diskTotalBytes = rootDisk?.totalBytes ?? null; @@ -304,21 +429,32 @@ export class InfrastructureMonitoringService { const keys = Object.keys(emptyTrends()) as Array; const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step))); return { - metrics: Object.fromEntries(keys.map((key, index) => [key, key === 'diskUsagePercent' ? [] : matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends'], - disks: new Map((responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [ - filesystemIdentity(item.metric), matrixValues({ status: 'success', data: { result: [item] } }), - ])), + metrics: Object.fromEntries( + keys.map((key, index) => [key, key === 'diskUsagePercent' ? [] : matrixValues(responses[index])]), + ) as InfrastructureMonitoringOverview['trends'], + disks: new Map( + (responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [ + filesystemIdentity(item.metric), + matrixValues({ status: 'success', data: { result: [item] } }), + ]), + ), }; } private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] { const values = new Map(); for (const item of response.data?.result ?? []) { - if (item.metric.name) values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0); + if (item.metric.name) + values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0); } return SERVICE_DEFINITIONS.map((definition) => { const present = definition.units.filter((unit) => values.has(unit)); - const status = present.length === 0 ? 'unknown' : present.some((unit) => (values.get(unit) ?? 0) >= 1) ? 'healthy' : 'unhealthy'; + const status = + present.length === 0 + ? 'unknown' + : present.some((unit) => (values.get(unit) ?? 0) >= 1) + ? 'healthy' + : 'unhealthy'; return { key: definition.key, name: definition.name, unit: present[0] ?? definition.units[0], status }; }); } @@ -329,7 +465,8 @@ export class InfrastructureMonitoringService { .map((item) => { const labels = item.labels ?? {}; const annotations = item.annotations ?? {}; - const severity: InfrastructureAlert['severity'] = labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info'; + const severity: InfrastructureAlert['severity'] = + labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info'; const identity = JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right))); return { fingerprint: createHash('sha256').update(identity).digest('hex').slice(0, 24), @@ -348,7 +485,9 @@ export class InfrastructureMonitoringService { }) .sort((left, right) => { const priority: Record = { critical: 0, warning: 1, info: 2 }; - return priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt); + return ( + priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt) + ); }); } @@ -377,24 +516,46 @@ export class InfrastructureMonitoringService { key: group.key, name: group.name, available: group.metrics.some((metric) => values.has(metric[2])), - metrics: group.metrics.map(([key, label, metricName, unit]) => ({ key, label, value: values.get(metricName) ?? null, unit })), + metrics: group.metrics.map(([key, label, metricName, unit]) => ({ + key, + label, + value: values.get(metricName) ?? null, + unit, + })), })); } private unavailable(range: InfrastructureMonitoringRange, collectedAt: string): InfrastructureMonitoringOverview { - const services = SERVICE_DEFINITIONS.map((item) => ({ key: item.key, name: item.name, unit: item.units[0], status: 'unknown' as const })); + const services = SERVICE_DEFINITIONS.map((item) => ({ + key: item.key, + name: item.name, + unit: item.units[0], + status: 'unknown' as const, + })); return { available: false, range, collectedAt, lastSampleAt: null, error: 'Prometheus监控数据当前不可用,请检查采集与服务状态', - summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 }, + summary: { + overallStatus: 'unknown', + serviceTotal: services.length, + serviceHealthy: 0, + warningAlerts: 0, + criticalAlerts: 0, + activeAlerts: 0, + }, metrics: emptyMetrics(), disks: [], trends: emptyTrends(), services, - serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ key: group.key, name: group.name, available: false, metrics: [] })), + serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ + key: group.key, + name: group.name, + available: false, + metrics: [], + })), alerts: [], }; } @@ -404,15 +565,26 @@ export class InfrastructureMonitoringService { } private queryRange(query: string, start: number, end: number, step: number) { - return this.getJson('/api/v1/query_range', { query, start: String(start), end: String(end), step: String(step) }); + return this.getJson('/api/v1/query_range', { + query, + start: String(start), + end: String(end), + step: String(step), + }); } - private async getJson(path: string, params: Record = {}): Promise { + private async getJson( + path: string, + params: Record = {}, + ): Promise { const url = new URL(`${this.prometheusUrl}${path}`); Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)); - const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(this.queryTimeoutMs) }); + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(this.queryTimeoutMs), + }); if (!response.ok) throw new Error(`Prometheus HTTP ${response.status}`); - const result = await response.json() as T; + const result = (await response.json()) as T; if (result.status !== 'success') throw new Error('Prometheus query failed'); return result; } diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index ca3451b..7dedb90 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -634,7 +634,7 @@ describe('OperationsService', () => { }, today: expect.objectContaining({ returnedCents: 10, - segmentCount: 20, + segmentCount: 2, deliveredSegmentCount: 18, arrivalRate: 90, billedCents: 360, diff --git a/api/src/operations/queries/dashboard.queries.ts b/api/src/operations/queries/dashboard.queries.ts index 2f9fa59..8e4a880 100644 --- a/api/src/operations/queries/dashboard.queries.ts +++ b/api/src/operations/queries/dashboard.queries.ts @@ -1,16 +1,24 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { randomUUID } from 'node:crypto'; import { moneyToNumber } from '../../common/money'; import { PrismaService } from '../../prisma/prisma.service'; -import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; -import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; + +import { + messageWhere, + qualityBusinessDay, + returnedTransactionWhere, + downstreamAlertWindows, + stalledPendingWhere, + clientBatchTaskView, + clientAccountView, + clientRechargeView, + summarizeMessageGroups, +} from '../operations.helpers'; // R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline. export class OperationsDashboardQueries { constructor(private readonly prisma: PrismaService) {} -async dashboard(query: { tenantId?: string }) { + async dashboard(query: { tenantId?: string }) { const businessDay = qualityBusinessDay(); const sinceToday = businessDay.startAt; const downstreamAlertWindow = downstreamAlertWindows(); @@ -94,13 +102,15 @@ async dashboard(query: { tenantId?: string }) { orderBy: { createdAt: 'desc' }, take: 10, }), - this.prisma.$queryRaw>(Prisma.sql` + this.prisma.$queryRaw< + Array<{ + tenantId: string; + tenantName: string; + todaySpendCents: bigint; + balanceCents: bigint; + creditCents: bigint; + }> + >(Prisma.sql` SELECT tenant.id AS "tenantId", tenant.name AS "tenantName", @@ -118,12 +128,14 @@ async dashboard(query: { tenantId?: string }) { GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents" ORDER BY "todaySpendCents" DESC, tenant.name ASC `), - this.prisma.$queryRaw>(Prisma.sql` + this.prisma.$queryRaw< + Array<{ + segmentCount: bigint; + deliveredSegmentCount: bigint; + billedCents: bigint; + costCents: bigint; + }> + >(Prisma.sql` WITH segment_metrics AS ( SELECT COUNT(segment.id)::bigint AS "segmentCount", @@ -208,11 +220,13 @@ async dashboard(query: { tenantId?: string }) { updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, }, }), - this.prisma.$queryRaw>(Prisma.sql` + this.prisma.$queryRaw< + Array<{ + hour: number; + submittedCount: bigint; + successCount: bigint; + }> + >(Prisma.sql` SELECT EXTRACT( HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai' @@ -227,11 +241,13 @@ async dashboard(query: { tenantId?: string }) { ORDER BY 1 `), // Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit. - this.prisma.$queryRaw>(Prisma.sql` + this.prisma.$queryRaw< + Array<{ + category: string; + count: bigint; + averageProcessingMs: bigint | null; + }> + >(Prisma.sql` WITH review_samples AS ( SELECT 'enterpriseCertifications'::text AS category, @@ -302,7 +318,8 @@ async dashboard(query: { tenantId?: string }) { ]); const todayTotals = summarizeMessageGroups(todayMessageGroups); const todayBusinessMetrics = todayBusinessMetricsRows[0]; - const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0); + const supplierSegmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0); + const segmentCount = todayTotals.billingUnits; const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0); const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents); const costCents = moneyToNumber(todayBusinessMetrics?.costCents); @@ -334,7 +351,8 @@ async dashboard(query: { tenantId?: string }) { averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs), }; }); - const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount; + const downstreamAlertCount = + downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount; return { taskCount, messageStatus: messageGroups, @@ -349,7 +367,8 @@ async dashboard(query: { tenantId?: string }) { billingUnits: todayTotals.billingUnits, segmentCount, deliveredSegmentCount, - arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0, + arrivalRate: + supplierSegmentCount > 0 ? Number(((deliveredSegmentCount / supplierSegmentCount) * 100).toFixed(1)) : 0, billedCents, profitCents, profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0, @@ -383,7 +402,7 @@ async dashboard(query: { tenantId?: string }) { recentRecharges, }; } -async clientDashboard(query: { tenantId?: string }) { + async clientDashboard(query: { tenantId?: string }) { const tenantId = query.tenantId; const [dashboard, tenant, approvedCertification, signatureCount, pendingBatchTaskCount] = await Promise.all([ this.dashboard(query), @@ -432,7 +451,7 @@ async clientDashboard(query: { tenantId?: string }) { }, }; } -pendingAudits(tenantId?: string) { + pendingAudits(tenantId?: string) { return Promise.all([ this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }), this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }), diff --git a/api/src/sending-monitor/alert-read-filter.spec.ts b/api/src/sending-monitor/alert-read-filter.spec.ts new file mode 100644 index 0000000..58fd935 --- /dev/null +++ b/api/src/sending-monitor/alert-read-filter.spec.ts @@ -0,0 +1,27 @@ +import { SendingMonitorService } from './sending-monitor.module'; + +describe('sending alert read filters', () => { + it.each(['', 'read', 'unread'])( + 'applies identical user/state/read criteria to rows and total: %s', + async (readStatus) => { + const prisma = { + $queryRawUnsafe: jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ total: 0 }]), + }; + await new SendingMonitorService(prisma as never).alerts({ state: 'active', readStatus, page: '2' }, 'user-a'); + const [list, count] = prisma.$queryRawUnsafe.mock.calls; + expect(list.slice(1)).toEqual(['user-a', 'active', readStatus, 20, 20]); + expect(count.slice(1)).toEqual(['user-a', 'active', readStatus]); + expect(list[0].split('FROM')[1].split('ORDER BY')[0].trim()).toBe(count[0].split('FROM')[1].trim()); + }, + ); + it('rejects unknown read states before querying', async () => { + const prisma = { $queryRawUnsafe: jest.fn() }; + await expect(new SendingMonitorService(prisma as never).alerts({ readStatus: 'bogus' }, 'user-a')).rejects.toThrow( + '已读状态无效', + ); + expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/sending-monitor/sending-monitor.module.ts b/api/src/sending-monitor/sending-monitor.module.ts index d0f2bb4..db9d211 100644 --- a/api/src/sending-monitor/sending-monitor.module.ts +++ b/api/src/sending-monitor/sending-monitor.module.ts @@ -312,17 +312,23 @@ export class SendingMonitorService { size = pageNumber(query.pageSize, 20, 100); const state = query.state ?? ''; if (state && !['active', 'recovered', 'closed'].includes(state)) throw new BadRequestException('告警状态无效'); + const readStatus = query.readStatus ?? ''; + if (!['', 'read', 'unread'].includes(readStatus)) throw new BadRequestException('已读状态无效'); + const from = `FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE ($2='' OR a.state=$2) AND ($3='' OR ($3='unread' AND r."readAt" IS NULL) OR ($3='read' AND r."readAt" IS NOT NULL))`; const [items, total] = await Promise.all([ this.prisma.$queryRawUnsafe( - `SELECT a.*,r."readAt" IS NULL unread FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE ($2='' OR a.state=$2) ORDER BY a."openedAt" DESC,a.id LIMIT $3 OFFSET $4`, + `SELECT a.*,r."readAt" IS NULL unread ${from} ORDER BY a."openedAt" DESC,a.id LIMIT $4 OFFSET $5`, user, state, + readStatus, size, (page - 1) * size, ), this.prisma.$queryRawUnsafe>( - `SELECT count(*)::int total FROM "SendingMonitorAlert" WHERE ($1='' OR state=$1)`, + `SELECT count(*)::int total ${from}`, + user, state, + readStatus, ), ]); return { items, total: total[0].total, page, pageSize: size }; diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 67c2871..403da8d 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2244,3 +2244,8 @@ ### 2026-09-08 夜补充:公共容量控件与顶栏通知可靠性 通道报备明细及签名质量四Tab统一使用公共Pagination,去掉可见“每页数量”文字,仅显示容量选项,保持可访问名称、10/25/50/100、默认25和各Tab独立日期。运营顶栏通知刷新应合并重复触发、限制在途批次、超时取消、隐藏/离线/锁定暂停和有上限的失败退避;计数失败保留上次真实值并明确暂不可用,不能归零冒充成功。实现与浏览器外部注入问题边界见operations-fixes-20260908.md“浏览器异常与通知刷新修复”,不改业务端口、后端计数口径或短信链路。 + + +## 2026-09-09 运营九项修正 + +用户确认需求及实现范围见 [九项修复设计](operations-fixes-20260909.md)。通道报备发送统计按实际运营商分开;创建/修改弹窗默认仅显式关闭;签名活跃度的企业、应用、签名、通道独立组合筛选;系统监控增加日期可选、默认近7日历史;首页客户分片按唯一业务消息汇总;发送质量告警已读计数与阅读筛选;详情行去通道组重复文案;HTTP地址随开关显示,保存校验错误居中;清退预警展示去“请通知 企业:”。本节客户分片口径替代旧供应商分片总数口径,到达率仍沿用供应商分片分子/分母。 diff --git a/docs/operations-fixes-20260909.md b/docs/operations-fixes-20260909.md new file mode 100644 index 0000000..c09dc75 --- /dev/null +++ b/docs/operations-fixes-20260909.md @@ -0,0 +1,17 @@ +# 2026-09-09 九项运营修复 + +状态:实现及本地验收完成,线上验收待授权部署;授权为修改并本地提交,不推送、不部署。与既有报备、发送监控、系统监控及清退设计配合,以下新口径替代旧首页分片口径;历史验收记录不改写。 + +1. 通道报备今日发送:保留供应商提交尝试口径,聚合键加入消息实际运营商;三网报备行分别匹配运营商。引流报备及无运营商的历史通道级报备保留汇总,未知运营商不得分摊给三网。 +2. 公共 Modal 默认仅显式关闭按钮关闭,遮罩和 Escape 不关闭;创建、编辑使用统一默认,保留保存成功关闭、未保存确认、焦点陷阱及恢复。 +3. 企业/通道活跃度分别提供企业、应用、签名输入;通道维度另有通道输入。条件按 AND 组合,各字段内部模糊匹配;各 Tab 独立,筛选回第一页,保留日期和分页。 +4. 系统监控保留活动告警,新增独立历史查询和日期范围(上海时区,默认含今天近7日,最多31日)。读取 Prometheus 保留的真实告警时间序列,明确采样和保留周期边界;采样缺失不等于确定恢复,不伪造已读、解决时间、摘要或历史数据库记录。无数据库迁移,不写监控配置。 +5. 今日消息分片数按客户业务消息 queuedAt 的上海自然日汇总 billingUnits,每个消息仅一次;不受多通道提交、补发和供应商分片变化影响。到达率继续使用原供应商分片分子/分母,避免只换分母导致超过100%;供应商分片数仅用于到达率内部计算,利润和收入口径不变。 +6. 发送质量告警新增 readStatus=read/unread/空,列表与 total 同条件,并与生命周期筛选 AND。已读只影响当前用户未读活动告警数,不消除真实活动告警;保持幂等及顶栏刷新。 +7. 发送详情仅移除通道发送与回执每行通道组文案,保留顶部汇总和真实通道/回执字段。 +8. HTTP 开关关闭时回执、上行地址与其他 HTTP 参数一并隐藏,不清空值;保存失败和校验错误用居中 Modal 展示,保留表单内容,不把部分写入冒报为成功。 +9. 清退预警明细展示去掉行首“请通知 企业名称:”,兼容已有消息;不改数据库历史内容和外发模板,不发送通知。 + +验收:定向失败回归、前端/API全量、类型/构建和质量门禁;真实 PostgreSQL 只读对账和真实 API/浏览器三尺寸。禁止发送短信和更改客户/通道/余额,写入场景仅隔离测试;缺少环境明确标记未验证。已有脏文件按开工副本保护,文档仅暂存本轮增量。 + +执行结果与限制见 [测试进度](testing-progress.md) 的“2026-09-09 九项运营修复执行结果”。本地提交仅包含本轮代码及上述文档增量;测试、预生产版本未变化。 diff --git a/docs/prometheus-system-monitoring-design-20260814.md b/docs/prometheus-system-monitoring-design-20260814.md index 2fb1408..01d4b70 100644 --- a/docs/prometheus-system-monitoring-design-20260814.md +++ b/docs/prometheus-system-monitoring-design-20260814.md @@ -249,3 +249,10 @@ type InfrastructureOverview = { - “已读”只表示某位管理员已查看某一次 Prometheus 活动告警,不是 resolve、silence 或 acknowledge 外部告警管理器;页面活动告警总数与平台健康状态仍按 Prometheus 原始 firing/pending 计算。 - 指纹由排序后的 Prometheus labels 稳定生成,`activeAt`区分同一指纹的不同触发周期。数据库以`fingerprint + userId`唯一,upsert同时更新`activeAt/readAt`;读取时只有数据库 activeAt 与当前 Prometheus activeAt 相同才算已读。 - 标记前必须回读当前 Prometheus 告警并校验指纹和 activeAt,防止客户端伪造或把已经恢复的新周期误标已读。预警中心轻量汇总只扣减当前管理员本次已读项;数据库故障不得用 localStorage 或静态状态替代。 + + +## 12. 历史告警查询(2026-09-09) + +新增GET /admin/infrastructure-monitoring/alert-history,继承运营端会话鉴权,参数from/to为上海自然日,默认含今天近7日、最多31日,page正整数、每页25条。逐日查询Prometheus原始ALERTS_FOR_STATE范围向量,以标签指纹+activeAt分开触发周期,跨日采样合并;不使用粗粒度步长丢掉短周期,不把等待触发当作已发送告警。保留真实触发时间和范围内最后观测时间,最后观测不代表准确恢复。 + +历史和活动列表独立;历史不提供伪造恢复状态、旧annotations、批量已读或清理功能。超出Prometheus保留期/采集缺口的历史无法追溯,界面明确说明。失败返回503并展示错误,日期非法返回400;不迁移数据库、不变更阈值或采集配置。完整实现/验收及限制见operations-fixes-20260909.md。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 6fa7bc7..695fd46 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5335,3 +5335,20 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对 | OPS-PAGE0908-06、通知登录后业务与恢复演练 | 预生产现有会话锁定,待用户解锁后验证;未改账号,不能引用测试环境通过作为此项通过。独立恢复资产已保留,实际恢复演练未执行 | 证据:%TEMP%/cmpp-starttime-pagination-20260908/public-verify/run-2026-09-08T15-03-44-416Z/verification.json 与 public-verify/anonymous-2026-09-08T15-07-16-145Z/review.json。复核状态为 anonymous_ui_verified_with_network_limitation,未宣称预生产业务全部通过或历史 startTime/网络关闭问题全部根除。 + + +## 2026-09-09 九项运营修复验收 + +设计见 [九项修复](operations-fixes-20260909.md),执行结果单独记testing-progress.md。本节TC-OPS0909-05替代TC-DASHBOARD-FRAGMENT-001的首页分片口径,历史测试结果不改写。 + +| 用例 | 场景 | 预期 | +|---|---|---| +| TC-OPS0909-01 | 同签名三网通道分别1/2/3次提交,并混入未知运营商;回执状态各异 | 三网独立统计数量、比例及最近成功时间;未知不分摊;历史无运营商通道级及引流仍汇总 | +| TC-OPS0909-02 | 创建/编辑,未修改/已修改,遮罩、内容、Escape、页脚和叉;连续快速关闭 | 非显式动作不关闭;保存成功/关闭按钮正常;dirty确认、焦点恢复无卸载异常 | +| TC-OPS0909-03 | 活跃度企业、应用、签名、通道组合与空条件、跨Tab、刷新、三尺寸 | AND过滤、字段独立、筛选回第一页,日期/分页保持原行为、无窄屏溢出 | +| TC-OPS0909-04 | 历史默认日期、指定日期、重复触发、跨日、分页、非法日期、Prometheus失败 | 近7日真实历史按周期分开;最后采样不假定恢复;失败不假成功,无保留数据明确空态 | +| TC-OPS0909-05 | 同客户长短信经多个通道多次提交,另有未路由消息及跨日记录 | 今日按消息billingUnits一次汇总;换通道不重复,含今日尚未向供应商提交的消息;到达率分母不混换 | +| TC-OPS0909-06 | 当前用户已读、重复已读、另一管理员、read/unread与生命周期组合、快速切换 | 未读活动数恰好扣一次,其他用户不受影响;列表/total同条件,顶栏刷新,慢响应不覆盖新筛选 | +| TC-OPS0909-07 | 短信记录发送详情多个提交/回执 | 每行无通道组文案,顶部汇总及通道/回执数据仍存在 | +| TC-OPS0909-08 | HTTP关/开/关/开;本地及API校验失败 | 地址随HTTP参数隐藏展示且保值;居中错误弹窗保留表单,不伪报保存成功 | +| TC-OPS0909-09 | 历史/新清退预警多行、包含企业名称、非前缀正文 | 仅展示去行首请通知企业文案,保留签名/运营商/数量正文;不改数据库/外发消息 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index b66fc33..faedd5a 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4820,3 +4820,21 @@ git diff --check - 匿名浏览器 1600×1000、1366×768、390×844 页面与截图复核正常,验证码均 200、pageerror 均 0,实际主 JS 不含 react_stack_bottom_frame/JSX dev runtime。两个 /cdn-cgi/rum POST 由只读验收策略主动阻断,单独列为预期;另一次外域 GET 在响应头前真实 ERR_CONNECTION_CLOSED,初始探针未保留具体来源,网络关闭层仍未确定,不归为已修复。原自动化还误要求登录页自发请求 session;经核验页面没有该请求,独立匿名 GET /api/admin/auth/session 返回预期 401,不能冒称执行登录。 - 生产 React、通知轮询及公共分页修复已发布;测试环境的真实业务验收结论保留原环境。预生产现有登录会话因空闲锁定,待用户解锁,登录后分页/通知业务验收尚未完成;未读取新凭据、修改账号配置或恢复管理员。Chrome DevTools startTime 沿用此前注入归因与已合入上游修复的证据,应用不屏蔽异常;不宣称所有 Chrome 或历史网络 CLOSED 问题已解决。 - 证据:主 TEMP/public-verify/run-2026-09-08T15-03-44-416Z/verification.json 与 public-verify/anonymous-2026-09-08T15-07-16-145Z/review.json(主 TEMP=%TEMP%/cmpp-starttime-pagination-20260908)。后者状态为 anonymous_ui_verified_with_network_limitation,明确保留原始自动化失败和人工复核差异,不记为登录后全通过。 + +## 2026-09-09 九项运营修复执行结果(23:10 CST) + +授权:修改并本地提交;未授权推送、测试部署或预生产部署。设计及根因见 [九项运营修复](operations-fixes-20260909.md),用例 TC-OPS0909-01 至09。本轮实现与本地验收完成,线上验收待部署。 + +- Git 开工核验:main / HEAD / 实际 origin/main 均为 6d63eb5452ffc7c802960d044bf598cc8646564d。开工已有19个 tracked 和21个具体 untracked 文件;40份开工副本逐一校验,既有文件内容保留。metrics、发布工具/脚本、AGENTS及已有治理文档不夹带。需求/UI/监控设计/系统用例/本记录仅提交本轮追加部分。 +- 根因:报备 SQL 未按消息运营商聚合,三网任务重复取通道/签名总数;首页错误采用供应商分片,现按 queuedAt 上海自然日客户消息 billingUnits 求和。到达率保留供应商口径,未改变计费。 +- 关闭交互:检索74个文件127处公共 Modal 调用,默认禁止遮罩/Escape关闭,并修复卸载时未取消的焦点动画帧;通道编辑的显式旧设置一并收敛。保留显式关闭、dirty确认和成功保存。只读签名详情抽屉非创建/编辑表单,未改其交互。 +- 自动验证:前端27文件136测试通过(maxWorkers=2);API66套701测试通过(工作区含原有未提交metrics测试,不将其计作本轮新增/提交)。前后端TypeScript/生产构建、lint、格式、Stylelint、CSS治理15测试、bundle/security/deploy静态门禁通过。lint仍有既有AdminAnalyticsPage loadData依赖警告,0 error;入口gzip107.14KiB,在250KiB预算内。Gateway/队列发送链路未改,未执行发送smoke或压测。 +- 真实环境:本地独立克隆库 cmpp_qa_nine_1788964857865 从原本地95迁移版本执行既有迁移到100;未迁移原库或远端。真实PostgreSQL、API和生产构建浏览器登录验收,非mock/假会话。认证使用现有Redis7通过独立DB15及本轮随机key前缀,队列留在独立本地Redis;不接短信发送Worker。Browser插件不可用,采用已安装Playwright + Edge。 +- 非零数据:隔离SQL记录经真实API验证三网1/2/3次,未知运营商不分摊;7条业务消息共14客户分片,对应两通道8条提交尝试,首页仍14。 fixture为已拒绝/失败数据,仅SQL写入独立库,无入队/发送。当前用户告警已读数减1,重复读不再扣,read/unread列表与总数一致;其他用户隔离由现有实现及单元测试覆盖。 +- 浏览器:企业/通道签名活动独立字段和三尺寸1600×1000、1366×768、390×844;创建通道遮罩/Escape不关闭且显式关闭正常;HTTP地址关/开跟随参数,非法扩展码在居中错误框展示且不保存配置。历史告警默认近7日取得真实Prometheus25个周期,选择今天后请求成功,三尺寸截图;短信历史记录详情不再有每行通道组,顶部保留;独立库清退消息经真实API展示去前缀,数据库原文不变。上述成功运行无未捕获pageerror,未声称逐个手工验收127处弹窗。 +- 当前环境只读证据:本轮约22:20预生产今日10257条客户消息、18270客户分片、31482供应商分片;近7日三网尝试13311/13647/13572、未知28,证明口径差异。测试和预生产仍为809175b544f2526891ba6d2dece1a50eadaf57a0,均未发布本轮代码。MinIO/Gateway写路径不在修改范围,未验证文件上传和真实短信链路。 +- 原失败保留:前端首轮高并发超时、观察器按钮/label不匹配、日期选定后未点确定、SQL fixture先用非统计状态failed以及清退fixture先缺关联检测记录均修正后重跑,不能记为产品通过。Prometheus隧道曾Connection reset造成一次真实503,重建只读连接后成功;未屏蔽应用异常。Redis5不支持认证GETDEL且原库缺表,后改独立库及真实Redis7进行隔离验收。 +- 本地启动影响:首轮API启动曾自动尝试连接两个通道,因本地Gateway不可达失败,写入本地CmppConnectionState运行状态;未发送短信、未修改通道配置。后续延后启动重连、关闭相关扫描器与归档/报表调度。临时QA用户/操作日志/告警和业务fixture及认证前缀按本轮ID清理;不恢复或改写原有运行状态。 +- 遗留边界:创建通道390宽原有布局拥挤,关闭按钮可见可用,未扩大为视觉改版;历史告警受Prometheus保留和采样限制,含等待触发周期,最后采样不等于恢复时刻。预生产历史ERR_CONNECTION_CLOSED根因和已有管理员会话锁定未在本轮解决,未尝试恢复管理员。未推送、未测试部署、未预生产部署,线上真实业务验收未执行。 + +本地证据目录:C:/Users/hectorzhao/AppData/Local/Temp/cmpp-nine-fixes-20260909。自动日志api-full.log、frontend-final.log、build-last.log、api-build-final.log、lint-last.log、security.log等;浏览器成功记录browser-run7.log、browser-extra2.log、browser-retirement2.log、browser-counts2.log及各browser-*目录截图/结果,失败日志保留。敏感认证值不写入文档或Git。 diff --git a/docs/ui-design-guidelines.md b/docs/ui-design-guidelines.md index 363ca99..0d64349 100644 --- a/docs/ui-design-guidelines.md +++ b/docs/ui-design-guidelines.md @@ -142,3 +142,8 @@ ### 公共分页容量展示补充(2026-09-08 夜) 公共Pagination中的容量Select只显示“10/25/50/100 条/页”选中项,不显示“每页数量”标题;与翻页控件放在同一操作区。复用sr-only隐藏标签并以唯一id关联,保持键盘和读屏可访问;不要删除其他表单Select的可见标签。签名质量四Tab和通道报备明细沿用该组件,默认25和独立筛选状态保持。顶栏通知接口失败时须说明计数暂不可用,既有真实值只能作为上次数据保留,不能归零伪装无通知。 + + +### 弹窗显式关闭补充(2026-09-09) + +公共Modal默认closeOnBackdrop=false、closeOnEscape=false;全部创建/修改表单沿用该默认,点击遮罩、内容空白或Escape不关闭。页脚关闭/取消及右上角叉是显式关闭入口,仍保留dirty确认;提交成功可由业务关闭。只读公共弹窗同样采用显式关闭默认,自定义只读质量抽屉保持原交互。 diff --git a/src/api/admin/infrastructure-monitoring.api.ts b/src/api/admin/infrastructure-monitoring.api.ts index 45149da..65df7ef 100644 --- a/src/api/admin/infrastructure-monitoring.api.ts +++ b/src/api/admin/infrastructure-monitoring.api.ts @@ -7,6 +7,24 @@ import type { } from '../types'; export const adminInfrastructureMonitoringApi = { + getInfrastructureAlertHistory: (from?: string, to?: string, page = 1) => + request<{ + items: Array<{ + id: string; + name: string; + severity: string; + service: string; + instance: string; + startedAt: string; + firstObservedAt: string; + lastObservedAt: string; + }>; + total: number; + page: number; + pageSize: number; + startDate: string; + endDate: string; + }>(withQuery('/admin/infrastructure-monitoring/alert-history', { from, to, page })), getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) => request(withQuery('/admin/infrastructure-monitoring/overview', { range })), getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) => diff --git a/src/apps/admin/AdminAnalyticsPage.css b/src/apps/admin/AdminAnalyticsPage.css index 71bd726..f425d82 100644 --- a/src/apps/admin/AdminAnalyticsPage.css +++ b/src/apps/admin/AdminAnalyticsPage.css @@ -10,6 +10,23 @@ display: none; } +.admin-analytics-page .signature-retirement-heatmap__heading { + flex-wrap: wrap; +} + +.admin-analytics-page .analytics-activity-filters { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--space-3); + width: 100%; +} + +.admin-analytics-page .analytics-activity-filters .ui-field { + flex: 1 1 180px; + min-width: 0; +} + @media (width <= 600px) { .admin-analytics-page .page-heading { align-items: stretch; diff --git a/src/apps/admin/AdminAnalyticsPage.test.tsx b/src/apps/admin/AdminAnalyticsPage.test.tsx index 53926a4..3f85dfc 100644 --- a/src/apps/admin/AdminAnalyticsPage.test.tsx +++ b/src/apps/admin/AdminAnalyticsPage.test.tsx @@ -8,6 +8,47 @@ const { api } = vi.hoisted(() => ({ vi.mock('@/api/adminApi', () => ({ adminApi: api })); describe('independent analytics tabs', () => { + it('combines separate activity search fields with AND and preserves them across tabs', async () => { + api.getSignatureRetirementHeatmap.mockResolvedValue({ + items: [], + dimensions: [ + { + dimensionType: 'channel', + signatureId: 'a', + signatureName: '签名甲', + tenantName: '企业甲', + applicationName: '应用甲', + channelName: '通道甲', + channelId: 'c', + carrier: 'mobile', + approvedAt: '2026-08-01', + }, + { + dimensionType: 'channel', + signatureId: 'b', + signatureName: '签名乙', + tenantName: '企业甲', + applicationName: '应用乙', + channelName: '通道乙', + channelId: 'd', + carrier: 'unicom', + approvedAt: '2026-08-01', + }, + ], + }); + render(); + fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' })); + const panel = screen.getByRole('region', { name: '通道签名活跃度' }); + await within(panel).findByText('签名甲'); + fireEvent.change(within(panel).getByLabelText('企业'), { target: { value: '企业甲' } }); + fireEvent.change(within(panel).getByLabelText('企业应用'), { target: { value: '应用甲' } }); + await waitFor(() => expect(within(panel).queryByText('签名乙')).not.toBeInTheDocument()); + fireEvent.change(within(panel).getByLabelText('通道'), { target: { value: '通道乙' } }); + await waitFor(() => expect(within(panel).queryByText('签名甲')).not.toBeInTheDocument()); + fireEvent.click(screen.getByRole('tab', { name: '企业签名活跃度' })); + fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' })); + expect(within(panel).getByLabelText('企业应用')).toHaveValue('应用甲'); + }); beforeEach(() => { vi.resetAllMocks(); api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] })); diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 306e240..5921c99 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -382,8 +382,8 @@ function RetirementHeatmap({ title: string; }) { const [pageState, setPageState] = useState({ key: '', page: 1 }); - const [keyword, setKeyword] = useState(''); - const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN')); + const [filters, setFilters] = useState({ tenantName: '', applicationName: '', signatureName: '', channelName: '' }); + const deferredFilters = useDeferredValue(filters); const visible = items.filter((item) => item.dimensionType === dimensionType); const dates = previousDateKeys(date, 30); const cellMap = new Map( @@ -394,12 +394,14 @@ function RetirementHeatmap({ ); const rows = dimensions .filter((item) => item.dimensionType === dimensionType) - .filter( - (item) => - !deferredKeyword || - [item.channelName, item.tenantName, item.applicationName, item.signatureName].some((value) => - value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword), - ), + .filter((item) => + Object.entries(deferredFilters).every( + ([key, value]) => + !value.trim() || + (item[key as keyof typeof deferredFilters] ?? '') + .toLocaleLowerCase('zh-CN') + .includes(value.trim().toLocaleLowerCase('zh-CN')), + ), ) .map((item) => ({ key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`, @@ -419,7 +421,7 @@ function RetirementHeatmap({ })) .sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN')); const totalPages = Math.max(1, Math.ceil(rows.length / pageSize)); - const paginationKey = JSON.stringify([date, deferredKeyword, dimensionType, dimensions.length, pageSize]); + const paginationKey = JSON.stringify([date, deferredFilters, dimensionType, dimensions.length, pageSize]); const page = pageState.key === paginationKey ? pageState.page : 1; const setPage = (value: number) => setPageState({ key: paginationKey, page: value }); const currentPage = Math.min(page, totalPages); @@ -432,13 +434,23 @@ function RetirementHeatmap({

{title}

数字为真实受理业务短信数;零提交为灰色,非零按现有六档成功率色阶展示。

-
- setKeyword(event.target.value)} - placeholder={dimensionType === 'channel' ? '搜索通道、企业、应用或签名' : '搜索企业、应用或签名'} - value={keyword} - /> +
+ {( + [ + ['tenantName', '企业'], + ['applicationName', '企业应用'], + ['signatureName', '签名'], + ...(dimensionType === 'channel' ? [['channelName', '通道']] : []), + ] as Array<[keyof typeof filters, string]> + ).map(([key, label]) => ( + setFilters((current) => ({ ...current, [key]: event.target.value }))} + /> + ))} T-1 至 T-30
@@ -500,7 +512,7 @@ function RetirementHeatmap({ ) : (

- {deferredKeyword + {Object.values(deferredFilters).some((value) => value.trim()) ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}

diff --git a/src/apps/admin/AdminSignatureRetirementPage.tsx b/src/apps/admin/AdminSignatureRetirementPage.tsx index b67e64c..43c62c4 100644 --- a/src/apps/admin/AdminSignatureRetirementPage.tsx +++ b/src/apps/admin/AdminSignatureRetirementPage.tsx @@ -197,12 +197,15 @@ export function AdminSignatureRetirementPage() { {item.dailyGroupKey ? (
{item.detections?.length ?? 0} 项预警明细 - {item.content.split('\n').map((line, index) => ( -

{line}

- ))} + {retirementDisplayContent(item.content, item.tenantName) + .split('\n') + .filter(Boolean) + .map((line, index) => ( +

{line}

+ ))}
) : ( - item.content + retirementDisplayContent(item.content, item.tenantName) )} @@ -983,3 +986,17 @@ function differenceInDateKeys(from: string, to: string) { const toDate = new Date(`${to}T12:00:00+08:00`); return Math.round((toDate.getTime() - fromDate.getTime()) / 86_400_000); } + +function retirementDisplayContent(content: string, tenantName?: string | null) { + return content + .split('\n') + .map((line) => { + const trimmed = line.trimStart(); + if (!tenantName || !trimmed.startsWith('请通知')) return line; + const rest = trimmed.slice(3).trimStart(); + if (!rest.startsWith(tenantName)) return line; + const afterName = rest.slice(tenantName.length).trimStart(); + return /^[::]/.test(afterName) ? afterName.slice(1).trimStart() : line; + }) + .join('\n'); +} diff --git a/src/apps/admin/AdminSmsApplicationFormPage.test.tsx b/src/apps/admin/AdminSmsApplicationFormPage.test.tsx new file mode 100644 index 0000000..e257cf9 --- /dev/null +++ b/src/apps/admin/AdminSmsApplicationFormPage.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { AdminSmsApplicationFormPage } from './AdminSmsApplicationFormPage'; + +vi.mock('@/api/adminApi', () => ({ + adminApi: { + listChannelGroups: vi + .fn() + .mockResolvedValue([{ id: 'group', name: '移动测试组', carrier: 'mobile', status: 'active' }]), + }, +})); + +describe('application form feedback', () => { + it('hides HTTP addresses with the protocol and retains input across toggles; invalid save uses a modal', async () => { + render( + + + } /> + + , + ); + await waitFor(() => expect(screen.getByRole('button', { name: '未开通' })).toBeInTheDocument()); + expect(screen.queryByLabelText(/^HTTP 回执地址/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '未开通' })); + fireEvent.change(screen.getByLabelText(/^HTTP 回执地址/), { target: { value: 'https://example.test/receipt' } }); + expect(screen.getByLabelText(/^HTTP 上行地址/)).toBeInTheDocument(); + fireEvent.click( + within(document.querySelector('.admin-app-protocol-section--http') as HTMLElement).getByRole('button', { + name: '已开通', + }), + ); + expect(screen.queryByLabelText(/^HTTP 回执地址/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '未开通' })); + expect(screen.getByLabelText(/^HTTP 回执地址/)).toHaveValue('https://example.test/receipt'); + fireEvent.change(screen.getByLabelText(/^应用名称/), { target: { value: '测试应用' } }); + fireEvent.click(screen.getByLabelText('移动通道组')); + fireEvent.click(screen.getByRole('option', { name: '移动测试组' })); + fireEvent.change(screen.getByLabelText(/^应用扩展码/), { target: { value: 'invalid' } }); + fireEvent.click(screen.getByRole('button', { name: '创建应用' })); + const dialog = screen.getByRole('dialog', { name: '短信应用保存失败' }); + expect(within(dialog).getByRole('alert')).toHaveTextContent('应用扩展码只能填写数字'); + fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!); + expect(dialog).toBeInTheDocument(); + }); +}); diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index 570d7dd..65ced26 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -1,8 +1,14 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react'; -import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi'; -import { Breadcrumb, Button, CarrierTag, Input, Select, Tag } from '@/components/ui'; +import { + adminApi, + type ChannelGroup, + type DictionaryItem, + type EnterpriseApplication, + type HttpApiConfig, +} from '@/api/adminApi'; +import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag } from '@/components/ui'; import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; import { createRandomHex } from '@/utils/randomId'; @@ -47,12 +53,27 @@ export function AdminSmsApplicationFormPage() { const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true); const [ipAddress, setIpAddress] = useState(''); const [httpConfig, setHttpConfig] = useState({ - enabled: false, sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true, - uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true, - qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90, - maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'http', uplinkDeliveryMode: 'http', - webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true, - allowClientManualRetry: true, allowClientTest: true, + enabled: false, + sendEnabled: true, + messageQueryEnabled: true, + receiptWebhookEnabled: true, + uplinkWebhookEnabled: true, + uplinkQueryEnabled: true, + credentialSelfServiceEnabled: true, + qpsLimit: 10, + timestampToleranceSeconds: 300, + maxCredentialCount: 2, + uplinkRetentionDays: 90, + maxQueryRangeDays: 31, + maxPageSize: 100, + receiptDeliveryMode: 'http', + uplinkDeliveryMode: 'http', + webhookRetryEnabled: true, + webhookMaxAttempts: 7, + webhookTimeoutSeconds: 10, + requireHttps: true, + allowClientManualRetry: true, + allowClientTest: true, }); const [httpIpAddress, setHttpIpAddress] = useState(''); const [receiptWebhookUrl, setReceiptWebhookUrl] = useState(''); @@ -62,6 +83,7 @@ export function AdminSmsApplicationFormPage() { const [unicomGroupId, setUnicomGroupId] = useState(''); const [telecomGroupId, setTelecomGroupId] = useState(''); const [error, setError] = useState(''); + const [saveError, setSaveError] = useState(''); const [saving, setSaving] = useState(false); useEffect(() => { @@ -92,7 +114,9 @@ export function AdminSmsApplicationFormPage() { } const [groupItems, application, routeRules] = await Promise.all([ adminApi.listChannelGroups(), - isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve(null), + isEdit && appId + ? adminApi.getEnterpriseApplication(appId) + : Promise.resolve(null), isEdit ? adminApi.listChannelRouteRules() : Promise.resolve([]), ]); if (cancelled) { @@ -122,19 +146,20 @@ export function AdminSmsApplicationFormPage() { useEffect(() => { if (!appId) return; let cancelled = false; - Promise.all([ - adminApi.getApplicationHttpApiConfig(appId), - adminApi.listApplicationHttpWebhooks(appId), - ]).then(([result, webhooks]) => { - if (cancelled) return; - if (result.config) setHttpConfig(result.config); - setHttpIpAddress(result.ipAllowlist.join('\n')); - setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? ''); - setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? ''); - }).catch((failure: Error) => { - if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败'); - }); - return () => { cancelled = true; }; + Promise.all([adminApi.getApplicationHttpApiConfig(appId), adminApi.listApplicationHttpWebhooks(appId)]) + .then(([result, webhooks]) => { + if (cancelled) return; + if (result.config) setHttpConfig(result.config); + setHttpIpAddress(result.ipAllowlist.join('\n')); + setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? ''); + setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? ''); + }) + .catch((failure: Error) => { + if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败'); + }); + return () => { + cancelled = true; + }; }, [appId]); function goBack() { @@ -160,12 +185,9 @@ export function AdminSmsApplicationFormPage() { setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false); setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? ''); - const activeRules = routeRules.filter((rule) => ( - rule.applicationId === application.id - && rule.status !== 'deleted' - && !rule.province - && !rule.channelId - )); + const activeRules = routeRules.filter( + (rule) => rule.applicationId === application.id && rule.status !== 'deleted' && !rule.province && !rule.channelId, + ); setMobileGroupId(getRouteGroupId(activeRules, 'mobile')); setUnicomGroupId(getRouteGroupId(activeRules, 'unicom')); setTelecomGroupId(getRouteGroupId(activeRules, 'telecom')); @@ -173,7 +195,7 @@ export function AdminSmsApplicationFormPage() { async function submit() { if (!enterpriseId) { - setError('缺少企业 ID'); + setSaveError('缺少企业 ID'); return; } const selectedGroups = [ @@ -182,29 +204,29 @@ export function AdminSmsApplicationFormPage() { { carrier: 'telecom' as Carrier, groupId: telecomGroupId }, ].filter((item) => item.groupId); if (selectedGroups.length === 0) { - setError('请至少配置一个运营商通道组'); + setSaveError('请至少配置一个运营商通道组'); return; } if (!isValidMoneyInput(customerUnitPrice)) { - setError('客户单价必须是非负金额,且最多保留小数点后 4 位'); + setSaveError('客户单价必须是非负金额,且最多保留小数点后 4 位'); return; } const normalizedExtension = applicationExtension.trim(); const normalizedFillPrefix = accessNumberFillPrefix.trim(); if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) { - setError('应用扩展码只能填写数字'); + setSaveError('应用扩展码只能填写数字'); return; } if (accessNumberFillEnabled && !normalizedExtension) { - setError('开启接入号填充时必须填写应用扩展码'); + setSaveError('开启接入号填充时必须填写应用扩展码'); return; } if (accessNumberFillEnabled && !/^\d+$/.test(normalizedFillPrefix)) { - setError('开启接入号填充时必须填写数字格式的填充前缀'); + setSaveError('开启接入号填充时必须填写数字格式的填充前缀'); return; } if (`${accessNumberFillEnabled ? normalizedFillPrefix : ''}${normalizedExtension}`.length > 21) { - setError('客户侧接入号不能超过 21 位'); + setSaveError('客户侧接入号不能超过 21 位'); return; } const payload = { @@ -228,11 +250,12 @@ export function AdminSmsApplicationFormPage() { }; setSaving(true); - setError(''); + setSaveError(''); try { - const application = isEdit && appId - ? await adminApi.updateEnterpriseApplication(appId, payload) - : await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload }); + const application = + isEdit && appId + ? await adminApi.updateEnterpriseApplication(appId, payload) + : await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload }); await adminApi.replaceApplicationRouteRules(application.id, { routes: selectedGroups.map((item, index) => ({ carrier: item.carrier, @@ -241,14 +264,17 @@ export function AdminSmsApplicationFormPage() { status: 'active', })), }); - await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) }); + await adminApi.updateApplicationHttpApiConfig(application.id, { + ...httpConfig, + ipAllowlist: parseIpAllowlist(httpIpAddress), + }); await Promise.all([ adminApi.saveApplicationHttpWebhook(application.id, 'receipt', { url: receiptWebhookUrl.trim() }), adminApi.saveApplicationHttpWebhook(application.id, 'uplink', { url: uplinkWebhookUrl.trim() }), ]); goBack(); } catch (failure) { - setError(failure instanceof Error ? failure.message : '短信应用保存失败'); + setSaveError(failure instanceof Error ? failure.message : '短信应用保存失败'); } finally { setSaving(false); } @@ -273,27 +299,65 @@ export function AdminSmsApplicationFormPage() {

短信应用和三网通道组配置写入真实后台接口。

- + {error ?

{error}

: null}
-

业务信息

先填写应用基础信息,保存后将生成真实企业应用。

+
+

业务信息

+

先填写应用基础信息,保存后将生成真实企业应用。

+
- setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} /> - setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} /> - setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} /> - setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required step="0.0001" type="number" value={customerUnitPrice} /> + setAppName(event.target.value)} + placeholder="请输入应用名称" + required + value={appName} + /> + setScene(event.target.value)} + placeholder="行业通知/营销推广/验证码" + value={scene} + /> + setDailyLimit(event.target.value)} + placeholder="100000" + required + value={dailyLimit} + /> + setCustomerUnitPrice(event.target.value)} + placeholder="0.0300" + required + step="0.0001" + type="number" + value={customerUnitPrice} + />
发送队列
@@ -319,10 +383,19 @@ export function AdminSmsApplicationFormPage() {
- -

CMPP 接入配置

管理客户端长连接、账号、接入号与下游回执投递。

+ + + +
+

CMPP 接入配置

+

管理客户端长连接、账号、接入号与下游回执投递。

+
- @@ -331,10 +404,30 @@ export function AdminSmsApplicationFormPage() {
CMPP 协议 -
+
+ +
- setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} /> - + setCmppAccount(event.target.value)} + placeholder="留空自动生成" + value={cmppAccount} + /> +
客户接入号填充 - -
填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id 时去掉开头前缀,上游发送时只拼接真实应用扩展码。
+ +
+ + + 填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id + 时去掉开头前缀,上游发送时只拼接真实应用扩展码。 + +
- {accessNumberFillEnabled ? setAccessNumberFillPrefix(event.target.value)} placeholder="例如 00" required value={accessNumberFillPrefix} /> : null} - + {accessNumberFillEnabled ? ( + setAccessNumberFillPrefix(event.target.value)} + placeholder="例如 00" + required + value={accessNumberFillPrefix} + /> + ) : null} + setPasswordCipher(event.target.value)} placeholder="16 位接口密码" - suffix={} + suffix={ + + } value={passwordCipher} /> - setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} /> - setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} /> + setCmppMaxConnections(event.target.value)} + placeholder="1" + required + value={cmppMaxConnections} + /> + setIpAddress(event.target.value)} + placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" + value={ipAddress} + />
CMPP 下游投递策略
- - + + +
+
+ + + 首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP + 的消息不会自动重发,仍可在下游投递记录中手工重投。 +
-
首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP 的消息不会自动重发,仍可在下游投递记录中手工重投。
- ) :
CMPP 接口未开通,账号、接入号和长连接参数已收起。
} + ) : ( +
CMPP 接口未开通,账号、接入号和长连接参数已收起。
+ )}
- -

HTTP 接口配置

管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。

+ + + +
+

HTTP 接口配置

+

管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。

+
- +
{httpConfig.enabled ? (
HTTP 能力
- {httpCapabilityOptions.map(({ key, label }) => )} + {httpCapabilityOptions.map(({ key, label }) => ( + + ))} +
+
+ + 访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。 +
+
+ setHttpIpAddress(event.target.value)} + placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" + value={httpIpAddress} + /> + + setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 })) + } + value={String(httpConfig.qpsLimit)} + /> + + setHttpConfig((current) => ({ + ...current, + timestampToleranceSeconds: Number(event.target.value) || 300, + })) + } + value={String(httpConfig.timestampToleranceSeconds)} + /> + + setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 })) + } + value={String(httpConfig.maxCredentialCount)} + /> + + setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 })) + } + value={String(httpConfig.webhookTimeoutSeconds)} + /> + + setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 })) + } + value={String(httpConfig.webhookMaxAttempts)} + /> +
+ HTTP 安全与重试 +
+ + +
-
访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。
- setHttpIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" value={httpIpAddress} /> - setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} /> - setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} /> - setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} /> - setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} /> - setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} /> -
HTTP 安全与重试
- - - -
- ) :
HTTP 接口未开通,接口能力和鉴权参数已收起。
} -
- setReceiptWebhookUrl(event.target.value)} - placeholder="https://example.com/webhooks/sms/receipt" - value={receiptWebhookUrl} - /> - setUplinkWebhookUrl(event.target.value)} - placeholder="https://example.com/webhooks/sms/uplink" - value={uplinkWebhookUrl} - /> -
-
投递通道由接口开通状态自动决定:CMPP开通则走CMPP,HTTP开通且地址非空则走HTTP,两者都开通时双投;运营端无需另选投递方式。
+ ) : ( +
HTTP 接口未开通,接口能力和鉴权参数已收起。
+ )} + {httpConfig.enabled ? ( +
+ setReceiptWebhookUrl(event.target.value)} + placeholder="https://example.com/webhooks/sms/receipt" + value={receiptWebhookUrl} + /> + setUplinkWebhookUrl(event.target.value)} + placeholder="https://example.com/webhooks/sms/uplink" + value={uplinkWebhookUrl} + /> +
+
+ + + 投递通道由接口开通状态自动决定:CMPP开通则走CMPP,HTTP开通且地址非空则走HTTP,两者都开通时双投;运营端无需另选投递方式。 + +
+
-
+ ) : null}
@@ -446,14 +717,23 @@ export function AdminSmsApplicationFormPage() { const available = groups.filter((group) => group.carrier === card.carrier); const meta = carrierMeta[card.carrier]; return ( -
+
- + + +
- 通道组 + + 通道组 + {meta.description}
- {card.groupId ? '已选择' : `${available.length} 个可选`} + + {card.groupId ? '已选择' : `${available.length} 个可选`} +
{ + setReadStatus(event.target.value); + setPage(1); + }} + /> setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], warning: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.warning ?? ''} /> - setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], critical: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.critical ?? ''} /> +
+ {definition.label} + 单位:{definition.unit} +
+ + setDraftThresholds((current) => ({ + ...current, + [definition.key]: { ...current[definition.key], warning: Number(event.target.value) }, + })) + } + step={definition.step} + type="number" + value={draftThresholds[definition.key]?.warning ?? ''} + /> + + setDraftThresholds((current) => ({ + ...current, + [definition.key]: { ...current[definition.key], critical: Number(event.target.value) }, + })) + } + step={definition.step} + type="number" + value={draftThresholds[definition.key]?.critical ?? ''} + />
))} - {settings?.applyStatus === 'failed' ?
上次应用失败{settings.lastError}
: null} - {settingsError ?
阈值配置不可用{settingsError}
: null} + {settings?.applyStatus === 'failed' ? ( +
+ +
+ 上次应用失败 + {settings.lastError} +
+
+ ) : null} + {settingsError ? ( +
+ +
+ 阈值配置不可用 + {settingsError} +
+
+ ) : null}
@@ -442,5 +824,10 @@ export function AdminSystemMonitoringPage() { } function EmptyChart() { - return
暂无真实趋势指标
; + return ( +
+ + 暂无真实趋势指标 +
+ ); } diff --git a/src/apps/admin/system-monitoring/AlertHistory.test.tsx b/src/apps/admin/system-monitoring/AlertHistory.test.tsx new file mode 100644 index 0000000..bc672c3 --- /dev/null +++ b/src/apps/admin/system-monitoring/AlertHistory.test.tsx @@ -0,0 +1,21 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AlertHistory } from './AlertHistory'; +const { get } = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock('@/api/adminApi', () => ({ adminApi: { getInfrastructureAlertHistory: get } })); + +describe('alert history', () => { + it('defaults to seven calendar days and sends the selected page; failure is not an empty success', async () => { + get.mockResolvedValue({ items: [], total: 30, page: 1, pageSize: 25 }); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: '下一页' })).toBeEnabled()); + const [from, to, page] = get.mock.calls[0]; + expect((Date.parse(to) - Date.parse(from)) / 86400_000).toBe(6); + expect(page).toBe(1); + get.mockRejectedValue(new Error('监控不可用')); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('监控不可用')); + expect(get).toHaveBeenLastCalledWith(from, to, 2); + expect(screen.getByText('历史告警不可用')).toBeInTheDocument(); + }); +}); diff --git a/src/apps/admin/system-monitoring/AlertHistory.tsx b/src/apps/admin/system-monitoring/AlertHistory.tsx new file mode 100644 index 0000000..3f020ea --- /dev/null +++ b/src/apps/admin/system-monitoring/AlertHistory.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { adminApi } from '@/api/adminApi'; +import { Button, DateRangeInput, Table, type TableColumn } from '@/components/ui'; +import { formatDateTime } from '@/utils/dateTime'; + +type History = Awaited>; +const columns: TableColumn[] = [ + { key: 'name', title: '告警', width: '280px', render: (row) => row.name }, + { + key: 'severity', + title: '级别', + width: '90px', + render: (row) => ({ critical: '严重', warning: '警告', info: '提示' })[row.severity] || row.severity, + }, + { key: 'instance', title: '服务 / 实例', width: '220px', render: (row) => row.service || row.instance || '主机资源' }, + { key: 'startedAt', title: '触发时间', width: '180px', render: (row) => formatDateTime(row.startedAt) }, + { + key: 'lastObservedAt', + title: '范围内最后采样', + width: '180px', + render: (row) => formatDateTime(row.lastObservedAt), + }, +]; + +export function AlertHistory() { + const [dates, setDates] = useState(() => { + const key = (time: number) => new Date(time + 8 * 3600_000).toISOString().slice(0, 10); + return { start: key(Date.now() - 6 * 86400_000), end: key(Date.now()) }; + }); + const [query, setQuery] = useState({ ...dates, page: 1, revision: 0 }); + const [response, setResponse] = useState<{ query: typeof query; data: History | null; error: string }>(); + const loading = response?.query !== query; + const data = loading ? null : response?.data; + const error = loading ? '' : response?.error; + useEffect(() => { + let current = true; + adminApi + .getInfrastructureAlertHistory(query.start, query.end, query.page) + .then((result) => { + if (current) setResponse({ query, data: result, error: '' }); + }) + .catch((reason) => { + if (current) + setResponse({ query, data: null, error: reason instanceof Error ? reason.message : '历史告警加载失败' }); + }); + return () => { + current = false; + }; + }, [query]); + return ( +
+
+ 历史告警记录 +
+
+ setDates({ start: value.start || '', end: value.end || '' })} + /> + +
+

+ 默认近7天,最多31天;读取 Prometheus + 保留的真实触发周期(含等待触发),最后采样不代表准确恢复时间。保留期外或采集缺失的历史无法补齐。 +

+ {error ? ( +

+ {error} +

+ ) : null} + +
+ + 共 {data?.total ?? 0} 条 · 第 {data?.page ?? query.page} 页 + + + +
+ + ); +} diff --git a/src/components/ui/Modal.close.test.tsx b/src/components/ui/Modal.close.test.tsx index 992f4e9..6c50b99 100644 --- a/src/components/ui/Modal.close.test.tsx +++ b/src/components/ui/Modal.close.test.tsx @@ -16,7 +16,7 @@ describe('Modal close policy', () => { fireEvent.click(screen.getByRole('button', { name: '关闭' })); expect(close).toHaveBeenCalledOnce(); }); - it('preserves mask closing by default for existing consumers', () => { + it('ignores backdrop, panel and Escape by default, including clean forms', () => { const close = vi.fn(); render( @@ -24,6 +24,32 @@ describe('Modal close policy', () => { , ); fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!); + fireEvent.mouseDown(screen.getByRole('dialog')); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(close).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + expect(close).toHaveBeenCalledOnce(); + }); + + it('keeps the dirty guard for explicit close and allows canceling it', () => { + const close = vi.fn(); + render( + } + > + 未保存内容 + , + ); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '继续编辑' })); + expect(close).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + fireEvent.click(screen.getByRole('button', { name: '放弃并关闭' })); expect(close).toHaveBeenCalledOnce(); }); }); diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index a55140f..6682dcc 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -97,8 +97,8 @@ export function Modal({ size = 'md', onClose, dirty = false, - closeOnBackdrop = true, - closeOnEscape = true, + closeOnBackdrop = false, + closeOnEscape = false, initialFocusRef, closeGuardTitle = '放弃未保存的修改?', closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。', @@ -139,9 +139,10 @@ export function Modal({ lockDocument(layer); const focusTarget = initialFocusRef?.current ?? focusableElements(panel)[0] ?? panel; - requestAnimationFrame(() => focusTarget.focus()); + const focusFrame = requestAnimationFrame(() => focusTarget.focus()); return () => { + cancelAnimationFrame(focusFrame); const stackIndex = modalStack.lastIndexOf(panel); if (stackIndex >= 0) modalStack.splice(stackIndex, 1); unlockDocument(); @@ -197,8 +198,12 @@ export function Modal({ if (!showCloseGuard) return; const panel = panelRef.current; if (panel) panel.inert = true; - requestAnimationFrame(() => focusableElements(guardRef.current ?? panelRef.current!)[0]?.focus()); + const focusFrame = requestAnimationFrame(() => { + const root = guardRef.current ?? panelRef.current; + if (root) focusableElements(root)[0]?.focus(); + }); return () => { + cancelAnimationFrame(focusFrame); if (panel) panel.inert = false; const restoreTarget = guardRestoreFocusRef.current; queueMicrotask(() => {