fix: bound formatter memory and improve operations workflows

This commit is contained in:
hectorzhao
2026-09-08 12:46:15 +08:00
parent 50ae37242b
commit 2c228a94e1
27 changed files with 4013 additions and 1230 deletions
@@ -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");
+4
View File
@@ -1251,6 +1251,10 @@ model SignatureRetirementDetection {
} }
model SignatureRetirementMessage { model SignatureRetirementMessage {
dailyGroupKey String? @unique
notificationDate DateTime? @db.Date
applicationId String?
detectionIds String[] @default([])
id String @id @default(cuid()) id String @id @default(cuid())
detectionId String @unique detectionId String @unique
cycleId String cycleId String
+12 -73
View File
@@ -1,24 +1,9 @@
import { import { BadRequestException, NotFoundException } from '@nestjs/common';
BadRequestException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { import type {
CreateChannelDto,
UpdateChannelDto,
CreateChannelGroupDto,
CreateChannelGroupItemDto,
UpdateChannelGroupDto,
CreateRouteRuleDto,
CreateReportFieldDto, CreateReportFieldDto,
ReplaceReportFieldsDto, ReplaceReportFieldsDto,
CreateReportMaterialDto, CreateReportMaterialDto,
@@ -26,73 +11,19 @@ import type {
ChangeReportTaskStatusesDto, ChangeReportTaskStatusesDto,
CreateReportExportDto, CreateReportExportDto,
CreateReceiptImportDto, CreateReceiptImportDto,
UpsertConnectionStateDto,
ChangeChannelStatusDto,
CopyChannelDto,
TestChannelDto,
} from './channels.contracts'; } from './channels.contracts';
import { 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, parseReceiptContent,
splitReceiptLine,
stripReceiptCell,
findReceiptStatusIndex,
normalizeReceiptStatus,
deriveReceiptStatus, deriveReceiptStatus,
ChannelReportDeliveryRow, ChannelReportDeliveryRow,
summarizeChannelReportDelivery, summarizeChannelReportDelivery,
sumReportDelivery,
percentage,
latestDate, latestDate,
currentShanghaiDayRange, currentShanghaiDayRange,
normalizeRetryTimeLimitMinutes,
normalizeSpreadsheetSize, normalizeSpreadsheetSize,
normalizeBusinessCarrier, normalizeBusinessCarrier,
normalizeChannelCarrier,
normalizeChannelCarriers, normalizeChannelCarriers,
isChannelCarrierCompatible,
normalizeRegion,
isRegionCompatible,
validateGroupItems,
normalizeReportType, normalizeReportType,
summarizeReportStatuses, summarizeReportStatuses,
normalizeLinkEvent,
} from './channels.helpers'; } from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */ /** R5 channel domain service composed behind ChannelsService. */
@@ -155,6 +86,9 @@ export class ChannelReportingService {
for (const legacy of legacyBoth) { for (const legacy of legacyBoth) {
if (oppositeCodes.has(legacy.code)) continue; if (oppositeCodes.has(legacy.code)) continue;
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy; const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
void _id;
void _createdAt;
void _updatedAt;
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } }); await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
} }
for (const [index, configured] of data.fields.entries()) { for (const [index, configured] of data.fields.entries()) {
@@ -225,7 +159,12 @@ export class ChannelReportingService {
const tasks = await this.prisma.channelSignatureReportTask.findMany({ const tasks = await this.prisma.channelSignatureReportTask.findMany({
where: { where: {
tenantId, tenantId,
status, status:
status === 'reporting' || status === 'exporting'
? { in: ['reporting', 'exporting'] }
: status === 'failed'
? { in: ['failed', 'rejected'] }
: status,
channelId, channelId,
reportType, reportType,
signature: { auditStatus: { not: 'deleted' } }, signature: { auditStatus: { not: 'deleted' } },
@@ -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 { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { execFile } from 'node:child_process'; 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 { dirname } from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -12,16 +19,148 @@ import type { InfrastructureAlertSettings, InfrastructureAlertThresholds } from
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
export const ALERT_THRESHOLD_DEFINITIONS = [ 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: 'hostCpu',
{ 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'] }, label: '主机 CPU 使用率',
{ 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'] }, unit: '%',
{ 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'] }, min: 1,
{ 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'] }, max: 100,
{ 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'] }, step: 1,
{ 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'] }, warning: 80,
{ 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'] }, critical: 90,
{ 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'] }, 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; ] as const;
export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries( export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries(
@@ -32,12 +171,17 @@ export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fr
export class InfrastructureAlertSettingsService { export class InfrastructureAlertSettingsService {
private readonly logger = new Logger(InfrastructureAlertSettingsService.name); private readonly logger = new Logger(InfrastructureAlertSettingsService.name);
private readonly rulesPath: string; private readonly rulesPath: string;
private readonly promtoolPath: string; private readonly promtoolPath: string | undefined;
private readonly reloadUrl: string; private readonly reloadUrl: string;
constructor(private readonly prisma: PrismaService, config: ConfigService) { constructor(
this.rulesPath = String(config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml'); private readonly prisma: PrismaService,
this.promtoolPath = String(config.get('PROMTOOL_PATH') ?? '/usr/bin/promtool'); config: ConfigService,
) {
this.rulesPath = String(
config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml',
);
this.promtoolPath = config.get<string>('PROMTOOL_PATH');
this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload'); 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, appliedAt: row?.appliedAt?.toISOString() ?? null,
thresholds, thresholds,
effectiveThresholds: effective, 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 thresholds = this.validate(body.thresholds);
const claimed = await this.prisma.infrastructureAlertSetting.updateMany({ const claimed = await this.prisma.infrastructureAlertSetting.updateMany({
where: { id: 'global', configVersion: expectedVersion }, 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 实例同时覆盖规则文件并把旧配置误标成已生效。 // 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。
if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试'); if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试');
@@ -71,12 +228,32 @@ export class InfrastructureAlertSettingsService {
try { try {
await this.applyRules(thresholds); await this.applyRules(thresholds);
await this.prisma.$transaction([ 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.infrastructureAlertSetting.update({
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'monitoring.alert_thresholds_updated', resource: 'infrastructure_alert_setting', resourceId: 'global', detail: { configVersion: nextVersion, thresholds } } }), 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) { } catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown 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}`); this.logger.error(`Prometheus managed rules apply failed: ${message}`);
throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留'); throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留');
} }
@@ -86,13 +263,20 @@ export class InfrastructureAlertSettingsService {
private validate(value: unknown): InfrastructureAlertThresholds { private validate(value: unknown): InfrastructureAlertThresholds {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效'); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效');
const input = value as Record<string, unknown>; const input = value as Record<string, unknown>;
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 = {}; const result: InfrastructureAlertThresholds = {};
for (const definition of ALERT_THRESHOLD_DEFINITIONS) { for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined; const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined;
const warning = Number(pair?.warning); const warning = Number(pair?.warning);
const critical = Number(pair?.critical); 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}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`); throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`);
} }
result[definition.key] = { warning, critical }; result[definition.key] = { warning, critical };
@@ -101,7 +285,11 @@ export class InfrastructureAlertSettingsService {
} }
private asThresholds(value: unknown) { private asThresholds(value: unknown) {
try { return this.validate(value); } catch { return null; } try {
return this.validate(value);
} catch {
return null;
}
} }
private renderRules(thresholds: InfrastructureAlertThresholds) { private renderRules(thresholds: InfrastructureAlertThresholds) {
@@ -113,9 +301,26 @@ export class InfrastructureAlertSettingsService {
const isWarning = index === 0; const isWarning = index === 0;
// 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。 // 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。
const guard = 'guard' in definition ? ` and (${definition.guard})` : ''; 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 expr = isWarning
const diskLocation = definition.key === 'hostDisk' ? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。' : ''; ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}`
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}"`); : `(${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`; return `${lines.join('\n')}\n`;
@@ -128,7 +333,9 @@ export class InfrastructureAlertSettingsService {
const previous = await readFile(this.rulesPath).catch(() => null); const previous = await readFile(this.rulesPath).catch(() => null);
try { try {
await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 }); 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); await rename(temporary, this.rulesPath);
const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) }); const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) });
if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`); 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或标准安装目录');
}
@@ -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);
});
});
+313 -125
View File
@@ -18,9 +18,17 @@ function createPrismaMock() {
}, },
smsMessageRecord: { smsMessageRecord: {
findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]), 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), 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 } }), aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
}, },
smsReceiptRecord: { smsReceiptRecord: {
@@ -64,10 +72,15 @@ function createPrismaMock() {
]), ]),
}, },
cmppConnectionState: { 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: { operationLog: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'log-1', id: 'log-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
tenant: { name: '租户A' }, tenant: { name: '租户A' },
@@ -77,12 +90,14 @@ function createPrismaMock() {
resourceId: 'order-1', resourceId: 'order-1',
detail: { amountCents: 1000 }, detail: { amountCents: 1000 },
createdAt: new Date('2026-07-02T01:00:00.000Z'), createdAt: new Date('2026-07-02T01:00:00.000Z'),
}]), },
]),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]), groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]),
}, },
gatewaySubmitDeadLetter: { gatewaySubmitDeadLetter: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'dead-1', id: 'dead-1',
streamMessageId: '1710000000000-0', streamMessageId: '1710000000000-0',
status: 'pending', status: 'pending',
@@ -93,13 +108,15 @@ function createPrismaMock() {
tenant: { name: '租户A' }, tenant: { name: '租户A' },
application: { name: '应用A' }, application: { name: '应用A' },
channel: { code: 'CMPP-A' }, channel: { code: 'CMPP-A' },
}]), },
]),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]), groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }), findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }),
}, },
smsReceiptAnomaly: { smsReceiptAnomaly: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'receipt-anomaly-1', id: 'receipt-anomaly-1',
anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1', anomalyKey: 'aggregate-receipt-conflict:record-1:SUB-1',
anomalyType: 'aggregate_success_then_failure', anomalyType: 'aggregate_success_then_failure',
@@ -113,13 +130,15 @@ function createPrismaMock() {
messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' }, messageRecord: { messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered' },
submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' }, submitRecord: { submitId: 'SUB-1', submitStatus: 'accepted' },
receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' }, receiptRecord: { gatewayMessageId: 'GW-1', receiptStatus: 'undelivered', rawStatus: 'UNDELIV' },
}]), },
]),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]), groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
findFirst: jest.fn().mockResolvedValue({ firstOccurredAt: new Date('2026-08-06T01:00:00.000Z') }), findFirst: jest.fn().mockResolvedValue({ firstOccurredAt: new Date('2026-08-06T01:00:00.000Z') }),
}, },
gatewayDownstreamRecoveryStatus: { gatewayDownstreamRecoveryStatus: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'recover-1', id: 'recover-1',
account: '100001', account: '100001',
state: 'waiting_connection', state: 'waiting_connection',
@@ -131,7 +150,8 @@ function createPrismaMock() {
lastError: 'downstream client is not connected', lastError: 'downstream client is not connected',
tenant: { name: '租户A' }, tenant: { name: '租户A' },
application: { name: '应用A' }, application: { name: '应用A' },
}]), },
]),
findUnique: jest.fn().mockResolvedValue({ findUnique: jest.fn().mockResolvedValue({
id: 'recover-1', id: 'recover-1',
account: '100001', account: '100001',
@@ -153,12 +173,11 @@ function createPrismaMock() {
application: { name: '应用A' }, application: { name: '应用A' },
}), }),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([ groupBy: jest.fn().mockResolvedValue([{ failureCategory: 'client_disconnected', _count: { _all: 1 } }]),
{ failureCategory: 'client_disconnected', _count: { _all: 1 } },
]),
}, },
smsMessageSegmentAudit: { smsMessageSegmentAudit: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'segment-1', id: 'segment-1',
messageRecordId: 'record-1', messageRecordId: 'record-1',
submitId: 'SUB-1', submitId: 'SUB-1',
@@ -169,10 +188,12 @@ function createPrismaMock() {
submitStatus: 'accepted', submitStatus: 'accepted',
receiptStatus: 'delivered', receiptStatus: 'delivered',
channel: { name: '通道A' }, channel: { name: '通道A' },
}]), },
]),
}, },
cmppDownstreamDelivery: { cmppDownstreamDelivery: {
findMany: jest.fn().mockResolvedValue([{ findMany: jest.fn().mockResolvedValue([
{
id: 'delivery-1', id: 'delivery-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
@@ -185,7 +206,8 @@ function createPrismaMock() {
tenant: { name: '租户A' }, tenant: { name: '租户A' },
application: { name: '应用A' }, application: { name: '应用A' },
messageRecord: { messageId: 'MSG-1' }, messageRecord: { messageId: 'MSG-1' },
}]), },
]),
count: jest.fn().mockResolvedValue(1), count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([ groupBy: jest.fn().mockResolvedValue([
{ deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } }, { deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } },
@@ -210,9 +232,11 @@ describe('OperationsService', () => {
await service.listBatchTasks({ tenantId: 'tenant-1', status: 'queued' }); await service.listBatchTasks({ tenantId: 'tenant-1', status: 'queued' });
expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsBatchTask.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { tenantId: 'tenant-1', status: 'queued', sourceType: 'client' }, 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 () => { it('filters send-chain messages by tenant, application, channel, carrier, content, date, task, phone, and status', async () => {
@@ -274,7 +298,8 @@ describe('OperationsService', () => {
await service.listMessages({ carrier: 'unknown' }); await service.listMessages({ carrier: 'unknown' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
AND: [ AND: [
{ {
@@ -283,9 +308,18 @@ describe('OperationsService', () => {
{ {
carrier: { carrier: {
notIn: [ notIn: [
'mobile', 'cmcc', '移动', '中国移动', 'mobile',
'unicom', 'cucc', '联通', '中国联通', 'cmcc',
'telecom', 'ctcc', '电信', '中国电信', '移动',
'中国移动',
'unicom',
'cucc',
'联通',
'中国联通',
'telecom',
'ctcc',
'电信',
'中国电信',
], ],
}, },
}, },
@@ -293,7 +327,8 @@ describe('OperationsService', () => {
}, },
], ],
}), }),
})); }),
);
}); });
it('separates upstream submit failures from post-acceptance delivery failures', async () => { it('separates upstream submit failures from post-acceptance delivery failures', async () => {
@@ -301,16 +336,20 @@ describe('OperationsService', () => {
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await service.listMessages({ status: 'submit_failed' }); await service.listMessages({ status: 'submit_failed' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }], OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }],
}), }),
})); }),
);
await service.listMessages({ status: 'failed' }); await service.listMessages({ status: 'failed' });
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }), where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }),
})); }),
);
}); });
it('paginates message summaries without preloading detail relations', async () => { it('paginates message summaries without preloading detail relations', async () => {
@@ -323,7 +362,8 @@ describe('OperationsService', () => {
page: 2, page: 2,
pageSize: 25, pageSize: 25,
}); });
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1' }), where: expect.objectContaining({ tenantId: 'tenant-1' }),
skip: 25, skip: 25,
take: 25, take: 25,
@@ -334,7 +374,8 @@ describe('OperationsService', () => {
hasDrainageContent: true, hasDrainageContent: true,
tenant: { select: { id: true, name: true } }, tenant: { select: { id: true, name: true } },
}), }),
})); }),
);
const call = prisma.smsMessageRecord.findMany.mock.calls.at(-1)?.[0]; const call = prisma.smsMessageRecord.findMany.mock.calls.at(-1)?.[0];
expect(call.select).not.toHaveProperty('submitRecords'); expect(call.select).not.toHaveProperty('submitRecords');
expect(call.select).not.toHaveProperty('receiptRecords'); expect(call.select).not.toHaveProperty('receiptRecords');
@@ -351,7 +392,14 @@ describe('OperationsService', () => {
await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' }); await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' });
expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({ 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: { include: {
tenant: true, tenant: true,
application: true, application: true,
@@ -376,19 +424,22 @@ describe('OperationsService', () => {
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.getMessage('message-1')).resolves.toEqual(expect.objectContaining({ id: 'message-1' })); await expect(service.getMessage('message-1')).resolves.toEqual(expect.objectContaining({ id: 'message-1' }));
expect(prisma.smsMessageRecord.findUnique).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.smsMessageRecord.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'message-1' }, where: { id: 'message-1' },
include: expect.objectContaining({ include: expect.objectContaining({
submitRecords: expect.any(Object), submitRecords: expect.any(Object),
receiptRecords: expect.any(Object), receiptRecords: expect.any(Object),
downstreamDeliveries: 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 () => { it('returns the matched message record and the distinct uplink gateway message id to the client view', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsUplinkMessage.findMany.mockResolvedValue([{ prisma.smsUplinkMessage.findMany.mockResolvedValue([
{
id: 'uplink-1', id: 'uplink-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
@@ -416,7 +467,8 @@ describe('OperationsService', () => {
application: { id: 'app-1', name: '应用A' }, application: { id: 'app-1', name: '应用A' },
}, },
matchCandidates: [], matchCandidates: [],
}]); },
]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
const [uplink] = await service.listClientUplinkMessages({ tenantId: 'tenant-1' }); const [uplink] = await service.listClientUplinkMessages({ tenantId: 'tenant-1' });
@@ -432,7 +484,8 @@ describe('OperationsService', () => {
it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => { it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsMessageRecord.findMany.mockResolvedValue([{ prisma.smsMessageRecord.findMany.mockResolvedValue([
{
id: 'record-1', id: 'record-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
batchTaskId: 'task-1', batchTaskId: 'task-1',
@@ -451,17 +504,31 @@ describe('OperationsService', () => {
tenant: { id: 'tenant-1', name: '企业A' }, tenant: { id: 'tenant-1', name: '企业A' },
channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 }, channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 },
submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }], submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }],
receiptRecords: [{ 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'), 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 service = new OperationsService(prisma as never);
const [message] = await service.listClientMessages({ tenantId: 'tenant-1' }); const [message] = await service.listClientMessages({ tenantId: 'tenant-1' });
expect(message).toMatchObject({ 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' })], receiptRecords: [expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD' })],
}); });
expect(message).not.toHaveProperty('tenant'); expect(message).not.toHaveProperty('tenant');
@@ -474,11 +541,19 @@ describe('OperationsService', () => {
it('returns a client dashboard without gateway state or supplier channel secrets', async () => { it('returns a client dashboard without gateway state or supplier channel secrets', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsBatchTask.findMany.mockResolvedValue([{ 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' }, 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 } }], messages: [{ channel: { account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 } }],
}]); },
]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
const dashboard = await service.clientDashboard({ tenantId: 'tenant-1' }); const dashboard = await service.clientDashboard({ tenantId: 'tenant-1' });
@@ -506,25 +581,34 @@ describe('OperationsService', () => {
it('builds dashboard and statistics aggregates', async () => { it('builds dashboard and statistics aggregates', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.$queryRaw.mockResolvedValueOnce([{ prisma.$queryRaw
.mockResolvedValueOnce([
{
tenantId: 'tenant-1', tenantId: 'tenant-1',
tenantName: '租户A', tenantName: '租户A',
todaySpendCents: 24000n, todaySpendCents: 24000n,
balanceCents: 1000000n, balanceCents: 1000000n,
creditCents: 50000n, creditCents: 50000n,
}]).mockResolvedValueOnce([{ },
])
.mockResolvedValueOnce([
{
segmentCount: 20n, segmentCount: 20n,
deliveredSegmentCount: 18n, deliveredSegmentCount: 18n,
billedCents: 360n, billedCents: 360n,
costCents: 216n, costCents: 216n,
}]).mockResolvedValueOnce([ },
])
.mockResolvedValueOnce([
{ hour: 9, submittedCount: 12n, successCount: 10n }, { hour: 9, submittedCount: 12n, successCount: 10n },
{ hour: 10, submittedCount: 5n, successCount: 4n }, { hour: 10, submittedCount: 5n, successCount: 4n },
]).mockResolvedValueOnce([ ])
.mockResolvedValueOnce([
{ category: 'templates', count: 3n, averageProcessingMs: 90_000n }, { category: 'templates', count: 3n, averageProcessingMs: 90_000n },
{ category: 'signatures', count: 2n, averageProcessingMs: 120_000n }, { category: 'signatures', count: 2n, averageProcessingMs: 120_000n },
]); ]);
prisma.cmppDownstreamDelivery.count = jest.fn() prisma.cmppDownstreamDelivery.count = jest
.fn()
.mockResolvedValueOnce(3) .mockResolvedValueOnce(3)
.mockResolvedValueOnce(2) .mockResolvedValueOnce(2)
.mockResolvedValueOnce(8) .mockResolvedValueOnce(8)
@@ -568,14 +652,18 @@ describe('OperationsService', () => {
{ category: 'signatures', label: '签名', count: 2, averageProcessingMs: 120000 }, { category: 'signatures', label: '签名', count: 2, averageProcessingMs: 120000 },
{ category: 'drainageInfos', label: '引流信息', count: 0, averageProcessingMs: null }, { category: 'drainageInfos', label: '引流信息', count: 0, averageProcessingMs: null },
], ],
enterpriseSpendRanks: [{ enterpriseSpendRanks: [
{
tenantId: 'tenant-1', tenantId: 'tenant-1',
tenantName: '租户A', tenantName: '租户A',
todaySpendCents: 24000, todaySpendCents: 24000,
balanceCents: 1000000, balanceCents: 1000000,
creditCents: 50000, creditCents: 50000,
}], },
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }], ],
gatewayConnections: [
{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } },
],
downstreamDeliverySummary: expect.objectContaining({ downstreamDeliverySummary: expect.objectContaining({
pending: 3, pending: 3,
failed: 2, failed: 2,
@@ -619,10 +707,7 @@ describe('OperationsService', () => {
where: { where: {
tenantId: 'tenant-1', tenantId: 'tenant-1',
createdAt: { gte: expect.any(Date) }, createdAt: { gte: expect.any(Date) },
OR: [ OR: [{ transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }],
{ transactionType: 'refunded' },
{ transactionType: 'released', relatedType: 'sms_message_record' },
],
}, },
_sum: { amountCents: true }, _sum: { amountCents: true },
_count: { _all: true }, _count: { _all: true },
@@ -640,7 +725,8 @@ describe('OperationsService', () => {
it('returns real daily channel and signature quality for the selected Shanghai date', async () => { it('returns real daily channel and signature quality for the selected Shanghai date', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.$queryRaw prisma.$queryRaw
.mockResolvedValueOnce([{ .mockResolvedValueOnce([
{
channelId: 'channel-1', channelId: 'channel-1',
channelName: '通道一', channelName: '通道一',
total: 5, total: 5,
@@ -654,8 +740,10 @@ describe('OperationsService', () => {
unknownRate: 20, unknownRate: 20,
failureRate: 20, failureRate: 20,
averageArrivalMs: 1200, averageArrivalMs: 1200,
}]) },
.mockResolvedValueOnce([{ ])
.mockResolvedValueOnce([
{
id: 'signature-1:plain', id: 'signature-1:plain',
signatureId: 'signature-1', signatureId: 'signature-1',
signatureName: '【测试签名】', signatureName: '【测试签名】',
@@ -670,15 +758,19 @@ describe('OperationsService', () => {
failureCount: 1, failureCount: 1,
successRate: 60, successRate: 60,
averageArrivalMs: 1200, averageArrivalMs: 1200,
}]) },
.mockResolvedValueOnce([{ ])
.mockResolvedValueOnce([
{
total: 5, total: 5,
successCount: 3, successCount: 3,
unknownCount: 1, unknownCount: 1,
failureCount: 1, failureCount: 1,
successRate: 60, successRate: 60,
}]) },
.mockResolvedValueOnce([{ ])
.mockResolvedValueOnce([
{
applicationId: 'app-1', applicationId: 'app-1',
applicationName: '通知应用', applicationName: '通知应用',
tenantId: 'tenant-1', tenantId: 'tenant-1',
@@ -688,7 +780,8 @@ describe('OperationsService', () => {
unknownCount: 1, unknownCount: 1,
failureCount: 1, failureCount: 1,
successRate: 60, successRate: 60,
}]); },
]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({ await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
@@ -701,22 +794,28 @@ describe('OperationsService', () => {
failureCount: 1, failureCount: 1,
successRate: 60, successRate: 60,
}, },
channels: [expect.objectContaining({ channels: [
expect.objectContaining({
channelId: 'channel-1', channelId: 'channel-1',
total: 5, total: 5,
acceptedCount: 4, acceptedCount: 4,
submitFailureCount: 1, submitFailureCount: 1,
submitFailureRate: 20, submitFailureRate: 20,
successRate: 60, successRate: 60,
})], }),
signatures: [expect.objectContaining({ ],
signatures: [
expect.objectContaining({
signatureId: 'signature-1', signatureId: 'signature-1',
signatureName: '【测试签名】', signatureName: '【测试签名】',
hasDrainage: false, hasDrainage: false,
acceptedCount: 4, acceptedCount: 4,
submitFailureCount: 1, submitFailureCount: 1,
})], }),
applications: [expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 })], ],
applications: [
expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 }),
],
}); });
expect(prisma.$queryRaw).toHaveBeenCalledTimes(4); expect(prisma.$queryRaw).toHaveBeenCalledTimes(4);
}); });
@@ -736,9 +835,15 @@ describe('OperationsService', () => {
}); });
expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } }); expect(prisma.smsTemplate.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', auditStatus: 'pending' } });
expect(prisma.smsSignature.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.smsDrainageInfo.count).toHaveBeenCalledWith({
expect(prisma.enterpriseCertification.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending' } }); where: { tenantId: 'tenant-1', auditStatus: 'pending' },
expect(prisma.smsSendTask.count).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1', status: 'pending_review' } }); });
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.smsMessageRecord.groupBy).not.toHaveBeenCalled();
expect(prisma.$queryRaw).not.toHaveBeenCalled(); expect(prisma.$queryRaw).not.toHaveBeenCalled();
}); });
@@ -761,7 +866,8 @@ describe('OperationsService', () => {
it('returns paged registered-signature quality with channel and carrier breakdowns', async () => { it('returns paged registered-signature quality with channel and carrier breakdowns', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.$queryRaw prisma.$queryRaw
.mockResolvedValueOnce([{ .mockResolvedValueOnce([
{
signatureId: 'signature-1', signatureId: 'signature-1',
signatureName: '【测试签名】', signatureName: '【测试签名】',
tenantId: 'tenant-1', tenantId: 'tenant-1',
@@ -776,7 +882,8 @@ describe('OperationsService', () => {
successRate: 75, successRate: 75,
averageArrivalMs: 1200, averageArrivalMs: 1200,
rowCount: 12, rowCount: 12,
}]) },
])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
signatureId: 'signature-1', signatureId: 'signature-1',
@@ -829,14 +936,17 @@ describe('OperationsService', () => {
]); ]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.signatureQuality({ await expect(
service.signatureQuality({
date: '2026-07-24', date: '2026-07-24',
keyword: '测试', keyword: '测试',
page: 2, page: 2,
pageSize: 5, pageSize: 5,
})).resolves.toEqual({ }),
).resolves.toEqual({
date: '2026-07-24', date: '2026-07-24',
items: [expect.objectContaining({ items: [
expect.objectContaining({
signatureId: 'signature-1', signatureId: 'signature-1',
signatureName: '【测试签名】', signatureName: '【测试签名】',
total: 5, total: 5,
@@ -863,7 +973,8 @@ describe('OperationsService', () => {
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }), expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }), expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }),
], ],
})], }),
],
total: 12, total: 12,
page: 2, page: 2,
pageSize: 5, pageSize: 5,
@@ -881,7 +992,7 @@ describe('OperationsService', () => {
items: [], items: [],
total: 0, total: 0,
page: 1, page: 1,
pageSize: 10, pageSize: 25,
}); });
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
}); });
@@ -934,13 +1045,15 @@ describe('OperationsService', () => {
await service.systemLogs({ level: 'error', page: 2, pageSize: 5 }); await service.systemLogs({ level: 'error', page: 2, pageSize: 5 });
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.operationLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
AND: expect.objectContaining({ OR: expect.any(Array) }), AND: expect.objectContaining({ OR: expect.any(Array) }),
}), }),
skip: 5, skip: 5,
take: 5, take: 5,
})); }),
);
expect(prisma.operationLog.count).toHaveBeenCalledWith({ expect(prisma.operationLog.count).toHaveBeenCalledWith({
where: expect.objectContaining({ AND: expect.objectContaining({ OR: expect.any(Array) }) }), 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' }); await service.systemLogs({ createdAtFrom: '2026-08-21', createdAtTo: '2026-08-27' });
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.operationLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
createdAt: { createdAt: {
gte: new Date('2026-08-20T16:00:00.000Z'), gte: new Date('2026-08-20T16:00:00.000Z'),
lte: new Date('2026-08-27T15:59:59.999Z'), lte: new Date('2026-08-27T15:59:59.999Z'),
}, },
}), }),
})); }),
);
}); });
it('exports filtered operation logs with a traceable operation id', async () => { it('exports filtered operation logs with a traceable operation id', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual(expect.objectContaining({ await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual(
expect.objectContaining({
operationId: expect.any(String), operationId: expect.any(String),
status: 'completed', status: 'completed',
recordCount: 1, recordCount: 1,
truncated: false, truncated: false,
content: expect.stringContaining('billing.manual_recharge'), content: expect.stringContaining('billing.manual_recharge'),
})); }),
);
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 10_001 })); 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'); 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.user.findFirst).toHaveBeenCalledWith(
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }) })); 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.split('\n')[0]).toBe('时间,级别,模块,操作人,动作,资源ID');
expect(exported.content).not.toContain('amountCents'); expect(exported.content).not.toContain('amountCents');
}); });
@@ -992,11 +1113,13 @@ describe('OperationsService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual(expect.objectContaining({ await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual(
expect.objectContaining({
total: 1, total: 1,
page: 1, page: 1,
pageSize: 100, pageSize: 100,
})); }),
);
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 })); expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 }));
}); });
@@ -1004,19 +1127,23 @@ describe('OperationsService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.listGatewaySubmitDeadLetters({ await expect(
service.listGatewaySubmitDeadLetters({
tenantId: 'tenant-1', tenantId: 'tenant-1',
status: 'pending', status: 'pending',
keyword: 'SUBMIT', keyword: 'SUBMIT',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
})).resolves.toEqual({ }),
items: [expect.objectContaining({ ).resolves.toEqual({
items: [
expect.objectContaining({
id: 'dead-1', id: 'dead-1',
status: 'pending', status: 'pending',
rawPayloadAvailable: true, rawPayloadAvailable: true,
commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } }, commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } },
})], }),
],
total: 1, total: 1,
page: 1, page: 1,
pageSize: 10, pageSize: 10,
@@ -1045,7 +1172,8 @@ describe('OperationsService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.listReceiptAnomalies({ await expect(
service.listReceiptAnomalies({
tenantId: 'tenant-1', tenantId: 'tenant-1',
channelId: 'channel-1', channelId: 'channel-1',
status: 'pending', status: 'pending',
@@ -1053,7 +1181,9 @@ describe('OperationsService', () => {
keyword: 'MSG-1', keyword: 'MSG-1',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
})).resolves.toEqual(expect.objectContaining({ }),
).resolves.toEqual(
expect.objectContaining({
total: 1, total: 1,
page: 1, page: 1,
pageSize: 10, pageSize: 10,
@@ -1063,8 +1193,10 @@ describe('OperationsService', () => {
ignored: 0, ignored: 0,
oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'), oldestPendingAt: new Date('2026-08-06T01:00:00.000Z'),
}, },
})); }),
expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith(expect.objectContaining({ );
expect(prisma.smsReceiptAnomaly.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
tenantId: 'tenant-1', tenantId: 'tenant-1',
channelId: 'channel-1', channelId: 'channel-1',
@@ -1079,16 +1211,20 @@ describe('OperationsService', () => {
channel: { select: { id: true, code: true, name: true, status: true } }, channel: { select: { id: true, code: true, name: true, status: true } },
messageRecord: { select: { messageId: true, phoneNumber: true, status: true } }, messageRecord: { select: { messageId: true, phoneNumber: true, status: true } },
submitRecord: { select: { submitId: true, submitStatus: true } }, submitRecord: { select: { submitId: true, submitStatus: true } },
receiptRecord: { select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true } }, receiptRecord: {
select: { gatewayMessageId: true, receiptStatus: true, rawStatus: true, deliveredAt: true },
}, },
})); },
}),
);
}); });
it('returns paginated downstream deliveries', async () => { it('returns paginated downstream deliveries', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.listDownstreamDeliveries({ await expect(
service.listDownstreamDeliveries({
tenantId: 'tenant-1', tenantId: 'tenant-1',
deliveryType: 'receipt', deliveryType: 'receipt',
status: 'failed', status: 'failed',
@@ -1097,7 +1233,8 @@ describe('OperationsService', () => {
createdAtTo: '2026-07-15', createdAtTo: '2026-07-15',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
})).resolves.toEqual({ }),
).resolves.toEqual({
items: [expect.objectContaining({ id: 'delivery-1', status: 'failed' })], items: [expect.objectContaining({ id: 'delivery-1', status: 'failed' })],
total: 1, total: 1,
page: 1, page: 1,
@@ -1128,7 +1265,8 @@ describe('OperationsService', () => {
it('builds downstream delivery dashboard aggregates', async () => { it('builds downstream delivery dashboard aggregates', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.cmppDownstreamDelivery.count = jest.fn() prisma.cmppDownstreamDelivery.count = jest
.fn()
.mockResolvedValueOnce(12) .mockResolvedValueOnce(12)
.mockResolvedValueOnce(3) .mockResolvedValueOnce(3)
.mockResolvedValueOnce(0) .mockResolvedValueOnce(0)
@@ -1142,7 +1280,8 @@ describe('OperationsService', () => {
.mockResolvedValueOnce(2) .mockResolvedValueOnce(2)
.mockResolvedValueOnce(1) .mockResolvedValueOnce(1)
.mockResolvedValueOnce(0); .mockResolvedValueOnce(0);
prisma.cmppDownstreamDelivery.groupBy = jest.fn() prisma.cmppDownstreamDelivery.groupBy = jest
.fn()
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } }, { deliveryType: 'receipt', status: 'pending', _count: { _all: 2 } },
{ deliveryType: 'receipt', status: 'failed', _count: { _all: 1 } }, { deliveryType: 'receipt', status: 'failed', _count: { _all: 1 } },
@@ -1163,11 +1302,13 @@ describe('OperationsService', () => {
]); ]);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.downstreamDeliveryDashboard({ await expect(
service.downstreamDeliveryDashboard({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
deliveryType: 'all', deliveryType: 'all',
})).resolves.toEqual({ }),
).resolves.toEqual({
summary: { summary: {
total: 12, total: 12,
pending: 3, pending: 3,
@@ -1182,8 +1323,26 @@ describe('OperationsService', () => {
alertCount: 2, alertCount: 2,
}, },
typeBreakdown: [ 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: [ retryBuckets: [
{ label: '0次', count: 2 }, { label: '0次', count: 2 },
@@ -1191,8 +1350,28 @@ describe('OperationsService', () => {
{ label: '4次及以上', count: 0 }, { label: '4次及以上', count: 0 },
], ],
topApplications: [ 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 () => { it('returns paginated downstream recovery statuses', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.gatewayDownstreamRecoveryStatus.count = jest.fn() prisma.gatewayDownstreamRecoveryStatus.count = jest
.fn()
.mockResolvedValueOnce(1) .mockResolvedValueOnce(1)
.mockResolvedValueOnce(0) .mockResolvedValueOnce(0)
.mockResolvedValueOnce(0) .mockResolvedValueOnce(0)
@@ -1231,7 +1411,8 @@ describe('OperationsService', () => {
.mockResolvedValueOnce(1); .mockResolvedValueOnce(1);
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.listDownstreamRecoveryStatuses({ await expect(
service.listDownstreamRecoveryStatuses({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
state: 'waiting_connection', state: 'waiting_connection',
@@ -1241,7 +1422,8 @@ describe('OperationsService', () => {
updatedAtTo: '2026-07-08', updatedAtTo: '2026-07-08',
page: 1, page: 1,
pageSize: 10, pageSize: 10,
})).resolves.toEqual({ }),
).resolves.toEqual({
items: [expect.objectContaining({ id: 'recover-1', account: '100001', state: 'waiting_connection' })], items: [expect.objectContaining({ id: 'recover-1', account: '100001', state: 'waiting_connection' })],
total: 1, total: 1,
page: 1, page: 1,
@@ -1253,19 +1435,19 @@ describe('OperationsService', () => {
failed: 0, failed: 0,
waitingConnection: 1, waitingConnection: 1,
backoff: 1, backoff: 1,
failureCategories: [ failureCategories: [{ category: 'client_disconnected', count: 1 }],
{ category: 'client_disconnected', count: 1 },
],
}, },
}); });
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
updatedAt: { updatedAt: {
gte: new Date('2026-07-01T16:00:00.000Z'), gte: new Date('2026-07-01T16:00:00.000Z'),
lte: new Date('2026-07-08T15:59:59.999Z'), lte: new Date('2026-07-08T15:59:59.999Z'),
}, },
}), }),
})); }),
);
}); });
it('returns downstream recovery status detail', async () => { it('returns downstream recovery status detail', async () => {
@@ -1290,18 +1472,23 @@ describe('OperationsService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
await expect(service.exportDownstreamRecoveryStatuses({ await expect(
service.exportDownstreamRecoveryStatuses({
tenantId: 'tenant-1', tenantId: 'tenant-1',
state: 'waiting_connection', state: 'waiting_connection',
keyword: '100001', keyword: '100001',
updatedAtFrom: '2026-07-02', updatedAtFrom: '2026-07-02',
updatedAtTo: '2026-07-08', updatedAtTo: '2026-07-08',
})).resolves.toEqual(expect.objectContaining({ }),
).resolves.toEqual(
expect.objectContaining({
total: 1, total: 1,
fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/), fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/),
content: expect.stringContaining('100001'), content: expect.stringContaining('100001'),
})); }),
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({ );
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
failureCategory: undefined, failureCategory: undefined,
updatedAt: { updatedAt: {
@@ -1309,7 +1496,8 @@ describe('OperationsService', () => {
lte: new Date('2026-07-08T15:59:59.999Z'), lte: new Date('2026-07-08T15:59:59.999Z'),
}, },
}), }),
})); }),
);
}); });
it('returns message segment audit rows', async () => { it('returns message segment audit rows', async () => {
+64 -29
View File
@@ -1,10 +1,8 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; import type { SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; import { messageWhere, qualityBusinessDay, normalizeGroupBy, positiveInteger } from '../operations.helpers';
// R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline. // R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsQualityQueries { export class OperationsQualityQueries {
@@ -38,7 +36,8 @@ async statistics(query: { tenantId?: string; groupBy?: string }) {
async sendQuality(date?: string) { async sendQuality(date?: string) {
const day = qualityBusinessDay(date); const day = qualityBusinessDay(date);
const [channels, signatureSplits, summaryRows, applications] = await Promise.all([ const [channels, signatureSplits, summaryRows, applications] = await Promise.all([
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
channelId: string; channelId: string;
channelName: string; channelName: string;
total: number; total: number;
@@ -52,7 +51,8 @@ async sendQuality(date?: string) {
unknownRate: number; unknownRate: number;
failureRate: number; failureRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
submit."channelId" AS channel_id, submit."channelId" AS channel_id,
@@ -131,7 +131,8 @@ async sendQuality(date?: string) {
GROUP BY channel_id GROUP BY channel_id
ORDER BY COUNT(*) DESC, channel_id ORDER BY COUNT(*) DESC, channel_id
`), `),
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
id: string; id: string;
signatureId: string; signatureId: string;
signatureName: string; signatureName: string;
@@ -146,7 +147,8 @@ async sendQuality(date?: string) {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
message."signatureId" AS signature_id, 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 GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
ORDER BY "successCount" DESC, total DESC, signature.name ORDER BY "successCount" DESC, total DESC, signature.name
`), `),
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
total: number; total: number;
successCount: number; successCount: number;
unknownCount: number; unknownCount: number;
failureCount: number; failureCount: number;
successRate: number; successRate: number;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT message.status, message."receiptStatus" AS receipt_status SELECT message.status, message."receiptStatus" AS receipt_status
FROM "SmsMessageRecord" message FROM "SmsMessageRecord" message
@@ -251,7 +255,8 @@ async sendQuality(date?: string) {
END AS "successRate" END AS "successRate"
FROM base FROM base
`), `),
this.prisma.$queryRaw<Array<{ this.prisma.$queryRaw<
Array<{
applicationId: string; applicationId: string;
applicationName: string; applicationName: string;
tenantId: string; tenantId: string;
@@ -261,7 +266,8 @@ async sendQuality(date?: string) {
unknownCount: number; unknownCount: number;
failureCount: number; failureCount: number;
successRate: number; successRate: number;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
message."applicationId" AS application_id, message."applicationId" AS application_id,
@@ -317,10 +323,11 @@ async sendQuality(date?: string) {
async signatureQuality(query: SignatureQualityQuery) { async signatureQuality(query: SignatureQualityQuery) {
const day = qualityBusinessDay(query.date); const day = qualityBusinessDay(query.date);
const page = positiveInteger(query.page, 1); 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 keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null; const keywordPattern = keyword ? `%${keyword}%` : null;
const summaries = await this.prisma.$queryRaw<Array<{ const summaries = await this.prisma.$queryRaw<
Array<{
signatureId: string; signatureId: string;
signatureName: string; signatureName: string;
tenantId: string; tenantId: string;
@@ -335,7 +342,8 @@ async signatureQuality(query: SignatureQualityQuery) {
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
rowCount: number; rowCount: number;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
message."signatureId" AS signature_id, message."signatureId" AS signature_id,
@@ -415,9 +423,11 @@ async signatureQuality(query: SignatureQualityQuery) {
OFFSET ${(page - 1) * pageSize} OFFSET ${(page - 1) * pageSize}
`); `);
const signatureIds = summaries.map((item) => item.signatureId); const signatureIds = summaries.map((item) => item.signatureId);
const drainageBreakdowns = signatureIds.length === 0 const drainageBreakdowns =
signatureIds.length === 0
? [] ? []
: await this.prisma.$queryRaw<Array<{ : await this.prisma.$queryRaw<
Array<{
signatureId: string; signatureId: string;
channelId: string; channelId: string;
channelName: string; channelName: string;
@@ -431,7 +441,8 @@ async signatureQuality(query: SignatureQualityQuery) {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
}>>(Prisma.sql` }>
>(Prisma.sql`
WITH base AS ( WITH base AS (
SELECT SELECT
message."signatureId" AS signature_id, message."signatureId" AS signature_id,
@@ -526,16 +537,19 @@ async signatureQuality(query: SignatureQualityQuery) {
GROUP BY signature_id, channel_id, carrier, drainage_state GROUP BY signature_id, channel_id, carrier, drainage_state
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state
`); `);
const carrierOverview = signatureIds.length === 0 const carrierOverview =
signatureIds.length === 0
? [] ? []
: await this.prisma.$queryRaw<Array<{ : await this.prisma.$queryRaw<
Array<{
signatureId: string; signatureId: string;
carrier: string; carrier: string;
businessMessageCount: number; businessMessageCount: number;
finalSuccessCount: number; finalSuccessCount: number;
finalSuccessRate: number; finalSuccessRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
}>>(Prisma.sql` }>
>(Prisma.sql`
SELECT SELECT
message."signatureId" AS "signatureId", message."signatureId" AS "signatureId",
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
@@ -570,6 +584,7 @@ async signatureQuality(query: SignatureQualityQuery) {
ORDER BY message."signatureId", COUNT(*) DESC, carrier ORDER BY message."signatureId", COUNT(*) DESC, carrier
`); `);
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => { const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
void _rowCount;
const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId); const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId);
const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns); const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns);
return { return {
@@ -610,12 +625,16 @@ type SignatureSplitRow = {
function aggregateSignatureRows(rows: SignatureSplitRow[]) { function aggregateSignatureRows(rows: SignatureSplitRow[]) {
const grouped = new Map<string, SignatureSplitRow[]>(); const grouped = new Map<string, SignatureSplitRow[]>();
rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row])); rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row]));
return [...grouped.values()].map((parts) => { return [...grouped.values()]
.map((parts) => {
const first = parts[0]; const first = parts[0];
const total = parts.reduce((sum, item) => sum + item.total, 0); const total = parts.reduce((sum, item) => sum + item.total, 0);
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0); const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 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); const arrivalWeight = parts.reduce(
(sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount),
0,
);
return { return {
...first, ...first,
id: first.signatureId, id: first.signatureId,
@@ -626,10 +645,21 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
successCount, successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0), unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0), failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10, 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), 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)); })
.sort(
(left, right) =>
right.successCount - left.successCount ||
right.total - left.total ||
left.signatureName.localeCompare(right.signatureName),
);
} }
type DrainageBreakdownRow = { type DrainageBreakdownRow = {
@@ -670,8 +700,13 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
successCount, successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0), unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0), failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10, 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), averageArrivalMs:
arrivalWeight === 0
? null
: Math.round(
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
),
}; };
}); });
} }
@@ -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();
}
});
});
@@ -16,17 +16,36 @@ describe('SignatureRetirementService dimensions', () => {
task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'), 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 === 'enterprise')).toHaveLength(2);
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3); 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(
expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true); dimensions
expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel'); .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', () => { 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', '')], [rule('enterprise_global', ''), rule('channel_global', '')],
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')], [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 () => { it('publishes frozen alert content only in the notification phase', async () => {
const detection = { const detection = {
id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1', id: 'detection-1',
cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文', detectionDate: new Date('2026-08-10T00:00:00.000Z'),
dimensionType: 'enterprise',
tenantId: 'tenant-1',
cycleId: 'cycle-1',
notificationTitle: '企业签名清退预警',
notificationContent: '冻结后的预警正文',
}; };
const prisma = { const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) }, signatureRetirementDetection: {
signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) }, 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([]) }, signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
signatureRetirementWebhookDelivery: { upsert: jest.fn() }, signatureRetirementWebhookDelivery: { upsert: jest.fn() },
}; };
const notificationService = new SignatureRetirementService(prisma as never); const notificationService = new SignatureRetirementService(prisma as never);
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 }); await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) }); 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 () => { it('returns enterprise application metadata for heatmap hover and search', async () => {
const prisma = { 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([]) }, smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([]) }, smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
tenant: { findMany: jest.fn().mockResolvedValue([]) }, tenant: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{ channelSignatureReportTask: {
signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'), 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: '测试应用' } }, signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } },
channel: { name: '移动通道' }, channel: { name: '移动通道' },
}]) }, },
]),
},
}; };
const heatmapService = new SignatureRetirementService(prisma as never); const heatmapService = new SignatureRetirementService(prisma as never);
const result = await heatmapService.heatmap('2026-08-10'); const result = await heatmapService.heatmap('2026-08-10');
expect(result.dimensions).toEqual(expect.arrayContaining([ expect(result.dimensions).toEqual(
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }), expect.arrayContaining([
expect.objectContaining({
dimensionType: 'enterprise',
signatureName: '测试签名',
applicationName: '测试应用',
}),
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }), expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
])); ]),
);
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' })); expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
}); });
it('persists daily observing snapshots without opening alert cycles', async () => { it('persists daily observing snapshots without opening alert cycles', async () => {
const prisma = { const prisma = {
signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) }, signatureRetirementSuppression: {
signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) }, updateMany: jest.fn().mockResolvedValue({ count: 0 }),
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) }, findUnique: jest.fn().mockResolvedValue(null),
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) }, },
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() }, 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); 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).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(); expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
}); });
it('maps the real unreported-signature aggregation to an independent page', async () => { it('maps the real unreported-signature aggregation to an independent page', async () => {
const prisma = { const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{ $queryRaw: jest.fn().mockResolvedValue([
signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', {
applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3, signatureId: 'signature-1',
}]), signatureName: '未报备签名',
tenantId: 'tenant-1',
tenantName: '测试企业',
applicationId: 'app-1',
applicationName: '测试应用',
messageCount: 7,
rowCount: 3,
},
]),
}; };
const unreportedService = new SignatureRetirementService(prisma as never); 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', 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, total: 3,
page: 2, page: 2,
pageSize: 10, pageSize: 10,
@@ -124,7 +229,14 @@ describe('SignatureRetirementService dimensions', () => {
it('returns a filtered historical message page with application metadata', async () => { 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 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 = { const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]), $queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]),
signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) }, signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) },
@@ -136,8 +248,27 @@ describe('SignatureRetirementService dimensions', () => {
}; };
const messageService = new SignatureRetirementService(prisma as never); 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({ await expect(
items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })], 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, total: 21,
page: 2, page: 2,
pageSize: 10, pageSize: 10,
@@ -160,22 +291,52 @@ describe('SignatureRetirementService dimensions', () => {
it('requires a reason for temporary and permanent suppression', async () => { it('requires a reason for temporary and permanent suppression', async () => {
const prisma = { const prisma = {
signatureRetirementMessage: { 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), findFirst: jest.fn().mockResolvedValue(null),
}, },
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) }, signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
}; };
const suppressionService = new SignatureRetirementService(prisma as never); const suppressionService = new SignatureRetirementService(prisma as never);
await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空'); await expect(
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空'); 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) { 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) { 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 },
};
} }
File diff suppressed because it is too large Load Diff
@@ -2222,3 +2222,8 @@
## 2026-09-07 夜间累计发送量审核 ## 2026-09-07 夜间累计发送量审核
原“非工作时间大批量营销发送”替换为每企业应用每夜累计所有业务短信的人工审核规则,所有企业应用默认通用5000条,支持应用阈值覆盖;不依赖分类、内容或任务名。CMPP、公开HTTP、客户端合并计数,第5001条及之后待审;分片/内部重试/补发/幂等重试不重复累计。统一在首次发送Worker提交前拦截,含定时任务到期及CMPP快速入队路径。短信审核复用现有字段、号码明细、详情和批量审核,按同应用同内容10秒窗口聚合,窗口关闭后审核;批准只释放该任务消息,不豁免后续发送。时间跨午夜连续,默认21:00至次日08:00(北京时间),夜间改时间待本夜结束生效,阈值修改不清零。详见phase-6-risk-review-plan.md的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)。不包含导入导出优化。
+19
View File
@@ -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生命周期。
+13
View File
@@ -5223,3 +5223,16 @@ npm run verify:phase8
- 633ba59双环境99项迁移、规则真实API/编辑取消/短信审核空态与刷新三尺寸验收;测试无控制台错误,预生产已完成两轮三尺寸业务API/交互检查,外部统计beacon网络错误单列;最后复核遇23:22:21账号再次停用而401,完整脚本未通过,不能宣称持续登录可用。具体结果与证据见testing-progress.md本次发布节。 - 633ba59双环境99项迁移、规则真实API/编辑取消/短信审核空态与刷新三尺寸验收;测试无控制台错误,预生产已完成两轮三尺寸业务API/交互检查,外部统计beacon网络错误单列;最后复核遇23:22:21账号再次停用而401,完整脚本未通过,不能宣称持续登录可用。具体结果与证据见testing-progress.md本次发布节。
- 夜间累计5001边界、共享入口、并发幂等、同内容聚合、跨午夜、覆盖、审核决定与入队失败恢复在两环境独立PG schema和Redis QA队列各11组通过。当前线上待审核为空,未发送或实际放行短信;不以空态浏览器验收替代这些业务规则验证。 - 夜间累计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。
+11
View File
@@ -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;未新建、启用或重置账号。保留已完成页面证据,最终完整零异常脚本未通过,不声称持续登录可用或整个控制台零错误。 - 真实页面:测试与预生产均使用真实管理员、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没有本轮新增错误,不能称所有应用日志正常。两项留待专项诊断,不影响已执行的夜间规则只读及隔离验收。 - 现存独立问题:测试安全代理发布前已因/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验证,不能冒充真实短信审核放行。 - 证据:本机%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;未完成项目不能视为上线通过。
+2
View File
@@ -47,6 +47,8 @@ export type SignatureRetirementDetection = {
}; };
export type SignatureRetirementMessage = { export type SignatureRetirementMessage = {
dailyGroupKey?: string | null;
detections?: Array<SignatureRetirementDetection & { signatureName?: string; channelName?: string }>;
id: string; id: string;
detectionId: string; detectionId: string;
cycleId: string; cycleId: string;
+22
View File
@@ -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;
}
}
@@ -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(<AdminAnalyticsPage />);
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();
});
});
+376 -126
View File
@@ -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 { BarChart3, Eye, Search, X } from 'lucide-react';
import { import {
adminApi, adminApi,
@@ -10,7 +10,19 @@ import {
type UnreportedSignatureItem, type UnreportedSignatureItem,
type PagedResult, type PagedResult,
} from '@/api/adminApi'; } 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'; import { successRateClassName, successRateTone } from '@/utils/successRate';
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown']; const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
@@ -20,19 +32,51 @@ const drainageStates = [
{ value: 'without', label: '不含引流' }, { value: 'without', label: '不含引流' },
{ value: 'unknown', label: '未检测' }, { value: 'unknown', label: '未检测' },
] as const; ] as const;
const carrierLabels: Record<string, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
unknown: '未知',
};
const analyticsTabs = [
{ value: 'quality', label: '签名通道发送质量' },
{ value: 'enterprise', label: '企业签名活跃度' },
{ value: 'channel', label: '通道签名活跃度' },
{ value: 'unreported', label: '未报备签名' },
];
export function AdminAnalyticsPage() { export function AdminAnalyticsPage() {
const [active, setActive] = useState('quality');
const [visited, setVisited] = useState(['quality']);
return (
<section className="page-stack admin-analytics-page">
<div className="page-heading">
<div>
<Breadcrumb items={['签名质量检测']} />
<h1></h1>
</div>
</div>
<Tabs
value={active}
onChange={(value) => {
setActive(value);
setVisited((current) => (current.includes(value) ? current : [...current, value]));
}}
items={analyticsTabs.map((tab) => ({ ...tab, content: null }))}
/>
{visited.map((kind) => (
<div key={kind} hidden={kind !== active}>
<AnalyticsPanel kind={kind} />
</div>
))}
</section>
);
}
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 [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null); const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]); const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]);
const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]); const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]);
const [unreportedSignatures, setUnreportedSignatures] = useState<(PagedResult<UnreportedSignatureItem> & { date: string }) | null>(null); const [unreportedSignatures, setUnreportedSignatures] = useState<
(PagedResult<UnreportedSignatureItem> & { date: string }) | null
>(null);
const [signatureKeyword, setSignatureKeyword] = useState(''); const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState(''); const [appliedKeyword, setAppliedKeyword] = useState('');
const [unreportedKeyword, setUnreportedKeyword] = useState(''); const [unreportedKeyword, setUnreportedKeyword] = useState('');
@@ -41,37 +85,52 @@ export function AdminAnalyticsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); 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); setLoading(true);
try {
const [signatureData, heatmapData, unreportedData] = await Promise.all([
adminApi.getSignatureQuality({
date: statisticsDate,
keyword: keyword || 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(''); setError('');
try {
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: 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) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '统计数据加载失败'); if (id === requestId.current) setError(failure instanceof Error ? failure.message : '统计数据加载失败');
} finally { } finally {
setLoading(false); if (id === requestId.current) setLoading(false);
} }
} }
useEffect(() => { useEffect(() => {
void loadData(1, ''); void loadData(1, '');
return () => {
requestId.current += 1;
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -122,21 +181,29 @@ export function AdminAnalyticsPage() {
title: '送达成功', title: '送达成功',
width: '110px', width: '110px',
align: 'right', align: 'right',
render: (record) => <span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>, render: (record) => (
<span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>
),
}, },
{ {
key: 'failureCount', key: 'failureCount',
title: '送达失败', title: '送达失败',
width: '110px', width: '110px',
align: 'right', align: 'right',
render: (record) => <span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>, render: (record) => (
<span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>
),
}, },
{ {
key: 'submitFailureCount', key: 'submitFailureCount',
title: '提交失败', title: '提交失败',
width: '110px', width: '110px',
align: 'right', align: 'right',
render: (record) => <span className="quality-number quality-number--warning">{record.submitFailureCount.toLocaleString('zh-CN')}</span>, render: (record) => (
<span className="quality-number quality-number--warning">
{record.submitFailureCount.toLocaleString('zh-CN')}
</span>
),
}, },
{ {
key: 'successRate', key: 'successRate',
@@ -169,36 +236,30 @@ export function AdminAnalyticsPage() {
} }
function changeSignaturePage(page: number) { function changeSignaturePage(page: number) {
setLoading(true); void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate);
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));
} }
function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) { function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) {
setLoading(true); void loadData(page, appliedKeyword, keyword, appliedDate);
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));
} }
return ( return (
<section className="page-stack"> <section className="page-stack" aria-label={analyticsTabs.find((tab) => tab.value === kind)?.label}>
<div className="page-heading"> <div className="page-heading">
<div> <div>
<Breadcrumb items={['签名质量检测']} /> <p className="muted"></p>
<h1></h1>
</div> </div>
<div className="page-actions"> <div className="page-actions">
<Select
label="每页数量"
value={String(pageSize)}
options={[10, 25, 50, 100].map((value) => ({ value: String(value), label: `${value} 条/页` }))}
onChange={(event) => {
const size = Number(event.target.value);
setPageSize(size);
if (kind === 'quality' || kind === 'unreported')
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
}}
/>
<Input <Input
aria-label="统计日期" aria-label="统计日期"
max={shanghaiDateKey()} max={shanghaiDateKey()}
@@ -213,6 +274,7 @@ export function AdminAnalyticsPage() {
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
{kind === 'quality' ? (
<div className="surface signature-quality-card"> <div className="surface signature-quality-card">
<div className="signature-quality-card__heading"> <div className="signature-quality-card__heading">
<div> <div>
@@ -234,7 +296,9 @@ export function AdminAnalyticsPage() {
placeholder="搜索签名、企业或应用" placeholder="搜索签名、企业或应用"
value={signatureKeyword} value={signatureKeyword}
/> />
<Button disabled={loading} icon={<Search size={16} />} onClick={queryStatistics} variant="secondary"></Button> <Button disabled={loading} icon={<Search size={16} />} onClick={queryStatistics} variant="secondary">
</Button>
</div> </div>
</div> </div>
<div className="signature-quality-card__note"> <div className="signature-quality-card__note">
@@ -250,7 +314,10 @@ export function AdminAnalyticsPage() {
/> />
{(signatureQuality?.total ?? 0) > 0 ? ( {(signatureQuality?.total ?? 0) > 0 ? (
<Pagination <Pagination
nextDisabled={(signatureQuality?.page ?? 1) >= Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))} nextDisabled={
(signatureQuality?.page ?? 1) >=
Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))
}
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)} onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
onPageChange={changeSignaturePage} onPageChange={changeSignaturePage}
onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)} onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)}
@@ -261,10 +328,30 @@ export function AdminAnalyticsPage() {
/> />
) : null} ) : null}
</div> </div>
) : null}
<RetirementHeatmap date={statisticsDate} dimensionType="enterprise" dimensions={retirementDimensions} items={retirementHeatmap} title="企业签名活跃度热力图" /> {kind === 'enterprise' ? (
<RetirementHeatmap date={statisticsDate} dimensionType="channel" dimensions={retirementDimensions} items={retirementHeatmap} title="通道签名活跃度热力图" /> <RetirementHeatmap
pageSize={pageSize}
date={appliedDate}
dimensionType="enterprise"
dimensions={retirementDimensions}
items={retirementHeatmap}
title="企业签名活跃度热力图"
/>
) : null}
{kind === 'channel' ? (
<RetirementHeatmap
pageSize={pageSize}
date={appliedDate}
dimensionType="channel"
dimensions={retirementDimensions}
items={retirementHeatmap}
title="通道签名活跃度热力图"
/>
) : null}
{kind === 'unreported' ? (
<UnreportedSignaturesCard <UnreportedSignaturesCard
data={unreportedSignatures} data={unreportedSignatures}
keyword={unreportedKeyword} keyword={unreportedKeyword}
@@ -273,6 +360,7 @@ export function AdminAnalyticsPage() {
onPageChange={(page) => loadUnreportedSignatures(page)} onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())} onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
/> />
) : null}
{selectedSignature ? ( {selectedSignature ? (
<SignatureQualityDrawer <SignatureQualityDrawer
@@ -285,18 +373,41 @@ export function AdminAnalyticsPage() {
); );
} }
function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: { date: string; dimensionType: 'enterprise' | 'channel'; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[]; title: string }) { function RetirementHeatmap({
const pageSize = 10; pageSize,
const [page, setPage] = useState(1); date,
dimensionType,
dimensions,
items,
title,
}: {
pageSize: number;
date: string;
dimensionType: 'enterprise' | 'channel';
dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureRetirementHeatmapItem[];
title: string;
}) {
const [pageState, setPageState] = useState({ key: '', page: 1 });
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN')); const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
const visible = items.filter((item) => item.dimensionType === dimensionType); const visible = items.filter((item) => item.dimensionType === dimensionType);
const dates = previousDateKeys(date, 30); 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 const rows = dimensions
.filter((item) => item.dimensionType === dimensionType) .filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.channelName, item.tenantName, item.applicationName, item.signatureName] .filter(
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword))) (item) =>
!deferredKeyword ||
[item.channelName, item.tenantName, item.applicationName, item.signatureName].some((value) =>
value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword),
),
)
.map((item) => ({ .map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`, key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
signatureName: item.signatureName, signatureName: item.signatureName,
@@ -305,17 +416,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
applicationName: item.applicationName, applicationName: item.applicationName,
carrier: item.carrier, carrier: item.carrier,
approvedAt: item.approvedAt.slice(0, 10), 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')); .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 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 currentPage = Math.min(page, totalPages);
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize); const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [date, deferredKeyword, dimensionType, dimensions.length]);
return ( return (
<div className="surface signature-retirement-heatmap"> <div className="surface signature-retirement-heatmap">
<div className="section-heading signature-retirement-heatmap__heading"> <div className="section-heading signature-retirement-heatmap__heading">
@@ -338,14 +454,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
<div className="signature-retirement-heatmap__scroll"> <div className="signature-retirement-heatmap__scroll">
<table> <table>
<thead> <thead>
<tr><th></th><th>30</th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr> <tr>
<th></th>
<th>30</th>
{dates.map((dateKey) => (
<th key={dateKey}>{dateKey.slice(5)}</th>
))}
</tr>
</thead> </thead>
<tbody> <tbody>
{pagedRows.map((row) => ( {pagedRows.map((row) => (
<tr key={row.key}> <tr key={row.key}>
<th> <th>
<span className="signature-retirement-heatmap__identity"> <span className="signature-retirement-heatmap__identity">
<strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>{row.signatureName}</strong> <strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>
{row.signatureName}
</strong>
{row.channelName ? <small>{row.channelName}</small> : null} {row.channelName ? <small>{row.channelName}</small> : null}
</span> </span>
<CarrierTag carrier={row.carrier} /> <CarrierTag carrier={row.carrier} />
@@ -354,10 +478,26 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
{dates.map((dateKey) => { {dates.map((dateKey) => {
const item = cellMap.get(`${row.key}:${dateKey}`); const item = cellMap.get(`${row.key}:${dateKey}`);
const beforeApproval = dateKey < row.approvedAt; const beforeApproval = dateKey < row.approvedAt;
const successRate = item?.acceptedBusinessCount ? item.deliveredBusinessCount / item.acceptedBusinessCount * 100 : 0; const successRate = item?.acceptedBusinessCount
const className = beforeApproval ? 'is-inapplicable' : !item ? '' : item.acceptedBusinessCount === 0 ? 'is-zero' : `is-rate-${successRateTone(successRate)}`; ? (item.deliveredBusinessCount / item.acceptedBusinessCount) * 100
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}` : '当日无检测快照'; : 0;
return <td className={className} key={dateKey} title={titleText}>{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}</td>; 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 (
<td className={className} key={dateKey} title={titleText}>
{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
</td>
);
})} })}
</tr> </tr>
))} ))}
@@ -375,7 +515,13 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
totalPages={totalPages} totalPages={totalPages}
/> />
</> </>
) : <p className="empty-state">{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}</p>} ) : (
<p className="empty-state">
{deferredKeyword
? '没有匹配企业、企业应用或签名的热力图维度。'
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p>
)}
</div> </div>
); );
} }
@@ -399,28 +545,43 @@ function UnreportedSignaturesCard({
{ key: 'signatureName', title: '短信签名', render: (record) => <strong>{record.signatureName}</strong> }, { key: 'signatureName', title: '短信签名', render: (record) => <strong>{record.signatureName}</strong> },
{ key: 'tenantName', title: '企业名称', render: (record) => record.tenantName }, { key: 'tenantName', title: '企业名称', render: (record) => record.tenantName },
{ key: 'applicationName', title: '企业应用', render: (record) => record.applicationName || '未关联企业应用' }, { 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))); const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 10)));
return ( return (
<div className="surface signature-quality-card"> <div className="surface signature-quality-card">
<div className="signature-quality-card__heading"> <div className="signature-quality-card__heading">
<div> <div>
<div className="section-heading__title"><h2></h2><Tag tone="warning"></Tag></div> <div className="section-heading__title">
<h2></h2>
<Tag tone="warning"></Tag>
</div>
<p className="muted">{data?.date ?? '所选日期'} </p> <p className="muted">{data?.date ?? '所选日期'} </p>
</div> </div>
<div className="signature-quality-card__query"> <div className="signature-quality-card__query">
<Input <Input
aria-label="搜索未报备签名、企业或企业应用" aria-label="搜索未报备签名、企业或企业应用"
onChange={(event) => onKeywordChange(event.target.value)} onChange={(event) => onKeywordChange(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') onSearch(); }} onKeyDown={(event) => {
if (event.key === 'Enter') onSearch();
}}
placeholder="搜索签名、企业或企业应用" placeholder="搜索签名、企业或企业应用"
value={keyword} value={keyword}
/> />
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary"></Button> <Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary">
</Button>
</div> </div>
</div> </div>
<div className="signature-quality-card__note"><strong></strong></div> <div className="signature-quality-card__note">
<strong></strong>
</div>
<Table <Table
columns={columns} columns={columns}
data={data?.items ?? []} data={data?.items ?? []}
@@ -467,33 +628,52 @@ function SignatureQualityDrawer({
.sort((left, right) => { .sort((left, right) => {
const leftRank = carrierOrder.indexOf(normalizeCarrier(left.carrier)); const leftRank = carrierOrder.indexOf(normalizeCarrier(left.carrier));
const rightRank = carrierOrder.indexOf(normalizeCarrier(right.carrier)); const rightRank = carrierOrder.indexOf(normalizeCarrier(right.carrier));
return (leftRank < 0 ? carrierOrder.length : leftRank) return (leftRank < 0 ? carrierOrder.length : leftRank) - (rightRank < 0 ? carrierOrder.length : rightRank);
- (rightRank < 0 ? carrierOrder.length : rightRank);
}); });
const channels = [...new Map([...item.breakdowns, ...item.drainageBreakdowns] const channels = [
.map((entry) => [entry.channelId, entry.channelName])).entries()] ...new Map(
.map(([channelId, channelName]) => ({ channelId, channelName })); [...item.breakdowns, ...item.drainageBreakdowns].map((entry) => [entry.channelId, entry.channelName]),
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier)); ).entries(),
].map(([channelId, channelName]) => ({ channelId, channelName }));
const visibleCarriers = carrierOrder.filter((carrier) =>
item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier),
);
return ( return (
<div className="signature-quality-drawer__backdrop" onMouseDown={(event) => { <div
className="signature-quality-drawer__backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose(); if (event.target === event.currentTarget) onClose();
}}> }}
<aside aria-labelledby="signature-quality-drawer-title" aria-modal="true" className="signature-quality-drawer" role="dialog"> >
<aside
aria-labelledby="signature-quality-drawer-title"
aria-modal="true"
className="signature-quality-drawer"
role="dialog"
>
<div className="signature-quality-drawer__header"> <div className="signature-quality-drawer__header">
<div> <div>
<p> / {item.signatureName}</p> <p> / {item.signatureName}</p>
<h2 id="signature-quality-drawer-title">{item.signatureName}</h2> <h2 id="signature-quality-drawer-title">{item.signatureName}</h2>
<span>{date} · {item.tenantName} · {item.applicationNames || '全部企业应用'}</span> <span>
{date} · {item.tenantName} · {item.applicationNames || '全部企业应用'}
</span>
</div> </div>
<button aria-label="关闭签名发送质量详情" onClick={onClose} type="button"><X size={20} /></button> <button aria-label="关闭签名发送质量详情" onClick={onClose} type="button">
<X size={20} />
</button>
</div> </div>
<div className="signature-quality-drawer__body"> <div className="signature-quality-drawer__body">
<div className="signature-quality-overview"> <div className="signature-quality-overview">
<QualityMetric label="业务短信" value={item.total.toLocaleString('zh-CN')} /> <QualityMetric label="业务短信" value={item.total.toLocaleString('zh-CN')} />
<QualityMetric label="通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} /> <QualityMetric label="通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
<QualityMetric label="最终成功率" value={`${item.successRate.toFixed(1)}%`} valueClassName={successRateClassName(item.successRate)} /> <QualityMetric
label="最终成功率"
value={`${item.successRate.toFixed(1)}%`}
valueClassName={successRateClassName(item.successRate)}
/>
<QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} /> <QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} />
</div> </div>
@@ -506,15 +686,29 @@ function SignatureQualityDrawer({
</div> </div>
<div className="signature-carrier-grid"> <div className="signature-carrier-grid">
{carriers.map((carrier) => ( {carriers.map((carrier) => (
<article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.carrier}> <article
className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`}
key={carrier.carrier}
>
<div> <div>
<CarrierTag carrier={carrier.carrier} /> <CarrierTag carrier={carrier.carrier} />
<strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} </strong> <strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} </strong>
</div> </div>
<dl> <dl>
<div><dt></dt><dd className={successRateClassName(carrier.finalSuccessRate)}>{carrier.finalSuccessRate.toFixed(1)}%</dd></div> <div>
<div><dt></dt><dd>{formatDuration(carrier.averageArrivalMs)}</dd></div> <dt></dt>
<div><dt></dt><dd>{carrier.channelCount} </dd></div> <dd className={successRateClassName(carrier.finalSuccessRate)}>
{carrier.finalSuccessRate.toFixed(1)}%
</dd>
</div>
<div>
<dt></dt>
<dd>{formatDuration(carrier.averageArrivalMs)}</dd>
</div>
<div>
<dt></dt>
<dd>{carrier.channelCount} </dd>
</div>
</dl> </dl>
</article> </article>
))} ))}
@@ -525,11 +719,28 @@ function SignatureQualityDrawer({
<div className="signature-quality-section__heading"> <div className="signature-quality-section__heading">
<div> <div>
<h3> × </h3> <h3> × </h3>
<p>{matrixMode === 'overall' <p>
{matrixMode === 'overall'
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。' ? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}</p> : '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}
</p>
</div>
<div className="page-actions">
<Button
onClick={() => setMatrixMode('overall')}
size="sm"
variant={matrixMode === 'overall' ? 'primary' : 'ghost'}
>
</Button>
<Button
onClick={() => setMatrixMode('drainage')}
size="sm"
variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}
>
</Button>
</div> </div>
<div className="page-actions"><Button onClick={() => setMatrixMode('overall')} size="sm" variant={matrixMode === 'overall' ? 'primary' : 'ghost'}></Button><Button onClick={() => setMatrixMode('drainage')} size="sm" variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}></Button></div>
</div> </div>
<div className="signature-quality-matrix"> <div className="signature-quality-matrix">
<table> <table>
@@ -537,13 +748,21 @@ function SignatureQualityDrawer({
{matrixMode === 'overall' ? ( {matrixMode === 'overall' ? (
<tr> <tr>
<th></th> <th></th>
{visibleCarriers.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)} {visibleCarriers.map((carrier) => (
<th key={carrier}>
<CarrierTag carrier={carrier} />
</th>
))}
</tr> </tr>
) : ( ) : (
<tr> <tr>
<th></th> <th></th>
<th></th> <th></th>
{majorCarrierOrder.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)} {majorCarrierOrder.map((carrier) => (
<th key={carrier}>
<CarrierTag carrier={carrier} />
</th>
))}
</tr> </tr>
)} )}
</thead> </thead>
@@ -553,31 +772,43 @@ function SignatureQualityDrawer({
<tr key={channel.channelId}> <tr key={channel.channelId}>
<th>{channel.channelName}</th> <th>{channel.channelName}</th>
{visibleCarriers.map((carrier) => { {visibleCarriers.map((carrier) => {
const metric = item.breakdowns.find((entry) => ( const metric = item.breakdowns.find(
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier (entry) =>
)); entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier,
);
return ( return (
<td key={carrier}> <td key={carrier}>
{metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>} {metric ? (
<MatrixMetric metric={metric} />
) : (
<span className="signature-quality-matrix__empty"></span>
)}
</td> </td>
); );
})} })}
</tr> </tr>
)) ))
: channels.flatMap((channel) => drainageStates.map((state, stateIndex) => ( : channels.flatMap((channel) =>
drainageStates.map((state, stateIndex) => (
<tr key={`${channel.channelId}-${state.value}`}> <tr key={`${channel.channelId}-${state.value}`}>
{stateIndex === 0 ? <th rowSpan={drainageStates.length}>{channel.channelName}</th> : null} {stateIndex === 0 ? <th rowSpan={drainageStates.length}>{channel.channelName}</th> : null}
<th className="signature-quality-matrix__drainage-label">{state.label}</th> <th className="signature-quality-matrix__drainage-label">{state.label}</th>
{majorCarrierOrder.map((carrier) => { {majorCarrierOrder.map((carrier) => {
const metric = item.drainageBreakdowns.find((entry) => ( const metric = item.drainageBreakdowns.find(
entry.channelId === channel.channelId (entry) =>
&& normalizeCarrier(entry.carrier) === carrier entry.channelId === channel.channelId &&
&& entry.drainageState === state.value normalizeCarrier(entry.carrier) === carrier &&
)); entry.drainageState === state.value,
return <td key={carrier}><MatrixMetric metric={metric} zeroWhenEmpty /></td>; );
return (
<td key={carrier}>
<MatrixMetric metric={metric} zeroWhenEmpty />
</td>
);
})} })}
</tr> </tr>
)))} )),
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -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 total = metric?.total ?? 0;
const successRate = metric?.successRate ?? 0; const successRate = metric?.successRate ?? 0;
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>; if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
return ( return (
<div className={`signature-quality-matrix__metric signature-quality-matrix__metric--${successRateTone(successRate)}`}> <div
<div><small></small><strong>{total.toLocaleString('zh-CN')} </strong></div> className={`signature-quality-matrix__metric signature-quality-matrix__metric--${successRateTone(successRate)}`}
<div><small></small><span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>{successRate.toFixed(1)}%</span></div> >
<div><small></small><span>{formatDuration(metric?.averageArrivalMs)}</span></div> <div>
<small></small>
<strong>{total.toLocaleString('zh-CN')} </strong>
</div>
<div>
<small></small>
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
{successRate.toFixed(1)}%
</span>
</div>
<div>
<small></small>
<span>{formatDuration(metric?.averageArrivalMs)}</span>
</div>
{(metric?.submitFailureCount ?? 0) > 0 ? <em> {metric?.submitFailureCount}</em> : null} {(metric?.submitFailureCount ?? 0) > 0 ? <em> {metric?.submitFailureCount}</em> : null}
</div> </div>
); );
@@ -619,7 +869,9 @@ function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureCha
function QualityRate({ value }: { value: number }) { function QualityRate({ value }: { value: number }) {
return ( return (
<div className="signature-quality-rate"> <div className="signature-quality-rate">
<div><span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} /></div> <div>
<span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
</div>
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong> <strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
</div> </div>
); );
@@ -633,10 +885,6 @@ function normalizeCarrier(value: string) {
return 'unknown'; return 'unknown';
} }
function carrierLabel(value: string) {
return carrierLabels[normalizeCarrier(value)] ?? '未知';
}
function formatDuration(value?: number | null) { function formatDuration(value?: number | null) {
if (value == null) return '—'; if (value == null) return '—';
if (value < 1000) return `${Math.round(value)} 毫秒`; if (value < 1000) return `${Math.round(value)} 毫秒`;
@@ -656,5 +904,7 @@ function shanghaiDateKey(value = new Date()) {
function previousDateKeys(endKey: string, days: number) { function previousDateKeys(endKey: string, days: number) {
const end = new Date(`${endKey}T12:00:00+08:00`); 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)),
);
} }
+87
View File
@@ -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;
}
}
+92 -53
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react'; import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import './AdminHome.css';
import { Chart } from '@/components/ui/Chart'; import { Chart } from '@/components/ui/Chart';
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi'; import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions'; import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
@@ -170,61 +171,99 @@ export function AdminHome() {
</div> </div>
</div> </div>
<div className="dashboard-grid admin-metric-grid"> <div className="home-metrics" aria-busy={!dashboard && !error}>
<div className="surface metric-card"> {[
<span></span> {
<strong>{formatCount(totalSend)} </strong> label: '今日发送总量',
<small></small> value: formatCount(totalSend),
unit: '条',
note: '业务短信',
group: '发送',
icon: <BarChart3 size={18} />,
},
{
label: '今日消息分片数',
value: formatCount(dashboard?.today.segmentCount ?? 0),
unit: '片',
note: '实际消息分片',
group: '发送',
},
{
label: '总体成功率',
value: averageSuccessRate.toFixed(1),
unit: '%',
note: '送达成功 / 今日总量',
group: '质量',
icon: <ShieldCheck size={18} />,
},
{
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: <DollarSign size={18} />,
},
{
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) => (
<article className="home-metric" key={metric.label}>
<div className="home-metric__heading">
<span>{metric.label}</span>
<span className="home-metric__category">
{metric.icon}
{metric.group}
</span>
</div> </div>
<div className="surface metric-card"> <div className={metric.danger ? 'home-metric__number is-negative' : 'home-metric__number'}>
<span></span> <strong>{dashboard ? metric.value : '—'}</strong>
<strong>{formatCount(dashboard?.today.segmentCount ?? 0)} </strong> <span>{metric.unit}</span>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{averageSuccessRate.toFixed(1)}%</strong>
<small>delivered / </small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{(dashboard?.today.arrivalRate ?? 0).toFixed(1)}%</strong>
<small> / </small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{activeSignatureCount}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todaySpend)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todayReturned)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todayBilled)}</strong>
<small> × </small>
</div>
<div className="surface metric-card">
<span></span>
<strong className={todayProfit < 0 ? 'metric-card__value--danger' : undefined}>
¥{formatCurrency(todayProfit)}
</strong>
<small> - </small>
</div>
<div className="surface metric-card">
<span></span>
<strong className={(dashboard?.today.profitRate ?? 0) < 0 ? 'metric-card__value--danger' : undefined}>
{(dashboard?.today.profitRate ?? 0).toFixed(1)}%
</strong>
<small> / </small>
</div> </div>
<p>{dashboard ? metric.note : error ? '数据暂不可用' : '正在加载…'}</p>
</article>
))}
</div> </div>
{error ? <div className="surface ui-table__empty">{error}</div> : null} {error ? <div className="surface ui-table__empty">{error}</div> : null}
+3 -1
View File
@@ -471,7 +471,9 @@ export function AdminReportTasksPage() {
onChange={(event) => setStatus(event.target.value)} onChange={(event) => setStatus(event.target.value)}
options={[ options={[
{ label: '全部状态', value: 'all' }, { 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} value={status}
/> />
+809 -74
View File
@@ -11,23 +11,55 @@ import {
type SignatureRetirementWebhook, type SignatureRetirementWebhook,
type TenantOption, type TenantOption,
} from '@/api/adminApi'; } 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 carriers = ['mobile', 'unicom', 'telecom'] as const;
const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' }; const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' };
const ruleTypeLabels: Record<SignatureRetirementRuleType, string> = { const ruleTypeLabels: Record<SignatureRetirementRuleType, string> = {
enterprise_global: '企业全局规则', enterprise_application: '企业应用特殊规则', channel_global: '通道全局规则', channel: '通道特殊规则', enterprise_global: '企业全局规则',
enterprise_application: '企业应用特殊规则',
channel_global: '通道全局规则',
channel: '通道特殊规则',
}; };
type RuleDraft = { type RuleDraft = {
ruleType: SignatureRetirementRuleType; targetId: string; enabled: boolean; ruleType: SignatureRetirementRuleType;
mobileWindowDays: string; mobileThreshold: string; unicomWindowDays: string; unicomThreshold: string; targetId: string;
telecomWindowDays: string; telecomThreshold: string; messageTemplate: string; enabled: boolean;
mobileWindowDays: string;
mobileThreshold: string;
unicomWindowDays: string;
unicomThreshold: string;
telecomWindowDays: string;
telecomThreshold: string;
messageTemplate: string;
}; };
const emptyRule: RuleDraft = { const emptyRule: RuleDraft = {
ruleType: 'enterprise_global', targetId: '', enabled: true, ruleType: 'enterprise_global',
mobileWindowDays: '30', mobileThreshold: '1', unicomWindowDays: '30', unicomThreshold: '1', targetId: '',
telecomWindowDays: '30', telecomThreshold: '1', messageTemplate: '', enabled: true,
mobileWindowDays: '30',
mobileThreshold: '1',
unicomWindowDays: '30',
unicomThreshold: '1',
telecomWindowDays: '30',
telecomThreshold: '1',
messageTemplate: '',
}; };
type MessageFilters = { type MessageFilters = {
@@ -47,7 +79,13 @@ type SuppressionDraft = {
function defaultMessageFilters(): MessageFilters { function defaultMessageFilters(): MessageFilters {
const today = shanghaiDateKey(); 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() { export function AdminSignatureRetirementPage() {
@@ -74,74 +112,252 @@ export function AdminSignatureRetirementPage() {
const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => { const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => {
setLoading(true); setLoading(true);
try { try {
const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] = await Promise.all([ const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] =
await Promise.all([
adminApi.getSignatureRetirementConfiguration(), adminApi.getSignatureRetirementConfiguration(),
adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)), adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)),
adminApi.listSignatureRetirementSuppressions(), adminApi.listSignatureRetirementSuppressions(),
adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels(), adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(),
adminApi.listChannels(),
adminApi.listTenantOptions(),
]); ]);
setRules(configuration.rules); setWebhooks(configuration.webhooks.filter((item) => item.status === 'active')); setRules(configuration.rules);
setMessages(messageResult.items); setMessageTotal(messageResult.total); setMessagePage(messageResult.page); setWebhooks(configuration.webhooks.filter((item) => item.status === 'active'));
setMessages(messageResult.items);
setMessageTotal(messageResult.total);
setMessagePage(messageResult.page);
setSuppressions(activeSuppressions); setSuppressions(activeSuppressions);
setApplications(applicationRows); setChannels(channelRows); setTenants(tenantRows); setError(''); setApplications(applicationRows);
setChannels(channelRows);
setTenants(tenantRows);
setError('');
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '签名清退预警数据加载失败'); 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) { async function loadMessages(targetPage: number, filters: MessageFilters) {
setLoading(true); setLoading(true);
try { try {
const result = await adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)); 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) { } catch (failure) {
setError(errorMessage(failure, '预警消息加载失败')); setError(errorMessage(failure, '预警消息加载失败'));
} finally { setLoading(false); } } finally {
setLoading(false);
}
} }
const ruleColumns: Array<TableColumn<SignatureRetirementRule>> = [ const ruleColumns: Array<TableColumn<SignatureRetirementRule>> = [
{ key: 'type', title: '规则范围', render: (item) => <><strong>{ruleTypeLabels[item.ruleType]}</strong><br /><small>{targetName(item, applications, channels)}</small></> }, {
key: 'type',
title: '规则范围',
render: (item) => (
<>
<strong>{ruleTypeLabels[item.ruleType]}</strong>
<br />
<small>{targetName(item, applications, channels)}</small>
</>
),
},
{ key: 'mobile', title: '移动', render: (item) => `${item.mobileWindowDays}天 / ${item.mobileThreshold}` }, { key: 'mobile', title: '移动', render: (item) => `${item.mobileWindowDays}天 / ${item.mobileThreshold}` },
{ key: 'unicom', title: '联通', render: (item) => `${item.unicomWindowDays}天 / ${item.unicomThreshold}` }, { key: 'unicom', title: '联通', render: (item) => `${item.unicomWindowDays}天 / ${item.unicomThreshold}` },
{ key: 'telecom', title: '电信', render: (item) => `${item.telecomWindowDays}天 / ${item.telecomThreshold}` }, { key: 'telecom', title: '电信', render: (item) => `${item.telecomWindowDays}天 / ${item.telecomThreshold}` },
{ key: 'version', title: '版本', render: (item) => `v${item.version}` }, { key: 'version', title: '版本', render: (item) => `v${item.version}` },
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button onClick={() => setRuleDraft(ruleToDraft(item))} size="sm" variant="ghost"></Button> }, {
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button onClick={() => setRuleDraft(ruleToDraft(item))} size="sm" variant="ghost">
</Button>
),
},
]; ];
const messageColumns: Array<TableColumn<SignatureRetirementMessage>> = [ const messageColumns: Array<TableColumn<SignatureRetirementMessage>> = [
{ key: 'title', title: '预警', width: '340px', render: (item) => <div className="ui-table__long-text"><strong>{item.title}</strong><br /><span>{item.content}</span></div> }, {
{ key: 'dimension', title: '维度', width: '240px', render: (item) => <>{item.tenantName ?? '-'}<br /><small>{item.applicationName ?? '未关联企业应用'} / {item.signatureName ?? '-'}</small><br /><small>{item.channelName ?? '企业维度'}</small></> }, key: 'title',
{ key: 'carrier', title: '运营商', width: '90px', render: (item) => <CarrierTag carrier={item.detection?.carrier ?? 'mobile'} /> }, title: '预警',
{ key: 'count', title: '活动量', width: '130px', render: (item) => item.detection ? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}` : '-' }, width: '340px',
render: (item) => (
<div className="ui-table__long-text">
<strong>{item.title}</strong>
<br />
<div>
{item.dailyGroupKey ? (
<details>
<summary>{item.detections?.length ?? 0} </summary>
{item.content.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
</details>
) : (
item.content
)}
</div>
</div>
),
},
{
key: 'dimension',
title: '维度',
width: '240px',
render: (item) => (
<>
{item.tenantName ?? '-'}
<br />
<small>
{item.applicationName ?? '未关联企业应用'}
{item.dailyGroupKey ? '' : ` / ${item.signatureName ?? '-'}`}
</small>
<br />
<small>{item.dailyGroupKey ? '企业应用每日汇总' : (item.channelName ?? '企业维度')}</small>
</>
),
},
{
key: 'carrier',
title: '运营商',
width: '90px',
render: (item) =>
item.dailyGroupKey ? (
<>
{[...new Set(item.detections?.map((entry) => entry.carrier) ?? [])].map((carrier) => (
<CarrierTag key={carrier} carrier={carrier} />
))}
</>
) : (
<CarrierTag carrier={item.detection?.carrier ?? 'mobile'} />
),
},
{
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: 'time', title: '消息时间', width: '170px', render: (item) => formatDateTime(item.createdAt) },
{ key: 'state', title: '状态', width: '90px', render: (item) => item.suppressed ? <Tag></Tag> : item.isRead ? <Tag tone="info"></Tag> : <Tag tone="warning"></Tag> }, {
{ key: 'actions', title: '操作', align: 'right', width: '190px', render: (item) => <div className="page-actions">{!item.isRead ? <Button onClick={() => void readMessage(item.id)} size="sm" variant="ghost"></Button> : null}{!item.suppressed ? <Button onClick={() => openSuppression(item.id)} size="sm" variant="ghost"></Button> : null}</div> }, key: 'state',
title: '状态',
width: '90px',
render: (item) =>
item.suppressed ? (
<Tag></Tag>
) : item.isRead ? (
<Tag tone="info"></Tag>
) : (
<Tag tone="warning"></Tag>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
width: '190px',
render: (item) => (
<div className="page-actions">
{!item.isRead ? (
<Button onClick={() => void readMessage(item.id)} size="sm" variant="ghost">
</Button>
) : null}
{!item.suppressed ? (
<Button onClick={() => openSuppression(item.id)} size="sm" variant="ghost">
{item.dailyGroupKey ? '抑制整组' : '抑制'}
</Button>
) : null}
</div>
),
},
]; ];
const suppressionColumns: Array<TableColumn<SignatureRetirementSuppression>> = [ const suppressionColumns: Array<TableColumn<SignatureRetirementSuppression>> = [
{ key: 'dimension', title: '维度', render: (item) => <span>{item.dimensionType === 'enterprise' ? '企业' : '通道'} / <CarrierTag carrier={item.carrier} /></span> }, {
{ key: 'mode', title: '方式', render: (item) => item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}` }, key: 'dimension',
title: '维度',
render: (item) => (
<span>
{item.dimensionType === 'enterprise' ? '企业' : '通道'} / <CarrierTag carrier={item.carrier} />
</span>
),
},
{
key: 'mode',
title: '方式',
render: (item) => (item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}`),
},
{ key: 'reason', title: '原因', render: (item) => item.reason || '-' }, { key: 'reason', title: '原因', render: (item) => item.reason || '-' },
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button icon={<ShieldOff size={15} />} onClick={() => { setActionError(''); setCancelSuppressionDraft({ id: item.id, reason: '' }); }} size="sm" variant="ghost"></Button> }, {
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button
icon={<ShieldOff size={15} />}
onClick={() => {
setActionError('');
setCancelSuppressionDraft({ id: item.id, reason: '' });
}}
size="sm"
variant="ghost"
>
</Button>
),
},
]; ];
async function saveRule() { async function saveRule() {
if (!ruleDraft) return; if (!ruleDraft) return;
setLoading(true); setLoading(true);
try { try {
await adminApi.saveSignatureRetirementRule({ await adminApi.saveSignatureRetirementRule({
...ruleDraft, targetId: ruleDraft.targetId || undefined, ...ruleDraft,
mobileWindowDays: Number(ruleDraft.mobileWindowDays), mobileThreshold: Number(ruleDraft.mobileThreshold), targetId: ruleDraft.targetId || undefined,
unicomWindowDays: Number(ruleDraft.unicomWindowDays), unicomThreshold: Number(ruleDraft.unicomThreshold), mobileWindowDays: Number(ruleDraft.mobileWindowDays),
telecomWindowDays: Number(ruleDraft.telecomWindowDays), telecomThreshold: Number(ruleDraft.telecomThreshold), 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); setRuleDraft(null);
} catch (failure) { setError(errorMessage(failure, '规则保存失败')); } finally { setLoading(false); } await loadData(messagePage, appliedMessageFilters);
} catch (failure) {
setError(errorMessage(failure, '规则保存失败'));
} finally {
setLoading(false);
}
} }
async function saveWebhook() { async function saveWebhook() {
try { await adminApi.createSignatureRetirementWebhook(webhookDraft); setWebhookOpen(false); setWebhookDraft({ name: '', platform: 'wecom', url: '' }); await loadData(messagePage, appliedMessageFilters); } try {
catch (failure) { setError(errorMessage(failure, 'Webhook保存失败')); } 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) { function openSuppression(messageId: string) {
setActionError(''); setActionError('');
setSuppressionDraft({ messageId, mode: 'temporary', muteUntil: addDateKey(shanghaiDateKey(), 7), reason: '' }); setSuppressionDraft({ messageId, mode: 'temporary', muteUntil: addDateKey(shanghaiDateKey(), 7), reason: '' });
@@ -149,76 +365,595 @@ export function AdminSignatureRetirementPage() {
async function saveSuppression() { async function saveSuppression() {
if (!suppressionDraft) return; if (!suppressionDraft) return;
const reason = suppressionDraft.reason.trim(); const reason = suppressionDraft.reason.trim();
const days = suppressionDraft.mode === 'temporary' ? differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) : undefined; const days =
if (!reason) { setActionError('请输入抑制原因'); return; } suppressionDraft.mode === 'temporary'
if (suppressionDraft.mode === 'temporary' && (!days || days < 1)) { setActionError('临时抑制截止日期必须晚于今天'); return; } ? differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil)
: undefined;
if (!reason) {
setActionError('请输入抑制原因');
return;
}
if (suppressionDraft.mode === 'temporary' && (!days || days < 1)) {
setActionError('临时抑制截止日期必须晚于今天');
return;
}
setLoading(true); setLoading(true);
try { try {
await adminApi.suppressSignatureRetirementMessage(suppressionDraft.messageId, suppressionDraft.mode === 'permanent' ? { mode: 'permanent', reason } : { mode: 'temporary', days, reason }); await adminApi.suppressSignatureRetirementMessage(
setSuppressionDraft(null); setActionError(''); suppressionDraft.messageId,
suppressionDraft.mode === 'permanent' ? { mode: 'permanent', reason } : { mode: 'temporary', days, reason },
);
setSuppressionDraft(null);
setActionError('');
await loadData(messagePage, appliedMessageFilters); await loadData(messagePage, appliedMessageFilters);
window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); 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() { async function confirmCancelSuppression() {
if (!cancelSuppressionDraft) return; if (!cancelSuppressionDraft) return;
const reason = cancelSuppressionDraft.reason.trim(); const reason = cancelSuppressionDraft.reason.trim();
if (!reason) { setActionError('请输入取消抑制原因'); return; } if (!reason) {
setActionError('请输入取消抑制原因');
return;
}
setLoading(true); setLoading(true);
try { try {
await adminApi.cancelSignatureRetirementSuppression(cancelSuppressionDraft.id, reason); await adminApi.cancelSignatureRetirementSuppression(cancelSuppressionDraft.id, reason);
setCancelSuppressionDraft(null); setActionError(''); await loadData(messagePage, appliedMessageFilters); setCancelSuppressionDraft(null);
} catch (failure) { setActionError(errorMessage(failure, '取消抑制失败')); } finally { setLoading(false); } 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 tenantOptions = tenants.map((item) => ({ value: item.id, label: item.name }));
const applicationOptions = applications const applicationOptions = applications
.filter((item) => !messageFilters.tenantId || item.tenantId === messageFilters.tenantId) .filter((item) => !messageFilters.tenantId || item.tenantId === messageFilters.tenantId)
.map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` })); .map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` }));
function applyMessageQuery() { function applyMessageQuery() {
const filters = { ...messageFilters, dateRange: normalizeMessageDateRange(messageFilters.dateRange) }; 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() { function resetMessageQuery() {
const filters = defaultMessageFilters(); 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 = [ const tabs = [
{ value: 'messages', label: `预警消息(${messageTotal}`, content: <div className="surface signature-retirement-message-card"><div className="section-heading"><div><h2></h2><p className="muted"></p></div><Button onClick={() => void readAll()} variant="ghost"></Button></div><div className="signature-retirement-message-filter"><Select label="企业" onChange={(event) => setMessageFilters((value) => ({ ...value, tenantId: event.target.value, applicationId: '' }))} options={[{ value: '', label: '全部企业' }, ...tenantOptions]} searchable value={messageFilters.tenantId} /><Select label="企业应用" onChange={(event) => setMessageFilters((value) => ({ ...value, applicationId: event.target.value }))} options={[{ value: '', label: '全部企业应用' }, ...applicationOptions]} searchable value={messageFilters.applicationId} /><Input label="签名" onChange={(event) => setMessageFilters((value) => ({ ...value, signatureKeyword: event.target.value }))} placeholder="请输入签名名称" value={messageFilters.signatureKeyword} /><Select label="通道" onChange={(event) => setMessageFilters((value) => ({ ...value, channelId: event.target.value }))} options={[{ value: '', label: '全部通道' }, ...channels.map((item) => ({ value: item.id, label: `${item.name}${item.code}` }))]} searchable value={messageFilters.channelId} /><DateRangeInput label="预警日期" onChange={(dateRange) => setMessageFilters((value) => ({ ...value, dateRange }))} value={messageFilters.dateRange} /><div className="signature-retirement-message-filter__actions"><Button icon={<Search size={16} />} onClick={applyMessageQuery}></Button><Button onClick={resetMessageQuery} variant="ghost"></Button></div></div><Table columns={messageColumns} data={messages} emptyText="暂无符合条件的预警消息" pagination={false} rowKey="id" />{messageTotal > 0 ? <Pagination nextDisabled={messagePage >= Math.ceil(messageTotal / 10)} onNext={() => { const page = messagePage + 1; setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} onPageChange={(page) => { setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} onPrevious={() => { const page = Math.max(1, messagePage - 1); setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} page={messagePage} previousDisabled={messagePage <= 1} total={messageTotal} totalPages={Math.max(1, Math.ceil(messageTotal / 10))} /> : null}</div> }, {
{ value: 'rules', label: '检测规则', content: <div className="surface"><div className="section-heading"><div><h2></h2><p className="muted"></p></div><Button icon={<Plus size={16} />} onClick={() => setRuleDraft({ ...emptyRule })}></Button></div><Table columns={ruleColumns} data={rules} emptyText="暂无规则,未配置规则的维度不会进入检测" pagination={false} rowKey="id" /></div> }, value: 'messages',
{ value: 'webhooks', label: 'Webhook', content: <div className="surface"><div className="section-heading"><div><h2> / </h2><p className="muted">退</p></div><Button icon={<Plus size={16} />} onClick={() => setWebhookOpen(true)}>Webhook</Button></div><div className="settings-list">{webhooks.map((item) => <div className="settings-list__item" key={item.id}><div><strong>{item.name}</strong><p>{item.platform === 'wecom' ? '企业微信' : '飞书'} · {item.urlMasked}</p></div><Button icon={<Trash2 size={15} />} onClick={() => void deleteWebhook(item.id)} variant="ghost"></Button></div>)}{!webhooks.length ? <p className="empty-state">Webhook</p> : null}</div></div> }, label: `预警消息(${messageTotal}`,
{ value: 'suppressions', label: `抑制管理(${suppressions.length}`, content: <div className="surface"><Table columns={suppressionColumns} data={suppressions} emptyText="暂无有效抑制" pagination={false} rowKey="id" /></div> }, content: (
<div className="surface signature-retirement-message-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<Button onClick={() => void readAll()} variant="ghost">
</Button>
</div>
<div className="signature-retirement-message-filter">
<Select
label="企业"
onChange={(event) =>
setMessageFilters((value) => ({ ...value, tenantId: event.target.value, applicationId: '' }))
}
options={[{ value: '', label: '全部企业' }, ...tenantOptions]}
searchable
value={messageFilters.tenantId}
/>
<Select
label="企业应用"
onChange={(event) => setMessageFilters((value) => ({ ...value, applicationId: event.target.value }))}
options={[{ value: '', label: '全部企业应用' }, ...applicationOptions]}
searchable
value={messageFilters.applicationId}
/>
<Input
label="签名"
onChange={(event) => setMessageFilters((value) => ({ ...value, signatureKeyword: event.target.value }))}
placeholder="请输入签名名称"
value={messageFilters.signatureKeyword}
/>
<Select
label="通道"
onChange={(event) => setMessageFilters((value) => ({ ...value, channelId: event.target.value }))}
options={[
{ value: '', label: '全部通道' },
...channels.map((item) => ({ value: item.id, label: `${item.name}${item.code}` })),
]}
searchable
value={messageFilters.channelId}
/>
<DateRangeInput
label="预警日期"
onChange={(dateRange) => setMessageFilters((value) => ({ ...value, dateRange }))}
value={messageFilters.dateRange}
/>
<div className="signature-retirement-message-filter__actions">
<Button icon={<Search size={16} />} onClick={applyMessageQuery}>
</Button>
<Button onClick={resetMessageQuery} variant="ghost">
</Button>
</div>
</div>
<Table
columns={messageColumns}
data={messages}
emptyText="暂无符合条件的预警消息"
pagination={false}
rowKey="id"
/>
{messageTotal > 0 ? (
<Pagination
nextDisabled={messagePage >= Math.ceil(messageTotal / 10)}
onNext={() => {
const page = messagePage + 1;
setMessagePage(page);
void loadMessages(page, appliedMessageFilters);
}}
onPageChange={(page) => {
setMessagePage(page);
void loadMessages(page, appliedMessageFilters);
}}
onPrevious={() => {
const page = Math.max(1, messagePage - 1);
setMessagePage(page);
void loadMessages(page, appliedMessageFilters);
}}
page={messagePage}
previousDisabled={messagePage <= 1}
total={messageTotal}
totalPages={Math.max(1, Math.ceil(messageTotal / 10))}
/>
) : null}
</div>
),
},
{
value: 'rules',
label: '检测规则',
content: (
<div className="surface">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<Button icon={<Plus size={16} />} onClick={() => setRuleDraft({ ...emptyRule })}>
</Button>
</div>
<Table
columns={ruleColumns}
data={rules}
emptyText="暂无规则,未配置规则的维度不会进入检测"
pagination={false}
rowKey="id"
/>
</div>
),
},
{
value: 'webhooks',
label: 'Webhook',
content: (
<div className="surface">
<div className="section-heading">
<div>
<h2> / </h2>
<p className="muted">退</p>
</div>
<Button icon={<Plus size={16} />} onClick={() => setWebhookOpen(true)}>
Webhook
</Button>
</div>
<div className="settings-list">
{webhooks.map((item) => (
<div className="settings-list__item" key={item.id}>
<div>
<strong>{item.name}</strong>
<p>
{item.platform === 'wecom' ? '企业微信' : '飞书'} · {item.urlMasked}
</p>
</div>
<Button icon={<Trash2 size={15} />} onClick={() => void deleteWebhook(item.id)} variant="ghost">
</Button>
</div>
))}
{!webhooks.length ? <p className="empty-state">Webhook</p> : null}
</div>
</div>
),
},
{
value: 'suppressions',
label: `抑制管理(${suppressions.length}`,
content: (
<div className="surface">
<Table
columns={suppressionColumns}
data={suppressions}
emptyText="暂无有效抑制"
pagination={false}
rowKey="id"
/>
</div>
),
},
]; ];
return <section className="page-stack"> return (
<div className="page-heading"><div><Breadcrumb items={['安全控制', '签名清退预警']} /><h1>退</h1><p className="muted">每天北京时间04:00自动检测08:00生成站内消息并发送Webhook</p></div><div className="page-actions"><Button disabled={loading} icon={<RefreshCw size={16} />} onClick={() => void loadData(messagePage, appliedMessageFilters)} variant="ghost"></Button></div></div> <section className="page-stack">
{error ? <p className="form-error">{error}</p> : null}<Tabs items={tabs} /> <div className="page-heading">
<RuleModal applications={applications} channels={channels} draft={ruleDraft} loading={loading} onChange={setRuleDraft} onClose={() => setRuleDraft(null)} onSave={() => void saveRule()} /> <div>
<Modal open={webhookOpen} title="新增Webhook" onClose={() => setWebhookOpen(false)} footer={<><Button onClick={() => setWebhookOpen(false)} variant="ghost"></Button><Button onClick={() => void saveWebhook()}></Button></>}><div className="form-grid"><Input label="名称" onChange={(event) => setWebhookDraft((value) => ({ ...value, name: event.target.value }))} value={webhookDraft.name} /><Select label="平台" onChange={(event) => setWebhookDraft((value) => ({ ...value, platform: event.target.value as 'wecom' | 'feishu' }))} options={[{ value: 'wecom', label: '企业微信' }, { value: 'feishu', label: '飞书' }]} value={webhookDraft.platform} /><Input className="form-grid__full" label="Webhook HTTPS地址" onChange={(event) => setWebhookDraft((value) => ({ ...value, url: event.target.value }))} value={webhookDraft.url} /></div></Modal> <Breadcrumb items={['安全控制', '签名清退预警']} />
<Modal dirty={Boolean(suppressionDraft?.reason.trim())} open={Boolean(suppressionDraft)} title="设置预警抑制" onClose={() => { setSuppressionDraft(null); setActionError(''); }} footer={<><Button onClick={() => { setSuppressionDraft(null); setActionError(''); }} variant="ghost"></Button><Button disabled={loading || !suppressionDraft?.reason.trim() || (suppressionDraft?.mode === 'temporary' && differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) < 1)} onClick={() => void saveSuppression()}></Button></>}> <h1>退</h1>
{suppressionDraft ? <div className="page-stack"><p className="muted">Webhook</p><div className="signature-retirement-suppression-modes"><label className={suppressionDraft.mode === 'temporary' ? 'is-selected' : ''}><input checked={suppressionDraft.mode === 'temporary'} name="suppression-mode" onChange={() => setSuppressionDraft((value) => value ? { ...value, mode: 'temporary' } : value)} type="radio" /><span><strong></strong><small></small></span></label><label className={suppressionDraft.mode === 'permanent' ? 'is-selected' : ''}><input checked={suppressionDraft.mode === 'permanent'} name="suppression-mode" onChange={() => setSuppressionDraft((value) => value ? { ...value, mode: 'permanent' } : value)} type="radio" /><span><strong></strong><small></small></span></label></div>{suppressionDraft.mode === 'temporary' ? <Input label="抑制截止日期" min={addDateKey(shanghaiDateKey(), 1)} onChange={(event) => setSuppressionDraft((value) => value ? { ...value, muteUntil: event.target.value } : value)} type="date" value={suppressionDraft.muteUntil} /> : null}<Textarea label="抑制原因" maxLength={500} onChange={(event) => setSuppressionDraft((value) => value ? { ...value, reason: event.target.value } : value)} placeholder="请填写抑制原因" rows={4} value={suppressionDraft.reason} />{actionError ? <p className="form-error">{actionError}</p> : null}</div> : null} <p className="muted">每天北京时间04:00自动检测08:00生成站内消息并发送Webhook</p>
</div>
<div className="page-actions">
<Button
disabled={loading}
icon={<RefreshCw size={16} />}
onClick={() => void loadData(messagePage, appliedMessageFilters)}
variant="ghost"
>
</Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<Tabs items={tabs} />
<RuleModal
applications={applications}
channels={channels}
draft={ruleDraft}
loading={loading}
onChange={setRuleDraft}
onClose={() => setRuleDraft(null)}
onSave={() => void saveRule()}
/>
<Modal
open={webhookOpen}
title="新增Webhook"
onClose={() => setWebhookOpen(false)}
footer={
<>
<Button onClick={() => setWebhookOpen(false)} variant="ghost">
</Button>
<Button onClick={() => void saveWebhook()}></Button>
</>
}
>
<div className="form-grid">
<Input
label="名称"
onChange={(event) => setWebhookDraft((value) => ({ ...value, name: event.target.value }))}
value={webhookDraft.name}
/>
<Select
label="平台"
onChange={(event) =>
setWebhookDraft((value) => ({ ...value, platform: event.target.value as 'wecom' | 'feishu' }))
}
options={[
{ value: 'wecom', label: '企业微信' },
{ value: 'feishu', label: '飞书' },
]}
value={webhookDraft.platform}
/>
<Input
className="form-grid__full"
label="Webhook HTTPS地址"
onChange={(event) => setWebhookDraft((value) => ({ ...value, url: event.target.value }))}
value={webhookDraft.url}
/>
</div>
</Modal> </Modal>
<Modal dirty={Boolean(cancelSuppressionDraft?.reason.trim())} open={Boolean(cancelSuppressionDraft)} title="取消抑制" onClose={() => { setCancelSuppressionDraft(null); setActionError(''); }} footer={<><Button onClick={() => { setCancelSuppressionDraft(null); setActionError(''); }} variant="ghost"></Button><Button disabled={loading || !cancelSuppressionDraft?.reason.trim()} onClick={() => void confirmCancelSuppression()}></Button></>}> <Modal
{cancelSuppressionDraft ? <div className="page-stack"><p className="muted"></p><Textarea label="取消原因" maxLength={500} onChange={(event) => setCancelSuppressionDraft((value) => value ? { ...value, reason: event.target.value } : value)} placeholder="请填写取消抑制原因" rows={4} value={cancelSuppressionDraft.reason} />{actionError ? <p className="form-error">{actionError}</p> : null}</div> : null} dirty={Boolean(suppressionDraft?.reason.trim())}
open={Boolean(suppressionDraft)}
title="设置预警抑制"
onClose={() => {
setSuppressionDraft(null);
setActionError('');
}}
footer={
<>
<Button
onClick={() => {
setSuppressionDraft(null);
setActionError('');
}}
variant="ghost"
>
</Button>
<Button
disabled={
loading ||
!suppressionDraft?.reason.trim() ||
(suppressionDraft?.mode === 'temporary' &&
differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) < 1)
}
onClick={() => void saveSuppression()}
>
</Button>
</>
}
>
{suppressionDraft ? (
<div className="page-stack">
<p className="muted">Webhook</p>
<div className="signature-retirement-suppression-modes">
<label className={suppressionDraft.mode === 'temporary' ? 'is-selected' : ''}>
<input
checked={suppressionDraft.mode === 'temporary'}
name="suppression-mode"
onChange={() => setSuppressionDraft((value) => (value ? { ...value, mode: 'temporary' } : value))}
type="radio"
/>
<span>
<strong></strong>
<small></small>
</span>
</label>
<label className={suppressionDraft.mode === 'permanent' ? 'is-selected' : ''}>
<input
checked={suppressionDraft.mode === 'permanent'}
name="suppression-mode"
onChange={() => setSuppressionDraft((value) => (value ? { ...value, mode: 'permanent' } : value))}
type="radio"
/>
<span>
<strong></strong>
<small></small>
</span>
</label>
</div>
{suppressionDraft.mode === 'temporary' ? (
<Input
label="抑制截止日期"
min={addDateKey(shanghaiDateKey(), 1)}
onChange={(event) =>
setSuppressionDraft((value) => (value ? { ...value, muteUntil: event.target.value } : value))
}
type="date"
value={suppressionDraft.muteUntil}
/>
) : null}
<Textarea
label="抑制原因"
maxLength={500}
onChange={(event) =>
setSuppressionDraft((value) => (value ? { ...value, reason: event.target.value } : value))
}
placeholder="请填写抑制原因"
rows={4}
value={suppressionDraft.reason}
/>
{actionError ? <p className="form-error">{actionError}</p> : null}
</div>
) : null}
</Modal> </Modal>
</section>; <Modal
dirty={Boolean(cancelSuppressionDraft?.reason.trim())}
open={Boolean(cancelSuppressionDraft)}
title="取消抑制"
onClose={() => {
setCancelSuppressionDraft(null);
setActionError('');
}}
footer={
<>
<Button
onClick={() => {
setCancelSuppressionDraft(null);
setActionError('');
}}
variant="ghost"
>
</Button>
<Button
disabled={loading || !cancelSuppressionDraft?.reason.trim()}
onClick={() => void confirmCancelSuppression()}
>
</Button>
</>
}
>
{cancelSuppressionDraft ? (
<div className="page-stack">
<p className="muted"></p>
<Textarea
label="取消原因"
maxLength={500}
onChange={(event) =>
setCancelSuppressionDraft((value) => (value ? { ...value, reason: event.target.value } : value))
}
placeholder="请填写取消抑制原因"
rows={4}
value={cancelSuppressionDraft.reason}
/>
{actionError ? <p className="form-error">{actionError}</p> : null}
</div>
) : null}
</Modal>
</section>
);
} }
function RuleModal({ applications, channels, draft, loading, onChange, onClose, onSave }: { applications: EnterpriseApplication[]; channels: AdminChannel[]; draft: RuleDraft | null; loading: boolean; onChange: (value: RuleDraft | null) => void; onClose: () => void; onSave: () => void }) { function RuleModal({
applications,
channels,
draft,
loading,
onChange,
onClose,
onSave,
}: {
applications: EnterpriseApplication[];
channels: AdminChannel[];
draft: RuleDraft | null;
loading: boolean;
onChange: (value: RuleDraft | null) => void;
onClose: () => void;
onSave: () => void;
}) {
const special = draft?.ruleType === 'enterprise_application' || draft?.ruleType === 'channel'; const special = draft?.ruleType === 'enterprise_application' || draft?.ruleType === 'channel';
const targetOptions = draft?.ruleType === 'enterprise_application' ? applications.map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` })) : channels.map((item) => ({ value: item.id, label: `${item.name}${item.code}` })); const targetOptions =
draft?.ruleType === 'enterprise_application'
? applications.map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` }))
: channels.map((item) => ({ value: item.id, label: `${item.name}${item.code}` }));
const change = (key: keyof RuleDraft, value: string | boolean) => draft && onChange({ ...draft, [key]: value }); const change = (key: keyof RuleDraft, value: string | boolean) => draft && onChange({ ...draft, [key]: value });
return <Modal open={Boolean(draft)} title="签名清退检测规则" onClose={onClose} footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={loading || (special && !draft?.targetId)} icon={<Settings2 size={15} />} onClick={onSave}></Button></>} size="xl"><div className="form-grid">{draft ? <><Select label="规则范围" onChange={(event) => onChange({ ...draft, ruleType: event.target.value as SignatureRetirementRuleType, targetId: '' })} options={Object.entries(ruleTypeLabels).map(([value, label]) => ({ value, label }))} value={draft.ruleType} />{special ? <Select label={draft.ruleType === 'channel' ? '目标通道' : '目标企业应用'} onChange={(event) => change('targetId', event.target.value)} options={targetOptions} placeholder="请选择" searchable value={draft.targetId} /> : <div className="surface"><strong></strong><p className="muted"></p></div>}{carriers.map((carrier) => <div className="surface" key={carrier}><strong>{carrierLabels[carrier]}</strong><div className="form-grid"><Input label="统计窗口(天)" min="1" max="365" onChange={(event) => change(`${carrier}WindowDays` as keyof RuleDraft, event.target.value)} type="number" value={draft[`${carrier}WindowDays`]} /><Input label="最低活动量(条)" min="0" onChange={(event) => change(`${carrier}Threshold` as keyof RuleDraft, event.target.value)} type="number" value={draft[`${carrier}Threshold`]} /></div></div>)}<Textarea className="form-grid__full" hint="可用变量:{enterprise} {signature} {channel} {carrier} {days} {threshold} {actual}" label="消息模板(留空使用系统模板)" onChange={(event) => change('messageTemplate', event.target.value)} rows={4} value={draft.messageTemplate} /></> : null}</div></Modal>; return (
<Modal
open={Boolean(draft)}
title="签名清退检测规则"
onClose={onClose}
footer={
<>
<Button onClick={onClose} variant="ghost">
</Button>
<Button disabled={loading || (special && !draft?.targetId)} icon={<Settings2 size={15} />} onClick={onSave}>
</Button>
</>
}
size="xl"
>
<div className="form-grid">
{draft ? (
<>
<Select
label="规则范围"
onChange={(event) =>
onChange({ ...draft, ruleType: event.target.value as SignatureRetirementRuleType, targetId: '' })
}
options={Object.entries(ruleTypeLabels).map(([value, label]) => ({ value, label }))}
value={draft.ruleType}
/>
{special ? (
<Select
label={draft.ruleType === 'channel' ? '目标通道' : '目标企业应用'}
onChange={(event) => change('targetId', event.target.value)}
options={targetOptions}
placeholder="请选择"
searchable
value={draft.targetId}
/>
) : (
<div className="surface">
<strong></strong>
<p className="muted"></p>
</div>
)}
{carriers.map((carrier) => (
<div className="surface" key={carrier}>
<strong>{carrierLabels[carrier]}</strong>
<div className="form-grid">
<Input
label="统计窗口(天)"
min="1"
max="365"
onChange={(event) => change(`${carrier}WindowDays` as keyof RuleDraft, event.target.value)}
type="number"
value={draft[`${carrier}WindowDays`]}
/>
<Input
label="最低活动量(条)"
min="0"
onChange={(event) => change(`${carrier}Threshold` as keyof RuleDraft, event.target.value)}
type="number"
value={draft[`${carrier}Threshold`]}
/>
</div>
</div>
))}
<Textarea
className="form-grid__full"
hint="可用变量:{enterprise} {signature} {channel} {carrier} {days} {threshold} {actual}"
label="消息模板(留空使用系统模板)"
onChange={(event) => change('messageTemplate', event.target.value)}
rows={4}
value={draft.messageTemplate}
/>
</>
) : null}
</div>
</Modal>
);
} }
function ruleToDraft(rule: SignatureRetirementRule): RuleDraft { return { ruleType: rule.ruleType, targetId: rule.targetId ?? '', enabled: rule.enabled, mobileWindowDays: String(rule.mobileWindowDays), mobileThreshold: String(rule.mobileThreshold), unicomWindowDays: String(rule.unicomWindowDays), unicomThreshold: String(rule.unicomThreshold), telecomWindowDays: String(rule.telecomWindowDays), telecomThreshold: String(rule.telecomThreshold), messageTemplate: rule.messageTemplate ?? '' }; } function ruleToDraft(rule: SignatureRetirementRule): RuleDraft {
function targetName(rule: SignatureRetirementRule, applications: EnterpriseApplication[], channels: AdminChannel[]) { if (!rule.targetId) return '全局默认'; return rule.ruleType === 'channel' ? channels.find((item) => item.id === rule.targetId)?.name ?? rule.targetId : applications.find((item) => item.id === rule.targetId)?.name ?? rule.targetId; } return {
function formatDate(value?: string | null) { return value ? new Date(value).toLocaleDateString('zh-CN') : '-'; } ruleType: rule.ruleType,
function formatDateTime(value?: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; } targetId: rule.targetId ?? '',
function errorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } enabled: rule.enabled,
function shanghaiDateKey(value = new Date()) { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(value); } mobileWindowDays: String(rule.mobileWindowDays),
mobileThreshold: String(rule.mobileThreshold),
unicomWindowDays: String(rule.unicomWindowDays),
unicomThreshold: String(rule.unicomThreshold),
telecomWindowDays: String(rule.telecomWindowDays),
telecomThreshold: String(rule.telecomThreshold),
messageTemplate: rule.messageTemplate ?? '',
};
}
function targetName(rule: SignatureRetirementRule, applications: EnterpriseApplication[], channels: AdminChannel[]) {
if (!rule.targetId) return '全局默认';
return rule.ruleType === 'channel'
? (channels.find((item) => item.id === rule.targetId)?.name ?? rule.targetId)
: (applications.find((item) => item.id === rule.targetId)?.name ?? rule.targetId);
}
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleDateString('zh-CN') : '-';
}
function formatDateTime(value?: string | null) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
function shanghaiDateKey(value = new Date()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(value);
}
function normalizeMessageDateRange(value: DateRangeValue): DateRangeValue { function normalizeMessageDateRange(value: DateRangeValue): DateRangeValue {
const fallback = shanghaiDateKey(); const fallback = shanghaiDateKey();
const start = value.start || value.end || fallback; const start = value.start || value.end || fallback;
+143 -23
View File
@@ -32,9 +32,13 @@ export function ChannelFormModal({
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100)); const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1)); const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16)); const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30)); const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(
String(channel?.heartbeatIntervalSeconds ?? 30),
);
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3)); const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(channel?.longMessageReceiptMode ?? 'per_segment'); const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(
channel?.longMessageReceiptMode ?? 'per_segment',
);
function submit() { function submit() {
if (carriers.length === 0) { if (carriers.length === 0) {
@@ -82,34 +86,74 @@ export function ChannelFormModal({
return ( return (
<Modal <Modal
footer={( closeOnBackdrop={modal.mode === 'edit'}
closeOnEscape={modal.mode === 'edit'}
footer={
<> <>
<Button onClick={onClose} variant="ghost"></Button> <Button onClick={onClose} variant="ghost">
</Button>
<Button onClick={submit}></Button> <Button onClick={submit}></Button>
</> </>
)} }
onClose={onClose} onClose={onClose}
open open
size="xl" size="xl"
title={<div className="template-modal-title"><h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2></div>} title={
<div className="template-modal-title">
<h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2>
</div>
}
> >
<div className="sms-channel-form"> <div className="sms-channel-form">
<section> <section>
<h3></h3> <h3></h3>
<div className="sms-channel-form-grid"> <div className="sms-channel-form-grid">
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} /> <Input
label="* 通道名称"
onChange={(event) => setName(event.target.value)}
placeholder="请输入通道名称"
value={name}
/>
<div className="sms-channel-radio-row"> <div className="sms-channel-radio-row">
<span>* </span> <span>* </span>
{baseCarrierOptions.map((item) => ( {baseCarrierOptions.map((item) => (
<label key={item.value}> <label key={item.value}>
<input checked={carriers.includes(item.value)} onChange={() => { setCarriers((current) => current.includes(item.value) ? current.filter((carrier) => carrier !== item.value) : [...current, item.value]); setCarrierError(''); }} type="checkbox" /> <input
checked={carriers.includes(item.value)}
onChange={() => {
setCarriers((current) =>
current.includes(item.value)
? current.filter((carrier) => carrier !== item.value)
: [...current, item.value],
);
setCarrierError('');
}}
type="checkbox"
/>
{item.label} {item.label}
</label> </label>
))} ))}
{carrierError ? <small className="form-error">{carrierError}</small> : null} {carrierError ? <small className="form-error">{carrierError}</small> : null}
</div> </div>
<Input error={unitPriceError} label="* 单价(元)" min="0" onChange={(event) => { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} /> <Input
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} /> error={unitPriceError}
label="* 单价(元)"
min="0"
onChange={(event) => {
setUnitPrice(event.target.value);
setUnitPriceError('');
}}
step="0.0001"
type="number"
value={unitPrice}
/>
<Select
label="* 发送地区"
onChange={(event) => setRegion(event.target.value)}
options={regionOptions}
value={region}
/>
</div> </div>
</section> </section>
@@ -118,13 +162,39 @@ export function ChannelFormModal({
<div className="sms-channel-form-grid"> <div className="sms-channel-form-grid">
<Input disabled label="* 协议选择" value="CMPP" /> <Input disabled label="* 协议选择" value="CMPP" />
<div className="sms-channel-inline-field"> <div className="sms-channel-inline-field">
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} /> <Input
label="* 网关地址"
onChange={(event) => setGatewayHost(event.target.value)}
placeholder="请输入网关地址"
value={gatewayHost}
/>
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} /> <Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
</div> </div>
<Input hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} /> <Input
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} /> hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。"
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} /> label="* 业务代码"
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} /> maxLength={10}
onChange={(event) => setBusinessCode(event.target.value.toUpperCase())}
value={businessCode}
/>
<Input
label="* 企业代码"
onChange={(event) => setCorpCode(event.target.value)}
placeholder="请输入企业代码"
value={corpCode}
/>
<Input
label="* 网关账号"
onChange={(event) => setAccount(event.target.value)}
placeholder="请输入网关账号"
value={account}
/>
<Select
label="* CMPP版本"
onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')}
options={cmppVersionOptions}
value={cmppVersion}
/>
<Input <Input
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined} hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
label="网关密码" label="网关密码"
@@ -137,14 +207,62 @@ export function ChannelFormModal({
value={password} value={password}
/> />
<div className="sms-channel-inline-field"> <div className="sms-channel-inline-field">
<Input autoComplete="off" label="* 接入号" name="cmpp-access-number" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} /> <Input
<Input label="扩展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" value={extensionDigits} /> autoComplete="off"
label="* 接入号"
name="cmpp-access-number"
onChange={(event) => setAccessNo(event.target.value)}
placeholder="请输入通道接入号"
value={accessNo}
/>
<Input
label="扩展位数"
max="20"
min="0"
onChange={(event) => setExtensionDigits(event.target.value)}
type="number"
value={extensionDigits}
/>
</div> </div>
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} /> <Input
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} /> label="* 通道流速"
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} /> max="2000"
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} /> min="1"
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} /> onChange={(event) => setFlowLimit(event.target.value)}
suffix="条/秒"
type="number"
value={flowLimit}
/>
<Input
label="* 期望连接数"
onChange={(event) => setDesiredConnections(event.target.value)}
placeholder="1"
value={desiredConnections}
/>
<Input
label="* 提交窗口"
onChange={(event) => setWindowSize(event.target.value)}
placeholder="16"
value={windowSize}
/>
<Input
hint="平台主动向供应商发送 ACTIVE_TEST 的间隔"
label="* 心跳间隔"
min="1"
onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)}
suffix="秒"
type="number"
value={heartbeatIntervalSeconds}
/>
<Input
hint="连续未收到心跳响应达到该次数后重连"
label="* 心跳失败阈值"
min="1"
onChange={(event) => setHeartbeatMissThreshold(event.target.value)}
suffix="次"
type="number"
value={heartbeatMissThreshold}
/>
<Select <Select
label="* 长短信成功回执口径" label="* 长短信成功回执口径"
onChange={(event) => setLongMessageReceiptMode(event.target.value as LongMessageReceiptMode)} onChange={(event) => setLongMessageReceiptMode(event.target.value as LongMessageReceiptMode)}
@@ -154,7 +272,9 @@ export function ChannelFormModal({
]} ]}
value={longMessageReceiptMode} value={longMessageReceiptMode}
/> />
<p className="page-inline-hint"></p> <p className="page-inline-hint">
</p>
</div> </div>
</section> </section>
</div> </div>
+29
View File
@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Modal } from './Modal';
describe('Modal close policy', () => {
it('keeps an explicitly locked form open on mask and Escape but permits the close button', () => {
const close = vi.fn();
render(
<Modal open title="创建通道" closeOnBackdrop={false} closeOnEscape={false} onClose={close}>
</Modal>,
);
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
fireEvent.keyDown(document, { key: 'Escape' });
expect(close).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(close).toHaveBeenCalledOnce();
});
it('preserves mask closing by default for existing consumers', () => {
const close = vi.fn();
render(
<Modal open title="普通弹窗" onClose={close}>
</Modal>,
);
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
expect(close).toHaveBeenCalledOnce();
});
});
+42 -16
View File
@@ -16,6 +16,8 @@ type ModalProps = {
size?: 'md' | 'xl'; size?: 'md' | 'xl';
onClose: () => void; onClose: () => void;
dirty?: boolean; dirty?: boolean;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
initialFocusRef?: RefObject<HTMLElement | null>; initialFocusRef?: RefObject<HTMLElement | null>;
closeGuardTitle?: string; closeGuardTitle?: string;
closeGuardDescription?: string; closeGuardDescription?: string;
@@ -82,8 +84,9 @@ function unlockDocument() {
} }
function focusableElements(root: HTMLElement) { function focusableElements(root: HTMLElement) {
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector)) return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector)).filter(
.filter((element) => !element.hidden && element.getClientRects().length > 0); (element) => !element.hidden && element.getClientRects().length > 0,
);
} }
export function Modal({ export function Modal({
@@ -94,6 +97,8 @@ export function Modal({
size = 'md', size = 'md',
onClose, onClose,
dirty = false, dirty = false,
closeOnBackdrop = true,
closeOnEscape = true,
initialFocusRef, initialFocusRef,
closeGuardTitle = '放弃未保存的修改?', closeGuardTitle = '放弃未保存的修改?',
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。', closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
@@ -108,6 +113,7 @@ export function Modal({
const guardRestoreFocusRef = useRef<HTMLElement | null>(null); const guardRestoreFocusRef = useRef<HTMLElement | null>(null);
const [showCloseGuard, setShowCloseGuard] = useState(false); const [showCloseGuard, setShowCloseGuard] = useState(false);
const [layer] = useState(() => modalLayer()); const [layer] = useState(() => modalLayer());
if (!open && showCloseGuard) setShowCloseGuard(false);
const requestClose = useCallback(() => { const requestClose = useCallback(() => {
if (dirty) { if (dirty) {
@@ -124,10 +130,7 @@ export function Modal({
}, [onClose]); }, [onClose]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) return undefined;
setShowCloseGuard(false);
return undefined;
}
const panel = panelRef.current; const panel = panelRef.current;
if (!panel) return undefined; if (!panel) return undefined;
@@ -163,7 +166,7 @@ export function Modal({
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (showCloseGuard) setShowCloseGuard(false); if (showCloseGuard) setShowCloseGuard(false);
else requestClose(); else if (closeOnEscape) requestClose();
return; return;
} }
if (event.key !== 'Tab') return; if (event.key !== 'Tab') return;
@@ -188,7 +191,7 @@ export function Modal({
document.addEventListener('keydown', handleKeyDown, true); document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true); return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [open, requestClose, showCloseGuard]); }, [closeOnEscape, open, requestClose, showCloseGuard]);
useEffect(() => { useEffect(() => {
if (!showCloseGuard) return; if (!showCloseGuard) return;
@@ -205,13 +208,16 @@ export function Modal({
}, [showCloseGuard]); }, [showCloseGuard]);
if (!open) return null; if (!open) return null;
const renderedFooter = typeof footer === 'function' ? footer({ requestClose }) : footer;
return createPortal( return createPortal(
<div className="ui-modal" data-ui-modal-root> <div className="ui-modal" data-ui-modal-root>
<div aria-hidden="true" className="ui-modal__mask" onMouseDown={(event) => { <div
if (event.target === event.currentTarget) requestClose(); aria-hidden="true"
}} /> className="ui-modal__mask"
onMouseDown={(event) => {
if (closeOnBackdrop && event.target === event.currentTarget) requestClose();
}}
/>
<section <section
aria-labelledby={titleId} aria-labelledby={titleId}
aria-modal="true" aria-modal="true"
@@ -221,13 +227,19 @@ export function Modal({
tabIndex={-1} tabIndex={-1}
> >
<header className="ui-modal__header"> <header className="ui-modal__header">
<div className="ui-modal__title" id={titleId}>{title}</div> <div className="ui-modal__title" id={titleId}>
{title}
</div>
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}> <Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}>
</Button> </Button>
</header> </header>
<div className="ui-modal__body">{children}</div> <div className="ui-modal__body">{children}</div>
{renderedFooter ? <footer className="ui-modal__footer">{renderedFooter}</footer> : null} {footer ? (
<footer className="ui-modal__footer">
<ModalFooter footer={footer} requestClose={requestClose} />
</footer>
) : null}
</section> </section>
{showCloseGuard ? ( {showCloseGuard ? (
<div className="ui-modal__guard-layer"> <div className="ui-modal__guard-layer">
@@ -246,8 +258,12 @@ export function Modal({
<p id={guardDescriptionId}>{closeGuardDescription}</p> <p id={guardDescriptionId}>{closeGuardDescription}</p>
</div> </div>
<footer> <footer>
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost"></Button> <Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost">
<Button onClick={discardAndClose} variant="danger"></Button>
</Button>
<Button onClick={discardAndClose} variant="danger">
</Button>
</footer> </footer>
</section> </section>
</div> </div>
@@ -256,3 +272,13 @@ export function Modal({
layer, layer,
); );
} }
function ModalFooter({
footer,
requestClose,
}: {
footer: NonNullable<ModalProps['footer']>;
requestClose: () => void;
}) {
return typeof footer === 'function' ? footer({ requestClose }) : footer;
}
+12
View File
@@ -10,6 +10,18 @@
"src/styles/components.css" "src/styles/components.css"
], ],
"files": [ "files": [
{
"file": "src/apps/admin/AdminHome.css",
"owners": ["src/apps/admin/AdminHome.tsx"],
"stylelintLegacy": false,
"roots": ["admin-dashboard"]
},
{
"file": "src/apps/admin/AdminAnalyticsPage.css",
"owners": ["src/apps/admin/AdminAnalyticsPage.tsx"],
"stylelintLegacy": false,
"roots": ["admin-analytics-page"]
},
{ {
"file": "src/layouts/AlertNotificationMenu.css", "file": "src/layouts/AlertNotificationMenu.css",
"owners": ["src/layouts/AlertNotificationMenu.tsx"], "owners": ["src/layouts/AlertNotificationMenu.tsx"],
@@ -0,0 +1,166 @@
// Test environment only. Uses real PostgreSQL in a new isolated schema; no Nest lifecycle or external delivery.
// CMPP_DIAGNOSTIC_ENV=test node --expose-gc <script> <candidate-api-root>
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
if (process.env.CMPP_DIAGNOSTIC_ENV !== 'test') throw new Error('Explicit test environment is required');
const root = resolve(process.argv[2]);
const require = createRequire(resolve(root, 'package.json'));
const { Pool } = require('pg');
const { PrismaPg } = require('@prisma/adapter-pg');
const { PrismaClient } = require('@prisma/client');
const { SignatureRetirementService } = require(
resolve(root, 'dist/signature-retirement/signature-retirement.service.js'),
);
const schema = `cmpp_fix_20260908_${Date.now()}`;
const admin = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
const tables = [
'Tenant',
'SmsApplication',
'SmsSignature',
'SmsChannel',
'SignatureRetirementDetection',
'SignatureRetirementMessage',
'SignatureRetirementSuppression',
'SignatureRetirementWebhook',
'SignatureRetirementWebhookDelivery',
'OperationLog',
'ChannelSignatureReportTask',
];
const clients = [];
const checks = [];
try {
const before = (await admin.query('SELECT count(*)::int AS count FROM public."SmsMessageRecord"')).rows[0].count;
await admin.query(`CREATE SCHEMA "${schema}"`);
for (const table of tables)
await admin.query(`CREATE TABLE "${schema}"."${table}" (LIKE public."${table}" INCLUDING ALL)`);
const columns = await admin.query(
'SELECT 1 FROM information_schema.columns WHERE table_schema=$1 AND table_name=$2 AND column_name=$3',
[schema, 'SignatureRetirementMessage', 'dailyGroupKey'],
);
if (!columns.rowCount) {
await admin.query(`SET search_path TO "${schema}"`);
await admin.query(
readFileSync(
resolve(root, 'prisma/migrations/20260908050000_retirement_application_daily_message/migration.sql'),
'utf8',
),
);
}
for (let i = 0; i < 2; i++) {
const url = new URL(process.env.DATABASE_URL);
url.searchParams.set('options', `-c search_path=${schema} -c statement_timeout=15000`);
clients.push(
new PrismaClient({
adapter: new PrismaPg(new Pool({ connectionString: url.toString(), max: 2 }), {
schema,
disposeExternalPool: true,
}),
}),
);
}
const [db, other] = clients;
assert.equal((await db.$queryRawUnsafe('SELECT current_schema() AS schema'))[0].schema, schema);
const service = new SignatureRetirementService(db);
const peer = new SignatureRetirementService(other);
const date = '2026-09-08';
for (const id of ['t1', 't2']) await db.tenant.create({ data: { id, code: `QA-${id}`, name: id } });
for (const id of ['app1', 'app2'])
await db.smsApplication.create({
data: {
id,
tenantId: 't1',
name: id,
cmppAccount: `QA-${id}`,
cmppEnterpriseCode: 'QA',
secretHash: 'isolated-not-a-credential',
status: 'disabled',
},
});
for (const [id, app, tenant] of [
['s1', 'app1', 't1'],
['s2', 'app1', 't1'],
['s3', 'app2', 't1'],
['s4', null, 't1'],
['s5', null, 't2'],
]) {
await db.smsSignature.create({
data: { id, tenantId: tenant, applicationId: app, name: id, auditStatus: 'approved' },
});
await db.signatureRetirementDetection.create({
data: {
id: `d-${id}`,
detectionDate: new Date(date),
dimensionType: 'enterprise',
tenantId: tenant,
applicationId: app,
signatureId: id,
carrier: 'mobile',
windowDays: 30,
threshold: 1,
approvedAt: new Date('2026-01-01'),
status: 'alert',
cycleId: `cycle-${id}`,
notificationTitle: '预警',
notificationContent: `冻结正文-${id}`,
},
});
}
const concurrent = await Promise.all([service.publishNotifications(date), peer.publishNotifications(date)]);
assert.equal(
concurrent.reduce((sum, item) => sum + item.created, 0),
4,
);
assert.equal(await db.signatureRetirementMessage.count(), 4);
assert.equal((await service.publishNotifications(date)).created, 0);
const grouped = await db.signatureRetirementMessage.findFirstOrThrow({ where: { applicationId: 'app1' } });
assert.deepEqual(grouped.detectionIds, ['d-s1', 'd-s2']);
assert.equal(grouped.content, '冻结正文-s1\n冻结正文-s2');
checks.push('two-instance daily uniqueness, app/tenant isolation, frozen contents, repeat idempotency');
const page = await service.listMessages({ dateFrom: date, dateTo: date, signatureKeyword: 's2' });
assert.equal(page.total, 1);
assert.equal(page.items[0].id, grouped.id);
assert.equal(page.items[0].detections.length, 2);
const unreadBefore = (await service.unreadCount()).count;
await service.markRead(grouped.id);
assert.equal((await service.unreadCount()).count, unreadBefore - 1);
checks.push('secondary member search, message pagination and deduplicated unread count');
await service.suppressMessage(grouped.id, { mode: 'temporary', days: 7, reason: 'isolated verification' });
assert.equal(await db.signatureRetirementSuppression.count(), 2);
assert.equal((await db.signatureRetirementMessage.findUniqueOrThrow({ where: { id: grouped.id } })).suppressed, true);
checks.push('atomic group suppression');
// Real database scale fixture, distinct from live business data and from the daily-message test above.
await admin.query(`INSERT INTO "${schema}"."SignatureRetirementDetection" (id,"detectionDate","dimensionType","tenantId","applicationId","signatureId","channelKey",carrier,"windowDays",threshold,"approvedAt",status)
SELECT 'scale-'||n, DATE '2026-09-08','channel','t1','app1','s1','scale-'||n,'mobile',30,1,DATE '2026-01-01','healthy' FROM generate_series(1,19783) n`);
const baseline = process.memoryUsage();
const started = Date.now();
const heatmap = await service.heatmap(date);
assert.equal(heatmap.items.length, 19788);
assert.ok(heatmap.items.every((item) => item.activityDate === '2026-09-07'));
const after = process.memoryUsage();
if (global.gc) global.gc();
const afterGc = process.memoryUsage();
assert.ok(after.rss - baseline.rss < 256 * 1024 ** 2, 'Heatmap RSS growth exceeds 256 MiB');
checks.push('19788 PostgreSQL rows through actual heatmap service, T-1 and bounded RSS');
const finalCount = (await admin.query('SELECT count(*)::int AS count FROM public."SmsMessageRecord"')).rows[0].count;
assert.equal(finalCount, before);
console.log(
JSON.stringify({
schema,
checks,
baseline,
after,
afterGc,
durationMs: Date.now() - started,
publicMessages: finalCount,
smsCalls: 0,
lifecycleStarted: false,
}),
);
} finally {
await Promise.all(clients.map((client) => client.$disconnect()));
// Keep the isolated schema for audit; no cleanup of existing schemas or business records.
await admin.end();
}