Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb185b22b | ||
|
|
2a9d03be2e | ||
|
|
6d3c78330d | ||
|
|
2c228a94e1 |
+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 {
|
model SignatureRetirementMessage {
|
||||||
|
dailyGroupKey String? @unique
|
||||||
|
notificationDate DateTime? @db.Date
|
||||||
|
applicationId String?
|
||||||
|
detectionIds String[] @default([])
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
detectionId String @unique
|
detectionId String @unique
|
||||||
cycleId String
|
cycleId String
|
||||||
|
|||||||
@@ -1,24 +1,9 @@
|
|||||||
import {
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
OnModuleDestroy,
|
|
||||||
OnModuleInit,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import IORedis from 'ioredis';
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type {
|
import type {
|
||||||
CreateChannelDto,
|
|
||||||
UpdateChannelDto,
|
|
||||||
CreateChannelGroupDto,
|
|
||||||
CreateChannelGroupItemDto,
|
|
||||||
UpdateChannelGroupDto,
|
|
||||||
CreateRouteRuleDto,
|
|
||||||
CreateReportFieldDto,
|
CreateReportFieldDto,
|
||||||
ReplaceReportFieldsDto,
|
ReplaceReportFieldsDto,
|
||||||
CreateReportMaterialDto,
|
CreateReportMaterialDto,
|
||||||
@@ -26,73 +11,19 @@ import type {
|
|||||||
ChangeReportTaskStatusesDto,
|
ChangeReportTaskStatusesDto,
|
||||||
CreateReportExportDto,
|
CreateReportExportDto,
|
||||||
CreateReceiptImportDto,
|
CreateReceiptImportDto,
|
||||||
UpsertConnectionStateDto,
|
|
||||||
ChangeChannelStatusDto,
|
|
||||||
CopyChannelDto,
|
|
||||||
TestChannelDto,
|
|
||||||
} from './channels.contracts';
|
} from './channels.contracts';
|
||||||
import {
|
import {
|
||||||
GATEWAY_CONNECTION_QUEUE,
|
|
||||||
GATEWAY_SUBMIT_QUEUE,
|
|
||||||
GATEWAY_SUBMIT_STREAM,
|
|
||||||
DEFAULT_GATEWAY_CONTROL_URL,
|
|
||||||
DEFAULT_CHANNEL_CONNECTION_ID,
|
|
||||||
DEFAULT_CONNECTING_TIMEOUT_MS,
|
|
||||||
DEFAULT_CONNECTING_TIMEOUT_SCAN_MS,
|
|
||||||
DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS,
|
|
||||||
DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS,
|
|
||||||
DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS,
|
|
||||||
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
|
||||||
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
|
|
||||||
HEARTBEAT_AUDIT_INTERVAL_MS,
|
|
||||||
CONNECTING_TIMEOUT_ERROR,
|
|
||||||
DEFAULT_CMPP_VERSION,
|
|
||||||
normalizeTestPhones,
|
|
||||||
normalizeTestContent,
|
|
||||||
calculateBillingUnits,
|
|
||||||
buildChannelTestSubmitCommand,
|
|
||||||
getConfigValue,
|
|
||||||
getStringConfigValue,
|
|
||||||
normalizeConnectionAction,
|
|
||||||
normalizeCmppVersion,
|
|
||||||
normalizeGatewayConnectionStatus,
|
|
||||||
defaultChannelConnectionId,
|
|
||||||
getDesiredConnections,
|
|
||||||
ChannelConnectionSettings,
|
|
||||||
getRuntimeConfigInteger,
|
|
||||||
channelConnectionSettingsChanged,
|
|
||||||
channelGroupAuditSnapshot,
|
|
||||||
normalizeChannelRuntimeConfig,
|
|
||||||
normalizeCmppServiceId,
|
|
||||||
normalizeChannelRateLimit,
|
|
||||||
normalizeExtensionDigits,
|
|
||||||
getPositiveRuntimeInteger,
|
|
||||||
bullmqConnection,
|
|
||||||
getPositiveIntegerEnv,
|
|
||||||
parseReceiptContent,
|
parseReceiptContent,
|
||||||
splitReceiptLine,
|
|
||||||
stripReceiptCell,
|
|
||||||
findReceiptStatusIndex,
|
|
||||||
normalizeReceiptStatus,
|
|
||||||
deriveReceiptStatus,
|
deriveReceiptStatus,
|
||||||
ChannelReportDeliveryRow,
|
ChannelReportDeliveryRow,
|
||||||
summarizeChannelReportDelivery,
|
summarizeChannelReportDelivery,
|
||||||
sumReportDelivery,
|
|
||||||
percentage,
|
|
||||||
latestDate,
|
latestDate,
|
||||||
currentShanghaiDayRange,
|
currentShanghaiDayRange,
|
||||||
normalizeRetryTimeLimitMinutes,
|
|
||||||
normalizeSpreadsheetSize,
|
normalizeSpreadsheetSize,
|
||||||
normalizeBusinessCarrier,
|
normalizeBusinessCarrier,
|
||||||
normalizeChannelCarrier,
|
|
||||||
normalizeChannelCarriers,
|
normalizeChannelCarriers,
|
||||||
isChannelCarrierCompatible,
|
|
||||||
normalizeRegion,
|
|
||||||
isRegionCompatible,
|
|
||||||
validateGroupItems,
|
|
||||||
normalizeReportType,
|
normalizeReportType,
|
||||||
summarizeReportStatuses,
|
summarizeReportStatuses,
|
||||||
normalizeLinkEvent,
|
|
||||||
} from './channels.helpers';
|
} from './channels.helpers';
|
||||||
|
|
||||||
/** R5 channel domain service composed behind ChannelsService. */
|
/** R5 channel domain service composed behind ChannelsService. */
|
||||||
@@ -155,6 +86,9 @@ export class ChannelReportingService {
|
|||||||
for (const legacy of legacyBoth) {
|
for (const legacy of legacyBoth) {
|
||||||
if (oppositeCodes.has(legacy.code)) continue;
|
if (oppositeCodes.has(legacy.code)) continue;
|
||||||
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
|
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
|
||||||
|
void _id;
|
||||||
|
void _createdAt;
|
||||||
|
void _updatedAt;
|
||||||
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
|
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
|
||||||
}
|
}
|
||||||
for (const [index, configured] of data.fields.entries()) {
|
for (const [index, configured] of data.fields.entries()) {
|
||||||
@@ -225,7 +159,12 @@ export class ChannelReportingService {
|
|||||||
const tasks = await this.prisma.channelSignatureReportTask.findMany({
|
const tasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
status,
|
status:
|
||||||
|
status === 'reporting' || status === 'exporting'
|
||||||
|
? { in: ['reporting', 'exporting'] }
|
||||||
|
: status === 'failed'
|
||||||
|
? { in: ['failed', 'rejected'] }
|
||||||
|
: status,
|
||||||
channelId,
|
channelId,
|
||||||
reportType,
|
reportType,
|
||||||
signature: { auditStatus: { not: 'deleted' } },
|
signature: { auditStatus: { not: 'deleted' } },
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -12,16 +19,148 @@ import type { InfrastructureAlertSettings, InfrastructureAlertThresholds } from
|
|||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export const ALERT_THRESHOLD_DEFINITIONS = [
|
export const ALERT_THRESHOLD_DEFINITIONS = [
|
||||||
{ key: 'hostCpu', label: '主机 CPU 使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], service: 'host', durations: ['10m', '5m'] },
|
{
|
||||||
{ key: 'hostMemory', label: '主机内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 85, critical: 95, expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], service: 'host', durations: ['10m', '5m'] },
|
key: 'hostCpu',
|
||||||
{ key: 'hostDisk', label: '磁盘(独立文件系统)使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: FILESYSTEM_USAGE_PERCENT, names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] },
|
label: '主机 CPU 使用率',
|
||||||
{ key: 'apiError', label: 'API 5xx 错误率', unit: '%', min: 0.1, max: 100, step: 0.1, warning: 1, critical: 5, expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], service: 'api', durations: ['5m', '5m'] },
|
unit: '%',
|
||||||
{ key: 'apiLatency', label: 'API P95 响应时间', unit: '秒', min: 0.1, max: 60, step: 0.1, warning: 1, critical: 3, expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], service: 'api', durations: ['10m', '5m'] },
|
min: 1,
|
||||||
{ key: 'apiEventLoop', label: 'API 事件循环 P99', unit: '秒', min: 0.01, max: 10, step: 0.01, warning: 0.2, critical: 1, expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], service: 'api', durations: ['10m', '5m'] },
|
max: 100,
|
||||||
{ key: 'gatewayQueue', label: 'Gateway 最旧 pending', unit: '秒', min: 1, max: 3600, step: 1, warning: 30, critical: 120, expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds', names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'], service: 'gateway', durations: ['2m', '2m'] },
|
step: 1,
|
||||||
{ key: 'postgresConnections', label: 'PostgreSQL 连接使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)', names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'], service: 'postgresql', durations: ['10m', '5m'] },
|
warning: 80,
|
||||||
{ key: 'redisMemory', label: 'Redis 内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes', guard: 'redis_memory_max_bytes > 0', names: ['RedisMemoryWarning', 'RedisMemoryCritical'], service: 'redis', durations: ['10m', '5m'] },
|
critical: 90,
|
||||||
{ key: 'minioCapacity', label: 'MinIO 容量使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)', names: ['MinioCapacityWarning', 'MinioCapacityCritical'], service: 'minio', durations: ['15m', '5m'] },
|
expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
|
||||||
|
names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'],
|
||||||
|
service: 'host',
|
||||||
|
durations: ['10m', '5m'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hostMemory',
|
||||||
|
label: '主机内存使用率',
|
||||||
|
unit: '%',
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
warning: 85,
|
||||||
|
critical: 95,
|
||||||
|
expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100',
|
||||||
|
names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'],
|
||||||
|
service: 'host',
|
||||||
|
durations: ['10m', '5m'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hostDisk',
|
||||||
|
label: '磁盘(独立文件系统)使用率',
|
||||||
|
unit: '%',
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
warning: 80,
|
||||||
|
critical: 90,
|
||||||
|
expr: FILESYSTEM_USAGE_PERCENT,
|
||||||
|
names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'],
|
||||||
|
service: 'host',
|
||||||
|
durations: ['15m', '5m'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'apiError',
|
||||||
|
label: 'API 5xx 错误率',
|
||||||
|
unit: '%',
|
||||||
|
min: 0.1,
|
||||||
|
max: 100,
|
||||||
|
step: 0.1,
|
||||||
|
warning: 1,
|
||||||
|
critical: 5,
|
||||||
|
expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)',
|
||||||
|
guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5',
|
||||||
|
names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'],
|
||||||
|
service: 'api',
|
||||||
|
durations: ['5m', '5m'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'apiLatency',
|
||||||
|
label: 'API P95 响应时间',
|
||||||
|
unit: '秒',
|
||||||
|
min: 0.1,
|
||||||
|
max: 60,
|
||||||
|
step: 0.1,
|
||||||
|
warning: 1,
|
||||||
|
critical: 3,
|
||||||
|
expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))',
|
||||||
|
names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'],
|
||||||
|
service: 'api',
|
||||||
|
durations: ['10m', '5m'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'apiEventLoop',
|
||||||
|
label: 'API 事件循环 P99',
|
||||||
|
unit: '秒',
|
||||||
|
min: 0.01,
|
||||||
|
max: 10,
|
||||||
|
step: 0.01,
|
||||||
|
warning: 0.2,
|
||||||
|
critical: 1,
|
||||||
|
expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds',
|
||||||
|
names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'],
|
||||||
|
service: 'api',
|
||||||
|
durations: ['10m', '5m'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gatewayQueue',
|
||||||
|
label: 'Gateway 最旧 pending',
|
||||||
|
unit: '秒',
|
||||||
|
min: 1,
|
||||||
|
max: 3600,
|
||||||
|
step: 1,
|
||||||
|
warning: 30,
|
||||||
|
critical: 120,
|
||||||
|
expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds',
|
||||||
|
names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'],
|
||||||
|
service: 'gateway',
|
||||||
|
durations: ['2m', '2m'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'postgresConnections',
|
||||||
|
label: 'PostgreSQL 连接使用率',
|
||||||
|
unit: '%',
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
warning: 70,
|
||||||
|
critical: 85,
|
||||||
|
expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)',
|
||||||
|
names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'],
|
||||||
|
service: 'postgresql',
|
||||||
|
durations: ['10m', '5m'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'redisMemory',
|
||||||
|
label: 'Redis 内存使用率',
|
||||||
|
unit: '%',
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
warning: 70,
|
||||||
|
critical: 85,
|
||||||
|
expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes',
|
||||||
|
guard: 'redis_memory_max_bytes > 0',
|
||||||
|
names: ['RedisMemoryWarning', 'RedisMemoryCritical'],
|
||||||
|
service: 'redis',
|
||||||
|
durations: ['10m', '5m'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minioCapacity',
|
||||||
|
label: 'MinIO 容量使用率',
|
||||||
|
unit: '%',
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
warning: 80,
|
||||||
|
critical: 90,
|
||||||
|
expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)',
|
||||||
|
names: ['MinioCapacityWarning', 'MinioCapacityCritical'],
|
||||||
|
service: 'minio',
|
||||||
|
durations: ['15m', '5m'],
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries(
|
export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries(
|
||||||
@@ -32,12 +171,17 @@ export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fr
|
|||||||
export class InfrastructureAlertSettingsService {
|
export class InfrastructureAlertSettingsService {
|
||||||
private readonly logger = new Logger(InfrastructureAlertSettingsService.name);
|
private readonly logger = new Logger(InfrastructureAlertSettingsService.name);
|
||||||
private readonly rulesPath: string;
|
private readonly rulesPath: string;
|
||||||
private readonly promtoolPath: string;
|
private readonly promtoolPath: string | undefined;
|
||||||
private readonly reloadUrl: string;
|
private readonly reloadUrl: string;
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService, config: ConfigService) {
|
constructor(
|
||||||
this.rulesPath = String(config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml');
|
private readonly prisma: PrismaService,
|
||||||
this.promtoolPath = String(config.get('PROMTOOL_PATH') ?? '/usr/bin/promtool');
|
config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.rulesPath = String(
|
||||||
|
config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml',
|
||||||
|
);
|
||||||
|
this.promtoolPath = config.get<string>('PROMTOOL_PATH');
|
||||||
this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload');
|
this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +197,14 @@ export class InfrastructureAlertSettingsService {
|
|||||||
appliedAt: row?.appliedAt?.toISOString() ?? null,
|
appliedAt: row?.appliedAt?.toISOString() ?? null,
|
||||||
thresholds,
|
thresholds,
|
||||||
effectiveThresholds: effective,
|
effectiveThresholds: effective,
|
||||||
definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({ key, label, unit, min, max, step })),
|
definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
unit,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +214,13 @@ export class InfrastructureAlertSettingsService {
|
|||||||
const thresholds = this.validate(body.thresholds);
|
const thresholds = this.validate(body.thresholds);
|
||||||
const claimed = await this.prisma.infrastructureAlertSetting.updateMany({
|
const claimed = await this.prisma.infrastructureAlertSetting.updateMany({
|
||||||
where: { id: 'global', configVersion: expectedVersion },
|
where: { id: 'global', configVersion: expectedVersion },
|
||||||
data: { configVersion: { increment: 1 }, thresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'applying', lastError: null, updatedById: operatorId },
|
data: {
|
||||||
|
configVersion: { increment: 1 },
|
||||||
|
thresholds: thresholds as Prisma.InputJsonValue,
|
||||||
|
applyStatus: 'applying',
|
||||||
|
lastError: null,
|
||||||
|
updatedById: operatorId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。
|
// 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。
|
||||||
if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试');
|
if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试');
|
||||||
@@ -71,12 +228,32 @@ export class InfrastructureAlertSettingsService {
|
|||||||
try {
|
try {
|
||||||
await this.applyRules(thresholds);
|
await this.applyRules(thresholds);
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { effectiveVersion: nextVersion, effectiveThresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'effective', lastError: null, appliedAt: new Date() } }),
|
this.prisma.infrastructureAlertSetting.update({
|
||||||
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'monitoring.alert_thresholds_updated', resource: 'infrastructure_alert_setting', resourceId: 'global', detail: { configVersion: nextVersion, thresholds } } }),
|
where: { id: 'global' },
|
||||||
|
data: {
|
||||||
|
effectiveVersion: nextVersion,
|
||||||
|
effectiveThresholds: thresholds as Prisma.InputJsonValue,
|
||||||
|
applyStatus: 'effective',
|
||||||
|
lastError: null,
|
||||||
|
appliedAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.operationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: operatorId,
|
||||||
|
action: 'monitoring.alert_thresholds_updated',
|
||||||
|
resource: 'infrastructure_alert_setting',
|
||||||
|
resourceId: 'global',
|
||||||
|
detail: { configVersion: nextVersion, thresholds },
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error';
|
const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error';
|
||||||
await this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { applyStatus: 'failed', lastError: message } });
|
await this.prisma.infrastructureAlertSetting.update({
|
||||||
|
where: { id: 'global' },
|
||||||
|
data: { applyStatus: 'failed', lastError: message },
|
||||||
|
});
|
||||||
this.logger.error(`Prometheus managed rules apply failed: ${message}`);
|
this.logger.error(`Prometheus managed rules apply failed: ${message}`);
|
||||||
throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留');
|
throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留');
|
||||||
}
|
}
|
||||||
@@ -86,13 +263,20 @@ export class InfrastructureAlertSettingsService {
|
|||||||
private validate(value: unknown): InfrastructureAlertThresholds {
|
private validate(value: unknown): InfrastructureAlertThresholds {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效');
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效');
|
||||||
const input = value as Record<string, unknown>;
|
const input = value as Record<string, unknown>;
|
||||||
if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key))) throw new BadRequestException('存在不允许配置的告警指标');
|
if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key)))
|
||||||
|
throw new BadRequestException('存在不允许配置的告警指标');
|
||||||
const result: InfrastructureAlertThresholds = {};
|
const result: InfrastructureAlertThresholds = {};
|
||||||
for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
|
for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
|
||||||
const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined;
|
const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined;
|
||||||
const warning = Number(pair?.warning);
|
const warning = Number(pair?.warning);
|
||||||
const critical = Number(pair?.critical);
|
const critical = Number(pair?.critical);
|
||||||
if (!Number.isFinite(warning) || !Number.isFinite(critical) || warning < definition.min || critical > definition.max || warning >= critical) {
|
if (
|
||||||
|
!Number.isFinite(warning) ||
|
||||||
|
!Number.isFinite(critical) ||
|
||||||
|
warning < definition.min ||
|
||||||
|
critical > definition.max ||
|
||||||
|
warning >= critical
|
||||||
|
) {
|
||||||
throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`);
|
throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`);
|
||||||
}
|
}
|
||||||
result[definition.key] = { warning, critical };
|
result[definition.key] = { warning, critical };
|
||||||
@@ -101,7 +285,11 @@ export class InfrastructureAlertSettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private asThresholds(value: unknown) {
|
private asThresholds(value: unknown) {
|
||||||
try { return this.validate(value); } catch { return null; }
|
try {
|
||||||
|
return this.validate(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderRules(thresholds: InfrastructureAlertThresholds) {
|
private renderRules(thresholds: InfrastructureAlertThresholds) {
|
||||||
@@ -113,9 +301,26 @@ export class InfrastructureAlertSettingsService {
|
|||||||
const isWarning = index === 0;
|
const isWarning = index === 0;
|
||||||
// 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。
|
// 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。
|
||||||
const guard = 'guard' in definition ? ` and (${definition.guard})` : '';
|
const guard = 'guard' in definition ? ` and (${definition.guard})` : '';
|
||||||
const expr = isWarning ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` : `(${definition.expr} > ${values[1]})${guard}`;
|
const expr = isWarning
|
||||||
const diskLocation = definition.key === 'hostDisk' ? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。' : '';
|
? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}`
|
||||||
lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}。${diskLocation}"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`);
|
: `(${definition.expr} > ${values[1]})${guard}`;
|
||||||
|
const diskLocation =
|
||||||
|
definition.key === 'hostDisk'
|
||||||
|
? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。'
|
||||||
|
: '';
|
||||||
|
lines.push(
|
||||||
|
` - alert: ${definition.names[index]}`,
|
||||||
|
` expr: ${expr}`,
|
||||||
|
` for: ${definition.durations[index]}`,
|
||||||
|
' labels:',
|
||||||
|
` severity: ${isWarning ? 'warning' : 'critical'}`,
|
||||||
|
` service: ${definition.service}`,
|
||||||
|
' annotations:',
|
||||||
|
` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`,
|
||||||
|
` description: "${definition.label}持续超过${values[index]}${definition.unit}。${diskLocation}"`,
|
||||||
|
' currentValue: "{{ $value }}"',
|
||||||
|
` threshold: "${values[index]}${definition.unit}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `${lines.join('\n')}\n`;
|
return `${lines.join('\n')}\n`;
|
||||||
@@ -128,7 +333,9 @@ export class InfrastructureAlertSettingsService {
|
|||||||
const previous = await readFile(this.rulesPath).catch(() => null);
|
const previous = await readFile(this.rulesPath).catch(() => null);
|
||||||
try {
|
try {
|
||||||
await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 });
|
await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 });
|
||||||
await execFileAsync(this.promtoolPath, ['check', 'rules', temporary], { timeout: 10_000 });
|
await execFileAsync(await resolvePromtoolPath(this.promtoolPath), ['check', 'rules', temporary], {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
await rename(temporary, this.rulesPath);
|
await rename(temporary, this.rulesPath);
|
||||||
const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) });
|
const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) });
|
||||||
if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`);
|
||||||
@@ -144,3 +351,17 @@ export class InfrastructureAlertSettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicit configuration is authoritative; never silently replace a broken configured binary.
|
||||||
|
export async function resolvePromtoolPath(configured?: string) {
|
||||||
|
const candidates = configured ? [configured] : ['/usr/local/bin/promtool', '/usr/bin/promtool'];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
await access(candidate, constants.X_OK);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
/* Try the next standard install location. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('promtool不可执行,请检查PROMTOOL_PATH或标准安装目录');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import { resolvePromtoolPath } from './infrastructure-alert-settings.service';
|
||||||
|
|
||||||
|
jest.mock('node:fs/promises', () => ({ ...jest.requireActual('node:fs/promises'), access: jest.fn() }));
|
||||||
|
|
||||||
|
describe('Prometheus binary resolution', () => {
|
||||||
|
beforeEach(() => jest.resetAllMocks());
|
||||||
|
it('uses the official installer location when available', async () => {
|
||||||
|
const check = jest.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
|
expect(await resolvePromtoolPath()).toBe('/usr/local/bin/promtool');
|
||||||
|
expect(check).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('supports the distribution package location', async () => {
|
||||||
|
jest.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')).mockResolvedValueOnce(undefined);
|
||||||
|
expect(await resolvePromtoolPath()).toBe('/usr/bin/promtool');
|
||||||
|
});
|
||||||
|
it('fails rather than silently overriding an invalid explicitly configured binary', async () => {
|
||||||
|
const check = jest.mocked(fs.access).mockRejectedValue(new Error('EACCES'));
|
||||||
|
await expect(resolvePromtoolPath('/custom/promtool')).rejects.toThrow('promtool不可执行');
|
||||||
|
expect(check).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
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 { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { moneyToNumber } from '../../common/money';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
|
import type { SignatureQualityQuery } from '../operations.contracts';
|
||||||
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
|
import { messageWhere, qualityBusinessDay, normalizeGroupBy, positiveInteger } from '../operations.helpers';
|
||||||
|
|
||||||
// R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
// R2 quality query domain. Method bodies are preserved byte-for-byte from the facade baseline.
|
||||||
export class OperationsQualityQueries {
|
export class OperationsQualityQueries {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async statistics(query: { tenantId?: string; groupBy?: string }) {
|
async statistics(query: { tenantId?: string; groupBy?: string }) {
|
||||||
const groupBy = normalizeGroupBy(query.groupBy);
|
const groupBy = normalizeGroupBy(query.groupBy);
|
||||||
if (groupBy === 'tenantId') {
|
if (groupBy === 'tenantId') {
|
||||||
return this.prisma.smsMessageRecord.groupBy({
|
return this.prisma.smsMessageRecord.groupBy({
|
||||||
@@ -35,24 +33,26 @@ async statistics(query: { tenantId?: string; groupBy?: string }) {
|
|||||||
_sum: { amountCents: true, billingUnits: true },
|
_sum: { amountCents: true, billingUnits: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async sendQuality(date?: string) {
|
async sendQuality(date?: string) {
|
||||||
const day = qualityBusinessDay(date);
|
const day = qualityBusinessDay(date);
|
||||||
const [channels, signatureSplits, summaryRows, applications] = await Promise.all([
|
const [channels, signatureSplits, summaryRows, applications] = await Promise.all([
|
||||||
this.prisma.$queryRaw<Array<{
|
this.prisma.$queryRaw<
|
||||||
channelId: string;
|
Array<{
|
||||||
channelName: string;
|
channelId: string;
|
||||||
total: number;
|
channelName: string;
|
||||||
acceptedCount: number;
|
total: number;
|
||||||
submitFailureCount: number;
|
acceptedCount: number;
|
||||||
submitFailureRate: number;
|
submitFailureCount: number;
|
||||||
successCount: number;
|
submitFailureRate: number;
|
||||||
unknownCount: number;
|
successCount: number;
|
||||||
failureCount: number;
|
unknownCount: number;
|
||||||
successRate: number;
|
failureCount: number;
|
||||||
unknownRate: number;
|
successRate: number;
|
||||||
failureRate: number;
|
unknownRate: number;
|
||||||
averageArrivalMs: number | null;
|
failureRate: number;
|
||||||
}>>(Prisma.sql`
|
averageArrivalMs: number | null;
|
||||||
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
SELECT
|
SELECT
|
||||||
submit."channelId" AS channel_id,
|
submit."channelId" AS channel_id,
|
||||||
@@ -131,22 +131,24 @@ async sendQuality(date?: string) {
|
|||||||
GROUP BY channel_id
|
GROUP BY channel_id
|
||||||
ORDER BY COUNT(*) DESC, channel_id
|
ORDER BY COUNT(*) DESC, channel_id
|
||||||
`),
|
`),
|
||||||
this.prisma.$queryRaw<Array<{
|
this.prisma.$queryRaw<
|
||||||
id: string;
|
Array<{
|
||||||
signatureId: string;
|
id: string;
|
||||||
signatureName: string;
|
signatureId: string;
|
||||||
tenantId: string;
|
signatureName: string;
|
||||||
tenantName: string;
|
tenantId: string;
|
||||||
hasDrainage: boolean;
|
tenantName: string;
|
||||||
total: number;
|
hasDrainage: boolean;
|
||||||
acceptedCount: number;
|
total: number;
|
||||||
submitFailureCount: number;
|
acceptedCount: number;
|
||||||
successCount: number;
|
submitFailureCount: number;
|
||||||
unknownCount: number;
|
successCount: number;
|
||||||
failureCount: number;
|
unknownCount: number;
|
||||||
successRate: number;
|
failureCount: number;
|
||||||
averageArrivalMs: number | null;
|
successRate: number;
|
||||||
}>>(Prisma.sql`
|
averageArrivalMs: number | null;
|
||||||
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
SELECT
|
SELECT
|
||||||
message."signatureId" AS signature_id,
|
message."signatureId" AS signature_id,
|
||||||
@@ -216,13 +218,15 @@ async sendQuality(date?: string) {
|
|||||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
|
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
|
||||||
ORDER BY "successCount" DESC, total DESC, signature.name
|
ORDER BY "successCount" DESC, total DESC, signature.name
|
||||||
`),
|
`),
|
||||||
this.prisma.$queryRaw<Array<{
|
this.prisma.$queryRaw<
|
||||||
total: number;
|
Array<{
|
||||||
successCount: number;
|
total: number;
|
||||||
unknownCount: number;
|
successCount: number;
|
||||||
failureCount: number;
|
unknownCount: number;
|
||||||
successRate: number;
|
failureCount: number;
|
||||||
}>>(Prisma.sql`
|
successRate: number;
|
||||||
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
SELECT message.status, message."receiptStatus" AS receipt_status
|
SELECT message.status, message."receiptStatus" AS receipt_status
|
||||||
FROM "SmsMessageRecord" message
|
FROM "SmsMessageRecord" message
|
||||||
@@ -251,17 +255,19 @@ async sendQuality(date?: string) {
|
|||||||
END AS "successRate"
|
END AS "successRate"
|
||||||
FROM base
|
FROM base
|
||||||
`),
|
`),
|
||||||
this.prisma.$queryRaw<Array<{
|
this.prisma.$queryRaw<
|
||||||
applicationId: string;
|
Array<{
|
||||||
applicationName: string;
|
applicationId: string;
|
||||||
tenantId: string;
|
applicationName: string;
|
||||||
tenantName: string;
|
tenantId: string;
|
||||||
total: number;
|
tenantName: string;
|
||||||
successCount: number;
|
total: number;
|
||||||
unknownCount: number;
|
successCount: number;
|
||||||
failureCount: number;
|
unknownCount: number;
|
||||||
successRate: number;
|
failureCount: number;
|
||||||
}>>(Prisma.sql`
|
successRate: number;
|
||||||
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
SELECT
|
SELECT
|
||||||
message."applicationId" AS application_id,
|
message."applicationId" AS application_id,
|
||||||
@@ -314,28 +320,30 @@ async sendQuality(date?: string) {
|
|||||||
};
|
};
|
||||||
return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
|
return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
|
||||||
}
|
}
|
||||||
async signatureQuality(query: SignatureQualityQuery) {
|
async signatureQuality(query: SignatureQualityQuery) {
|
||||||
const day = qualityBusinessDay(query.date);
|
const day = qualityBusinessDay(query.date);
|
||||||
const page = positiveInteger(query.page, 1);
|
const page = positiveInteger(query.page, 1);
|
||||||
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
|
const pageSize = Math.min(100, positiveInteger(query.pageSize, 25));
|
||||||
const keyword = query.keyword?.trim() || null;
|
const keyword = query.keyword?.trim() || null;
|
||||||
const keywordPattern = keyword ? `%${keyword}%` : null;
|
const keywordPattern = keyword ? `%${keyword}%` : null;
|
||||||
const summaries = await this.prisma.$queryRaw<Array<{
|
const summaries = await this.prisma.$queryRaw<
|
||||||
signatureId: string;
|
Array<{
|
||||||
signatureName: string;
|
signatureId: string;
|
||||||
tenantId: string;
|
signatureName: string;
|
||||||
tenantName: string;
|
tenantId: string;
|
||||||
applicationNames: string | null;
|
tenantName: string;
|
||||||
total: number;
|
applicationNames: string | null;
|
||||||
acceptedCount: number;
|
total: number;
|
||||||
submitFailureCount: number;
|
acceptedCount: number;
|
||||||
successCount: number;
|
submitFailureCount: number;
|
||||||
unknownCount: number;
|
successCount: number;
|
||||||
failureCount: number;
|
unknownCount: number;
|
||||||
successRate: number;
|
failureCount: number;
|
||||||
averageArrivalMs: number | null;
|
successRate: number;
|
||||||
rowCount: number;
|
averageArrivalMs: number | null;
|
||||||
}>>(Prisma.sql`
|
rowCount: number;
|
||||||
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
SELECT
|
SELECT
|
||||||
message."signatureId" AS signature_id,
|
message."signatureId" AS signature_id,
|
||||||
@@ -415,23 +423,26 @@ async signatureQuality(query: SignatureQualityQuery) {
|
|||||||
OFFSET ${(page - 1) * pageSize}
|
OFFSET ${(page - 1) * pageSize}
|
||||||
`);
|
`);
|
||||||
const signatureIds = summaries.map((item) => item.signatureId);
|
const signatureIds = summaries.map((item) => item.signatureId);
|
||||||
const drainageBreakdowns = signatureIds.length === 0
|
const drainageBreakdowns =
|
||||||
? []
|
signatureIds.length === 0
|
||||||
: await this.prisma.$queryRaw<Array<{
|
? []
|
||||||
signatureId: string;
|
: await this.prisma.$queryRaw<
|
||||||
channelId: string;
|
Array<{
|
||||||
channelName: string;
|
signatureId: string;
|
||||||
carrier: string;
|
channelId: string;
|
||||||
drainageState: 'with' | 'without' | 'unknown';
|
channelName: string;
|
||||||
total: number;
|
carrier: string;
|
||||||
acceptedCount: number;
|
drainageState: 'with' | 'without' | 'unknown';
|
||||||
submitFailureCount: number;
|
total: number;
|
||||||
successCount: number;
|
acceptedCount: number;
|
||||||
unknownCount: number;
|
submitFailureCount: number;
|
||||||
failureCount: number;
|
successCount: number;
|
||||||
successRate: number;
|
unknownCount: number;
|
||||||
averageArrivalMs: number | null;
|
failureCount: number;
|
||||||
}>>(Prisma.sql`
|
successRate: number;
|
||||||
|
averageArrivalMs: number | null;
|
||||||
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
SELECT
|
SELECT
|
||||||
message."signatureId" AS signature_id,
|
message."signatureId" AS signature_id,
|
||||||
@@ -526,16 +537,19 @@ async signatureQuality(query: SignatureQualityQuery) {
|
|||||||
GROUP BY signature_id, channel_id, carrier, drainage_state
|
GROUP BY signature_id, channel_id, carrier, drainage_state
|
||||||
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state
|
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state
|
||||||
`);
|
`);
|
||||||
const carrierOverview = signatureIds.length === 0
|
const carrierOverview =
|
||||||
? []
|
signatureIds.length === 0
|
||||||
: await this.prisma.$queryRaw<Array<{
|
? []
|
||||||
signatureId: string;
|
: await this.prisma.$queryRaw<
|
||||||
carrier: string;
|
Array<{
|
||||||
businessMessageCount: number;
|
signatureId: string;
|
||||||
finalSuccessCount: number;
|
carrier: string;
|
||||||
finalSuccessRate: number;
|
businessMessageCount: number;
|
||||||
averageArrivalMs: number | null;
|
finalSuccessCount: number;
|
||||||
}>>(Prisma.sql`
|
finalSuccessRate: number;
|
||||||
|
averageArrivalMs: number | null;
|
||||||
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
message."signatureId" AS "signatureId",
|
message."signatureId" AS "signatureId",
|
||||||
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
|
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
|
||||||
@@ -570,6 +584,7 @@ async signatureQuality(query: SignatureQualityQuery) {
|
|||||||
ORDER BY message."signatureId", COUNT(*) DESC, carrier
|
ORDER BY message."signatureId", COUNT(*) DESC, carrier
|
||||||
`);
|
`);
|
||||||
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
|
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
|
||||||
|
void _rowCount;
|
||||||
const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId);
|
const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId);
|
||||||
const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns);
|
const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns);
|
||||||
return {
|
return {
|
||||||
@@ -610,26 +625,41 @@ type SignatureSplitRow = {
|
|||||||
function aggregateSignatureRows(rows: SignatureSplitRow[]) {
|
function aggregateSignatureRows(rows: SignatureSplitRow[]) {
|
||||||
const grouped = new Map<string, SignatureSplitRow[]>();
|
const grouped = new Map<string, SignatureSplitRow[]>();
|
||||||
rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row]));
|
rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row]));
|
||||||
return [...grouped.values()].map((parts) => {
|
return [...grouped.values()]
|
||||||
const first = parts[0];
|
.map((parts) => {
|
||||||
const total = parts.reduce((sum, item) => sum + item.total, 0);
|
const first = parts[0];
|
||||||
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
|
const total = parts.reduce((sum, item) => sum + item.total, 0);
|
||||||
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
|
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
|
||||||
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
|
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
|
||||||
return {
|
const arrivalWeight = parts.reduce(
|
||||||
...first,
|
(sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount),
|
||||||
id: first.signatureId,
|
0,
|
||||||
hasDrainage: false,
|
);
|
||||||
total,
|
return {
|
||||||
acceptedCount,
|
...first,
|
||||||
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
|
id: first.signatureId,
|
||||||
successCount,
|
hasDrainage: false,
|
||||||
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
|
total,
|
||||||
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
|
acceptedCount,
|
||||||
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
|
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
|
||||||
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
|
successCount,
|
||||||
};
|
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
|
||||||
}).sort((left, right) => right.successCount - left.successCount || right.total - left.total || left.signatureName.localeCompare(right.signatureName));
|
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 = {
|
type DrainageBreakdownRow = {
|
||||||
@@ -670,8 +700,13 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
|
|||||||
successCount,
|
successCount,
|
||||||
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
|
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
|
||||||
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
|
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
|
||||||
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
|
successRate: acceptedCount === 0 ? 0 : Math.round((successCount * 1000) / acceptedCount) / 10,
|
||||||
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
|
averageArrivalMs:
|
||||||
|
arrivalWeight === 0
|
||||||
|
? null
|
||||||
|
: Math.round(
|
||||||
|
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { ReportsService } from './reports.service';
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
type CapturedSql = { strings: string[]; values: unknown[]; text: string };
|
||||||
|
|
||||||
|
function sqlText(query: CapturedSql) {
|
||||||
|
return query.strings.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
describe('ReportsService', () => {
|
describe('ReportsService', () => {
|
||||||
const tx = {
|
const tx = {
|
||||||
dailyReconciliationReport: { deleteMany: jest.fn() },
|
dailyReconciliationReport: { deleteMany: jest.fn() },
|
||||||
dailyProfitReport: { deleteMany: jest.fn() },
|
dailyProfitReport: { deleteMany: jest.fn() },
|
||||||
dailyQualityReport: { deleteMany: jest.fn() },
|
dailyQualityReport: { deleteMany: jest.fn() },
|
||||||
$executeRaw: jest.fn(),
|
$executeRaw: jest.fn(),
|
||||||
|
$queryRaw: jest.fn(),
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
|
||||||
@@ -14,25 +22,62 @@ describe('ReportsService', () => {
|
|||||||
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
|
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||||
};
|
};
|
||||||
let service: ReportsService;
|
let service: ReportsService;
|
||||||
|
const environmentKeys = [
|
||||||
|
'REPORT_DAILY_REFRESH_ENABLED',
|
||||||
|
'REPORT_REFRESH_INTERVAL_MS',
|
||||||
|
'REPORT_REFRESH_TRANSACTION_TIMEOUT_MS',
|
||||||
|
] as const;
|
||||||
|
const originalEnvironment = Object.fromEntries(environmentKeys.map((key) => [key, process.env[key]]));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.resetAllMocks();
|
||||||
|
for (const key of environmentKeys) delete process.env[key];
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
prisma.$transaction.mockImplementation((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||||
tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
|
tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
|
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
|
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
tx.$executeRaw.mockResolvedValue(0);
|
tx.$executeRaw.mockResolvedValue(0);
|
||||||
|
tx.$queryRaw.mockResolvedValue([{ locked: true }]);
|
||||||
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
|
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
|
||||||
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
|
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
|
||||||
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({
|
||||||
|
_sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
|
||||||
|
});
|
||||||
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
|
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
|
||||||
prisma.dailyProfitReport.count.mockResolvedValue(1);
|
prisma.dailyProfitReport.count.mockResolvedValue(1);
|
||||||
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), costCents: BigInt(600), profitCents: BigInt(400) } });
|
prisma.dailyProfitReport.aggregate.mockResolvedValue({
|
||||||
|
_sum: {
|
||||||
|
submittedUnits: 11,
|
||||||
|
sentUnits: 10,
|
||||||
|
unknownUnits: 2,
|
||||||
|
successUnits: 7,
|
||||||
|
failedUnits: 1,
|
||||||
|
revenueCents: BigInt(1000),
|
||||||
|
costCents: BigInt(600),
|
||||||
|
profitCents: BigInt(400),
|
||||||
|
},
|
||||||
|
});
|
||||||
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
|
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
|
||||||
prisma.dailyQualityReport.count.mockResolvedValue(1);
|
prisma.dailyQualityReport.count.mockResolvedValue(1);
|
||||||
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
prisma.dailyQualityReport.aggregate.mockResolvedValue({
|
||||||
|
_sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
|
||||||
|
});
|
||||||
service = new ReportsService(prisma as never);
|
service = new ReportsService(prisma as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
service.onModuleDestroy();
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
for (const key of environmentKeys) {
|
||||||
|
const value = originalEnvironment[key];
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('rebuilds exactly T-4 through T-1 in independent transactions', async () => {
|
it('rebuilds exactly T-4 through T-1 in independent transactions', async () => {
|
||||||
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({
|
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({
|
||||||
refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'],
|
refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'],
|
||||||
@@ -41,14 +86,193 @@ describe('ReportsService', () => {
|
|||||||
expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
|
expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
|
||||||
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
|
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
|
||||||
expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4);
|
expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4);
|
||||||
expect(tx.$executeRaw).toHaveBeenCalledTimes(28);
|
expect(tx.$executeRaw).toHaveBeenCalledTimes(32);
|
||||||
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['2026-12-31T15:59:59.999Z', ['2026-12-27', '2026-12-28', '2026-12-29', '2026-12-30']],
|
||||||
|
['2026-12-31T16:00:00.000Z', ['2026-12-28', '2026-12-29', '2026-12-30', '2026-12-31']],
|
||||||
|
['2026-02-28T16:00:00.000Z', ['2026-02-25', '2026-02-26', '2026-02-27', '2026-02-28']],
|
||||||
|
])('uses completed Shanghai dates at boundary %s', async (now, expectedDates) => {
|
||||||
|
await expect(service.refreshRollingWindow(new Date(now))).resolves.toEqual({ refreshedDates: expectedDates });
|
||||||
|
expect(
|
||||||
|
tx.dailyReconciliationReport.deleteMany.mock.calls.map(([query]) =>
|
||||||
|
query.where.reportDate.toISOString().slice(0, 10),
|
||||||
|
),
|
||||||
|
).toEqual(expectedDates);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds application costs by the original message day while retaining cross-day submits', async () => {
|
||||||
|
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||||
|
const applicationProfit = tx.$executeRaw.mock.calls
|
||||||
|
.map(([query]) => query as CapturedSql)
|
||||||
|
.find((query) => sqlText(query).includes('WITH costs AS'))!;
|
||||||
|
const costs = applicationProfit.text.split('INSERT INTO "DailyProfitReport"')[0];
|
||||||
|
|
||||||
|
expect(costs).toMatch(/message\."queuedAt" >= \$1/);
|
||||||
|
expect(costs).toMatch(/message\."queuedAt" < \$2/);
|
||||||
|
expect(applicationProfit.values.slice(0, 2)).toEqual([
|
||||||
|
new Date('2026-07-10T16:00:00.000Z'),
|
||||||
|
new Date('2026-07-11T16:00:00.000Z'),
|
||||||
|
]);
|
||||||
|
expect(costs).toContain('submit."submitStatus" = \'accepted\'');
|
||||||
|
expect(costs).not.toContain('submit."submittedAt"');
|
||||||
|
expect(costs).not.toContain('submit."createdAt"');
|
||||||
|
expect(costs).toContain('WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count');
|
||||||
|
expect(costs).toContain('WHEN legacy_receipt.delivered THEN message."billingUnits"');
|
||||||
|
|
||||||
|
const channelProfit = tx.$executeRaw.mock.calls
|
||||||
|
.map(([query]) => sqlText(query))
|
||||||
|
.find((query) => query.includes("CONCAT('profit-channel-'"))!;
|
||||||
|
expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") >=');
|
||||||
|
expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") <');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[undefined, 30_000],
|
||||||
|
['45000', 45_000],
|
||||||
|
['200000', 120_000],
|
||||||
|
['0', 30_000],
|
||||||
|
['-1', 30_000],
|
||||||
|
['1.5', 30_000],
|
||||||
|
['invalid', 30_000],
|
||||||
|
['Infinity', 30_000],
|
||||||
|
])('uses a bounded report transaction timeout for %s', async (configured, expected) => {
|
||||||
|
if (configured !== undefined) process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS = configured;
|
||||||
|
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||||
|
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5_000, timeout: expected });
|
||||||
|
const timeoutQueries = tx.$executeRaw.mock.calls
|
||||||
|
.map(([query]) => query as CapturedSql)
|
||||||
|
.filter((query) => sqlText(query).includes('statement_timeout'));
|
||||||
|
expect(timeoutQueries).toHaveLength(4);
|
||||||
|
for (const query of timeoutQueries) {
|
||||||
|
expect(sqlText(query)).toContain("set_config('statement_timeout',");
|
||||||
|
expect(sqlText(query)).toContain(', true)');
|
||||||
|
expect(query.values).toEqual([`${expected}ms`]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues later dates after one transaction fails and reports partial completion', async () => {
|
||||||
|
const failure = new Error('database transaction expired');
|
||||||
|
tx.$executeRaw.mockResolvedValueOnce(0).mockRejectedValueOnce(failure);
|
||||||
|
|
||||||
|
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
|
||||||
|
'failed dates: 2026-07-11; refreshed dates: 2026-07-12, 2026-07-13, 2026-07-14',
|
||||||
|
);
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
|
||||||
|
expect(
|
||||||
|
tx.dailyQualityReport.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10)),
|
||||||
|
).toEqual(['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14']);
|
||||||
|
expect(Logger.prototype.error).toHaveBeenCalledWith('Daily report refresh failed for 2026-07-11', failure.stack);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete reports for a date whose lock is owned by another transaction', async () => {
|
||||||
|
tx.$queryRaw.mockResolvedValueOnce([{ locked: false }]);
|
||||||
|
|
||||||
|
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
|
||||||
|
'failed dates: 2026-07-11',
|
||||||
|
);
|
||||||
|
for (const model of [tx.dailyReconciliationReport, tx.dailyProfitReport, tx.dailyQualityReport]) {
|
||||||
|
expect(model.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10))).toEqual([
|
||||||
|
'2026-07-12',
|
||||||
|
'2026-07-13',
|
||||||
|
'2026-07-14',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
expect(tx.$queryRaw.mock.calls.map(([query]) => query.values[1])).toEqual([20260711, 20260712, 20260713, 20260714]);
|
||||||
|
expect(sqlText(tx.$queryRaw.mock.calls[0][0])).toContain('pg_try_advisory_xact_lock');
|
||||||
|
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||||
|
tx.dailyReconciliationReport.deleteMany.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails safely when the transaction lock query returns no result', async () => {
|
||||||
|
tx.$queryRaw.mockResolvedValue([]);
|
||||||
|
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
|
||||||
|
'refreshed dates: none',
|
||||||
|
);
|
||||||
|
expect(tx.dailyReconciliationReport.deleteMany).not.toHaveBeenCalled();
|
||||||
|
expect(tx.dailyProfitReport.deleteMany).not.toHaveBeenCalled();
|
||||||
|
expect(tx.dailyQualityReport.deleteMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries partial failures and skips the business day only after full success', async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z'));
|
||||||
|
tx.$executeRaw.mockRejectedValueOnce(new Error('database unavailable'));
|
||||||
|
|
||||||
|
await service['runScheduledRefresh']();
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
|
||||||
|
expect(Logger.prototype.log).not.toHaveBeenCalled();
|
||||||
|
await service['runScheduledRefresh']();
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(8);
|
||||||
|
expect(Logger.prototype.log).toHaveBeenCalledTimes(1);
|
||||||
|
await service['runScheduledRefresh']();
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(8);
|
||||||
|
|
||||||
|
jest.setSystemTime(new Date('2026-07-15T16:00:00.000Z'));
|
||||||
|
await service['runScheduledRefresh']();
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents overlapping scheduled refreshes in the same service instance', async () => {
|
||||||
|
let finishRefresh!: (result: { refreshedDates: string[] }) => void;
|
||||||
|
const refresh = jest.spyOn(service, 'refreshRollingWindow').mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishRefresh = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const running = service['runScheduledRefresh']();
|
||||||
|
await service['runScheduledRefresh']();
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1);
|
||||||
|
finishRefresh({ refreshedDates: ['2026-07-14'] });
|
||||||
|
await running;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels startup and interval timers when destroyed before startup refresh', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const refresh = jest.spyOn(service, 'refreshRollingWindow');
|
||||||
|
service.onModuleInit();
|
||||||
|
expect(jest.getTimerCount()).toBe(2);
|
||||||
|
service.onModuleDestroy();
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
await jest.advanceTimersByTimeAsync(2 * 60 * 60 * 1000);
|
||||||
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts after 15 seconds and retries a failed scheduled run on the configured interval', async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z'));
|
||||||
|
process.env.REPORT_REFRESH_INTERVAL_MS = '60000';
|
||||||
|
const refresh = jest
|
||||||
|
.spyOn(service, 'refreshRollingWindow')
|
||||||
|
.mockRejectedValueOnce(new Error('temporarily unavailable'));
|
||||||
|
service.onModuleInit();
|
||||||
|
|
||||||
|
await jest.advanceTimersByTimeAsync(14_999);
|
||||||
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1);
|
||||||
|
await jest.advanceTimersByTimeAsync(45_000);
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(2);
|
||||||
|
await jest.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not schedule reports when daily refresh is disabled', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
process.env.REPORT_DAILY_REFRESH_ENABLED = 'false';
|
||||||
|
service.onModuleInit();
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates profit cost from channel unit price times delivered fragment count', async () => {
|
it('calculates profit cost from channel unit price times delivered fragment count', async () => {
|
||||||
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||||
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
|
const firstDayQueries = tx.$executeRaw.mock.calls
|
||||||
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
|
.slice(1, 8)
|
||||||
);
|
.map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
|
||||||
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
||||||
|
|
||||||
expect(profitQueries).toContain('"SmsMessageSegmentAudit"');
|
expect(profitQueries).toContain('"SmsMessageSegmentAudit"');
|
||||||
@@ -59,9 +283,9 @@ describe('ReportsService', () => {
|
|||||||
|
|
||||||
it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => {
|
it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => {
|
||||||
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
|
||||||
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
|
const firstDayQueries = tx.$executeRaw.mock.calls
|
||||||
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
|
.slice(1, 8)
|
||||||
);
|
.map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
|
||||||
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
|
||||||
|
|
||||||
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
|
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
|
||||||
@@ -71,58 +295,111 @@ describe('ReportsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
|
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
|
||||||
await expect(service.listReconciliation({
|
await expect(
|
||||||
dateFrom: '2026-07-01',
|
service.listReconciliation({
|
||||||
dateTo: '2026-07-14',
|
dateFrom: '2026-07-01',
|
||||||
tenantId: 'tenant-1',
|
dateTo: '2026-07-14',
|
||||||
applicationId: 'app-1',
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
page: 2,
|
||||||
|
pageSize: 500,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
items: [{ id: 'recon-1' }],
|
||||||
|
total: 1,
|
||||||
page: 2,
|
page: 2,
|
||||||
pageSize: 500,
|
pageSize: 100,
|
||||||
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100, summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
|
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
|
||||||
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
});
|
||||||
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
|
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
|
||||||
skip: 100,
|
expect.objectContaining({
|
||||||
take: 100,
|
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||||
}));
|
skip: 100,
|
||||||
|
take: 100,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps application and channel profit filters separate', async () => {
|
it('keeps application and channel profit filters separate', async () => {
|
||||||
const result = await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
|
const result = await service.listProfit({
|
||||||
expect(result).toEqual(expect.objectContaining({
|
dimensionType: 'channel',
|
||||||
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
|
tenantId: 'tenant-1',
|
||||||
}));
|
applicationId: 'app-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
});
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(result.summary).not.toHaveProperty('refundCents');
|
expect(result.summary).not.toHaveProperty('refundCents');
|
||||||
expect(result.items[0]).not.toHaveProperty('refundCents');
|
expect(result.items[0]).not.toHaveProperty('refundCents');
|
||||||
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(
|
||||||
where: expect.objectContaining({
|
expect.objectContaining({
|
||||||
dimensionType: 'channel',
|
where: expect.objectContaining({
|
||||||
tenantId: undefined,
|
dimensionType: 'channel',
|
||||||
applicationId: undefined,
|
tenantId: undefined,
|
||||||
channelId: 'channel-1',
|
applicationId: undefined,
|
||||||
|
channelId: 'channel-1',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}));
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
|
it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
|
||||||
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
|
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
|
||||||
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
|
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
|
||||||
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
|
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
|
||||||
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, costCents: null, profitCents: null },
|
_sum: {
|
||||||
|
submittedUnits: null,
|
||||||
|
sentUnits: null,
|
||||||
|
unknownUnits: null,
|
||||||
|
successUnits: null,
|
||||||
|
failedUnits: null,
|
||||||
|
revenueCents: null,
|
||||||
|
costCents: null,
|
||||||
|
profitCents: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({
|
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(
|
||||||
total: 0,
|
expect.objectContaining({
|
||||||
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
|
total: 0,
|
||||||
}));
|
summary: {
|
||||||
|
submittedUnits: 0,
|
||||||
|
sentUnits: 0,
|
||||||
|
unknownUnits: 0,
|
||||||
|
successUnits: 0,
|
||||||
|
failedUnits: 0,
|
||||||
|
revenueCents: 0,
|
||||||
|
costCents: 0,
|
||||||
|
profitCents: 0,
|
||||||
|
profitRateBps: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports income without refund columns', async () => {
|
it('exports income without refund columns', async () => {
|
||||||
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{
|
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([
|
||||||
id: 'profit-export', reportDate: new Date('2026-07-14'), dimensionName: '应用A', tenantName: '示例企业',
|
{
|
||||||
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1,
|
id: 'profit-export',
|
||||||
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400),
|
reportDate: new Date('2026-07-14'),
|
||||||
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'),
|
dimensionName: '应用A',
|
||||||
}]);
|
tenantName: '示例企业',
|
||||||
|
submittedUnits: 14,
|
||||||
|
sentUnits: 12,
|
||||||
|
unknownUnits: 1,
|
||||||
|
successUnits: 10,
|
||||||
|
failedUnits: 1,
|
||||||
|
revenueCents: BigInt(3500),
|
||||||
|
refundCents: BigInt(200),
|
||||||
|
costCents: BigInt(2100),
|
||||||
|
profitCents: BigInt(1400),
|
||||||
|
profitRateBps: 4000,
|
||||||
|
generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const exported = await service.exportProfit({ dimensionType: 'application' });
|
const exported = await service.exportProfit({ dimensionType: 'application' });
|
||||||
expect(exported.content).toContain('收入金额(元)');
|
expect(exported.content).toContain('收入金额(元)');
|
||||||
@@ -131,28 +408,59 @@ describe('ReportsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
|
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
|
||||||
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
|
await expect(
|
||||||
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
|
service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 }),
|
||||||
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 },
|
).resolves.toEqual({
|
||||||
|
items: [{ id: 'quality-1' }],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
dimensionType: 'drainage',
|
||||||
|
summary: {
|
||||||
|
submittedUnits: 11,
|
||||||
|
sentUnits: 10,
|
||||||
|
unknownUnits: 2,
|
||||||
|
successUnits: 7,
|
||||||
|
failedUnits: 1,
|
||||||
|
successRateBps: 7000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(
|
||||||
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
|
expect.objectContaining({
|
||||||
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
|
||||||
}));
|
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
|
it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
|
||||||
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{
|
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([
|
||||||
id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A',
|
{
|
||||||
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, generatedAt: new Date('2026-07-15T00:00:00Z'),
|
id: 'recon-export',
|
||||||
}]);
|
reportDate: new Date('2026-07-14'),
|
||||||
const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' });
|
tenantName: '示例,企业',
|
||||||
|
applicationName: '应用A',
|
||||||
|
submittedUnits: 14,
|
||||||
|
sentUnits: 12,
|
||||||
|
unknownUnits: 1,
|
||||||
|
successUnits: 10,
|
||||||
|
failedUnits: 1,
|
||||||
|
generatedAt: new Date('2026-07-15T00:00:00Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const exported = await service.exportReconciliation({
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
dateFrom: '2026-07-01',
|
||||||
|
dateTo: '2026-07-14',
|
||||||
|
});
|
||||||
expect(exported.fileName).toContain('对账单-');
|
expect(exported.fileName).toContain('对账单-');
|
||||||
expect(exported.content).toContain('"示例,企业"');
|
expect(exported.content).toContain('"示例,企业"');
|
||||||
expect(exported.content).toContain('提交条数');
|
expect(exported.content).toContain('提交条数');
|
||||||
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
|
||||||
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
expect.objectContaining({
|
||||||
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
||||||
}));
|
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
|
const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
const DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS = 30_000;
|
||||||
|
const REPORT_LOCK_NAMESPACE = 0x434d5052;
|
||||||
|
|
||||||
export type ReportListQuery = {
|
export type ReportListQuery = {
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
@@ -21,6 +23,7 @@ export type ReportListQuery = {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(ReportsService.name);
|
private readonly logger = new Logger(ReportsService.name);
|
||||||
|
private startupTimer?: ReturnType<typeof setTimeout>;
|
||||||
private refreshTimer?: ReturnType<typeof setInterval>;
|
private refreshTimer?: ReturnType<typeof setInterval>;
|
||||||
private refreshRunning = false;
|
private refreshRunning = false;
|
||||||
private lastRefreshBusinessDate?: string;
|
private lastRefreshBusinessDate?: string;
|
||||||
@@ -29,8 +32,8 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
|
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
|
||||||
const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
|
this.startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
|
||||||
startupTimer.unref?.();
|
this.startupTimer.unref?.();
|
||||||
this.refreshTimer = setInterval(
|
this.refreshTimer = setInterval(
|
||||||
() => void this.runScheduledRefresh(),
|
() => void this.runScheduledRefresh(),
|
||||||
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
|
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
|
||||||
@@ -39,6 +42,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onModuleDestroy() {
|
onModuleDestroy() {
|
||||||
|
if (this.startupTimer) clearTimeout(this.startupTimer);
|
||||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +50,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { page, pageSize, skip } = pagination(query);
|
const { page, pageSize, skip } = pagination(query);
|
||||||
const where = reconciliationWhere(query);
|
const where = reconciliationWhere(query);
|
||||||
const [items, total, aggregate] = await Promise.all([
|
const [items, total, aggregate] = await Promise.all([
|
||||||
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }),
|
this.prisma.dailyReconciliationReport.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
this.prisma.dailyReconciliationReport.count({ where }),
|
this.prisma.dailyReconciliationReport.count({ where }),
|
||||||
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||||
]);
|
]);
|
||||||
@@ -57,7 +66,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { page, pageSize, skip } = pagination(query);
|
const { page, pageSize, skip } = pagination(query);
|
||||||
const { dimensionType, where } = profitWhere(query);
|
const { dimensionType, where } = profitWhere(query);
|
||||||
const [storedItems, total, aggregate] = await Promise.all([
|
const [storedItems, total, aggregate] = await Promise.all([
|
||||||
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
|
this.prisma.dailyProfitReport.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
this.prisma.dailyProfitReport.count({ where }),
|
this.prisma.dailyProfitReport.count({ where }),
|
||||||
this.prisma.dailyProfitReport.aggregate({
|
this.prisma.dailyProfitReport.aggregate({
|
||||||
where,
|
where,
|
||||||
@@ -65,7 +79,10 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
|
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
|
||||||
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item);
|
const items = storedItems.map(({ refundCents, ...item }) => {
|
||||||
|
void refundCents;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
const summary = {
|
const summary = {
|
||||||
...volumeSummary(aggregate._sum),
|
...volumeSummary(aggregate._sum),
|
||||||
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
|
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
|
||||||
@@ -81,7 +98,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { page, pageSize, skip } = pagination(query);
|
const { page, pageSize, skip } = pagination(query);
|
||||||
const { dimensionType, where } = qualityWhere(query);
|
const { dimensionType, where } = qualityWhere(query);
|
||||||
const [items, total, aggregate] = await Promise.all([
|
const [items, total, aggregate] = await Promise.all([
|
||||||
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
|
this.prisma.dailyQualityReport.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
this.prisma.dailyQualityReport.count({ where }),
|
this.prisma.dailyQualityReport.count({ where }),
|
||||||
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
|
||||||
]);
|
]);
|
||||||
@@ -94,34 +116,136 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async exportReconciliation(query: ReportListQuery) {
|
async exportReconciliation(query: ReportListQuery) {
|
||||||
const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] });
|
const items = await this.prisma.dailyReconciliationReport.findMany({
|
||||||
return csvExport('对账单', ['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)]));
|
where: reconciliationWhere(query),
|
||||||
|
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
|
||||||
|
});
|
||||||
|
return csvExport(
|
||||||
|
'对账单',
|
||||||
|
['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'],
|
||||||
|
items.map((item) => [
|
||||||
|
dateKey(item.reportDate),
|
||||||
|
item.tenantName,
|
||||||
|
item.applicationName,
|
||||||
|
item.submittedUnits,
|
||||||
|
item.sentUnits,
|
||||||
|
item.unknownUnits,
|
||||||
|
item.successUnits,
|
||||||
|
item.failedUnits,
|
||||||
|
formatCsvDate(item.generatedAt),
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportProfit(query: ReportListQuery) {
|
async exportProfit(query: ReportListQuery) {
|
||||||
const { dimensionType, where } = profitWhere(query);
|
const { dimensionType, where } = profitWhere(query);
|
||||||
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
const items = await this.prisma.dailyProfitReport.findMany({
|
||||||
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '收入金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
where,
|
||||||
|
orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||||
|
});
|
||||||
|
return csvExport(
|
||||||
|
`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`,
|
||||||
|
[
|
||||||
|
'发送日期',
|
||||||
|
'统计维度',
|
||||||
|
'企业',
|
||||||
|
'提交条数',
|
||||||
|
'发送条数',
|
||||||
|
'未知条数',
|
||||||
|
'成功条数',
|
||||||
|
'失败条数',
|
||||||
|
'收入金额(元)',
|
||||||
|
'成本金额(元)',
|
||||||
|
'利润(元)',
|
||||||
|
'利润率(%)',
|
||||||
|
'生成时间',
|
||||||
|
],
|
||||||
|
items.map((item) => [
|
||||||
|
dateKey(item.reportDate),
|
||||||
|
item.dimensionName,
|
||||||
|
item.tenantName ?? '',
|
||||||
|
item.submittedUnits,
|
||||||
|
item.sentUnits,
|
||||||
|
item.unknownUnits,
|
||||||
|
item.successUnits,
|
||||||
|
item.failedUnits,
|
||||||
|
moneyUnitsToFixedYuan(item.revenueCents),
|
||||||
|
moneyUnitsToFixedYuan(item.costCents),
|
||||||
|
moneyUnitsToFixedYuan(item.profitCents),
|
||||||
|
(item.profitRateBps / 100).toFixed(2),
|
||||||
|
formatCsvDate(item.generatedAt),
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportQuality(query: ReportListQuery) {
|
async exportQuality(query: ReportListQuery) {
|
||||||
const { dimensionType, where } = qualityWhere(query);
|
const { dimensionType, where } = qualityWhere(query);
|
||||||
const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
const items = await this.prisma.dailyQualityReport.findMany({
|
||||||
return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)]));
|
where,
|
||||||
|
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
|
||||||
|
});
|
||||||
|
return csvExport(
|
||||||
|
`发送质量报表-${dimensionType}`,
|
||||||
|
[
|
||||||
|
'发送日期',
|
||||||
|
'统计对象',
|
||||||
|
'企业',
|
||||||
|
'提交条数',
|
||||||
|
'发送条数',
|
||||||
|
'未知条数',
|
||||||
|
'成功条数',
|
||||||
|
'失败条数',
|
||||||
|
'成功率(%)',
|
||||||
|
'平均到达时长(毫秒)',
|
||||||
|
'生成时间',
|
||||||
|
],
|
||||||
|
items.map((item) => [
|
||||||
|
dateKey(item.reportDate),
|
||||||
|
item.dimensionName,
|
||||||
|
item.tenantName ?? '',
|
||||||
|
item.submittedUnits,
|
||||||
|
item.sentUnits,
|
||||||
|
item.unknownUnits,
|
||||||
|
item.successUnits,
|
||||||
|
item.failedUnits,
|
||||||
|
(item.successRateBps / 100).toFixed(2),
|
||||||
|
item.avgArrivalMs ?? '',
|
||||||
|
formatCsvDate(item.generatedAt),
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshRollingWindow(now = new Date()) {
|
async refreshRollingWindow(now = new Date()) {
|
||||||
const days = completedBusinessDays(now, 4);
|
const days = completedBusinessDays(now, 4);
|
||||||
for (const day of days) await this.refreshBusinessDay(day);
|
const refreshedDates: string[] = [];
|
||||||
return { refreshedDates: days.map((day) => day.key) };
|
const failedDates: string[] = [];
|
||||||
|
for (const day of days) {
|
||||||
|
try {
|
||||||
|
await this.refreshBusinessDay(day);
|
||||||
|
refreshedDates.push(day.key);
|
||||||
|
} catch (error) {
|
||||||
|
failedDates.push(day.key);
|
||||||
|
this.logger.error(
|
||||||
|
`Daily report refresh failed for ${day.key}`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failedDates.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Daily report refresh incomplete; failed dates: ${failedDates.join(', ')}; refreshed dates: ${refreshedDates.join(', ') || 'none'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { refreshedDates };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runScheduledRefresh() {
|
private async runScheduledRefresh() {
|
||||||
const businessDate = shanghaiDateKey(new Date());
|
const now = new Date();
|
||||||
|
const businessDate = shanghaiDateKey(now);
|
||||||
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
|
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
|
||||||
this.refreshRunning = true;
|
this.refreshRunning = true;
|
||||||
try {
|
try {
|
||||||
const result = await this.refreshRollingWindow();
|
const result = await this.refreshRollingWindow(now);
|
||||||
this.lastRefreshBusinessDate = businessDate;
|
this.lastRefreshBusinessDate = businessDate;
|
||||||
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
|
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -132,12 +256,22 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async refreshBusinessDay(day: BusinessDay) {
|
private async refreshBusinessDay(day: BusinessDay) {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
const timeout = Math.min(
|
||||||
await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
|
120_000,
|
||||||
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
|
positiveInteger(process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS, DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS),
|
||||||
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
|
);
|
||||||
|
await this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await tx.$executeRaw(Prisma.sql`SELECT set_config('statement_timeout', ${`${timeout}ms`}, true)`);
|
||||||
|
const [lock] = await tx.$queryRaw<{ locked: boolean }[]>(Prisma.sql`
|
||||||
|
SELECT pg_try_advisory_xact_lock(${REPORT_LOCK_NAMESPACE}::integer, ${Number(day.key.replaceAll('-', ''))}::integer) AS locked
|
||||||
|
`);
|
||||||
|
if (!lock?.locked) throw new Error(`Daily reports for ${day.key} are being refreshed by another transaction`);
|
||||||
|
await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||||
|
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||||
|
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
|
||||||
|
|
||||||
await tx.$executeRaw(Prisma.sql`
|
await tx.$executeRaw(Prisma.sql`
|
||||||
INSERT INTO "DailyReconciliationReport" (
|
INSERT INTO "DailyReconciliationReport" (
|
||||||
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
|
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
|
||||||
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
|
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
|
||||||
@@ -169,7 +303,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
GROUP BY tenant.id, tenant.name, application.id, application.name
|
GROUP BY tenant.id, tenant.name, application.id, application.name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await tx.$executeRaw(Prisma.sql`
|
await tx.$executeRaw(Prisma.sql`
|
||||||
WITH costs AS (
|
WITH costs AS (
|
||||||
SELECT
|
SELECT
|
||||||
submit."messageRecordId",
|
submit."messageRecordId",
|
||||||
@@ -197,6 +331,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) AS delivered
|
) AS delivered
|
||||||
) legacy_receipt ON TRUE
|
) legacy_receipt ON TRUE
|
||||||
WHERE submit."submitStatus" = 'accepted'
|
WHERE submit."submitStatus" = 'accepted'
|
||||||
|
-- 应用成本归属于原短信日,仍包含该短信全部跨日补发尝试。
|
||||||
|
AND message."queuedAt" >= ${day.startAt}
|
||||||
|
AND message."queuedAt" < ${day.endAt}
|
||||||
GROUP BY submit."messageRecordId"
|
GROUP BY submit."messageRecordId"
|
||||||
)
|
)
|
||||||
INSERT INTO "DailyProfitReport" (
|
INSERT INTO "DailyProfitReport" (
|
||||||
@@ -249,7 +386,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
GROUP BY tenant.id, tenant.name, application.id, application.name
|
GROUP BY tenant.id, tenant.name, application.id, application.name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await tx.$executeRaw(Prisma.sql`
|
await tx.$executeRaw(Prisma.sql`
|
||||||
INSERT INTO "DailyProfitReport" (
|
INSERT INTO "DailyProfitReport" (
|
||||||
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
|
||||||
"tenantId", "tenantName", "applicationId", "channelId",
|
"tenantId", "tenantName", "applicationId", "channelId",
|
||||||
@@ -348,29 +485,34 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
GROUP BY channel.id, channel.name
|
GROUP BY channel.id, channel.name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application'));
|
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application'));
|
||||||
await tx.$executeRaw(qualityByChannelSql(day));
|
await tx.$executeRaw(qualityByChannelSql(day));
|
||||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
|
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
|
||||||
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
|
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
|
||||||
});
|
},
|
||||||
|
{ maxWait: 5_000, timeout },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
|
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
|
||||||
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
|
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
|
||||||
const dimensionId = dimensionType === 'application'
|
const dimensionId =
|
||||||
? Prisma.sql`application.id`
|
dimensionType === 'application'
|
||||||
: dimensionType === 'signature'
|
? Prisma.sql`application.id`
|
||||||
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
|
: dimensionType === 'signature'
|
||||||
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
|
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
|
||||||
const dimensionName = dimensionType === 'application'
|
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
|
||||||
? Prisma.sql`application.name`
|
const dimensionName =
|
||||||
: dimensionType === 'signature'
|
dimensionType === 'application'
|
||||||
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
|
? Prisma.sql`application.name`
|
||||||
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
|
: dimensionType === 'signature'
|
||||||
const applicationJoin = dimensionType === 'application'
|
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
|
||||||
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
|
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
|
||||||
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
|
const applicationJoin =
|
||||||
|
dimensionType === 'application'
|
||||||
|
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
|
||||||
|
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
|
||||||
|
|
||||||
return Prisma.sql`
|
return Prisma.sql`
|
||||||
WITH base AS (
|
WITH base AS (
|
||||||
@@ -548,7 +690,13 @@ const reportVolumeSumSelection = {
|
|||||||
failedUnits: true,
|
failedUnits: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number | null; unknownUnits?: number | null; successUnits?: number | null; failedUnits?: number | null }) {
|
function volumeSummary(sum: {
|
||||||
|
submittedUnits?: number | null;
|
||||||
|
sentUnits?: number | null;
|
||||||
|
unknownUnits?: number | null;
|
||||||
|
successUnits?: number | null;
|
||||||
|
failedUnits?: number | null;
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
submittedUnits: Number(sum.submittedUnits ?? 0),
|
submittedUnits: Number(sum.submittedUnits ?? 0),
|
||||||
sentUnits: Number(sum.sentUnits ?? 0),
|
sentUnits: Number(sum.sentUnits ?? 0),
|
||||||
@@ -559,11 +707,15 @@ function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ratioBps(numerator: number, denominator: number) {
|
function ratioBps(numerator: number, denominator: number) {
|
||||||
return denominator === 0 ? 0 : Math.round(numerator * 10_000 / denominator);
|
return denominator === 0 ? 0 : Math.round((numerator * 10_000) / denominator);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput {
|
function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput {
|
||||||
return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined };
|
return {
|
||||||
|
reportDate: dateFilter(query.dateFrom, query.dateTo),
|
||||||
|
tenantId: query.tenantId || undefined,
|
||||||
|
applicationId: query.applicationId || undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function profitWhere(query: ReportListQuery) {
|
function profitWhere(query: ReportListQuery) {
|
||||||
@@ -580,7 +732,9 @@ function profitWhere(query: ReportListQuery) {
|
|||||||
|
|
||||||
function qualityWhere(query: ReportListQuery) {
|
function qualityWhere(query: ReportListQuery) {
|
||||||
const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']);
|
const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']);
|
||||||
const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application';
|
const dimensionType = allowedDimensions.has(String(query.dimensionType))
|
||||||
|
? String(query.dimensionType)
|
||||||
|
: 'application';
|
||||||
const where: Prisma.DailyQualityReportWhereInput = {
|
const where: Prisma.DailyQualityReportWhereInput = {
|
||||||
dimensionType,
|
dimensionType,
|
||||||
reportDate: dateFilter(query.dateFrom, query.dateTo),
|
reportDate: dateFilter(query.dateFrom, query.dateTo),
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { SignatureRetirementService } from './signature-retirement.service';
|
||||||
|
|
||||||
|
describe('daily application messages and date formatting', () => {
|
||||||
|
function setup() {
|
||||||
|
const detections = [
|
||||||
|
{ id: 'a', tenantId: 't1', applicationId: 'app1', dimensionType: 'enterprise' },
|
||||||
|
{ id: 'b', tenantId: 't1', applicationId: 'app1', dimensionType: 'channel' },
|
||||||
|
{ id: 'c', tenantId: 't1', applicationId: 'app2', dimensionType: 'enterprise' },
|
||||||
|
{ id: 'd', tenantId: 't1', applicationId: null, dimensionType: 'enterprise' },
|
||||||
|
{ id: 'e', tenantId: 't2', applicationId: null, dimensionType: 'enterprise' },
|
||||||
|
].map((item) => ({
|
||||||
|
...item,
|
||||||
|
cycleId: `cycle-${item.id}`,
|
||||||
|
notificationTitle: '预警',
|
||||||
|
notificationContent: `冻结正文${item.id}`,
|
||||||
|
}));
|
||||||
|
const prisma = {
|
||||||
|
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue(detections) },
|
||||||
|
smsApplication: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
{ id: 'app1', name: '应用一' },
|
||||||
|
{ id: 'app2', name: '应用二' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
signatureRetirementMessage: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn().mockResolvedValue({}),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
return { prisma, service: new SignatureRetirementService(prisma as never) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates one frozen message per application per day including all dimensions and separate unbound tenants', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
expect(await service.publishNotifications('2026-09-09')).toEqual({ notificationDate: '2026-09-09', created: 4 });
|
||||||
|
const data = prisma.signatureRetirementMessage.create.mock.calls.map(([arg]) => arg.data);
|
||||||
|
expect(data[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: '应用一 · 签名清退预警',
|
||||||
|
detectionIds: ['a', 'b'],
|
||||||
|
content: '冻结正文a\n冻结正文b',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(new Set(data.map((item) => item.dailyGroupKey)).size).toBe(4);
|
||||||
|
expect(data.every((item) => item.notificationDate.toISOString() === '2026-09-09T00:00:00.000Z')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not regenerate already-published or historical single-detection messages', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
prisma.signatureRetirementMessage.findFirst.mockResolvedValue({ id: 'existing' });
|
||||||
|
expect((await service.publishNotifications('2026-09-09')).created).toBe(0);
|
||||||
|
expect(prisma.signatureRetirementMessage.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['2024-03-01', '2024-02-29'],
|
||||||
|
['2026-01-01', '2025-12-31'],
|
||||||
|
['2026-09-01', '2026-08-31'],
|
||||||
|
])('keeps T-1 semantics for %s without constructing formatters per row', async (date, expected) => {
|
||||||
|
const prisma = {
|
||||||
|
signatureRetirementDetection: {
|
||||||
|
findMany: jest.fn().mockResolvedValue(
|
||||||
|
Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: String(i),
|
||||||
|
detectionDate: new Date(`${date}T00:00:00Z`),
|
||||||
|
signatureId: 's',
|
||||||
|
tenantId: 't',
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
tenant: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
const spy = jest.spyOn(Intl, 'DateTimeFormat');
|
||||||
|
try {
|
||||||
|
const result = await new SignatureRetirementService(prisma as never).heatmap(date);
|
||||||
|
expect(result.items.every((item) => item.activityDate === expected)).toBe(true);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,17 +16,36 @@ describe('SignatureRetirementService dimensions', () => {
|
|||||||
task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'),
|
task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }> }).buildDimensions(rules, tasks);
|
const dimensions = (
|
||||||
|
service as unknown as {
|
||||||
|
buildDimensions: (
|
||||||
|
inputRules: unknown[],
|
||||||
|
inputTasks: unknown[],
|
||||||
|
) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }>;
|
||||||
|
}
|
||||||
|
).buildDimensions(rules, tasks);
|
||||||
|
|
||||||
expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2);
|
expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2);
|
||||||
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3);
|
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3);
|
||||||
expect(dimensions.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')?.approvedAt.toISOString()).toBe('2026-06-01T00:00:00.000Z');
|
expect(
|
||||||
expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true);
|
dimensions
|
||||||
expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel');
|
.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')
|
||||||
|
?.approvedAt.toISOString(),
|
||||||
|
).toBe('2026-06-01T00:00:00.000Z');
|
||||||
|
expect(
|
||||||
|
dimensions
|
||||||
|
.filter((item) => item.dimensionType === 'enterprise')
|
||||||
|
.every((item) => item.rule.ruleType === 'enterprise_application'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType,
|
||||||
|
).toBe('channel');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not monitor legacy carrier-null reporting facts', () => {
|
it('does not monitor legacy carrier-null reporting facts', () => {
|
||||||
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }).buildDimensions(
|
const dimensions = (
|
||||||
|
service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }
|
||||||
|
).buildDimensions(
|
||||||
[rule('enterprise_global', ''), rule('channel_global', '')],
|
[rule('enterprise_global', ''), rule('channel_global', '')],
|
||||||
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')],
|
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')],
|
||||||
);
|
);
|
||||||
@@ -41,73 +60,159 @@ describe('SignatureRetirementService dimensions', () => {
|
|||||||
|
|
||||||
it('publishes frozen alert content only in the notification phase', async () => {
|
it('publishes frozen alert content only in the notification phase', async () => {
|
||||||
const detection = {
|
const detection = {
|
||||||
id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1',
|
id: 'detection-1',
|
||||||
cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文',
|
detectionDate: new Date('2026-08-10T00:00:00.000Z'),
|
||||||
|
dimensionType: 'enterprise',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
cycleId: 'cycle-1',
|
||||||
|
notificationTitle: '企业签名清退预警',
|
||||||
|
notificationContent: '冻结后的预警正文',
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) },
|
signatureRetirementDetection: {
|
||||||
signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) },
|
findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]),
|
||||||
|
},
|
||||||
|
smsApplication: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
signatureRetirementMessage: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'message-1' }),
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]),
|
||||||
|
},
|
||||||
signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
|
signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
signatureRetirementWebhookDelivery: { upsert: jest.fn() },
|
signatureRetirementWebhookDelivery: { upsert: jest.fn() },
|
||||||
};
|
};
|
||||||
const notificationService = new SignatureRetirementService(prisma as never);
|
const notificationService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 });
|
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({
|
||||||
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) });
|
notificationDate: '2026-08-10',
|
||||||
|
created: 1,
|
||||||
|
});
|
||||||
|
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns enterprise application metadata for heatmap hover and search', async () => {
|
it('returns enterprise application metadata for heatmap hover and search', async () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([{ id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00Z'), signatureId: 'signature-1', channelId: null, tenantId: 'tenant-1' }]) },
|
signatureRetirementDetection: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'detection-1',
|
||||||
|
detectionDate: new Date('2026-08-10T00:00:00Z'),
|
||||||
|
signatureId: 'signature-1',
|
||||||
|
channelId: null,
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
|
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
|
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
tenant: { findMany: jest.fn().mockResolvedValue([]) },
|
tenant: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{
|
channelSignatureReportTask: {
|
||||||
signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'),
|
findMany: jest.fn().mockResolvedValue([
|
||||||
signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } },
|
{
|
||||||
channel: { name: '移动通道' },
|
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 heatmapService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
const result = await heatmapService.heatmap('2026-08-10');
|
const result = await heatmapService.heatmap('2026-08-10');
|
||||||
|
|
||||||
expect(result.dimensions).toEqual(expect.arrayContaining([
|
expect(result.dimensions).toEqual(
|
||||||
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
|
expect.objectContaining({
|
||||||
]));
|
dimensionType: 'enterprise',
|
||||||
|
signatureName: '测试签名',
|
||||||
|
applicationName: '测试应用',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
|
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists daily observing snapshots without opening alert cycles', async () => {
|
it('persists daily observing snapshots without opening alert cycles', async () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) },
|
signatureRetirementSuppression: {
|
||||||
signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) },
|
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||||
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) },
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
|
},
|
||||||
|
signatureRetirementRule: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]),
|
||||||
|
},
|
||||||
|
channelSignatureReportTask: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]),
|
||||||
|
},
|
||||||
|
signatureRetirementDetection: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'detection-1' }),
|
||||||
|
},
|
||||||
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
|
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
|
||||||
$queryRaw: jest.fn().mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
|
$queryRaw: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
|
||||||
};
|
};
|
||||||
const observingService = new SignatureRetirementService(prisma as never);
|
const observingService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ detectionDate: '2026-08-10', dimensions: 2, alerted: 0, healthy: 0, ineligible: 2 });
|
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({
|
||||||
|
detectionDate: '2026-08-10',
|
||||||
|
dimensions: 2,
|
||||||
|
alerted: 0,
|
||||||
|
healthy: 0,
|
||||||
|
ineligible: 2,
|
||||||
|
});
|
||||||
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
|
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
|
||||||
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'observing', acceptedBusinessCount: 10, cycleId: undefined, notificationTitle: null, notificationContent: null }) });
|
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'observing',
|
||||||
|
acceptedBusinessCount: 10,
|
||||||
|
cycleId: undefined,
|
||||||
|
notificationTitle: null,
|
||||||
|
notificationContent: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
|
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps the real unreported-signature aggregation to an independent page', async () => {
|
it('maps the real unreported-signature aggregation to an independent page', async () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
$queryRaw: jest.fn().mockResolvedValue([{
|
$queryRaw: jest.fn().mockResolvedValue([
|
||||||
signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业',
|
{
|
||||||
applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3,
|
signatureId: 'signature-1',
|
||||||
}]),
|
signatureName: '未报备签名',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
tenantName: '测试企业',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
applicationName: '测试应用',
|
||||||
|
messageCount: 7,
|
||||||
|
rowCount: 3,
|
||||||
|
},
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
const unreportedService = new SignatureRetirementService(prisma as never);
|
const unreportedService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
await expect(unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 })).resolves.toEqual({
|
await expect(
|
||||||
|
unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 }),
|
||||||
|
).resolves.toEqual({
|
||||||
date: '2026-08-10',
|
date: '2026-08-10',
|
||||||
items: [{ signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', applicationId: 'app-1', applicationName: '测试应用', messageCount: 7 }],
|
items: [
|
||||||
|
{
|
||||||
|
signatureId: 'signature-1',
|
||||||
|
signatureName: '未报备签名',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
tenantName: '测试企业',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
applicationName: '测试应用',
|
||||||
|
messageCount: 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
total: 3,
|
total: 3,
|
||||||
page: 2,
|
page: 2,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
@@ -124,7 +229,14 @@ describe('SignatureRetirementService dimensions', () => {
|
|||||||
|
|
||||||
it('returns a filtered historical message page with application metadata', async () => {
|
it('returns a filtered historical message page with application metadata', async () => {
|
||||||
const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') };
|
const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') };
|
||||||
const detection = { id: 'detection-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile' };
|
const detection = {
|
||||||
|
id: 'detection-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
signatureId: 'signature-1',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
carrier: 'mobile',
|
||||||
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]),
|
$queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]),
|
||||||
signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) },
|
signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) },
|
||||||
@@ -136,8 +248,27 @@ describe('SignatureRetirementService dimensions', () => {
|
|||||||
};
|
};
|
||||||
const messageService = new SignatureRetirementService(prisma as never);
|
const messageService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
await expect(messageService.listMessages({ dateFrom: '2026-08-01', dateTo: '2026-08-10', tenantId: 'tenant-1', applicationId: 'app-1', signatureKeyword: '测试', channelId: 'channel-1', page: 2, pageSize: 10 })).resolves.toEqual({
|
await expect(
|
||||||
items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })],
|
messageService.listMessages({
|
||||||
|
dateFrom: '2026-08-01',
|
||||||
|
dateTo: '2026-08-10',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
applicationId: 'app-1',
|
||||||
|
signatureKeyword: '测试',
|
||||||
|
channelId: 'channel-1',
|
||||||
|
page: 2,
|
||||||
|
pageSize: 10,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'message-1',
|
||||||
|
tenantName: '测试企业',
|
||||||
|
applicationName: '测试应用',
|
||||||
|
signatureName: '测试签名',
|
||||||
|
channelName: '测试通道',
|
||||||
|
}),
|
||||||
|
],
|
||||||
total: 21,
|
total: 21,
|
||||||
page: 2,
|
page: 2,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
@@ -160,22 +291,52 @@ describe('SignatureRetirementService dimensions', () => {
|
|||||||
it('requires a reason for temporary and permanent suppression', async () => {
|
it('requires a reason for temporary and permanent suppression', async () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
signatureRetirementMessage: {
|
signatureRetirementMessage: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ id: 'message-1', cycleId: 'cycle-1', detectionId: 'detection-1', createdAt: new Date() }),
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: 'message-1',
|
||||||
|
cycleId: 'cycle-1',
|
||||||
|
detectionId: 'detection-1',
|
||||||
|
createdAt: new Date(),
|
||||||
|
}),
|
||||||
findFirst: jest.fn().mockResolvedValue(null),
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
|
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
|
||||||
};
|
};
|
||||||
const suppressionService = new SignatureRetirementService(prisma as never);
|
const suppressionService = new SignatureRetirementService(prisma as never);
|
||||||
|
|
||||||
await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空');
|
await expect(
|
||||||
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空');
|
suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' }),
|
||||||
|
).rejects.toThrow('抑制原因不能为空');
|
||||||
|
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow(
|
||||||
|
'抑制原因不能为空',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function rule(ruleType: string, targetKey: string) {
|
function rule(ruleType: string, targetKey: string) {
|
||||||
return { id: `${ruleType}-${targetKey}`, ruleType, targetId: targetKey || null, targetKey, enabled: true, mobileWindowDays: 30, mobileThreshold: 1, unicomWindowDays: 30, unicomThreshold: 1, telecomWindowDays: 30, telecomThreshold: 1, messageTemplate: null, version: 1 };
|
return {
|
||||||
|
id: `${ruleType}-${targetKey}`,
|
||||||
|
ruleType,
|
||||||
|
targetId: targetKey || null,
|
||||||
|
targetKey,
|
||||||
|
enabled: true,
|
||||||
|
mobileWindowDays: 30,
|
||||||
|
mobileThreshold: 1,
|
||||||
|
unicomWindowDays: 30,
|
||||||
|
unicomThreshold: 1,
|
||||||
|
telecomWindowDays: 30,
|
||||||
|
telecomThreshold: 1,
|
||||||
|
messageTemplate: null,
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) {
|
function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) {
|
||||||
return { signatureId: 'signature-1', channelId, carrier, approvedAt: new Date(approvedAt), signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } }, channel: { name: channelName } };
|
return {
|
||||||
|
signatureId: 'signature-1',
|
||||||
|
channelId,
|
||||||
|
carrier,
|
||||||
|
approvedAt: new Date(approvedAt),
|
||||||
|
signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } },
|
||||||
|
channel: { name: channelName },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2222,3 +2222,20 @@
|
|||||||
## 2026-09-07 夜间累计发送量审核
|
## 2026-09-07 夜间累计发送量审核
|
||||||
|
|
||||||
原“非工作时间大批量营销发送”替换为每企业应用每夜累计所有业务短信的人工审核规则,所有企业应用默认通用5000条,支持应用阈值覆盖;不依赖分类、内容或任务名。CMPP、公开HTTP、客户端合并计数,第5001条及之后待审;分片/内部重试/补发/幂等重试不重复累计。统一在首次发送Worker提交前拦截,含定时任务到期及CMPP快速入队路径。短信审核复用现有字段、号码明细、详情和批量审核,按同应用同内容10秒窗口聚合,窗口关闭后审核;批准只释放该任务消息,不豁免后续发送。时间跨午夜连续,默认21:00至次日08:00(北京时间),夜间改时间待本夜结束生效,阈值修改不清零。详见phase-6-risk-review-plan.md的2026-09-07章节;该章节替代旧营销识别和单任务阈值语义。
|
原“非工作时间大批量营销发送”替换为每企业应用每夜累计所有业务短信的人工审核规则,所有企业应用默认通用5000条,支持应用阈值覆盖;不依赖分类、内容或任务名。CMPP、公开HTTP、客户端合并计数,第5001条及之后待审;分片/内部重试/补发/幂等重试不重复累计。统一在首次发送Worker提交前拦截,含定时任务到期及CMPP快速入队路径。短信审核复用现有字段、号码明细、详情和批量审核,按同应用同内容10秒窗口聚合,窗口关闭后审核;批准只释放该任务消息,不豁免后续发送。时间跨午夜连续,默认21:00至次日08:00(北京时间),夜间改时间待本夜结束生效,阈值修改不清零。详见phase-6-risk-review-plan.md的2026-09-07章节;该章节替代旧营销识别和单任务阈值语义。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 运营修复需求补充
|
||||||
|
|
||||||
|
每日签名清退预警按检测日期、企业和企业应用生成,每个应用每天仅一条消息;并发及重复执行不得重复生成,正文冻结,历史消息保留。首页保留原指标口径更新数字展示;质量检测四个Tab独立日期及分页,每页10/25/50/100默认25。修复监控阈值保存、创建通道误关闭、报备状态重复项及日期格式化器重复构造。具体规则与兼容边界见 [运营修复设计](operations-fixes-20260908.md)。不包含导入导出优化。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 分页交互补充
|
||||||
|
|
||||||
|
签名质量检测四个Tab的每页数量选择移至各自列表底部,与上一页、下一页和页码跳转放在同一分页区;保留独立日期、筛选和每页10/25/50/100,默认25,空结果也可选择数量。通道报备明细增加相同四档容量,默认25;修改容量回到第一页、清空当页勾选、保留已应用筛选,使用真实服务端分页,较早请求的成功或失败不得覆盖最新查询结果。本节补充前述运营修复要求;不更改后端接口、端口或数据模型,详见[运营修复设计](operations-fixes-20260908.md#分页交互补充2026-09-08)。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 报表生成可靠性补充
|
||||||
|
|
||||||
|
本节明确 5.19.1 中的事务范围为“每个日期独立事务”,四日窗口不共用一个长事务。对账、应用/通道利润及四类质量报表在同一日期内原子重建;失败保留该日旧报表,继续处理窗口内其他日期,只有四日全部成功才标记当天完成,失败下次检查仍可重试。默认启动 15 秒后执行、每小时检查,不承诺固定分钟触发。
|
||||||
|
|
||||||
|
应用利润成本只扫描目标日原短信关联的全部 accepted 提交,包括跨日补发;收入、成本快照、分片审计优先和历史成功回执兼容语义不变。通道维度仍按实际提交日统计。报表使用专属有限事务预算和数据库日锁,不调整发送/计费事务。T-5 及更早数据不被正常日任务改写,历史缺口须单独授权补齐;本轮不新增自动历史重算。实现与验收见 [日报生成超时修复](report-generation-reliability-20260908.md)。
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 运营页面与内存修复(2026-09-08)
|
||||||
|
|
||||||
|
状态:已授权实现、本地提交和测试环境部署,测试环境已完成验收(2026-09-08 13:39,运行提交2c228a9)。不包含推送、预生产部署或导入导出优化。补充签名清退、监控设计及UI规范,本节替代相关旧分页和汇总展示口径。
|
||||||
|
|
||||||
|
1. 固定北京时间日期/小时格式化器按模块复用,保持T-1、闰日、跨月年语义;真实检测规模对照验证内存,不更改检测阈值、发送和报备流程。
|
||||||
|
2. 每日清退预警在生成时按日期、企业、企业应用汇总,每应用每天只产生一条消息,并按消息分页;未绑定应用独立归组,保留原消息明细、已读、抑制与查询条件。数据库完成分组和分页,禁止仅合并当前页伪汇总。每日Webhook按企业应用分组,保留幂等键和冻结正文,不补发历史通知,验收不调用外部Webhook。
|
||||||
|
3. 首页指标保持原业务口径,以发送、质量、经营的清晰数字卡展示,单位独立、数值对齐、状态强调;加载/失败显示未取得数据,不能展示假零。CSS归页面所有者,不改共享历史样式。
|
||||||
|
4. 签名质量四个模块用四个Tab,分别保留日期、查询条件及页码,每页10/25/50/100,默认25。只请求当前模块;热力图保持真实30天快照与客户端维度分页,服务端质量及未报备分页上限同步100。切换和过期响应不得串数据。
|
||||||
|
5. 监控告警保存优先使用显式PROMTOOL_PATH,否则查找标准安装目录;仍强制promtool校验、原子替换、reload和失败恢复,不跳门禁。测试写入经真实API回读与Prometheus规则核验,恢复原阈值。
|
||||||
|
6. 创建通道只能关闭按钮或右上角叉主动关闭,遮罩和Escape无效;公共Modal新增可选控制,其他消费者默认行为保持。验收不创建或修改实际通道。
|
||||||
|
7. 通道报备状态筛选去重为业务状态,报备中覆盖reporting/exporting,报备失败覆盖failed/rejected;历史状态展示保持,查询必须命中全部对应记录。
|
||||||
|
|
||||||
|
验证:定向及前后端全量、类型/构建、格式/lint/CSS/安全/部署/包体门禁;真实PostgreSQL隔离集成、测试环境API/浏览器1600×1000、1366×768、390×844及内存对照。使用标准release完成精确提交计划、验证、preflight、prepare、deploy、verify。已有脏文件保护,只提交本轮精确文件/hunk。
|
||||||
|
|
||||||
|
## 每日消息兼容与事务
|
||||||
|
|
||||||
|
新增dailyGroupKey唯一键、notificationDate/applicationId和detectionIds数组,原代表detectionId/cycleId保留供历史读取。新增列可空或有空数组默认,无删除或历史重写。发布当天已由旧版本生成的检测不重复创建消息;下一检测日按新规则生成。一组包含全部企业/通道维度,不因运营商多条而增加未读数。查询以组内任意检测命中筛选并按消息去重,正文和明细可展开查看。整组抑制必须有原因并原子写入该消息包含的全部维度,旧消息继续原单维度抑制;较新的应用消息存在时禁止从旧组设置抑制。Webhook只为新汇总消息建立每日应用幂等任务,不补发历史任务。
|
||||||
|
|
||||||
|
回退应用时保留新增列和唯一键;旧版本只能识别代表明细且会恢复旧生成口径,所以消息行为回退需要停用相应调度后专项决策,不能称简单应用回退即可恢复新口径。正式数据库不造预警;并发与迁移在独立PostgreSQL schema验证,禁止启动检测/发送/Webhook生命周期。
|
||||||
|
|
||||||
|
|
||||||
|
## 验收结果
|
||||||
|
|
||||||
|
精确归档前端108项、API673项、类型/格式/样式/构建与质量门禁通过。真实PG并发每日应用唯一、分组查询和抑制通过,19788条实际热力图服务RSS增长49.81MiB。测试环境三视口真实浏览器、100条分页、状态历史exporting合并、阈值保存及恢复通过。详细证据和未执行项见testing-progress.md的2026-09-08 13:39记录;只发布测试环境,历史预警保留。
|
||||||
|
|
||||||
|
|
||||||
|
## 分页交互补充(2026-09-08)
|
||||||
|
|
||||||
|
本次单独授权为修改并本地提交,不包含推送、测试环境部署或预生产部署。补充上文第4项及通道报备明细的分页交互,不改变已部署版本的历史验收结论。
|
||||||
|
|
||||||
|
- 根因:质量检测容量Select位于顶部page-actions,与底部分页分离;报备明细硬编码pageSize=10,查询依赖只有page。现有GET /api/admin/report-details支持page/pageSize及最大100条,无需后端变更。
|
||||||
|
- 公共Pagination新增可选pageSize、pageSizeOptions、onPageSizeChange,将容量Select置于翻页区。只有传入容量和回调的消费者显示下拉;未传的既有消费者保持原界面与默认每页10的推算。页码输入独立按page重置草稿,避免effect级联更新;原Enter与页码边界保持。
|
||||||
|
- 签名质量、企业活跃度、通道活跃度、未报备签名都在底部显示10/25/50/100(默认25),包含零结果。日期与已应用查询条件继续按Tab独立保存;质量/未报备重新请求服务端第一页,热力图保留既有真实30天维度数据及客户端分页,改变容量回到第一页。
|
||||||
|
- 通道报备明细首次进入/刷新默认25,改变容量回到第一页、清空当页选择,保留已应用筛选及URL范围;总数与页面内容来自同一个真实分页接口。增加请求序号和卸载失效保护,过期成功与失败都不能覆盖最新列表或错误;当前请求失败继续真实展示。
|
||||||
|
- 实际新增容量消费者只有AdminAnalyticsPage的四个模块与AdminReportTasksPage;不改CSS、后端、数据库、权限、详情/状态修改/导出逻辑或其他列表的容量设置。共享Pagination的原消费者通过兼容单测和前端全量回归验证。
|
||||||
|
- 验收覆盖四档请求、翻页后改容量、空结果、独立日期/筛选、乱序响应、跳转输入草稿、三视口和刷新/路由切换。执行结果见testing-progress.md本轮分页记录;用例OPS-PAGE0908-01~05。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 日报生成超时修复
|
||||||
|
|
||||||
|
维护日期:2026-09-08。适用于对账单、利润报表、发送质量报表的共同生成任务。本文补充[需求 5.19.1](first-version-development-requirements.md#5191-报表对账)的执行与失败恢复机制,不改变收入、成本、计费条数、质量维度及 T+1 / T-4~T-1 业务口径。实施结果见[测试进度](testing-progress.md)。
|
||||||
|
|
||||||
|
## 证据与影响
|
||||||
|
|
||||||
|
预生产版本 `633ba597754c1b89943ea3a819f45042f41b019a` 在 2026-09-08 16:40~16:51 只读核验时,三类报表最新日期均为 9 月 2 日,9 月 3~7 日有短信却缺报表。保留日志有 93 次生成失败,最近 16:12 的错误为 Prisma 事务上限 5000ms、实际已耗时 7510ms。应用利润成本 CTE 未限定原短信日期;9 月 4 日只有 2492 条短信,却扫描 103450 条 accepted 提交。仅执行原 SELECT 的实际计划耗时 9090.976ms;添加原短信日期范围的只读候选耗时 90.515ms。单次对照可能受缓存影响,不代表完整任务提速比例或已经上线。
|
||||||
|
|
||||||
|
同日期三类报表共用事务,应用利润耗时使事务过期,在下一条通道利润语句处报错,已插入的对账单也回滚。原滚动任务遇到一个日期失败即退出,阻止后续日期执行。
|
||||||
|
|
||||||
|
## 最小修复设计
|
||||||
|
|
||||||
|
1. 应用利润的成本 CTE 在关联 `SmsMessageRecord` 后限定 `message.queuedAt >= startAt AND message.queuedAt < endAt`,继续累计这些短信的全部 accepted 提交,包括跨日补发。分片审计优先、无分片审计才兼容明确成功的历史回执;不按当前通道价格倒算,不改通道维度的实际提交日归属。
|
||||||
|
2. 每个日期仍在一个独立事务内原子重建对账、应用/通道利润及四个质量维度;任何失败都保留该日期旧报表,不能先删后在事务外插入。
|
||||||
|
3. 报表专用事务默认上限 30000ms、获取连接最长等待 5000ms。`REPORT_REFRESH_TRANSACTION_TIMEOUT_MS` 可设正整数毫秒,非法值回退默认,上限 120000ms;不修改其他业务事务的全局设置。事务内设置同上限的 PostgreSQL `statement_timeout`,避免单条异常 SQL 无界运行。
|
||||||
|
4. 同日期使用 PostgreSQL 事务级 advisory lock(固定报表命名空间 + YYYYMMDD)。取锁失败视为该日期未完成,不删除报表;事务结束自动释放锁,下一调度周期可重试。日锁同时保护不同 API 实例和同进程手动服务调用。
|
||||||
|
5. T-4~T-1 逐日执行;记录每个失败日期及错误后继续其他日期,最后汇总失败并向调用者抛错。只有四天全部成功,调度器才记录本日刷新完成;部分失败保持下个小时重试资格,不静默报告成功。启动 15 秒后的首次执行和默认每小时检查保持;销毁服务同时清除启动与周期定时器。
|
||||||
|
6. 本轮不新增迁移、持久化任务表、API 写入口或自动历史回算。T-5 及更早报表不被日常任务改写;已发现的历史缺口须在修复部署后按明确授权、日期清单单独补齐。进程重启仍按现有四日窗口执行,不能声称历史缺口永久恢复机制已实现。
|
||||||
|
|
||||||
|
## 验收与交付边界
|
||||||
|
|
||||||
|
- 定向回归覆盖日期边界、成本扫描范围、部分失败继续后续日期、失败重试/成功去重、并发、原子回滚和生命周期停止;真实 PostgreSQL 验证跨日尝试、部分分片成功及历史回执兼容、三类报表及重复生成一致性。
|
||||||
|
- 真实 SQL 性能与结果对照在预生产只允许 SELECT、限时和只读事务,不调用生成服务或写业务表。完整生成测试使用本机独立 PostgreSQL 测试库,不能以 mock 通过代替真实数据库证据。
|
||||||
|
- 执行 API 全量、类型/生产构建及现有相关质量门禁。没有前端改动,不改变页面、权限、查询 API、端口或租户过滤。
|
||||||
|
- 本轮授权修改代码并本地提交;不推送、不部署两环境、不补跑预生产报表、不发送短信或修改业务配置。测试数据仅在隔离本地测试库构造,结果与未验证项记入进度。
|
||||||
@@ -5223,3 +5223,51 @@ npm run verify:phase8
|
|||||||
|
|
||||||
- 633ba59双环境99项迁移、规则真实API/编辑取消/短信审核空态与刷新三尺寸验收;测试无控制台错误,预生产已完成两轮三尺寸业务API/交互检查,外部统计beacon网络错误单列;最后复核遇23:22:21账号再次停用而401,完整脚本未通过,不能宣称持续登录可用。具体结果与证据见testing-progress.md本次发布节。
|
- 633ba59双环境99项迁移、规则真实API/编辑取消/短信审核空态与刷新三尺寸验收;测试无控制台错误,预生产已完成两轮三尺寸业务API/交互检查,外部统计beacon网络错误单列;最后复核遇23:22:21账号再次停用而401,完整脚本未通过,不能宣称持续登录可用。具体结果与证据见testing-progress.md本次发布节。
|
||||||
- 夜间累计5001边界、共享入口、并发幂等、同内容聚合、跨午夜、覆盖、审核决定与入队失败恢复在两环境独立PG schema和Redis QA队列各11组通过。当前线上待审核为空,未发送或实际放行短信;不以空态浏览器验收替代这些业务规则验证。
|
- 夜间累计5001边界、共享入口、并发幂等、同内容聚合、跨午夜、覆盖、审核决定与入队失败恢复在两环境独立PG schema和Redis QA队列各11组通过。当前线上待审核为空,未发送或实际放行短信;不以空态浏览器验收替代这些业务规则验证。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 运营修复定向用例
|
||||||
|
|
||||||
|
- OPS0908-01:同日同应用多签名/多通道仅生成一条预警;不同应用分开;未绑定应用按企业隔离;两个服务实例并发和重复执行保持唯一;冻结正文不变。
|
||||||
|
- OPS0908-02:组内非代表签名可搜索命中;分页、未读均按消息计数;整组抑制原子更新;历史单条消息兼容,不重发历史Webhook。
|
||||||
|
- OPS0908-03:固定上海时区跨月年/闰日T-1结果不变;真实PostgreSQL 19788条检测通过实际heatmap服务验证内存,无发送生命周期。
|
||||||
|
- OPS0908-04:首页10项真实指标、单位、加载/失败;1600×1000、1366×768、390×844检查布局及刷新、切路由。
|
||||||
|
- OPS0908-05:质量四Tab各自日期、10/25/50/100默认25;查询、分页、切换保留状态,未访问Tab不请求;请求参数与真实API核对。
|
||||||
|
- OPS0908-06:监控阈值真实保存、回读配置版本及Prometheus规则,再恢复原阈值;promtool显式路径优先,标准两目录兼容,校验失败不得发布规则。
|
||||||
|
- OPS0908-07:创建通道遮罩和Escape不关闭,关闭按钮及叉可关闭;不得提交创建实际通道。报备状态每个业务标签仅一项,查询覆盖reporting/exporting和failed/rejected历史值。
|
||||||
|
|
||||||
|
执行证据见testing-progress.md,设计见operations-fixes-20260908.md。
|
||||||
|
|
||||||
|
|
||||||
|
### OPS0908 测试环境执行记录(2026-09-08 13:39)
|
||||||
|
|
||||||
|
OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对应时间记录。真实PG覆盖并发分组与19788行规模;真实浏览器覆盖三视口和阈值保存恢复。通道失败历史状态暂无非空样本,外部Webhook和次日自然调度未执行,不扩写为全链路发送通过。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 分页交互补充
|
||||||
|
|
||||||
|
以下用例补充OPS0908-05及通道报备明细分页规则;功能验收使用真实API及其数据库结果,API替身只用于单元测试的乱序/错误隔离。
|
||||||
|
|
||||||
|
| 编号 | 操作 | 预期结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| OPS-PAGE0908-01 | 依次进入签名质量检测四Tab,在有数据和空结果下查看列表底部并选择10/25/50/100 | 默认25;每页数量与翻页控件在同一底部分页区,顶部不再显示容量;空结果可选择且不能翻到不存在页面 |
|
||||||
|
| OPS-PAGE0908-02 | 各Tab设置不同日期/筛选和容量,翻页后改容量,切换Tab再返回 | 各自保留状态;改容量回第1页;未提交日期不影响分页请求;质量和未报备请求真实page/pageSize,热力图按既有真实维度分页 |
|
||||||
|
| OPS-PAGE0908-03 | 打开通道报备明细,设置筛选,到第2页勾选后依次改变四档容量 | 默认25;真实接口page=1及正确pageSize,总数/页数/行数一致;筛选与URL范围保留,旧勾选清空,不执行状态或发送操作 |
|
||||||
|
| OPS-PAGE0908-04 | 隔离测试让旧25条查询晚于新100条返回,分别返回成功和失败 | 旧结果和旧错误均不能覆盖最新查询;当前失败显示真实错误;组件卸载后旧请求失效 |
|
||||||
|
| OPS-PAGE0908-05 | 在1600×1000、1366×768、390×844检查两页面,刷新/跨路由,回归原公共分页消费者 | 下拉完整可见、可操作,表格内部滚动不造成页面整体溢出;旧消费者不新增容量控件;跳转输入在真实页码改变后重置,返回旧页不恢复未提交草稿 |
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 日报生成超时与失败恢复
|
||||||
|
|
||||||
|
依据 [日报生成超时修复](report-generation-reliability-20260908.md)。涉及造数和失败注入的用例仅在本地独立 PostgreSQL 测试库执行;预生产仅限时只读核验,短信发送、业务配置变更与历史补跑不包含在验收授权中。
|
||||||
|
|
||||||
|
| 编号 | 场景 | 预期结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| TC-DAILY-0908-01 | 大量窗口外历史记录下,对照目标日期应用利润原 SELECT 与限定原短信日期后的 SELECT | 除生成时间外结果字段逐项一致;成本扫描受原短信日期限定,记录实际执行计划和耗时,不能仅比较返回行数 |
|
||||||
|
| TC-DAILY-0908-02 | 原短信日后跨天、跨通道 accepted 补发,最终收入归最新成功提交 | 应用累计所有关联成功分片成本,仍归原短信日;通道按实际提交日归属且收入不重复;不存在按提交日期裁剪应用成本 |
|
||||||
|
| TC-DAILY-0908-03 | 长短信部分分片成功、审计存在但零成功、无审计但旧回执成功、失败/未知提交 | 审计优先;零成功不退回历史回执计整条成本;只有无审计且明确旧成功回执时按计费分片数兼容 |
|
||||||
|
| TC-DAILY-0908-04 | 北京时间零点、跨月/跨年、目标日起点及终点边界 | 每次恰好 T-4~T-1,起点包含、终点排除;不生成当天,不改写 T-5 更早报表 |
|
||||||
|
| TC-DAILY-0908-05 | 某日对账重建后注入真实 SQL 错误,其他三日继续,之后恢复再执行 | 失败日三表旧结果全部保留、无半成品;其余日期成功;返回含失败/成功日期的错误,不标记当天完成;重试成功后相同日期无重复 |
|
||||||
|
| TC-DAILY-0908-06 | 同实例重叠触发、两个事务/服务实例同时刷新同日 | 同实例定时入口防重入;数据库日锁未获取时不删除报表,明确失败并保留重试资格;锁释放后可重算且无重复 |
|
||||||
|
| TC-DAILY-0908-07 | 默认/有效/非法/超大事务超时配置,实际 PostgreSQL 限时及事务结束 | 默认 30000ms、maxWait 5000ms;非法值回退默认、最大 120000ms;statement_timeout 仅当前事务有效,其他业务事务不改变 |
|
||||||
|
| TC-DAILY-0908-08 | 失败后次周期、成功后同日重复周期,以及启动 15 秒内销毁服务 | 失败继续重试、全成功后同日跳过;销毁清除启动及周期定时器,无销毁后新任务 |
|
||||||
|
| TC-DAILY-0908-09 | 真实生成后调用报表查询/汇总/分页及 CSV | 数据与 PostgreSQL 一致;既有日期/租户/维度筛选和金额精度不变,API 无假成功或静态数据 |
|
||||||
|
|||||||
@@ -4710,3 +4710,57 @@ git diff --check
|
|||||||
- 真实页面:测试与预生产均使用真实管理员、API及PG数据,1600×1000、1366×768、390×844检查规则读取、阈值5000、北京时间21:00–08:00、人工审核动作锁定、编辑取消、短信审核空态、刷新和路由切换;无整页横向溢出,相关GET均200,未保存业务规则。预生产Codex_admin最初停用/删除,用户23:16:06恢复后原凭据可登录,前两轮三尺寸业务GET均200且检查完成,仅static.cloudflareinsights.com统计beacon出现连接重置;第三轮23:21:57登录后,23:22:21发生user.disabled操作,最后回读401。23:24再试仍停用,PG确认status=disabled、sessionVersion=2;未新建、启用或重置账号。保留已完成页面证据,最终完整零异常脚本未通过,不声称持续登录可用或整个控制台零错误。
|
- 真实页面:测试与预生产均使用真实管理员、API及PG数据,1600×1000、1366×768、390×844检查规则读取、阈值5000、北京时间21:00–08:00、人工审核动作锁定、编辑取消、短信审核空态、刷新和路由切换;无整页横向溢出,相关GET均200,未保存业务规则。预生产Codex_admin最初停用/删除,用户23:16:06恢复后原凭据可登录,前两轮三尺寸业务GET均200且检查完成,仅static.cloudflareinsights.com统计beacon出现连接重置;第三轮23:21:57登录后,23:22:21发生user.disabled操作,最后回读401。23:24再试仍停用,PG确认status=disabled、sessionVersion=2;未新建、启用或重置账号。保留已完成页面证据,最终完整零异常脚本未通过,不声称持续登录可用或整个控制台零错误。
|
||||||
- 现存独立问题:测试安全代理发布前已因/run/cmpp-security-agent缺失反复启动失败(226/NAMESPACE),未扩大本轮修改;预生产ReportsService日报刷新在发布前21:34、22:34及发布后23:12仍出现5000ms事务超时,发送/报备Worker没有本轮新增错误,不能称所有应用日志正常。两项留待专项诊断,不影响已执行的夜间规则只读及隔离验收。
|
- 现存独立问题:测试安全代理发布前已因/run/cmpp-security-agent缺失反复启动失败(226/NAMESPACE),未扩大本轮修改;预生产ReportsService日报刷新在发布前21:34、22:34及发布后23:12仍出现5000ms事务超时,发送/报备Worker没有本轮新增错误,不能称所有应用日志正常。两项留待专项诊断,不影响已执行的夜间规则只读及隔离验收。
|
||||||
- 证据:本机%TEMP%/cmpp-night-risk-20260907的测试/构建日志、两环境三尺寸截图、测试browser.json及预生产browser-observations.json/最后一次browser-failed.json;两机独立恢复目录before/after、verification.json、migrate.log、services及storage摘要,预生产account-compare.json。未发送/补发/重投/重新入队真实短信,未执行真实供应商高并发或实际备份恢复;线上审核当前为空,同内容审核/续发恢复由真实PG和隔离Redis验证,不能冒充真实短信审核放行。
|
- 证据:本机%TEMP%/cmpp-night-risk-20260907的测试/构建日志、两环境三尺寸截图、测试browser.json及预生产browser-observations.json/最后一次browser-failed.json;两机独立恢复目录before/after、verification.json、migrate.log、services及storage摘要,预生产account-compare.json。未发送/补发/重投/重新入队真实短信,未执行真实供应商高并发或实际备份恢复;线上审核当前为空,同内容审核/续发恢复由真实PG和隔离Redis验证,不能冒充真实短信审核放行。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 运营修复实施与本地验证(测试部署前)
|
||||||
|
|
||||||
|
本轮授权为格式化器修复及六项运营需求、本地提交、测试部署;未授权推送或预生产发布,不处理导入导出优化。开始基线main 50ae372,保留发布工具、部署文档、metrics及更早脏文件,只纳入本轮精确文件或文档追加hunk。
|
||||||
|
|
||||||
|
根因:热力图每行多次新建Intl.DateTimeFormat,独立进程39756次调用RSS约55MB至1169MB;复用格式化器对照约55MB至63MB。阈值保存日志明确为spawn /usr/bin/promtool ENOENT,而官方安装目录为/usr/local/bin。消息旧按检测维度生成,现新增每日应用唯一键;筛选旧标签将内部历史状态重复呈现。
|
||||||
|
|
||||||
|
已实现规则见operations-fixes-20260908.md。API全量64套674项通过;前端23文件108项通过(--maxWorkers=2;首次高并发运行出现4项超时,未放宽断言,限定并发重跑通过)。API构建、前端生产构建、安全/部署/包体检查通过;CSS新增页面归属已登记。真实测试API连接本地候选UI检查四Tab日期独立、创建弹窗遮罩/Escape及关闭、三个视口无水平溢出,控制台无错误;截图尚需部署后等待指标完成加载再验收。
|
||||||
|
|
||||||
|
待执行:精确提交归档验证、真实PG隔离并发与规模测试、标准测试部署、线上阈值保存恢复及三视口浏览器验收。测试过程不发短信、不创建真实通道或客户配置、不触发外部Webhook;未完成项目不能视为上线通过。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 13:39 运营修复测试环境验收完成
|
||||||
|
|
||||||
|
- 本地业务提交2c228a94e13b628447720c24bed45fb169acca6f,测试环境已部署该精确版本;没有推送或预生产部署。历史预警不重写,当日旧版本已生成消息不重复生成,后续新预警按每日企业应用唯一消息。
|
||||||
|
- 精确提交归档:前端23文件108项、API64套673项全量通过,类型/格式/lint/样式/CSS检查通过。工作区先前674项含1项未提交metrics测试,未夹带到发布包。候选生产构建、安全、部署、包体检查通过;2个exhaustive-deps warning(无lint error)保留记录。
|
||||||
|
- 真实PostgreSQL隔离schema cmpp_fix_20260908_1788844913115:两个服务实例并发,5条检测归为4条应用/企业消息;重复执行0新增;非代表签名搜索、按消息分页/未读、整组事务抑制通过。该schema保留审计;业务public表未造测试配置,未启动检测/发送/Webhook生命周期。
|
||||||
|
- 实际heatmap服务处理19788条PG检测耗534ms,RSS253353984→305582080字节,增长49.81MiB;仅隔离进程GC后302989312字节。相同调用规模的旧表达式此前独立进程增长约1.06GiB,此次不是单凭服务重启判断效果。正式测试API发布前RSS约417MiB;发布后同一PID101308经多轮页面查询约230–262MiB(观察至13:39),未进行长时/并发导入验收,导入导出优化未实施。
|
||||||
|
- Edge真实测试API浏览器:1600×1000、1366×768、390×844;首页10项数据真实加载、无水平溢出;4Tab独立日期及10/25/50/100选项,100条服务端回读;刷新/路由切换、创建弹窗遮罩/Escape不关闭及按钮关闭、报备状态唯一项通过。窄屏截图等待侧栏过渡结束后复核;脚本异常与console error均0。Browser插件不可用,按frontend-testing-debugging规范使用本机Playwright/Edge。
|
||||||
|
- 真实报备API共258条,全部历史exporting;按reporting或exporting筛选均返回258条,证明合并标签不丢历史记录。failed/rejected实际样本为0,对应查询均0,失败历史状态真实非空样本未覆盖。
|
||||||
|
- 阈值真实页面85→84,版本3→4且effective;再恢复85,版本5/effectiveVersion5。PG只读回查hostMemory={warning:85,critical:95};Prometheus实际规则85/95且health=ok。public短信记录全程119509,完成迁移99→100。
|
||||||
|
- 标准release发布编号20260908T052302-2c228a94e13b-2c6dd8a1:plan→精确提交已执行validation证据→preflight→prepare→deploy→verify通过,verificationWarnings为空。独立恢复资产/var/backups/cmpp-platform/20260908T052302-2c228a94e13b-2c6dd8a1-attempt-1788845221951986051;备份可读和摘要已检,不代表实际恢复演练。旧候选及旧依赖均保留。
|
||||||
|
- 首次使用标准工具暴露并修复:测试sudo交互掩码输入、归档EOL绑定兼容现有CRLF、前端worker上限2、vendor链接以整个应用目录为边界(仍拒绝外部目标)。Windows31项29过2平台跳过,测试机Linux31项全部通过。工具及其原有第一期文档仍在受保护未提交集合中,未将前一会话整批工具夹带提交;实际工具摘要绑定发布计划。
|
||||||
|
- 原保护项AGENTS.md、metrics两文件、package.json、三份部署脚本的diff与开工快照相同。相关需求/设计/测试用例已同步;本轮验收文档另做精确增量提交。证据:本机.local-data/releases/上述编号/以及%TEMP%/cmpp-fixes-20260908/(browser-acceptance.json、threshold-browser.log、status-api.log及deployed截图)。自动发布报告不自动导入人工业务结果,人工验收见同目录business-acceptance.md与本节。
|
||||||
|
- 未执行:预生产发布、Git推送、真实短信发送/重投、外部Webhook发送(测试机配置0个)、次日定时自然触发、长时导入压力与实际备份恢复演练。已有每小时日报事务超时线索另案保留,不在本轮六项及格式化器修复范围。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 14:49 分页交互修复与提交前验收
|
||||||
|
|
||||||
|
- 授权:两项分页修复并本地提交,不推送、不部署。开工main/HEAD为6d3c783;只读ls-remote确认远端main为50ae372,本地领先2提交、暂存为空。发布工具、metrics和既有文档等修改继续保护;相关脏文档只提交本轮追加内容。
|
||||||
|
- 需求/设计:[需求分页补充](first-version-development-requirements.md#2026-09-08-分页交互补充)、[运营修复分页补充](operations-fixes-20260908.md#分页交互补充2026-09-08)、用例OPS-PAGE0908-01~05。没有后端、端口、CSS或数据库结构变更。
|
||||||
|
- 只读复现:真实测试页面质量Tab容量控件位于page-actions;GET /api/admin/report-details返回pageSize=10、total=294、10行且页面无容量选择。当前代码同样固定10。新增deferred回归还明确复现旧25条请求晚返回覆盖100条新列表(修复前失败),据此加请求序号/卸载失效保护,当前错误继续显示。
|
||||||
|
- 实现:四个质量Tab的容量Select与各自底部分页合并,空结果保留选择;报备明细默认25且支持10/25/50/100,改容量回首页、清空勾选、保留已应用筛选。公共Pagination新props可选,旧消费者不出现额外下拉;以独立输入组件按页码重置跳转草稿,消除既有effect触发的质量门禁错误。
|
||||||
|
- 代码验证:node node_modules/vitest/vitest.mjs run --maxWorkers=2,全量24文件118项通过;定向包括质量5项、报备工作台9项、公共分页3项。TypeScript --noEmit、Vite生产构建、changed-code格式与ESLint、结构检查、Stylelint、CSS治理及15项工具测试、安全/部署检查、包体门禁、git diff --check通过。当前环境npm不在PATH,直接执行package.json对应的已有Node CLI,未安装依赖。ESLint保留两个既有loadData依赖warning,0 error;Chart分包约181.65KiB gzip、入口106.42KiB gzip,包体门禁通过。
|
||||||
|
- 真实浏览器:Browser插件及其skill未提供,使用本机Playwright/Edge。最终生产构建本地127.0.0.1:4173临时预览代理到测试100.93.204.60:12026真实API;后端精确版本仍2c228a94e13b628447720c24bed45fb169acca6f,cmpp-api active、Redis PONG。正常使用已有专用管理员登录并退出,无认证状态落盘。未修改测试运行产物或后端端口。
|
||||||
|
- 验收结果:四Tab底部容量、独立日期/容量保留、三视口1600×1000/1366×768/390×844下拉完整可操作、刷新和跨路由通过,页面无水平溢出,console/pageerror/HTTP业务错误均0。签名质量2026-08-20真实10个结果,四档均返回完整10行;未报备样本为空,四档空态可选且无越界。报备明细总294,四档分别返回10/25/50/100行,第二页请求容量正确,回25后回到第1页且勾选清空;按现有签名搜索命中6条,改容量后关键词/总数保留;无匹配词返回0且分页正常。原状态记录页面未新增下拉,真实页码跳转仍有效。
|
||||||
|
- 验收脚本问题与边界:独立Vite开发服务器曾出现本机连接超时,改用同一验收进程创建/关闭生产预览后通过;一次脚本假定Escape关闭Select导致等待超时,按当前触发按钮关闭方式修正后全流程通过,未扩改Select。错误/乱序以隔离测试验证;未报备真实非空、热力图超过100个维度、全部旧分页消费者的逐页人工验收未覆盖。真实API数据来自现有服务及PostgreSQL查询;本轮不修改数据库,未另取得测试受限数据库配置作直接SQL对账。
|
||||||
|
- 证据位于%TEMP%/cmpp-pagination-20260908:before-quality/report截图、acceptance.json、filter-acceptance.json、三视口截图、frontend-tests.log、build.log、format.log、lint.log和开工diff快照。截图/脚本/凭据不进入提交。临时预览已关闭。
|
||||||
|
- 交付边界:本地代码、需求、设计和用例完成;本节随本轮精确文件/追加hunk作本地提交,提交号见Git记录。不推送、不测试部署、不预生产部署;不发送/补发/重投/入队短信,不变更余额、通道、客户配置或恢复管理员。测试环境仍运行旧2c228a9,本地提交不等于已上线。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-08 17:14 日报生成超时修复与本地提交前验收
|
||||||
|
|
||||||
|
- 授权:修改代码并本地提交,不推送、不测试部署、不预生产部署、不补跑历史报表。开工 main/HEAD 为 2a9d03be2edc06dbd9a494e971e6b04a8336619f,暂存空;只读 ls-remote 回读远端 main 为 50ae37242bc33acf1038fb62618fc4cd95409956,本地领先 3 提交。17 个已有脏跟踪文件及原未跟踪发布工具/诊断脚本继续保护;涉及三份旧脏文档仅追加并精确暂存本轮增量。
|
||||||
|
- 需求/设计:[可靠性补充](first-version-development-requirements.md#2026-09-08-报表生成可靠性补充)、[日报生成超时修复](report-generation-reliability-20260908.md);TC-DAILY-0908-01~09。明确单日期三类报表原子事务、T-4~T-1 和既有财务口径,历史缺口不自动扩窗。
|
||||||
|
- 根因证据:预生产 633ba597754c1b89943ea3a819f45042f41b019a 三类表最新 reportDate 均为 9 月 2 日;9 月 3~7 日源短信非空。保留日志 93 次事务超时,9 月 8 日 16:12 的上限 5000ms、实际 7510ms。成本 CTE 扫全部 accepted 历史提交,单条 SELECT 实测 9090.976ms 已超过整日报表事务预算。
|
||||||
|
- 实现:应用成本 CTE 按原短信 queuedAt 限定日期,保留全部跨日提交的成功分片成本;日事务默认 30000ms、maxWait 5000ms,REPORT_REFRESH_TRANSACTION_TIMEOUT_MS 正整数覆盖且最大 120000ms,并设置事务局部 statement_timeout。删除前获取 PostgreSQL 日期 advisory lock;单日失败保留旧报表并继续其他日期,最终汇总抛错,只有全成功才标记本日完成。启动/周期定时器销毁清理,调度日与窗口共用同一时刻。未新增迁移/API 写入口/历史重算队列。两份已修改 TS 按当前 Prettier 门禁格式化,并显式忽略原 refundCents 解构值以修复该文件既有 lint 错误,列表/CSV 语义不变。
|
||||||
|
- 预生产只读结果一致性:在同一 REPEATABLE READ READ ONLY 事务中、statement_timeout=15000ms,9 月 4 日原 SELECT 8919ms、当前候选 SELECT 100ms;两行全部结果列(含金额,按数组避免同名列丢失)摘要均为 576592fc45b7f3137882229321eaf905d1e9bba12631ccfe1a0b2016c759231b。仅移除 INSERT 头部执行 SELECT,防写入检查通过;事务最终 ROLLBACK,不调用线上生成服务。这是单日查询对照,缓存可能影响耗时,不宣称完整任务同比提速或全日期金额对账已完成。
|
||||||
|
- 代码验收:API 全量 64 套 694 项通过(工作区包含原保护 metrics 的 1 项额外测试,未夹带提交);最终报表定向 1 套 29 项通过。API TypeScript 生产构建、前端 TypeScript(既有 lint 组成)、changed-code Prettier/ESLint、结构检查、Stylelint、CSS 治理及 15 项工具测试、安全/部署静态门禁、git diff --check 通过。npm 当前不在 PATH,执行 package.json 对应 Node CLI;未安装依赖。API 命令为 node node_modules/jest/bin/jest.js --runInBand、node node_modules/typescript/bin/tsc -p tsconfig.build.json。
|
||||||
|
- 真实数据库/API:新增 tools/testing/verify-report-refresh.mjs,强制独立 REPORT_TEST_DATABASE_URL、loopback 地址及 cmpp_report_test_ 数据库前缀,不使用业务 DATABASE_URL。独立本机 PostgreSQL 8 组通过:7 个报表维度与完整自然日、跨日补发/审计优先/历史兼容、重复幂等、真实 SQL 失败三表回滚且后续日期成功、跨连接日期锁、超过旧 5 秒的成功事务、200ms 限额超时回滚恢复、真实 ReportsController HTTP 筛选/分页/汇总及三类 CSV。10 条消息/10 次提交的应用 9 月 4 日收入 4000、成本 800、利润 3200(0.0001 元整数单位)一致;单次注入 5.2 秒延迟后单日事务实际 5248ms 成功。金额源列采用生产 bigint,报表表完整列/类型/唯一约束,源表只建查询用列,未做全库迁移验收。测试 schema 残留 0,临时 PostgreSQL 已停止;测试资料目录保留。
|
||||||
|
- 证据:%TEMP%/cmpp-report-fix-20260908/{baseline.json,api-tests.log,report-tests-final.log};%TEMP%/cmpp-report-diagnosis-20260908/{profile-select.cjs.result.jsonl,compare-candidate.cjs.result.jsonl};%TEMP%/cmpp-report-pg-1788858316792/integration.log。脚本、源库结果未输出凭据或短信正文。
|
||||||
|
- 未执行:预生产完整任务写入验收/自然定时观察、两环境部署、历史缺口补齐、完整应用登录与浏览器。本轮无前端改动;本地 HTTP 是独立真实报表 Controller/Service/PG,不代表完整认证/UI 验收。旧 api/tools/verify-report-recalculation.ts 含过期金额断言和业务造数,不执行也不在本轮改写。T-5 更早缺口仍须部署修复后单独授权补齐。
|
||||||
|
- 交付:本地报表代码、测试工具、需求/设计/用例和本节一起按精确 7 文件/文档增量提交,提交号以 Git 为准;未推送、未测试部署、未预生产部署、未短信发送/补发/重投/入队,未修改业务余额、通道、客户配置或管理员。提交前保护校验确认全部已有跟踪修改未被覆盖。
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export type SignatureRetirementDetection = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SignatureRetirementMessage = {
|
export type SignatureRetirementMessage = {
|
||||||
|
dailyGroupKey?: string | null;
|
||||||
|
detections?: Array<SignatureRetirementDetection & { signatureName?: string; channelName?: string }>;
|
||||||
id: string;
|
id: string;
|
||||||
detectionId: string;
|
detectionId: string;
|
||||||
cycleId: string;
|
cycleId: string;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.admin-analytics-page .page-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-analytics-page .page-heading {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-analytics-page [hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.admin-analytics-page .page-heading {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-analytics-page .page-actions {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AdminAnalyticsPage } from './AdminAnalyticsPage';
|
||||||
|
|
||||||
|
const { api } = vi.hoisted(() => ({
|
||||||
|
api: { getSignatureQuality: vi.fn(), getSignatureRetirementHeatmap: vi.fn(), getUnreportedSignatures: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
|
||||||
|
|
||||||
|
describe('independent analytics tabs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
|
||||||
|
api.getUnreportedSignatures.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
|
||||||
|
api.getSignatureRetirementHeatmap.mockResolvedValue({ items: [], dimensions: [] });
|
||||||
|
});
|
||||||
|
it('loads only the visited tab and preserves independent dates when switching back', async () => {
|
||||||
|
render(<AdminAnalyticsPage />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.getSignatureQuality).toHaveBeenCalledWith(expect.objectContaining({ pageSize: 25 })),
|
||||||
|
);
|
||||||
|
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled();
|
||||||
|
const quality = screen.getByRole('region', { name: '签名通道发送质量' });
|
||||||
|
fireEvent.change(within(quality).getByLabelText('统计日期'), { target: { value: '2026-08-20' } });
|
||||||
|
fireEvent.click(within(quality).getByRole('button', { name: '查询统计' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.getSignatureQuality).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' })),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '未报备签名' }));
|
||||||
|
await waitFor(() => expect(api.getUnreportedSignatures).toHaveBeenCalledTimes(1));
|
||||||
|
const unreported = screen.getByRole('region', { name: '未报备签名' });
|
||||||
|
fireEvent.change(within(unreported).getByLabelText('统计日期'), { target: { value: '2026-08-25' } });
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '签名通道发送质量' }));
|
||||||
|
expect(within(quality).getByLabelText('统计日期')).toHaveValue('2026-08-20');
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '未报备签名' }));
|
||||||
|
expect(within(unreported).getByLabelText('统计日期')).toHaveValue('2026-08-25');
|
||||||
|
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['签名通道发送质量', 'getSignatureQuality'],
|
||||||
|
['未报备签名', 'getUnreportedSignatures'],
|
||||||
|
] as const)('keeps %s page size in the footer and resets the page with applied filters', async (tab, method) => {
|
||||||
|
api[method].mockImplementation(async (query) => ({ ...query, total: 120, items: [] }));
|
||||||
|
render(<AdminAnalyticsPage />);
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: tab }));
|
||||||
|
const panel = screen.getByRole('region', { name: tab });
|
||||||
|
await waitFor(() => expect(within(panel).getByRole('button', { name: '下一页' })).toBeEnabled());
|
||||||
|
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-20' } });
|
||||||
|
fireEvent.click(within(panel).getByRole('button', { name: '查询统计' }));
|
||||||
|
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' })));
|
||||||
|
fireEvent.click(within(panel).getByRole('button', { name: '下一页' }));
|
||||||
|
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })));
|
||||||
|
// An unsubmitted date must not alter a pagination request.
|
||||||
|
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-21' } });
|
||||||
|
const size = within(panel).getByLabelText(/^每页数量/);
|
||||||
|
expect(size.closest('.ui-pagination')).not.toBeNull();
|
||||||
|
expect(size.closest('.page-actions')).toBeNull();
|
||||||
|
fireEvent.click(size);
|
||||||
|
expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
|
||||||
|
'10 条/页',
|
||||||
|
'25 条/页',
|
||||||
|
'50 条/页',
|
||||||
|
'100 条/页',
|
||||||
|
]);
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: '100 条/页' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api[method]).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ page: 1, pageSize: 100, date: '2026-08-20' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(within(panel).getByLabelText('跳转页码')).toHaveValue(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['企业签名活跃度', '通道签名活跃度'])(
|
||||||
|
'preserves %s footer size independently even with no rows',
|
||||||
|
async (tab) => {
|
||||||
|
render(<AdminAnalyticsPage />);
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: tab }));
|
||||||
|
await waitFor(() => expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1));
|
||||||
|
const panel = screen.getByRole('region', { name: tab });
|
||||||
|
const size = within(panel).getByLabelText(/^每页数量/);
|
||||||
|
expect(size.closest('.ui-pagination')).not.toBeNull();
|
||||||
|
expect(within(panel).getByRole('button', { name: '下一页' })).toBeDisabled();
|
||||||
|
fireEvent.click(size);
|
||||||
|
fireEvent.click(screen.getByRole('option', { name: '50 条/页' }));
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '签名通道发送质量' }));
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole('region', { name: '签名通道发送质量' })).getByLabelText(/^每页数量/),
|
||||||
|
).toHaveTextContent('25 条/页');
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: tab }));
|
||||||
|
expect(size).toHaveTextContent('50 条/页');
|
||||||
|
expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useDeferredValue, useEffect, useState } from 'react';
|
import { useDeferredValue, useEffect, useRef, useState } from 'react';
|
||||||
import { BarChart3, Eye, Search, X } from 'lucide-react';
|
import { BarChart3, Eye, Search, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
type UnreportedSignatureItem,
|
type UnreportedSignatureItem,
|
||||||
type PagedResult,
|
type PagedResult,
|
||||||
} from '@/api/adminApi';
|
} from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, CarrierTag, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, CarrierTag, Input, Pagination, Tabs, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
import './AdminAnalyticsPage.css';
|
||||||
import { successRateClassName, successRateTone } from '@/utils/successRate';
|
import { successRateClassName, successRateTone } from '@/utils/successRate';
|
||||||
|
|
||||||
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
|
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
|
||||||
@@ -20,19 +21,51 @@ const drainageStates = [
|
|||||||
{ value: 'without', label: '不含引流' },
|
{ value: 'without', label: '不含引流' },
|
||||||
{ value: 'unknown', label: '未检测' },
|
{ value: 'unknown', label: '未检测' },
|
||||||
] as const;
|
] as const;
|
||||||
const carrierLabels: Record<string, string> = {
|
|
||||||
mobile: '移动',
|
|
||||||
unicom: '联通',
|
|
||||||
telecom: '电信',
|
|
||||||
unknown: '未知',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const analyticsTabs = [
|
||||||
|
{ value: 'quality', label: '签名通道发送质量' },
|
||||||
|
{ value: 'enterprise', label: '企业签名活跃度' },
|
||||||
|
{ value: 'channel', label: '通道签名活跃度' },
|
||||||
|
{ value: 'unreported', label: '未报备签名' },
|
||||||
|
];
|
||||||
export function AdminAnalyticsPage() {
|
export function AdminAnalyticsPage() {
|
||||||
|
const [active, setActive] = useState('quality');
|
||||||
|
const [visited, setVisited] = useState(['quality']);
|
||||||
|
return (
|
||||||
|
<section className="page-stack admin-analytics-page">
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<Breadcrumb items={['签名质量检测']} />
|
||||||
|
<h1>签名质量检测</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs
|
||||||
|
value={active}
|
||||||
|
onChange={(value) => {
|
||||||
|
setActive(value);
|
||||||
|
setVisited((current) => (current.includes(value) ? current : [...current, value]));
|
||||||
|
}}
|
||||||
|
items={analyticsTabs.map((tab) => ({ ...tab, content: null }))}
|
||||||
|
/>
|
||||||
|
{visited.map((kind) => (
|
||||||
|
<div key={kind} hidden={kind !== active}>
|
||||||
|
<AnalyticsPanel kind={kind} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function AnalyticsPanel({ kind }: { kind: string }) {
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [appliedDate, setAppliedDate] = useState(() => shanghaiDateKey());
|
||||||
|
const requestId = useRef(0);
|
||||||
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
|
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
|
||||||
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
|
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
|
||||||
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]);
|
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]);
|
||||||
const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]);
|
const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]);
|
||||||
const [unreportedSignatures, setUnreportedSignatures] = useState<(PagedResult<UnreportedSignatureItem> & { date: string }) | null>(null);
|
const [unreportedSignatures, setUnreportedSignatures] = useState<
|
||||||
|
(PagedResult<UnreportedSignatureItem> & { date: string }) | null
|
||||||
|
>(null);
|
||||||
const [signatureKeyword, setSignatureKeyword] = useState('');
|
const [signatureKeyword, setSignatureKeyword] = useState('');
|
||||||
const [appliedKeyword, setAppliedKeyword] = useState('');
|
const [appliedKeyword, setAppliedKeyword] = useState('');
|
||||||
const [unreportedKeyword, setUnreportedKeyword] = useState('');
|
const [unreportedKeyword, setUnreportedKeyword] = useState('');
|
||||||
@@ -41,37 +74,52 @@ export function AdminAnalyticsPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
async function loadData(page = 1, keyword = appliedKeyword) {
|
async function loadData(
|
||||||
|
page = 1,
|
||||||
|
keyword = appliedKeyword,
|
||||||
|
unreported = appliedUnreportedKeyword,
|
||||||
|
date = statisticsDate,
|
||||||
|
size = pageSize,
|
||||||
|
) {
|
||||||
|
const id = ++requestId.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [signatureData, heatmapData, unreportedData] = await Promise.all([
|
if (kind === 'quality') {
|
||||||
adminApi.getSignatureQuality({
|
const data = await adminApi.getSignatureQuality({ date, keyword: keyword || undefined, page, pageSize: size });
|
||||||
date: statisticsDate,
|
if (id !== requestId.current) return;
|
||||||
keyword: keyword || undefined,
|
setSignatureQuality(data);
|
||||||
|
setAppliedKeyword(keyword);
|
||||||
|
setSelectedSignature(null);
|
||||||
|
} else if (kind === 'unreported') {
|
||||||
|
const data = await adminApi.getUnreportedSignatures({
|
||||||
|
date,
|
||||||
|
keyword: unreported || undefined,
|
||||||
page,
|
page,
|
||||||
pageSize: 10,
|
pageSize: size,
|
||||||
}),
|
});
|
||||||
adminApi.getSignatureRetirementHeatmap(statisticsDate),
|
if (id !== requestId.current) return;
|
||||||
adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: appliedUnreportedKeyword || undefined, page: 1, pageSize: 10 }),
|
setUnreportedSignatures(data);
|
||||||
]);
|
setAppliedUnreportedKeyword(unreported);
|
||||||
setSignatureQuality(signatureData);
|
} else {
|
||||||
setRetirementHeatmap(heatmapData.items);
|
const data = await adminApi.getSignatureRetirementHeatmap(date);
|
||||||
setRetirementDimensions(heatmapData.dimensions);
|
if (id !== requestId.current) return;
|
||||||
setUnreportedSignatures(unreportedData);
|
setRetirementHeatmap(data.items);
|
||||||
setAppliedKeyword(keyword);
|
setRetirementDimensions(data.dimensions);
|
||||||
setSelectedSignature((current) => current
|
}
|
||||||
? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null
|
setAppliedDate(date);
|
||||||
: null);
|
|
||||||
setError('');
|
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(failure instanceof Error ? failure.message : '统计数据加载失败');
|
if (id === requestId.current) setError(failure instanceof Error ? failure.message : '统计数据加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (id === requestId.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadData(1, '');
|
void loadData(1, '');
|
||||||
|
return () => {
|
||||||
|
requestId.current += 1;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,21 +170,29 @@ export function AdminAnalyticsPage() {
|
|||||||
title: '送达成功',
|
title: '送达成功',
|
||||||
width: '110px',
|
width: '110px',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (record) => <span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>,
|
render: (record) => (
|
||||||
|
<span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'failureCount',
|
key: 'failureCount',
|
||||||
title: '送达失败',
|
title: '送达失败',
|
||||||
width: '110px',
|
width: '110px',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (record) => <span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>,
|
render: (record) => (
|
||||||
|
<span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'submitFailureCount',
|
key: 'submitFailureCount',
|
||||||
title: '提交失败',
|
title: '提交失败',
|
||||||
width: '110px',
|
width: '110px',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (record) => <span className="quality-number quality-number--warning">{record.submitFailureCount.toLocaleString('zh-CN')}</span>,
|
render: (record) => (
|
||||||
|
<span className="quality-number quality-number--warning">
|
||||||
|
{record.submitFailureCount.toLocaleString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'successRate',
|
key: 'successRate',
|
||||||
@@ -169,34 +225,23 @@ export function AdminAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function changeSignaturePage(page: number) {
|
function changeSignaturePage(page: number) {
|
||||||
setLoading(true);
|
void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate);
|
||||||
void adminApi.getSignatureQuality({ date: statisticsDate, keyword: appliedKeyword || undefined, page, pageSize: 10 })
|
}
|
||||||
.then((data) => {
|
function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) {
|
||||||
setSignatureQuality(data);
|
void loadData(page, appliedKeyword, keyword, appliedDate);
|
||||||
setError('');
|
|
||||||
})
|
|
||||||
.catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '签名发送质量加载失败'))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) {
|
function changePageSize(size: number) {
|
||||||
setLoading(true);
|
setPageSize(size);
|
||||||
void adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: keyword || undefined, page, pageSize: 10 })
|
if (kind === 'quality' || kind === 'unreported')
|
||||||
.then((data) => {
|
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
|
||||||
setUnreportedSignatures(data);
|
|
||||||
setAppliedUnreportedKeyword(keyword);
|
|
||||||
setError('');
|
|
||||||
})
|
|
||||||
.catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '未报备签名加载失败'))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack" aria-label={analyticsTabs.find((tab) => tab.value === kind)?.label}>
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumb items={['签名质量检测']} />
|
<p className="muted">日期、筛选和分页在当前页签内独立生效。</p>
|
||||||
<h1>签名质量检测</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
<Input
|
<Input
|
||||||
@@ -213,66 +258,100 @@ export function AdminAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
<div className="surface signature-quality-card">
|
{kind === 'quality' ? (
|
||||||
<div className="signature-quality-card__heading">
|
<div className="surface signature-quality-card">
|
||||||
<div>
|
<div className="signature-quality-card__heading">
|
||||||
<div className="section-heading__title">
|
<div>
|
||||||
<h2>签名通道发送质量</h2>
|
<div className="section-heading__title">
|
||||||
<Tag tone="info">已登记签名</Tag>
|
<h2>签名通道发送质量</h2>
|
||||||
|
<Tag tone="info">已登记签名</Tag>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
{signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="signature-quality-card__query">
|
||||||
|
<Input
|
||||||
|
aria-label="搜索短信签名、企业或应用"
|
||||||
|
onChange={(event) => setSignatureKeyword(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') queryStatistics();
|
||||||
|
}}
|
||||||
|
placeholder="搜索签名、企业或应用"
|
||||||
|
value={signatureKeyword}
|
||||||
|
/>
|
||||||
|
<Button disabled={loading} icon={<Search size={16} />} onClick={queryStatistics} variant="secondary">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
|
||||||
{signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="signature-quality-card__query">
|
<div className="signature-quality-card__note">
|
||||||
<Input
|
<strong>统计说明:</strong>
|
||||||
aria-label="搜索短信签名、企业或应用"
|
业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
|
||||||
onChange={(event) => setSignatureKeyword(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') queryStatistics();
|
|
||||||
}}
|
|
||||||
placeholder="搜索签名、企业或应用"
|
|
||||||
value={signatureKeyword}
|
|
||||||
/>
|
|
||||||
<Button disabled={loading} icon={<Search size={16} />} onClick={queryStatistics} variant="secondary">查询</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Table
|
||||||
<div className="signature-quality-card__note">
|
columns={signatureColumns}
|
||||||
<strong>统计说明:</strong>
|
data={signatureQuality?.items ?? []}
|
||||||
业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
|
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
|
||||||
</div>
|
pagination={false}
|
||||||
<Table
|
rowKey="signatureId"
|
||||||
columns={signatureColumns}
|
/>
|
||||||
data={signatureQuality?.items ?? []}
|
|
||||||
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
|
|
||||||
pagination={false}
|
|
||||||
rowKey="signatureId"
|
|
||||||
/>
|
|
||||||
{(signatureQuality?.total ?? 0) > 0 ? (
|
|
||||||
<Pagination
|
<Pagination
|
||||||
nextDisabled={(signatureQuality?.page ?? 1) >= Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={changePageSize}
|
||||||
|
nextDisabled={
|
||||||
|
(signatureQuality?.page ?? 1) >=
|
||||||
|
Math.max(1, Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? pageSize)))
|
||||||
|
}
|
||||||
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
|
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
|
||||||
onPageChange={changeSignaturePage}
|
onPageChange={changeSignaturePage}
|
||||||
onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)}
|
onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)}
|
||||||
page={signatureQuality?.page ?? 1}
|
page={signatureQuality?.page ?? 1}
|
||||||
previousDisabled={(signatureQuality?.page ?? 1) <= 1}
|
previousDisabled={(signatureQuality?.page ?? 1) <= 1}
|
||||||
total={signatureQuality?.total ?? 0}
|
total={signatureQuality?.total ?? 0}
|
||||||
totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
|
totalPages={Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? pageSize)),
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
<RetirementHeatmap date={statisticsDate} dimensionType="enterprise" dimensions={retirementDimensions} items={retirementHeatmap} title="企业签名活跃度热力图" />
|
{kind === 'enterprise' ? (
|
||||||
<RetirementHeatmap date={statisticsDate} dimensionType="channel" dimensions={retirementDimensions} items={retirementHeatmap} title="通道签名活跃度热力图" />
|
<RetirementHeatmap
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={changePageSize}
|
||||||
|
date={appliedDate}
|
||||||
|
dimensionType="enterprise"
|
||||||
|
dimensions={retirementDimensions}
|
||||||
|
items={retirementHeatmap}
|
||||||
|
title="企业签名活跃度热力图"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{kind === 'channel' ? (
|
||||||
|
<RetirementHeatmap
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={changePageSize}
|
||||||
|
date={appliedDate}
|
||||||
|
dimensionType="channel"
|
||||||
|
dimensions={retirementDimensions}
|
||||||
|
items={retirementHeatmap}
|
||||||
|
title="通道签名活跃度热力图"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<UnreportedSignaturesCard
|
{kind === 'unreported' ? (
|
||||||
data={unreportedSignatures}
|
<UnreportedSignaturesCard
|
||||||
keyword={unreportedKeyword}
|
pageSize={pageSize}
|
||||||
loading={loading}
|
onPageSizeChange={changePageSize}
|
||||||
onKeywordChange={setUnreportedKeyword}
|
data={unreportedSignatures}
|
||||||
onPageChange={(page) => loadUnreportedSignatures(page)}
|
keyword={unreportedKeyword}
|
||||||
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
|
loading={loading}
|
||||||
/>
|
onKeywordChange={setUnreportedKeyword}
|
||||||
|
onPageChange={(page) => loadUnreportedSignatures(page)}
|
||||||
|
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{selectedSignature ? (
|
{selectedSignature ? (
|
||||||
<SignatureQualityDrawer
|
<SignatureQualityDrawer
|
||||||
@@ -285,18 +364,43 @@ export function AdminAnalyticsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: { date: string; dimensionType: 'enterprise' | 'channel'; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[]; title: string }) {
|
function RetirementHeatmap({
|
||||||
const pageSize = 10;
|
pageSize,
|
||||||
const [page, setPage] = useState(1);
|
onPageSizeChange,
|
||||||
|
date,
|
||||||
|
dimensionType,
|
||||||
|
dimensions,
|
||||||
|
items,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
pageSize: number;
|
||||||
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
|
date: string;
|
||||||
|
dimensionType: 'enterprise' | 'channel';
|
||||||
|
dimensions: SignatureRetirementHeatmapDimension[];
|
||||||
|
items: SignatureRetirementHeatmapItem[];
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
const [pageState, setPageState] = useState({ key: '', page: 1 });
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
|
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
|
||||||
const visible = items.filter((item) => item.dimensionType === dimensionType);
|
const visible = items.filter((item) => item.dimensionType === dimensionType);
|
||||||
const dates = previousDateKeys(date, 30);
|
const dates = previousDateKeys(date, 30);
|
||||||
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item]));
|
const cellMap = new Map(
|
||||||
|
visible.map((item) => [
|
||||||
|
`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`,
|
||||||
|
item,
|
||||||
|
]),
|
||||||
|
);
|
||||||
const rows = dimensions
|
const rows = dimensions
|
||||||
.filter((item) => item.dimensionType === dimensionType)
|
.filter((item) => item.dimensionType === dimensionType)
|
||||||
.filter((item) => !deferredKeyword || [item.channelName, item.tenantName, item.applicationName, item.signatureName]
|
.filter(
|
||||||
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword)))
|
(item) =>
|
||||||
|
!deferredKeyword ||
|
||||||
|
[item.channelName, item.tenantName, item.applicationName, item.signatureName].some((value) =>
|
||||||
|
value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword),
|
||||||
|
),
|
||||||
|
)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
|
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
|
||||||
signatureName: item.signatureName,
|
signatureName: item.signatureName,
|
||||||
@@ -305,17 +409,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
|
|||||||
applicationName: item.applicationName,
|
applicationName: item.applicationName,
|
||||||
carrier: item.carrier,
|
carrier: item.carrier,
|
||||||
approvedAt: item.approvedAt.slice(0, 10),
|
approvedAt: item.approvedAt.slice(0, 10),
|
||||||
total: dates.reduce((sum, dateKey) => sum + (cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)?.acceptedBusinessCount ?? 0), 0),
|
total: dates.reduce(
|
||||||
|
(sum, dateKey) =>
|
||||||
|
sum +
|
||||||
|
(cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)
|
||||||
|
?.acceptedBusinessCount ?? 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
|
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
|
||||||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||||
|
const paginationKey = JSON.stringify([date, deferredKeyword, dimensionType, dimensions.length, pageSize]);
|
||||||
|
const page = pageState.key === paginationKey ? pageState.page : 1;
|
||||||
|
const setPage = (value: number) => setPageState({ key: paginationKey, page: value });
|
||||||
const currentPage = Math.min(page, totalPages);
|
const currentPage = Math.min(page, totalPages);
|
||||||
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1);
|
|
||||||
}, [date, deferredKeyword, dimensionType, dimensions.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="surface signature-retirement-heatmap">
|
<div className="surface signature-retirement-heatmap">
|
||||||
<div className="section-heading signature-retirement-heatmap__heading">
|
<div className="section-heading signature-retirement-heatmap__heading">
|
||||||
@@ -338,14 +447,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
|
|||||||
<div className="signature-retirement-heatmap__scroll">
|
<div className="signature-retirement-heatmap__scroll">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>签名维度</th><th>30日合计</th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr>
|
<tr>
|
||||||
|
<th>签名维度</th>
|
||||||
|
<th>30日合计</th>
|
||||||
|
{dates.map((dateKey) => (
|
||||||
|
<th key={dateKey}>{dateKey.slice(5)}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{pagedRows.map((row) => (
|
{pagedRows.map((row) => (
|
||||||
<tr key={row.key}>
|
<tr key={row.key}>
|
||||||
<th>
|
<th>
|
||||||
<span className="signature-retirement-heatmap__identity">
|
<span className="signature-retirement-heatmap__identity">
|
||||||
<strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>{row.signatureName}</strong>
|
<strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>
|
||||||
|
{row.signatureName}
|
||||||
|
</strong>
|
||||||
{row.channelName ? <small>{row.channelName}</small> : null}
|
{row.channelName ? <small>{row.channelName}</small> : null}
|
||||||
</span>
|
</span>
|
||||||
<CarrierTag carrier={row.carrier} />
|
<CarrierTag carrier={row.carrier} />
|
||||||
@@ -354,33 +471,59 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
|
|||||||
{dates.map((dateKey) => {
|
{dates.map((dateKey) => {
|
||||||
const item = cellMap.get(`${row.key}:${dateKey}`);
|
const item = cellMap.get(`${row.key}:${dateKey}`);
|
||||||
const beforeApproval = dateKey < row.approvedAt;
|
const beforeApproval = dateKey < row.approvedAt;
|
||||||
const successRate = item?.acceptedBusinessCount ? item.deliveredBusinessCount / item.acceptedBusinessCount * 100 : 0;
|
const successRate = item?.acceptedBusinessCount
|
||||||
const className = beforeApproval ? 'is-inapplicable' : !item ? '' : item.acceptedBusinessCount === 0 ? 'is-zero' : `is-rate-${successRateTone(successRate)}`;
|
? (item.deliveredBusinessCount / item.acceptedBusinessCount) * 100
|
||||||
const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts} 条\n上游接受条数:${item.acceptedBusinessCount} 条\n发送成功条数:${item.deliveredBusinessCount} 条\n发送成功率:${successRate.toFixed(1)}%\n检测状态:${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold} 条` : '当日无检测快照';
|
: 0;
|
||||||
return <td className={className} key={dateKey} title={titleText}>{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}</td>;
|
const className = beforeApproval
|
||||||
|
? 'is-inapplicable'
|
||||||
|
: !item
|
||||||
|
? ''
|
||||||
|
: item.acceptedBusinessCount === 0
|
||||||
|
? 'is-zero'
|
||||||
|
: `is-rate-${successRateTone(successRate)}`;
|
||||||
|
const titleText = beforeApproval
|
||||||
|
? '报备前,不适用'
|
||||||
|
: item
|
||||||
|
? `提交条数:${item.submittedAttempts} 条\n上游接受条数:${item.acceptedBusinessCount} 条\n发送成功条数:${item.deliveredBusinessCount} 条\n发送成功率:${successRate.toFixed(1)}%\n检测状态:${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold} 条`
|
||||||
|
: '当日无检测快照';
|
||||||
|
return (
|
||||||
|
<td className={className} key={dateKey} title={titleText}>
|
||||||
|
{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
|
||||||
nextDisabled={currentPage >= totalPages}
|
|
||||||
onNext={() => setPage(currentPage + 1)}
|
|
||||||
onPageChange={setPage}
|
|
||||||
onPrevious={() => setPage(currentPage - 1)}
|
|
||||||
page={currentPage}
|
|
||||||
previousDisabled={currentPage <= 1}
|
|
||||||
total={rows.length}
|
|
||||||
totalPages={totalPages}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
) : <p className="empty-state">{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}</p>}
|
) : (
|
||||||
|
<p className="empty-state">
|
||||||
|
{deferredKeyword
|
||||||
|
? '没有匹配企业、企业应用或签名的热力图维度。'
|
||||||
|
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Pagination
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
nextDisabled={currentPage >= totalPages}
|
||||||
|
onNext={() => setPage(currentPage + 1)}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPrevious={() => setPage(currentPage - 1)}
|
||||||
|
page={currentPage}
|
||||||
|
previousDisabled={currentPage <= 1}
|
||||||
|
total={rows.length}
|
||||||
|
totalPages={totalPages}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UnreportedSignaturesCard({
|
function UnreportedSignaturesCard({
|
||||||
|
pageSize,
|
||||||
|
onPageSizeChange,
|
||||||
data,
|
data,
|
||||||
keyword,
|
keyword,
|
||||||
loading,
|
loading,
|
||||||
@@ -388,6 +531,8 @@ function UnreportedSignaturesCard({
|
|||||||
onPageChange,
|
onPageChange,
|
||||||
onSearch,
|
onSearch,
|
||||||
}: {
|
}: {
|
||||||
|
pageSize: number;
|
||||||
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
data: (PagedResult<UnreportedSignatureItem> & { date: string }) | null;
|
data: (PagedResult<UnreportedSignatureItem> & { date: string }) | null;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -399,28 +544,43 @@ function UnreportedSignaturesCard({
|
|||||||
{ key: 'signatureName', title: '短信签名', render: (record) => <strong>{record.signatureName}</strong> },
|
{ key: 'signatureName', title: '短信签名', render: (record) => <strong>{record.signatureName}</strong> },
|
||||||
{ key: 'tenantName', title: '企业名称', render: (record) => record.tenantName },
|
{ key: 'tenantName', title: '企业名称', render: (record) => record.tenantName },
|
||||||
{ key: 'applicationName', title: '企业应用', render: (record) => record.applicationName || '未关联企业应用' },
|
{ key: 'applicationName', title: '企业应用', render: (record) => record.applicationName || '未关联企业应用' },
|
||||||
{ key: 'messageCount', title: '未报备短信', align: 'right', width: '150px', render: (record) => `${record.messageCount.toLocaleString('zh-CN')} 条` },
|
{
|
||||||
|
key: 'messageCount',
|
||||||
|
title: '未报备短信',
|
||||||
|
align: 'right',
|
||||||
|
width: '150px',
|
||||||
|
render: (record) => `${record.messageCount.toLocaleString('zh-CN')} 条`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 10)));
|
const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? pageSize)));
|
||||||
return (
|
return (
|
||||||
<div className="surface signature-quality-card">
|
<div className="surface signature-quality-card">
|
||||||
<div className="signature-quality-card__heading">
|
<div className="signature-quality-card__heading">
|
||||||
<div>
|
<div>
|
||||||
<div className="section-heading__title"><h2>未报备签名</h2><Tag tone="warning">待处理</Tag></div>
|
<div className="section-heading__title">
|
||||||
|
<h2>未报备签名</h2>
|
||||||
|
<Tag tone="warning">待处理</Tag>
|
||||||
|
</div>
|
||||||
<p className="muted">{data?.date ?? '所选日期'} 已进入平台、但系统签名库中没有对应记录的业务短信。</p>
|
<p className="muted">{data?.date ?? '所选日期'} 已进入平台、但系统签名库中没有对应记录的业务短信。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="signature-quality-card__query">
|
<div className="signature-quality-card__query">
|
||||||
<Input
|
<Input
|
||||||
aria-label="搜索未报备签名、企业或企业应用"
|
aria-label="搜索未报备签名、企业或企业应用"
|
||||||
onChange={(event) => onKeywordChange(event.target.value)}
|
onChange={(event) => onKeywordChange(event.target.value)}
|
||||||
onKeyDown={(event) => { if (event.key === 'Enter') onSearch(); }}
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') onSearch();
|
||||||
|
}}
|
||||||
placeholder="搜索签名、企业或企业应用"
|
placeholder="搜索签名、企业或企业应用"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
/>
|
/>
|
||||||
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary">查询</Button>
|
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="signature-quality-card__note"><strong>统计说明:</strong>从短信正文开头识别签名;当前企业应用存在同名签名记录时不计入。</div>
|
<div className="signature-quality-card__note">
|
||||||
|
<strong>统计说明:</strong>从短信正文开头识别签名;当前企业应用存在同名签名记录时不计入。
|
||||||
|
</div>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={data?.items ?? []}
|
data={data?.items ?? []}
|
||||||
@@ -428,18 +588,18 @@ function UnreportedSignaturesCard({
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey={(record) => `${record.signatureId}:${record.applicationId ?? ''}`}
|
rowKey={(record) => `${record.signatureId}:${record.applicationId ?? ''}`}
|
||||||
/>
|
/>
|
||||||
{(data?.total ?? 0) > 0 ? (
|
<Pagination
|
||||||
<Pagination
|
pageSize={pageSize}
|
||||||
nextDisabled={(data?.page ?? 1) >= totalPages}
|
onPageSizeChange={onPageSizeChange}
|
||||||
onNext={() => onPageChange((data?.page ?? 1) + 1)}
|
nextDisabled={(data?.page ?? 1) >= totalPages}
|
||||||
onPageChange={onPageChange}
|
onNext={() => onPageChange((data?.page ?? 1) + 1)}
|
||||||
onPrevious={() => onPageChange((data?.page ?? 1) - 1)}
|
onPageChange={onPageChange}
|
||||||
page={data?.page ?? 1}
|
onPrevious={() => onPageChange((data?.page ?? 1) - 1)}
|
||||||
previousDisabled={(data?.page ?? 1) <= 1}
|
page={data?.page ?? 1}
|
||||||
total={data?.total ?? 0}
|
previousDisabled={(data?.page ?? 1) <= 1}
|
||||||
totalPages={totalPages}
|
total={data?.total ?? 0}
|
||||||
/>
|
totalPages={totalPages}
|
||||||
) : null}
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -467,33 +627,52 @@ function SignatureQualityDrawer({
|
|||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const leftRank = carrierOrder.indexOf(normalizeCarrier(left.carrier));
|
const leftRank = carrierOrder.indexOf(normalizeCarrier(left.carrier));
|
||||||
const rightRank = carrierOrder.indexOf(normalizeCarrier(right.carrier));
|
const rightRank = carrierOrder.indexOf(normalizeCarrier(right.carrier));
|
||||||
return (leftRank < 0 ? carrierOrder.length : leftRank)
|
return (leftRank < 0 ? carrierOrder.length : leftRank) - (rightRank < 0 ? carrierOrder.length : rightRank);
|
||||||
- (rightRank < 0 ? carrierOrder.length : rightRank);
|
|
||||||
});
|
});
|
||||||
const channels = [...new Map([...item.breakdowns, ...item.drainageBreakdowns]
|
const channels = [
|
||||||
.map((entry) => [entry.channelId, entry.channelName])).entries()]
|
...new Map(
|
||||||
.map(([channelId, channelName]) => ({ channelId, channelName }));
|
[...item.breakdowns, ...item.drainageBreakdowns].map((entry) => [entry.channelId, entry.channelName]),
|
||||||
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
|
).entries(),
|
||||||
|
].map(([channelId, channelName]) => ({ channelId, channelName }));
|
||||||
|
const visibleCarriers = carrierOrder.filter((carrier) =>
|
||||||
|
item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signature-quality-drawer__backdrop" onMouseDown={(event) => {
|
<div
|
||||||
if (event.target === event.currentTarget) onClose();
|
className="signature-quality-drawer__backdrop"
|
||||||
}}>
|
onMouseDown={(event) => {
|
||||||
<aside aria-labelledby="signature-quality-drawer-title" aria-modal="true" className="signature-quality-drawer" role="dialog">
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
aria-labelledby="signature-quality-drawer-title"
|
||||||
|
aria-modal="true"
|
||||||
|
className="signature-quality-drawer"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
<div className="signature-quality-drawer__header">
|
<div className="signature-quality-drawer__header">
|
||||||
<div>
|
<div>
|
||||||
<p>全部签名 / {item.signatureName}</p>
|
<p>全部签名 / {item.signatureName}</p>
|
||||||
<h2 id="signature-quality-drawer-title">{item.signatureName}发送质量详情</h2>
|
<h2 id="signature-quality-drawer-title">{item.signatureName}发送质量详情</h2>
|
||||||
<span>{date} · {item.tenantName} · {item.applicationNames || '全部企业应用'}</span>
|
<span>
|
||||||
|
{date} · {item.tenantName} · {item.applicationNames || '全部企业应用'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button aria-label="关闭签名发送质量详情" onClick={onClose} type="button"><X size={20} /></button>
|
<button aria-label="关闭签名发送质量详情" onClick={onClose} type="button">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="signature-quality-drawer__body">
|
<div className="signature-quality-drawer__body">
|
||||||
<div className="signature-quality-overview">
|
<div className="signature-quality-overview">
|
||||||
<QualityMetric label="业务短信" value={item.total.toLocaleString('zh-CN')} />
|
<QualityMetric label="业务短信" value={item.total.toLocaleString('zh-CN')} />
|
||||||
<QualityMetric label="通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
|
<QualityMetric label="通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
|
||||||
<QualityMetric label="最终成功率" value={`${item.successRate.toFixed(1)}%`} valueClassName={successRateClassName(item.successRate)} />
|
<QualityMetric
|
||||||
|
label="最终成功率"
|
||||||
|
value={`${item.successRate.toFixed(1)}%`}
|
||||||
|
valueClassName={successRateClassName(item.successRate)}
|
||||||
|
/>
|
||||||
<QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} />
|
<QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -506,15 +685,29 @@ function SignatureQualityDrawer({
|
|||||||
</div>
|
</div>
|
||||||
<div className="signature-carrier-grid">
|
<div className="signature-carrier-grid">
|
||||||
{carriers.map((carrier) => (
|
{carriers.map((carrier) => (
|
||||||
<article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.carrier}>
|
<article
|
||||||
|
className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`}
|
||||||
|
key={carrier.carrier}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<CarrierTag carrier={carrier.carrier} />
|
<CarrierTag carrier={carrier.carrier} />
|
||||||
<strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} 条业务短信</strong>
|
<strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} 条业务短信</strong>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>最终成功率</dt><dd className={successRateClassName(carrier.finalSuccessRate)}>{carrier.finalSuccessRate.toFixed(1)}%</dd></div>
|
<div>
|
||||||
<div><dt>平均到达</dt><dd>{formatDuration(carrier.averageArrivalMs)}</dd></div>
|
<dt>最终成功率</dt>
|
||||||
<div><dt>涉及通道</dt><dd>{carrier.channelCount} 个</dd></div>
|
<dd className={successRateClassName(carrier.finalSuccessRate)}>
|
||||||
|
{carrier.finalSuccessRate.toFixed(1)}%
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>平均到达</dt>
|
||||||
|
<dd>{formatDuration(carrier.averageArrivalMs)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>涉及通道</dt>
|
||||||
|
<dd>{carrier.channelCount} 个</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -525,11 +718,28 @@ function SignatureQualityDrawer({
|
|||||||
<div className="signature-quality-section__heading">
|
<div className="signature-quality-section__heading">
|
||||||
<div>
|
<div>
|
||||||
<h3>通道 × 运营商矩阵</h3>
|
<h3>通道 × 运营商矩阵</h3>
|
||||||
<p>{matrixMode === 'overall'
|
<p>
|
||||||
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
|
{matrixMode === 'overall'
|
||||||
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}</p>
|
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
|
||||||
|
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="page-actions">
|
||||||
|
<Button
|
||||||
|
onClick={() => setMatrixMode('overall')}
|
||||||
|
size="sm"
|
||||||
|
variant={matrixMode === 'overall' ? 'primary' : 'ghost'}
|
||||||
|
>
|
||||||
|
整体统计
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setMatrixMode('drainage')}
|
||||||
|
size="sm"
|
||||||
|
variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}
|
||||||
|
>
|
||||||
|
按引流切分
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions"><Button onClick={() => setMatrixMode('overall')} size="sm" variant={matrixMode === 'overall' ? 'primary' : 'ghost'}>整体统计</Button><Button onClick={() => setMatrixMode('drainage')} size="sm" variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}>按引流切分</Button></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="signature-quality-matrix">
|
<div className="signature-quality-matrix">
|
||||||
<table>
|
<table>
|
||||||
@@ -537,47 +747,67 @@ function SignatureQualityDrawer({
|
|||||||
{matrixMode === 'overall' ? (
|
{matrixMode === 'overall' ? (
|
||||||
<tr>
|
<tr>
|
||||||
<th>通道名称</th>
|
<th>通道名称</th>
|
||||||
{visibleCarriers.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)}
|
{visibleCarriers.map((carrier) => (
|
||||||
|
<th key={carrier}>
|
||||||
|
<CarrierTag carrier={carrier} />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<th>通道名称</th>
|
<th>通道名称</th>
|
||||||
<th>引流类型</th>
|
<th>引流类型</th>
|
||||||
{majorCarrierOrder.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)}
|
{majorCarrierOrder.map((carrier) => (
|
||||||
|
<th key={carrier}>
|
||||||
|
<CarrierTag carrier={carrier} />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{matrixMode === 'overall'
|
{matrixMode === 'overall'
|
||||||
? channels.map((channel) => (
|
? channels.map((channel) => (
|
||||||
<tr key={channel.channelId}>
|
<tr key={channel.channelId}>
|
||||||
<th>{channel.channelName}</th>
|
<th>{channel.channelName}</th>
|
||||||
{visibleCarriers.map((carrier) => {
|
{visibleCarriers.map((carrier) => {
|
||||||
const metric = item.breakdowns.find((entry) => (
|
const metric = item.breakdowns.find(
|
||||||
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
|
(entry) =>
|
||||||
));
|
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier,
|
||||||
return (
|
);
|
||||||
<td key={carrier}>
|
return (
|
||||||
{metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty">—</span>}
|
<td key={carrier}>
|
||||||
</td>
|
{metric ? (
|
||||||
);
|
<MatrixMetric metric={metric} />
|
||||||
})}
|
) : (
|
||||||
</tr>
|
<span className="signature-quality-matrix__empty">—</span>
|
||||||
))
|
)}
|
||||||
: channels.flatMap((channel) => drainageStates.map((state, stateIndex) => (
|
</td>
|
||||||
<tr key={`${channel.channelId}-${state.value}`}>
|
);
|
||||||
{stateIndex === 0 ? <th rowSpan={drainageStates.length}>{channel.channelName}</th> : null}
|
})}
|
||||||
<th className="signature-quality-matrix__drainage-label">{state.label}</th>
|
</tr>
|
||||||
{majorCarrierOrder.map((carrier) => {
|
))
|
||||||
const metric = item.drainageBreakdowns.find((entry) => (
|
: channels.flatMap((channel) =>
|
||||||
entry.channelId === channel.channelId
|
drainageStates.map((state, stateIndex) => (
|
||||||
&& normalizeCarrier(entry.carrier) === carrier
|
<tr key={`${channel.channelId}-${state.value}`}>
|
||||||
&& entry.drainageState === state.value
|
{stateIndex === 0 ? <th rowSpan={drainageStates.length}>{channel.channelName}</th> : null}
|
||||||
));
|
<th className="signature-quality-matrix__drainage-label">{state.label}</th>
|
||||||
return <td key={carrier}><MatrixMetric metric={metric} zeroWhenEmpty /></td>;
|
{majorCarrierOrder.map((carrier) => {
|
||||||
})}
|
const metric = item.drainageBreakdowns.find(
|
||||||
</tr>
|
(entry) =>
|
||||||
)))}
|
entry.channelId === channel.channelId &&
|
||||||
|
normalizeCarrier(entry.carrier) === carrier &&
|
||||||
|
entry.drainageState === state.value,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<td key={carrier}>
|
||||||
|
<MatrixMetric metric={metric} zeroWhenEmpty />
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -601,16 +831,35 @@ function QualityMetric({ label, value, valueClassName }: { label: string; value:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureChannelCarrierQualityStat; zeroWhenEmpty?: boolean }) {
|
function MatrixMetric({
|
||||||
|
metric,
|
||||||
|
zeroWhenEmpty = false,
|
||||||
|
}: {
|
||||||
|
metric?: SignatureChannelCarrierQualityStat;
|
||||||
|
zeroWhenEmpty?: boolean;
|
||||||
|
}) {
|
||||||
const total = metric?.total ?? 0;
|
const total = metric?.total ?? 0;
|
||||||
const successRate = metric?.successRate ?? 0;
|
const successRate = metric?.successRate ?? 0;
|
||||||
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
|
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`signature-quality-matrix__metric signature-quality-matrix__metric--${successRateTone(successRate)}`}>
|
<div
|
||||||
<div><small>通道提交</small><strong>{total.toLocaleString('zh-CN')} 次</strong></div>
|
className={`signature-quality-matrix__metric signature-quality-matrix__metric--${successRateTone(successRate)}`}
|
||||||
<div><small>成功率</small><span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>{successRate.toFixed(1)}%</span></div>
|
>
|
||||||
<div><small>平均到达</small><span>{formatDuration(metric?.averageArrivalMs)}</span></div>
|
<div>
|
||||||
|
<small>通道提交</small>
|
||||||
|
<strong>{total.toLocaleString('zh-CN')} 次</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small>成功率</small>
|
||||||
|
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
|
||||||
|
{successRate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small>平均到达</small>
|
||||||
|
<span>{formatDuration(metric?.averageArrivalMs)}</span>
|
||||||
|
</div>
|
||||||
{(metric?.submitFailureCount ?? 0) > 0 ? <em>提交失败 {metric?.submitFailureCount}</em> : null}
|
{(metric?.submitFailureCount ?? 0) > 0 ? <em>提交失败 {metric?.submitFailureCount}</em> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -619,7 +868,9 @@ function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureCha
|
|||||||
function QualityRate({ value }: { value: number }) {
|
function QualityRate({ value }: { value: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="signature-quality-rate">
|
<div className="signature-quality-rate">
|
||||||
<div><span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} /></div>
|
<div>
|
||||||
|
<span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
|
||||||
|
</div>
|
||||||
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
|
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -633,10 +884,6 @@ function normalizeCarrier(value: string) {
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function carrierLabel(value: string) {
|
|
||||||
return carrierLabels[normalizeCarrier(value)] ?? '未知';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(value?: number | null) {
|
function formatDuration(value?: number | null) {
|
||||||
if (value == null) return '—';
|
if (value == null) return '—';
|
||||||
if (value < 1000) return `${Math.round(value)} 毫秒`;
|
if (value < 1000) return `${Math.round(value)} 毫秒`;
|
||||||
@@ -656,5 +903,7 @@ function shanghaiDateKey(value = new Date()) {
|
|||||||
|
|
||||||
function previousDateKeys(endKey: string, days: number) {
|
function previousDateKeys(endKey: string, days: number) {
|
||||||
const end = new Date(`${endKey}T12:00:00+08:00`);
|
const end = new Date(`${endKey}T12:00:00+08:00`);
|
||||||
return Array.from({ length: days }, (_, index) => shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000)));
|
return Array.from({ length: days }, (_, index) =>
|
||||||
|
shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
.admin-dashboard .home-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric__heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric__category {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric__number {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
color: #111827;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric__number strong {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric__number span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric__number.is-negative {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1400px) {
|
||||||
|
.admin-dashboard .home-metrics {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 700px) {
|
||||||
|
.admin-dashboard .home-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric__category {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard .home-metric__number strong {
|
||||||
|
font-size: 23px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react';
|
import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
import './AdminHome.css';
|
||||||
import { Chart } from '@/components/ui/Chart';
|
import { Chart } from '@/components/ui/Chart';
|
||||||
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
|
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
|
||||||
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
|
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
|
||||||
@@ -170,61 +171,99 @@ export function AdminHome() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dashboard-grid admin-metric-grid">
|
<div className="home-metrics" aria-busy={!dashboard && !error}>
|
||||||
<div className="surface metric-card">
|
{[
|
||||||
<span>今日发送总量</span>
|
{
|
||||||
<strong>{formatCount(totalSend)} 条</strong>
|
label: '今日发送总量',
|
||||||
<small>来自真实短信记录聚合</small>
|
value: formatCount(totalSend),
|
||||||
</div>
|
unit: '条',
|
||||||
<div className="surface metric-card">
|
note: '业务短信',
|
||||||
<span>今日消息分片数</span>
|
group: '发送',
|
||||||
<strong>{formatCount(dashboard?.today.segmentCount ?? 0)} 片</strong>
|
icon: <BarChart3 size={18} />,
|
||||||
<small>来自真实分片审计记录</small>
|
},
|
||||||
</div>
|
{
|
||||||
<div className="surface metric-card">
|
label: '今日消息分片数',
|
||||||
<span>总体成功率</span>
|
value: formatCount(dashboard?.today.segmentCount ?? 0),
|
||||||
<strong>{averageSuccessRate.toFixed(1)}%</strong>
|
unit: '片',
|
||||||
<small>delivered / 今日总量</small>
|
note: '实际消息分片',
|
||||||
</div>
|
group: '发送',
|
||||||
<div className="surface metric-card">
|
},
|
||||||
<span>今日到达率</span>
|
{
|
||||||
<strong>{(dashboard?.today.arrivalRate ?? 0).toFixed(1)}%</strong>
|
label: '总体成功率',
|
||||||
<small>到达分片 / 发送总分片</small>
|
value: averageSuccessRate.toFixed(1),
|
||||||
</div>
|
unit: '%',
|
||||||
<div className="surface metric-card">
|
note: '送达成功 / 今日总量',
|
||||||
<span>今日活跃签名</span>
|
group: '质量',
|
||||||
<strong>{activeSignatureCount}</strong>
|
icon: <ShieldCheck size={18} />,
|
||||||
<small>当天有真实发送记录的签名</small>
|
},
|
||||||
</div>
|
{
|
||||||
<div className="surface metric-card">
|
label: '今日到达率',
|
||||||
<span>今日消费金额</span>
|
value: (dashboard?.today.arrivalRate ?? 0).toFixed(1),
|
||||||
<strong>¥{formatCurrency(todaySpend)}</strong>
|
unit: '%',
|
||||||
<small>来自今日消息金额聚合</small>
|
note: '到达分片 / 发送总分片',
|
||||||
</div>
|
group: '质量',
|
||||||
<div className="surface metric-card">
|
},
|
||||||
<span>今日返还金额</span>
|
{
|
||||||
<strong>¥{formatCurrency(todayReturned)}</strong>
|
label: '今日活跃签名',
|
||||||
<small>来自今日返还流水</small>
|
value: formatCount(activeSignatureCount),
|
||||||
</div>
|
unit: '个',
|
||||||
<div className="surface metric-card">
|
note: '今日有真实发送记录',
|
||||||
<span>今日计收金额</span>
|
group: '发送',
|
||||||
<strong>¥{formatCurrency(todayBilled)}</strong>
|
},
|
||||||
<small>成功短信计费条数 × 客户价</small>
|
{
|
||||||
</div>
|
label: '今日消费金额',
|
||||||
<div className="surface metric-card">
|
value: formatCurrency(todaySpend),
|
||||||
<span>今日利润</span>
|
unit: '元',
|
||||||
<strong className={todayProfit < 0 ? 'metric-card__value--danger' : undefined}>
|
note: '今日消息消费',
|
||||||
¥{formatCurrency(todayProfit)}
|
group: '经营',
|
||||||
</strong>
|
icon: <DollarSign size={18} />,
|
||||||
<small>计收金额 - 成功分片通道成本</small>
|
},
|
||||||
</div>
|
{
|
||||||
<div className="surface metric-card">
|
label: '今日返还金额',
|
||||||
<span>今日利润率</span>
|
value: formatCurrency(todayReturned),
|
||||||
<strong className={(dashboard?.today.profitRate ?? 0) < 0 ? 'metric-card__value--danger' : undefined}>
|
unit: '元',
|
||||||
{(dashboard?.today.profitRate ?? 0).toFixed(1)}%
|
note: '今日返还流水',
|
||||||
</strong>
|
group: '经营',
|
||||||
<small>今日利润 / 今日计收金额</small>
|
},
|
||||||
</div>
|
{
|
||||||
|
label: '今日计收金额',
|
||||||
|
value: formatCurrency(todayBilled),
|
||||||
|
unit: '元',
|
||||||
|
note: '成功计费条数 × 客户价',
|
||||||
|
group: '经营',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '今日利润',
|
||||||
|
value: formatCurrency(todayProfit),
|
||||||
|
unit: '元',
|
||||||
|
note: '计收金额 − 成功分片通道成本',
|
||||||
|
group: '经营',
|
||||||
|
danger: todayProfit < 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '今日利润率',
|
||||||
|
value: (dashboard?.today.profitRate ?? 0).toFixed(1),
|
||||||
|
unit: '%',
|
||||||
|
note: '今日利润 / 今日计收金额',
|
||||||
|
group: '经营',
|
||||||
|
danger: (dashboard?.today.profitRate ?? 0) < 0,
|
||||||
|
},
|
||||||
|
].map((metric) => (
|
||||||
|
<article className="home-metric" key={metric.label}>
|
||||||
|
<div className="home-metric__heading">
|
||||||
|
<span>{metric.label}</span>
|
||||||
|
<span className="home-metric__category">
|
||||||
|
{metric.icon}
|
||||||
|
{metric.group}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={metric.danger ? 'home-metric__number is-negative' : 'home-metric__number'}>
|
||||||
|
<strong>{dashboard ? metric.value : '—'}</strong>
|
||||||
|
<span>{metric.unit}</span>
|
||||||
|
</div>
|
||||||
|
<p>{dashboard ? metric.note : error ? '数据暂不可用' : '正在加载…'}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Download, Eye, Search } from 'lucide-react';
|
import { Download, Eye, Search } from 'lucide-react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { adminApi, fileDownloadUrl, type ReportTask, type SingleReportMaterialDetail } from '@/api/adminApi';
|
import { adminApi, fileDownloadUrl, type ReportTask, type SingleReportMaterialDetail } from '@/api/adminApi';
|
||||||
@@ -192,6 +192,8 @@ export function AdminReportTasksPage() {
|
|||||||
const [exportTask, setExportTask] = useState<ReportTask | null>(null);
|
const [exportTask, setExportTask] = useState<ReportTask | null>(null);
|
||||||
const [exportBusy, setExportBusy] = useState(false);
|
const [exportBusy, setExportBusy] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const requestId = useRef(0);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [appliedFilters, setAppliedFilters] = useState({
|
const [appliedFilters, setAppliedFilters] = useState({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
@@ -200,9 +202,8 @@ export function AdminReportTasksPage() {
|
|||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
carrier: 'all',
|
carrier: 'all',
|
||||||
});
|
});
|
||||||
const pageSize = 10;
|
|
||||||
|
|
||||||
function loadData(targetPage = page, filters = appliedFilters) {
|
function loadData(targetPage = page, filters = appliedFilters) {
|
||||||
|
const id = ++requestId.current;
|
||||||
adminApi
|
adminApi
|
||||||
.listReportDetailsPage({
|
.listReportDetailsPage({
|
||||||
signatureId: searchParams.get('signatureId') || undefined,
|
signatureId: searchParams.get('signatureId') || undefined,
|
||||||
@@ -216,17 +217,23 @@ export function AdminReportTasksPage() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
if (id !== requestId.current) return;
|
||||||
setTasks(result.items);
|
setTasks(result.items);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '报备明细加载失败'));
|
.catch((failure: Error) => {
|
||||||
|
if (id === requestId.current) setError(failure.message || '报备明细加载失败');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(page);
|
loadData(page);
|
||||||
}, [page]);
|
return () => {
|
||||||
|
requestId.current += 1;
|
||||||
|
};
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
async function saveTaskStatus() {
|
async function saveTaskStatus() {
|
||||||
if (!statusTask) return;
|
if (!statusTask) return;
|
||||||
@@ -471,7 +478,9 @@ export function AdminReportTasksPage() {
|
|||||||
onChange={(event) => setStatus(event.target.value)}
|
onChange={(event) => setStatus(event.target.value)}
|
||||||
options={[
|
options={[
|
||||||
{ label: '全部状态', value: 'all' },
|
{ label: '全部状态', value: 'all' },
|
||||||
...Object.entries(statusMeta).map(([value, meta]) => ({ label: meta.label, value })),
|
...Object.entries(statusMeta)
|
||||||
|
.filter(([value]) => !['exporting', 'rejected'].includes(value))
|
||||||
|
.map(([value, meta]) => ({ label: meta.label, value })),
|
||||||
]}
|
]}
|
||||||
value={status}
|
value={status}
|
||||||
/>
|
/>
|
||||||
@@ -519,8 +528,14 @@ export function AdminReportTasksPage() {
|
|||||||
nextDisabled={page * pageSize >= total}
|
nextDisabled={page * pageSize >= total}
|
||||||
onNext={() => setPage((current) => current + 1)}
|
onNext={() => setPage((current) => current + 1)}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
setSelected(new Set());
|
||||||
|
}}
|
||||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
page={page}
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
previousDisabled={page <= 1}
|
previousDisabled={page <= 1}
|
||||||
total={total}
|
total={total}
|
||||||
totalPages={Math.max(1, Math.ceil(total / pageSize))}
|
totalPages={Math.max(1, Math.ceil(total / pageSize))}
|
||||||
|
|||||||
@@ -11,23 +11,55 @@ import {
|
|||||||
type SignatureRetirementWebhook,
|
type SignatureRetirementWebhook,
|
||||||
type TenantOption,
|
type TenantOption,
|
||||||
} from '@/api/adminApi';
|
} from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
Button,
|
||||||
|
CarrierTag,
|
||||||
|
DateRangeInput,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Pagination,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Textarea,
|
||||||
|
type DateRangeValue,
|
||||||
|
type TableColumn,
|
||||||
|
} from '@/components/ui';
|
||||||
|
|
||||||
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
const carriers = ['mobile', 'unicom', 'telecom'] as const;
|
||||||
const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' };
|
const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' };
|
||||||
const ruleTypeLabels: Record<SignatureRetirementRuleType, string> = {
|
const ruleTypeLabels: Record<SignatureRetirementRuleType, string> = {
|
||||||
enterprise_global: '企业全局规则', enterprise_application: '企业应用特殊规则', channel_global: '通道全局规则', channel: '通道特殊规则',
|
enterprise_global: '企业全局规则',
|
||||||
|
enterprise_application: '企业应用特殊规则',
|
||||||
|
channel_global: '通道全局规则',
|
||||||
|
channel: '通道特殊规则',
|
||||||
};
|
};
|
||||||
type RuleDraft = {
|
type RuleDraft = {
|
||||||
ruleType: SignatureRetirementRuleType; targetId: string; enabled: boolean;
|
ruleType: SignatureRetirementRuleType;
|
||||||
mobileWindowDays: string; mobileThreshold: string; unicomWindowDays: string; unicomThreshold: string;
|
targetId: string;
|
||||||
telecomWindowDays: string; telecomThreshold: string; messageTemplate: string;
|
enabled: boolean;
|
||||||
|
mobileWindowDays: string;
|
||||||
|
mobileThreshold: string;
|
||||||
|
unicomWindowDays: string;
|
||||||
|
unicomThreshold: string;
|
||||||
|
telecomWindowDays: string;
|
||||||
|
telecomThreshold: string;
|
||||||
|
messageTemplate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyRule: RuleDraft = {
|
const emptyRule: RuleDraft = {
|
||||||
ruleType: 'enterprise_global', targetId: '', enabled: true,
|
ruleType: 'enterprise_global',
|
||||||
mobileWindowDays: '30', mobileThreshold: '1', unicomWindowDays: '30', unicomThreshold: '1',
|
targetId: '',
|
||||||
telecomWindowDays: '30', telecomThreshold: '1', messageTemplate: '',
|
enabled: true,
|
||||||
|
mobileWindowDays: '30',
|
||||||
|
mobileThreshold: '1',
|
||||||
|
unicomWindowDays: '30',
|
||||||
|
unicomThreshold: '1',
|
||||||
|
telecomWindowDays: '30',
|
||||||
|
telecomThreshold: '1',
|
||||||
|
messageTemplate: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
type MessageFilters = {
|
type MessageFilters = {
|
||||||
@@ -47,7 +79,13 @@ type SuppressionDraft = {
|
|||||||
|
|
||||||
function defaultMessageFilters(): MessageFilters {
|
function defaultMessageFilters(): MessageFilters {
|
||||||
const today = shanghaiDateKey();
|
const today = shanghaiDateKey();
|
||||||
return { dateRange: { start: today, end: today }, tenantId: '', applicationId: '', signatureKeyword: '', channelId: '' };
|
return {
|
||||||
|
dateRange: { start: today, end: today },
|
||||||
|
tenantId: '',
|
||||||
|
applicationId: '',
|
||||||
|
signatureKeyword: '',
|
||||||
|
channelId: '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSignatureRetirementPage() {
|
export function AdminSignatureRetirementPage() {
|
||||||
@@ -74,74 +112,252 @@ export function AdminSignatureRetirementPage() {
|
|||||||
const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => {
|
const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] = await Promise.all([
|
const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] =
|
||||||
adminApi.getSignatureRetirementConfiguration(),
|
await Promise.all([
|
||||||
adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)),
|
adminApi.getSignatureRetirementConfiguration(),
|
||||||
adminApi.listSignatureRetirementSuppressions(),
|
adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)),
|
||||||
adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels(), adminApi.listTenantOptions(),
|
adminApi.listSignatureRetirementSuppressions(),
|
||||||
]);
|
adminApi.listEnterpriseApplicationOptions(),
|
||||||
setRules(configuration.rules); setWebhooks(configuration.webhooks.filter((item) => item.status === 'active'));
|
adminApi.listChannels(),
|
||||||
setMessages(messageResult.items); setMessageTotal(messageResult.total); setMessagePage(messageResult.page);
|
adminApi.listTenantOptions(),
|
||||||
|
]);
|
||||||
|
setRules(configuration.rules);
|
||||||
|
setWebhooks(configuration.webhooks.filter((item) => item.status === 'active'));
|
||||||
|
setMessages(messageResult.items);
|
||||||
|
setMessageTotal(messageResult.total);
|
||||||
|
setMessagePage(messageResult.page);
|
||||||
setSuppressions(activeSuppressions);
|
setSuppressions(activeSuppressions);
|
||||||
setApplications(applicationRows); setChannels(channelRows); setTenants(tenantRows); setError('');
|
setApplications(applicationRows);
|
||||||
|
setChannels(channelRows);
|
||||||
|
setTenants(tenantRows);
|
||||||
|
setError('');
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(failure instanceof Error ? failure.message : '签名清退预警数据加载失败');
|
setError(failure instanceof Error ? failure.message : '签名清退预警数据加载失败');
|
||||||
} finally { setLoading(false); }
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { void loadData(1, defaultMessageFilters()); }, [loadData]);
|
useEffect(() => {
|
||||||
|
void loadData(1, defaultMessageFilters());
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
async function loadMessages(targetPage: number, filters: MessageFilters) {
|
async function loadMessages(targetPage: number, filters: MessageFilters) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters));
|
const result = await adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters));
|
||||||
setMessages(result.items); setMessageTotal(result.total); setMessagePage(result.page); setError('');
|
setMessages(result.items);
|
||||||
|
setMessageTotal(result.total);
|
||||||
|
setMessagePage(result.page);
|
||||||
|
setError('');
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(errorMessage(failure, '预警消息加载失败'));
|
setError(errorMessage(failure, '预警消息加载失败'));
|
||||||
} finally { setLoading(false); }
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ruleColumns: Array<TableColumn<SignatureRetirementRule>> = [
|
const ruleColumns: Array<TableColumn<SignatureRetirementRule>> = [
|
||||||
{ key: 'type', title: '规则范围', render: (item) => <><strong>{ruleTypeLabels[item.ruleType]}</strong><br /><small>{targetName(item, applications, channels)}</small></> },
|
{
|
||||||
|
key: 'type',
|
||||||
|
title: '规则范围',
|
||||||
|
render: (item) => (
|
||||||
|
<>
|
||||||
|
<strong>{ruleTypeLabels[item.ruleType]}</strong>
|
||||||
|
<br />
|
||||||
|
<small>{targetName(item, applications, channels)}</small>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ key: 'mobile', title: '移动', render: (item) => `${item.mobileWindowDays}天 / ${item.mobileThreshold}条` },
|
{ key: 'mobile', title: '移动', render: (item) => `${item.mobileWindowDays}天 / ${item.mobileThreshold}条` },
|
||||||
{ key: 'unicom', title: '联通', render: (item) => `${item.unicomWindowDays}天 / ${item.unicomThreshold}条` },
|
{ key: 'unicom', title: '联通', render: (item) => `${item.unicomWindowDays}天 / ${item.unicomThreshold}条` },
|
||||||
{ key: 'telecom', title: '电信', render: (item) => `${item.telecomWindowDays}天 / ${item.telecomThreshold}条` },
|
{ key: 'telecom', title: '电信', render: (item) => `${item.telecomWindowDays}天 / ${item.telecomThreshold}条` },
|
||||||
{ key: 'version', title: '版本', render: (item) => `v${item.version}` },
|
{ key: 'version', title: '版本', render: (item) => `v${item.version}` },
|
||||||
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button onClick={() => setRuleDraft(ruleToDraft(item))} size="sm" variant="ghost">编辑</Button> },
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
align: 'right',
|
||||||
|
render: (item) => (
|
||||||
|
<Button onClick={() => setRuleDraft(ruleToDraft(item))} size="sm" variant="ghost">
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const messageColumns: Array<TableColumn<SignatureRetirementMessage>> = [
|
const messageColumns: Array<TableColumn<SignatureRetirementMessage>> = [
|
||||||
{ key: 'title', title: '预警', width: '340px', render: (item) => <div className="ui-table__long-text"><strong>{item.title}</strong><br /><span>{item.content}</span></div> },
|
{
|
||||||
{ key: 'dimension', title: '维度', width: '240px', render: (item) => <>{item.tenantName ?? '-'}<br /><small>{item.applicationName ?? '未关联企业应用'} / {item.signatureName ?? '-'}</small><br /><small>{item.channelName ?? '企业维度'}</small></> },
|
key: 'title',
|
||||||
{ key: 'carrier', title: '运营商', width: '90px', render: (item) => <CarrierTag carrier={item.detection?.carrier ?? 'mobile'} /> },
|
title: '预警',
|
||||||
{ key: 'count', title: '活动量', width: '130px', render: (item) => item.detection ? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}` : '-' },
|
width: '340px',
|
||||||
|
render: (item) => (
|
||||||
|
<div className="ui-table__long-text">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
{item.dailyGroupKey ? (
|
||||||
|
<details>
|
||||||
|
<summary>{item.detections?.length ?? 0} 项预警明细</summary>
|
||||||
|
{item.content.split('\n').map((line, index) => (
|
||||||
|
<p key={index}>{line}</p>
|
||||||
|
))}
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
item.content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dimension',
|
||||||
|
title: '维度',
|
||||||
|
width: '240px',
|
||||||
|
render: (item) => (
|
||||||
|
<>
|
||||||
|
{item.tenantName ?? '-'}
|
||||||
|
<br />
|
||||||
|
<small>
|
||||||
|
{item.applicationName ?? '未关联企业应用'}
|
||||||
|
{item.dailyGroupKey ? '' : ` / ${item.signatureName ?? '-'}`}
|
||||||
|
</small>
|
||||||
|
<br />
|
||||||
|
<small>{item.dailyGroupKey ? '企业应用每日汇总' : (item.channelName ?? '企业维度')}</small>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'carrier',
|
||||||
|
title: '运营商',
|
||||||
|
width: '90px',
|
||||||
|
render: (item) =>
|
||||||
|
item.dailyGroupKey ? (
|
||||||
|
<>
|
||||||
|
{[...new Set(item.detections?.map((entry) => entry.carrier) ?? [])].map((carrier) => (
|
||||||
|
<CarrierTag key={carrier} carrier={carrier} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<CarrierTag carrier={item.detection?.carrier ?? 'mobile'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'count',
|
||||||
|
title: '活动量',
|
||||||
|
width: '130px',
|
||||||
|
render: (item) =>
|
||||||
|
item.dailyGroupKey
|
||||||
|
? `${item.detections?.length ?? 0} 项(详见正文)`
|
||||||
|
: item.detection
|
||||||
|
? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}`
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
{ key: 'time', title: '消息时间', width: '170px', render: (item) => formatDateTime(item.createdAt) },
|
{ key: 'time', title: '消息时间', width: '170px', render: (item) => formatDateTime(item.createdAt) },
|
||||||
{ key: 'state', title: '状态', width: '90px', render: (item) => item.suppressed ? <Tag>已抑制</Tag> : item.isRead ? <Tag tone="info">已读</Tag> : <Tag tone="warning">未读</Tag> },
|
{
|
||||||
{ key: 'actions', title: '操作', align: 'right', width: '190px', render: (item) => <div className="page-actions">{!item.isRead ? <Button onClick={() => void readMessage(item.id)} size="sm" variant="ghost">标为已读</Button> : null}{!item.suppressed ? <Button onClick={() => openSuppression(item.id)} size="sm" variant="ghost">抑制</Button> : null}</div> },
|
key: 'state',
|
||||||
|
title: '状态',
|
||||||
|
width: '90px',
|
||||||
|
render: (item) =>
|
||||||
|
item.suppressed ? (
|
||||||
|
<Tag>已抑制</Tag>
|
||||||
|
) : item.isRead ? (
|
||||||
|
<Tag tone="info">已读</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag tone="warning">未读</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
align: 'right',
|
||||||
|
width: '190px',
|
||||||
|
render: (item) => (
|
||||||
|
<div className="page-actions">
|
||||||
|
{!item.isRead ? (
|
||||||
|
<Button onClick={() => void readMessage(item.id)} size="sm" variant="ghost">
|
||||||
|
标为已读
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!item.suppressed ? (
|
||||||
|
<Button onClick={() => openSuppression(item.id)} size="sm" variant="ghost">
|
||||||
|
{item.dailyGroupKey ? '抑制整组' : '抑制'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const suppressionColumns: Array<TableColumn<SignatureRetirementSuppression>> = [
|
const suppressionColumns: Array<TableColumn<SignatureRetirementSuppression>> = [
|
||||||
{ key: 'dimension', title: '维度', render: (item) => <span>{item.dimensionType === 'enterprise' ? '企业' : '通道'} / <CarrierTag carrier={item.carrier} /></span> },
|
{
|
||||||
{ key: 'mode', title: '方式', render: (item) => item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}` },
|
key: 'dimension',
|
||||||
|
title: '维度',
|
||||||
|
render: (item) => (
|
||||||
|
<span>
|
||||||
|
{item.dimensionType === 'enterprise' ? '企业' : '通道'} / <CarrierTag carrier={item.carrier} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mode',
|
||||||
|
title: '方式',
|
||||||
|
render: (item) => (item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}`),
|
||||||
|
},
|
||||||
{ key: 'reason', title: '原因', render: (item) => item.reason || '-' },
|
{ key: 'reason', title: '原因', render: (item) => item.reason || '-' },
|
||||||
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button icon={<ShieldOff size={15} />} onClick={() => { setActionError(''); setCancelSuppressionDraft({ id: item.id, reason: '' }); }} size="sm" variant="ghost">取消抑制</Button> },
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
align: 'right',
|
||||||
|
render: (item) => (
|
||||||
|
<Button
|
||||||
|
icon={<ShieldOff size={15} />}
|
||||||
|
onClick={() => {
|
||||||
|
setActionError('');
|
||||||
|
setCancelSuppressionDraft({ id: item.id, reason: '' });
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
取消抑制
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
async function saveRule() {
|
async function saveRule() {
|
||||||
if (!ruleDraft) return;
|
if (!ruleDraft) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await adminApi.saveSignatureRetirementRule({
|
await adminApi.saveSignatureRetirementRule({
|
||||||
...ruleDraft, targetId: ruleDraft.targetId || undefined,
|
...ruleDraft,
|
||||||
mobileWindowDays: Number(ruleDraft.mobileWindowDays), mobileThreshold: Number(ruleDraft.mobileThreshold),
|
targetId: ruleDraft.targetId || undefined,
|
||||||
unicomWindowDays: Number(ruleDraft.unicomWindowDays), unicomThreshold: Number(ruleDraft.unicomThreshold),
|
mobileWindowDays: Number(ruleDraft.mobileWindowDays),
|
||||||
telecomWindowDays: Number(ruleDraft.telecomWindowDays), telecomThreshold: Number(ruleDraft.telecomThreshold),
|
mobileThreshold: Number(ruleDraft.mobileThreshold),
|
||||||
|
unicomWindowDays: Number(ruleDraft.unicomWindowDays),
|
||||||
|
unicomThreshold: Number(ruleDraft.unicomThreshold),
|
||||||
|
telecomWindowDays: Number(ruleDraft.telecomWindowDays),
|
||||||
|
telecomThreshold: Number(ruleDraft.telecomThreshold),
|
||||||
});
|
});
|
||||||
setRuleDraft(null); await loadData(messagePage, appliedMessageFilters);
|
setRuleDraft(null);
|
||||||
} catch (failure) { setError(errorMessage(failure, '规则保存失败')); } finally { setLoading(false); }
|
await loadData(messagePage, appliedMessageFilters);
|
||||||
|
} catch (failure) {
|
||||||
|
setError(errorMessage(failure, '规则保存失败'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function saveWebhook() {
|
async function saveWebhook() {
|
||||||
try { await adminApi.createSignatureRetirementWebhook(webhookDraft); setWebhookOpen(false); setWebhookDraft({ name: '', platform: 'wecom', url: '' }); await loadData(messagePage, appliedMessageFilters); }
|
try {
|
||||||
catch (failure) { setError(errorMessage(failure, 'Webhook保存失败')); }
|
await adminApi.createSignatureRetirementWebhook(webhookDraft);
|
||||||
|
setWebhookOpen(false);
|
||||||
|
setWebhookDraft({ name: '', platform: 'wecom', url: '' });
|
||||||
|
await loadData(messagePage, appliedMessageFilters);
|
||||||
|
} catch (failure) {
|
||||||
|
setError(errorMessage(failure, 'Webhook保存失败'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function readMessage(id: string) {
|
||||||
|
await adminApi.readSignatureRetirementMessage(id);
|
||||||
|
await loadMessages(messagePage, appliedMessageFilters);
|
||||||
|
window.dispatchEvent(new Event('cmpp-retirement-count-refresh'));
|
||||||
}
|
}
|
||||||
async function readMessage(id: string) { await adminApi.readSignatureRetirementMessage(id); await loadMessages(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); }
|
|
||||||
function openSuppression(messageId: string) {
|
function openSuppression(messageId: string) {
|
||||||
setActionError('');
|
setActionError('');
|
||||||
setSuppressionDraft({ messageId, mode: 'temporary', muteUntil: addDateKey(shanghaiDateKey(), 7), reason: '' });
|
setSuppressionDraft({ messageId, mode: 'temporary', muteUntil: addDateKey(shanghaiDateKey(), 7), reason: '' });
|
||||||
@@ -149,76 +365,595 @@ export function AdminSignatureRetirementPage() {
|
|||||||
async function saveSuppression() {
|
async function saveSuppression() {
|
||||||
if (!suppressionDraft) return;
|
if (!suppressionDraft) return;
|
||||||
const reason = suppressionDraft.reason.trim();
|
const reason = suppressionDraft.reason.trim();
|
||||||
const days = suppressionDraft.mode === 'temporary' ? differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) : undefined;
|
const days =
|
||||||
if (!reason) { setActionError('请输入抑制原因'); return; }
|
suppressionDraft.mode === 'temporary'
|
||||||
if (suppressionDraft.mode === 'temporary' && (!days || days < 1)) { setActionError('临时抑制截止日期必须晚于今天'); return; }
|
? differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil)
|
||||||
|
: undefined;
|
||||||
|
if (!reason) {
|
||||||
|
setActionError('请输入抑制原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suppressionDraft.mode === 'temporary' && (!days || days < 1)) {
|
||||||
|
setActionError('临时抑制截止日期必须晚于今天');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await adminApi.suppressSignatureRetirementMessage(suppressionDraft.messageId, suppressionDraft.mode === 'permanent' ? { mode: 'permanent', reason } : { mode: 'temporary', days, reason });
|
await adminApi.suppressSignatureRetirementMessage(
|
||||||
setSuppressionDraft(null); setActionError('');
|
suppressionDraft.messageId,
|
||||||
|
suppressionDraft.mode === 'permanent' ? { mode: 'permanent', reason } : { mode: 'temporary', days, reason },
|
||||||
|
);
|
||||||
|
setSuppressionDraft(null);
|
||||||
|
setActionError('');
|
||||||
await loadData(messagePage, appliedMessageFilters);
|
await loadData(messagePage, appliedMessageFilters);
|
||||||
window.dispatchEvent(new Event('cmpp-retirement-count-refresh'));
|
window.dispatchEvent(new Event('cmpp-retirement-count-refresh'));
|
||||||
} catch (failure) { setActionError(errorMessage(failure, '抑制失败')); } finally { setLoading(false); }
|
} catch (failure) {
|
||||||
|
setActionError(errorMessage(failure, '抑制失败'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function confirmCancelSuppression() {
|
async function confirmCancelSuppression() {
|
||||||
if (!cancelSuppressionDraft) return;
|
if (!cancelSuppressionDraft) return;
|
||||||
const reason = cancelSuppressionDraft.reason.trim();
|
const reason = cancelSuppressionDraft.reason.trim();
|
||||||
if (!reason) { setActionError('请输入取消抑制原因'); return; }
|
if (!reason) {
|
||||||
|
setActionError('请输入取消抑制原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await adminApi.cancelSignatureRetirementSuppression(cancelSuppressionDraft.id, reason);
|
await adminApi.cancelSignatureRetirementSuppression(cancelSuppressionDraft.id, reason);
|
||||||
setCancelSuppressionDraft(null); setActionError(''); await loadData(messagePage, appliedMessageFilters);
|
setCancelSuppressionDraft(null);
|
||||||
} catch (failure) { setActionError(errorMessage(failure, '取消抑制失败')); } finally { setLoading(false); }
|
setActionError('');
|
||||||
|
await loadData(messagePage, appliedMessageFilters);
|
||||||
|
} catch (failure) {
|
||||||
|
setActionError(errorMessage(failure, '取消抑制失败'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteWebhook(id: string) {
|
||||||
|
if (!window.confirm('确认停用该Webhook?')) return;
|
||||||
|
await adminApi.deleteSignatureRetirementWebhook(id);
|
||||||
|
await loadData(messagePage, appliedMessageFilters);
|
||||||
|
}
|
||||||
|
async function readAll() {
|
||||||
|
await adminApi.readAllSignatureRetirementMessagesToday();
|
||||||
|
await loadMessages(messagePage, appliedMessageFilters);
|
||||||
|
window.dispatchEvent(new Event('cmpp-retirement-count-refresh'));
|
||||||
}
|
}
|
||||||
async function deleteWebhook(id: string) { if (!window.confirm('确认停用该Webhook?')) return; await adminApi.deleteSignatureRetirementWebhook(id); await loadData(messagePage, appliedMessageFilters); }
|
|
||||||
async function readAll() { await adminApi.readAllSignatureRetirementMessagesToday(); await loadMessages(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); }
|
|
||||||
const tenantOptions = tenants.map((item) => ({ value: item.id, label: item.name }));
|
const tenantOptions = tenants.map((item) => ({ value: item.id, label: item.name }));
|
||||||
const applicationOptions = applications
|
const applicationOptions = applications
|
||||||
.filter((item) => !messageFilters.tenantId || item.tenantId === messageFilters.tenantId)
|
.filter((item) => !messageFilters.tenantId || item.tenantId === messageFilters.tenantId)
|
||||||
.map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` }));
|
.map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` }));
|
||||||
function applyMessageQuery() {
|
function applyMessageQuery() {
|
||||||
const filters = { ...messageFilters, dateRange: normalizeMessageDateRange(messageFilters.dateRange) };
|
const filters = { ...messageFilters, dateRange: normalizeMessageDateRange(messageFilters.dateRange) };
|
||||||
setMessageFilters(filters); setAppliedMessageFilters(filters); setMessagePage(1); void loadMessages(1, filters);
|
setMessageFilters(filters);
|
||||||
|
setAppliedMessageFilters(filters);
|
||||||
|
setMessagePage(1);
|
||||||
|
void loadMessages(1, filters);
|
||||||
}
|
}
|
||||||
function resetMessageQuery() {
|
function resetMessageQuery() {
|
||||||
const filters = defaultMessageFilters();
|
const filters = defaultMessageFilters();
|
||||||
setMessageFilters(filters); setAppliedMessageFilters(filters); setMessagePage(1); void loadMessages(1, filters);
|
setMessageFilters(filters);
|
||||||
|
setAppliedMessageFilters(filters);
|
||||||
|
setMessagePage(1);
|
||||||
|
void loadMessages(1, filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'messages', label: `预警消息(${messageTotal})`, content: <div className="surface signature-retirement-message-card"><div className="section-heading"><div><h2>预警消息</h2><p className="muted">默认查询今日,可按历史日期区间和业务维度检索真实预警消息。</p></div><Button onClick={() => void readAll()} variant="ghost">今日全部已读</Button></div><div className="signature-retirement-message-filter"><Select label="企业" onChange={(event) => setMessageFilters((value) => ({ ...value, tenantId: event.target.value, applicationId: '' }))} options={[{ value: '', label: '全部企业' }, ...tenantOptions]} searchable value={messageFilters.tenantId} /><Select label="企业应用" onChange={(event) => setMessageFilters((value) => ({ ...value, applicationId: event.target.value }))} options={[{ value: '', label: '全部企业应用' }, ...applicationOptions]} searchable value={messageFilters.applicationId} /><Input label="签名" onChange={(event) => setMessageFilters((value) => ({ ...value, signatureKeyword: event.target.value }))} placeholder="请输入签名名称" value={messageFilters.signatureKeyword} /><Select label="通道" onChange={(event) => setMessageFilters((value) => ({ ...value, channelId: event.target.value }))} options={[{ value: '', label: '全部通道' }, ...channels.map((item) => ({ value: item.id, label: `${item.name}(${item.code})` }))]} searchable value={messageFilters.channelId} /><DateRangeInput label="预警日期" onChange={(dateRange) => setMessageFilters((value) => ({ ...value, dateRange }))} value={messageFilters.dateRange} /><div className="signature-retirement-message-filter__actions"><Button icon={<Search size={16} />} onClick={applyMessageQuery}>查询</Button><Button onClick={resetMessageQuery} variant="ghost">重置</Button></div></div><Table columns={messageColumns} data={messages} emptyText="暂无符合条件的预警消息" pagination={false} rowKey="id" />{messageTotal > 0 ? <Pagination nextDisabled={messagePage >= Math.ceil(messageTotal / 10)} onNext={() => { const page = messagePage + 1; setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} onPageChange={(page) => { setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} onPrevious={() => { const page = Math.max(1, messagePage - 1); setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} page={messagePage} previousDisabled={messagePage <= 1} total={messageTotal} totalPages={Math.max(1, Math.ceil(messageTotal / 10))} /> : null}</div> },
|
{
|
||||||
{ value: 'rules', label: '检测规则', content: <div className="surface"><div className="section-heading"><div><h2>检测规则</h2><p className="muted">特殊规则优先于全局规则;修改后从下一检测日生效。</p></div><Button icon={<Plus size={16} />} onClick={() => setRuleDraft({ ...emptyRule })}>新增规则</Button></div><Table columns={ruleColumns} data={rules} emptyText="暂无规则,未配置规则的维度不会进入检测" pagination={false} rowKey="id" /></div> },
|
value: 'messages',
|
||||||
{ value: 'webhooks', label: 'Webhook', content: <div className="surface"><div className="section-heading"><div><h2>企业微信 / 飞书通知</h2><p className="muted">地址加密保存,发送失败自动退避重试。</p></div><Button icon={<Plus size={16} />} onClick={() => setWebhookOpen(true)}>新增Webhook</Button></div><div className="settings-list">{webhooks.map((item) => <div className="settings-list__item" key={item.id}><div><strong>{item.name}</strong><p>{item.platform === 'wecom' ? '企业微信' : '飞书'} · {item.urlMasked}</p></div><Button icon={<Trash2 size={15} />} onClick={() => void deleteWebhook(item.id)} variant="ghost">停用</Button></div>)}{!webhooks.length ? <p className="empty-state">暂无Webhook</p> : null}</div></div> },
|
label: `预警消息(${messageTotal})`,
|
||||||
{ value: 'suppressions', label: `抑制管理(${suppressions.length})`, content: <div className="surface"><Table columns={suppressionColumns} data={suppressions} emptyText="暂无有效抑制" pagination={false} rowKey="id" /></div> },
|
content: (
|
||||||
|
<div className="surface signature-retirement-message-card">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>预警消息</h2>
|
||||||
|
<p className="muted">每日每个企业应用一条预警,展开查看全部明细;历史消息保持原记录。</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => void readAll()} variant="ghost">
|
||||||
|
今日全部已读
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="signature-retirement-message-filter">
|
||||||
|
<Select
|
||||||
|
label="企业"
|
||||||
|
onChange={(event) =>
|
||||||
|
setMessageFilters((value) => ({ ...value, tenantId: event.target.value, applicationId: '' }))
|
||||||
|
}
|
||||||
|
options={[{ value: '', label: '全部企业' }, ...tenantOptions]}
|
||||||
|
searchable
|
||||||
|
value={messageFilters.tenantId}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="企业应用"
|
||||||
|
onChange={(event) => setMessageFilters((value) => ({ ...value, applicationId: event.target.value }))}
|
||||||
|
options={[{ value: '', label: '全部企业应用' }, ...applicationOptions]}
|
||||||
|
searchable
|
||||||
|
value={messageFilters.applicationId}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="签名"
|
||||||
|
onChange={(event) => setMessageFilters((value) => ({ ...value, signatureKeyword: event.target.value }))}
|
||||||
|
placeholder="请输入签名名称"
|
||||||
|
value={messageFilters.signatureKeyword}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="通道"
|
||||||
|
onChange={(event) => setMessageFilters((value) => ({ ...value, channelId: event.target.value }))}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部通道' },
|
||||||
|
...channels.map((item) => ({ value: item.id, label: `${item.name}(${item.code})` })),
|
||||||
|
]}
|
||||||
|
searchable
|
||||||
|
value={messageFilters.channelId}
|
||||||
|
/>
|
||||||
|
<DateRangeInput
|
||||||
|
label="预警日期"
|
||||||
|
onChange={(dateRange) => setMessageFilters((value) => ({ ...value, dateRange }))}
|
||||||
|
value={messageFilters.dateRange}
|
||||||
|
/>
|
||||||
|
<div className="signature-retirement-message-filter__actions">
|
||||||
|
<Button icon={<Search size={16} />} onClick={applyMessageQuery}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={resetMessageQuery} variant="ghost">
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
columns={messageColumns}
|
||||||
|
data={messages}
|
||||||
|
emptyText="暂无符合条件的预警消息"
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
|
/>
|
||||||
|
{messageTotal > 0 ? (
|
||||||
|
<Pagination
|
||||||
|
nextDisabled={messagePage >= Math.ceil(messageTotal / 10)}
|
||||||
|
onNext={() => {
|
||||||
|
const page = messagePage + 1;
|
||||||
|
setMessagePage(page);
|
||||||
|
void loadMessages(page, appliedMessageFilters);
|
||||||
|
}}
|
||||||
|
onPageChange={(page) => {
|
||||||
|
setMessagePage(page);
|
||||||
|
void loadMessages(page, appliedMessageFilters);
|
||||||
|
}}
|
||||||
|
onPrevious={() => {
|
||||||
|
const page = Math.max(1, messagePage - 1);
|
||||||
|
setMessagePage(page);
|
||||||
|
void loadMessages(page, appliedMessageFilters);
|
||||||
|
}}
|
||||||
|
page={messagePage}
|
||||||
|
previousDisabled={messagePage <= 1}
|
||||||
|
total={messageTotal}
|
||||||
|
totalPages={Math.max(1, Math.ceil(messageTotal / 10))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'rules',
|
||||||
|
label: '检测规则',
|
||||||
|
content: (
|
||||||
|
<div className="surface">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>检测规则</h2>
|
||||||
|
<p className="muted">特殊规则优先于全局规则;修改后从下一检测日生效。</p>
|
||||||
|
</div>
|
||||||
|
<Button icon={<Plus size={16} />} onClick={() => setRuleDraft({ ...emptyRule })}>
|
||||||
|
新增规则
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
columns={ruleColumns}
|
||||||
|
data={rules}
|
||||||
|
emptyText="暂无规则,未配置规则的维度不会进入检测"
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'webhooks',
|
||||||
|
label: 'Webhook',
|
||||||
|
content: (
|
||||||
|
<div className="surface">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>企业微信 / 飞书通知</h2>
|
||||||
|
<p className="muted">地址加密保存,发送失败自动退避重试。</p>
|
||||||
|
</div>
|
||||||
|
<Button icon={<Plus size={16} />} onClick={() => setWebhookOpen(true)}>
|
||||||
|
新增Webhook
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="settings-list">
|
||||||
|
{webhooks.map((item) => (
|
||||||
|
<div className="settings-list__item" key={item.id}>
|
||||||
|
<div>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<p>
|
||||||
|
{item.platform === 'wecom' ? '企业微信' : '飞书'} · {item.urlMasked}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button icon={<Trash2 size={15} />} onClick={() => void deleteWebhook(item.id)} variant="ghost">
|
||||||
|
停用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!webhooks.length ? <p className="empty-state">暂无Webhook</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'suppressions',
|
||||||
|
label: `抑制管理(${suppressions.length})`,
|
||||||
|
content: (
|
||||||
|
<div className="surface">
|
||||||
|
<Table
|
||||||
|
columns={suppressionColumns}
|
||||||
|
data={suppressions}
|
||||||
|
emptyText="暂无有效抑制"
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return <section className="page-stack">
|
return (
|
||||||
<div className="page-heading"><div><Breadcrumb items={['安全控制', '签名清退预警']} /><h1>签名清退预警</h1><p className="muted">每天北京时间04:00自动检测,08:00生成站内消息并发送Webhook。</p></div><div className="page-actions"><Button disabled={loading} icon={<RefreshCw size={16} />} onClick={() => void loadData(messagePage, appliedMessageFilters)} variant="ghost">刷新</Button></div></div>
|
<section className="page-stack">
|
||||||
{error ? <p className="form-error">{error}</p> : null}<Tabs items={tabs} />
|
<div className="page-heading">
|
||||||
<RuleModal applications={applications} channels={channels} draft={ruleDraft} loading={loading} onChange={setRuleDraft} onClose={() => setRuleDraft(null)} onSave={() => void saveRule()} />
|
<div>
|
||||||
<Modal open={webhookOpen} title="新增Webhook" onClose={() => setWebhookOpen(false)} footer={<><Button onClick={() => setWebhookOpen(false)} variant="ghost">取消</Button><Button onClick={() => void saveWebhook()}>保存</Button></>}><div className="form-grid"><Input label="名称" onChange={(event) => setWebhookDraft((value) => ({ ...value, name: event.target.value }))} value={webhookDraft.name} /><Select label="平台" onChange={(event) => setWebhookDraft((value) => ({ ...value, platform: event.target.value as 'wecom' | 'feishu' }))} options={[{ value: 'wecom', label: '企业微信' }, { value: 'feishu', label: '飞书' }]} value={webhookDraft.platform} /><Input className="form-grid__full" label="Webhook HTTPS地址" onChange={(event) => setWebhookDraft((value) => ({ ...value, url: event.target.value }))} value={webhookDraft.url} /></div></Modal>
|
<Breadcrumb items={['安全控制', '签名清退预警']} />
|
||||||
<Modal dirty={Boolean(suppressionDraft?.reason.trim())} open={Boolean(suppressionDraft)} title="设置预警抑制" onClose={() => { setSuppressionDraft(null); setActionError(''); }} footer={<><Button onClick={() => { setSuppressionDraft(null); setActionError(''); }} variant="ghost">取消</Button><Button disabled={loading || !suppressionDraft?.reason.trim() || (suppressionDraft?.mode === 'temporary' && differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) < 1)} onClick={() => void saveSuppression()}>确认抑制</Button></>}>
|
<h1>签名清退预警</h1>
|
||||||
{suppressionDraft ? <div className="page-stack"><p className="muted">抑制只停止站内提醒和Webhook,每日检测仍会继续。</p><div className="signature-retirement-suppression-modes"><label className={suppressionDraft.mode === 'temporary' ? 'is-selected' : ''}><input checked={suppressionDraft.mode === 'temporary'} name="suppression-mode" onChange={() => setSuppressionDraft((value) => value ? { ...value, mode: 'temporary' } : value)} type="radio" /><span><strong>临时抑制</strong><small>到指定日期后自动恢复</small></span></label><label className={suppressionDraft.mode === 'permanent' ? 'is-selected' : ''}><input checked={suppressionDraft.mode === 'permanent'} name="suppression-mode" onChange={() => setSuppressionDraft((value) => value ? { ...value, mode: 'permanent' } : value)} type="radio" /><span><strong>永久抑制</strong><small>需在抑制管理中人工取消</small></span></label></div>{suppressionDraft.mode === 'temporary' ? <Input label="抑制截止日期" min={addDateKey(shanghaiDateKey(), 1)} onChange={(event) => setSuppressionDraft((value) => value ? { ...value, muteUntil: event.target.value } : value)} type="date" value={suppressionDraft.muteUntil} /> : null}<Textarea label="抑制原因" maxLength={500} onChange={(event) => setSuppressionDraft((value) => value ? { ...value, reason: event.target.value } : value)} placeholder="请填写抑制原因" rows={4} value={suppressionDraft.reason} />{actionError ? <p className="form-error">{actionError}</p> : null}</div> : null}
|
<p className="muted">每天北京时间04:00自动检测,08:00生成站内消息并发送Webhook。</p>
|
||||||
</Modal>
|
</div>
|
||||||
<Modal dirty={Boolean(cancelSuppressionDraft?.reason.trim())} open={Boolean(cancelSuppressionDraft)} title="取消抑制" onClose={() => { setCancelSuppressionDraft(null); setActionError(''); }} footer={<><Button onClick={() => { setCancelSuppressionDraft(null); setActionError(''); }} variant="ghost">取消</Button><Button disabled={loading || !cancelSuppressionDraft?.reason.trim()} onClick={() => void confirmCancelSuppression()}>确认取消抑制</Button></>}>
|
<div className="page-actions">
|
||||||
{cancelSuppressionDraft ? <div className="page-stack"><p className="muted">取消后从下一检测日恢复预警,不补发抑制期间的历史通知。</p><Textarea label="取消原因" maxLength={500} onChange={(event) => setCancelSuppressionDraft((value) => value ? { ...value, reason: event.target.value } : value)} placeholder="请填写取消抑制原因" rows={4} value={cancelSuppressionDraft.reason} />{actionError ? <p className="form-error">{actionError}</p> : null}</div> : null}
|
<Button
|
||||||
</Modal>
|
disabled={loading}
|
||||||
</section>;
|
icon={<RefreshCw size={16} />}
|
||||||
|
onClick={() => void loadData(messagePage, appliedMessageFilters)}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
<Tabs items={tabs} />
|
||||||
|
<RuleModal
|
||||||
|
applications={applications}
|
||||||
|
channels={channels}
|
||||||
|
draft={ruleDraft}
|
||||||
|
loading={loading}
|
||||||
|
onChange={setRuleDraft}
|
||||||
|
onClose={() => setRuleDraft(null)}
|
||||||
|
onSave={() => void saveRule()}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
open={webhookOpen}
|
||||||
|
title="新增Webhook"
|
||||||
|
onClose={() => setWebhookOpen(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setWebhookOpen(false)} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void saveWebhook()}>保存</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Input
|
||||||
|
label="名称"
|
||||||
|
onChange={(event) => setWebhookDraft((value) => ({ ...value, name: event.target.value }))}
|
||||||
|
value={webhookDraft.name}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="平台"
|
||||||
|
onChange={(event) =>
|
||||||
|
setWebhookDraft((value) => ({ ...value, platform: event.target.value as 'wecom' | 'feishu' }))
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: 'wecom', label: '企业微信' },
|
||||||
|
{ value: 'feishu', label: '飞书' },
|
||||||
|
]}
|
||||||
|
value={webhookDraft.platform}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="form-grid__full"
|
||||||
|
label="Webhook HTTPS地址"
|
||||||
|
onChange={(event) => setWebhookDraft((value) => ({ ...value, url: event.target.value }))}
|
||||||
|
value={webhookDraft.url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
dirty={Boolean(suppressionDraft?.reason.trim())}
|
||||||
|
open={Boolean(suppressionDraft)}
|
||||||
|
title="设置预警抑制"
|
||||||
|
onClose={() => {
|
||||||
|
setSuppressionDraft(null);
|
||||||
|
setActionError('');
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSuppressionDraft(null);
|
||||||
|
setActionError('');
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
loading ||
|
||||||
|
!suppressionDraft?.reason.trim() ||
|
||||||
|
(suppressionDraft?.mode === 'temporary' &&
|
||||||
|
differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) < 1)
|
||||||
|
}
|
||||||
|
onClick={() => void saveSuppression()}
|
||||||
|
>
|
||||||
|
确认抑制
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{suppressionDraft ? (
|
||||||
|
<div className="page-stack">
|
||||||
|
<p className="muted">抑制只停止站内提醒和Webhook,每日检测仍会继续。</p>
|
||||||
|
<div className="signature-retirement-suppression-modes">
|
||||||
|
<label className={suppressionDraft.mode === 'temporary' ? 'is-selected' : ''}>
|
||||||
|
<input
|
||||||
|
checked={suppressionDraft.mode === 'temporary'}
|
||||||
|
name="suppression-mode"
|
||||||
|
onChange={() => setSuppressionDraft((value) => (value ? { ...value, mode: 'temporary' } : value))}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>临时抑制</strong>
|
||||||
|
<small>到指定日期后自动恢复</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className={suppressionDraft.mode === 'permanent' ? 'is-selected' : ''}>
|
||||||
|
<input
|
||||||
|
checked={suppressionDraft.mode === 'permanent'}
|
||||||
|
name="suppression-mode"
|
||||||
|
onChange={() => setSuppressionDraft((value) => (value ? { ...value, mode: 'permanent' } : value))}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>永久抑制</strong>
|
||||||
|
<small>需在抑制管理中人工取消</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{suppressionDraft.mode === 'temporary' ? (
|
||||||
|
<Input
|
||||||
|
label="抑制截止日期"
|
||||||
|
min={addDateKey(shanghaiDateKey(), 1)}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSuppressionDraft((value) => (value ? { ...value, muteUntil: event.target.value } : value))
|
||||||
|
}
|
||||||
|
type="date"
|
||||||
|
value={suppressionDraft.muteUntil}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Textarea
|
||||||
|
label="抑制原因"
|
||||||
|
maxLength={500}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSuppressionDraft((value) => (value ? { ...value, reason: event.target.value } : value))
|
||||||
|
}
|
||||||
|
placeholder="请填写抑制原因"
|
||||||
|
rows={4}
|
||||||
|
value={suppressionDraft.reason}
|
||||||
|
/>
|
||||||
|
{actionError ? <p className="form-error">{actionError}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
dirty={Boolean(cancelSuppressionDraft?.reason.trim())}
|
||||||
|
open={Boolean(cancelSuppressionDraft)}
|
||||||
|
title="取消抑制"
|
||||||
|
onClose={() => {
|
||||||
|
setCancelSuppressionDraft(null);
|
||||||
|
setActionError('');
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCancelSuppressionDraft(null);
|
||||||
|
setActionError('');
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={loading || !cancelSuppressionDraft?.reason.trim()}
|
||||||
|
onClick={() => void confirmCancelSuppression()}
|
||||||
|
>
|
||||||
|
确认取消抑制
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cancelSuppressionDraft ? (
|
||||||
|
<div className="page-stack">
|
||||||
|
<p className="muted">取消后从下一检测日恢复预警,不补发抑制期间的历史通知。</p>
|
||||||
|
<Textarea
|
||||||
|
label="取消原因"
|
||||||
|
maxLength={500}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCancelSuppressionDraft((value) => (value ? { ...value, reason: event.target.value } : value))
|
||||||
|
}
|
||||||
|
placeholder="请填写取消抑制原因"
|
||||||
|
rows={4}
|
||||||
|
value={cancelSuppressionDraft.reason}
|
||||||
|
/>
|
||||||
|
{actionError ? <p className="form-error">{actionError}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleModal({ applications, channels, draft, loading, onChange, onClose, onSave }: { applications: EnterpriseApplication[]; channels: AdminChannel[]; draft: RuleDraft | null; loading: boolean; onChange: (value: RuleDraft | null) => void; onClose: () => void; onSave: () => void }) {
|
function RuleModal({
|
||||||
|
applications,
|
||||||
|
channels,
|
||||||
|
draft,
|
||||||
|
loading,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
applications: EnterpriseApplication[];
|
||||||
|
channels: AdminChannel[];
|
||||||
|
draft: RuleDraft | null;
|
||||||
|
loading: boolean;
|
||||||
|
onChange: (value: RuleDraft | null) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}) {
|
||||||
const special = draft?.ruleType === 'enterprise_application' || draft?.ruleType === 'channel';
|
const special = draft?.ruleType === 'enterprise_application' || draft?.ruleType === 'channel';
|
||||||
const targetOptions = draft?.ruleType === 'enterprise_application' ? applications.map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` })) : channels.map((item) => ({ value: item.id, label: `${item.name}(${item.code})` }));
|
const targetOptions =
|
||||||
|
draft?.ruleType === 'enterprise_application'
|
||||||
|
? applications.map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` }))
|
||||||
|
: channels.map((item) => ({ value: item.id, label: `${item.name}(${item.code})` }));
|
||||||
const change = (key: keyof RuleDraft, value: string | boolean) => draft && onChange({ ...draft, [key]: value });
|
const change = (key: keyof RuleDraft, value: string | boolean) => draft && onChange({ ...draft, [key]: value });
|
||||||
return <Modal open={Boolean(draft)} title="签名清退检测规则" onClose={onClose} footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={loading || (special && !draft?.targetId)} icon={<Settings2 size={15} />} onClick={onSave}>保存规则</Button></>} size="xl"><div className="form-grid">{draft ? <><Select label="规则范围" onChange={(event) => onChange({ ...draft, ruleType: event.target.value as SignatureRetirementRuleType, targetId: '' })} options={Object.entries(ruleTypeLabels).map(([value, label]) => ({ value, label }))} value={draft.ruleType} />{special ? <Select label={draft.ruleType === 'channel' ? '目标通道' : '目标企业应用'} onChange={(event) => change('targetId', event.target.value)} options={targetOptions} placeholder="请选择" searchable value={draft.targetId} /> : <div className="surface"><strong>全局默认</strong><p className="muted">适用于未配置特殊规则的全部对象。</p></div>}{carriers.map((carrier) => <div className="surface" key={carrier}><strong>{carrierLabels[carrier]}</strong><div className="form-grid"><Input label="统计窗口(天)" min="1" max="365" onChange={(event) => change(`${carrier}WindowDays` as keyof RuleDraft, event.target.value)} type="number" value={draft[`${carrier}WindowDays`]} /><Input label="最低活动量(条)" min="0" onChange={(event) => change(`${carrier}Threshold` as keyof RuleDraft, event.target.value)} type="number" value={draft[`${carrier}Threshold`]} /></div></div>)}<Textarea className="form-grid__full" hint="可用变量:{enterprise} {signature} {channel} {carrier} {days} {threshold} {actual}" label="消息模板(留空使用系统模板)" onChange={(event) => change('messageTemplate', event.target.value)} rows={4} value={draft.messageTemplate} /></> : null}</div></Modal>;
|
return (
|
||||||
|
<Modal
|
||||||
|
open={Boolean(draft)}
|
||||||
|
title="签名清退检测规则"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} variant="ghost">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={loading || (special && !draft?.targetId)} icon={<Settings2 size={15} />} onClick={onSave}>
|
||||||
|
保存规则
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<div className="form-grid">
|
||||||
|
{draft ? (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
label="规则范围"
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...draft, ruleType: event.target.value as SignatureRetirementRuleType, targetId: '' })
|
||||||
|
}
|
||||||
|
options={Object.entries(ruleTypeLabels).map(([value, label]) => ({ value, label }))}
|
||||||
|
value={draft.ruleType}
|
||||||
|
/>
|
||||||
|
{special ? (
|
||||||
|
<Select
|
||||||
|
label={draft.ruleType === 'channel' ? '目标通道' : '目标企业应用'}
|
||||||
|
onChange={(event) => change('targetId', event.target.value)}
|
||||||
|
options={targetOptions}
|
||||||
|
placeholder="请选择"
|
||||||
|
searchable
|
||||||
|
value={draft.targetId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="surface">
|
||||||
|
<strong>全局默认</strong>
|
||||||
|
<p className="muted">适用于未配置特殊规则的全部对象。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{carriers.map((carrier) => (
|
||||||
|
<div className="surface" key={carrier}>
|
||||||
|
<strong>{carrierLabels[carrier]}</strong>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Input
|
||||||
|
label="统计窗口(天)"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
onChange={(event) => change(`${carrier}WindowDays` as keyof RuleDraft, event.target.value)}
|
||||||
|
type="number"
|
||||||
|
value={draft[`${carrier}WindowDays`]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="最低活动量(条)"
|
||||||
|
min="0"
|
||||||
|
onChange={(event) => change(`${carrier}Threshold` as keyof RuleDraft, event.target.value)}
|
||||||
|
type="number"
|
||||||
|
value={draft[`${carrier}Threshold`]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Textarea
|
||||||
|
className="form-grid__full"
|
||||||
|
hint="可用变量:{enterprise} {signature} {channel} {carrier} {days} {threshold} {actual}"
|
||||||
|
label="消息模板(留空使用系统模板)"
|
||||||
|
onChange={(event) => change('messageTemplate', event.target.value)}
|
||||||
|
rows={4}
|
||||||
|
value={draft.messageTemplate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ruleToDraft(rule: SignatureRetirementRule): RuleDraft { return { ruleType: rule.ruleType, targetId: rule.targetId ?? '', enabled: rule.enabled, mobileWindowDays: String(rule.mobileWindowDays), mobileThreshold: String(rule.mobileThreshold), unicomWindowDays: String(rule.unicomWindowDays), unicomThreshold: String(rule.unicomThreshold), telecomWindowDays: String(rule.telecomWindowDays), telecomThreshold: String(rule.telecomThreshold), messageTemplate: rule.messageTemplate ?? '' }; }
|
function ruleToDraft(rule: SignatureRetirementRule): RuleDraft {
|
||||||
function targetName(rule: SignatureRetirementRule, applications: EnterpriseApplication[], channels: AdminChannel[]) { if (!rule.targetId) return '全局默认'; return rule.ruleType === 'channel' ? channels.find((item) => item.id === rule.targetId)?.name ?? rule.targetId : applications.find((item) => item.id === rule.targetId)?.name ?? rule.targetId; }
|
return {
|
||||||
function formatDate(value?: string | null) { return value ? new Date(value).toLocaleDateString('zh-CN') : '-'; }
|
ruleType: rule.ruleType,
|
||||||
function formatDateTime(value?: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; }
|
targetId: rule.targetId ?? '',
|
||||||
function errorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; }
|
enabled: rule.enabled,
|
||||||
function shanghaiDateKey(value = new Date()) { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(value); }
|
mobileWindowDays: String(rule.mobileWindowDays),
|
||||||
|
mobileThreshold: String(rule.mobileThreshold),
|
||||||
|
unicomWindowDays: String(rule.unicomWindowDays),
|
||||||
|
unicomThreshold: String(rule.unicomThreshold),
|
||||||
|
telecomWindowDays: String(rule.telecomWindowDays),
|
||||||
|
telecomThreshold: String(rule.telecomThreshold),
|
||||||
|
messageTemplate: rule.messageTemplate ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function targetName(rule: SignatureRetirementRule, applications: EnterpriseApplication[], channels: AdminChannel[]) {
|
||||||
|
if (!rule.targetId) return '全局默认';
|
||||||
|
return rule.ruleType === 'channel'
|
||||||
|
? (channels.find((item) => item.id === rule.targetId)?.name ?? rule.targetId)
|
||||||
|
: (applications.find((item) => item.id === rule.targetId)?.name ?? rule.targetId);
|
||||||
|
}
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
return value ? new Date(value).toLocaleDateString('zh-CN') : '-';
|
||||||
|
}
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||||
|
}
|
||||||
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
function shanghaiDateKey(value = new Date()) {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
function normalizeMessageDateRange(value: DateRangeValue): DateRangeValue {
|
function normalizeMessageDateRange(value: DateRangeValue): DateRangeValue {
|
||||||
const fallback = shanghaiDateKey();
|
const fallback = shanghaiDateKey();
|
||||||
const start = value.start || value.end || fallback;
|
const start = value.start || value.end || fallback;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
@@ -57,7 +57,7 @@ describe('report workbench pages', () => {
|
|||||||
items: [task('1'), task('2')],
|
items: [task('1'), task('2')],
|
||||||
total: 2,
|
total: 2,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 25,
|
||||||
});
|
});
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -77,6 +77,117 @@ describe('report workbench pages', () => {
|
|||||||
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
|
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('changes detail page size while retaining applied filters and clearing current-page selection', async () => {
|
||||||
|
adminApi.listReportDetailsPage.mockImplementation(async ({ page, pageSize }) => ({
|
||||||
|
items: [task(`${page}-1`), task(`${page}-2`)],
|
||||||
|
total: 258,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}));
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/admin/report-tasks?signatureId=signature-filter&scope=pending']}>
|
||||||
|
<AdminReportTasksPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await screen.findByText('签名1-1');
|
||||||
|
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ page: 1, pageSize: 25, signatureId: 'signature-filter', status: 'pending' }),
|
||||||
|
);
|
||||||
|
const sizeControl = screen.getByLabelText(/^每页数量/);
|
||||||
|
expect(sizeControl.closest('.ui-pagination')).not.toBeNull();
|
||||||
|
await user.click(sizeControl);
|
||||||
|
expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
|
||||||
|
'10 条/页',
|
||||||
|
'25 条/页',
|
||||||
|
'50 条/页',
|
||||||
|
'100 条/页',
|
||||||
|
]);
|
||||||
|
await user.click(screen.getByRole('option', { name: '25 条/页' }));
|
||||||
|
await user.type(screen.getByRole('textbox', { name: '企业/应用/通道/报备对象' }), '测试企业');
|
||||||
|
await user.click(screen.getByRole('button', { name: '查询' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '下一页' }));
|
||||||
|
await screen.findByText('签名2-1');
|
||||||
|
await user.click(screen.getByRole('button', { name: '全选当页' }));
|
||||||
|
expect(screen.getByRole('button', { name: '批量修改状态(2)' })).toBeEnabled();
|
||||||
|
|
||||||
|
await user.click(sizeControl);
|
||||||
|
await user.click(screen.getByRole('option', { name: '100 条/页' }));
|
||||||
|
await screen.findByText('签名1-1');
|
||||||
|
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
keyword: '测试企业',
|
||||||
|
signatureId: 'signature-filter',
|
||||||
|
status: 'pending',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: '批量修改状态(0)' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('spinbutton', { name: '跳转页码' })).toHaveAttribute('max', '3');
|
||||||
|
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
|
||||||
|
|
||||||
|
for (const size of [10, 50, 25]) {
|
||||||
|
await user.click(sizeControl);
|
||||||
|
await user.click(screen.getByRole('option', { name: `${size} 条/页` }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(adminApi.listReportDetailsPage).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ page: 1, pageSize: size, keyword: '测试企业' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the latest detail page size result when an earlier request finishes last', async () => {
|
||||||
|
const initialPage = { items: [task('old-25')], total: 258, page: 1, pageSize: 25 };
|
||||||
|
const latestPage = { items: [task('new-100')], total: 258, page: 1, pageSize: 100 };
|
||||||
|
let resolveInitial!: (value: typeof initialPage) => void;
|
||||||
|
let resolveLatest!: (value: typeof latestPage) => void;
|
||||||
|
adminApi.listReportDetailsPage
|
||||||
|
.mockImplementationOnce(() => new Promise((resolve) => (resolveInitial = resolve)))
|
||||||
|
.mockImplementationOnce(() => new Promise((resolve) => (resolveLatest = resolve)));
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AdminReportTasksPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByLabelText(/^每页数量/));
|
||||||
|
await user.click(screen.getByRole('option', { name: '100 条/页' }));
|
||||||
|
await waitFor(() => expect(adminApi.listReportDetailsPage).toHaveBeenCalledTimes(2));
|
||||||
|
await act(async () => resolveLatest(latestPage));
|
||||||
|
expect(screen.getByText('签名new-100')).toBeVisible();
|
||||||
|
await act(async () => resolveInitial(initialPage));
|
||||||
|
expect(screen.queryByText('签名old-25')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('签名new-100')).toBeVisible();
|
||||||
|
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('100 条/页');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the current detail request failure visible without replacing it with a stale failure', async () => {
|
||||||
|
let rejectInitial!: (error: Error) => void;
|
||||||
|
let rejectLatest!: (error: Error) => void;
|
||||||
|
adminApi.listReportDetailsPage
|
||||||
|
.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectInitial = reject)))
|
||||||
|
.mockImplementationOnce(() => new Promise((_resolve, reject) => (rejectLatest = reject)));
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AdminReportTasksPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByLabelText(/^每页数量/));
|
||||||
|
await user.click(screen.getByRole('option', { name: '100 条/页' }));
|
||||||
|
await waitFor(() => expect(adminApi.listReportDetailsPage).toHaveBeenCalledTimes(2));
|
||||||
|
await act(async () => rejectLatest(new Error('当前分页加载失败')));
|
||||||
|
expect(screen.getByText('当前分页加载失败')).toBeVisible();
|
||||||
|
await act(async () => rejectInitial(new Error('已过期的分页请求失败')));
|
||||||
|
expect(screen.getByText('当前分页加载失败')).toBeVisible();
|
||||||
|
expect(screen.queryByText('已过期的分页请求失败')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the shared current-page selection button in the material pool heading', async () => {
|
it('uses the shared current-page selection button in the material pool heading', async () => {
|
||||||
const material = {
|
const material = {
|
||||||
id: 'signature:signature-1',
|
id: 'signature:signature-1',
|
||||||
|
|||||||
@@ -32,9 +32,13 @@ export function ChannelFormModal({
|
|||||||
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
|
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
|
||||||
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
|
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
|
||||||
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
||||||
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30));
|
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(
|
||||||
|
String(channel?.heartbeatIntervalSeconds ?? 30),
|
||||||
|
);
|
||||||
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
|
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
|
||||||
const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(channel?.longMessageReceiptMode ?? 'per_segment');
|
const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(
|
||||||
|
channel?.longMessageReceiptMode ?? 'per_segment',
|
||||||
|
);
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (carriers.length === 0) {
|
if (carriers.length === 0) {
|
||||||
@@ -82,34 +86,74 @@ export function ChannelFormModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
footer={(
|
closeOnBackdrop={modal.mode === 'edit'}
|
||||||
|
closeOnEscape={modal.mode === 'edit'}
|
||||||
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
<Button onClick={onClose} variant="ghost">
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
<Button onClick={submit}>确认</Button>
|
<Button onClick={submit}>确认</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
open
|
open
|
||||||
size="xl"
|
size="xl"
|
||||||
title={<div className="template-modal-title"><h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2></div>}
|
title={
|
||||||
|
<div className="template-modal-title">
|
||||||
|
<h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="sms-channel-form">
|
<div className="sms-channel-form">
|
||||||
<section>
|
<section>
|
||||||
<h3>业务信息</h3>
|
<h3>业务信息</h3>
|
||||||
<div className="sms-channel-form-grid">
|
<div className="sms-channel-form-grid">
|
||||||
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
|
<Input
|
||||||
|
label="* 通道名称"
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="请输入通道名称"
|
||||||
|
value={name}
|
||||||
|
/>
|
||||||
<div className="sms-channel-radio-row">
|
<div className="sms-channel-radio-row">
|
||||||
<span>* 运营商</span>
|
<span>* 运营商</span>
|
||||||
{baseCarrierOptions.map((item) => (
|
{baseCarrierOptions.map((item) => (
|
||||||
<label key={item.value}>
|
<label key={item.value}>
|
||||||
<input checked={carriers.includes(item.value)} onChange={() => { setCarriers((current) => current.includes(item.value) ? current.filter((carrier) => carrier !== item.value) : [...current, item.value]); setCarrierError(''); }} type="checkbox" />
|
<input
|
||||||
|
checked={carriers.includes(item.value)}
|
||||||
|
onChange={() => {
|
||||||
|
setCarriers((current) =>
|
||||||
|
current.includes(item.value)
|
||||||
|
? current.filter((carrier) => carrier !== item.value)
|
||||||
|
: [...current, item.value],
|
||||||
|
);
|
||||||
|
setCarrierError('');
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
{item.label}
|
{item.label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{carrierError ? <small className="form-error">{carrierError}</small> : null}
|
{carrierError ? <small className="form-error">{carrierError}</small> : null}
|
||||||
</div>
|
</div>
|
||||||
<Input error={unitPriceError} label="* 单价(元)" min="0" onChange={(event) => { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} />
|
<Input
|
||||||
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} />
|
error={unitPriceError}
|
||||||
|
label="* 单价(元)"
|
||||||
|
min="0"
|
||||||
|
onChange={(event) => {
|
||||||
|
setUnitPrice(event.target.value);
|
||||||
|
setUnitPriceError('');
|
||||||
|
}}
|
||||||
|
step="0.0001"
|
||||||
|
type="number"
|
||||||
|
value={unitPrice}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="* 发送地区"
|
||||||
|
onChange={(event) => setRegion(event.target.value)}
|
||||||
|
options={regionOptions}
|
||||||
|
value={region}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -118,13 +162,39 @@ export function ChannelFormModal({
|
|||||||
<div className="sms-channel-form-grid">
|
<div className="sms-channel-form-grid">
|
||||||
<Input disabled label="* 协议选择" value="CMPP" />
|
<Input disabled label="* 协议选择" value="CMPP" />
|
||||||
<div className="sms-channel-inline-field">
|
<div className="sms-channel-inline-field">
|
||||||
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
|
<Input
|
||||||
|
label="* 网关地址"
|
||||||
|
onChange={(event) => setGatewayHost(event.target.value)}
|
||||||
|
placeholder="请输入网关地址"
|
||||||
|
value={gatewayHost}
|
||||||
|
/>
|
||||||
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
|
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
|
||||||
</div>
|
</div>
|
||||||
<Input hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} />
|
<Input
|
||||||
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
|
hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。"
|
||||||
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
|
label="* 业务代码"
|
||||||
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
|
maxLength={10}
|
||||||
|
onChange={(event) => setBusinessCode(event.target.value.toUpperCase())}
|
||||||
|
value={businessCode}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="* 企业代码"
|
||||||
|
onChange={(event) => setCorpCode(event.target.value)}
|
||||||
|
placeholder="请输入企业代码"
|
||||||
|
value={corpCode}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="* 网关账号"
|
||||||
|
onChange={(event) => setAccount(event.target.value)}
|
||||||
|
placeholder="请输入网关账号"
|
||||||
|
value={account}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="* CMPP版本"
|
||||||
|
onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')}
|
||||||
|
options={cmppVersionOptions}
|
||||||
|
value={cmppVersion}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
|
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
|
||||||
label="网关密码"
|
label="网关密码"
|
||||||
@@ -137,14 +207,62 @@ export function ChannelFormModal({
|
|||||||
value={password}
|
value={password}
|
||||||
/>
|
/>
|
||||||
<div className="sms-channel-inline-field">
|
<div className="sms-channel-inline-field">
|
||||||
<Input autoComplete="off" label="* 接入号" name="cmpp-access-number" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
<Input
|
||||||
<Input label="扩展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
|
autoComplete="off"
|
||||||
|
label="* 接入号"
|
||||||
|
name="cmpp-access-number"
|
||||||
|
onChange={(event) => setAccessNo(event.target.value)}
|
||||||
|
placeholder="请输入通道接入号"
|
||||||
|
value={accessNo}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="扩展位数"
|
||||||
|
max="20"
|
||||||
|
min="0"
|
||||||
|
onChange={(event) => setExtensionDigits(event.target.value)}
|
||||||
|
type="number"
|
||||||
|
value={extensionDigits}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
<Input
|
||||||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
label="* 通道流速"
|
||||||
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
max="2000"
|
||||||
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
|
min="1"
|
||||||
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
|
onChange={(event) => setFlowLimit(event.target.value)}
|
||||||
|
suffix="条/秒"
|
||||||
|
type="number"
|
||||||
|
value={flowLimit}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="* 期望连接数"
|
||||||
|
onChange={(event) => setDesiredConnections(event.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
value={desiredConnections}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="* 提交窗口"
|
||||||
|
onChange={(event) => setWindowSize(event.target.value)}
|
||||||
|
placeholder="16"
|
||||||
|
value={windowSize}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
hint="平台主动向供应商发送 ACTIVE_TEST 的间隔"
|
||||||
|
label="* 心跳间隔"
|
||||||
|
min="1"
|
||||||
|
onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)}
|
||||||
|
suffix="秒"
|
||||||
|
type="number"
|
||||||
|
value={heartbeatIntervalSeconds}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
hint="连续未收到心跳响应达到该次数后重连"
|
||||||
|
label="* 心跳失败阈值"
|
||||||
|
min="1"
|
||||||
|
onChange={(event) => setHeartbeatMissThreshold(event.target.value)}
|
||||||
|
suffix="次"
|
||||||
|
type="number"
|
||||||
|
value={heartbeatMissThreshold}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="* 长短信成功回执口径"
|
label="* 长短信成功回执口径"
|
||||||
onChange={(event) => setLongMessageReceiptMode(event.target.value as LongMessageReceiptMode)}
|
onChange={(event) => setLongMessageReceiptMode(event.target.value as LongMessageReceiptMode)}
|
||||||
@@ -154,7 +272,9 @@ export function ChannelFormModal({
|
|||||||
]}
|
]}
|
||||||
value={longMessageReceiptMode}
|
value={longMessageReceiptMode}
|
||||||
/>
|
/>
|
||||||
<p className="page-inline-hint">仅在供应商明确约定长短信成功只返回一条整条级回执时选择“整条级”,否则保持逐分片。</p>
|
<p className="page-inline-hint">
|
||||||
|
仅在供应商明确约定长短信成功只返回一条整条级回执时选择“整条级”,否则保持逐分片。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
|
||||||
|
describe('Modal close policy', () => {
|
||||||
|
it('keeps an explicitly locked form open on mask and Escape but permits the close button', () => {
|
||||||
|
const close = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal open title="创建通道" closeOnBackdrop={false} closeOnEscape={false} onClose={close}>
|
||||||
|
表单
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
it('preserves mask closing by default for existing consumers', () => {
|
||||||
|
const close = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal open title="普通弹窗" onClose={close}>
|
||||||
|
内容
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
+42
-16
@@ -16,6 +16,8 @@ type ModalProps = {
|
|||||||
size?: 'md' | 'xl';
|
size?: 'md' | 'xl';
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
dirty?: boolean;
|
dirty?: boolean;
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
initialFocusRef?: RefObject<HTMLElement | null>;
|
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||||
closeGuardTitle?: string;
|
closeGuardTitle?: string;
|
||||||
closeGuardDescription?: string;
|
closeGuardDescription?: string;
|
||||||
@@ -82,8 +84,9 @@ function unlockDocument() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function focusableElements(root: HTMLElement) {
|
function focusableElements(root: HTMLElement) {
|
||||||
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector))
|
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector)).filter(
|
||||||
.filter((element) => !element.hidden && element.getClientRects().length > 0);
|
(element) => !element.hidden && element.getClientRects().length > 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({
|
export function Modal({
|
||||||
@@ -94,6 +97,8 @@ export function Modal({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
onClose,
|
onClose,
|
||||||
dirty = false,
|
dirty = false,
|
||||||
|
closeOnBackdrop = true,
|
||||||
|
closeOnEscape = true,
|
||||||
initialFocusRef,
|
initialFocusRef,
|
||||||
closeGuardTitle = '放弃未保存的修改?',
|
closeGuardTitle = '放弃未保存的修改?',
|
||||||
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
|
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
|
||||||
@@ -108,6 +113,7 @@ export function Modal({
|
|||||||
const guardRestoreFocusRef = useRef<HTMLElement | null>(null);
|
const guardRestoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const [showCloseGuard, setShowCloseGuard] = useState(false);
|
const [showCloseGuard, setShowCloseGuard] = useState(false);
|
||||||
const [layer] = useState(() => modalLayer());
|
const [layer] = useState(() => modalLayer());
|
||||||
|
if (!open && showCloseGuard) setShowCloseGuard(false);
|
||||||
|
|
||||||
const requestClose = useCallback(() => {
|
const requestClose = useCallback(() => {
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
@@ -124,10 +130,7 @@ export function Modal({
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) return undefined;
|
||||||
setShowCloseGuard(false);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const panel = panelRef.current;
|
const panel = panelRef.current;
|
||||||
if (!panel) return undefined;
|
if (!panel) return undefined;
|
||||||
|
|
||||||
@@ -163,7 +166,7 @@ export function Modal({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (showCloseGuard) setShowCloseGuard(false);
|
if (showCloseGuard) setShowCloseGuard(false);
|
||||||
else requestClose();
|
else if (closeOnEscape) requestClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key !== 'Tab') return;
|
if (event.key !== 'Tab') return;
|
||||||
@@ -188,7 +191,7 @@ export function Modal({
|
|||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown, true);
|
document.addEventListener('keydown', handleKeyDown, true);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||||
}, [open, requestClose, showCloseGuard]);
|
}, [closeOnEscape, open, requestClose, showCloseGuard]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCloseGuard) return;
|
if (!showCloseGuard) return;
|
||||||
@@ -205,13 +208,16 @@ export function Modal({
|
|||||||
}, [showCloseGuard]);
|
}, [showCloseGuard]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
const renderedFooter = typeof footer === 'function' ? footer({ requestClose }) : footer;
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="ui-modal" data-ui-modal-root>
|
<div className="ui-modal" data-ui-modal-root>
|
||||||
<div aria-hidden="true" className="ui-modal__mask" onMouseDown={(event) => {
|
<div
|
||||||
if (event.target === event.currentTarget) requestClose();
|
aria-hidden="true"
|
||||||
}} />
|
className="ui-modal__mask"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (closeOnBackdrop && event.target === event.currentTarget) requestClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<section
|
<section
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -221,13 +227,19 @@ export function Modal({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<header className="ui-modal__header">
|
<header className="ui-modal__header">
|
||||||
<div className="ui-modal__title" id={titleId}>{title}</div>
|
<div className="ui-modal__title" id={titleId}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}>
|
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div className="ui-modal__body">{children}</div>
|
<div className="ui-modal__body">{children}</div>
|
||||||
{renderedFooter ? <footer className="ui-modal__footer">{renderedFooter}</footer> : null}
|
{footer ? (
|
||||||
|
<footer className="ui-modal__footer">
|
||||||
|
<ModalFooter footer={footer} requestClose={requestClose} />
|
||||||
|
</footer>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
{showCloseGuard ? (
|
{showCloseGuard ? (
|
||||||
<div className="ui-modal__guard-layer">
|
<div className="ui-modal__guard-layer">
|
||||||
@@ -246,8 +258,12 @@ export function Modal({
|
|||||||
<p id={guardDescriptionId}>{closeGuardDescription}</p>
|
<p id={guardDescriptionId}>{closeGuardDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost">继续编辑</Button>
|
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost">
|
||||||
<Button onClick={discardAndClose} variant="danger">放弃并关闭</Button>
|
继续编辑
|
||||||
|
</Button>
|
||||||
|
<Button onClick={discardAndClose} variant="danger">
|
||||||
|
放弃并关闭
|
||||||
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,3 +272,13 @@ export function Modal({
|
|||||||
layer,
|
layer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModalFooter({
|
||||||
|
footer,
|
||||||
|
requestClose,
|
||||||
|
}: {
|
||||||
|
footer: NonNullable<ModalProps['footer']>;
|
||||||
|
requestClose: () => void;
|
||||||
|
}) {
|
||||||
|
return typeof footer === 'function' ? footer({ requestClose }) : footer;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Pagination } from './PagePrimitives';
|
||||||
|
|
||||||
|
describe('Pagination compatibility', () => {
|
||||||
|
it('preserves existing navigation without showing page size controls unless opted in', () => {
|
||||||
|
const previous = vi.fn();
|
||||||
|
const next = vi.fn();
|
||||||
|
const changePage = vi.fn();
|
||||||
|
render(
|
||||||
|
<Pagination
|
||||||
|
total={30}
|
||||||
|
page={2}
|
||||||
|
previousDisabled={false}
|
||||||
|
nextDisabled={false}
|
||||||
|
onPrevious={previous}
|
||||||
|
onNext={next}
|
||||||
|
onPageChange={changePage}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText(/^每页数量/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('显示 30 条记录')).toBeVisible();
|
||||||
|
expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||||
|
expect(previous).toHaveBeenCalledOnce();
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '首页' }));
|
||||||
|
expect(changePage).toHaveBeenLastCalledWith(1);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '末页' }));
|
||||||
|
expect(changePage).toHaveBeenLastCalledWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives total pages from the opted-in size when totalPages is omitted', () => {
|
||||||
|
const changePage = vi.fn();
|
||||||
|
render(<Pagination total={60} pageSize={25} onPageSizeChange={vi.fn()} onPageChange={changePage} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/^每页数量/)).toHaveTextContent('25 条/页');
|
||||||
|
expect(screen.getByLabelText('跳转页码')).toHaveAttribute('max', '3');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '末页' }));
|
||||||
|
expect(changePage).toHaveBeenLastCalledWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets only the jump draft when the page changes and does not restore an older draft', () => {
|
||||||
|
const changePage = vi.fn();
|
||||||
|
const { rerender } = render(<Pagination total={100} page={1} onPageChange={changePage} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('跳转页码'), { target: { value: '8' } });
|
||||||
|
expect(screen.getByLabelText('跳转页码')).toHaveValue(8);
|
||||||
|
expect(changePage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender(<Pagination total={100} page={2} onPageChange={changePage} />);
|
||||||
|
expect(screen.getByLabelText('跳转页码')).toHaveValue(2);
|
||||||
|
rerender(<Pagination total={100} page={1} onPageChange={changePage} />);
|
||||||
|
expect(screen.getByLabelText('跳转页码')).toHaveValue(1);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('跳转页码'), { target: { value: '20' } });
|
||||||
|
fireEvent.keyDown(screen.getByLabelText('跳转页码'), { key: 'Enter' });
|
||||||
|
expect(changePage).toHaveBeenLastCalledWith(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
|
||||||
type QueryPanelProps = {
|
type QueryPanelProps = {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
@@ -16,6 +17,9 @@ type PaginationProps = {
|
|||||||
onPrevious?: () => void;
|
onPrevious?: () => void;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
|
pageSize?: number;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InlineTextPreviewProps = {
|
type InlineTextPreviewProps = {
|
||||||
@@ -34,6 +38,35 @@ export function QueryPanel({ title, summary, children }: QueryPanelProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PaginationJump({
|
||||||
|
page,
|
||||||
|
pages,
|
||||||
|
onPageChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
const [targetPage, setTargetPage] = useState(String(page));
|
||||||
|
return (
|
||||||
|
<label className="ui-pagination__jump">
|
||||||
|
第{' '}
|
||||||
|
<input
|
||||||
|
aria-label="跳转页码"
|
||||||
|
min="1"
|
||||||
|
max={pages}
|
||||||
|
onChange={(event) => setTargetPage(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') onPageChange(Number(targetPage));
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
value={targetPage}
|
||||||
|
/>{' '}
|
||||||
|
/ {pages} 页
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Pagination({
|
export function Pagination({
|
||||||
total,
|
total,
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -43,11 +76,11 @@ export function Pagination({
|
|||||||
onPrevious,
|
onPrevious,
|
||||||
onNext,
|
onNext,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
pageSize,
|
||||||
|
pageSizeOptions = [10, 25, 50, 100],
|
||||||
|
onPageSizeChange,
|
||||||
}: PaginationProps) {
|
}: PaginationProps) {
|
||||||
const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / 10) : page));
|
const pages = Math.max(1, totalPages ?? (typeof total === 'number' ? Math.ceil(total / (pageSize ?? 10)) : page));
|
||||||
const [targetPage, setTargetPage] = useState(String(page));
|
|
||||||
|
|
||||||
useEffect(() => setTargetPage(String(page)), [page]);
|
|
||||||
|
|
||||||
function changePage(nextPage: number) {
|
function changePage(nextPage: number) {
|
||||||
onPageChange?.(Math.min(pages, Math.max(1, nextPage)));
|
onPageChange?.(Math.min(pages, Math.max(1, nextPage)));
|
||||||
@@ -57,12 +90,34 @@ export function Pagination({
|
|||||||
<div className="ui-pagination">
|
<div className="ui-pagination">
|
||||||
{typeof total === 'number' ? <span>显示 {total} 条记录</span> : <span />}
|
{typeof total === 'number' ? <span>显示 {total} 条记录</span> : <span />}
|
||||||
<div>
|
<div>
|
||||||
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">上一页</Button>
|
{typeof pageSize === 'number' && onPageSizeChange ? (
|
||||||
{onPageChange ? <Button disabled={page <= 1} onClick={() => changePage(1)} size="sm" variant="ghost">首页</Button> : null}
|
<Select
|
||||||
<Button size="sm" variant="secondary">{page}</Button>
|
label="每页数量"
|
||||||
{onPageChange ? <label className="ui-pagination__jump">第 <input aria-label="跳转页码" min="1" max={pages} onChange={(event) => setTargetPage(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') changePage(Number(targetPage)); }} type="number" value={targetPage} /> / {pages} 页</label> : null}
|
value={String(pageSize)}
|
||||||
{onPageChange ? <Button disabled={page >= pages} onClick={() => changePage(pages)} size="sm" variant="ghost">末页</Button> : null}
|
options={pageSizeOptions.map((value) => ({ value: String(value), label: `${value} 条/页` }))}
|
||||||
<Button disabled={nextDisabled} onClick={onNext} size="sm" variant="ghost">下一页</Button>
|
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
{onPageChange ? (
|
||||||
|
<Button disabled={page <= 1} onClick={() => changePage(1)} size="sm" variant="ghost">
|
||||||
|
首页
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
{onPageChange ? <PaginationJump key={page} page={page} pages={pages} onPageChange={changePage} /> : null}
|
||||||
|
{onPageChange ? (
|
||||||
|
<Button disabled={page >= pages} onClick={() => changePage(pages)} size="sm" variant="ghost">
|
||||||
|
末页
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button disabled={nextDisabled} onClick={onNext} size="sm" variant="ghost">
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -72,7 +127,10 @@ export function InlineTextPreview({ label, leading, children }: InlineTextPrevie
|
|||||||
return (
|
return (
|
||||||
<div className="ui-inline-text-preview">
|
<div className="ui-inline-text-preview">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<p>{leading}{children}</p>
|
<p>
|
||||||
|
{leading}
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,18 @@
|
|||||||
"src/styles/components.css"
|
"src/styles/components.css"
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/AdminHome.css",
|
||||||
|
"owners": ["src/apps/admin/AdminHome.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["admin-dashboard"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/AdminAnalyticsPage.css",
|
||||||
|
"owners": ["src/apps/admin/AdminAnalyticsPage.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["admin-analytics-page"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "src/layouts/AlertNotificationMenu.css",
|
"file": "src/layouts/AlertNotificationMenu.css",
|
||||||
"owners": ["src/layouts/AlertNotificationMenu.tsx"],
|
"owners": ["src/layouts/AlertNotificationMenu.tsx"],
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* Real PostgreSQL report integration, isolated from all messaging lifecycles.
|
||||||
|
* Build API first. Set REPORT_TEST_DATABASE_URL to a disposable local database
|
||||||
|
* named cmpp_report_test_*. The caller owns database/server creation and shutdown.
|
||||||
|
* This script creates and drops only its unique schema; it never uses DATABASE_URL.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||||
|
const { ReportsService } = require('./dist/reports/reports.service.js');
|
||||||
|
const { ReportsController } = require('./dist/reports/reports.controller.js');
|
||||||
|
const { Module, Logger } = require('@nestjs/common');
|
||||||
|
const { NestFactory } = require('@nestjs/core');
|
||||||
|
|
||||||
|
const connectionString = process.env.REPORT_TEST_DATABASE_URL;
|
||||||
|
assert.ok(connectionString, 'Set REPORT_TEST_DATABASE_URL explicitly; DATABASE_URL is never used');
|
||||||
|
const target = new URL(connectionString);
|
||||||
|
assert.ok(['postgres:', 'postgresql:'].includes(target.protocol), 'PostgreSQL URL required');
|
||||||
|
assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname), 'Only loopback PostgreSQL is allowed');
|
||||||
|
assert.match(
|
||||||
|
decodeURIComponent(target.pathname.slice(1)),
|
||||||
|
/^cmpp_report_test_[a-z0-9_]+$/,
|
||||||
|
'Dedicated disposable database name required',
|
||||||
|
);
|
||||||
|
assert.equal(target.search, '', 'URL query overrides are forbidden');
|
||||||
|
|
||||||
|
process.env.REPORT_DAILY_REFRESH_ENABLED = 'false';
|
||||||
|
delete process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS;
|
||||||
|
Logger.overrideLogger(false);
|
||||||
|
const schema = `report_refresh_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
const admin = new Pool({ connectionString, max: 2 });
|
||||||
|
const pool = new Pool({ connectionString, max: 4, options: `-c search_path=${schema} -c timezone=UTC` });
|
||||||
|
const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { schema, disposeExternalPool: true }) });
|
||||||
|
const service = new ReportsService(prisma);
|
||||||
|
const checks = [];
|
||||||
|
let slowDayElapsedMs;
|
||||||
|
const now = new Date('2026-09-08T03:00:00Z');
|
||||||
|
const days = ['2026-09-04', '2026-09-05', '2026-09-06', '2026-09-07'];
|
||||||
|
const tables = ['DailyReconciliationReport', 'DailyProfitReport', 'DailyQualityReport'];
|
||||||
|
let app;
|
||||||
|
let schemaCreated = false;
|
||||||
|
|
||||||
|
async function check(name, operation) {
|
||||||
|
await operation();
|
||||||
|
checks.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshot(date, includeTimestamps = false) {
|
||||||
|
const result = {};
|
||||||
|
for (const table of tables) {
|
||||||
|
const expression = includeTimestamps ? 'to_jsonb(row)' : "to_jsonb(row) - 'generatedAt' - 'updatedAt'";
|
||||||
|
const rows = await pool.query(
|
||||||
|
`SELECT ${expression} AS value FROM "${table}" row WHERE "reportDate" = $1::date ORDER BY id`,
|
||||||
|
[date],
|
||||||
|
);
|
||||||
|
result[table] = rows.rows.map(({ value }) => value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installFailureTrigger(body) {
|
||||||
|
await pool.query(`CREATE FUNCTION fail_report_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||||
|
IF NEW."reportDate" = DATE '2026-09-04' THEN ${body} END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER fail_report_test BEFORE INSERT ON "DailyQualityReport" FOR EACH ROW EXECUTE FUNCTION fail_report_test()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFailureTrigger() {
|
||||||
|
await pool.query('DROP TRIGGER fail_report_test ON "DailyQualityReport"; DROP FUNCTION fail_report_test()');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPartialFailure() {
|
||||||
|
await assert.rejects(service.refreshRollingWindow(now), (error) => {
|
||||||
|
assert.match(error.message, /failed dates: 2026-09-04/);
|
||||||
|
assert.match(error.message, /refreshed dates: 2026-09-05, 2026-09-06, 2026-09-07/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const identity = await admin.query('SELECT current_database() AS database, host(inet_server_addr()) AS address');
|
||||||
|
assert.equal(identity.rows[0].database, decodeURIComponent(target.pathname.slice(1)));
|
||||||
|
assert.ok(['127.0.0.1', '::1'].includes(identity.rows[0].address), 'Server must actually be loopback');
|
||||||
|
await admin.query(`CREATE SCHEMA "${schema}"`);
|
||||||
|
schemaCreated = true;
|
||||||
|
// Minimal source tables expose exactly the columns read by reporting SQL.
|
||||||
|
// Report table columns/types and uniqueness mirror the production Prisma model.
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE "Tenant" (id text PRIMARY KEY, name text NOT NULL);
|
||||||
|
CREATE TABLE "SmsApplication" (id text PRIMARY KEY, name text NOT NULL);
|
||||||
|
CREATE TABLE "SmsChannel" (id text PRIMARY KEY, name text NOT NULL);
|
||||||
|
CREATE TABLE "SmsSignature" (id text PRIMARY KEY, name text NOT NULL);
|
||||||
|
CREATE TABLE "SmsDrainageInfo" (id text PRIMARY KEY, "siteName" text NOT NULL);
|
||||||
|
CREATE TABLE "SmsMessageRecord" (
|
||||||
|
id text PRIMARY KEY, "tenantId" text NOT NULL, "applicationId" text NOT NULL,
|
||||||
|
"signatureId" text, "drainageInfoId" text, "billingUnits" integer NOT NULL,
|
||||||
|
status text, "receiptStatus" text, "queuedAt" timestamp(3) NOT NULL,
|
||||||
|
"submittedAt" timestamp(3), "deliveredAt" timestamp(3), "unitPrice" bigint NOT NULL, "submitId" text
|
||||||
|
);
|
||||||
|
CREATE INDEX ON "SmsMessageRecord" ("queuedAt");
|
||||||
|
CREATE TABLE "SmsSubmitRecord" (
|
||||||
|
id text PRIMARY KEY, "messageRecordId" text NOT NULL, "channelId" text NOT NULL,
|
||||||
|
"gatewayMessageId" text, "submitId" text, "submitStatus" text NOT NULL,
|
||||||
|
"costUnitPrice" bigint NOT NULL, "submittedAt" timestamp(3), "createdAt" timestamp(3) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX ON "SmsSubmitRecord" ("messageRecordId");
|
||||||
|
CREATE TABLE "SmsMessageSegmentAudit" (id text PRIMARY KEY, "submitRecordId" text NOT NULL, "receiptStatus" text);
|
||||||
|
CREATE INDEX ON "SmsMessageSegmentAudit" ("submitRecordId");
|
||||||
|
CREATE TABLE "SmsReceiptRecord" (id text PRIMARY KEY, "gatewayMessageId" text, "channelId" text, "receiptStatus" text, "deliveredAt" timestamp(3));
|
||||||
|
CREATE INDEX ON "SmsReceiptRecord" ("channelId", "gatewayMessageId");
|
||||||
|
CREATE TABLE "DailyReconciliationReport" (
|
||||||
|
id text PRIMARY KEY, "reportDate" date NOT NULL, "tenantId" text NOT NULL, "tenantName" text NOT NULL,
|
||||||
|
"applicationId" text NOT NULL, "applicationName" text NOT NULL,
|
||||||
|
"submittedUnits" integer NOT NULL, "sentUnits" integer NOT NULL, "unknownUnits" integer NOT NULL,
|
||||||
|
"successUnits" integer NOT NULL, "failedUnits" integer NOT NULL,
|
||||||
|
"generatedAt" timestamp(3) NOT NULL, "updatedAt" timestamp(3) NOT NULL,
|
||||||
|
UNIQUE ("reportDate", "tenantId", "applicationId")
|
||||||
|
);
|
||||||
|
CREATE TABLE "DailyProfitReport" (
|
||||||
|
id text PRIMARY KEY, "reportDate" date NOT NULL, "dimensionType" text NOT NULL, "dimensionId" text NOT NULL, "dimensionName" text NOT NULL,
|
||||||
|
"tenantId" text, "tenantName" text, "applicationId" text, "channelId" text,
|
||||||
|
"submittedUnits" integer NOT NULL, "sentUnits" integer NOT NULL, "unknownUnits" integer NOT NULL,
|
||||||
|
"successUnits" integer NOT NULL, "failedUnits" integer NOT NULL,
|
||||||
|
"revenueCents" bigint NOT NULL, "refundCents" bigint NOT NULL, "costCents" bigint NOT NULL,
|
||||||
|
"profitCents" bigint NOT NULL, "profitRateBps" integer NOT NULL,
|
||||||
|
"generatedAt" timestamp(3) NOT NULL, "updatedAt" timestamp(3) NOT NULL,
|
||||||
|
UNIQUE ("reportDate", "dimensionType", "dimensionId")
|
||||||
|
);
|
||||||
|
CREATE TABLE "DailyQualityReport" (
|
||||||
|
id text PRIMARY KEY, "reportDate" date NOT NULL, "dimensionType" text NOT NULL, "dimensionId" text NOT NULL, "dimensionName" text NOT NULL,
|
||||||
|
"tenantId" text, "tenantName" text, "applicationId" text, "channelId" text, "signatureId" text, "drainageInfoId" text,
|
||||||
|
"submittedUnits" integer NOT NULL, "sentUnits" integer NOT NULL, "unknownUnits" integer NOT NULL,
|
||||||
|
"successUnits" integer NOT NULL, "failedUnits" integer NOT NULL, "successRateBps" integer NOT NULL, "avgArrivalMs" integer,
|
||||||
|
"generatedAt" timestamp(3) NOT NULL, "updatedAt" timestamp(3) NOT NULL,
|
||||||
|
UNIQUE ("reportDate", "dimensionType", "dimensionId")
|
||||||
|
);
|
||||||
|
INSERT INTO "Tenant" VALUES ('t1', 'Report Test One'), ('t2', 'Report Test Two');
|
||||||
|
INSERT INTO "SmsApplication" VALUES ('a1', 'Test Application One'), ('a2', 'Test Application Two');
|
||||||
|
INSERT INTO "SmsChannel" VALUES ('c1', 'Test Channel One'), ('c2', 'Test Channel Two');
|
||||||
|
INSERT INTO "SmsSignature" VALUES ('sig1', 'Test Signature'), ('sig2', 'Second Test Signature');
|
||||||
|
INSERT INTO "SmsDrainageInfo" VALUES ('drain1', 'Test Site'), ('drain2', 'Second Test Site');
|
||||||
|
`);
|
||||||
|
const messages = [
|
||||||
|
['cross', '2026-09-03T16:00:00.000Z', 3, 'delivered', 1000, 'cross_retry'],
|
||||||
|
['legacy', '2026-09-04T02:00:00.000Z', 2, 'delivered', 500, 'legacy_submit'],
|
||||||
|
['failed', '2026-09-04T03:00:00.000Z', 1, 'failed', 800, 'failed_submit'],
|
||||||
|
['unknown', '2026-09-04T04:00:00.000Z', 1, 'unknown', 800, 'unknown_submit'],
|
||||||
|
['before', '2026-09-03T15:59:59.999Z', 7, 'delivered', 900, 'before_submit'],
|
||||||
|
['next', '2026-09-04T16:00:00.000Z', 1, 'delivered', 1000, 'next_submit'],
|
||||||
|
['six', '2026-09-06T01:00:00.000Z', 1, 'delivered', 1000, 'six_submit'],
|
||||||
|
['seven', '2026-09-07T01:00:00.000Z', 1, 'delivered', 1000, 'seven_submit'],
|
||||||
|
['today', '2026-09-07T16:00:00.000Z', 11, 'delivered', 900, 'today_submit'],
|
||||||
|
['other', '2026-09-04T02:00:00.000Z', 1, 'delivered', 2000, null],
|
||||||
|
];
|
||||||
|
for (const [id, date, units, status, price, submitId] of messages) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO "SmsMessageRecord" VALUES ($1,$2,$3,$9,$10,$4,$5,NULL,$6,$6,$6::timestamp + INTERVAL '1 second',$7,$8)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
id === 'other' ? 't2' : 't1',
|
||||||
|
id === 'other' ? 'a2' : 'a1',
|
||||||
|
units,
|
||||||
|
status,
|
||||||
|
date,
|
||||||
|
price,
|
||||||
|
submitId,
|
||||||
|
id === 'other' ? 'sig2' : 'sig1',
|
||||||
|
id === 'other' ? 'drain2' : 'drain1',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const submits = [
|
||||||
|
['cross_first', 'cross', 'c1', '2026-09-04T01:00:00Z', 100, ['delivered', 'undelivered', 'undelivered'], true],
|
||||||
|
['cross_retry', 'cross', 'c2', '2026-09-05T01:00:00Z', 200, ['delivered', 'delivered', 'undelivered'], true],
|
||||||
|
['legacy_submit', 'legacy', 'c1', '2026-09-04T02:00:00Z', 150, [], true],
|
||||||
|
['failed_submit', 'failed', 'c1', '2026-09-04T03:00:00Z', 50, ['undelivered'], true],
|
||||||
|
['unknown_submit', 'unknown', 'c1', '2026-09-04T04:00:00Z', 70, ['unknown'], false],
|
||||||
|
['before_submit', 'before', 'c1', '2026-09-03T15:59:59Z', 99, ['delivered'], true],
|
||||||
|
['next_submit', 'next', 'c1', '2026-09-04T16:00:00Z', 100, ['delivered'], true],
|
||||||
|
['six_submit', 'six', 'c1', '2026-09-06T01:00:00Z', 100, ['delivered'], true],
|
||||||
|
['seven_submit', 'seven', 'c1', '2026-09-07T01:00:00Z', 100, ['delivered'], true],
|
||||||
|
['today_submit', 'today', 'c1', '2026-09-07T16:00:00Z', 99, ['delivered'], true],
|
||||||
|
];
|
||||||
|
for (const [id, messageId, channel, date, price, audits, delivered] of submits) {
|
||||||
|
await pool.query('INSERT INTO "SmsSubmitRecord" VALUES ($1,$2,$3,$1,$1,\'accepted\',$4,$5,$5)', [
|
||||||
|
id,
|
||||||
|
messageId,
|
||||||
|
channel,
|
||||||
|
price,
|
||||||
|
date,
|
||||||
|
]);
|
||||||
|
for (const [index, status] of audits.entries()) {
|
||||||
|
await pool.query('INSERT INTO "SmsMessageSegmentAudit" VALUES ($1,$2,$3)', [`${id}_${index}`, id, status]);
|
||||||
|
}
|
||||||
|
if (delivered)
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO \"SmsReceiptRecord\" VALUES ($1,$1,$2,'delivered',$3::timestamp + INTERVAL '1 second')",
|
||||||
|
[id, channel, date],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO "SmsReceiptRecord" SELECT \'duplicate_legacy\', "gatewayMessageId", "channelId", "receiptStatus", "deliveredAt" FROM "SmsReceiptRecord" WHERE id=\'legacy_submit\'',
|
||||||
|
);
|
||||||
|
|
||||||
|
await check('seven report dimensions and Beijing complete-day boundaries', async () => {
|
||||||
|
assert.deepEqual(await service.refreshRollingWindow(now), { refreshedDates: days });
|
||||||
|
const stored = await snapshot(days[0]);
|
||||||
|
assert.equal(stored.DailyReconciliationReport.length, 2);
|
||||||
|
assert.deepEqual([...new Set(stored.DailyProfitReport.map((row) => row.dimensionType))].sort(), [
|
||||||
|
'application',
|
||||||
|
'channel',
|
||||||
|
]);
|
||||||
|
assert.deepEqual([...new Set(stored.DailyQualityReport.map((row) => row.dimensionType))].sort(), [
|
||||||
|
'application',
|
||||||
|
'channel',
|
||||||
|
'drainage',
|
||||||
|
'signature',
|
||||||
|
]);
|
||||||
|
const appRow = stored.DailyReconciliationReport.find((row) => row.applicationId === 'a1');
|
||||||
|
assert.deepEqual(
|
||||||
|
[appRow.submittedUnits, appRow.sentUnits, appRow.successUnits, appRow.failedUnits, appRow.unknownUnits],
|
||||||
|
[7, 7, 5, 1, 1],
|
||||||
|
);
|
||||||
|
for (const table of tables) {
|
||||||
|
const dates = await pool.query(`SELECT DISTINCT "reportDate"::text AS date FROM "${table}" ORDER BY date`);
|
||||||
|
assert.deepEqual(
|
||||||
|
dates.rows.map((row) => row.date),
|
||||||
|
days,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('cross-day retry cost, segment priority, legacy fallback and final-only revenue', async () => {
|
||||||
|
const appRow = (await snapshot(days[0])).DailyProfitReport.find((row) => row.dimensionId === 'a1');
|
||||||
|
assert.deepEqual(
|
||||||
|
[appRow.revenueCents, appRow.costCents, appRow.profitCents, appRow.profitRateBps],
|
||||||
|
[4000, 800, 3200, 8000],
|
||||||
|
);
|
||||||
|
const dayFourChannel = (await snapshot(days[0])).DailyProfitReport.find((row) => row.dimensionId === 'c1');
|
||||||
|
assert.deepEqual([dayFourChannel.revenueCents, dayFourChannel.costCents], [1000, 400]);
|
||||||
|
const retryChannel = (await snapshot(days[1])).DailyProfitReport.find((row) => row.dimensionId === 'c2');
|
||||||
|
assert.deepEqual([retryChannel.revenueCents, retryChannel.costCents], [3000, 400]);
|
||||||
|
const nextApp = (await snapshot(days[1])).DailyProfitReport.find((row) => row.dimensionId === 'a1');
|
||||||
|
assert.deepEqual([nextApp.revenueCents, nextApp.costCents, nextApp.submittedUnits], [1000, 100, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('repeated refresh is idempotent', async () => {
|
||||||
|
const before = await Promise.all(days.map((date) => snapshot(date)));
|
||||||
|
await service.refreshRollingWindow(now);
|
||||||
|
assert.deepEqual(await Promise.all(days.map((date) => snapshot(date))), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('real SQL failure rolls back all three tables and later days still refresh', async () => {
|
||||||
|
const before = await snapshot(days[0], true);
|
||||||
|
const later = await snapshot(days[1], true);
|
||||||
|
await pool.query('UPDATE "SmsMessageRecord" SET "unitPrice"=1100 WHERE id=\'cross\'');
|
||||||
|
await installFailureTrigger("RAISE EXCEPTION 'intentional isolated report failure';");
|
||||||
|
await assertPartialFailure();
|
||||||
|
assert.deepEqual(await snapshot(days[0], true), before);
|
||||||
|
assert.notDeepEqual(await snapshot(days[1], true), later);
|
||||||
|
await removeFailureTrigger();
|
||||||
|
await service.refreshRollingWindow(now);
|
||||||
|
const recovered = (await snapshot(days[0])).DailyProfitReport.find((row) => row.dimensionId === 'a1');
|
||||||
|
assert.equal(recovered.revenueCents, 4300);
|
||||||
|
await pool.query('UPDATE "SmsMessageRecord" SET "unitPrice"=1000 WHERE id=\'cross\'');
|
||||||
|
await service.refreshRollingWindow(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('database transaction day lock protects prior reports and permits retry', async () => {
|
||||||
|
const before = await snapshot(days[0], true);
|
||||||
|
const holder = await pool.connect();
|
||||||
|
try {
|
||||||
|
await holder.query('BEGIN');
|
||||||
|
await holder.query('SELECT pg_advisory_xact_lock($1::integer, 20260904)', [0x434d5052]);
|
||||||
|
await assertPartialFailure();
|
||||||
|
assert.deepEqual(await snapshot(days[0], true), before);
|
||||||
|
} finally {
|
||||||
|
await holder.query('ROLLBACK');
|
||||||
|
holder.release();
|
||||||
|
}
|
||||||
|
assert.deepEqual(await service.refreshRollingWindow(now), { refreshedDates: days });
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('day transaction exceeding the old five-second limit completes within the new budget', async () => {
|
||||||
|
await installFailureTrigger(
|
||||||
|
'IF NEW."dimensionType" = \'application\' AND NEW."dimensionId" = \'a1\' THEN PERFORM pg_sleep(5.2); END IF;',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const start = performance.now();
|
||||||
|
await service.refreshBusinessDay({
|
||||||
|
key: days[0],
|
||||||
|
reportDate: new Date('2026-09-04T00:00:00Z'),
|
||||||
|
startAt: new Date('2026-09-03T16:00:00Z'),
|
||||||
|
endAt: new Date('2026-09-04T16:00:00Z'),
|
||||||
|
});
|
||||||
|
slowDayElapsedMs = Math.round(performance.now() - start);
|
||||||
|
assert.ok(slowDayElapsedMs >= 5200);
|
||||||
|
} finally {
|
||||||
|
await removeFailureTrigger();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('bounded real statement timeout rolls back and recovers', async () => {
|
||||||
|
const before = await snapshot(days[0], true);
|
||||||
|
await installFailureTrigger('PERFORM pg_sleep(0.3);');
|
||||||
|
process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS = '200';
|
||||||
|
try {
|
||||||
|
await assertPartialFailure();
|
||||||
|
assert.deepEqual(await snapshot(days[0], true), before);
|
||||||
|
} finally {
|
||||||
|
delete process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS;
|
||||||
|
await removeFailureTrigger();
|
||||||
|
}
|
||||||
|
assert.deepEqual(await service.refreshRollingWindow(now), { refreshedDates: days });
|
||||||
|
});
|
||||||
|
|
||||||
|
await check('real ReportsController HTTP reads match PostgreSQL filtering pagination and exports', async () => {
|
||||||
|
// Harness exposes only the real reporting controller on an ephemeral loopback
|
||||||
|
// port. Full application authentication and UI are outside this integration.
|
||||||
|
class ReportTestModule {}
|
||||||
|
Module({ controllers: [ReportsController], providers: [{ provide: ReportsService, useValue: service }] })(
|
||||||
|
ReportTestModule,
|
||||||
|
);
|
||||||
|
app = await NestFactory.create(ReportTestModule, { logger: false });
|
||||||
|
app.use((_request, response, next) => {
|
||||||
|
response.app.set('json replacer', (_key, value) => (typeof value === 'bigint' ? Number(value) : value));
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
const base = await app.getUrl();
|
||||||
|
const read = async (path) => {
|
||||||
|
const response = await fetch(`${base}/admin/reports/${path}`);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
const recon = await read('reconciliation?dateFrom=2026-09-04&dateTo=2026-09-04&tenantId=t1&pageSize=1');
|
||||||
|
assert.equal(recon.total, 1);
|
||||||
|
assert.equal(recon.items[0].applicationId, 'a1');
|
||||||
|
assert.deepEqual(recon.summary, {
|
||||||
|
submittedUnits: 7,
|
||||||
|
sentUnits: 7,
|
||||||
|
unknownUnits: 1,
|
||||||
|
successUnits: 5,
|
||||||
|
failedUnits: 1,
|
||||||
|
});
|
||||||
|
const profit = await read('profit?dateFrom=2026-09-04&dateTo=2026-09-04&dimensionType=application&tenantId=t1');
|
||||||
|
assert.deepEqual([profit.summary.revenueCents, profit.summary.costCents], [4000, 800]);
|
||||||
|
assert.equal('refundCents' in profit.items[0], false);
|
||||||
|
for (const dimension of ['application', 'channel', 'signature', 'drainage']) {
|
||||||
|
const result = await read(`quality?dateFrom=2026-09-04&dateTo=2026-09-04&dimensionType=${dimension}&pageSize=1`);
|
||||||
|
const expected = await pool.query(
|
||||||
|
'SELECT count(*)::integer AS total, sum("sentUnits")::integer AS sent FROM "DailyQualityReport" WHERE "reportDate"=\'2026-09-04\' AND "dimensionType"=$1',
|
||||||
|
[dimension],
|
||||||
|
);
|
||||||
|
assert.equal(result.total, expected.rows[0].total);
|
||||||
|
assert.equal(result.summary.sentUnits, expected.rows[0].sent);
|
||||||
|
assert.equal(result.items.length, 1);
|
||||||
|
}
|
||||||
|
const empty = await read('reconciliation?tenantId=missing');
|
||||||
|
assert.equal(empty.total, 0);
|
||||||
|
for (const report of ['reconciliation', 'profit', 'quality']) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${base}/admin/reports/${report}/export?dateFrom=2026-09-04&dateTo=2026-09-04&tenantId=t1`,
|
||||||
|
);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.match(response.headers.get('content-type'), /text\/csv/);
|
||||||
|
assert.ok((await response.text()).split('\n').length > 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
passed: checks.length,
|
||||||
|
checks,
|
||||||
|
sourceMessages: messages.length,
|
||||||
|
sourceSubmits: submits.length,
|
||||||
|
reportDates: days,
|
||||||
|
slowDayElapsedMs,
|
||||||
|
expectedApplicationDayFour: { revenue: 4000, cost: 800, profit: 3200 },
|
||||||
|
schemaCleanup: 'performed in finally',
|
||||||
|
environment: 'isolated local PostgreSQL; no application or messaging lifecycle',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (app) await app.close();
|
||||||
|
service.onModuleDestroy();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
if (schemaCreated) await admin.query(`DROP SCHEMA "${schema}" CASCADE`);
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Test environment only. Uses real PostgreSQL in a new isolated schema; no Nest lifecycle or external delivery.
|
||||||
|
// CMPP_DIAGNOSTIC_ENV=test node --expose-gc <script> <candidate-api-root>
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
if (process.env.CMPP_DIAGNOSTIC_ENV !== 'test') throw new Error('Explicit test environment is required');
|
||||||
|
const root = resolve(process.argv[2]);
|
||||||
|
const require = createRequire(resolve(root, 'package.json'));
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { SignatureRetirementService } = require(
|
||||||
|
resolve(root, 'dist/signature-retirement/signature-retirement.service.js'),
|
||||||
|
);
|
||||||
|
const schema = `cmpp_fix_20260908_${Date.now()}`;
|
||||||
|
const admin = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
|
||||||
|
const tables = [
|
||||||
|
'Tenant',
|
||||||
|
'SmsApplication',
|
||||||
|
'SmsSignature',
|
||||||
|
'SmsChannel',
|
||||||
|
'SignatureRetirementDetection',
|
||||||
|
'SignatureRetirementMessage',
|
||||||
|
'SignatureRetirementSuppression',
|
||||||
|
'SignatureRetirementWebhook',
|
||||||
|
'SignatureRetirementWebhookDelivery',
|
||||||
|
'OperationLog',
|
||||||
|
'ChannelSignatureReportTask',
|
||||||
|
];
|
||||||
|
const clients = [];
|
||||||
|
const checks = [];
|
||||||
|
try {
|
||||||
|
const before = (await admin.query('SELECT count(*)::int AS count FROM public."SmsMessageRecord"')).rows[0].count;
|
||||||
|
await admin.query(`CREATE SCHEMA "${schema}"`);
|
||||||
|
for (const table of tables)
|
||||||
|
await admin.query(`CREATE TABLE "${schema}"."${table}" (LIKE public."${table}" INCLUDING ALL)`);
|
||||||
|
const columns = await admin.query(
|
||||||
|
'SELECT 1 FROM information_schema.columns WHERE table_schema=$1 AND table_name=$2 AND column_name=$3',
|
||||||
|
[schema, 'SignatureRetirementMessage', 'dailyGroupKey'],
|
||||||
|
);
|
||||||
|
if (!columns.rowCount) {
|
||||||
|
await admin.query(`SET search_path TO "${schema}"`);
|
||||||
|
await admin.query(
|
||||||
|
readFileSync(
|
||||||
|
resolve(root, 'prisma/migrations/20260908050000_retirement_application_daily_message/migration.sql'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const url = new URL(process.env.DATABASE_URL);
|
||||||
|
url.searchParams.set('options', `-c search_path=${schema} -c statement_timeout=15000`);
|
||||||
|
clients.push(
|
||||||
|
new PrismaClient({
|
||||||
|
adapter: new PrismaPg(new Pool({ connectionString: url.toString(), max: 2 }), {
|
||||||
|
schema,
|
||||||
|
disposeExternalPool: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [db, other] = clients;
|
||||||
|
assert.equal((await db.$queryRawUnsafe('SELECT current_schema() AS schema'))[0].schema, schema);
|
||||||
|
const service = new SignatureRetirementService(db);
|
||||||
|
const peer = new SignatureRetirementService(other);
|
||||||
|
const date = '2026-09-08';
|
||||||
|
for (const id of ['t1', 't2']) await db.tenant.create({ data: { id, code: `QA-${id}`, name: id } });
|
||||||
|
for (const id of ['app1', 'app2'])
|
||||||
|
await db.smsApplication.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
tenantId: 't1',
|
||||||
|
name: id,
|
||||||
|
cmppAccount: `QA-${id}`,
|
||||||
|
cmppEnterpriseCode: 'QA',
|
||||||
|
secretHash: 'isolated-not-a-credential',
|
||||||
|
status: 'disabled',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const [id, app, tenant] of [
|
||||||
|
['s1', 'app1', 't1'],
|
||||||
|
['s2', 'app1', 't1'],
|
||||||
|
['s3', 'app2', 't1'],
|
||||||
|
['s4', null, 't1'],
|
||||||
|
['s5', null, 't2'],
|
||||||
|
]) {
|
||||||
|
await db.smsSignature.create({
|
||||||
|
data: { id, tenantId: tenant, applicationId: app, name: id, auditStatus: 'approved' },
|
||||||
|
});
|
||||||
|
await db.signatureRetirementDetection.create({
|
||||||
|
data: {
|
||||||
|
id: `d-${id}`,
|
||||||
|
detectionDate: new Date(date),
|
||||||
|
dimensionType: 'enterprise',
|
||||||
|
tenantId: tenant,
|
||||||
|
applicationId: app,
|
||||||
|
signatureId: id,
|
||||||
|
carrier: 'mobile',
|
||||||
|
windowDays: 30,
|
||||||
|
threshold: 1,
|
||||||
|
approvedAt: new Date('2026-01-01'),
|
||||||
|
status: 'alert',
|
||||||
|
cycleId: `cycle-${id}`,
|
||||||
|
notificationTitle: '预警',
|
||||||
|
notificationContent: `冻结正文-${id}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const concurrent = await Promise.all([service.publishNotifications(date), peer.publishNotifications(date)]);
|
||||||
|
assert.equal(
|
||||||
|
concurrent.reduce((sum, item) => sum + item.created, 0),
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
assert.equal(await db.signatureRetirementMessage.count(), 4);
|
||||||
|
assert.equal((await service.publishNotifications(date)).created, 0);
|
||||||
|
const grouped = await db.signatureRetirementMessage.findFirstOrThrow({ where: { applicationId: 'app1' } });
|
||||||
|
assert.deepEqual(grouped.detectionIds, ['d-s1', 'd-s2']);
|
||||||
|
assert.equal(grouped.content, '冻结正文-s1\n冻结正文-s2');
|
||||||
|
checks.push('two-instance daily uniqueness, app/tenant isolation, frozen contents, repeat idempotency');
|
||||||
|
const page = await service.listMessages({ dateFrom: date, dateTo: date, signatureKeyword: 's2' });
|
||||||
|
assert.equal(page.total, 1);
|
||||||
|
assert.equal(page.items[0].id, grouped.id);
|
||||||
|
assert.equal(page.items[0].detections.length, 2);
|
||||||
|
const unreadBefore = (await service.unreadCount()).count;
|
||||||
|
await service.markRead(grouped.id);
|
||||||
|
assert.equal((await service.unreadCount()).count, unreadBefore - 1);
|
||||||
|
checks.push('secondary member search, message pagination and deduplicated unread count');
|
||||||
|
await service.suppressMessage(grouped.id, { mode: 'temporary', days: 7, reason: 'isolated verification' });
|
||||||
|
assert.equal(await db.signatureRetirementSuppression.count(), 2);
|
||||||
|
assert.equal((await db.signatureRetirementMessage.findUniqueOrThrow({ where: { id: grouped.id } })).suppressed, true);
|
||||||
|
checks.push('atomic group suppression');
|
||||||
|
// Real database scale fixture, distinct from live business data and from the daily-message test above.
|
||||||
|
await admin.query(`INSERT INTO "${schema}"."SignatureRetirementDetection" (id,"detectionDate","dimensionType","tenantId","applicationId","signatureId","channelKey",carrier,"windowDays",threshold,"approvedAt",status)
|
||||||
|
SELECT 'scale-'||n, DATE '2026-09-08','channel','t1','app1','s1','scale-'||n,'mobile',30,1,DATE '2026-01-01','healthy' FROM generate_series(1,19783) n`);
|
||||||
|
const baseline = process.memoryUsage();
|
||||||
|
const started = Date.now();
|
||||||
|
const heatmap = await service.heatmap(date);
|
||||||
|
assert.equal(heatmap.items.length, 19788);
|
||||||
|
assert.ok(heatmap.items.every((item) => item.activityDate === '2026-09-07'));
|
||||||
|
const after = process.memoryUsage();
|
||||||
|
if (global.gc) global.gc();
|
||||||
|
const afterGc = process.memoryUsage();
|
||||||
|
assert.ok(after.rss - baseline.rss < 256 * 1024 ** 2, 'Heatmap RSS growth exceeds 256 MiB');
|
||||||
|
checks.push('19788 PostgreSQL rows through actual heatmap service, T-1 and bounded RSS');
|
||||||
|
const finalCount = (await admin.query('SELECT count(*)::int AS count FROM public."SmsMessageRecord"')).rows[0].count;
|
||||||
|
assert.equal(finalCount, before);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
schema,
|
||||||
|
checks,
|
||||||
|
baseline,
|
||||||
|
after,
|
||||||
|
afterGc,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
publicMessages: finalCount,
|
||||||
|
smsCalls: 0,
|
||||||
|
lifecycleStarted: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await Promise.all(clients.map((client) => client.$disconnect()));
|
||||||
|
// Keep the isolated schema for audit; no cleanup of existing schemas or business records.
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user