From 2c228a94e13b628447720c24bed45fb169acca6f Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Tue, 8 Sep 2026 12:46:15 +0800 Subject: [PATCH] fix: bound formatter memory and improve operations workflows --- .../migration.sql | 7 + api/prisma/schema.prisma | 4 + api/src/channels/channel-reporting.service.ts | 85 +- .../infrastructure-alert-settings.service.ts | 277 +++- .../promtool-path.spec.ts | 22 + api/src/operations/operations.service.spec.ts | 1126 ++++++++++------- api/src/operations/queries/quality.queries.ts | 281 ++-- .../daily-message.spec.ts | 87 ++ .../signature-retirement.service.spec.ts | 245 +++- .../signature-retirement.service.ts | 796 ++++++++++-- .../first-version-development-requirements.md | 5 + docs/operations-fixes-20260908.md | 19 + docs/system-functional-test-cases.md | 13 + docs/testing-progress.md | 11 + src/api/types/signature-retirement.ts | 2 + src/apps/admin/AdminAnalyticsPage.css | 22 + src/apps/admin/AdminAnalyticsPage.test.tsx | 39 + src/apps/admin/AdminAnalyticsPage.tsx | 636 +++++++--- src/apps/admin/AdminHome.css | 87 ++ src/apps/admin/AdminHome.tsx | 149 ++- src/apps/admin/AdminReportTasksPage.tsx | 4 +- .../admin/AdminSignatureRetirementPage.tsx | 895 +++++++++++-- src/apps/admin/channels/ChannelFormModal.tsx | 166 ++- src/components/ui/Modal.close.test.tsx | 29 + src/components/ui/Modal.tsx | 58 +- tools/quality/css-ownership.json | 12 + .../verify-retirement-daily-postgres.mjs | 166 +++ 27 files changed, 4013 insertions(+), 1230 deletions(-) create mode 100644 api/prisma/migrations/20260908050000_retirement_application_daily_message/migration.sql create mode 100644 api/src/infrastructure-monitoring/promtool-path.spec.ts create mode 100644 api/src/signature-retirement/daily-message.spec.ts create mode 100644 docs/operations-fixes-20260908.md create mode 100644 src/apps/admin/AdminAnalyticsPage.css create mode 100644 src/apps/admin/AdminAnalyticsPage.test.tsx create mode 100644 src/apps/admin/AdminHome.css create mode 100644 src/components/ui/Modal.close.test.tsx create mode 100644 tools/testing/verify-retirement-daily-postgres.mjs diff --git a/api/prisma/migrations/20260908050000_retirement_application_daily_message/migration.sql b/api/prisma/migrations/20260908050000_retirement_application_daily_message/migration.sql new file mode 100644 index 0000000..015ce5b --- /dev/null +++ b/api/prisma/migrations/20260908050000_retirement_application_daily_message/migration.sql @@ -0,0 +1,7 @@ +-- Nullable additive fields preserve historical messages and old readers. +ALTER TABLE "SignatureRetirementMessage" + ADD COLUMN "dailyGroupKey" TEXT, + ADD COLUMN "notificationDate" DATE, + ADD COLUMN "applicationId" TEXT, + ADD COLUMN "detectionIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; +CREATE UNIQUE INDEX "SignatureRetirementMessage_dailyGroupKey_key" ON "SignatureRetirementMessage"("dailyGroupKey"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 7e88a95..d5b0efc 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -1251,6 +1251,10 @@ model SignatureRetirementDetection { } model SignatureRetirementMessage { + dailyGroupKey String? @unique + notificationDate DateTime? @db.Date + applicationId String? + detectionIds String[] @default([]) id String @id @default(cuid()) detectionId String @unique cycleId String diff --git a/api/src/channels/channel-reporting.service.ts b/api/src/channels/channel-reporting.service.ts index 2516b72..a991b5b 100644 --- a/api/src/channels/channel-reporting.service.ts +++ b/api/src/channels/channel-reporting.service.ts @@ -1,24 +1,9 @@ -import { - BadRequestException, - Injectable, - Logger, - NotFoundException, - OnModuleDestroy, - OnModuleInit, -} from '@nestjs/common'; -import { Queue } from 'bullmq'; -import IORedis from 'ioredis'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; + import { Prisma } from '@prisma/client'; -import { randomUUID } from 'crypto'; -import { assertMoneyUnits, moneyToNumber } from '../common/money'; + import { PrismaService } from '../prisma/prisma.service'; import type { - CreateChannelDto, - UpdateChannelDto, - CreateChannelGroupDto, - CreateChannelGroupItemDto, - UpdateChannelGroupDto, - CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, @@ -26,73 +11,19 @@ import type { ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, - UpsertConnectionStateDto, - ChangeChannelStatusDto, - CopyChannelDto, - TestChannelDto, } from './channels.contracts'; import { - GATEWAY_CONNECTION_QUEUE, - GATEWAY_SUBMIT_QUEUE, - GATEWAY_SUBMIT_STREAM, - DEFAULT_GATEWAY_CONTROL_URL, - DEFAULT_CHANNEL_CONNECTION_ID, - DEFAULT_CONNECTING_TIMEOUT_MS, - DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, - DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, - DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, - DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, - DEFAULT_HEARTBEAT_INTERVAL_SECONDS, - DEFAULT_HEARTBEAT_MISS_THRESHOLD, - HEARTBEAT_AUDIT_INTERVAL_MS, - CONNECTING_TIMEOUT_ERROR, - DEFAULT_CMPP_VERSION, - normalizeTestPhones, - normalizeTestContent, - calculateBillingUnits, - buildChannelTestSubmitCommand, - getConfigValue, - getStringConfigValue, - normalizeConnectionAction, - normalizeCmppVersion, - normalizeGatewayConnectionStatus, - defaultChannelConnectionId, - getDesiredConnections, - ChannelConnectionSettings, - getRuntimeConfigInteger, - channelConnectionSettingsChanged, - channelGroupAuditSnapshot, - normalizeChannelRuntimeConfig, - normalizeCmppServiceId, - normalizeChannelRateLimit, - normalizeExtensionDigits, - getPositiveRuntimeInteger, - bullmqConnection, - getPositiveIntegerEnv, parseReceiptContent, - splitReceiptLine, - stripReceiptCell, - findReceiptStatusIndex, - normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, - sumReportDelivery, - percentage, latestDate, currentShanghaiDayRange, - normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, - normalizeChannelCarrier, normalizeChannelCarriers, - isChannelCarrierCompatible, - normalizeRegion, - isRegionCompatible, - validateGroupItems, normalizeReportType, summarizeReportStatuses, - normalizeLinkEvent, } from './channels.helpers'; /** R5 channel domain service composed behind ChannelsService. */ @@ -155,6 +86,9 @@ export class ChannelReportingService { for (const legacy of legacyBoth) { if (oppositeCodes.has(legacy.code)) continue; const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy; + void _id; + void _createdAt; + void _updatedAt; await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } }); } for (const [index, configured] of data.fields.entries()) { @@ -225,7 +159,12 @@ export class ChannelReportingService { const tasks = await this.prisma.channelSignatureReportTask.findMany({ where: { tenantId, - status, + status: + status === 'reporting' || status === 'exporting' + ? { in: ['reporting', 'exporting'] } + : status === 'failed' + ? { in: ['failed', 'rejected'] } + : status, channelId, reportType, signature: { auditStatus: { not: 'deleted' } }, diff --git a/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.ts b/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.ts index 60cacc1..d7b0dc4 100644 --- a/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.ts +++ b/api/src/infrastructure-monitoring/infrastructure-alert-settings.service.ts @@ -1,8 +1,15 @@ -import { BadRequestException, ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; import { execFile } from 'node:child_process'; -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; import { dirname } from 'node:path'; import { promisify } from 'node:util'; import { PrismaService } from '../prisma/prisma.service'; @@ -12,16 +19,148 @@ import type { InfrastructureAlertSettings, InfrastructureAlertThresholds } from const execFileAsync = promisify(execFile); export const ALERT_THRESHOLD_DEFINITIONS = [ - { key: 'hostCpu', label: '主机 CPU 使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], service: 'host', durations: ['10m', '5m'] }, - { key: 'hostMemory', label: '主机内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 85, critical: 95, expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], service: 'host', durations: ['10m', '5m'] }, - { key: 'hostDisk', label: '磁盘(独立文件系统)使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: FILESYSTEM_USAGE_PERCENT, names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] }, - { key: 'apiError', label: 'API 5xx 错误率', unit: '%', min: 0.1, max: 100, step: 0.1, warning: 1, critical: 5, expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], service: 'api', durations: ['5m', '5m'] }, - { key: 'apiLatency', label: 'API P95 响应时间', unit: '秒', min: 0.1, max: 60, step: 0.1, warning: 1, critical: 3, expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], service: 'api', durations: ['10m', '5m'] }, - { key: 'apiEventLoop', label: 'API 事件循环 P99', unit: '秒', min: 0.01, max: 10, step: 0.01, warning: 0.2, critical: 1, expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], service: 'api', durations: ['10m', '5m'] }, - { key: 'gatewayQueue', label: 'Gateway 最旧 pending', unit: '秒', min: 1, max: 3600, step: 1, warning: 30, critical: 120, expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds', names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'], service: 'gateway', durations: ['2m', '2m'] }, - { key: 'postgresConnections', label: 'PostgreSQL 连接使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)', names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'], service: 'postgresql', durations: ['10m', '5m'] }, - { key: 'redisMemory', label: 'Redis 内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes', guard: 'redis_memory_max_bytes > 0', names: ['RedisMemoryWarning', 'RedisMemoryCritical'], service: 'redis', durations: ['10m', '5m'] }, - { key: 'minioCapacity', label: 'MinIO 容量使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)', names: ['MinioCapacityWarning', 'MinioCapacityCritical'], service: 'minio', durations: ['15m', '5m'] }, + { + key: 'hostCpu', + label: '主机 CPU 使用率', + unit: '%', + min: 1, + max: 100, + step: 1, + warning: 80, + critical: 90, + expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', + names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], + service: 'host', + durations: ['10m', '5m'], + }, + { + key: 'hostMemory', + label: '主机内存使用率', + unit: '%', + min: 1, + max: 100, + step: 1, + warning: 85, + critical: 95, + expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', + names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], + service: 'host', + durations: ['10m', '5m'], + }, + { + key: 'hostDisk', + label: '磁盘(独立文件系统)使用率', + unit: '%', + min: 1, + max: 100, + step: 1, + warning: 80, + critical: 90, + expr: FILESYSTEM_USAGE_PERCENT, + names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], + service: 'host', + durations: ['15m', '5m'], + }, + { + key: 'apiError', + label: 'API 5xx 错误率', + unit: '%', + min: 0.1, + max: 100, + step: 0.1, + warning: 1, + critical: 5, + expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', + guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', + names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], + service: 'api', + durations: ['5m', '5m'], + }, + { + key: 'apiLatency', + label: 'API P95 响应时间', + unit: '秒', + min: 0.1, + max: 60, + step: 0.1, + warning: 1, + critical: 3, + expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', + names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], + service: 'api', + durations: ['10m', '5m'], + }, + { + key: 'apiEventLoop', + label: 'API 事件循环 P99', + unit: '秒', + min: 0.01, + max: 10, + step: 0.01, + warning: 0.2, + critical: 1, + expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', + names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], + service: 'api', + durations: ['10m', '5m'], + }, + { + key: 'gatewayQueue', + label: 'Gateway 最旧 pending', + unit: '秒', + min: 1, + max: 3600, + step: 1, + warning: 30, + critical: 120, + expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds', + names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'], + service: 'gateway', + durations: ['2m', '2m'], + }, + { + key: 'postgresConnections', + label: 'PostgreSQL 连接使用率', + unit: '%', + min: 1, + max: 100, + step: 1, + warning: 70, + critical: 85, + expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)', + names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'], + service: 'postgresql', + durations: ['10m', '5m'], + }, + { + key: 'redisMemory', + label: 'Redis 内存使用率', + unit: '%', + min: 1, + max: 100, + step: 1, + warning: 70, + critical: 85, + expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes', + guard: 'redis_memory_max_bytes > 0', + names: ['RedisMemoryWarning', 'RedisMemoryCritical'], + service: 'redis', + durations: ['10m', '5m'], + }, + { + key: 'minioCapacity', + label: 'MinIO 容量使用率', + unit: '%', + min: 1, + max: 100, + step: 1, + warning: 80, + critical: 90, + expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)', + names: ['MinioCapacityWarning', 'MinioCapacityCritical'], + service: 'minio', + durations: ['15m', '5m'], + }, ] as const; export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries( @@ -32,12 +171,17 @@ export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fr export class InfrastructureAlertSettingsService { private readonly logger = new Logger(InfrastructureAlertSettingsService.name); private readonly rulesPath: string; - private readonly promtoolPath: string; + private readonly promtoolPath: string | undefined; private readonly reloadUrl: string; - constructor(private readonly prisma: PrismaService, config: ConfigService) { - this.rulesPath = String(config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml'); - this.promtoolPath = String(config.get('PROMTOOL_PATH') ?? '/usr/bin/promtool'); + constructor( + private readonly prisma: PrismaService, + config: ConfigService, + ) { + this.rulesPath = String( + config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml', + ); + this.promtoolPath = config.get('PROMTOOL_PATH'); this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload'); } @@ -53,7 +197,14 @@ export class InfrastructureAlertSettingsService { appliedAt: row?.appliedAt?.toISOString() ?? null, thresholds, effectiveThresholds: effective, - definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({ key, label, unit, min, max, step })), + definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({ + key, + label, + unit, + min, + max, + step, + })), }; } @@ -63,7 +214,13 @@ export class InfrastructureAlertSettingsService { const thresholds = this.validate(body.thresholds); const claimed = await this.prisma.infrastructureAlertSetting.updateMany({ where: { id: 'global', configVersion: expectedVersion }, - data: { configVersion: { increment: 1 }, thresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'applying', lastError: null, updatedById: operatorId }, + data: { + configVersion: { increment: 1 }, + thresholds: thresholds as Prisma.InputJsonValue, + applyStatus: 'applying', + lastError: null, + updatedById: operatorId, + }, }); // 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。 if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试'); @@ -71,12 +228,32 @@ export class InfrastructureAlertSettingsService { try { await this.applyRules(thresholds); await this.prisma.$transaction([ - this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { effectiveVersion: nextVersion, effectiveThresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'effective', lastError: null, appliedAt: new Date() } }), - this.prisma.operationLog.create({ data: { userId: operatorId, action: 'monitoring.alert_thresholds_updated', resource: 'infrastructure_alert_setting', resourceId: 'global', detail: { configVersion: nextVersion, thresholds } } }), + this.prisma.infrastructureAlertSetting.update({ + where: { id: 'global' }, + data: { + effectiveVersion: nextVersion, + effectiveThresholds: thresholds as Prisma.InputJsonValue, + applyStatus: 'effective', + lastError: null, + appliedAt: new Date(), + }, + }), + this.prisma.operationLog.create({ + data: { + userId: operatorId, + action: 'monitoring.alert_thresholds_updated', + resource: 'infrastructure_alert_setting', + resourceId: 'global', + detail: { configVersion: nextVersion, thresholds }, + }, + }), ]); } catch (error) { const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error'; - await this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { applyStatus: 'failed', lastError: message } }); + await this.prisma.infrastructureAlertSetting.update({ + where: { id: 'global' }, + data: { applyStatus: 'failed', lastError: message }, + }); this.logger.error(`Prometheus managed rules apply failed: ${message}`); throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留'); } @@ -86,13 +263,20 @@ export class InfrastructureAlertSettingsService { private validate(value: unknown): InfrastructureAlertThresholds { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效'); const input = value as Record; - if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key))) throw new BadRequestException('存在不允许配置的告警指标'); + if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key))) + throw new BadRequestException('存在不允许配置的告警指标'); const result: InfrastructureAlertThresholds = {}; for (const definition of ALERT_THRESHOLD_DEFINITIONS) { const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined; const warning = Number(pair?.warning); const critical = Number(pair?.critical); - if (!Number.isFinite(warning) || !Number.isFinite(critical) || warning < definition.min || critical > definition.max || warning >= critical) { + if ( + !Number.isFinite(warning) || + !Number.isFinite(critical) || + warning < definition.min || + critical > definition.max || + warning >= critical + ) { throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`); } result[definition.key] = { warning, critical }; @@ -101,7 +285,11 @@ export class InfrastructureAlertSettingsService { } private asThresholds(value: unknown) { - try { return this.validate(value); } catch { return null; } + try { + return this.validate(value); + } catch { + return null; + } } private renderRules(thresholds: InfrastructureAlertThresholds) { @@ -113,9 +301,26 @@ export class InfrastructureAlertSettingsService { const isWarning = index === 0; // 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。 const guard = 'guard' in definition ? ` and (${definition.guard})` : ''; - const expr = isWarning ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` : `(${definition.expr} > ${values[1]})${guard}`; - const diskLocation = definition.key === 'hostDisk' ? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。' : ''; - lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}。${diskLocation}"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`); + const expr = isWarning + ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` + : `(${definition.expr} > ${values[1]})${guard}`; + const diskLocation = + definition.key === 'hostDisk' + ? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。' + : ''; + lines.push( + ` - alert: ${definition.names[index]}`, + ` expr: ${expr}`, + ` for: ${definition.durations[index]}`, + ' labels:', + ` severity: ${isWarning ? 'warning' : 'critical'}`, + ` service: ${definition.service}`, + ' annotations:', + ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, + ` description: "${definition.label}持续超过${values[index]}${definition.unit}。${diskLocation}"`, + ' currentValue: "{{ $value }}"', + ` threshold: "${values[index]}${definition.unit}"`, + ); } } return `${lines.join('\n')}\n`; @@ -128,7 +333,9 @@ export class InfrastructureAlertSettingsService { const previous = await readFile(this.rulesPath).catch(() => null); try { await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 }); - await execFileAsync(this.promtoolPath, ['check', 'rules', temporary], { timeout: 10_000 }); + await execFileAsync(await resolvePromtoolPath(this.promtoolPath), ['check', 'rules', temporary], { + timeout: 10_000, + }); await rename(temporary, this.rulesPath); const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) }); if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`); @@ -144,3 +351,17 @@ export class InfrastructureAlertSettingsService { } } } + +// Explicit configuration is authoritative; never silently replace a broken configured binary. +export async function resolvePromtoolPath(configured?: string) { + const candidates = configured ? [configured] : ['/usr/local/bin/promtool', '/usr/bin/promtool']; + for (const candidate of candidates) { + try { + await access(candidate, constants.X_OK); + return candidate; + } catch { + /* Try the next standard install location. */ + } + } + throw new Error('promtool不可执行,请检查PROMTOOL_PATH或标准安装目录'); +} diff --git a/api/src/infrastructure-monitoring/promtool-path.spec.ts b/api/src/infrastructure-monitoring/promtool-path.spec.ts new file mode 100644 index 0000000..d8abdb4 --- /dev/null +++ b/api/src/infrastructure-monitoring/promtool-path.spec.ts @@ -0,0 +1,22 @@ +import * as fs from 'node:fs/promises'; +import { resolvePromtoolPath } from './infrastructure-alert-settings.service'; + +jest.mock('node:fs/promises', () => ({ ...jest.requireActual('node:fs/promises'), access: jest.fn() })); + +describe('Prometheus binary resolution', () => { + beforeEach(() => jest.resetAllMocks()); + it('uses the official installer location when available', async () => { + const check = jest.mocked(fs.access).mockResolvedValue(undefined); + expect(await resolvePromtoolPath()).toBe('/usr/local/bin/promtool'); + expect(check).toHaveBeenCalledTimes(1); + }); + it('supports the distribution package location', async () => { + jest.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')).mockResolvedValueOnce(undefined); + expect(await resolvePromtoolPath()).toBe('/usr/bin/promtool'); + }); + it('fails rather than silently overriding an invalid explicitly configured binary', async () => { + const check = jest.mocked(fs.access).mockRejectedValue(new Error('EACCES')); + await expect(resolvePromtoolPath('/custom/promtool')).rejects.toThrow('promtool不可执行'); + expect(check).toHaveBeenCalledTimes(1); + }); +}); diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index cb74d64..ca3451b 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -18,11 +18,19 @@ function createPrismaMock() { }, smsMessageRecord: { findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]), - findUnique: jest.fn().mockResolvedValue({ id: 'message-1', messageId: 'MSG-1', submitRecords: [], receiptRecords: [], downstreamDeliveries: [] }), + findUnique: jest.fn().mockResolvedValue({ + id: 'message-1', + messageId: 'MSG-1', + submitRecords: [], + receiptRecords: [], + downstreamDeliveries: [], + }), count: jest.fn().mockResolvedValue(51), - groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]), + groupBy: jest + .fn() + .mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]), aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }), - }, + }, smsReceiptRecord: { findMany: jest.fn().mockResolvedValue([]), }, @@ -64,74 +72,86 @@ function createPrismaMock() { ]), }, cmppConnectionState: { - groupBy: jest.fn().mockResolvedValue([{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]), + groupBy: jest + .fn() + .mockResolvedValue([ + { status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }, + ]), }, operationLog: { - findMany: jest.fn().mockResolvedValue([{ - id: 'log-1', - tenantId: 'tenant-1', - tenant: { name: '租户A' }, - user: { displayName: '运营' }, - action: 'billing.manual_recharge', - resource: 'recharge_order', - resourceId: 'order-1', - detail: { amountCents: 1000 }, - createdAt: new Date('2026-07-02T01:00:00.000Z'), - }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'log-1', + tenantId: 'tenant-1', + tenant: { name: '租户A' }, + user: { displayName: '运营' }, + action: 'billing.manual_recharge', + resource: 'recharge_order', + resourceId: 'order-1', + detail: { amountCents: 1000 }, + createdAt: new Date('2026-07-02T01:00:00.000Z'), + }, + ]), count: jest.fn().mockResolvedValue(1), groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]), }, gatewaySubmitDeadLetter: { - findMany: jest.fn().mockResolvedValue([{ - id: 'dead-1', - streamMessageId: '1710000000000-0', - status: 'pending', - failureCode: 'SUBMIT_PROCESSING_FAILED', - failureMessage: 'network down', - rawPayload: '{"upstream":{"passwordCipher":"secret"}}', - commandPayload: { upstream: { account: 'sp', passwordCipher: 'secret' } }, - tenant: { name: '租户A' }, - application: { name: '应用A' }, - channel: { code: 'CMPP-A' }, - }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'dead-1', + streamMessageId: '1710000000000-0', + status: 'pending', + failureCode: 'SUBMIT_PROCESSING_FAILED', + failureMessage: 'network down', + rawPayload: '{"upstream":{"passwordCipher":"secret"}}', + commandPayload: { upstream: { account: 'sp', passwordCipher: 'secret' } }, + tenant: { name: '租户A' }, + application: { name: '应用A' }, + channel: { code: 'CMPP-A' }, + }, + ]), count: jest.fn().mockResolvedValue(1), groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]), findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }), }, smsReceiptAnomaly: { - findMany: jest.fn().mockResolvedValue([{ - id: 'receipt-anomaly-1', - anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1', - anomalyType: 'aggregate_success_then_failure', - status: 'pending', - occurrenceCount: 1, - firstOccurredAt: new Date('2026-08-06T01:00:00.000Z'), - lastOccurredAt: new Date('2026-08-06T01:00:00.000Z'), - tenant: { name: '租户A' }, - application: { name: '应用A' }, - channel: { name: '通道A' }, - messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' }, - submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' }, - receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' }, - }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'receipt-anomaly-1', + anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1', + anomalyType: 'aggregate_success_then_failure', + status: 'pending', + occurrenceCount: 1, + firstOccurredAt: new Date('2026-08-06T01:00:00.000Z'), + lastOccurredAt: new Date('2026-08-06T01:00:00.000Z'), + tenant: { name: '租户A' }, + application: { name: '应用A' }, + channel: { name: '通道A' }, + messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' }, + submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' }, + receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' }, + }, + ]), count: jest.fn().mockResolvedValue(1), groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]), findFirst: jest.fn().mockResolvedValue({ firstOccurredAt: new Date('2026-08-06T01:00:00.000Z') }), }, gatewayDownstreamRecoveryStatus: { - findMany: jest.fn().mockResolvedValue([{ - id: 'recover-1', - account: '100001', - state: 'waiting_connection', - lockOwner: 'gateway-a', - lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), - attemptCount: 2, - failureCategory: 'client_disconnected', - nextRetryAt: new Date('2026-07-08T12:10:00.000Z'), - lastError: 'downstream client is not connected', - tenant: { name: '租户A' }, - application: { name: '应用A' }, - }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'recover-1', + account: '100001', + state: 'waiting_connection', + lockOwner: 'gateway-a', + lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), + attemptCount: 2, + failureCategory: 'client_disconnected', + nextRetryAt: new Date('2026-07-08T12:10:00.000Z'), + lastError: 'downstream client is not connected', + tenant: { name: '租户A' }, + application: { name: '应用A' }, + }, + ]), findUnique: jest.fn().mockResolvedValue({ id: 'recover-1', account: '100001', @@ -153,39 +173,41 @@ function createPrismaMock() { application: { name: '应用A' }, }), count: jest.fn().mockResolvedValue(1), - groupBy: jest.fn().mockResolvedValue([ - { failureCategory: 'client_disconnected', _count: { _all: 1 } }, - ]), + groupBy: jest.fn().mockResolvedValue([{ failureCategory: 'client_disconnected', _count: { _all: 1 } }]), }, smsMessageSegmentAudit: { - findMany: jest.fn().mockResolvedValue([{ - id: 'segment-1', - messageRecordId: 'record-1', - submitId: 'SUB-1', - segmentTotal: 2, - segmentIndex: 1, - sequenceId: 7, - gatewayMessageId: 'GW-1-A', - submitStatus: 'accepted', - receiptStatus: 'delivered', - channel: { name: '通道A' }, - }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'segment-1', + messageRecordId: 'record-1', + submitId: 'SUB-1', + segmentTotal: 2, + segmentIndex: 1, + sequenceId: 7, + gatewayMessageId: 'GW-1-A', + submitStatus: 'accepted', + receiptStatus: 'delivered', + channel: { name: '通道A' }, + }, + ]), }, cmppDownstreamDelivery: { - findMany: jest.fn().mockResolvedValue([{ - id: 'delivery-1', - tenantId: 'tenant-1', - applicationId: 'app-1', - messageId: 'MSG-1', - deliveryType: 'receipt', - status: 'failed', - payload: { account: '100001', phoneNumber: '13800000001' }, - retryCount: 10, - lastError: 'client offline', - tenant: { name: '租户A' }, - application: { name: '应用A' }, - messageRecord: { messageId: 'MSG-1' }, - }]), + findMany: jest.fn().mockResolvedValue([ + { + id: 'delivery-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + messageId: 'MSG-1', + deliveryType: 'receipt', + status: 'failed', + payload: { account: '100001', phoneNumber: '13800000001' }, + retryCount: 10, + lastError: 'client offline', + tenant: { name: '租户A' }, + application: { name: '应用A' }, + messageRecord: { messageId: 'MSG-1' }, + }, + ]), count: jest.fn().mockResolvedValue(1), groupBy: jest.fn().mockResolvedValue([ { deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } }, @@ -210,9 +232,11 @@ describe('OperationsService', () => { await service.listBatchTasks({ tenantId: 'tenant-1', status: 'queued' }); - expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' }, - })); + expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' }, + }), + ); }); it('filters send-chain messages by tenant, application, channel, carrier, content, date, task, phone, and status', async () => { @@ -274,26 +298,37 @@ describe('OperationsService', () => { await service.listMessages({ carrier: 'unknown' }); - expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - AND: [ - { - OR: [ - { carrier: null }, - { - carrier: { - notIn: [ - 'mobile', 'cmcc', '移动', '中国移动', - 'unicom', 'cucc', '联通', '中国联通', - 'telecom', 'ctcc', '电信', '中国电信', - ], + expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: [ + { + OR: [ + { carrier: null }, + { + carrier: { + notIn: [ + 'mobile', + 'cmcc', + '移动', + '中国移动', + 'unicom', + 'cucc', + '联通', + '中国联通', + 'telecom', + 'ctcc', + '电信', + '中国电信', + ], + }, }, - }, - ], - }, - ], + ], + }, + ], + }), }), - })); + ); }); it('separates upstream submit failures from post-acceptance delivery failures', async () => { @@ -301,16 +336,20 @@ describe('OperationsService', () => { const service = new OperationsService(prisma as never); await service.listMessages({ status: 'submit_failed' }); - expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }], + expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }], + }), }), - })); + ); await service.listMessages({ status: 'failed' }); - expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ - where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }), - })); + expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }), + }), + ); }); it('paginates message summaries without preloading detail relations', async () => { @@ -323,18 +362,20 @@ describe('OperationsService', () => { page: 2, pageSize: 25, }); - expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ tenantId: 'tenant-1' }), - skip: 25, - take: 25, - orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], - select: expect.objectContaining({ - id: true, - content: true, - hasDrainageContent: true, - tenant: { select: { id: true, name: true } }, + expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1' }), + skip: 25, + take: 25, + orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], + select: expect.objectContaining({ + id: true, + content: true, + hasDrainageContent: true, + tenant: { select: { id: true, name: true } }, + }), }), - })); + ); const call = prisma.smsMessageRecord.findMany.mock.calls.at(-1)?.[0]; expect(call.select).not.toHaveProperty('submitRecords'); expect(call.select).not.toHaveProperty('receiptRecords'); @@ -351,7 +392,14 @@ describe('OperationsService', () => { await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' }); expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({ - where: { tenantId: 'tenant-1', channelId: 'channel-1', applicationId: undefined, phoneNumber: undefined, content: undefined, receivedAt: undefined }, + where: { + tenantId: 'tenant-1', + channelId: 'channel-1', + applicationId: undefined, + phoneNumber: undefined, + content: undefined, + receivedAt: undefined, + }, include: { tenant: true, application: true, @@ -376,47 +424,51 @@ describe('OperationsService', () => { const service = new OperationsService(prisma as never); await expect(service.getMessage('message-1')).resolves.toEqual(expect.objectContaining({ id: 'message-1' })); - expect(prisma.smsMessageRecord.findUnique).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'message-1' }, - include: expect.objectContaining({ - submitRecords: expect.any(Object), - receiptRecords: expect.any(Object), - downstreamDeliveries: expect.any(Object), + expect(prisma.smsMessageRecord.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'message-1' }, + include: expect.objectContaining({ + submitRecords: expect.any(Object), + receiptRecords: expect.any(Object), + downstreamDeliveries: expect.any(Object), + }), }), - })); + ); }); it('returns the matched message record and the distinct uplink gateway message id to the client view', async () => { const prisma = createPrismaMock(); - prisma.smsUplinkMessage.findMany.mockResolvedValue([{ - id: 'uplink-1', - tenantId: 'tenant-1', - applicationId: 'app-1', - messageRecordId: 'record-1', - messageId: null, - gatewayMessageId: '8412634832294102675', - phoneNumber: '13800000001', - destId: '10690000', - content: 'TD', - matchStatus: 'matched', - matchReason: '手机号 72 小时窗口唯一匹配', - receivedAt: new Date('2026-08-26T01:00:00.000Z'), - createdAt: new Date('2026-08-26T01:00:00.000Z'), - application: { id: 'app-1', name: '应用A' }, - messageRecord: { - id: 'record-1', + prisma.smsUplinkMessage.findMany.mockResolvedValue([ + { + id: 'uplink-1', + tenantId: 'tenant-1', applicationId: 'app-1', - messageId: 'MSG-1', + messageRecordId: 'record-1', + messageId: null, + gatewayMessageId: '8412634832294102675', phoneNumber: '13800000001', - content: '通知内容', - billingUnits: 1, - amountCents: 325, - status: 'delivered', - queuedAt: new Date('2026-08-25T01:00:00.000Z'), + destId: '10690000', + content: 'TD', + matchStatus: 'matched', + matchReason: '手机号 72 小时窗口唯一匹配', + receivedAt: new Date('2026-08-26T01:00:00.000Z'), + createdAt: new Date('2026-08-26T01:00:00.000Z'), application: { id: 'app-1', name: '应用A' }, + messageRecord: { + id: 'record-1', + applicationId: 'app-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + content: '通知内容', + billingUnits: 1, + amountCents: 325, + status: 'delivered', + queuedAt: new Date('2026-08-25T01:00:00.000Z'), + application: { id: 'app-1', name: '应用A' }, + }, + matchCandidates: [], }, - matchCandidates: [], - }]); + ]); const service = new OperationsService(prisma as never); const [uplink] = await service.listClientUplinkMessages({ tenantId: 'tenant-1' }); @@ -432,36 +484,51 @@ describe('OperationsService', () => { it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => { const prisma = createPrismaMock(); - prisma.smsMessageRecord.findMany.mockResolvedValue([{ - id: 'record-1', - tenantId: 'tenant-1', - batchTaskId: 'task-1', - applicationId: 'app-1', - channelId: 'channel-1', - messageId: 'MSG-1', - phoneNumber: '13800000001', - carrier: 'mobile', - province: '上海', - content: '验证码1234', - billingUnits: 1, - amountCents: 352, - status: 'delivered', - queuedAt: new Date('2026-07-21T01:00:00.000Z'), - application: { id: 'app-1', name: '应用A', secretHash: 'secret' }, - tenant: { id: 'tenant-1', name: '企业A' }, - channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 }, - submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }], - receiptRecords: [{ - id: 'receipt-1', messageId: 'MSG-1', gatewayMessageId: 'GW-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD', - errorCode: null, errorMessage: null, deliveredAt: new Date('2026-07-21T01:00:05.000Z'), createdAt: new Date('2026-07-21T01:00:05.000Z'), - }], - }]); + prisma.smsMessageRecord.findMany.mockResolvedValue([ + { + id: 'record-1', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + channelId: 'channel-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + carrier: 'mobile', + province: '上海', + content: '验证码1234', + billingUnits: 1, + amountCents: 352, + status: 'delivered', + queuedAt: new Date('2026-07-21T01:00:00.000Z'), + application: { id: 'app-1', name: '应用A', secretHash: 'secret' }, + tenant: { id: 'tenant-1', name: '企业A' }, + channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 }, + submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }], + receiptRecords: [ + { + id: 'receipt-1', + messageId: 'MSG-1', + gatewayMessageId: 'GW-1', + receiptStatus: 'delivered', + rawStatus: 'DELIVRD', + errorCode: null, + errorMessage: null, + deliveredAt: new Date('2026-07-21T01:00:05.000Z'), + createdAt: new Date('2026-07-21T01:00:05.000Z'), + }, + ], + }, + ]); const service = new OperationsService(prisma as never); const [message] = await service.listClientMessages({ tenantId: 'tenant-1' }); expect(message).toMatchObject({ - id: 'record-1', messageId: 'MSG-1', carrier: 'mobile', province: '上海', application: { id: 'app-1', name: '应用A' }, + id: 'record-1', + messageId: 'MSG-1', + carrier: 'mobile', + province: '上海', + application: { id: 'app-1', name: '应用A' }, receiptRecords: [expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD' })], }); expect(message).not.toHaveProperty('tenant'); @@ -474,11 +541,19 @@ describe('OperationsService', () => { it('returns a client dashboard without gateway state or supplier channel secrets', async () => { const prisma = createPrismaMock(); - prisma.smsBatchTask.findMany.mockResolvedValue([{ - id: 'task-1', taskNo: 'BATCH-1', tenantId: 'tenant-1', applicationId: 'app-1', phoneTotal: 1, status: 'finished', - createdAt: new Date('2026-07-21T01:00:00.000Z'), application: { id: 'app-1', name: '应用A' }, - messages: [{ channel: { account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 } }], - }]); + prisma.smsBatchTask.findMany.mockResolvedValue([ + { + id: 'task-1', + taskNo: 'BATCH-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + phoneTotal: 1, + status: 'finished', + createdAt: new Date('2026-07-21T01:00:00.000Z'), + application: { id: 'app-1', name: '应用A' }, + messages: [{ channel: { account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 } }], + }, + ]); const service = new OperationsService(prisma as never); const dashboard = await service.clientDashboard({ tenantId: 'tenant-1' }); @@ -506,25 +581,34 @@ describe('OperationsService', () => { it('builds dashboard and statistics aggregates', async () => { const prisma = createPrismaMock(); - prisma.$queryRaw.mockResolvedValueOnce([{ - tenantId: 'tenant-1', - tenantName: '租户A', - todaySpendCents: 24000n, - balanceCents: 1000000n, - creditCents: 50000n, - }]).mockResolvedValueOnce([{ - segmentCount: 20n, - deliveredSegmentCount: 18n, - billedCents: 360n, - costCents: 216n, - }]).mockResolvedValueOnce([ - { hour: 9, submittedCount: 12n, successCount: 10n }, - { hour: 10, submittedCount: 5n, successCount: 4n }, - ]).mockResolvedValueOnce([ - { category: 'templates', count: 3n, averageProcessingMs: 90_000n }, - { category: 'signatures', count: 2n, averageProcessingMs: 120_000n }, - ]); - prisma.cmppDownstreamDelivery.count = jest.fn() + prisma.$queryRaw + .mockResolvedValueOnce([ + { + tenantId: 'tenant-1', + tenantName: '租户A', + todaySpendCents: 24000n, + balanceCents: 1000000n, + creditCents: 50000n, + }, + ]) + .mockResolvedValueOnce([ + { + segmentCount: 20n, + deliveredSegmentCount: 18n, + billedCents: 360n, + costCents: 216n, + }, + ]) + .mockResolvedValueOnce([ + { hour: 9, submittedCount: 12n, successCount: 10n }, + { hour: 10, submittedCount: 5n, successCount: 4n }, + ]) + .mockResolvedValueOnce([ + { category: 'templates', count: 3n, averageProcessingMs: 90_000n }, + { category: 'signatures', count: 2n, averageProcessingMs: 120_000n }, + ]); + prisma.cmppDownstreamDelivery.count = jest + .fn() .mockResolvedValueOnce(3) .mockResolvedValueOnce(2) .mockResolvedValueOnce(8) @@ -568,14 +652,18 @@ describe('OperationsService', () => { { category: 'signatures', label: '签名', count: 2, averageProcessingMs: 120000 }, { category: 'drainageInfos', label: '引流信息', count: 0, averageProcessingMs: null }, ], - enterpriseSpendRanks: [{ - tenantId: 'tenant-1', - tenantName: '租户A', - todaySpendCents: 24000, - balanceCents: 1000000, - creditCents: 50000, - }], - gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }], + enterpriseSpendRanks: [ + { + tenantId: 'tenant-1', + tenantName: '租户A', + todaySpendCents: 24000, + balanceCents: 1000000, + creditCents: 50000, + }, + ], + gatewayConnections: [ + { status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }, + ], downstreamDeliverySummary: expect.objectContaining({ pending: 3, failed: 2, @@ -619,10 +707,7 @@ describe('OperationsService', () => { where: { tenantId: 'tenant-1', createdAt: { gte: expect.any(Date) }, - OR: [ - { transactionType: 'refunded' }, - { transactionType: 'released', relatedType: 'sms_message_record' }, - ], + OR: [{ transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }], }, _sum: { amountCents: true }, _count: { _all: true }, @@ -640,55 +725,63 @@ describe('OperationsService', () => { it('returns real daily channel and signature quality for the selected Shanghai date', async () => { const prisma = createPrismaMock(); prisma.$queryRaw - .mockResolvedValueOnce([{ - channelId: 'channel-1', - channelName: '通道一', - total: 5, - acceptedCount: 4, - submitFailureCount: 1, - submitFailureRate: 20, - successCount: 3, - unknownCount: 1, - failureCount: 1, - successRate: 60, - unknownRate: 20, - failureRate: 20, - averageArrivalMs: 1200, - }]) - .mockResolvedValueOnce([{ - id: 'signature-1:plain', - signatureId: 'signature-1', - signatureName: '【测试签名】', - tenantId: 'tenant-1', - tenantName: '租户A', - hasDrainage: false, - total: 5, - acceptedCount: 4, - submitFailureCount: 1, - successCount: 3, - unknownCount: 1, - failureCount: 1, - successRate: 60, - averageArrivalMs: 1200, - }]) - .mockResolvedValueOnce([{ - total: 5, - successCount: 3, - unknownCount: 1, - failureCount: 1, - successRate: 60, - }]) - .mockResolvedValueOnce([{ - applicationId: 'app-1', - applicationName: '通知应用', - tenantId: 'tenant-1', - tenantName: '租户A', - total: 5, - successCount: 3, - unknownCount: 1, - failureCount: 1, - successRate: 60, - }]); + .mockResolvedValueOnce([ + { + channelId: 'channel-1', + channelName: '通道一', + total: 5, + acceptedCount: 4, + submitFailureCount: 1, + submitFailureRate: 20, + successCount: 3, + unknownCount: 1, + failureCount: 1, + successRate: 60, + unknownRate: 20, + failureRate: 20, + averageArrivalMs: 1200, + }, + ]) + .mockResolvedValueOnce([ + { + id: 'signature-1:plain', + signatureId: 'signature-1', + signatureName: '【测试签名】', + tenantId: 'tenant-1', + tenantName: '租户A', + hasDrainage: false, + total: 5, + acceptedCount: 4, + submitFailureCount: 1, + successCount: 3, + unknownCount: 1, + failureCount: 1, + successRate: 60, + averageArrivalMs: 1200, + }, + ]) + .mockResolvedValueOnce([ + { + total: 5, + successCount: 3, + unknownCount: 1, + failureCount: 1, + successRate: 60, + }, + ]) + .mockResolvedValueOnce([ + { + applicationId: 'app-1', + applicationName: '通知应用', + tenantId: 'tenant-1', + tenantName: '租户A', + total: 5, + successCount: 3, + unknownCount: 1, + failureCount: 1, + successRate: 60, + }, + ]); const service = new OperationsService(prisma as never); await expect(service.sendQuality('2026-07-24')).resolves.toEqual({ @@ -701,22 +794,28 @@ describe('OperationsService', () => { failureCount: 1, successRate: 60, }, - channels: [expect.objectContaining({ - channelId: 'channel-1', - total: 5, - acceptedCount: 4, - submitFailureCount: 1, - submitFailureRate: 20, - successRate: 60, - })], - signatures: [expect.objectContaining({ - signatureId: 'signature-1', - signatureName: '【测试签名】', - hasDrainage: false, - acceptedCount: 4, - submitFailureCount: 1, - })], - applications: [expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 })], + channels: [ + expect.objectContaining({ + channelId: 'channel-1', + total: 5, + acceptedCount: 4, + submitFailureCount: 1, + submitFailureRate: 20, + successRate: 60, + }), + ], + signatures: [ + expect.objectContaining({ + signatureId: 'signature-1', + signatureName: '【测试签名】', + hasDrainage: false, + acceptedCount: 4, + submitFailureCount: 1, + }), + ], + applications: [ + expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 }), + ], }); expect(prisma.$queryRaw).toHaveBeenCalledTimes(4); }); @@ -736,9 +835,15 @@ describe('OperationsService', () => { }); expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); expect(prisma.smsSignature.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); - expect(prisma.smsDrainageInfo.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); - expect(prisma.enterpriseCertification.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending' } }); - expect(prisma.smsSendTask.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending_review' } }); + expect(prisma.smsDrainageInfo.count).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', auditStatus: 'pending' }, + }); + expect(prisma.enterpriseCertification.count).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', status: 'pending' }, + }); + expect(prisma.smsSendTask.count).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', status: 'pending_review' }, + }); expect(prisma.smsMessageRecord.groupBy).not.toHaveBeenCalled(); expect(prisma.$queryRaw).not.toHaveBeenCalled(); }); @@ -761,22 +866,24 @@ describe('OperationsService', () => { it('returns paged registered-signature quality with channel and carrier breakdowns', async () => { const prisma = createPrismaMock(); prisma.$queryRaw - .mockResolvedValueOnce([{ - signatureId: 'signature-1', - signatureName: '【测试签名】', - tenantId: 'tenant-1', - tenantName: '租户A', - applicationNames: '通知应用、营销应用', - total: 5, - acceptedCount: 4, - submitFailureCount: 1, - successCount: 3, - unknownCount: 1, - failureCount: 0, - successRate: 75, - averageArrivalMs: 1200, - rowCount: 12, - }]) + .mockResolvedValueOnce([ + { + signatureId: 'signature-1', + signatureName: '【测试签名】', + tenantId: 'tenant-1', + tenantName: '租户A', + applicationNames: '通知应用、营销应用', + total: 5, + acceptedCount: 4, + submitFailureCount: 1, + successCount: 3, + unknownCount: 1, + failureCount: 0, + successRate: 75, + averageArrivalMs: 1200, + rowCount: 12, + }, + ]) .mockResolvedValueOnce([ { signatureId: 'signature-1', @@ -829,41 +936,45 @@ describe('OperationsService', () => { ]); const service = new OperationsService(prisma as never); - await expect(service.signatureQuality({ + await expect( + service.signatureQuality({ + date: '2026-07-24', + keyword: '测试', + page: 2, + pageSize: 5, + }), + ).resolves.toEqual({ date: '2026-07-24', - keyword: '测试', - page: 2, - pageSize: 5, - })).resolves.toEqual({ - date: '2026-07-24', - items: [expect.objectContaining({ - signatureId: 'signature-1', - signatureName: '【测试签名】', - total: 5, - channelSubmitTotal: 6, - carrierOverview: [ - expect.objectContaining({ - carrier: 'mobile', - businessMessageCount: 3, - finalSuccessCount: 2, - finalSuccessRate: 66.7, - }), - expect.objectContaining({ - carrier: 'telecom', - businessMessageCount: 2, - finalSuccessCount: 1, - finalSuccessRate: 50, - }), - ], - breakdowns: [ - expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }), - expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }), - ], - drainageBreakdowns: [ - expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }), - expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }), - ], - })], + items: [ + expect.objectContaining({ + signatureId: 'signature-1', + signatureName: '【测试签名】', + total: 5, + channelSubmitTotal: 6, + carrierOverview: [ + expect.objectContaining({ + carrier: 'mobile', + businessMessageCount: 3, + finalSuccessCount: 2, + finalSuccessRate: 66.7, + }), + expect.objectContaining({ + carrier: 'telecom', + businessMessageCount: 2, + finalSuccessCount: 1, + finalSuccessRate: 50, + }), + ], + breakdowns: [ + expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }), + expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }), + ], + drainageBreakdowns: [ + expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }), + expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }), + ], + }), + ], total: 12, page: 2, pageSize: 5, @@ -881,7 +992,7 @@ describe('OperationsService', () => { items: [], total: 0, page: 1, - pageSize: 10, + pageSize: 25, }); expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); }); @@ -934,13 +1045,15 @@ describe('OperationsService', () => { await service.systemLogs({ level: 'error', page: 2, pageSize: 5 }); - expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - AND: expect.objectContaining({ OR: expect.any(Array) }), + expect(prisma.operationLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.objectContaining({ OR: expect.any(Array) }), + }), + skip: 5, + take: 5, }), - skip: 5, - take: 5, - })); + ); expect(prisma.operationLog.count).toHaveBeenCalledWith({ where: expect.objectContaining({ AND: expect.objectContaining({ OR: expect.any(Array) }) }), }); @@ -952,27 +1065,31 @@ describe('OperationsService', () => { await service.systemLogs({ createdAtFrom: '2026-08-21', createdAtTo: '2026-08-27' }); - expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - createdAt: { - gte: new Date('2026-08-20T16:00:00.000Z'), - lte: new Date('2026-08-27T15:59:59.999Z'), - }, + expect(prisma.operationLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: { + gte: new Date('2026-08-20T16:00:00.000Z'), + lte: new Date('2026-08-27T15:59:59.999Z'), + }, + }), }), - })); + ); }); it('exports filtered operation logs with a traceable operation id', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); - await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual(expect.objectContaining({ - operationId: expect.any(String), - status: 'completed', - recordCount: 1, - truncated: false, - content: expect.stringContaining('billing.manual_recharge'), - })); + await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual( + expect.objectContaining({ + operationId: expect.any(String), + status: 'completed', + recordCount: 1, + truncated: false, + content: expect.stringContaining('billing.manual_recharge'), + }), + ); expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 10_001 })); }); @@ -982,8 +1099,12 @@ describe('OperationsService', () => { const exported = await service.exportSystemLogs({ tenantId: 'spoofed-tenant' }, 'client-user'); - expect(prisma.user.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'client-user' }) })); - expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }) })); + expect(prisma.user.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ id: 'client-user' }) }), + ); + expect(prisma.operationLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }) }), + ); expect(exported.content.split('\n')[0]).toBe('时间,级别,模块,操作人,动作,资源ID'); expect(exported.content).not.toContain('amountCents'); }); @@ -992,11 +1113,13 @@ describe('OperationsService', () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); - await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual(expect.objectContaining({ - total: 1, - page: 1, - pageSize: 100, - })); + await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual( + expect.objectContaining({ + total: 1, + page: 1, + pageSize: 100, + }), + ); expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 })); }); @@ -1004,19 +1127,23 @@ describe('OperationsService', () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); - await expect(service.listGatewaySubmitDeadLetters({ - tenantId: 'tenant-1', - status: 'pending', - keyword: 'SUBMIT', - page: 1, - pageSize: 10, - })).resolves.toEqual({ - items: [expect.objectContaining({ - id: 'dead-1', + await expect( + service.listGatewaySubmitDeadLetters({ + tenantId: 'tenant-1', status: 'pending', - rawPayloadAvailable: true, - commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } }, - })], + keyword: 'SUBMIT', + page: 1, + pageSize: 10, + }), + ).resolves.toEqual({ + items: [ + expect.objectContaining({ + id: 'dead-1', + status: 'pending', + rawPayloadAvailable: true, + commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } }, + }), + ], total: 1, page: 1, pageSize: 10, @@ -1045,59 +1172,69 @@ describe('OperationsService', () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); - await expect(service.listReceiptAnomalies({ - tenantId: 'tenant-1', - channelId: 'channel-1', - status: 'pending', - anomalyType: 'aggregate_success_then_failure', - keyword: 'MSG-1', - page: 1, - pageSize: 10, - })).resolves.toEqual(expect.objectContaining({ - total: 1, - page: 1, - pageSize: 10, - summary: { - pending: 1, - resolved: 0, - ignored: 0, - oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'), - }, - })); - expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ + await expect( + service.listReceiptAnomalies({ tenantId: 'tenant-1', channelId: 'channel-1', status: 'pending', anomalyType: 'aggregate_success_then_failure', + keyword: 'MSG-1', + page: 1, + pageSize: 10, }), - orderBy: [{ lastOccurredAt: 'desc' }, { id: 'desc' }], - take: 10, - include: { - tenant: { select: { id: true, name: true } }, - application: { select: { id: true, name: true } }, - channel: { select: { id: true, code: true, name: true, status: true } }, - messageRecord: { select: { messageId: true, phoneNumber: true, status: true } }, - submitRecord: { select: { submitId: true, submitStatus: true } }, - receiptRecord: { select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true } }, - }, - })); + ).resolves.toEqual( + expect.objectContaining({ + total: 1, + page: 1, + pageSize: 10, + summary: { + pending: 1, + resolved: 0, + ignored: 0, + oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'), + }, + }), + ); + expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + tenantId: 'tenant-1', + channelId: 'channel-1', + status: 'pending', + anomalyType: 'aggregate_success_then_failure', + }), + orderBy: [{ lastOccurredAt: 'desc' }, { id: 'desc' }], + take: 10, + include: { + tenant: { select: { id: true, name: true } }, + application: { select: { id: true, name: true } }, + channel: { select: { id: true, code: true, name: true, status: true } }, + messageRecord: { select: { messageId: true, phoneNumber: true, status: true } }, + submitRecord: { select: { submitId: true, submitStatus: true } }, + receiptRecord: { + select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true }, + }, + }, + }), + ); }); it('returns paginated downstream deliveries', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); - await expect(service.listDownstreamDeliveries({ - tenantId: 'tenant-1', - deliveryType: 'receipt', - status: 'failed', - keyword: '1380', - createdAtFrom: '2026-07-01', - createdAtTo: '2026-07-15', - page: 1, - pageSize: 10, - })).resolves.toEqual({ + await expect( + service.listDownstreamDeliveries({ + tenantId: 'tenant-1', + deliveryType: 'receipt', + status: 'failed', + keyword: '1380', + createdAtFrom: '2026-07-01', + createdAtTo: '2026-07-15', + page: 1, + pageSize: 10, + }), + ).resolves.toEqual({ items: [expect.objectContaining({ id: 'delivery-1', status: 'failed' })], total: 1, page: 1, @@ -1128,7 +1265,8 @@ describe('OperationsService', () => { it('builds downstream delivery dashboard aggregates', async () => { const prisma = createPrismaMock(); - prisma.cmppDownstreamDelivery.count = jest.fn() + prisma.cmppDownstreamDelivery.count = jest + .fn() .mockResolvedValueOnce(12) .mockResolvedValueOnce(3) .mockResolvedValueOnce(0) @@ -1142,7 +1280,8 @@ describe('OperationsService', () => { .mockResolvedValueOnce(2) .mockResolvedValueOnce(1) .mockResolvedValueOnce(0); - prisma.cmppDownstreamDelivery.groupBy = jest.fn() + prisma.cmppDownstreamDelivery.groupBy = jest + .fn() .mockResolvedValueOnce([ { deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } }, { deliveryType: 'receipt', status: 'failed', _count: { _all: 1 } }, @@ -1163,11 +1302,13 @@ describe('OperationsService', () => { ]); const service = new OperationsService(prisma as never); - await expect(service.downstreamDeliveryDashboard({ - tenantId: 'tenant-1', - applicationId: 'app-1', - deliveryType: 'all', - })).resolves.toEqual({ + await expect( + service.downstreamDeliveryDashboard({ + tenantId: 'tenant-1', + applicationId: 'app-1', + deliveryType: 'all', + }), + ).resolves.toEqual({ summary: { total: 12, pending: 3, @@ -1182,8 +1323,26 @@ describe('OperationsService', () => { alertCount: 2, }, typeBreakdown: [ - { deliveryType: 'receipt', total: 9, pending: 2, awaitingAck: 0, delivered: 6, failed: 1, unconfirmed: 0, rejected: 0 }, - { deliveryType: 'uplink', total: 3, pending: 1, awaitingAck: 0, delivered: 2, failed: 0, unconfirmed: 0, rejected: 0 }, + { + deliveryType: 'receipt', + total: 9, + pending: 2, + awaitingAck: 0, + delivered: 6, + failed: 1, + unconfirmed: 0, + rejected: 0, + }, + { + deliveryType: 'uplink', + total: 3, + pending: 1, + awaitingAck: 0, + delivered: 2, + failed: 0, + unconfirmed: 0, + rejected: 0, + }, ], retryBuckets: [ { label: '0次', count: 2 }, @@ -1191,8 +1350,28 @@ describe('OperationsService', () => { { label: '4次及以上', count: 0 }, ], topApplications: [ - { applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 1 }, - { applicationId: 'app-2', name: '应用B', pending: 1, awaitingAck: 0, failed: 0, unconfirmed: 0, rejected: 0, delivered: 3, alertCount: 1 }, + { + applicationId: 'app-1', + name: '应用A', + pending: 2, + awaitingAck: 0, + failed: 1, + unconfirmed: 0, + rejected: 0, + delivered: 5, + alertCount: 1, + }, + { + applicationId: 'app-2', + name: '应用B', + pending: 1, + awaitingAck: 0, + failed: 0, + unconfirmed: 0, + rejected: 0, + delivered: 3, + alertCount: 1, + }, ], }); @@ -1222,7 +1401,8 @@ describe('OperationsService', () => { it('returns paginated downstream recovery statuses', async () => { const prisma = createPrismaMock(); - prisma.gatewayDownstreamRecoveryStatus.count = jest.fn() + prisma.gatewayDownstreamRecoveryStatus.count = jest + .fn() .mockResolvedValueOnce(1) .mockResolvedValueOnce(0) .mockResolvedValueOnce(0) @@ -1231,17 +1411,19 @@ describe('OperationsService', () => { .mockResolvedValueOnce(1); const service = new OperationsService(prisma as never); - await expect(service.listDownstreamRecoveryStatuses({ - tenantId: 'tenant-1', - applicationId: 'app-1', - state: 'waiting_connection', - failureCategory: 'client_disconnected', - keyword: '100001', - updatedAtFrom: '2026-07-02', - updatedAtTo: '2026-07-08', - page: 1, - pageSize: 10, - })).resolves.toEqual({ + await expect( + service.listDownstreamRecoveryStatuses({ + tenantId: 'tenant-1', + applicationId: 'app-1', + state: 'waiting_connection', + failureCategory: 'client_disconnected', + keyword: '100001', + updatedAtFrom: '2026-07-02', + updatedAtTo: '2026-07-08', + page: 1, + pageSize: 10, + }), + ).resolves.toEqual({ items: [expect.objectContaining({ id: 'recover-1', account: '100001', state: 'waiting_connection' })], total: 1, page: 1, @@ -1253,19 +1435,19 @@ describe('OperationsService', () => { failed: 0, waitingConnection: 1, backoff: 1, - failureCategories: [ - { category: 'client_disconnected', count: 1 }, - ], + failureCategories: [{ category: 'client_disconnected', count: 1 }], }, }); - expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - updatedAt: { - gte: new Date('2026-07-01T16:00:00.000Z'), - lte: new Date('2026-07-08T15:59:59.999Z'), - }, + expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + updatedAt: { + gte: new Date('2026-07-01T16:00:00.000Z'), + lte: new Date('2026-07-08T15:59:59.999Z'), + }, + }), }), - })); + ); }); it('returns downstream recovery status detail', async () => { @@ -1290,26 +1472,32 @@ describe('OperationsService', () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); - await expect(service.exportDownstreamRecoveryStatuses({ - tenantId: 'tenant-1', - state: 'waiting_connection', - keyword: '100001', - updatedAtFrom: '2026-07-02', - updatedAtTo: '2026-07-08', - })).resolves.toEqual(expect.objectContaining({ - total: 1, - fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/), - content: expect.stringContaining('100001'), - })); - expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - failureCategory: undefined, - updatedAt: { - gte: new Date('2026-07-01T16:00:00.000Z'), - lte: new Date('2026-07-08T15:59:59.999Z'), - }, + await expect( + service.exportDownstreamRecoveryStatuses({ + tenantId: 'tenant-1', + state: 'waiting_connection', + keyword: '100001', + updatedAtFrom: '2026-07-02', + updatedAtTo: '2026-07-08', }), - })); + ).resolves.toEqual( + expect.objectContaining({ + total: 1, + fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/), + content: expect.stringContaining('100001'), + }), + ); + expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + failureCategory: undefined, + updatedAt: { + gte: new Date('2026-07-01T16:00:00.000Z'), + lte: new Date('2026-07-08T15:59:59.999Z'), + }, + }), + }), + ); }); it('returns message segment audit rows', async () => { diff --git a/api/src/operations/queries/quality.queries.ts b/api/src/operations/queries/quality.queries.ts index 4352543..44c14c0 100644 --- a/api/src/operations/queries/quality.queries.ts +++ b/api/src/operations/queries/quality.queries.ts @@ -1,16 +1,14 @@ -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 type { SignatureQualityQuery } from '../operations.contracts'; +import { messageWhere, qualityBusinessDay, normalizeGroupBy, positiveInteger } from '../operations.helpers'; // R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline. export class OperationsQualityQueries { constructor(private readonly prisma: PrismaService) {} -async statistics(query: { tenantId?: string; groupBy?: string }) { + async statistics(query: { tenantId?: string; groupBy?: string }) { const groupBy = normalizeGroupBy(query.groupBy); if (groupBy === 'tenantId') { return this.prisma.smsMessageRecord.groupBy({ @@ -35,24 +33,26 @@ async statistics(query: { tenantId?: string; groupBy?: string }) { _sum: { amountCents: true, billingUnits: true }, }); } -async sendQuality(date?: string) { + async sendQuality(date?: string) { const day = qualityBusinessDay(date); const [channels, signatureSplits, summaryRows, applications] = await Promise.all([ - this.prisma.$queryRaw>(Prisma.sql` + this.prisma.$queryRaw< + Array<{ + channelId: string; + channelName: string; + total: number; + acceptedCount: number; + submitFailureCount: number; + submitFailureRate: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + unknownRate: number; + failureRate: number; + averageArrivalMs: number | null; + }> + >(Prisma.sql` WITH base AS ( SELECT submit."channelId" AS channel_id, @@ -131,22 +131,24 @@ async sendQuality(date?: string) { GROUP BY channel_id ORDER BY COUNT(*) DESC, channel_id `), - this.prisma.$queryRaw>(Prisma.sql` + this.prisma.$queryRaw< + Array<{ + id: string; + signatureId: string; + signatureName: string; + tenantId: string; + tenantName: string; + hasDrainage: boolean; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs: number | null; + }> + >(Prisma.sql` WITH base AS ( SELECT message."signatureId" AS signature_id, @@ -216,13 +218,15 @@ async sendQuality(date?: string) { GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage ORDER BY "successCount" DESC, total DESC, signature.name `), - this.prisma.$queryRaw>(Prisma.sql` + this.prisma.$queryRaw< + Array<{ + total: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + }> + >(Prisma.sql` WITH base AS ( SELECT message.status, message."receiptStatus" AS receipt_status FROM "SmsMessageRecord" message @@ -251,17 +255,19 @@ async sendQuality(date?: string) { END AS "successRate" FROM base `), - this.prisma.$queryRaw>(Prisma.sql` + this.prisma.$queryRaw< + Array<{ + applicationId: string; + applicationName: string; + tenantId: string; + tenantName: string; + total: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + }> + >(Prisma.sql` WITH base AS ( SELECT message."applicationId" AS application_id, @@ -314,28 +320,30 @@ async sendQuality(date?: string) { }; return { date: day.key, summary, channels, signatures, drainageSignatures, applications }; } -async signatureQuality(query: SignatureQualityQuery) { + async signatureQuality(query: SignatureQualityQuery) { const day = qualityBusinessDay(query.date); const page = positiveInteger(query.page, 1); - const pageSize = Math.min(50, positiveInteger(query.pageSize, 10)); + const pageSize = Math.min(100, positiveInteger(query.pageSize, 25)); const keyword = query.keyword?.trim() || null; const keywordPattern = keyword ? `%${keyword}%` : null; - const summaries = await this.prisma.$queryRaw>(Prisma.sql` + const summaries = await this.prisma.$queryRaw< + Array<{ + signatureId: string; + signatureName: string; + tenantId: string; + tenantName: string; + applicationNames: string | null; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs: number | null; + rowCount: number; + }> + >(Prisma.sql` WITH base AS ( SELECT message."signatureId" AS signature_id, @@ -415,23 +423,26 @@ async signatureQuality(query: SignatureQualityQuery) { OFFSET ${(page - 1) * pageSize} `); const signatureIds = summaries.map((item) => item.signatureId); - const drainageBreakdowns = signatureIds.length === 0 - ? [] - : await this.prisma.$queryRaw>(Prisma.sql` + const drainageBreakdowns = + signatureIds.length === 0 + ? [] + : await this.prisma.$queryRaw< + Array<{ + signatureId: string; + channelId: string; + channelName: string; + carrier: string; + drainageState: 'with' | 'without' | 'unknown'; + total: number; + acceptedCount: number; + submitFailureCount: number; + successCount: number; + unknownCount: number; + failureCount: number; + successRate: number; + averageArrivalMs: number | null; + }> + >(Prisma.sql` WITH base AS ( SELECT message."signatureId" AS signature_id, @@ -526,16 +537,19 @@ async signatureQuality(query: SignatureQualityQuery) { GROUP BY signature_id, channel_id, carrier, drainage_state ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state `); - const carrierOverview = signatureIds.length === 0 - ? [] - : await this.prisma.$queryRaw>(Prisma.sql` + const carrierOverview = + signatureIds.length === 0 + ? [] + : await this.prisma.$queryRaw< + Array<{ + signatureId: string; + carrier: string; + businessMessageCount: number; + finalSuccessCount: number; + finalSuccessRate: number; + averageArrivalMs: number | null; + }> + >(Prisma.sql` SELECT message."signatureId" AS "signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, @@ -570,6 +584,7 @@ async signatureQuality(query: SignatureQualityQuery) { ORDER BY message."signatureId", COUNT(*) DESC, carrier `); const items = summaries.map(({ rowCount: _rowCount, ...summary }) => { + void _rowCount; const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId); const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns); return { @@ -610,26 +625,41 @@ type SignatureSplitRow = { function aggregateSignatureRows(rows: SignatureSplitRow[]) { const grouped = new Map(); rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row])); - return [...grouped.values()].map((parts) => { - const first = parts[0]; - const total = parts.reduce((sum, item) => sum + item.total, 0); - const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0); - const successCount = parts.reduce((sum, item) => sum + item.successCount, 0); - const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0); - return { - ...first, - id: first.signatureId, - hasDrainage: false, - total, - acceptedCount, - submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0), - successCount, - unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0), - failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0), - successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10, - averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight), - }; - }).sort((left, right) => right.successCount - left.successCount || right.total - left.total || left.signatureName.localeCompare(right.signatureName)); + return [...grouped.values()] + .map((parts) => { + const first = parts[0]; + const total = parts.reduce((sum, item) => sum + item.total, 0); + const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0); + const successCount = parts.reduce((sum, item) => sum + item.successCount, 0); + const arrivalWeight = parts.reduce( + (sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), + 0, + ); + return { + ...first, + id: first.signatureId, + hasDrainage: false, + total, + acceptedCount, + submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0), + successCount, + unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0), + failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0), + successRate: acceptedCount === 0 ? 0 : Math.round((successCount * 1000) / acceptedCount) / 10, + averageArrivalMs: + arrivalWeight === 0 + ? null + : Math.round( + parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight, + ), + }; + }) + .sort( + (left, right) => + right.successCount - left.successCount || + right.total - left.total || + left.signatureName.localeCompare(right.signatureName), + ); } type DrainageBreakdownRow = { @@ -670,8 +700,13 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) { successCount, unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0), failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0), - successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10, - averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight), + successRate: acceptedCount === 0 ? 0 : Math.round((successCount * 1000) / acceptedCount) / 10, + averageArrivalMs: + arrivalWeight === 0 + ? null + : Math.round( + parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight, + ), }; }); } diff --git a/api/src/signature-retirement/daily-message.spec.ts b/api/src/signature-retirement/daily-message.spec.ts new file mode 100644 index 0000000..531a8f9 --- /dev/null +++ b/api/src/signature-retirement/daily-message.spec.ts @@ -0,0 +1,87 @@ +import { SignatureRetirementService } from './signature-retirement.service'; + +describe('daily application messages and date formatting', () => { + function setup() { + const detections = [ + { id: 'a', tenantId: 't1', applicationId: 'app1', dimensionType: 'enterprise' }, + { id: 'b', tenantId: 't1', applicationId: 'app1', dimensionType: 'channel' }, + { id: 'c', tenantId: 't1', applicationId: 'app2', dimensionType: 'enterprise' }, + { id: 'd', tenantId: 't1', applicationId: null, dimensionType: 'enterprise' }, + { id: 'e', tenantId: 't2', applicationId: null, dimensionType: 'enterprise' }, + ].map((item) => ({ + ...item, + cycleId: `cycle-${item.id}`, + notificationTitle: '预警', + notificationContent: `冻结正文${item.id}`, + })); + const prisma = { + signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue(detections) }, + smsApplication: { + findMany: jest.fn().mockResolvedValue([ + { id: 'app1', name: '应用一' }, + { id: 'app2', name: '应用二' }, + ]), + }, + signatureRetirementMessage: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }, + signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) }, + }; + return { prisma, service: new SignatureRetirementService(prisma as never) }; + } + + it('creates one frozen message per application per day including all dimensions and separate unbound tenants', async () => { + const { prisma, service } = setup(); + expect(await service.publishNotifications('2026-09-09')).toEqual({ notificationDate: '2026-09-09', created: 4 }); + const data = prisma.signatureRetirementMessage.create.mock.calls.map(([arg]) => arg.data); + expect(data[0]).toEqual( + expect.objectContaining({ + title: '应用一 · 签名清退预警', + detectionIds: ['a', 'b'], + content: '冻结正文a\n冻结正文b', + }), + ); + expect(new Set(data.map((item) => item.dailyGroupKey)).size).toBe(4); + expect(data.every((item) => item.notificationDate.toISOString() === '2026-09-09T00:00:00.000Z')).toBe(true); + }); + + it('does not regenerate already-published or historical single-detection messages', async () => { + const { prisma, service } = setup(); + prisma.signatureRetirementMessage.findFirst.mockResolvedValue({ id: 'existing' }); + expect((await service.publishNotifications('2026-09-09')).created).toBe(0); + expect(prisma.signatureRetirementMessage.create).not.toHaveBeenCalled(); + }); + + it.each([ + ['2024-03-01', '2024-02-29'], + ['2026-01-01', '2025-12-31'], + ['2026-09-01', '2026-08-31'], + ])('keeps T-1 semantics for %s without constructing formatters per row', async (date, expected) => { + const prisma = { + signatureRetirementDetection: { + findMany: jest.fn().mockResolvedValue( + Array.from({ length: 100 }, (_, i) => ({ + id: String(i), + detectionDate: new Date(`${date}T00:00:00Z`), + signatureId: 's', + tenantId: 't', + })), + ), + }, + smsSignature: { findMany: jest.fn().mockResolvedValue([]) }, + smsChannel: { findMany: jest.fn().mockResolvedValue([]) }, + tenant: { findMany: jest.fn().mockResolvedValue([]) }, + channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([]) }, + }; + const spy = jest.spyOn(Intl, 'DateTimeFormat'); + try { + const result = await new SignatureRetirementService(prisma as never).heatmap(date); + expect(result.items.every((item) => item.activityDate === expected)).toBe(true); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/api/src/signature-retirement/signature-retirement.service.spec.ts b/api/src/signature-retirement/signature-retirement.service.spec.ts index 21090cf..af64437 100644 --- a/api/src/signature-retirement/signature-retirement.service.spec.ts +++ b/api/src/signature-retirement/signature-retirement.service.spec.ts @@ -16,17 +16,36 @@ describe('SignatureRetirementService dimensions', () => { task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'), ]; - const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }> }).buildDimensions(rules, tasks); + const dimensions = ( + service as unknown as { + buildDimensions: ( + inputRules: unknown[], + inputTasks: unknown[], + ) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }>; + } + ).buildDimensions(rules, tasks); expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2); expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3); - expect(dimensions.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')?.approvedAt.toISOString()).toBe('2026-06-01T00:00:00.000Z'); - expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true); - expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel'); + expect( + dimensions + .find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile') + ?.approvedAt.toISOString(), + ).toBe('2026-06-01T00:00:00.000Z'); + expect( + dimensions + .filter((item) => item.dimensionType === 'enterprise') + .every((item) => item.rule.ruleType === 'enterprise_application'), + ).toBe(true); + expect( + dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType, + ).toBe('channel'); }); it('does not monitor legacy carrier-null reporting facts', () => { - const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }).buildDimensions( + const dimensions = ( + service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] } + ).buildDimensions( [rule('enterprise_global', ''), rule('channel_global', '')], [task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')], ); @@ -41,73 +60,159 @@ describe('SignatureRetirementService dimensions', () => { it('publishes frozen alert content only in the notification phase', async () => { const detection = { - id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1', - cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文', + id: 'detection-1', + detectionDate: new Date('2026-08-10T00:00:00.000Z'), + dimensionType: 'enterprise', + tenantId: 'tenant-1', + cycleId: 'cycle-1', + notificationTitle: '企业签名清退预警', + notificationContent: '冻结后的预警正文', }; const prisma = { - signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) }, - signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) }, + signatureRetirementDetection: { + findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]), + }, + smsApplication: { findMany: jest.fn().mockResolvedValue([]) }, + signatureRetirementMessage: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'message-1' }), + findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]), + }, signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) }, signatureRetirementWebhookDelivery: { upsert: jest.fn() }, }; const notificationService = new SignatureRetirementService(prisma as never); - await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 }); - expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) }); + await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ + notificationDate: '2026-08-10', + created: 1, + }); + expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }), + }); }); it('returns enterprise application metadata for heatmap hover and search', async () => { const prisma = { - signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([{ id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00Z'), signatureId: 'signature-1', channelId: null, tenantId: 'tenant-1' }]) }, + signatureRetirementDetection: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'detection-1', + detectionDate: new Date('2026-08-10T00:00:00Z'), + signatureId: 'signature-1', + channelId: null, + tenantId: 'tenant-1', + }, + ]), + }, smsSignature: { findMany: jest.fn().mockResolvedValue([]) }, smsChannel: { findMany: jest.fn().mockResolvedValue([]) }, tenant: { findMany: jest.fn().mockResolvedValue([]) }, - channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{ - signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'), - signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } }, - channel: { name: '移动通道' }, - }]) }, + channelSignatureReportTask: { + findMany: jest.fn().mockResolvedValue([ + { + signatureId: 'signature-1', + channelId: 'channel-1', + carrier: 'mobile', + approvedAt: new Date('2026-06-01T00:00:00Z'), + signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } }, + channel: { name: '移动通道' }, + }, + ]), + }, }; const heatmapService = new SignatureRetirementService(prisma as never); const result = await heatmapService.heatmap('2026-08-10'); - expect(result.dimensions).toEqual(expect.arrayContaining([ - expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }), - expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }), - ])); + expect(result.dimensions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + dimensionType: 'enterprise', + signatureName: '测试签名', + applicationName: '测试应用', + }), + expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }), + ]), + ); expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' })); }); it('persists daily observing snapshots without opening alert cycles', async () => { const prisma = { - signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) }, - signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) }, - channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) }, - signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) }, + signatureRetirementSuppression: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + findUnique: jest.fn().mockResolvedValue(null), + }, + signatureRetirementRule: { + findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]), + }, + channelSignatureReportTask: { + findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]), + }, + signatureRetirementDetection: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'detection-1' }), + }, signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() }, - $queryRaw: jest.fn().mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]), + $queryRaw: jest + .fn() + .mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]), }; const observingService = new SignatureRetirementService(prisma as never); - await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ detectionDate: '2026-08-10', dimensions: 2, alerted: 0, healthy: 0, ineligible: 2 }); + await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ + detectionDate: '2026-08-10', + dimensions: 2, + alerted: 0, + healthy: 0, + ineligible: 2, + }); expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2); - expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'observing', acceptedBusinessCount: 10, cycleId: undefined, notificationTitle: null, notificationContent: null }) }); + expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + status: 'observing', + acceptedBusinessCount: 10, + cycleId: undefined, + notificationTitle: null, + notificationContent: null, + }), + }); expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled(); }); it('maps the real unreported-signature aggregation to an independent page', async () => { const prisma = { - $queryRaw: jest.fn().mockResolvedValue([{ - signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', - applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3, - }]), + $queryRaw: jest.fn().mockResolvedValue([ + { + signatureId: 'signature-1', + signatureName: '未报备签名', + tenantId: 'tenant-1', + tenantName: '测试企业', + applicationId: 'app-1', + applicationName: '测试应用', + messageCount: 7, + rowCount: 3, + }, + ]), }; const unreportedService = new SignatureRetirementService(prisma as never); - await expect(unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 })).resolves.toEqual({ + await expect( + unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 }), + ).resolves.toEqual({ date: '2026-08-10', - items: [{ signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', applicationId: 'app-1', applicationName: '测试应用', messageCount: 7 }], + items: [ + { + signatureId: 'signature-1', + signatureName: '未报备签名', + tenantId: 'tenant-1', + tenantName: '测试企业', + applicationId: 'app-1', + applicationName: '测试应用', + messageCount: 7, + }, + ], total: 3, page: 2, pageSize: 10, @@ -124,7 +229,14 @@ describe('SignatureRetirementService dimensions', () => { it('returns a filtered historical message page with application metadata', async () => { const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') }; - const detection = { id: 'detection-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile' }; + const detection = { + id: 'detection-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'signature-1', + channelId: 'channel-1', + carrier: 'mobile', + }; const prisma = { $queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]), signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) }, @@ -136,8 +248,27 @@ describe('SignatureRetirementService dimensions', () => { }; const messageService = new SignatureRetirementService(prisma as never); - await expect(messageService.listMessages({ dateFrom: '2026-08-01', dateTo: '2026-08-10', tenantId: 'tenant-1', applicationId: 'app-1', signatureKeyword: '测试', channelId: 'channel-1', page: 2, pageSize: 10 })).resolves.toEqual({ - items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })], + await expect( + messageService.listMessages({ + dateFrom: '2026-08-01', + dateTo: '2026-08-10', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureKeyword: '测试', + channelId: 'channel-1', + page: 2, + pageSize: 10, + }), + ).resolves.toEqual({ + items: [ + expect.objectContaining({ + id: 'message-1', + tenantName: '测试企业', + applicationName: '测试应用', + signatureName: '测试签名', + channelName: '测试通道', + }), + ], total: 21, page: 2, pageSize: 10, @@ -160,22 +291,52 @@ describe('SignatureRetirementService dimensions', () => { it('requires a reason for temporary and permanent suppression', async () => { const prisma = { signatureRetirementMessage: { - findUnique: jest.fn().mockResolvedValue({ id: 'message-1', cycleId: 'cycle-1', detectionId: 'detection-1', createdAt: new Date() }), + findUnique: jest.fn().mockResolvedValue({ + id: 'message-1', + cycleId: 'cycle-1', + detectionId: 'detection-1', + createdAt: new Date(), + }), findFirst: jest.fn().mockResolvedValue(null), }, signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) }, }; const suppressionService = new SignatureRetirementService(prisma as never); - await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空'); - await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空'); + await expect( + suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' }), + ).rejects.toThrow('抑制原因不能为空'); + await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow( + '抑制原因不能为空', + ); }); }); function rule(ruleType: string, targetKey: string) { - return { id: `${ruleType}-${targetKey}`, ruleType, targetId: targetKey || null, targetKey, enabled: true, mobileWindowDays: 30, mobileThreshold: 1, unicomWindowDays: 30, unicomThreshold: 1, telecomWindowDays: 30, telecomThreshold: 1, messageTemplate: null, version: 1 }; + return { + id: `${ruleType}-${targetKey}`, + ruleType, + targetId: targetKey || null, + targetKey, + enabled: true, + mobileWindowDays: 30, + mobileThreshold: 1, + unicomWindowDays: 30, + unicomThreshold: 1, + telecomWindowDays: 30, + telecomThreshold: 1, + messageTemplate: null, + version: 1, + }; } function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) { - return { signatureId: 'signature-1', channelId, carrier, approvedAt: new Date(approvedAt), signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } }, channel: { name: channelName } }; + return { + signatureId: 'signature-1', + channelId, + carrier, + approvedAt: new Date(approvedAt), + signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } }, + channel: { name: channelName }, + }; } diff --git a/api/src/signature-retirement/signature-retirement.service.ts b/api/src/signature-retirement/signature-retirement.service.ts index 943a93d..144ecf4 100644 --- a/api/src/signature-retirement/signature-retirement.service.ts +++ b/api/src/signature-retirement/signature-retirement.service.ts @@ -1,11 +1,38 @@ -import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { lookup } from 'node:dns/promises'; import { decryptSecret, encryptSecret } from '../open-api/open-api.crypto'; import { PrismaService } from '../prisma/prisma.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; -import { normalizeChannelCarriers } from '../channels/channels.helpers'; -import type { CancelRetirementSuppressionDto, CreateRetirementWebhookDto, RetirementMessageQuery, RetirementRuleType, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts'; + +import type { + CancelRetirementSuppressionDto, + CreateRetirementWebhookDto, + RetirementMessageQuery, + RetirementRuleType, + SuppressRetirementMessageDto, + UnreportedSignatureQuery, + UpsertRetirementRuleDto, +} from './signature-retirement.contracts'; + +const shanghaiDayFormatter = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); +const shanghaiHourFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Shanghai', + hour: '2-digit', + hour12: false, +}); const DAY_MS = 86_400_000; const CARRIERS = ['mobile', 'unicom', 'telecom'] as const; @@ -50,7 +77,10 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy this.startupTimer.unref?.(); this.scheduleDetection(); this.scheduleNotification(); - this.deliveryTimer = setInterval(() => void this.deliverPendingWebhooks(), positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS)); + this.deliveryTimer = setInterval( + () => void this.deliverPendingWebhooks(), + positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS), + ); this.deliveryTimer.unref?.(); } @@ -78,19 +108,36 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]), Number(data[`${carrier}Threshold` as keyof UpsertRetirementRuleDto]), ]); - if (values.some((value) => !Number.isInteger(value) || value < 0)) throw new BadRequestException('检测天数和阈值必须为非负整数'); - if (CARRIERS.some((carrier) => Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) < 1 || Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) > 365)) { + if (values.some((value) => !Number.isInteger(value) || value < 0)) + throw new BadRequestException('检测天数和阈值必须为非负整数'); + if ( + CARRIERS.some( + (carrier) => + Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) < 1 || + Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) > 365, + ) + ) { throw new BadRequestException('检测天数必须在1至365天之间'); } const targetId = data.targetId?.trim() || null; const targetKey = targetId ?? ''; - const existing = await this.prisma.signatureRetirementRule.findUnique({ where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } } }); + const existing = await this.prisma.signatureRetirementRule.findUnique({ + where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } }, + }); const rule = await this.prisma.signatureRetirementRule.upsert({ where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } }, create: { ...data, targetId, targetKey, createdById: operatorId }, update: { ...data, targetId, targetKey, version: { increment: 1 } }, }); - await this.prisma.operationLog.create({ data: { userId: operatorId, action: existing ? 'signature_retirement.rule_updated' : 'signature_retirement.rule_created', resource: 'signature_retirement_rule', resourceId: rule.id, detail: { ruleType: rule.ruleType, targetId, version: rule.version } as Prisma.InputJsonValue } }); + await this.prisma.operationLog.create({ + data: { + userId: operatorId, + action: existing ? 'signature_retirement.rule_updated' : 'signature_retirement.rule_created', + resource: 'signature_retirement_rule', + resourceId: rule.id, + detail: { ruleType: rule.ruleType, targetId, version: rule.version } as Prisma.InputJsonValue, + }, + }); return rule; } @@ -99,7 +146,12 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy if (!['wecom', 'feishu'].includes(data.platform)) throw new BadRequestException('仅支持企业微信或飞书'); await assertSafeWebhookUrl(data.url); return this.prisma.signatureRetirementWebhook.create({ - data: { name: data.name.trim(), platform: data.platform, urlEncrypted: encryptSecret(data.url.trim()), urlMasked: maskWebhookUrl(data.url.trim()) }, + data: { + name: data.name.trim(), + platform: data.platform, + urlEncrypted: encryptSecret(data.url.trim()), + urlMasked: maskWebhookUrl(data.url.trim()), + }, }); } @@ -112,9 +164,13 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy async listMessages(query: RetirementMessageQuery) { const page = Math.max(1, Math.floor(query.page || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(query.pageSize || 10))); - const range = shanghaiDateRange(query.dateFrom || shanghaiDateKey(), query.dateTo || query.dateFrom || shanghaiDateKey()); + const range = shanghaiDateRange( + query.dateFrom || shanghaiDateKey(), + query.dateTo || query.dateFrom || shanghaiDateKey(), + ); const dimensionType = query.dimensionType && query.dimensionType !== 'all' ? query.dimensionType : null; - if (dimensionType && !['enterprise', 'channel'].includes(dimensionType)) throw new BadRequestException('不支持的预警类型'); + if (dimensionType && !['enterprise', 'channel'].includes(dimensionType)) + throw new BadRequestException('不支持的预警类型'); const tenantId = query.tenantId?.trim() || null; const applicationId = query.applicationId?.trim() || null; const signatureKeyword = query.signatureKeyword?.trim() || null; @@ -123,7 +179,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy const messageRows = await this.prisma.$queryRaw>(Prisma.sql` SELECT message.id, COUNT(*) OVER()::integer AS "totalCount" FROM "SignatureRetirementMessage" message - JOIN "SignatureRetirementDetection" detection ON detection.id = message."detectionId" + JOIN "SignatureRetirementDetection" detection ON (detection.id = message."detectionId" OR detection.id = ANY(message."detectionIds")) JOIN "SmsSignature" signature ON signature.id = detection."signatureId" LEFT JOIN "SmsApplication" application ON application.id = detection."applicationId" WHERE message."createdAt" >= ${range?.gte} @@ -134,22 +190,41 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy AND (${applicationId}::text IS NULL OR application.id = ${applicationId}) AND (${signatureKeyword}::text IS NULL OR signature.name ILIKE ${signaturePattern}) AND (${channelId}::text IS NULL OR detection."channelId" = ${channelId}) + GROUP BY message.id ORDER BY message."createdAt" DESC, message.id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize} `); const orderedIds = messageRows.map((item) => item.id); - const unorderedItems = orderedIds.length ? await this.prisma.signatureRetirementMessage.findMany({ where: { id: { in: orderedIds } } }) : []; + const unorderedItems = orderedIds.length + ? await this.prisma.signatureRetirementMessage.findMany({ where: { id: { in: orderedIds } } }) + : []; const itemMap = new Map(unorderedItems.map((item) => [item.id, item])); - const items = orderedIds.flatMap((id) => itemMap.has(id) ? [itemMap.get(id)!] : []); + const items = orderedIds.flatMap((id) => (itemMap.has(id) ? [itemMap.get(id)!] : [])); const total = messageRows[0]?.totalCount ?? 0; - const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { id: { in: items.map((item) => item.detectionId) } } }); + const detections = await this.prisma.signatureRetirementDetection.findMany({ + where: { + id: { in: items.flatMap((item) => (item.detectionIds?.length ? item.detectionIds : [item.detectionId])) }, + }, + }); const detectionMap = new Map(detections.map((item) => [item.id, item])); const [signatures, channels, tenants, applications] = await Promise.all([ - this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }), - this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }), - this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }), - this.prisma.smsApplication.findMany({ where: { id: { in: detections.flatMap((item) => item.applicationId ? [item.applicationId] : []) } }, select: { id: true, name: true } }), + this.prisma.smsSignature.findMany({ + where: { id: { in: detections.map((item) => item.signatureId) } }, + select: { id: true, name: true }, + }), + this.prisma.smsChannel.findMany({ + where: { id: { in: detections.flatMap((item) => (item.channelId ? [item.channelId] : [])) } }, + select: { id: true, name: true }, + }), + this.prisma.tenant.findMany({ + where: { id: { in: detections.map((item) => item.tenantId) } }, + select: { id: true, name: true }, + }), + this.prisma.smsApplication.findMany({ + where: { id: { in: detections.flatMap((item) => (item.applicationId ? [item.applicationId] : [])) } }, + select: { id: true, name: true }, + }), ]); const signatureMap = new Map(signatures.map((item) => [item.id, item.name])); const channelMap = new Map(channels.map((item) => [item.id, item.name])); @@ -158,7 +233,26 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy return { items: items.map((item) => { const detection = detectionMap.get(item.detectionId); - return { ...item, detection, signatureName: detection ? signatureMap.get(detection.signatureId) : undefined, channelName: detection?.channelId ? channelMap.get(detection.channelId) : undefined, tenantName: detection ? tenantMap.get(detection.tenantId) : undefined, applicationName: detection?.applicationId ? applicationMap.get(detection.applicationId) : undefined }; + return { + ...item, + detection, + detections: (item.detectionIds?.length ? item.detectionIds : [item.detectionId]).flatMap((id) => { + const entry = detectionMap.get(id); + return entry + ? [ + { + ...entry, + signatureName: signatureMap.get(entry.signatureId), + channelName: entry.channelId ? channelMap.get(entry.channelId) : undefined, + }, + ] + : []; + }), + signatureName: detection ? signatureMap.get(detection.signatureId) : undefined, + channelName: detection?.channelId ? channelMap.get(detection.channelId) : undefined, + tenantName: detection ? tenantMap.get(detection.tenantId) : undefined, + applicationName: detection?.applicationId ? applicationMap.get(detection.applicationId) : undefined, + }; }), total, page, @@ -169,9 +263,9 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy async unreadCount() { const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey()); const rows = await this.prisma.$queryRaw>(Prisma.sql` - SELECT COUNT(*)::integer AS count + SELECT COUNT(DISTINCT message.id)::integer AS count FROM "SignatureRetirementMessage" message - JOIN "SignatureRetirementDetection" detection ON detection.id = message."detectionId" + JOIN "SignatureRetirementDetection" detection ON (detection.id = message."detectionId" OR detection.id = ANY(message."detectionIds")) JOIN "SmsSignature" signature ON signature.id = detection."signatureId" WHERE message."createdAt" >= ${range?.gte} AND message."createdAt" <= ${range?.lte} @@ -188,34 +282,145 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy async markAllTodayRead() { const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey()); - const result = await this.prisma.signatureRetirementMessage.updateMany({ where: { createdAt: range, isRead: false }, data: { isRead: true, readAt: new Date() } }); + const result = await this.prisma.signatureRetirementMessage.updateMany({ + where: { createdAt: range, isRead: false }, + data: { isRead: true, readAt: new Date() }, + }); return { count: result.count }; } async suppressMessage(id: string, data: SuppressRetirementMessageDto, operatorId?: string) { const message = await this.prisma.signatureRetirementMessage.findUnique({ where: { id } }); if (!message) throw new NotFoundException('预警消息不存在'); - const newerMessage = await this.prisma.signatureRetirementMessage.findFirst({ where: { cycleId: message.cycleId, createdAt: { gt: message.createdAt } }, select: { id: true } }); + if (message.dailyGroupKey) return this.suppressDailyMessage(message, data, operatorId); + const newerMessage = await this.prisma.signatureRetirementMessage.findFirst({ + where: { cycleId: message.cycleId, createdAt: { gt: message.createdAt } }, + select: { id: true }, + }); if (newerMessage) throw new BadRequestException('只能从当前预警周期的最新消息设置抑制'); const detection = await this.prisma.signatureRetirementDetection.findUnique({ where: { id: message.detectionId } }); if (!detection) throw new NotFoundException('预警检测记录不存在'); if (!['temporary', 'permanent'].includes(data.mode)) throw new BadRequestException('不支持的抑制类型'); if (!data.reason?.trim()) throw new BadRequestException('抑制原因不能为空'); const days = data.mode === 'temporary' ? Math.floor(Number(data.days)) : undefined; - if (data.mode === 'temporary' && (!days || days < 1 || days > 3650)) throw new BadRequestException('临时抑制天数必须在1至3650之间'); + if (data.mode === 'temporary' && (!days || days < 1 || days > 3650)) + throw new BadRequestException('临时抑制天数必须在1至3650之间'); const muteUntil = days ? databaseDate(addDays(shanghaiDateKey(), days)) : null; const suppression = await this.prisma.signatureRetirementSuppression.upsert({ - where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelKey: detection.channelKey, carrier: detection.carrier } }, - create: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelId: detection.channelId, channelKey: detection.channelKey, carrier: detection.carrier, mode: data.mode, muteUntil, reason: data.reason?.trim(), operatorId }, - update: { channelId: detection.channelId, mode: data.mode, muteUntil, active: true, reason: data.reason?.trim(), operatorId, cancelledAt: null, cancelledById: null, cancelReason: null }, + where: { + dimensionType_signatureId_channelKey_carrier: { + dimensionType: detection.dimensionType, + signatureId: detection.signatureId, + channelKey: detection.channelKey, + carrier: detection.carrier, + }, + }, + create: { + dimensionType: detection.dimensionType, + signatureId: detection.signatureId, + channelId: detection.channelId, + channelKey: detection.channelKey, + carrier: detection.carrier, + mode: data.mode, + muteUntil, + reason: data.reason?.trim(), + operatorId, + }, + update: { + channelId: detection.channelId, + mode: data.mode, + muteUntil, + active: true, + reason: data.reason?.trim(), + operatorId, + cancelledAt: null, + cancelledById: null, + cancelReason: null, + }, }); await Promise.all([ this.prisma.signatureRetirementMessage.update({ where: { id }, data: { suppressed: true } }), - this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppressed', resource: 'signature_retirement_suppression', resourceId: suppression.id, detail: { mode: data.mode, days, reason: data.reason } as Prisma.InputJsonValue } }), + this.prisma.operationLog.create({ + data: { + userId: operatorId, + action: 'signature_retirement.suppressed', + resource: 'signature_retirement_suppression', + resourceId: suppression.id, + detail: { mode: data.mode, days, reason: data.reason } as Prisma.InputJsonValue, + }, + }), ]); return suppression; } + private async suppressDailyMessage( + message: { + id: string; + tenantId: string; + applicationId: string | null; + notificationDate: Date | null; + detectionIds: string[]; + }, + data: SuppressRetirementMessageDto, + operatorId?: string, + ) { + if (!['temporary', 'permanent'].includes(data.mode) || !data.reason?.trim()) + throw new BadRequestException('抑制方式和原因不能为空'); + const days = data.mode === 'temporary' ? Math.floor(Number(data.days)) : undefined; + if (data.mode === 'temporary' && (!days || days < 1 || days > 3650)) + throw new BadRequestException('临时抑制天数必须在1至3650之间'); + const muteUntil = days ? databaseDate(addDays(shanghaiDateKey(), days)) : null; + return this.prisma.$transaction(async (tx) => { + const newer = await tx.signatureRetirementMessage.findFirst({ + where: { + tenantId: message.tenantId, + applicationId: message.applicationId, + notificationDate: { gt: message.notificationDate! }, + }, + select: { id: true }, + }); + if (newer) throw new BadRequestException('只能从该企业应用最新预警消息设置整组抑制'); + const detections = await tx.signatureRetirementDetection.findMany({ + where: { id: { in: message.detectionIds } }, + }); + if (detections.length !== message.detectionIds.length) throw new BadRequestException('预警明细不完整'); + for (const detection of detections) { + const dimension = { + dimensionType: detection.dimensionType, + signatureId: detection.signatureId, + channelKey: detection.channelKey, + carrier: detection.carrier, + }; + const values = { + channelId: detection.channelId, + mode: data.mode, + muteUntil, + reason: data.reason!.trim(), + operatorId, + active: true, + cancelledAt: null, + cancelledById: null, + cancelReason: null, + }; + await tx.signatureRetirementSuppression.upsert({ + where: { dimensionType_signatureId_channelKey_carrier: dimension }, + create: { ...dimension, ...values }, + update: values, + }); + } + await tx.operationLog.create({ + data: { + userId: operatorId, + action: 'signature_retirement.group_suppressed', + resource: 'signature_retirement_message', + resourceId: message.id, + detail: { mode: data.mode, days, count: detections.length, reason: data.reason }, + }, + }); + return tx.signatureRetirementMessage.update({ where: { id: message.id }, data: { suppressed: true } }); + }); + } + listSuppressions() { return this.prisma.signatureRetirementSuppression.findMany({ where: { active: true, OR: [{ mode: 'permanent' }, { muteUntil: { gte: databaseDate(shanghaiDateKey()) } }] }, @@ -227,8 +432,19 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy if (!data.reason?.trim()) throw new BadRequestException('取消抑制原因不能为空'); const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { id } }); if (!suppression) throw new NotFoundException('抑制记录不存在'); - const updated = await this.prisma.signatureRetirementSuppression.update({ where: { id }, data: { active: false, cancelledAt: new Date(), cancelledById: operatorId, cancelReason: data.reason.trim() } }); - await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppression_cancelled', resource: 'signature_retirement_suppression', resourceId: id, detail: { reason: data.reason.trim() } as Prisma.InputJsonValue } }); + const updated = await this.prisma.signatureRetirementSuppression.update({ + where: { id }, + data: { active: false, cancelledAt: new Date(), cancelledById: operatorId, cancelReason: data.reason.trim() }, + }); + await this.prisma.operationLog.create({ + data: { + userId: operatorId, + action: 'signature_retirement.suppression_cancelled', + resource: 'signature_retirement_suppression', + resourceId: id, + detail: { reason: data.reason.trim() } as Prisma.InputJsonValue, + }, + }); return updated; } @@ -237,50 +453,110 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy const startKey = addDays(endKey, -30); const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { detectionDate: { gt: databaseDate(startKey), lte: databaseDate(endKey) } }, - orderBy: [{ dimensionType: 'asc' }, { signatureId: 'asc' }, { channelKey: 'asc' }, { carrier: 'asc' }, { detectionDate: 'desc' }], + orderBy: [ + { dimensionType: 'asc' }, + { signatureId: 'asc' }, + { channelKey: 'asc' }, + { carrier: 'asc' }, + { detectionDate: 'desc' }, + ], }); const [signatures, channels, tenants, approvedTasks] = await Promise.all([ - this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }), - this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }), - this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }), + this.prisma.smsSignature.findMany({ + where: { id: { in: detections.map((item) => item.signatureId) } }, + select: { id: true, name: true }, + }), + this.prisma.smsChannel.findMany({ + where: { id: { in: detections.flatMap((item) => (item.channelId ? [item.channelId] : [])) } }, + select: { id: true, name: true }, + }), + this.prisma.tenant.findMany({ + where: { id: { in: detections.map((item) => item.tenantId) } }, + select: { id: true, name: true }, + }), this.prisma.channelSignatureReportTask.findMany({ - where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } }, + where: { + reportType: 'signature', + status: 'approved', + carrier: { not: null }, + approvalScope: 'carrier_specific', + approvedAt: { not: null }, + signature: { auditStatus: { not: 'deleted' } }, + channel: { status: { not: 'deleted' } }, + }, include: { signature: { include: { tenant: true, application: true } }, channel: true }, }), ]); - const dimensionMap = new Map(); + const dimensionMap = new Map< + string, + { + dimensionType: 'enterprise' | 'channel'; + signatureId: string; + channelId: string | null; + carrier: string; + approvedAt: Date; + signatureName: string; + channelName: string | null; + tenantName: string; + applicationName: string | null; + } + >(); for (const task of approvedTasks) { if (!task.carrier || !task.approvedAt) continue; - const channelDimension = { dimensionType: 'channel' as const, signatureId: task.signatureId, channelId: task.channelId, carrier: task.carrier, approvedAt: task.approvedAt, signatureName: task.signature.name, channelName: task.channel.name, tenantName: task.signature.tenant.name, applicationName: task.signature.application?.name ?? null }; + const channelDimension = { + dimensionType: 'channel' as const, + signatureId: task.signatureId, + channelId: task.channelId, + carrier: task.carrier, + approvedAt: task.approvedAt, + signatureName: task.signature.name, + channelName: task.channel.name, + tenantName: task.signature.tenant.name, + applicationName: task.signature.application?.name ?? null, + }; dimensionMap.set(`channel:${task.signatureId}:${task.channelId}:${task.carrier}`, channelDimension); const enterpriseKey = `enterprise:${task.signatureId}::${task.carrier}`; const current = dimensionMap.get(enterpriseKey); - if (!current || task.approvedAt < current.approvedAt) dimensionMap.set(enterpriseKey, { ...channelDimension, dimensionType: 'enterprise', channelId: null, channelName: null }); + if (!current || task.approvedAt < current.approvedAt) + dimensionMap.set(enterpriseKey, { + ...channelDimension, + dimensionType: 'enterprise', + channelId: null, + channelName: null, + }); } return { date: endKey, dimensions: [...dimensionMap.values()], // 检测在次日04:00运行,因此检测日对应的活动自然日固定为T-1。 - items: detections.map((item) => ({ ...item, activityDate: addDays(shanghaiDateKey(item.detectionDate), -1), signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name })), + items: detections.map((item) => ({ + ...item, + activityDate: addDays(shanghaiDateKey(item.detectionDate), -1), + signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, + channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, + tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name, + })), }; } async unreportedSignatures(query: UnreportedSignatureQuery) { const date = assertDateKey(query.date || shanghaiDateKey()); const page = positiveInteger(query.page, 1); - const pageSize = Math.min(50, positiveInteger(query.pageSize, 10)); + const pageSize = Math.min(100, positiveInteger(query.pageSize, 25)); const keyword = query.keyword?.trim() || null; const keywordPattern = keyword ? `%${keyword}%` : null; - const rows = await this.prisma.$queryRaw>(Prisma.sql` + const rows = await this.prisma.$queryRaw< + Array<{ + signatureId: string; + signatureName: string; + tenantId: string; + tenantName: string; + applicationId: string | null; + applicationName: string | null; + messageCount: number; + rowCount: number; + }> + >(Prisma.sql` WITH extracted AS ( SELECT message."tenantId" AS tenant_id, @@ -343,7 +619,10 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy `); return { date, - items: rows.map(({ rowCount: _rowCount, ...item }) => item), + items: rows.map(({ rowCount: _rowCount, ...item }) => { + void _rowCount; + return item; + }), total: rows[0]?.rowCount ?? 0, page, pageSize, @@ -359,7 +638,15 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy const [rules, approvedTasks] = await Promise.all([ this.prisma.signatureRetirementRule.findMany({ where: { enabled: true } }), this.prisma.channelSignatureReportTask.findMany({ - where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } }, + where: { + reportType: 'signature', + status: 'approved', + carrier: { not: null }, + approvalScope: 'carrier_specific', + approvedAt: { not: null }, + signature: { auditStatus: { not: 'deleted' } }, + channel: { status: { not: 'deleted' } }, + }, include: { signature: { include: { tenant: true, application: true } }, channel: true }, }), ]); @@ -387,7 +674,16 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy } const windowCounts = await this.activityCounts(dimension, windowStart, activityEnd); const isAlert = windowCounts.acceptedBusinessCount < threshold; - await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, isAlert, false, windowCounts); + await this.persistDetection( + detectionKey, + dimension, + windowDays, + threshold, + dailyCounts, + isAlert, + false, + windowCounts, + ); if (isAlert) alerted += 1; else healthy += 1; } @@ -398,20 +694,57 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy const notificationKey = assertDateKey(date || shanghaiDateKey()); const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { - detectionDate: databaseDate(notificationKey), status: 'alert', suppressed: false, - cycleId: { not: null }, notificationTitle: { not: null }, notificationContent: { not: null }, + detectionDate: databaseDate(notificationKey), + status: 'alert', + suppressed: false, + cycleId: { not: null }, + notificationTitle: { not: null }, + notificationContent: { not: null }, }, }); - let created = 0; + const groups = new Map(); for (const detection of detections) { if (!detection.cycleId || !detection.notificationTitle || !detection.notificationContent) continue; + const key = JSON.stringify([notificationKey, detection.tenantId, detection.applicationId ?? null]); + const group = groups.get(key); + if (group) group.push(detection); + else groups.set(key, [detection]); + } + const applications = await this.prisma.smsApplication.findMany({ + where: { + id: { in: [...new Set(detections.flatMap((item) => (item.applicationId ? [item.applicationId] : [])))] }, + }, + select: { id: true, name: true }, + }); + const names = new Map(applications.map((item) => [item.id, item.name])); + let created = 0; + for (const [dailyGroupKey, group] of groups) { + group.sort((a, b) => a.id.localeCompare(b.id)); + const detection = group[0]; + const ids = group.map((item) => item.id); + // Do not regenerate days already published by the old version or alter frozen messages on reruns. + const existing = await this.prisma.signatureRetirementMessage.findFirst({ + where: { OR: [{ dailyGroupKey }, { detectionId: { in: ids } }] }, + select: { id: true }, + }); + if (existing) continue; try { await this.prisma.signatureRetirementMessage.create({ - data: { detectionId: detection.id, cycleId: detection.cycleId, tenantId: detection.tenantId, title: detection.notificationTitle, content: detection.notificationContent }, + data: { + dailyGroupKey, + notificationDate: databaseDate(notificationKey), + applicationId: detection.applicationId, + detectionId: detection.id, + detectionIds: ids, + cycleId: detection.cycleId!, + tenantId: detection.tenantId, + title: `${names.get(detection.applicationId ?? '') ?? '未绑定企业应用'} · 签名清退预警`, + content: group.map((item) => item.notificationContent).join('\n'), + }, }); created += 1; } catch (error) { - // 多实例08:00并发发布时,检测ID唯一键保证只产生一条站内消息。 + // The daily application key enforces idempotency across processes. if (!isPrismaUniqueError(error)) throw error; } } @@ -429,47 +762,101 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy await this.deliverPendingWebhooks(); } } catch (error) { - this.logger.error(`Signature retirement startup compensation failed: ${error instanceof Error ? error.message : String(error)}`); + this.logger.error( + `Signature retirement startup compensation failed: ${error instanceof Error ? error.message : String(error)}`, + ); } } private scheduleDetection() { - this.detectionTimer = setTimeout(() => { - void this.runDetection(shanghaiDateKey()) - .catch((error) => this.logger.error(`Signature retirement 04:00 detection failed: ${error instanceof Error ? error.message : String(error)}`)) - .finally(() => this.scheduleDetection()); - }, millisecondsUntilShanghaiHour(new Date(), 4)); + this.detectionTimer = setTimeout( + () => { + void this.runDetection(shanghaiDateKey()) + .catch((error) => + this.logger.error( + `Signature retirement 04:00 detection failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ) + .finally(() => this.scheduleDetection()); + }, + millisecondsUntilShanghaiHour(new Date(), 4), + ); this.detectionTimer.unref?.(); } private scheduleNotification() { - this.notificationTimer = setTimeout(() => { - void this.publishNotifications(shanghaiDateKey()) - .then(() => this.deliverPendingWebhooks()) - .catch((error) => this.logger.error(`Signature retirement 08:00 notification failed: ${error instanceof Error ? error.message : String(error)}`)) - .finally(() => this.scheduleNotification()); - }, millisecondsUntilShanghaiHour(new Date(), 8)); + this.notificationTimer = setTimeout( + () => { + void this.publishNotifications(shanghaiDateKey()) + .then(() => this.deliverPendingWebhooks()) + .catch((error) => + this.logger.error( + `Signature retirement 08:00 notification failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ) + .finally(() => this.scheduleNotification()); + }, + millisecondsUntilShanghaiHour(new Date(), 8), + ); this.notificationTimer.unref?.(); } - private buildDimensions(rules: Array>, tasks: Array<{ signatureId: string; channelId: string; carrier: string | null; approvedAt: Date | null; signature: { tenantId: string; applicationId: string | null; name: string; tenant: { name: string } }; channel: { name: string } }>) { + private buildDimensions( + rules: Array>, + tasks: Array<{ + signatureId: string; + channelId: string; + carrier: string | null; + approvedAt: Date | null; + signature: { tenantId: string; applicationId: string | null; name: string; tenant: { name: string } }; + channel: { name: string }; + }>, + ) { const dimensions: DetectionDimension[] = []; const enterprise = new Map(); for (const task of tasks) { if (!task.carrier || !task.approvedAt) continue; const channelRule = selectRule(rules, 'channel', task.channelId); - if (channelRule) dimensions.push({ dimensionType: 'channel', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: task.channelId, channelName: task.channel.name, carrier: task.carrier, approvedAt: task.approvedAt, rule: channelRule }); + if (channelRule) + dimensions.push({ + dimensionType: 'channel', + tenantId: task.signature.tenantId, + applicationId: task.signature.applicationId, + signatureId: task.signatureId, + signatureName: task.signature.name, + tenantName: task.signature.tenant.name, + channelId: task.channelId, + channelName: task.channel.name, + carrier: task.carrier, + approvedAt: task.approvedAt, + rule: channelRule, + }); const enterpriseRule = selectRule(rules, 'enterprise', task.signature.applicationId); if (!enterpriseRule) continue; const key = `${task.signatureId}:${task.carrier}`; const current = enterprise.get(key); - if (!current || task.approvedAt < current.approvedAt) enterprise.set(key, { dimensionType: 'enterprise', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: null, channelName: null, carrier: task.carrier, approvedAt: task.approvedAt, rule: enterpriseRule }); + if (!current || task.approvedAt < current.approvedAt) + enterprise.set(key, { + dimensionType: 'enterprise', + tenantId: task.signature.tenantId, + applicationId: task.signature.applicationId, + signatureId: task.signatureId, + signatureName: task.signature.name, + tenantName: task.signature.tenant.name, + channelId: null, + channelName: null, + carrier: task.carrier, + approvedAt: task.approvedAt, + rule: enterpriseRule, + }); } return [...enterprise.values(), ...dimensions]; } private async activityCounts(dimension: DetectionDimension, startAt: Date, endAt: Date): Promise { - const channelFilter = dimension.channelId ? Prisma.sql`AND submit."channelId" = ${dimension.channelId}` : Prisma.empty; + const channelFilter = dimension.channelId + ? Prisma.sql`AND submit."channelId" = ${dimension.channelId}` + : Prisma.empty; const rows = await this.prisma.$queryRaw>(Prisma.sql` WITH attempts AS ( SELECT @@ -506,40 +893,131 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy return rows[0] ?? { submittedAttempts: 0, acceptedBusinessCount: 0, deliveredBusinessCount: 0 }; } - private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean, observing = false, alertCounts = counts) { + private async persistDetection( + dateKey: string, + dimension: DetectionDimension, + windowDays: number, + threshold: number, + counts: ActivityCounts, + isAlert: boolean, + observing = false, + alertCounts = counts, + ) { const detectionDate = databaseDate(dateKey); const channelKey = dimension.channelId ?? ''; const existingDetection = await this.prisma.signatureRetirementDetection.findUnique({ - where: { detectionDate_dimensionType_signatureId_channelKey_carrier: { detectionDate, dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } }, + where: { + detectionDate_dimensionType_signatureId_channelKey_carrier: { + detectionDate, + dimensionType: dimension.dimensionType, + signatureId: dimension.signatureId, + channelKey, + carrier: dimension.carrier, + }, + }, select: { id: true }, }); // 同一检测日的结果冻结规则版本;规则变更只在下一检测日生效。 if (existingDetection) return; - const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } } }); - const suppressed = Boolean(suppression?.active && (suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= detectionDate)); - let cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } }); + const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ + where: { + dimensionType_signatureId_channelKey_carrier: { + dimensionType: dimension.dimensionType, + signatureId: dimension.signatureId, + channelKey, + carrier: dimension.carrier, + }, + }, + }); + const suppressed = Boolean( + suppression?.active && + (suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= detectionDate), + ); + let cycle = await this.prisma.signatureRetirementCycle.findFirst({ + where: { + dimensionType: dimension.dimensionType, + signatureId: dimension.signatureId, + channelKey, + carrier: dimension.carrier, + status: 'open', + }, + }); if (observing) { cycle = null; } else if (isAlert) { if (!cycle) { try { - cycle = await this.prisma.signatureRetirementCycle.create({ data: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, startedOn: detectionDate, lastDetectedOn: detectionDate } }); + cycle = await this.prisma.signatureRetirementCycle.create({ + data: { + dimensionType: dimension.dimensionType, + signatureId: dimension.signatureId, + channelId: dimension.channelId, + channelKey, + carrier: dimension.carrier, + startedOn: detectionDate, + lastDetectedOn: detectionDate, + }, + }); } catch (error) { if (!isPrismaUniqueError(error)) throw error; - cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } }); + cycle = await this.prisma.signatureRetirementCycle.findFirst({ + where: { + dimensionType: dimension.dimensionType, + signatureId: dimension.signatureId, + channelKey, + carrier: dimension.carrier, + status: 'open', + }, + }); } } else { - cycle = await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { lastDetectedOn: detectionDate } }); + cycle = await this.prisma.signatureRetirementCycle.update({ + where: { id: cycle.id }, + data: { lastDetectedOn: detectionDate }, + }); } } else if (cycle) { - await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { status: 'resolved', resolvedOn: detectionDate, lastDetectedOn: detectionDate } }); + await this.prisma.signatureRetirementCycle.update({ + where: { id: cycle.id }, + data: { status: 'resolved', resolvedOn: detectionDate, lastDetectedOn: detectionDate }, + }); cycle = null; } - const notificationTitle = isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null; - const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, alertCounts.acceptedBusinessCount) : null; + const notificationTitle = + isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null; + const notificationContent = + isAlert && cycle + ? renderMessage( + dimension.rule.messageTemplate, + dimension, + windowDays, + threshold, + alertCounts.acceptedBusinessCount, + ) + : null; try { await this.prisma.signatureRetirementDetection.create({ - data: { detectionDate, dimensionType: dimension.dimensionType, tenantId: dimension.tenantId, applicationId: dimension.applicationId, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, windowDays, threshold, ...counts, approvedAt: dimension.approvedAt, ruleId: dimension.rule.id, ruleVersion: dimension.rule.version, status: observing ? 'observing' : isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent }, + data: { + detectionDate, + dimensionType: dimension.dimensionType, + tenantId: dimension.tenantId, + applicationId: dimension.applicationId, + signatureId: dimension.signatureId, + channelId: dimension.channelId, + channelKey, + carrier: dimension.carrier, + windowDays, + threshold, + ...counts, + approvedAt: dimension.approvedAt, + ruleId: dimension.rule.id, + ruleVersion: dimension.rule.version, + status: observing ? 'observing' : isAlert ? 'alert' : 'healthy', + cycleId: cycle?.id, + suppressed, + notificationTitle, + notificationContent, + }, }); } catch (error) { // 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。 @@ -550,26 +1028,23 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy private async enqueueWebhookSummaries(dateKey: string) { const detectionDate = databaseDate(dateKey); - const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { detectionDate, status: 'alert', suppressed: false } }); - if (!detections.length) return; const [webhooks, messages] = await Promise.all([ this.prisma.signatureRetirementWebhook.findMany({ where: { status: 'active' } }), - this.prisma.signatureRetirementMessage.findMany({ where: { detectionId: { in: detections.map((item) => item.id) }, suppressed: false } }), + this.prisma.signatureRetirementMessage.findMany({ + where: { notificationDate: detectionDate, dailyGroupKey: { not: null }, suppressed: false }, + }), ]); - const messageMap = new Map(messages.map((item) => [item.detectionId, item.content])); - const groups = new Map(); - for (const detection of detections) { - const key = detection.dimensionType === 'enterprise' ? `enterprise:${detection.tenantId}` : 'channel:all'; - const values = groups.get(key) ?? []; - const content = messageMap.get(detection.id); - if (content) values.push(content); - groups.set(key, values); - } for (const webhook of webhooks) { - for (const [groupKey, contents] of groups) { + for (const message of messages) { + const groupKey = JSON.stringify(['application', message.tenantId, message.applicationId ?? null]); await this.prisma.signatureRetirementWebhookDelivery.upsert({ where: { webhookId_detectionDate_groupKey: { webhookId: webhook.id, detectionDate, groupKey } }, - create: { webhookId: webhook.id, detectionDate, groupKey, payload: { content: contents.join('\n') } }, + create: { + webhookId: webhook.id, + detectionDate, + groupKey, + payload: { content: `${message.title}\n${message.content}` }, + }, update: {}, }); } @@ -581,41 +1056,80 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy where: { status: 'sending', updatedAt: { lt: new Date(Date.now() - 5 * 60_000) } }, data: { status: 'retrying', nextRetryAt: new Date() }, }); - const deliveries = await this.prisma.signatureRetirementWebhookDelivery.findMany({ where: { status: { in: ['pending', 'retrying'] }, OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }] }, orderBy: { createdAt: 'asc' }, take: 20 }); + const deliveries = await this.prisma.signatureRetirementWebhookDelivery.findMany({ + where: { + status: { in: ['pending', 'retrying'] }, + OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }], + }, + orderBy: { createdAt: 'asc' }, + take: 20, + }); for (const delivery of deliveries) { - const claimed = await this.prisma.signatureRetirementWebhookDelivery.updateMany({ where: { id: delivery.id, status: { in: ['pending', 'retrying'] }, attemptCount: delivery.attemptCount }, data: { status: 'sending', attemptCount: { increment: 1 } } }); + const claimed = await this.prisma.signatureRetirementWebhookDelivery.updateMany({ + where: { id: delivery.id, status: { in: ['pending', 'retrying'] }, attemptCount: delivery.attemptCount }, + data: { status: 'sending', attemptCount: { increment: 1 } }, + }); if (!claimed.count) continue; const webhook = await this.prisma.signatureRetirementWebhook.findUnique({ where: { id: delivery.webhookId } }); if (!webhook || webhook.status !== 'active') { - await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'failed', lastError: 'Webhook已停用' } }); + await this.prisma.signatureRetirementWebhookDelivery.update({ + where: { id: delivery.id }, + data: { status: 'failed', lastError: 'Webhook已停用' }, + }); continue; } try { const url = decryptSecret(webhook.urlEncrypted); await assertSafeWebhookUrl(url); const content = String((delivery.payload as { content?: unknown }).content ?? ''); - const body = webhook.platform === 'feishu' ? { msg_type: 'text', content: { text: content } } : { msgtype: 'text', text: { content } }; - const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }); + const body = + webhook.platform === 'feishu' + ? { msg_type: 'text', content: { text: content } } + : { msgtype: 'text', text: { content } }; + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const responseBody = await response.json().catch(() => null) as { errcode?: number; code?: number } | null; - if ((typeof responseBody?.errcode === 'number' && responseBody.errcode !== 0) || (typeof responseBody?.code === 'number' && responseBody.code !== 0)) { + const responseBody = (await response.json().catch(() => null)) as { errcode?: number; code?: number } | null; + if ( + (typeof responseBody?.errcode === 'number' && responseBody.errcode !== 0) || + (typeof responseBody?.code === 'number' && responseBody.code !== 0) + ) { throw new Error(`Webhook业务响应失败:${responseBody.errcode ?? responseBody.code}`); } - await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'delivered', deliveredAt: new Date(), lastHttpStatus: response.status, lastError: null } }); + await this.prisma.signatureRetirementWebhookDelivery.update({ + where: { id: delivery.id }, + data: { status: 'delivered', deliveredAt: new Date(), lastHttpStatus: response.status, lastError: null }, + }); } catch (error) { const attempts = delivery.attemptCount + 1; - await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: attempts >= 5 ? 'failed' : 'retrying', nextRetryAt: attempts >= 5 ? null : new Date(Date.now() + Math.min(60 * 60_000, 2 ** attempts * 60_000)), lastError: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500) } }); + await this.prisma.signatureRetirementWebhookDelivery.update({ + where: { id: delivery.id }, + data: { + status: attempts >= 5 ? 'failed' : 'retrying', + nextRetryAt: attempts >= 5 ? null : new Date(Date.now() + Math.min(60 * 60_000, 2 ** attempts * 60_000)), + lastError: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500), + }, + }); } } } - } -function selectRule(rules: Array>, dimension: 'enterprise' | 'channel', targetId: string | null) { +function selectRule( + rules: Array>, + dimension: 'enterprise' | 'channel', + targetId: string | null, +) { const specialType = dimension === 'enterprise' ? 'enterprise_application' : 'channel'; const globalType = dimension === 'enterprise' ? 'enterprise_global' : 'channel_global'; - return (targetId ? rules.find((rule) => rule.ruleType === specialType && rule.targetId === targetId) : undefined) - ?? rules.find((rule) => rule.ruleType === globalType && rule.targetKey === ''); + return ( + (targetId ? rules.find((rule) => rule.ruleType === specialType && rule.targetId === targetId) : undefined) ?? + rules.find((rule) => rule.ruleType === globalType && rule.targetKey === '') + ); } function carrierRule(rule: NonNullable, carrier: string) { @@ -624,10 +1138,17 @@ function carrierRule(rule: NonNullable, carrier: string) { return { windowDays: rule.telecomWindowDays, threshold: rule.telecomThreshold }; } -function renderMessage(template: string | null, dimension: DetectionDimension, windowDays: number, threshold: number, actual: number) { - const fallback = dimension.dimensionType === 'enterprise' - ? '请通知 {enterprise}:{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。' - : '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。'; +function renderMessage( + template: string | null, + dimension: DetectionDimension, + windowDays: number, + threshold: number, + actual: number, +) { + const fallback = + dimension.dimensionType === 'enterprise' + ? '请通知 {enterprise}:{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。' + : '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。'; return (template?.trim() || fallback) .replaceAll('{enterprise}', dimension.tenantName) .replaceAll('{signature}', dimension.signatureName) @@ -639,11 +1160,11 @@ function renderMessage(template: string | null, dimension: DetectionDimension, w } function shanghaiDateKey(date = new Date()) { - return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); + return shanghaiDayFormatter.format(date); } export function shanghaiHour(date = new Date()) { - return Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', hour12: false }).format(date)); + return Number(shanghaiHourFormatter.format(date)); } export function millisecondsUntilShanghaiHour(now: Date, targetHour: number) { @@ -653,7 +1174,8 @@ export function millisecondsUntilShanghaiHour(now: Date, targetHour: number) { } function assertDateKey(value: string) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(new Date(`${value}T00:00:00+08:00`).getTime())) throw new BadRequestException('日期格式必须为YYYY-MM-DD'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(new Date(`${value}T00:00:00+08:00`).getTime())) + throw new BadRequestException('日期格式必须为YYYY-MM-DD'); return value; } @@ -671,7 +1193,8 @@ function databaseDate(value: string) { } function assertRuleType(value: string): asserts value is RetirementRuleType { - if (!['enterprise_global', 'enterprise_application', 'channel_global', 'channel'].includes(value)) throw new BadRequestException('不支持的规则类型'); + if (!['enterprise_global', 'enterprise_application', 'channel_global', 'channel'].includes(value)) + throw new BadRequestException('不支持的规则类型'); } function positiveIntegerEnv(name: string, fallback: number) { @@ -689,22 +1212,41 @@ function isPrismaUniqueError(error: unknown) { async function assertSafeWebhookUrl(value: string) { let url: URL; - try { url = new URL(value); } catch { throw new BadRequestException('Webhook地址无效'); } + try { + url = new URL(value); + } catch { + throw new BadRequestException('Webhook地址无效'); + } if (url.protocol !== 'https:') throw new BadRequestException('Webhook必须使用HTTPS'); if (url.username || url.password) throw new BadRequestException('Webhook地址不能包含用户名或密码'); - if (url.hostname === 'localhost' || url.hostname.endsWith('.local')) throw new BadRequestException('Webhook地址不能指向本地网络'); + if (url.hostname === 'localhost' || url.hostname.endsWith('.local')) + throw new BadRequestException('Webhook地址不能指向本地网络'); const addresses = await lookup(url.hostname, { all: true }).catch(() => []); if (!addresses.length) throw new BadRequestException('Webhook域名无法解析'); - if (addresses.some((entry) => isPrivateAddress(entry.address))) throw new BadRequestException('Webhook地址不能指向内网'); + if (addresses.some((entry) => isPrivateAddress(entry.address))) + throw new BadRequestException('Webhook地址不能指向内网'); } function isPrivateAddress(address: string) { const normalized = address.toLowerCase(); - if (normalized === '::1' || normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd')) return true; + if ( + normalized === '::1' || + normalized.startsWith('fe80:') || + normalized.startsWith('fc') || + normalized.startsWith('fd') + ) + return true; const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); if (!match) return false; const [a, b] = [Number(match[1]), Number(match[2])]; - return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); + return ( + a === 10 || + a === 127 || + a === 0 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); } function maskWebhookUrl(value: string) { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 63f30f1..add1596 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2222,3 +2222,8 @@ ## 2026-09-07 夜间累计发送量审核 原“非工作时间大批量营销发送”替换为每企业应用每夜累计所有业务短信的人工审核规则,所有企业应用默认通用5000条,支持应用阈值覆盖;不依赖分类、内容或任务名。CMPP、公开HTTP、客户端合并计数,第5001条及之后待审;分片/内部重试/补发/幂等重试不重复累计。统一在首次发送Worker提交前拦截,含定时任务到期及CMPP快速入队路径。短信审核复用现有字段、号码明细、详情和批量审核,按同应用同内容10秒窗口聚合,窗口关闭后审核;批准只释放该任务消息,不豁免后续发送。时间跨午夜连续,默认21:00至次日08:00(北京时间),夜间改时间待本夜结束生效,阈值修改不清零。详见phase-6-risk-review-plan.md的2026-09-07章节;该章节替代旧营销识别和单任务阈值语义。 + + +## 2026-09-08 运营修复需求补充 + +每日签名清退预警按检测日期、企业和企业应用生成,每个应用每天仅一条消息;并发及重复执行不得重复生成,正文冻结,历史消息保留。首页保留原指标口径更新数字展示;质量检测四个Tab独立日期及分页,每页10/25/50/100默认25。修复监控阈值保存、创建通道误关闭、报备状态重复项及日期格式化器重复构造。具体规则与兼容边界见 [运营修复设计](operations-fixes-20260908.md)。不包含导入导出优化。 diff --git a/docs/operations-fixes-20260908.md b/docs/operations-fixes-20260908.md new file mode 100644 index 0000000..4c80ff1 --- /dev/null +++ b/docs/operations-fixes-20260908.md @@ -0,0 +1,19 @@ +# 运营页面与内存修复(2026-09-08) + +状态:已授权实现、本地提交和测试环境部署,待验收。不包含推送、预生产部署或导入导出优化。补充签名清退、监控设计及UI规范,本节替代相关旧分页和汇总展示口径。 + +1. 固定北京时间日期/小时格式化器按模块复用,保持T-1、闰日、跨月年语义;真实检测规模对照验证内存,不更改检测阈值、发送和报备流程。 +2. 每日清退预警在生成时按日期、企业、企业应用汇总,每应用每天只产生一条消息,并按消息分页;未绑定应用独立归组,保留原消息明细、已读、抑制与查询条件。数据库完成分组和分页,禁止仅合并当前页伪汇总。每日Webhook按企业应用分组,保留幂等键和冻结正文,不补发历史通知,验收不调用外部Webhook。 +3. 首页指标保持原业务口径,以发送、质量、经营的清晰数字卡展示,单位独立、数值对齐、状态强调;加载/失败显示未取得数据,不能展示假零。CSS归页面所有者,不改共享历史样式。 +4. 签名质量四个模块用四个Tab,分别保留日期、查询条件及页码,每页10/25/50/100,默认25。只请求当前模块;热力图保持真实30天快照与客户端维度分页,服务端质量及未报备分页上限同步100。切换和过期响应不得串数据。 +5. 监控告警保存优先使用显式PROMTOOL_PATH,否则查找标准安装目录;仍强制promtool校验、原子替换、reload和失败恢复,不跳门禁。测试写入经真实API回读与Prometheus规则核验,恢复原阈值。 +6. 创建通道只能关闭按钮或右上角叉主动关闭,遮罩和Escape无效;公共Modal新增可选控制,其他消费者默认行为保持。验收不创建或修改实际通道。 +7. 通道报备状态筛选去重为业务状态,报备中覆盖reporting/exporting,报备失败覆盖failed/rejected;历史状态展示保持,查询必须命中全部对应记录。 + +验证:定向及前后端全量、类型/构建、格式/lint/CSS/安全/部署/包体门禁;真实PostgreSQL隔离集成、测试环境API/浏览器1600×1000、1366×768、390×844及内存对照。使用标准release完成精确提交计划、验证、preflight、prepare、deploy、verify。已有脏文件保护,只提交本轮精确文件/hunk。 + +## 每日消息兼容与事务 + +新增dailyGroupKey唯一键、notificationDate/applicationId和detectionIds数组,原代表detectionId/cycleId保留供历史读取。新增列可空或有空数组默认,无删除或历史重写。发布当天已由旧版本生成的检测不重复创建消息;下一检测日按新规则生成。一组包含全部企业/通道维度,不因运营商多条而增加未读数。查询以组内任意检测命中筛选并按消息去重,正文和明细可展开查看。整组抑制必须有原因并原子写入该消息包含的全部维度,旧消息继续原单维度抑制;较新的应用消息存在时禁止从旧组设置抑制。Webhook只为新汇总消息建立每日应用幂等任务,不补发历史任务。 + +回退应用时保留新增列和唯一键;旧版本只能识别代表明细且会恢复旧生成口径,所以消息行为回退需要停用相应调度后专项决策,不能称简单应用回退即可恢复新口径。正式数据库不造预警;并发与迁移在独立PostgreSQL schema验证,禁止启动检测/发送/Webhook生命周期。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index abcfcac..c817f02 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5223,3 +5223,16 @@ npm run verify:phase8 - 633ba59双环境99项迁移、规则真实API/编辑取消/短信审核空态与刷新三尺寸验收;测试无控制台错误,预生产已完成两轮三尺寸业务API/交互检查,外部统计beacon网络错误单列;最后复核遇23:22:21账号再次停用而401,完整脚本未通过,不能宣称持续登录可用。具体结果与证据见testing-progress.md本次发布节。 - 夜间累计5001边界、共享入口、并发幂等、同内容聚合、跨午夜、覆盖、审核决定与入队失败恢复在两环境独立PG schema和Redis QA队列各11组通过。当前线上待审核为空,未发送或实际放行短信;不以空态浏览器验收替代这些业务规则验证。 + + +## 2026-09-08 运营修复定向用例 + +- OPS0908-01:同日同应用多签名/多通道仅生成一条预警;不同应用分开;未绑定应用按企业隔离;两个服务实例并发和重复执行保持唯一;冻结正文不变。 +- OPS0908-02:组内非代表签名可搜索命中;分页、未读均按消息计数;整组抑制原子更新;历史单条消息兼容,不重发历史Webhook。 +- OPS0908-03:固定上海时区跨月年/闰日T-1结果不变;真实PostgreSQL 19788条检测通过实际heatmap服务验证内存,无发送生命周期。 +- OPS0908-04:首页10项真实指标、单位、加载/失败;1600×1000、1366×768、390×844检查布局及刷新、切路由。 +- OPS0908-05:质量四Tab各自日期、10/25/50/100默认25;查询、分页、切换保留状态,未访问Tab不请求;请求参数与真实API核对。 +- OPS0908-06:监控阈值真实保存、回读配置版本及Prometheus规则,再恢复原阈值;promtool显式路径优先,标准两目录兼容,校验失败不得发布规则。 +- OPS0908-07:创建通道遮罩和Escape不关闭,关闭按钮及叉可关闭;不得提交创建实际通道。报备状态每个业务标签仅一项,查询覆盖reporting/exporting和failed/rejected历史值。 + +执行证据见testing-progress.md,设计见operations-fixes-20260908.md。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index b25f17e..5a42aa7 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4710,3 +4710,14 @@ git diff --check - 真实页面:测试与预生产均使用真实管理员、API及PG数据,1600×1000、1366×768、390×844检查规则读取、阈值5000、北京时间21:00–08:00、人工审核动作锁定、编辑取消、短信审核空态、刷新和路由切换;无整页横向溢出,相关GET均200,未保存业务规则。预生产Codex_admin最初停用/删除,用户23:16:06恢复后原凭据可登录,前两轮三尺寸业务GET均200且检查完成,仅static.cloudflareinsights.com统计beacon出现连接重置;第三轮23:21:57登录后,23:22:21发生user.disabled操作,最后回读401。23:24再试仍停用,PG确认status=disabled、sessionVersion=2;未新建、启用或重置账号。保留已完成页面证据,最终完整零异常脚本未通过,不声称持续登录可用或整个控制台零错误。 - 现存独立问题:测试安全代理发布前已因/run/cmpp-security-agent缺失反复启动失败(226/NAMESPACE),未扩大本轮修改;预生产ReportsService日报刷新在发布前21:34、22:34及发布后23:12仍出现5000ms事务超时,发送/报备Worker没有本轮新增错误,不能称所有应用日志正常。两项留待专项诊断,不影响已执行的夜间规则只读及隔离验收。 - 证据:本机%TEMP%/cmpp-night-risk-20260907的测试/构建日志、两环境三尺寸截图、测试browser.json及预生产browser-observations.json/最后一次browser-failed.json;两机独立恢复目录before/after、verification.json、migrate.log、services及storage摘要,预生产account-compare.json。未发送/补发/重投/重新入队真实短信,未执行真实供应商高并发或实际备份恢复;线上审核当前为空,同内容审核/续发恢复由真实PG和隔离Redis验证,不能冒充真实短信审核放行。 + + +## 2026-09-08 运营修复实施与本地验证(测试部署前) + +本轮授权为格式化器修复及六项运营需求、本地提交、测试部署;未授权推送或预生产发布,不处理导入导出优化。开始基线main 50ae372,保留发布工具、部署文档、metrics及更早脏文件,只纳入本轮精确文件或文档追加hunk。 + +根因:热力图每行多次新建Intl.DateTimeFormat,独立进程39756次调用RSS约55MB至1169MB;复用格式化器对照约55MB至63MB。阈值保存日志明确为spawn /usr/bin/promtool ENOENT,而官方安装目录为/usr/local/bin。消息旧按检测维度生成,现新增每日应用唯一键;筛选旧标签将内部历史状态重复呈现。 + +已实现规则见operations-fixes-20260908.md。API全量64套674项通过;前端23文件108项通过(--maxWorkers=2;首次高并发运行出现4项超时,未放宽断言,限定并发重跑通过)。API构建、前端生产构建、安全/部署/包体检查通过;CSS新增页面归属已登记。真实测试API连接本地候选UI检查四Tab日期独立、创建弹窗遮罩/Escape及关闭、三个视口无水平溢出,控制台无错误;截图尚需部署后等待指标完成加载再验收。 + +待执行:精确提交归档验证、真实PG隔离并发与规模测试、标准测试部署、线上阈值保存恢复及三视口浏览器验收。测试过程不发短信、不创建真实通道或客户配置、不触发外部Webhook;未完成项目不能视为上线通过。 diff --git a/src/api/types/signature-retirement.ts b/src/api/types/signature-retirement.ts index e553566..7c1fbc6 100644 --- a/src/api/types/signature-retirement.ts +++ b/src/api/types/signature-retirement.ts @@ -47,6 +47,8 @@ export type SignatureRetirementDetection = { }; export type SignatureRetirementMessage = { + dailyGroupKey?: string | null; + detections?: Array; id: string; detectionId: string; cycleId: string; diff --git a/src/apps/admin/AdminAnalyticsPage.css b/src/apps/admin/AdminAnalyticsPage.css new file mode 100644 index 0000000..71bd726 --- /dev/null +++ b/src/apps/admin/AdminAnalyticsPage.css @@ -0,0 +1,22 @@ +.admin-analytics-page .page-actions { + flex-wrap: wrap; +} + +.admin-analytics-page .page-heading { + gap: 16px; +} + +.admin-analytics-page [hidden] { + display: none; +} + +@media (width <= 600px) { + .admin-analytics-page .page-heading { + align-items: stretch; + flex-direction: column; + } + + .admin-analytics-page .page-actions { + align-items: end; + } +} diff --git a/src/apps/admin/AdminAnalyticsPage.test.tsx b/src/apps/admin/AdminAnalyticsPage.test.tsx new file mode 100644 index 0000000..45e546e --- /dev/null +++ b/src/apps/admin/AdminAnalyticsPage.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AdminAnalyticsPage } from './AdminAnalyticsPage'; + +const { api } = vi.hoisted(() => ({ + api: { getSignatureQuality: vi.fn(), getSignatureRetirementHeatmap: vi.fn(), getUnreportedSignatures: vi.fn() }, +})); +vi.mock('@/api/adminApi', () => ({ adminApi: api })); + +describe('independent analytics tabs', () => { + beforeEach(() => { + vi.resetAllMocks(); + api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] })); + api.getUnreportedSignatures.mockImplementation(async (query) => ({ ...query, total: 0, items: [] })); + api.getSignatureRetirementHeatmap.mockResolvedValue({ items: [], dimensions: [] }); + }); + it('loads only the visited tab and preserves independent dates when switching back', async () => { + render(); + await waitFor(() => + expect(api.getSignatureQuality).toHaveBeenCalledWith(expect.objectContaining({ pageSize: 25 })), + ); + expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled(); + const quality = screen.getByRole('region', { name: '签名通道发送质量' }); + fireEvent.change(within(quality).getByLabelText('统计日期'), { target: { value: '2026-08-20' } }); + fireEvent.click(within(quality).getByRole('button', { name: '查询统计' })); + await waitFor(() => + expect(api.getSignatureQuality).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' })), + ); + fireEvent.click(screen.getByRole('tab', { name: '未报备签名' })); + await waitFor(() => expect(api.getUnreportedSignatures).toHaveBeenCalledTimes(1)); + const unreported = screen.getByRole('region', { name: '未报备签名' }); + fireEvent.change(within(unreported).getByLabelText('统计日期'), { target: { value: '2026-08-25' } }); + fireEvent.click(screen.getByRole('tab', { name: '签名通道发送质量' })); + expect(within(quality).getByLabelText('统计日期')).toHaveValue('2026-08-20'); + fireEvent.click(screen.getByRole('tab', { name: '未报备签名' })); + expect(within(unreported).getByLabelText('统计日期')).toHaveValue('2026-08-25'); + expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled(); + }); +}); diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 9e5c64c..f6370e0 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -1,4 +1,4 @@ -import { useDeferredValue, useEffect, useState } from 'react'; +import { useDeferredValue, useEffect, useRef, useState } from 'react'; import { BarChart3, Eye, Search, X } from 'lucide-react'; import { adminApi, @@ -10,7 +10,19 @@ import { type UnreportedSignatureItem, type PagedResult, } from '@/api/adminApi'; -import { Breadcrumb, Button, CarrierTag, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui'; +import { + Breadcrumb, + Button, + CarrierTag, + Input, + Pagination, + Select, + Tabs, + Table, + Tag, + type TableColumn, +} from '@/components/ui'; +import './AdminAnalyticsPage.css'; import { successRateClassName, successRateTone } from '@/utils/successRate'; const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown']; @@ -20,19 +32,51 @@ const drainageStates = [ { value: 'without', label: '不含引流' }, { value: 'unknown', label: '未检测' }, ] as const; -const carrierLabels: Record = { - mobile: '移动', - unicom: '联通', - telecom: '电信', - unknown: '未知', -}; +const analyticsTabs = [ + { value: 'quality', label: '签名通道发送质量' }, + { value: 'enterprise', label: '企业签名活跃度' }, + { value: 'channel', label: '通道签名活跃度' }, + { value: 'unreported', label: '未报备签名' }, +]; export function AdminAnalyticsPage() { + const [active, setActive] = useState('quality'); + const [visited, setVisited] = useState(['quality']); + return ( +
+
+
+ +

签名质量检测

+
+
+ { + setActive(value); + setVisited((current) => (current.includes(value) ? current : [...current, value])); + }} + items={analyticsTabs.map((tab) => ({ ...tab, content: null }))} + /> + {visited.map((kind) => ( + + ))} +
+ ); +} +function AnalyticsPanel({ kind }: { kind: string }) { + const [pageSize, setPageSize] = useState(25); + const [appliedDate, setAppliedDate] = useState(() => shanghaiDateKey()); + const requestId = useRef(0); const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey()); const [signatureQuality, setSignatureQuality] = useState(null); const [retirementHeatmap, setRetirementHeatmap] = useState([]); const [retirementDimensions, setRetirementDimensions] = useState([]); - const [unreportedSignatures, setUnreportedSignatures] = useState<(PagedResult & { date: string }) | null>(null); + const [unreportedSignatures, setUnreportedSignatures] = useState< + (PagedResult & { date: string }) | null + >(null); const [signatureKeyword, setSignatureKeyword] = useState(''); const [appliedKeyword, setAppliedKeyword] = useState(''); const [unreportedKeyword, setUnreportedKeyword] = useState(''); @@ -41,37 +85,52 @@ export function AdminAnalyticsPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - async function loadData(page = 1, keyword = appliedKeyword) { + async function loadData( + page = 1, + keyword = appliedKeyword, + unreported = appliedUnreportedKeyword, + date = statisticsDate, + size = pageSize, + ) { + const id = ++requestId.current; setLoading(true); + setError(''); try { - const [signatureData, heatmapData, unreportedData] = await Promise.all([ - adminApi.getSignatureQuality({ - date: statisticsDate, - keyword: keyword || undefined, + if (kind === 'quality') { + const data = await adminApi.getSignatureQuality({ date, keyword: keyword || undefined, page, pageSize: size }); + if (id !== requestId.current) return; + setSignatureQuality(data); + setAppliedKeyword(keyword); + setSelectedSignature(null); + } else if (kind === 'unreported') { + const data = await adminApi.getUnreportedSignatures({ + date, + keyword: unreported || undefined, page, - pageSize: 10, - }), - adminApi.getSignatureRetirementHeatmap(statisticsDate), - adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: appliedUnreportedKeyword || undefined, page: 1, pageSize: 10 }), - ]); - setSignatureQuality(signatureData); - setRetirementHeatmap(heatmapData.items); - setRetirementDimensions(heatmapData.dimensions); - setUnreportedSignatures(unreportedData); - setAppliedKeyword(keyword); - setSelectedSignature((current) => current - ? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null - : null); - setError(''); + pageSize: size, + }); + if (id !== requestId.current) return; + setUnreportedSignatures(data); + setAppliedUnreportedKeyword(unreported); + } else { + const data = await adminApi.getSignatureRetirementHeatmap(date); + if (id !== requestId.current) return; + setRetirementHeatmap(data.items); + setRetirementDimensions(data.dimensions); + } + setAppliedDate(date); } catch (failure) { - setError(failure instanceof Error ? failure.message : '统计数据加载失败'); + if (id === requestId.current) setError(failure instanceof Error ? failure.message : '统计数据加载失败'); } finally { - setLoading(false); + if (id === requestId.current) setLoading(false); } } useEffect(() => { void loadData(1, ''); + return () => { + requestId.current += 1; + }; }, []); useEffect(() => { @@ -122,21 +181,29 @@ export function AdminAnalyticsPage() { title: '送达成功', width: '110px', align: 'right', - render: (record) => {record.successCount.toLocaleString('zh-CN')}, + render: (record) => ( + {record.successCount.toLocaleString('zh-CN')} + ), }, { key: 'failureCount', title: '送达失败', width: '110px', align: 'right', - render: (record) => {record.failureCount.toLocaleString('zh-CN')}, + render: (record) => ( + {record.failureCount.toLocaleString('zh-CN')} + ), }, { key: 'submitFailureCount', title: '提交失败', width: '110px', align: 'right', - render: (record) => {record.submitFailureCount.toLocaleString('zh-CN')}, + render: (record) => ( + + {record.submitFailureCount.toLocaleString('zh-CN')} + + ), }, { key: 'successRate', @@ -169,36 +236,30 @@ export function AdminAnalyticsPage() { } function changeSignaturePage(page: number) { - setLoading(true); - void adminApi.getSignatureQuality({ date: statisticsDate, keyword: appliedKeyword || undefined, page, pageSize: 10 }) - .then((data) => { - setSignatureQuality(data); - setError(''); - }) - .catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '签名发送质量加载失败')) - .finally(() => setLoading(false)); + void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate); } - function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) { - setLoading(true); - void adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: keyword || undefined, page, pageSize: 10 }) - .then((data) => { - setUnreportedSignatures(data); - setAppliedUnreportedKeyword(keyword); - setError(''); - }) - .catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '未报备签名加载失败')) - .finally(() => setLoading(false)); + void loadData(page, appliedKeyword, keyword, appliedDate); } return ( -
+
tab.value === kind)?.label}>
- -

签名质量检测

+

日期、筛选和分页在当前页签内独立生效。

+ {error ?

{error}

: null} -
-
-
-
-

签名通道发送质量

- 已登记签名 + {kind === 'quality' ? ( +
+
+
+
+

签名通道发送质量

+ 已登记签名 +
+

+ {signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。 +

+
+
+ setSignatureKeyword(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') queryStatistics(); + }} + placeholder="搜索签名、企业或应用" + value={signatureKeyword} + /> +
-

- {signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。 -

-
- setSignatureKeyword(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') queryStatistics(); - }} - placeholder="搜索签名、企业或应用" - value={signatureKeyword} - /> - +
+ 统计说明: + 业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
-
-
- 统计说明: - 业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。 -
- - {(signatureQuality?.total ?? 0) > 0 ? ( - = Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))} - onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)} - onPageChange={changeSignaturePage} - onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)} - page={signatureQuality?.page ?? 1} - previousDisabled={(signatureQuality?.page ?? 1) <= 1} - total={signatureQuality?.total ?? 0} - totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))} +
- ) : null} - + {(signatureQuality?.total ?? 0) > 0 ? ( + = + Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10)) + } + onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)} + onPageChange={changeSignaturePage} + onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)} + page={signatureQuality?.page ?? 1} + previousDisabled={(signatureQuality?.page ?? 1) <= 1} + total={signatureQuality?.total ?? 0} + totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))} + /> + ) : null} + + ) : null} - - + {kind === 'enterprise' ? ( + + ) : null} + {kind === 'channel' ? ( + + ) : null} - loadUnreportedSignatures(page)} - onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())} - /> + {kind === 'unreported' ? ( + loadUnreportedSignatures(page)} + onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())} + /> + ) : null} {selectedSignature ? ( item.dimensionType === dimensionType); const dates = previousDateKeys(date, 30); - const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item])); + const cellMap = new Map( + visible.map((item) => [ + `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, + item, + ]), + ); 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) => + !deferredKeyword || + [item.channelName, item.tenantName, item.applicationName, item.signatureName].some((value) => + value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword), + ), + ) .map((item) => ({ key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`, signatureName: item.signatureName, @@ -305,17 +416,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: { applicationName: item.applicationName, carrier: item.carrier, approvedAt: item.approvedAt.slice(0, 10), - total: dates.reduce((sum, dateKey) => sum + (cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)?.acceptedBusinessCount ?? 0), 0), + total: dates.reduce( + (sum, dateKey) => + sum + + (cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`) + ?.acceptedBusinessCount ?? 0), + 0, + ), })) .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 page = pageState.key === paginationKey ? pageState.page : 1; + const setPage = (value: number) => setPageState({ key: paginationKey, page: value }); const currentPage = Math.min(page, totalPages); const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize); - useEffect(() => { - setPage(1); - }, [date, deferredKeyword, dimensionType, dimensions.length]); - return (
@@ -338,14 +454,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
- {dates.map((dateKey) => )} + + + + {dates.map((dateKey) => ( + + ))} + {pagedRows.map((row) => ( ; + const successRate = item?.acceptedBusinessCount + ? (item.deliveredBusinessCount / item.acceptedBusinessCount) * 100 + : 0; + const className = beforeApproval + ? 'is-inapplicable' + : !item + ? '' + : item.acceptedBusinessCount === 0 + ? 'is-zero' + : `is-rate-${successRateTone(successRate)}`; + const titleText = beforeApproval + ? '报备前,不适用' + : item + ? `提交条数:${item.submittedAttempts} 条\n上游接受条数:${item.acceptedBusinessCount} 条\n发送成功条数:${item.deliveredBusinessCount} 条\n发送成功率:${successRate.toFixed(1)}%\n检测状态:${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold} 条` + : '当日无检测快照'; + return ( + + ); })} ))} @@ -375,7 +515,13 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: { totalPages={totalPages} /> - ) :

{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}

} + ) : ( +

+ {deferredKeyword + ? '没有匹配企业、企业应用或签名的热力图维度。' + : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'} +

+ )} ); } @@ -399,28 +545,43 @@ function UnreportedSignaturesCard({ { key: 'signatureName', title: '短信签名', render: (record) => {record.signatureName} }, { key: 'tenantName', title: '企业名称', render: (record) => record.tenantName }, { key: 'applicationName', title: '企业应用', render: (record) => record.applicationName || '未关联企业应用' }, - { key: 'messageCount', title: '未报备短信', align: 'right', width: '150px', render: (record) => `${record.messageCount.toLocaleString('zh-CN')} 条` }, + { + key: 'messageCount', + title: '未报备短信', + align: 'right', + width: '150px', + render: (record) => `${record.messageCount.toLocaleString('zh-CN')} 条`, + }, ]; const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 10))); return (
-

未报备签名

待处理
+
+

未报备签名

+ 待处理 +

{data?.date ?? '所选日期'} 已进入平台、但系统签名库中没有对应记录的业务短信。

onKeywordChange(event.target.value)} - onKeyDown={(event) => { if (event.key === 'Enter') onSearch(); }} + onKeyDown={(event) => { + if (event.key === 'Enter') onSearch(); + }} placeholder="搜索签名、企业或企业应用" value={keyword} /> - +
-
统计说明:从短信正文开头识别签名;当前企业应用存在同名签名记录时不计入。
+
+ 统计说明:从短信正文开头识别签名;当前企业应用存在同名签名记录时不计入。 +
签名维度30日合计{dateKey.slice(5)}
签名维度30日合计{dateKey.slice(5)}
- {row.signatureName} + + {row.signatureName} + {row.channelName ? {row.channelName} : null} @@ -354,10 +478,26 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: { {dates.map((dateKey) => { const item = cellMap.get(`${row.key}:${dateKey}`); const beforeApproval = dateKey < row.approvedAt; - const successRate = item?.acceptedBusinessCount ? item.deliveredBusinessCount / item.acceptedBusinessCount * 100 : 0; - const className = beforeApproval ? 'is-inapplicable' : !item ? '' : item.acceptedBusinessCount === 0 ? 'is-zero' : `is-rate-${successRateTone(successRate)}`; - const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts} 条\n上游接受条数:${item.acceptedBusinessCount} 条\n发送成功条数:${item.deliveredBusinessCount} 条\n发送成功率:${successRate.toFixed(1)}%\n检测状态:${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold} 条` : '当日无检测快照'; - return {beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'} + {beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'} +
{ const leftRank = carrierOrder.indexOf(normalizeCarrier(left.carrier)); const rightRank = carrierOrder.indexOf(normalizeCarrier(right.carrier)); - return (leftRank < 0 ? carrierOrder.length : leftRank) - - (rightRank < 0 ? carrierOrder.length : rightRank); + return (leftRank < 0 ? carrierOrder.length : leftRank) - (rightRank < 0 ? carrierOrder.length : rightRank); }); - const channels = [...new Map([...item.breakdowns, ...item.drainageBreakdowns] - .map((entry) => [entry.channelId, entry.channelName])).entries()] - .map(([channelId, channelName]) => ({ channelId, channelName })); - const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier)); + const channels = [ + ...new Map( + [...item.breakdowns, ...item.drainageBreakdowns].map((entry) => [entry.channelId, entry.channelName]), + ).entries(), + ].map(([channelId, channelName]) => ({ channelId, channelName })); + const visibleCarriers = carrierOrder.filter((carrier) => + item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier), + ); return ( -
{ - if (event.target === event.currentTarget) onClose(); - }}> -
@@ -537,47 +748,67 @@ function SignatureQualityDrawer({ {matrixMode === 'overall' ? ( - {visibleCarriers.map((carrier) => )} + {visibleCarriers.map((carrier) => ( + + ))} ) : ( - {majorCarrierOrder.map((carrier) => )} + {majorCarrierOrder.map((carrier) => ( + + ))} )} {matrixMode === 'overall' ? channels.map((channel) => ( - - - {visibleCarriers.map((carrier) => { - const metric = item.breakdowns.find((entry) => ( - entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier - )); - return ( - - ); - })} - - )) - : channels.flatMap((channel) => drainageStates.map((state, stateIndex) => ( - - {stateIndex === 0 ? : null} - - {majorCarrierOrder.map((carrier) => { - const metric = item.drainageBreakdowns.find((entry) => ( - entry.channelId === channel.channelId - && normalizeCarrier(entry.carrier) === carrier - && entry.drainageState === state.value - )); - return ; - })} - - )))} + + + {visibleCarriers.map((carrier) => { + const metric = item.breakdowns.find( + (entry) => + entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier, + ); + return ( + + ); + })} + + )) + : channels.flatMap((channel) => + drainageStates.map((state, stateIndex) => ( + + {stateIndex === 0 ? : null} + + {majorCarrierOrder.map((carrier) => { + const metric = item.drainageBreakdowns.find( + (entry) => + entry.channelId === channel.channelId && + normalizeCarrier(entry.carrier) === carrier && + entry.drainageState === state.value, + ); + return ( + + ); + })} + + )), + )}
通道名称 + +
通道名称 引流类型 + +
{channel.channelName} - {metric ? : } -
{channel.channelName}{state.label}
{channel.channelName} + {metric ? ( + + ) : ( + + )} +
{channel.channelName}{state.label} + +
@@ -601,16 +832,35 @@ function QualityMetric({ label, value, valueClassName }: { label: string; value: ); } -function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureChannelCarrierQualityStat; zeroWhenEmpty?: boolean }) { +function MatrixMetric({ + metric, + zeroWhenEmpty = false, +}: { + metric?: SignatureChannelCarrierQualityStat; + zeroWhenEmpty?: boolean; +}) { const total = metric?.total ?? 0; const successRate = metric?.successRate ?? 0; if (zeroWhenEmpty && total === 0) return 0; return ( -
-
通道提交{total.toLocaleString('zh-CN')} 次
-
成功率{successRate.toFixed(1)}%
-
平均到达{formatDuration(metric?.averageArrivalMs)}
+
+
+ 通道提交 + {total.toLocaleString('zh-CN')} 次 +
+
+ 成功率 + + {successRate.toFixed(1)}% + +
+
+ 平均到达 + {formatDuration(metric?.averageArrivalMs)} +
{(metric?.submitFailureCount ?? 0) > 0 ? 提交失败 {metric?.submitFailureCount} : null}
); @@ -619,7 +869,9 @@ function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureCha function QualityRate({ value }: { value: number }) { return (
-
+
+ +
{value.toFixed(1)}%
); @@ -633,10 +885,6 @@ function normalizeCarrier(value: string) { return 'unknown'; } -function carrierLabel(value: string) { - return carrierLabels[normalizeCarrier(value)] ?? '未知'; -} - function formatDuration(value?: number | null) { if (value == null) return '—'; if (value < 1000) return `${Math.round(value)} 毫秒`; @@ -656,5 +904,7 @@ function shanghaiDateKey(value = new Date()) { function previousDateKeys(endKey: string, days: number) { const end = new Date(`${endKey}T12:00:00+08:00`); - return Array.from({ length: days }, (_, index) => shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000))); + return Array.from({ length: days }, (_, index) => + shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000)), + ); } diff --git a/src/apps/admin/AdminHome.css b/src/apps/admin/AdminHome.css new file mode 100644 index 0000000..3ea1605 --- /dev/null +++ b/src/apps/admin/AdminHome.css @@ -0,0 +1,87 @@ +.admin-dashboard .home-metrics { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; +} + +.admin-dashboard .home-metric { + padding: 18px; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; + background: #fff; + min-width: 0; +} + +.admin-dashboard .home-metric__heading { + display: flex; + justify-content: space-between; + align-items: start; + gap: 8px; + font-size: 13px; + color: #6b7280; +} + +.admin-dashboard .home-metric__category { + display: flex; + align-items: center; + gap: 4px; + color: #2563eb; + font-size: 12px; + white-space: nowrap; +} + +.admin-dashboard .home-metric__number { + display: flex; + align-items: baseline; + gap: 6px; + margin: 16px 0 10px; + color: #111827; + font-variant-numeric: tabular-nums; + flex-wrap: wrap; + overflow-wrap: anywhere; +} + +.admin-dashboard .home-metric__number strong { + font-size: 26px; + font-weight: 600; + line-height: 1.2; +} + +.admin-dashboard .home-metric__number span { + font-size: 12px; + color: #6b7280; +} + +.admin-dashboard .home-metric__number.is-negative { + color: #dc2626; +} + +.admin-dashboard .home-metric p { + margin: 0; + font-size: 12px; + color: #6b7280; +} + +@media (width <= 1400px) { + .admin-dashboard .home-metrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (width <= 700px) { + .admin-dashboard .home-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-dashboard .home-metric { + padding: 14px; + } + + .admin-dashboard .home-metric__category { + display: none; + } + + .admin-dashboard .home-metric__number strong { + font-size: 23px; + } +} diff --git a/src/apps/admin/AdminHome.tsx b/src/apps/admin/AdminHome.tsx index aa61ee0..69fc297 100644 --- a/src/apps/admin/AdminHome.tsx +++ b/src/apps/admin/AdminHome.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui'; +import './AdminHome.css'; import { Chart } from '@/components/ui/Chart'; import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi'; import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions'; @@ -170,61 +171,99 @@ export function AdminHome() {
-
-
- 今日发送总量 - {formatCount(totalSend)} 条 - 来自真实短信记录聚合 -
-
- 今日消息分片数 - {formatCount(dashboard?.today.segmentCount ?? 0)} 片 - 来自真实分片审计记录 -
-
- 总体成功率 - {averageSuccessRate.toFixed(1)}% - delivered / 今日总量 -
-
- 今日到达率 - {(dashboard?.today.arrivalRate ?? 0).toFixed(1)}% - 到达分片 / 发送总分片 -
-
- 今日活跃签名 - {activeSignatureCount} - 当天有真实发送记录的签名 -
-
- 今日消费金额 - ¥{formatCurrency(todaySpend)} - 来自今日消息金额聚合 -
-
- 今日返还金额 - ¥{formatCurrency(todayReturned)} - 来自今日返还流水 -
-
- 今日计收金额 - ¥{formatCurrency(todayBilled)} - 成功短信计费条数 × 客户价 -
-
- 今日利润 - - ¥{formatCurrency(todayProfit)} - - 计收金额 - 成功分片通道成本 -
-
- 今日利润率 - - {(dashboard?.today.profitRate ?? 0).toFixed(1)}% - - 今日利润 / 今日计收金额 -
+
+ {[ + { + label: '今日发送总量', + value: formatCount(totalSend), + unit: '条', + note: '业务短信', + group: '发送', + icon: , + }, + { + label: '今日消息分片数', + value: formatCount(dashboard?.today.segmentCount ?? 0), + unit: '片', + note: '实际消息分片', + group: '发送', + }, + { + label: '总体成功率', + value: averageSuccessRate.toFixed(1), + unit: '%', + note: '送达成功 / 今日总量', + group: '质量', + icon: , + }, + { + label: '今日到达率', + value: (dashboard?.today.arrivalRate ?? 0).toFixed(1), + unit: '%', + note: '到达分片 / 发送总分片', + group: '质量', + }, + { + label: '今日活跃签名', + value: formatCount(activeSignatureCount), + unit: '个', + note: '今日有真实发送记录', + group: '发送', + }, + { + label: '今日消费金额', + value: formatCurrency(todaySpend), + unit: '元', + note: '今日消息消费', + group: '经营', + icon: , + }, + { + label: '今日返还金额', + value: formatCurrency(todayReturned), + unit: '元', + note: '今日返还流水', + group: '经营', + }, + { + label: '今日计收金额', + value: formatCurrency(todayBilled), + unit: '元', + note: '成功计费条数 × 客户价', + group: '经营', + }, + { + label: '今日利润', + value: formatCurrency(todayProfit), + unit: '元', + note: '计收金额 − 成功分片通道成本', + group: '经营', + danger: todayProfit < 0, + }, + { + label: '今日利润率', + value: (dashboard?.today.profitRate ?? 0).toFixed(1), + unit: '%', + note: '今日利润 / 今日计收金额', + group: '经营', + danger: (dashboard?.today.profitRate ?? 0) < 0, + }, + ].map((metric) => ( +
+
+ {metric.label} + + {metric.icon} + {metric.group} + +
+
+ {dashboard ? metric.value : '—'} + {metric.unit} +
+

{dashboard ? metric.note : error ? '数据暂不可用' : '正在加载…'}

+
+ ))}
{error ?
{error}
: null} diff --git a/src/apps/admin/AdminReportTasksPage.tsx b/src/apps/admin/AdminReportTasksPage.tsx index 2ce5d8c..54ca64f 100644 --- a/src/apps/admin/AdminReportTasksPage.tsx +++ b/src/apps/admin/AdminReportTasksPage.tsx @@ -471,7 +471,9 @@ export function AdminReportTasksPage() { onChange={(event) => setStatus(event.target.value)} options={[ { label: '全部状态', value: 'all' }, - ...Object.entries(statusMeta).map(([value, meta]) => ({ label: meta.label, value })), + ...Object.entries(statusMeta) + .filter(([value]) => !['exporting', 'rejected'].includes(value)) + .map(([value, meta]) => ({ label: meta.label, value })), ]} value={status} /> diff --git a/src/apps/admin/AdminSignatureRetirementPage.tsx b/src/apps/admin/AdminSignatureRetirementPage.tsx index 76019a0..b67e64c 100644 --- a/src/apps/admin/AdminSignatureRetirementPage.tsx +++ b/src/apps/admin/AdminSignatureRetirementPage.tsx @@ -11,23 +11,55 @@ import { type SignatureRetirementWebhook, type TenantOption, } from '@/api/adminApi'; -import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; +import { + Breadcrumb, + Button, + CarrierTag, + DateRangeInput, + Input, + Modal, + Pagination, + Select, + Table, + Tabs, + Tag, + Textarea, + type DateRangeValue, + type TableColumn, +} from '@/components/ui'; const carriers = ['mobile', 'unicom', 'telecom'] as const; const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' }; const ruleTypeLabels: Record = { - enterprise_global: '企业全局规则', enterprise_application: '企业应用特殊规则', channel_global: '通道全局规则', channel: '通道特殊规则', + enterprise_global: '企业全局规则', + enterprise_application: '企业应用特殊规则', + channel_global: '通道全局规则', + channel: '通道特殊规则', }; type RuleDraft = { - ruleType: SignatureRetirementRuleType; targetId: string; enabled: boolean; - mobileWindowDays: string; mobileThreshold: string; unicomWindowDays: string; unicomThreshold: string; - telecomWindowDays: string; telecomThreshold: string; messageTemplate: string; + ruleType: SignatureRetirementRuleType; + targetId: string; + enabled: boolean; + mobileWindowDays: string; + mobileThreshold: string; + unicomWindowDays: string; + unicomThreshold: string; + telecomWindowDays: string; + telecomThreshold: string; + messageTemplate: string; }; const emptyRule: RuleDraft = { - ruleType: 'enterprise_global', targetId: '', enabled: true, - mobileWindowDays: '30', mobileThreshold: '1', unicomWindowDays: '30', unicomThreshold: '1', - telecomWindowDays: '30', telecomThreshold: '1', messageTemplate: '', + ruleType: 'enterprise_global', + targetId: '', + enabled: true, + mobileWindowDays: '30', + mobileThreshold: '1', + unicomWindowDays: '30', + unicomThreshold: '1', + telecomWindowDays: '30', + telecomThreshold: '1', + messageTemplate: '', }; type MessageFilters = { @@ -47,7 +79,13 @@ type SuppressionDraft = { function defaultMessageFilters(): MessageFilters { const today = shanghaiDateKey(); - return { dateRange: { start: today, end: today }, tenantId: '', applicationId: '', signatureKeyword: '', channelId: '' }; + return { + dateRange: { start: today, end: today }, + tenantId: '', + applicationId: '', + signatureKeyword: '', + channelId: '', + }; } export function AdminSignatureRetirementPage() { @@ -74,74 +112,252 @@ export function AdminSignatureRetirementPage() { const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => { setLoading(true); try { - const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] = await Promise.all([ - adminApi.getSignatureRetirementConfiguration(), - adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)), - adminApi.listSignatureRetirementSuppressions(), - adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels(), adminApi.listTenantOptions(), - ]); - setRules(configuration.rules); setWebhooks(configuration.webhooks.filter((item) => item.status === 'active')); - setMessages(messageResult.items); setMessageTotal(messageResult.total); setMessagePage(messageResult.page); + const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] = + await Promise.all([ + adminApi.getSignatureRetirementConfiguration(), + adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)), + adminApi.listSignatureRetirementSuppressions(), + adminApi.listEnterpriseApplicationOptions(), + adminApi.listChannels(), + adminApi.listTenantOptions(), + ]); + setRules(configuration.rules); + setWebhooks(configuration.webhooks.filter((item) => item.status === 'active')); + setMessages(messageResult.items); + setMessageTotal(messageResult.total); + setMessagePage(messageResult.page); setSuppressions(activeSuppressions); - setApplications(applicationRows); setChannels(channelRows); setTenants(tenantRows); setError(''); + setApplications(applicationRows); + setChannels(channelRows); + setTenants(tenantRows); + setError(''); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名清退预警数据加载失败'); - } finally { setLoading(false); } + } finally { + setLoading(false); + } }, []); - useEffect(() => { void loadData(1, defaultMessageFilters()); }, [loadData]); + useEffect(() => { + void loadData(1, defaultMessageFilters()); + }, [loadData]); async function loadMessages(targetPage: number, filters: MessageFilters) { setLoading(true); try { const result = await adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)); - setMessages(result.items); setMessageTotal(result.total); setMessagePage(result.page); setError(''); + setMessages(result.items); + setMessageTotal(result.total); + setMessagePage(result.page); + setError(''); } catch (failure) { setError(errorMessage(failure, '预警消息加载失败')); - } finally { setLoading(false); } + } finally { + setLoading(false); + } } const ruleColumns: Array> = [ - { key: 'type', title: '规则范围', render: (item) => <>{ruleTypeLabels[item.ruleType]}
{targetName(item, applications, channels)} }, + { + key: 'type', + title: '规则范围', + render: (item) => ( + <> + {ruleTypeLabels[item.ruleType]} +
+ {targetName(item, applications, channels)} + + ), + }, { key: 'mobile', title: '移动', render: (item) => `${item.mobileWindowDays}天 / ${item.mobileThreshold}条` }, { key: 'unicom', title: '联通', render: (item) => `${item.unicomWindowDays}天 / ${item.unicomThreshold}条` }, { key: 'telecom', title: '电信', render: (item) => `${item.telecomWindowDays}天 / ${item.telecomThreshold}条` }, { key: 'version', title: '版本', render: (item) => `v${item.version}` }, - { key: 'actions', title: '操作', align: 'right', render: (item) => }, + { + key: 'actions', + title: '操作', + align: 'right', + render: (item) => ( + + ), + }, ]; const messageColumns: Array> = [ - { key: 'title', title: '预警', width: '340px', render: (item) =>
{item.title}
{item.content}
}, - { key: 'dimension', title: '维度', width: '240px', render: (item) => <>{item.tenantName ?? '-'}
{item.applicationName ?? '未关联企业应用'} / {item.signatureName ?? '-'}
{item.channelName ?? '企业维度'} }, - { key: 'carrier', title: '运营商', width: '90px', render: (item) => }, - { key: 'count', title: '活动量', width: '130px', render: (item) => item.detection ? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}` : '-' }, + { + key: 'title', + title: '预警', + width: '340px', + render: (item) => ( +
+ {item.title} +
+
+ {item.dailyGroupKey ? ( +
+ {item.detections?.length ?? 0} 项预警明细 + {item.content.split('\n').map((line, index) => ( +

{line}

+ ))} +
+ ) : ( + item.content + )} +
+
+ ), + }, + { + key: 'dimension', + title: '维度', + width: '240px', + render: (item) => ( + <> + {item.tenantName ?? '-'} +
+ + {item.applicationName ?? '未关联企业应用'} + {item.dailyGroupKey ? '' : ` / ${item.signatureName ?? '-'}`} + +
+ {item.dailyGroupKey ? '企业应用每日汇总' : (item.channelName ?? '企业维度')} + + ), + }, + { + key: 'carrier', + title: '运营商', + width: '90px', + render: (item) => + item.dailyGroupKey ? ( + <> + {[...new Set(item.detections?.map((entry) => entry.carrier) ?? [])].map((carrier) => ( + + ))} + + ) : ( + + ), + }, + { + key: 'count', + title: '活动量', + width: '130px', + render: (item) => + item.dailyGroupKey + ? `${item.detections?.length ?? 0} 项(详见正文)` + : item.detection + ? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}` + : '-', + }, { key: 'time', title: '消息时间', width: '170px', render: (item) => formatDateTime(item.createdAt) }, - { key: 'state', title: '状态', width: '90px', render: (item) => item.suppressed ? 已抑制 : item.isRead ? 已读 : 未读 }, - { key: 'actions', title: '操作', align: 'right', width: '190px', render: (item) =>
{!item.isRead ? : null}{!item.suppressed ? : null}
}, + { + key: 'state', + title: '状态', + width: '90px', + render: (item) => + item.suppressed ? ( + 已抑制 + ) : item.isRead ? ( + 已读 + ) : ( + 未读 + ), + }, + { + key: 'actions', + title: '操作', + align: 'right', + width: '190px', + render: (item) => ( +
+ {!item.isRead ? ( + + ) : null} + {!item.suppressed ? ( + + ) : null} +
+ ), + }, ]; const suppressionColumns: Array> = [ - { key: 'dimension', title: '维度', render: (item) => {item.dimensionType === 'enterprise' ? '企业' : '通道'} / }, - { key: 'mode', title: '方式', render: (item) => item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}` }, + { + key: 'dimension', + title: '维度', + render: (item) => ( + + {item.dimensionType === 'enterprise' ? '企业' : '通道'} / + + ), + }, + { + key: 'mode', + title: '方式', + render: (item) => (item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}`), + }, { key: 'reason', title: '原因', render: (item) => item.reason || '-' }, - { key: 'actions', title: '操作', align: 'right', render: (item) => }, + { + key: 'actions', + title: '操作', + align: 'right', + render: (item) => ( + + ), + }, ]; async function saveRule() { if (!ruleDraft) return; setLoading(true); try { await adminApi.saveSignatureRetirementRule({ - ...ruleDraft, targetId: ruleDraft.targetId || undefined, - mobileWindowDays: Number(ruleDraft.mobileWindowDays), mobileThreshold: Number(ruleDraft.mobileThreshold), - unicomWindowDays: Number(ruleDraft.unicomWindowDays), unicomThreshold: Number(ruleDraft.unicomThreshold), - telecomWindowDays: Number(ruleDraft.telecomWindowDays), telecomThreshold: Number(ruleDraft.telecomThreshold), + ...ruleDraft, + targetId: ruleDraft.targetId || undefined, + mobileWindowDays: Number(ruleDraft.mobileWindowDays), + mobileThreshold: Number(ruleDraft.mobileThreshold), + unicomWindowDays: Number(ruleDraft.unicomWindowDays), + unicomThreshold: Number(ruleDraft.unicomThreshold), + telecomWindowDays: Number(ruleDraft.telecomWindowDays), + telecomThreshold: Number(ruleDraft.telecomThreshold), }); - setRuleDraft(null); await loadData(messagePage, appliedMessageFilters); - } catch (failure) { setError(errorMessage(failure, '规则保存失败')); } finally { setLoading(false); } + setRuleDraft(null); + await loadData(messagePage, appliedMessageFilters); + } catch (failure) { + setError(errorMessage(failure, '规则保存失败')); + } finally { + setLoading(false); + } } async function saveWebhook() { - try { await adminApi.createSignatureRetirementWebhook(webhookDraft); setWebhookOpen(false); setWebhookDraft({ name: '', platform: 'wecom', url: '' }); await loadData(messagePage, appliedMessageFilters); } - catch (failure) { setError(errorMessage(failure, 'Webhook保存失败')); } + try { + await adminApi.createSignatureRetirementWebhook(webhookDraft); + setWebhookOpen(false); + setWebhookDraft({ name: '', platform: 'wecom', url: '' }); + await loadData(messagePage, appliedMessageFilters); + } catch (failure) { + setError(errorMessage(failure, 'Webhook保存失败')); + } + } + async function readMessage(id: string) { + await adminApi.readSignatureRetirementMessage(id); + await loadMessages(messagePage, appliedMessageFilters); + window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); } - async function readMessage(id: string) { await adminApi.readSignatureRetirementMessage(id); await loadMessages(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); } function openSuppression(messageId: string) { setActionError(''); setSuppressionDraft({ messageId, mode: 'temporary', muteUntil: addDateKey(shanghaiDateKey(), 7), reason: '' }); @@ -149,76 +365,595 @@ export function AdminSignatureRetirementPage() { async function saveSuppression() { if (!suppressionDraft) return; const reason = suppressionDraft.reason.trim(); - const days = suppressionDraft.mode === 'temporary' ? differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) : undefined; - if (!reason) { setActionError('请输入抑制原因'); return; } - if (suppressionDraft.mode === 'temporary' && (!days || days < 1)) { setActionError('临时抑制截止日期必须晚于今天'); return; } + const days = + suppressionDraft.mode === 'temporary' + ? differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) + : undefined; + if (!reason) { + setActionError('请输入抑制原因'); + return; + } + if (suppressionDraft.mode === 'temporary' && (!days || days < 1)) { + setActionError('临时抑制截止日期必须晚于今天'); + return; + } setLoading(true); try { - await adminApi.suppressSignatureRetirementMessage(suppressionDraft.messageId, suppressionDraft.mode === 'permanent' ? { mode: 'permanent', reason } : { mode: 'temporary', days, reason }); - setSuppressionDraft(null); setActionError(''); + await adminApi.suppressSignatureRetirementMessage( + suppressionDraft.messageId, + suppressionDraft.mode === 'permanent' ? { mode: 'permanent', reason } : { mode: 'temporary', days, reason }, + ); + setSuppressionDraft(null); + setActionError(''); await loadData(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); - } catch (failure) { setActionError(errorMessage(failure, '抑制失败')); } finally { setLoading(false); } + } catch (failure) { + setActionError(errorMessage(failure, '抑制失败')); + } finally { + setLoading(false); + } } async function confirmCancelSuppression() { if (!cancelSuppressionDraft) return; const reason = cancelSuppressionDraft.reason.trim(); - if (!reason) { setActionError('请输入取消抑制原因'); return; } + if (!reason) { + setActionError('请输入取消抑制原因'); + return; + } setLoading(true); try { await adminApi.cancelSignatureRetirementSuppression(cancelSuppressionDraft.id, reason); - setCancelSuppressionDraft(null); setActionError(''); await loadData(messagePage, appliedMessageFilters); - } catch (failure) { setActionError(errorMessage(failure, '取消抑制失败')); } finally { setLoading(false); } + setCancelSuppressionDraft(null); + setActionError(''); + await loadData(messagePage, appliedMessageFilters); + } catch (failure) { + setActionError(errorMessage(failure, '取消抑制失败')); + } finally { + setLoading(false); + } + } + async function deleteWebhook(id: string) { + if (!window.confirm('确认停用该Webhook?')) return; + await adminApi.deleteSignatureRetirementWebhook(id); + await loadData(messagePage, appliedMessageFilters); + } + async function readAll() { + await adminApi.readAllSignatureRetirementMessagesToday(); + await loadMessages(messagePage, appliedMessageFilters); + window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); } - async function deleteWebhook(id: string) { if (!window.confirm('确认停用该Webhook?')) return; await adminApi.deleteSignatureRetirementWebhook(id); await loadData(messagePage, appliedMessageFilters); } - async function readAll() { await adminApi.readAllSignatureRetirementMessagesToday(); await loadMessages(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); } const tenantOptions = tenants.map((item) => ({ value: item.id, label: item.name })); const applicationOptions = applications .filter((item) => !messageFilters.tenantId || item.tenantId === messageFilters.tenantId) .map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` })); function applyMessageQuery() { const filters = { ...messageFilters, dateRange: normalizeMessageDateRange(messageFilters.dateRange) }; - setMessageFilters(filters); setAppliedMessageFilters(filters); setMessagePage(1); void loadMessages(1, filters); + setMessageFilters(filters); + setAppliedMessageFilters(filters); + setMessagePage(1); + void loadMessages(1, filters); } function resetMessageQuery() { const filters = defaultMessageFilters(); - setMessageFilters(filters); setAppliedMessageFilters(filters); setMessagePage(1); void loadMessages(1, filters); + setMessageFilters(filters); + setAppliedMessageFilters(filters); + setMessagePage(1); + void loadMessages(1, filters); } const tabs = [ - { value: 'messages', label: `预警消息(${messageTotal})`, content:

预警消息

默认查询今日,可按历史日期区间和业务维度检索真实预警消息。

setMessageFilters((value) => ({ ...value, applicationId: event.target.value }))} options={[{ value: '', label: '全部企业应用' }, ...applicationOptions]} searchable value={messageFilters.applicationId} /> setMessageFilters((value) => ({ ...value, signatureKeyword: event.target.value }))} placeholder="请输入签名名称" value={messageFilters.signatureKeyword} /> + setMessageFilters((value) => ({ ...value, tenantId: event.target.value, applicationId: '' })) + } + options={[{ value: '', label: '全部企业' }, ...tenantOptions]} + searchable + value={messageFilters.tenantId} + /> + setMessageFilters((value) => ({ ...value, signatureKeyword: event.target.value }))} + placeholder="请输入签名名称" + value={messageFilters.signatureKeyword} + /> + setWebhookDraft((value) => ({ ...value, name: event.target.value }))} value={webhookDraft.name} /> setWebhookDraft((value) => ({ ...value, url: event.target.value }))} value={webhookDraft.url} />
- { setSuppressionDraft(null); setActionError(''); }} footer={<>}> - {suppressionDraft ?

抑制只停止站内提醒和Webhook,每日检测仍会继续。

{suppressionDraft.mode === 'temporary' ? setSuppressionDraft((value) => value ? { ...value, muteUntil: event.target.value } : value)} type="date" value={suppressionDraft.muteUntil} /> : null}