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 {
dailyGroupKey String? @unique
notificationDate DateTime? @db.Date
applicationId String?
detectionIds String[] @default([])
id String @id @default(cuid())
detectionId String @unique
cycleId String
+12 -73
View File
@@ -1,24 +1,9 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateChannelDto,
UpdateChannelDto,
CreateChannelGroupDto,
CreateChannelGroupItemDto,
UpdateChannelGroupDto,
CreateRouteRuleDto,
CreateReportFieldDto,
ReplaceReportFieldsDto,
CreateReportMaterialDto,
@@ -26,73 +11,19 @@ import type {
ChangeReportTaskStatusesDto,
CreateReportExportDto,
CreateReceiptImportDto,
UpsertConnectionStateDto,
ChangeChannelStatusDto,
CopyChannelDto,
TestChannelDto,
} from './channels.contracts';
import {
GATEWAY_CONNECTION_QUEUE,
GATEWAY_SUBMIT_QUEUE,
GATEWAY_SUBMIT_STREAM,
DEFAULT_GATEWAY_CONTROL_URL,
DEFAULT_CHANNEL_CONNECTION_ID,
DEFAULT_CONNECTING_TIMEOUT_MS,
DEFAULT_CONNECTING_TIMEOUT_SCAN_MS,
DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS,
DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS,
DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS,
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
HEARTBEAT_AUDIT_INTERVAL_MS,
CONNECTING_TIMEOUT_ERROR,
DEFAULT_CMPP_VERSION,
normalizeTestPhones,
normalizeTestContent,
calculateBillingUnits,
buildChannelTestSubmitCommand,
getConfigValue,
getStringConfigValue,
normalizeConnectionAction,
normalizeCmppVersion,
normalizeGatewayConnectionStatus,
defaultChannelConnectionId,
getDesiredConnections,
ChannelConnectionSettings,
getRuntimeConfigInteger,
channelConnectionSettingsChanged,
channelGroupAuditSnapshot,
normalizeChannelRuntimeConfig,
normalizeCmppServiceId,
normalizeChannelRateLimit,
normalizeExtensionDigits,
getPositiveRuntimeInteger,
bullmqConnection,
getPositiveIntegerEnv,
parseReceiptContent,
splitReceiptLine,
stripReceiptCell,
findReceiptStatusIndex,
normalizeReceiptStatus,
deriveReceiptStatus,
ChannelReportDeliveryRow,
summarizeChannelReportDelivery,
sumReportDelivery,
percentage,
latestDate,
currentShanghaiDayRange,
normalizeRetryTimeLimitMinutes,
normalizeSpreadsheetSize,
normalizeBusinessCarrier,
normalizeChannelCarrier,
normalizeChannelCarriers,
isChannelCarrierCompatible,
normalizeRegion,
isRegionCompatible,
validateGroupItems,
normalizeReportType,
summarizeReportStatuses,
normalizeLinkEvent,
} from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
@@ -155,6 +86,9 @@ export class ChannelReportingService {
for (const legacy of legacyBoth) {
if (oppositeCodes.has(legacy.code)) continue;
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
void _id;
void _createdAt;
void _updatedAt;
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
}
for (const [index, configured] of data.fields.entries()) {
@@ -225,7 +159,12 @@ export class ChannelReportingService {
const tasks = await this.prisma.channelSignatureReportTask.findMany({
where: {
tenantId,
status,
status:
status === 'reporting' || status === 'exporting'
? { in: ['reporting', 'exporting'] }
: status === 'failed'
? { in: ['failed', 'rejected'] }
: status,
channelId,
reportType,
signature: { auditStatus: { not: 'deleted' } },
@@ -1,8 +1,15 @@
import { BadRequestException, ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { execFile } from 'node:child_process';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { dirname } from 'node:path';
import { promisify } from 'node:util';
import { PrismaService } from '../prisma/prisma.service';
@@ -12,16 +19,148 @@ import type { InfrastructureAlertSettings, InfrastructureAlertThresholds } from
const execFileAsync = promisify(execFile);
export const ALERT_THRESHOLD_DEFINITIONS = [
{ key: 'hostCpu', label: '主机 CPU 使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], service: 'host', durations: ['10m', '5m'] },
{ key: 'hostMemory', label: '主机内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 85, critical: 95, expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], service: 'host', durations: ['10m', '5m'] },
{ key: 'hostDisk', label: '磁盘(独立文件系统)使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: FILESYSTEM_USAGE_PERCENT, names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] },
{ key: 'apiError', label: 'API 5xx 错误率', unit: '%', min: 0.1, max: 100, step: 0.1, warning: 1, critical: 5, expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], service: 'api', durations: ['5m', '5m'] },
{ key: 'apiLatency', label: 'API P95 响应时间', unit: '秒', min: 0.1, max: 60, step: 0.1, warning: 1, critical: 3, expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], service: 'api', durations: ['10m', '5m'] },
{ key: 'apiEventLoop', label: 'API 事件循环 P99', unit: '秒', min: 0.01, max: 10, step: 0.01, warning: 0.2, critical: 1, expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], service: 'api', durations: ['10m', '5m'] },
{ key: 'gatewayQueue', label: 'Gateway 最旧 pending', unit: '秒', min: 1, max: 3600, step: 1, warning: 30, critical: 120, expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds', names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'], service: 'gateway', durations: ['2m', '2m'] },
{ key: 'postgresConnections', label: 'PostgreSQL 连接使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)', names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'], service: 'postgresql', durations: ['10m', '5m'] },
{ key: 'redisMemory', label: 'Redis 内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes', guard: 'redis_memory_max_bytes > 0', names: ['RedisMemoryWarning', 'RedisMemoryCritical'], service: 'redis', durations: ['10m', '5m'] },
{ key: 'minioCapacity', label: 'MinIO 容量使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)', names: ['MinioCapacityWarning', 'MinioCapacityCritical'], service: 'minio', durations: ['15m', '5m'] },
{
key: 'hostCpu',
label: '主机 CPU 使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 80,
critical: 90,
expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'],
service: 'host',
durations: ['10m', '5m'],
},
{
key: 'hostMemory',
label: '主机内存使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 85,
critical: 95,
expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100',
names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'],
service: 'host',
durations: ['10m', '5m'],
},
{
key: 'hostDisk',
label: '磁盘(独立文件系统)使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 80,
critical: 90,
expr: FILESYSTEM_USAGE_PERCENT,
names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'],
service: 'host',
durations: ['15m', '5m'],
},
{
key: 'apiError',
label: 'API 5xx 错误率',
unit: '%',
min: 0.1,
max: 100,
step: 0.1,
warning: 1,
critical: 5,
expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)',
guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5',
names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'],
service: 'api',
durations: ['5m', '5m'],
},
{
key: 'apiLatency',
label: 'API P95 响应时间',
unit: '秒',
min: 0.1,
max: 60,
step: 0.1,
warning: 1,
critical: 3,
expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))',
names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'],
service: 'api',
durations: ['10m', '5m'],
},
{
key: 'apiEventLoop',
label: 'API 事件循环 P99',
unit: '秒',
min: 0.01,
max: 10,
step: 0.01,
warning: 0.2,
critical: 1,
expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds',
names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'],
service: 'api',
durations: ['10m', '5m'],
},
{
key: 'gatewayQueue',
label: 'Gateway 最旧 pending',
unit: '秒',
min: 1,
max: 3600,
step: 1,
warning: 30,
critical: 120,
expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds',
names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'],
service: 'gateway',
durations: ['2m', '2m'],
},
{
key: 'postgresConnections',
label: 'PostgreSQL 连接使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 70,
critical: 85,
expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)',
names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'],
service: 'postgresql',
durations: ['10m', '5m'],
},
{
key: 'redisMemory',
label: 'Redis 内存使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 70,
critical: 85,
expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes',
guard: 'redis_memory_max_bytes > 0',
names: ['RedisMemoryWarning', 'RedisMemoryCritical'],
service: 'redis',
durations: ['10m', '5m'],
},
{
key: 'minioCapacity',
label: 'MinIO 容量使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 80,
critical: 90,
expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)',
names: ['MinioCapacityWarning', 'MinioCapacityCritical'],
service: 'minio',
durations: ['15m', '5m'],
},
] as const;
export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries(
@@ -32,12 +171,17 @@ export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fr
export class InfrastructureAlertSettingsService {
private readonly logger = new Logger(InfrastructureAlertSettingsService.name);
private readonly rulesPath: string;
private readonly promtoolPath: string;
private readonly promtoolPath: string | undefined;
private readonly reloadUrl: string;
constructor(private readonly prisma: PrismaService, config: ConfigService) {
this.rulesPath = String(config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml');
this.promtoolPath = String(config.get('PROMTOOL_PATH') ?? '/usr/bin/promtool');
constructor(
private readonly prisma: PrismaService,
config: ConfigService,
) {
this.rulesPath = String(
config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml',
);
this.promtoolPath = config.get<string>('PROMTOOL_PATH');
this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload');
}
@@ -53,7 +197,14 @@ export class InfrastructureAlertSettingsService {
appliedAt: row?.appliedAt?.toISOString() ?? null,
thresholds,
effectiveThresholds: effective,
definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({ key, label, unit, min, max, step })),
definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({
key,
label,
unit,
min,
max,
step,
})),
};
}
@@ -63,7 +214,13 @@ export class InfrastructureAlertSettingsService {
const thresholds = this.validate(body.thresholds);
const claimed = await this.prisma.infrastructureAlertSetting.updateMany({
where: { id: 'global', configVersion: expectedVersion },
data: { configVersion: { increment: 1 }, thresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'applying', lastError: null, updatedById: operatorId },
data: {
configVersion: { increment: 1 },
thresholds: thresholds as Prisma.InputJsonValue,
applyStatus: 'applying',
lastError: null,
updatedById: operatorId,
},
});
// 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。
if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试');
@@ -71,12 +228,32 @@ export class InfrastructureAlertSettingsService {
try {
await this.applyRules(thresholds);
await this.prisma.$transaction([
this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { effectiveVersion: nextVersion, effectiveThresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'effective', lastError: null, appliedAt: new Date() } }),
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'monitoring.alert_thresholds_updated', resource: 'infrastructure_alert_setting', resourceId: 'global', detail: { configVersion: nextVersion, thresholds } } }),
this.prisma.infrastructureAlertSetting.update({
where: { id: 'global' },
data: {
effectiveVersion: nextVersion,
effectiveThresholds: thresholds as Prisma.InputJsonValue,
applyStatus: 'effective',
lastError: null,
appliedAt: new Date(),
},
}),
this.prisma.operationLog.create({
data: {
userId: operatorId,
action: 'monitoring.alert_thresholds_updated',
resource: 'infrastructure_alert_setting',
resourceId: 'global',
detail: { configVersion: nextVersion, thresholds },
},
}),
]);
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error';
await this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { applyStatus: 'failed', lastError: message } });
await this.prisma.infrastructureAlertSetting.update({
where: { id: 'global' },
data: { applyStatus: 'failed', lastError: message },
});
this.logger.error(`Prometheus managed rules apply failed: ${message}`);
throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留');
}
@@ -86,13 +263,20 @@ export class InfrastructureAlertSettingsService {
private validate(value: unknown): InfrastructureAlertThresholds {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效');
const input = value as Record<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 = {};
for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined;
const warning = Number(pair?.warning);
const critical = Number(pair?.critical);
if (!Number.isFinite(warning) || !Number.isFinite(critical) || warning < definition.min || critical > definition.max || warning >= critical) {
if (
!Number.isFinite(warning) ||
!Number.isFinite(critical) ||
warning < definition.min ||
critical > definition.max ||
warning >= critical
) {
throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`);
}
result[definition.key] = { warning, critical };
@@ -101,7 +285,11 @@ export class InfrastructureAlertSettingsService {
}
private asThresholds(value: unknown) {
try { return this.validate(value); } catch { return null; }
try {
return this.validate(value);
} catch {
return null;
}
}
private renderRules(thresholds: InfrastructureAlertThresholds) {
@@ -113,9 +301,26 @@ export class InfrastructureAlertSettingsService {
const isWarning = index === 0;
// 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。
const guard = 'guard' in definition ? ` and (${definition.guard})` : '';
const expr = isWarning ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` : `(${definition.expr} > ${values[1]})${guard}`;
const diskLocation = definition.key === 'hostDisk' ? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。' : '';
lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}${diskLocation}"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`);
const expr = isWarning
? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}`
: `(${definition.expr} > ${values[1]})${guard}`;
const diskLocation =
definition.key === 'hostDisk'
? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。'
: '';
lines.push(
` - alert: ${definition.names[index]}`,
` expr: ${expr}`,
` for: ${definition.durations[index]}`,
' labels:',
` severity: ${isWarning ? 'warning' : 'critical'}`,
` service: ${definition.service}`,
' annotations:',
` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`,
` description: "${definition.label}持续超过${values[index]}${definition.unit}${diskLocation}"`,
' currentValue: "{{ $value }}"',
` threshold: "${values[index]}${definition.unit}"`,
);
}
}
return `${lines.join('\n')}\n`;
@@ -128,7 +333,9 @@ export class InfrastructureAlertSettingsService {
const previous = await readFile(this.rulesPath).catch(() => null);
try {
await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 });
await execFileAsync(this.promtoolPath, ['check', 'rules', temporary], { timeout: 10_000 });
await execFileAsync(await resolvePromtoolPath(this.promtoolPath), ['check', 'rules', temporary], {
timeout: 10_000,
});
await rename(temporary, this.rulesPath);
const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) });
if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`);
@@ -144,3 +351,17 @@ export class InfrastructureAlertSettingsService {
}
}
}
// Explicit configuration is authoritative; never silently replace a broken configured binary.
export async function resolvePromtoolPath(configured?: string) {
const candidates = configured ? [configured] : ['/usr/local/bin/promtool', '/usr/bin/promtool'];
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK);
return candidate;
} catch {
/* Try the next standard install location. */
}
}
throw new Error('promtool不可执行,请检查PROMTOOL_PATH或标准安装目录');
}
@@ -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);
});
});
File diff suppressed because it is too large Load Diff
+158 -123
View File
@@ -1,16 +1,14 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
import type { SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, qualityBusinessDay, normalizeGroupBy, positiveInteger } from '../operations.helpers';
// R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsQualityQueries {
constructor(private readonly prisma: PrismaService) {}
async statistics(query: { tenantId?: string; groupBy?: string }) {
async statistics(query: { tenantId?: string; groupBy?: string }) {
const groupBy = normalizeGroupBy(query.groupBy);
if (groupBy === 'tenantId') {
return this.prisma.smsMessageRecord.groupBy({
@@ -35,24 +33,26 @@ async statistics(query: { tenantId?: string; groupBy?: string }) {
_sum: { amountCents: true, billingUnits: true },
});
}
async sendQuality(date?: string) {
async sendQuality(date?: string) {
const day = qualityBusinessDay(date);
const [channels, signatureSplits, summaryRows, applications] = await Promise.all([
this.prisma.$queryRaw<Array<{
channelId: string;
channelName: string;
total: number;
acceptedCount: number;
submitFailureCount: number;
submitFailureRate: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
unknownRate: number;
failureRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
this.prisma.$queryRaw<
Array<{
channelId: string;
channelName: string;
total: number;
acceptedCount: number;
submitFailureCount: number;
submitFailureRate: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
unknownRate: number;
failureRate: number;
averageArrivalMs: number | null;
}>
>(Prisma.sql`
WITH base AS (
SELECT
submit."channelId" AS channel_id,
@@ -131,22 +131,24 @@ async sendQuality(date?: string) {
GROUP BY channel_id
ORDER BY COUNT(*) DESC, channel_id
`),
this.prisma.$queryRaw<Array<{
id: string;
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
hasDrainage: boolean;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
this.prisma.$queryRaw<
Array<{
id: string;
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
hasDrainage: boolean;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
}>
>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
@@ -216,13 +218,15 @@ async sendQuality(date?: string) {
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
ORDER BY "successCount" DESC, total DESC, signature.name
`),
this.prisma.$queryRaw<Array<{
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
}>>(Prisma.sql`
this.prisma.$queryRaw<
Array<{
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
}>
>(Prisma.sql`
WITH base AS (
SELECT message.status, message."receiptStatus" AS receipt_status
FROM "SmsMessageRecord" message
@@ -251,17 +255,19 @@ async sendQuality(date?: string) {
END AS "successRate"
FROM base
`),
this.prisma.$queryRaw<Array<{
applicationId: string;
applicationName: string;
tenantId: string;
tenantName: string;
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
}>>(Prisma.sql`
this.prisma.$queryRaw<
Array<{
applicationId: string;
applicationName: string;
tenantId: string;
tenantName: string;
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
}>
>(Prisma.sql`
WITH base AS (
SELECT
message."applicationId" AS application_id,
@@ -314,28 +320,30 @@ async sendQuality(date?: string) {
};
return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
}
async signatureQuality(query: SignatureQualityQuery) {
async signatureQuality(query: SignatureQualityQuery) {
const day = qualityBusinessDay(query.date);
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
const pageSize = Math.min(100, positiveInteger(query.pageSize, 25));
const keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null;
const summaries = await this.prisma.$queryRaw<Array<{
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
applicationNames: string | null;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
rowCount: number;
}>>(Prisma.sql`
const summaries = await this.prisma.$queryRaw<
Array<{
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
applicationNames: string | null;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
rowCount: number;
}>
>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
@@ -415,23 +423,26 @@ async signatureQuality(query: SignatureQualityQuery) {
OFFSET ${(page - 1) * pageSize}
`);
const signatureIds = summaries.map((item) => item.signatureId);
const drainageBreakdowns = signatureIds.length === 0
? []
: await this.prisma.$queryRaw<Array<{
signatureId: string;
channelId: string;
channelName: string;
carrier: string;
drainageState: 'with' | 'without' | 'unknown';
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
const drainageBreakdowns =
signatureIds.length === 0
? []
: await this.prisma.$queryRaw<
Array<{
signatureId: string;
channelId: string;
channelName: string;
carrier: string;
drainageState: 'with' | 'without' | 'unknown';
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
}>
>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
@@ -526,16 +537,19 @@ async signatureQuality(query: SignatureQualityQuery) {
GROUP BY signature_id, channel_id, carrier, drainage_state
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state
`);
const carrierOverview = signatureIds.length === 0
? []
: await this.prisma.$queryRaw<Array<{
signatureId: string;
carrier: string;
businessMessageCount: number;
finalSuccessCount: number;
finalSuccessRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
const carrierOverview =
signatureIds.length === 0
? []
: await this.prisma.$queryRaw<
Array<{
signatureId: string;
carrier: string;
businessMessageCount: number;
finalSuccessCount: number;
finalSuccessRate: number;
averageArrivalMs: number | null;
}>
>(Prisma.sql`
SELECT
message."signatureId" AS "signatureId",
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
@@ -570,6 +584,7 @@ async signatureQuality(query: SignatureQualityQuery) {
ORDER BY message."signatureId", COUNT(*) DESC, carrier
`);
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
void _rowCount;
const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId);
const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns);
return {
@@ -610,26 +625,41 @@ type SignatureSplitRow = {
function aggregateSignatureRows(rows: SignatureSplitRow[]) {
const grouped = new Map<string, SignatureSplitRow[]>();
rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row]));
return [...grouped.values()].map((parts) => {
const first = parts[0];
const total = parts.reduce((sum, item) => sum + item.total, 0);
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
return {
...first,
id: first.signatureId,
hasDrainage: false,
total,
acceptedCount,
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
};
}).sort((left, right) => right.successCount - left.successCount || right.total - left.total || left.signatureName.localeCompare(right.signatureName));
return [...grouped.values()]
.map((parts) => {
const first = parts[0];
const total = parts.reduce((sum, item) => sum + item.total, 0);
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce(
(sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount),
0,
);
return {
...first,
id: first.signatureId,
hasDrainage: false,
total,
acceptedCount,
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round((successCount * 1000) / acceptedCount) / 10,
averageArrivalMs:
arrivalWeight === 0
? null
: Math.round(
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
),
};
})
.sort(
(left, right) =>
right.successCount - left.successCount ||
right.total - left.total ||
left.signatureName.localeCompare(right.signatureName),
);
}
type DrainageBreakdownRow = {
@@ -670,8 +700,13 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
successRate: acceptedCount === 0 ? 0 : Math.round((successCount * 1000) / acceptedCount) / 10,
averageArrivalMs:
arrivalWeight === 0
? null
: Math.round(
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
),
};
});
}
@@ -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'),
];
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }> }).buildDimensions(rules, tasks);
const dimensions = (
service as unknown as {
buildDimensions: (
inputRules: unknown[],
inputTasks: unknown[],
) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }>;
}
).buildDimensions(rules, tasks);
expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2);
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3);
expect(dimensions.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')?.approvedAt.toISOString()).toBe('2026-06-01T00:00:00.000Z');
expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true);
expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel');
expect(
dimensions
.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')
?.approvedAt.toISOString(),
).toBe('2026-06-01T00:00:00.000Z');
expect(
dimensions
.filter((item) => item.dimensionType === 'enterprise')
.every((item) => item.rule.ruleType === 'enterprise_application'),
).toBe(true);
expect(
dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType,
).toBe('channel');
});
it('does not monitor legacy carrier-null reporting facts', () => {
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }).buildDimensions(
const dimensions = (
service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }
).buildDimensions(
[rule('enterprise_global', ''), rule('channel_global', '')],
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')],
);
@@ -41,73 +60,159 @@ describe('SignatureRetirementService dimensions', () => {
it('publishes frozen alert content only in the notification phase', async () => {
const detection = {
id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1',
cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文',
id: 'detection-1',
detectionDate: new Date('2026-08-10T00:00:00.000Z'),
dimensionType: 'enterprise',
tenantId: 'tenant-1',
cycleId: 'cycle-1',
notificationTitle: '企业签名清退预警',
notificationContent: '冻结后的预警正文',
};
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) },
signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) },
signatureRetirementDetection: {
findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]),
},
smsApplication: { findMany: jest.fn().mockResolvedValue([]) },
signatureRetirementMessage: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 'message-1' }),
findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]),
},
signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
signatureRetirementWebhookDelivery: { upsert: jest.fn() },
};
const notificationService = new SignatureRetirementService(prisma as never);
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 });
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) });
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({
notificationDate: '2026-08-10',
created: 1,
});
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({
data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }),
});
});
it('returns enterprise application metadata for heatmap hover and search', async () => {
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([{ id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00Z'), signatureId: 'signature-1', channelId: null, tenantId: 'tenant-1' }]) },
signatureRetirementDetection: {
findMany: jest.fn().mockResolvedValue([
{
id: 'detection-1',
detectionDate: new Date('2026-08-10T00:00:00Z'),
signatureId: 'signature-1',
channelId: null,
tenantId: 'tenant-1',
},
]),
},
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
tenant: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{
signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'),
signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } },
channel: { name: '移动通道' },
}]) },
channelSignatureReportTask: {
findMany: jest.fn().mockResolvedValue([
{
signatureId: 'signature-1',
channelId: 'channel-1',
carrier: 'mobile',
approvedAt: new Date('2026-06-01T00:00:00Z'),
signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } },
channel: { name: '移动通道' },
},
]),
},
};
const heatmapService = new SignatureRetirementService(prisma as never);
const result = await heatmapService.heatmap('2026-08-10');
expect(result.dimensions).toEqual(expect.arrayContaining([
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
]));
expect(result.dimensions).toEqual(
expect.arrayContaining([
expect.objectContaining({
dimensionType: 'enterprise',
signatureName: '测试签名',
applicationName: '测试应用',
}),
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
]),
);
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
});
it('persists daily observing snapshots without opening alert cycles', async () => {
const prisma = {
signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) },
signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) },
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
signatureRetirementSuppression: {
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
findUnique: jest.fn().mockResolvedValue(null),
},
signatureRetirementRule: {
findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]),
},
channelSignatureReportTask: {
findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]),
},
signatureRetirementDetection: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 'detection-1' }),
},
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
$queryRaw: jest.fn().mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
$queryRaw: jest
.fn()
.mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
};
const observingService = new SignatureRetirementService(prisma as never);
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ detectionDate: '2026-08-10', dimensions: 2, alerted: 0, healthy: 0, ineligible: 2 });
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({
detectionDate: '2026-08-10',
dimensions: 2,
alerted: 0,
healthy: 0,
ineligible: 2,
});
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'observing', acceptedBusinessCount: 10, cycleId: undefined, notificationTitle: null, notificationContent: null }) });
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({
data: expect.objectContaining({
status: 'observing',
acceptedBusinessCount: 10,
cycleId: undefined,
notificationTitle: null,
notificationContent: null,
}),
});
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
});
it('maps the real unreported-signature aggregation to an independent page', async () => {
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{
signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业',
applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3,
}]),
$queryRaw: jest.fn().mockResolvedValue([
{
signatureId: 'signature-1',
signatureName: '未报备签名',
tenantId: 'tenant-1',
tenantName: '测试企业',
applicationId: 'app-1',
applicationName: '测试应用',
messageCount: 7,
rowCount: 3,
},
]),
};
const unreportedService = new SignatureRetirementService(prisma as never);
await expect(unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 })).resolves.toEqual({
await expect(
unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 }),
).resolves.toEqual({
date: '2026-08-10',
items: [{ signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', applicationId: 'app-1', applicationName: '测试应用', messageCount: 7 }],
items: [
{
signatureId: 'signature-1',
signatureName: '未报备签名',
tenantId: 'tenant-1',
tenantName: '测试企业',
applicationId: 'app-1',
applicationName: '测试应用',
messageCount: 7,
},
],
total: 3,
page: 2,
pageSize: 10,
@@ -124,7 +229,14 @@ describe('SignatureRetirementService dimensions', () => {
it('returns a filtered historical message page with application metadata', async () => {
const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') };
const detection = { id: 'detection-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile' };
const detection = {
id: 'detection-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureId: 'signature-1',
channelId: 'channel-1',
carrier: 'mobile',
};
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]),
signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) },
@@ -136,8 +248,27 @@ describe('SignatureRetirementService dimensions', () => {
};
const messageService = new SignatureRetirementService(prisma as never);
await expect(messageService.listMessages({ dateFrom: '2026-08-01', dateTo: '2026-08-10', tenantId: 'tenant-1', applicationId: 'app-1', signatureKeyword: '测试', channelId: 'channel-1', page: 2, pageSize: 10 })).resolves.toEqual({
items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })],
await expect(
messageService.listMessages({
dateFrom: '2026-08-01',
dateTo: '2026-08-10',
tenantId: 'tenant-1',
applicationId: 'app-1',
signatureKeyword: '测试',
channelId: 'channel-1',
page: 2,
pageSize: 10,
}),
).resolves.toEqual({
items: [
expect.objectContaining({
id: 'message-1',
tenantName: '测试企业',
applicationName: '测试应用',
signatureName: '测试签名',
channelName: '测试通道',
}),
],
total: 21,
page: 2,
pageSize: 10,
@@ -160,22 +291,52 @@ describe('SignatureRetirementService dimensions', () => {
it('requires a reason for temporary and permanent suppression', async () => {
const prisma = {
signatureRetirementMessage: {
findUnique: jest.fn().mockResolvedValue({ id: 'message-1', cycleId: 'cycle-1', detectionId: 'detection-1', createdAt: new Date() }),
findUnique: jest.fn().mockResolvedValue({
id: 'message-1',
cycleId: 'cycle-1',
detectionId: 'detection-1',
createdAt: new Date(),
}),
findFirst: jest.fn().mockResolvedValue(null),
},
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
};
const suppressionService = new SignatureRetirementService(prisma as never);
await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空');
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空');
await expect(
suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' }),
).rejects.toThrow('抑制原因不能为空');
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow(
'抑制原因不能为空',
);
});
});
function rule(ruleType: string, targetKey: string) {
return { id: `${ruleType}-${targetKey}`, ruleType, targetId: targetKey || null, targetKey, enabled: true, mobileWindowDays: 30, mobileThreshold: 1, unicomWindowDays: 30, unicomThreshold: 1, telecomWindowDays: 30, telecomThreshold: 1, messageTemplate: null, version: 1 };
return {
id: `${ruleType}-${targetKey}`,
ruleType,
targetId: targetKey || null,
targetKey,
enabled: true,
mobileWindowDays: 30,
mobileThreshold: 1,
unicomWindowDays: 30,
unicomThreshold: 1,
telecomWindowDays: 30,
telecomThreshold: 1,
messageTemplate: null,
version: 1,
};
}
function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) {
return { signatureId: 'signature-1', channelId, carrier, approvedAt: new Date(approvedAt), signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } }, channel: { name: channelName } };
return {
signatureId: 'signature-1',
channelId,
carrier,
approvedAt: new Date(approvedAt),
signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } },
channel: { name: channelName },
};
}
File diff suppressed because it is too large Load Diff