fix: bound formatter memory and improve operations workflows
This commit is contained in:
+7
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user