4 Commits
Author SHA1 Message Date
hectorzhao ebb185b22b fix: prevent daily report refresh timeouts
CSS quality / css-quality (push) Has been cancelled
2026-09-08 17:16:43 +08:00
hectorzhao 2a9d03be2e fix: unify analytics and report pagination controls 2026-09-08 14:52:12 +08:00
hectorzhao 6d3c78330d docs: record test release and real operations acceptance 2026-09-08 13:41:49 +08:00
hectorzhao 2c228a94e1 fix: bound formatter memory and improve operations workflows 2026-09-08 12:46:15 +08:00
34 changed files with 5447 additions and 1371 deletions
@@ -0,0 +1,7 @@
-- Nullable additive fields preserve historical messages and old readers.
ALTER TABLE "SignatureRetirementMessage"
ADD COLUMN "dailyGroupKey" TEXT,
ADD COLUMN "notificationDate" DATE,
ADD COLUMN "applicationId" TEXT,
ADD COLUMN "detectionIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
CREATE UNIQUE INDEX "SignatureRetirementMessage_dailyGroupKey_key" ON "SignatureRetirementMessage"("dailyGroupKey");
+4
View File
@@ -1251,6 +1251,10 @@ model SignatureRetirementDetection {
} }
model SignatureRetirementMessage { model SignatureRetirementMessage {
dailyGroupKey String? @unique
notificationDate DateTime? @db.Date
applicationId String?
detectionIds String[] @default([])
id String @id @default(cuid()) id String @id @default(cuid())
detectionId String @unique detectionId String @unique
cycleId String cycleId String
+12 -73
View File
@@ -1,24 +1,9 @@
import { import { BadRequestException, NotFoundException } from '@nestjs/common';
BadRequestException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { import type {
CreateChannelDto,
UpdateChannelDto,
CreateChannelGroupDto,
CreateChannelGroupItemDto,
UpdateChannelGroupDto,
CreateRouteRuleDto,
CreateReportFieldDto, CreateReportFieldDto,
ReplaceReportFieldsDto, ReplaceReportFieldsDto,
CreateReportMaterialDto, CreateReportMaterialDto,
@@ -26,73 +11,19 @@ import type {
ChangeReportTaskStatusesDto, ChangeReportTaskStatusesDto,
CreateReportExportDto, CreateReportExportDto,
CreateReceiptImportDto, CreateReceiptImportDto,
UpsertConnectionStateDto,
ChangeChannelStatusDto,
CopyChannelDto,
TestChannelDto,
} from './channels.contracts'; } from './channels.contracts';
import { import {
GATEWAY_CONNECTION_QUEUE,
GATEWAY_SUBMIT_QUEUE,
GATEWAY_SUBMIT_STREAM,
DEFAULT_GATEWAY_CONTROL_URL,
DEFAULT_CHANNEL_CONNECTION_ID,
DEFAULT_CONNECTING_TIMEOUT_MS,
DEFAULT_CONNECTING_TIMEOUT_SCAN_MS,
DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS,
DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS,
DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS,
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
DEFAULT_HEARTBEAT_MISS_THRESHOLD,
HEARTBEAT_AUDIT_INTERVAL_MS,
CONNECTING_TIMEOUT_ERROR,
DEFAULT_CMPP_VERSION,
normalizeTestPhones,
normalizeTestContent,
calculateBillingUnits,
buildChannelTestSubmitCommand,
getConfigValue,
getStringConfigValue,
normalizeConnectionAction,
normalizeCmppVersion,
normalizeGatewayConnectionStatus,
defaultChannelConnectionId,
getDesiredConnections,
ChannelConnectionSettings,
getRuntimeConfigInteger,
channelConnectionSettingsChanged,
channelGroupAuditSnapshot,
normalizeChannelRuntimeConfig,
normalizeCmppServiceId,
normalizeChannelRateLimit,
normalizeExtensionDigits,
getPositiveRuntimeInteger,
bullmqConnection,
getPositiveIntegerEnv,
parseReceiptContent, parseReceiptContent,
splitReceiptLine,
stripReceiptCell,
findReceiptStatusIndex,
normalizeReceiptStatus,
deriveReceiptStatus, deriveReceiptStatus,
ChannelReportDeliveryRow, ChannelReportDeliveryRow,
summarizeChannelReportDelivery, summarizeChannelReportDelivery,
sumReportDelivery,
percentage,
latestDate, latestDate,
currentShanghaiDayRange, currentShanghaiDayRange,
normalizeRetryTimeLimitMinutes,
normalizeSpreadsheetSize, normalizeSpreadsheetSize,
normalizeBusinessCarrier, normalizeBusinessCarrier,
normalizeChannelCarrier,
normalizeChannelCarriers, normalizeChannelCarriers,
isChannelCarrierCompatible,
normalizeRegion,
isRegionCompatible,
validateGroupItems,
normalizeReportType, normalizeReportType,
summarizeReportStatuses, summarizeReportStatuses,
normalizeLinkEvent,
} from './channels.helpers'; } from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */ /** R5 channel domain service composed behind ChannelsService. */
@@ -155,6 +86,9 @@ export class ChannelReportingService {
for (const legacy of legacyBoth) { for (const legacy of legacyBoth) {
if (oppositeCodes.has(legacy.code)) continue; if (oppositeCodes.has(legacy.code)) continue;
const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy; const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...legacyData } = legacy;
void _id;
void _createdAt;
void _updatedAt;
await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } }); await tx.channelReportField.create({ data: { ...legacyData, reportType: oppositeType } });
} }
for (const [index, configured] of data.fields.entries()) { for (const [index, configured] of data.fields.entries()) {
@@ -225,7 +159,12 @@ export class ChannelReportingService {
const tasks = await this.prisma.channelSignatureReportTask.findMany({ const tasks = await this.prisma.channelSignatureReportTask.findMany({
where: { where: {
tenantId, tenantId,
status, status:
status === 'reporting' || status === 'exporting'
? { in: ['reporting', 'exporting'] }
: status === 'failed'
? { in: ['failed', 'rejected'] }
: status,
channelId, channelId,
reportType, reportType,
signature: { auditStatus: { not: 'deleted' } }, signature: { auditStatus: { not: 'deleted' } },
@@ -1,8 +1,15 @@
import { BadRequestException, ConflictException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -12,16 +19,148 @@ import type { InfrastructureAlertSettings, InfrastructureAlertThresholds } from
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
export const ALERT_THRESHOLD_DEFINITIONS = [ export const ALERT_THRESHOLD_DEFINITIONS = [
{ key: 'hostCpu', label: '主机 CPU 使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], service: 'host', durations: ['10m', '5m'] }, {
{ key: 'hostMemory', label: '主机内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 85, critical: 95, expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], service: 'host', durations: ['10m', '5m'] }, key: 'hostCpu',
{ key: 'hostDisk', label: '磁盘(独立文件系统)使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: FILESYSTEM_USAGE_PERCENT, names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] }, label: '主机 CPU 使用率',
{ key: 'apiError', label: 'API 5xx 错误率', unit: '%', min: 0.1, max: 100, step: 0.1, warning: 1, critical: 5, expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], service: 'api', durations: ['5m', '5m'] }, unit: '%',
{ key: 'apiLatency', label: 'API P95 响应时间', unit: '秒', min: 0.1, max: 60, step: 0.1, warning: 1, critical: 3, expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], service: 'api', durations: ['10m', '5m'] }, min: 1,
{ key: 'apiEventLoop', label: 'API 事件循环 P99', unit: '秒', min: 0.01, max: 10, step: 0.01, warning: 0.2, critical: 1, expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], service: 'api', durations: ['10m', '5m'] }, max: 100,
{ key: 'gatewayQueue', label: 'Gateway 最旧 pending', unit: '秒', min: 1, max: 3600, step: 1, warning: 30, critical: 120, expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds', names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'], service: 'gateway', durations: ['2m', '2m'] }, step: 1,
{ key: 'postgresConnections', label: 'PostgreSQL 连接使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)', names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'], service: 'postgresql', durations: ['10m', '5m'] }, warning: 80,
{ key: 'redisMemory', label: 'Redis 内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 70, critical: 85, expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes', guard: 'redis_memory_max_bytes > 0', names: ['RedisMemoryWarning', 'RedisMemoryCritical'], service: 'redis', durations: ['10m', '5m'] }, critical: 90,
{ key: 'minioCapacity', label: 'MinIO 容量使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)', names: ['MinioCapacityWarning', 'MinioCapacityCritical'], service: 'minio', durations: ['15m', '5m'] }, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)',
names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'],
service: 'host',
durations: ['10m', '5m'],
},
{
key: 'hostMemory',
label: '主机内存使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 85,
critical: 95,
expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100',
names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'],
service: 'host',
durations: ['10m', '5m'],
},
{
key: 'hostDisk',
label: '磁盘(独立文件系统)使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 80,
critical: 90,
expr: FILESYSTEM_USAGE_PERCENT,
names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'],
service: 'host',
durations: ['15m', '5m'],
},
{
key: 'apiError',
label: 'API 5xx 错误率',
unit: '%',
min: 0.1,
max: 100,
step: 0.1,
warning: 1,
critical: 5,
expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)',
guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5',
names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'],
service: 'api',
durations: ['5m', '5m'],
},
{
key: 'apiLatency',
label: 'API P95 响应时间',
unit: '秒',
min: 0.1,
max: 60,
step: 0.1,
warning: 1,
critical: 3,
expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))',
names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'],
service: 'api',
durations: ['10m', '5m'],
},
{
key: 'apiEventLoop',
label: 'API 事件循环 P99',
unit: '秒',
min: 0.01,
max: 10,
step: 0.01,
warning: 0.2,
critical: 1,
expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds',
names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'],
service: 'api',
durations: ['10m', '5m'],
},
{
key: 'gatewayQueue',
label: 'Gateway 最旧 pending',
unit: '秒',
min: 1,
max: 3600,
step: 1,
warning: 30,
critical: 120,
expr: 'cmpp_gateway_submit_queue_oldest_pending_age_seconds',
names: ['CmppGatewayQueueDelayedWarning', 'CmppGatewayQueueDelayedCritical'],
service: 'gateway',
durations: ['2m', '2m'],
},
{
key: 'postgresConnections',
label: 'PostgreSQL 连接使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 70,
critical: 85,
expr: '100 * sum(pg_stat_activity_count) / clamp_min(max(pg_settings_max_connections), 1)',
names: ['PostgresConnectionsWarning', 'PostgresConnectionsCritical'],
service: 'postgresql',
durations: ['10m', '5m'],
},
{
key: 'redisMemory',
label: 'Redis 内存使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 70,
critical: 85,
expr: '100 * redis_memory_used_bytes / redis_memory_max_bytes',
guard: 'redis_memory_max_bytes > 0',
names: ['RedisMemoryWarning', 'RedisMemoryCritical'],
service: 'redis',
durations: ['10m', '5m'],
},
{
key: 'minioCapacity',
label: 'MinIO 容量使用率',
unit: '%',
min: 1,
max: 100,
step: 1,
warning: 80,
critical: 90,
expr: '100 * (1 - minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes)',
names: ['MinioCapacityWarning', 'MinioCapacityCritical'],
service: 'minio',
durations: ['15m', '5m'],
},
] as const; ] as const;
export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries( export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fromEntries(
@@ -32,12 +171,17 @@ export const DEFAULT_ALERT_THRESHOLDS: InfrastructureAlertThresholds = Object.fr
export class InfrastructureAlertSettingsService { export class InfrastructureAlertSettingsService {
private readonly logger = new Logger(InfrastructureAlertSettingsService.name); private readonly logger = new Logger(InfrastructureAlertSettingsService.name);
private readonly rulesPath: string; private readonly rulesPath: string;
private readonly promtoolPath: string; private readonly promtoolPath: string | undefined;
private readonly reloadUrl: string; private readonly reloadUrl: string;
constructor(private readonly prisma: PrismaService, config: ConfigService) { constructor(
this.rulesPath = String(config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml'); private readonly prisma: PrismaService,
this.promtoolPath = String(config.get('PROMTOOL_PATH') ?? '/usr/bin/promtool'); config: ConfigService,
) {
this.rulesPath = String(
config.get('PROMETHEUS_MANAGED_RULES_PATH') ?? '/var/lib/cmpp-platform/monitoring/cmpp-managed-alerts.yml',
);
this.promtoolPath = config.get<string>('PROMTOOL_PATH');
this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload'); this.reloadUrl = String(config.get('PROMETHEUS_RELOAD_URL') ?? 'http://127.0.0.1:9090/-/reload');
} }
@@ -53,7 +197,14 @@ export class InfrastructureAlertSettingsService {
appliedAt: row?.appliedAt?.toISOString() ?? null, appliedAt: row?.appliedAt?.toISOString() ?? null,
thresholds, thresholds,
effectiveThresholds: effective, effectiveThresholds: effective,
definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({ key, label, unit, min, max, step })), definitions: ALERT_THRESHOLD_DEFINITIONS.map(({ key, label, unit, min, max, step }) => ({
key,
label,
unit,
min,
max,
step,
})),
}; };
} }
@@ -63,7 +214,13 @@ export class InfrastructureAlertSettingsService {
const thresholds = this.validate(body.thresholds); const thresholds = this.validate(body.thresholds);
const claimed = await this.prisma.infrastructureAlertSetting.updateMany({ const claimed = await this.prisma.infrastructureAlertSetting.updateMany({
where: { id: 'global', configVersion: expectedVersion }, where: { id: 'global', configVersion: expectedVersion },
data: { configVersion: { increment: 1 }, thresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'applying', lastError: null, updatedById: operatorId }, data: {
configVersion: { increment: 1 },
thresholds: thresholds as Prisma.InputJsonValue,
applyStatus: 'applying',
lastError: null,
updatedById: operatorId,
},
}); });
// 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。 // 版本条件更新是跨进程的写锁;避免两个 API 实例同时覆盖规则文件并把旧配置误标成已生效。
if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试'); if (claimed.count !== 1) throw new ConflictException('告警阈值已被其他管理员修改,请刷新后重试');
@@ -71,12 +228,32 @@ export class InfrastructureAlertSettingsService {
try { try {
await this.applyRules(thresholds); await this.applyRules(thresholds);
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { effectiveVersion: nextVersion, effectiveThresholds: thresholds as Prisma.InputJsonValue, applyStatus: 'effective', lastError: null, appliedAt: new Date() } }), this.prisma.infrastructureAlertSetting.update({
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'monitoring.alert_thresholds_updated', resource: 'infrastructure_alert_setting', resourceId: 'global', detail: { configVersion: nextVersion, thresholds } } }), where: { id: 'global' },
data: {
effectiveVersion: nextVersion,
effectiveThresholds: thresholds as Prisma.InputJsonValue,
applyStatus: 'effective',
lastError: null,
appliedAt: new Date(),
},
}),
this.prisma.operationLog.create({
data: {
userId: operatorId,
action: 'monitoring.alert_thresholds_updated',
resource: 'infrastructure_alert_setting',
resourceId: 'global',
detail: { configVersion: nextVersion, thresholds },
},
}),
]); ]);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error'; const message = error instanceof Error ? error.message.slice(0, 500) : 'unknown error';
await this.prisma.infrastructureAlertSetting.update({ where: { id: 'global' }, data: { applyStatus: 'failed', lastError: message } }); await this.prisma.infrastructureAlertSetting.update({
where: { id: 'global' },
data: { applyStatus: 'failed', lastError: message },
});
this.logger.error(`Prometheus managed rules apply failed: ${message}`); this.logger.error(`Prometheus managed rules apply failed: ${message}`);
throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留'); throw new ServiceUnavailableException('阈值已保存但 Prometheus 应用失败,原生效规则已保留');
} }
@@ -86,13 +263,20 @@ export class InfrastructureAlertSettingsService {
private validate(value: unknown): InfrastructureAlertThresholds { private validate(value: unknown): InfrastructureAlertThresholds {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效'); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('告警阈值格式无效');
const input = value as Record<string, unknown>; const input = value as Record<string, unknown>;
if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key))) throw new BadRequestException('存在不允许配置的告警指标'); if (Object.keys(input).some((key) => !ALERT_THRESHOLD_DEFINITIONS.some((item) => item.key === key)))
throw new BadRequestException('存在不允许配置的告警指标');
const result: InfrastructureAlertThresholds = {}; const result: InfrastructureAlertThresholds = {};
for (const definition of ALERT_THRESHOLD_DEFINITIONS) { for (const definition of ALERT_THRESHOLD_DEFINITIONS) {
const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined; const pair = input[definition.key] as { warning?: unknown; critical?: unknown } | undefined;
const warning = Number(pair?.warning); const warning = Number(pair?.warning);
const critical = Number(pair?.critical); const critical = Number(pair?.critical);
if (!Number.isFinite(warning) || !Number.isFinite(critical) || warning < definition.min || critical > definition.max || warning >= critical) { if (
!Number.isFinite(warning) ||
!Number.isFinite(critical) ||
warning < definition.min ||
critical > definition.max ||
warning >= critical
) {
throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`); throw new BadRequestException(`${definition.label}必须满足最小值 ≤ 警告阈值 < 严重阈值 ≤ 最大值`);
} }
result[definition.key] = { warning, critical }; result[definition.key] = { warning, critical };
@@ -101,7 +285,11 @@ export class InfrastructureAlertSettingsService {
} }
private asThresholds(value: unknown) { private asThresholds(value: unknown) {
try { return this.validate(value); } catch { return null; } try {
return this.validate(value);
} catch {
return null;
}
} }
private renderRules(thresholds: InfrastructureAlertThresholds) { private renderRules(thresholds: InfrastructureAlertThresholds) {
@@ -113,9 +301,26 @@ export class InfrastructureAlertSettingsService {
const isWarning = index === 0; const isWarning = index === 0;
// 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。 // 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。
const guard = 'guard' in definition ? ` and (${definition.guard})` : ''; const guard = 'guard' in definition ? ` and (${definition.guard})` : '';
const expr = isWarning ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` : `(${definition.expr} > ${values[1]})${guard}`; const expr = isWarning
const diskLocation = definition.key === 'hostDisk' ? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。' : ''; ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}`
lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}${diskLocation}"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`); : `(${definition.expr} > ${values[1]})${guard}`;
const diskLocation =
definition.key === 'hostDisk'
? ' 设备:{{ $labels.device }};文件系统:{{ $labels.fstype }}(绑定挂载已合并)。'
: '';
lines.push(
` - alert: ${definition.names[index]}`,
` expr: ${expr}`,
` for: ${definition.durations[index]}`,
' labels:',
` severity: ${isWarning ? 'warning' : 'critical'}`,
` service: ${definition.service}`,
' annotations:',
` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`,
` description: "${definition.label}持续超过${values[index]}${definition.unit}${diskLocation}"`,
' currentValue: "{{ $value }}"',
` threshold: "${values[index]}${definition.unit}"`,
);
} }
} }
return `${lines.join('\n')}\n`; return `${lines.join('\n')}\n`;
@@ -128,7 +333,9 @@ export class InfrastructureAlertSettingsService {
const previous = await readFile(this.rulesPath).catch(() => null); const previous = await readFile(this.rulesPath).catch(() => null);
try { try {
await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 }); await writeFile(temporary, this.renderRules(thresholds), { mode: 0o640 });
await execFileAsync(this.promtoolPath, ['check', 'rules', temporary], { timeout: 10_000 }); await execFileAsync(await resolvePromtoolPath(this.promtoolPath), ['check', 'rules', temporary], {
timeout: 10_000,
});
await rename(temporary, this.rulesPath); await rename(temporary, this.rulesPath);
const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) }); const response = await fetch(this.reloadUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) });
if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`); if (!response.ok) throw new Error(`Prometheus reload HTTP ${response.status}`);
@@ -144,3 +351,17 @@ export class InfrastructureAlertSettingsService {
} }
} }
} }
// Explicit configuration is authoritative; never silently replace a broken configured binary.
export async function resolvePromtoolPath(configured?: string) {
const candidates = configured ? [configured] : ['/usr/local/bin/promtool', '/usr/bin/promtool'];
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK);
return candidate;
} catch {
/* Try the next standard install location. */
}
}
throw new Error('promtool不可执行,请检查PROMTOOL_PATH或标准安装目录');
}
@@ -0,0 +1,22 @@
import * as fs from 'node:fs/promises';
import { resolvePromtoolPath } from './infrastructure-alert-settings.service';
jest.mock('node:fs/promises', () => ({ ...jest.requireActual('node:fs/promises'), access: jest.fn() }));
describe('Prometheus binary resolution', () => {
beforeEach(() => jest.resetAllMocks());
it('uses the official installer location when available', async () => {
const check = jest.mocked(fs.access).mockResolvedValue(undefined);
expect(await resolvePromtoolPath()).toBe('/usr/local/bin/promtool');
expect(check).toHaveBeenCalledTimes(1);
});
it('supports the distribution package location', async () => {
jest.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')).mockResolvedValueOnce(undefined);
expect(await resolvePromtoolPath()).toBe('/usr/bin/promtool');
});
it('fails rather than silently overriding an invalid explicitly configured binary', async () => {
const check = jest.mocked(fs.access).mockRejectedValue(new Error('EACCES'));
await expect(resolvePromtoolPath('/custom/promtool')).rejects.toThrow('promtool不可执行');
expect(check).toHaveBeenCalledTimes(1);
});
});
File diff suppressed because it is too large Load Diff
+158 -123
View File
@@ -1,16 +1,14 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { 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,
),
}; };
}); });
} }
+369 -61
View File
@@ -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' }],
}),
);
}); });
}); });
+199 -45
View File
@@ -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)。
+36
View File
@@ -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-0105。
@@ -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:4016: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、端口或租户过滤。
- 本轮授权修改代码并本地提交;不推送、不部署两环境、不补跑预生产报表、不发送短信或修改业务配置。测试数据仅在隔离本地测试库构造,结果与未验证项记入进度。
+48
View File
@@ -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;非法值回退默认、最大 120000msstatement_timeout 仅当前事务有效,其他业务事务不改变 |
| TC-DAILY-0908-08 | 失败后次周期、成功后同日重复周期,以及启动 15 秒内销毁服务 | 失败继续重试、全成功后同日跳过;销毁清除启动及周期定时器,无销毁后新任务 |
| TC-DAILY-0908-09 | 真实生成后调用报表查询/汇总/分页及 CSV | 数据与 PostgreSQL 一致;既有日期/租户/维度筛选和金额精度不变,API 无假成功或静态数据 |
+54
View File
@@ -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检测耗534msRSS253353984→305582080字节,增长49.81MiB;仅隔离进程GC后302989312字节。相同调用规模的旧表达式此前独立进程增长约1.06GiB,此次不是单凭服务重启判断效果。正式测试API发布前RSS约417MiB;发布后同一PID101308经多轮页面查询约230262MiB(观察至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-2c6dd8a1plan→精确提交已执行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-0105。没有后端、端口、CSS或数据库结构变更。
- 只读复现:真实测试页面质量Tab容量控件位于page-actionsGET /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依赖warning0 errorChart分包约181.65KiB gzip、入口106.42KiB gzip,包体门禁通过。
- 真实浏览器:Browser插件及其skill未提供,使用本机Playwright/Edge。最终生产构建本地127.0.0.1:4173临时预览代理到测试100.93.204.60:12026真实API;后端精确版本仍2c228a94e13b628447720c24bed45fb169acca6fcmpp-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-20260908before-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-0109。明确单日期三类报表原子事务、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 5000msREPORT_REFRESH_TRANSACTION_TIMEOUT_MS 正整数覆盖且最大 120000ms,并设置事务局部 statement_timeout。删除前获取 PostgreSQL 日期 advisory lock;单日失败保留旧报表并继续其他日期,最终汇总抛错,只有全成功才标记本日完成。启动/周期定时器销毁清理,调度日与窗口共用同一时刻。未新增迁移/API 写入口/历史重算队列。两份已修改 TS 按当前 Prettier 门禁格式化,并显式忽略原 refundCents 解构值以修复该文件既有 lint 错误,列表/CSV 语义不变。
- 预生产只读结果一致性:在同一 REPEATABLE READ READ ONLY 事务中、statement_timeout=15000ms9 月 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 为准;未推送、未测试部署、未预生产部署、未短信发送/补发/重投/入队,未修改业务余额、通道、客户配置或管理员。提交前保护校验确认全部已有跟踪修改未被覆盖。
+2
View File
@@ -47,6 +47,8 @@ export type SignatureRetirementDetection = {
}; };
export type SignatureRetirementMessage = { export type SignatureRetirementMessage = {
dailyGroupKey?: string | null;
detections?: Array<SignatureRetirementDetection & { signatureName?: string; channelName?: string }>;
id: string; id: string;
detectionId: string; detectionId: string;
cycleId: string; cycleId: string;
+22
View File
@@ -0,0 +1,22 @@
.admin-analytics-page .page-actions {
flex-wrap: wrap;
}
.admin-analytics-page .page-heading {
gap: 16px;
}
.admin-analytics-page [hidden] {
display: none;
}
@media (width <= 600px) {
.admin-analytics-page .page-heading {
align-items: stretch;
flex-direction: column;
}
.admin-analytics-page .page-actions {
align-items: end;
}
}
@@ -0,0 +1,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);
},
);
});
+458 -209
View File
@@ -1,4 +1,4 @@
import { useDeferredValue, useEffect, useState } from 'react'; import { useDeferredValue, useEffect, useRef, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react'; import { BarChart3, Eye, Search, X } from 'lucide-react';
import { import {
adminApi, adminApi,
@@ -10,7 +10,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)),
);
} }
+87
View File
@@ -0,0 +1,87 @@
.admin-dashboard .home-metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.admin-dashboard .home-metric {
padding: 18px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: #fff;
min-width: 0;
}
.admin-dashboard .home-metric__heading {
display: flex;
justify-content: space-between;
align-items: start;
gap: 8px;
font-size: 13px;
color: #6b7280;
}
.admin-dashboard .home-metric__category {
display: flex;
align-items: center;
gap: 4px;
color: #2563eb;
font-size: 12px;
white-space: nowrap;
}
.admin-dashboard .home-metric__number {
display: flex;
align-items: baseline;
gap: 6px;
margin: 16px 0 10px;
color: #111827;
font-variant-numeric: tabular-nums;
flex-wrap: wrap;
overflow-wrap: anywhere;
}
.admin-dashboard .home-metric__number strong {
font-size: 26px;
font-weight: 600;
line-height: 1.2;
}
.admin-dashboard .home-metric__number span {
font-size: 12px;
color: #6b7280;
}
.admin-dashboard .home-metric__number.is-negative {
color: #dc2626;
}
.admin-dashboard .home-metric p {
margin: 0;
font-size: 12px;
color: #6b7280;
}
@media (width <= 1400px) {
.admin-dashboard .home-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (width <= 700px) {
.admin-dashboard .home-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-dashboard .home-metric {
padding: 14px;
}
.admin-dashboard .home-metric__category {
display: none;
}
.admin-dashboard .home-metric__number strong {
font-size: 23px;
}
}
+94 -55
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react'; import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import './AdminHome.css';
import { Chart } from '@/components/ui/Chart'; import { Chart } from '@/components/ui/Chart';
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi'; import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions'; import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
@@ -170,61 +171,99 @@ export function AdminHome() {
</div> </div>
</div> </div>
<div className="dashboard-grid admin-metric-grid"> <div className="home-metrics" aria-busy={!dashboard && !error}>
<div className="surface metric-card"> {[
<span></span> {
<strong>{formatCount(totalSend)} </strong> label: '今日发送总量',
<small></small> value: formatCount(totalSend),
</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}
+21 -6
View File
@@ -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))}
+815 -80
View File
@@ -11,23 +11,55 @@ import {
type SignatureRetirementWebhook, type SignatureRetirementWebhook,
type TenantOption, type TenantOption,
} from '@/api/adminApi'; } from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; import {
Breadcrumb,
Button,
CarrierTag,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tabs,
Tag,
Textarea,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
const carriers = ['mobile', 'unicom', 'telecom'] as const; const carriers = ['mobile', 'unicom', 'telecom'] as const;
const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' }; const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' };
const ruleTypeLabels: Record<SignatureRetirementRuleType, string> = { const ruleTypeLabels: Record<SignatureRetirementRuleType, string> = {
enterprise_global: '企业全局规则', enterprise_application: '企业应用特殊规则', channel_global: '通道全局规则', channel: '通道特殊规则', enterprise_global: '企业全局规则',
enterprise_application: '企业应用特殊规则',
channel_global: '通道全局规则',
channel: '通道特殊规则',
}; };
type RuleDraft = { type RuleDraft = {
ruleType: SignatureRetirementRuleType; targetId: string; enabled: boolean; ruleType: SignatureRetirementRuleType;
mobileWindowDays: string; mobileThreshold: string; unicomWindowDays: string; unicomThreshold: string; targetId: string;
telecomWindowDays: string; telecomThreshold: string; messageTemplate: string; enabled: boolean;
mobileWindowDays: string;
mobileThreshold: string;
unicomWindowDays: string;
unicomThreshold: string;
telecomWindowDays: string;
telecomThreshold: string;
messageTemplate: string;
}; };
const emptyRule: RuleDraft = { const emptyRule: RuleDraft = {
ruleType: 'enterprise_global', targetId: '', enabled: true, ruleType: 'enterprise_global',
mobileWindowDays: '30', mobileThreshold: '1', unicomWindowDays: '30', unicomThreshold: '1', targetId: '',
telecomWindowDays: '30', telecomThreshold: '1', messageTemplate: '', enabled: true,
mobileWindowDays: '30',
mobileThreshold: '1',
unicomWindowDays: '30',
unicomThreshold: '1',
telecomWindowDays: '30',
telecomThreshold: '1',
messageTemplate: '',
}; };
type MessageFilters = { type MessageFilters = {
@@ -47,7 +79,13 @@ type SuppressionDraft = {
function defaultMessageFilters(): MessageFilters { function defaultMessageFilters(): MessageFilters {
const today = shanghaiDateKey(); const today = shanghaiDateKey();
return { dateRange: { start: today, end: today }, tenantId: '', applicationId: '', signatureKeyword: '', channelId: '' }; return {
dateRange: { start: today, end: today },
tenantId: '',
applicationId: '',
signatureKeyword: '',
channelId: '',
};
} }
export function AdminSignatureRetirementPage() { export function AdminSignatureRetirementPage() {
@@ -74,74 +112,252 @@ export function AdminSignatureRetirementPage() {
const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => { const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => {
setLoading(true); setLoading(true);
try { try {
const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] = await Promise.all([ const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] =
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;
+113 -2
View File
@@ -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',
+143 -23
View File
@@ -32,9 +32,13 @@ export function ChannelFormModal({
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100)); const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1)); const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16)); const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30)); const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(
String(channel?.heartbeatIntervalSeconds ?? 30),
);
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3)); const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(channel?.longMessageReceiptMode ?? 'per_segment'); const [longMessageReceiptMode, setLongMessageReceiptMode] = useState<LongMessageReceiptMode>(
channel?.longMessageReceiptMode ?? 'per_segment',
);
function submit() { function submit() {
if (carriers.length === 0) { if (carriers.length === 0) {
@@ -82,34 +86,74 @@ export function ChannelFormModal({
return ( return (
<Modal <Modal
footer={( closeOnBackdrop={modal.mode === 'edit'}
closeOnEscape={modal.mode === 'edit'}
footer={
<> <>
<Button onClick={onClose} variant="ghost"></Button> <Button onClick={onClose} variant="ghost">
</Button>
<Button onClick={submit}></Button> <Button onClick={submit}></Button>
</> </>
)} }
onClose={onClose} onClose={onClose}
open open
size="xl" size="xl"
title={<div className="template-modal-title"><h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2></div>} title={
<div className="template-modal-title">
<h2>{modal.mode === 'edit' ? '编辑通道' : '创建通道'}</h2>
</div>
}
> >
<div className="sms-channel-form"> <div className="sms-channel-form">
<section> <section>
<h3></h3> <h3></h3>
<div className="sms-channel-form-grid"> <div className="sms-channel-form-grid">
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} /> <Input
label="* 通道名称"
onChange={(event) => setName(event.target.value)}
placeholder="请输入通道名称"
value={name}
/>
<div className="sms-channel-radio-row"> <div className="sms-channel-radio-row">
<span>* </span> <span>* </span>
{baseCarrierOptions.map((item) => ( {baseCarrierOptions.map((item) => (
<label key={item.value}> <label key={item.value}>
<input checked={carriers.includes(item.value)} onChange={() => { setCarriers((current) => current.includes(item.value) ? current.filter((carrier) => carrier !== item.value) : [...current, item.value]); setCarrierError(''); }} type="checkbox" /> <input
checked={carriers.includes(item.value)}
onChange={() => {
setCarriers((current) =>
current.includes(item.value)
? current.filter((carrier) => carrier !== item.value)
: [...current, item.value],
);
setCarrierError('');
}}
type="checkbox"
/>
{item.label} {item.label}
</label> </label>
))} ))}
{carrierError ? <small className="form-error">{carrierError}</small> : null} {carrierError ? <small className="form-error">{carrierError}</small> : null}
</div> </div>
<Input error={unitPriceError} label="* 单价(元)" min="0" onChange={(event) => { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} /> <Input
<Select label="* 发送地区" onChange={(event) => setRegion(event.target.value)} options={regionOptions} value={region} /> error={unitPriceError}
label="* 单价(元)"
min="0"
onChange={(event) => {
setUnitPrice(event.target.value);
setUnitPriceError('');
}}
step="0.0001"
type="number"
value={unitPrice}
/>
<Select
label="* 发送地区"
onChange={(event) => setRegion(event.target.value)}
options={regionOptions}
value={region}
/>
</div> </div>
</section> </section>
@@ -118,13 +162,39 @@ export function ChannelFormModal({
<div className="sms-channel-form-grid"> <div className="sms-channel-form-grid">
<Input disabled label="* 协议选择" value="CMPP" /> <Input disabled label="* 协议选择" value="CMPP" />
<div className="sms-channel-inline-field"> <div className="sms-channel-inline-field">
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} /> <Input
label="* 网关地址"
onChange={(event) => setGatewayHost(event.target.value)}
placeholder="请输入网关地址"
value={gatewayHost}
/>
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} /> <Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
</div> </div>
<Input hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} /> <Input
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} /> hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。"
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} /> label="* 业务代码"
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} /> maxLength={10}
onChange={(event) => setBusinessCode(event.target.value.toUpperCase())}
value={businessCode}
/>
<Input
label="* 企业代码"
onChange={(event) => setCorpCode(event.target.value)}
placeholder="请输入企业代码"
value={corpCode}
/>
<Input
label="* 网关账号"
onChange={(event) => setAccount(event.target.value)}
placeholder="请输入网关账号"
value={account}
/>
<Select
label="* CMPP版本"
onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')}
options={cmppVersionOptions}
value={cmppVersion}
/>
<Input <Input
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined} hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
label="网关密码" label="网关密码"
@@ -137,14 +207,62 @@ export function ChannelFormModal({
value={password} value={password}
/> />
<div className="sms-channel-inline-field"> <div className="sms-channel-inline-field">
<Input autoComplete="off" label="* 接入号" name="cmpp-access-number" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} /> <Input
<Input label="扩展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" value={extensionDigits} /> autoComplete="off"
label="* 接入号"
name="cmpp-access-number"
onChange={(event) => setAccessNo(event.target.value)}
placeholder="请输入通道接入号"
value={accessNo}
/>
<Input
label="扩展位数"
max="20"
min="0"
onChange={(event) => setExtensionDigits(event.target.value)}
type="number"
value={extensionDigits}
/>
</div> </div>
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} /> <Input
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} /> label="* 通道流速"
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} /> max="2000"
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} /> min="1"
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} /> onChange={(event) => setFlowLimit(event.target.value)}
suffix="条/秒"
type="number"
value={flowLimit}
/>
<Input
label="* 期望连接数"
onChange={(event) => setDesiredConnections(event.target.value)}
placeholder="1"
value={desiredConnections}
/>
<Input
label="* 提交窗口"
onChange={(event) => setWindowSize(event.target.value)}
placeholder="16"
value={windowSize}
/>
<Input
hint="平台主动向供应商发送 ACTIVE_TEST 的间隔"
label="* 心跳间隔"
min="1"
onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)}
suffix="秒"
type="number"
value={heartbeatIntervalSeconds}
/>
<Input
hint="连续未收到心跳响应达到该次数后重连"
label="* 心跳失败阈值"
min="1"
onChange={(event) => setHeartbeatMissThreshold(event.target.value)}
suffix="次"
type="number"
value={heartbeatMissThreshold}
/>
<Select <Select
label="* 长短信成功回执口径" label="* 长短信成功回执口径"
onChange={(event) => setLongMessageReceiptMode(event.target.value as LongMessageReceiptMode)} onChange={(event) => setLongMessageReceiptMode(event.target.value as LongMessageReceiptMode)}
@@ -154,7 +272,9 @@ export function ChannelFormModal({
]} ]}
value={longMessageReceiptMode} value={longMessageReceiptMode}
/> />
<p className="page-inline-hint"></p> <p className="page-inline-hint">
</p>
</div> </div>
</section> </section>
</div> </div>
+29
View File
@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Modal } from './Modal';
describe('Modal close policy', () => {
it('keeps an explicitly locked form open on mask and Escape but permits the close button', () => {
const close = vi.fn();
render(
<Modal open title="创建通道" closeOnBackdrop={false} closeOnEscape={false} onClose={close}>
</Modal>,
);
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
fireEvent.keyDown(document, { key: 'Escape' });
expect(close).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(close).toHaveBeenCalledOnce();
});
it('preserves mask closing by default for existing consumers', () => {
const close = vi.fn();
render(
<Modal open title="普通弹窗" onClose={close}>
</Modal>,
);
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
expect(close).toHaveBeenCalledOnce();
});
});
+42 -16
View File
@@ -16,6 +16,8 @@ type ModalProps = {
size?: 'md' | 'xl'; size?: 'md' | 'xl';
onClose: () => void; onClose: () => void;
dirty?: boolean; dirty?: boolean;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
initialFocusRef?: RefObject<HTMLElement | null>; initialFocusRef?: RefObject<HTMLElement | null>;
closeGuardTitle?: string; closeGuardTitle?: string;
closeGuardDescription?: string; closeGuardDescription?: string;
@@ -82,8 +84,9 @@ function unlockDocument() {
} }
function focusableElements(root: HTMLElement) { function focusableElements(root: HTMLElement) {
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector)) return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector)).filter(
.filter((element) => !element.hidden && element.getClientRects().length > 0); (element) => !element.hidden && element.getClientRects().length > 0,
);
} }
export function Modal({ export function Modal({
@@ -94,6 +97,8 @@ export function Modal({
size = 'md', size = 'md',
onClose, onClose,
dirty = false, dirty = false,
closeOnBackdrop = true,
closeOnEscape = true,
initialFocusRef, initialFocusRef,
closeGuardTitle = '放弃未保存的修改?', closeGuardTitle = '放弃未保存的修改?',
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。', closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
@@ -108,6 +113,7 @@ export function Modal({
const guardRestoreFocusRef = useRef<HTMLElement | null>(null); const guardRestoreFocusRef = useRef<HTMLElement | null>(null);
const [showCloseGuard, setShowCloseGuard] = useState(false); const [showCloseGuard, setShowCloseGuard] = useState(false);
const [layer] = useState(() => modalLayer()); const [layer] = useState(() => modalLayer());
if (!open && showCloseGuard) setShowCloseGuard(false);
const requestClose = useCallback(() => { const requestClose = useCallback(() => {
if (dirty) { if (dirty) {
@@ -124,10 +130,7 @@ export function Modal({
}, [onClose]); }, [onClose]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) return undefined;
setShowCloseGuard(false);
return undefined;
}
const panel = panelRef.current; const panel = panelRef.current;
if (!panel) return undefined; if (!panel) return undefined;
@@ -163,7 +166,7 @@ export function Modal({
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (showCloseGuard) setShowCloseGuard(false); if (showCloseGuard) setShowCloseGuard(false);
else requestClose(); else if (closeOnEscape) requestClose();
return; return;
} }
if (event.key !== 'Tab') return; if (event.key !== 'Tab') return;
@@ -188,7 +191,7 @@ export function Modal({
document.addEventListener('keydown', handleKeyDown, true); document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true); return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [open, requestClose, showCloseGuard]); }, [closeOnEscape, open, requestClose, showCloseGuard]);
useEffect(() => { useEffect(() => {
if (!showCloseGuard) return; if (!showCloseGuard) return;
@@ -205,13 +208,16 @@ export function Modal({
}, [showCloseGuard]); }, [showCloseGuard]);
if (!open) return null; if (!open) return null;
const renderedFooter = typeof footer === 'function' ? footer({ requestClose }) : footer;
return createPortal( return createPortal(
<div className="ui-modal" data-ui-modal-root> <div className="ui-modal" data-ui-modal-root>
<div aria-hidden="true" className="ui-modal__mask" onMouseDown={(event) => { <div
if (event.target === event.currentTarget) requestClose(); aria-hidden="true"
}} /> className="ui-modal__mask"
onMouseDown={(event) => {
if (closeOnBackdrop && event.target === event.currentTarget) requestClose();
}}
/>
<section <section
aria-labelledby={titleId} aria-labelledby={titleId}
aria-modal="true" aria-modal="true"
@@ -221,13 +227,19 @@ export function Modal({
tabIndex={-1} tabIndex={-1}
> >
<header className="ui-modal__header"> <header className="ui-modal__header">
<div className="ui-modal__title" id={titleId}>{title}</div> <div className="ui-modal__title" id={titleId}>
{title}
</div>
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}> <Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}>
</Button> </Button>
</header> </header>
<div className="ui-modal__body">{children}</div> <div className="ui-modal__body">{children}</div>
{renderedFooter ? <footer className="ui-modal__footer">{renderedFooter}</footer> : null} {footer ? (
<footer className="ui-modal__footer">
<ModalFooter footer={footer} requestClose={requestClose} />
</footer>
) : null}
</section> </section>
{showCloseGuard ? ( {showCloseGuard ? (
<div className="ui-modal__guard-layer"> <div className="ui-modal__guard-layer">
@@ -246,8 +258,12 @@ export function Modal({
<p id={guardDescriptionId}>{closeGuardDescription}</p> <p id={guardDescriptionId}>{closeGuardDescription}</p>
</div> </div>
<footer> <footer>
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost"></Button> <Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost">
<Button onClick={discardAndClose} variant="danger"></Button>
</Button>
<Button onClick={discardAndClose} variant="danger">
</Button>
</footer> </footer>
</section> </section>
</div> </div>
@@ -256,3 +272,13 @@ export function Modal({
layer, layer,
); );
} }
function ModalFooter({
footer,
requestClose,
}: {
footer: NonNullable<ModalProps['footer']>;
requestClose: () => void;
}) {
return typeof footer === 'function' ? footer({ requestClose }) : footer;
}
+61
View File
@@ -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);
});
});
+70 -12
View File
@@ -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>
); );
} }
+12
View File
@@ -10,6 +10,18 @@
"src/styles/components.css" "src/styles/components.css"
], ],
"files": [ "files": [
{
"file": "src/apps/admin/AdminHome.css",
"owners": ["src/apps/admin/AdminHome.tsx"],
"stylelintLegacy": false,
"roots": ["admin-dashboard"]
},
{
"file": "src/apps/admin/AdminAnalyticsPage.css",
"owners": ["src/apps/admin/AdminAnalyticsPage.tsx"],
"stylelintLegacy": false,
"roots": ["admin-analytics-page"]
},
{ {
"file": "src/layouts/AlertNotificationMenu.css", "file": "src/layouts/AlertNotificationMenu.css",
"owners": ["src/layouts/AlertNotificationMenu.tsx"], "owners": ["src/layouts/AlertNotificationMenu.tsx"],
+400
View File
@@ -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();
}