feat: add carrier-aware signature retirement alerts

This commit is contained in:
hectorzhao
2026-08-10 20:54:05 +08:00
parent 232d1c22a3
commit 55aa054005
52 changed files with 3074 additions and 152 deletions
@@ -0,0 +1,58 @@
export type RetirementRuleType = 'enterprise_global' | 'enterprise_application' | 'channel_global' | 'channel';
export interface UpsertRetirementRuleDto {
ruleType: RetirementRuleType;
targetId?: string;
enabled?: boolean;
mobileWindowDays: number;
mobileThreshold: number;
unicomWindowDays: number;
unicomThreshold: number;
telecomWindowDays: number;
telecomThreshold: number;
messageTemplate?: string;
}
export interface CreateRetirementWebhookDto {
name: string;
platform: 'wecom' | 'feishu';
url: string;
}
export interface SuppressRetirementMessageDto {
mode: 'temporary' | 'permanent';
days?: number;
reason?: string;
}
export interface CancelRetirementSuppressionDto {
reason: string;
}
export interface UnreportedSignatureQuery {
date?: string;
keyword?: string;
page?: number;
pageSize?: number;
}
export interface RetirementMessageQuery {
dateFrom?: string;
dateTo?: string;
dimensionType?: string;
tenantId?: string;
applicationId?: string;
signatureKeyword?: string;
channelId?: string;
page?: number;
pageSize?: number;
}
export interface ConfirmLegacyReportDto {
results: Array<{
carrier: 'mobile' | 'unicom' | 'telecom';
status: 'pending' | 'waiting_material' | 'reporting' | 'approved' | 'failed' | 'rejected' | 'abandoned';
approvedAt?: string;
}>;
reason?: string;
}
@@ -0,0 +1,110 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import type { CancelRetirementSuppressionDto, ConfirmLegacyReportDto, CreateRetirementWebhookDto, RetirementMessageQuery, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
import { SignatureRetirementService } from './signature-retirement.service';
@ApiTags('signature-retirement')
@Controller('admin/signature-retirement')
export class SignatureRetirementController {
constructor(private readonly service: SignatureRetirementService) {}
@Get('configuration')
getConfiguration() {
return this.service.getConfiguration();
}
@Put('rules')
@RequireRecentAuthentication()
upsertRule(@Body() body: UpsertRetirementRuleDto, @CurrentSessionUserId() operatorId?: string) {
return this.service.upsertRule(body, operatorId);
}
@Post('webhooks')
@RequireRecentAuthentication()
createWebhook(@Body() body: CreateRetirementWebhookDto) {
return this.service.createWebhook(body);
}
@Delete('webhooks/:id')
@RequireRecentAuthentication()
deleteWebhook(@Param('id') id: string) {
return this.service.deleteWebhook(id);
}
@Get('messages')
listMessages(
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
@Query('dimensionType') dimensionType?: string,
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('signatureKeyword') signatureKeyword?: string,
@Query('channelId') channelId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const query: RetirementMessageQuery = { dateFrom, dateTo, dimensionType, tenantId, applicationId, signatureKeyword, channelId, page: Number(page), pageSize: Number(pageSize) };
return this.service.listMessages(query);
}
@Get('unread-count')
unreadCount() {
return this.service.unreadCount();
}
@Post('messages/:id/read')
markRead(@Param('id') id: string) {
return this.service.markRead(id);
}
@Post('messages/read-all-today')
markAllTodayRead() {
return this.service.markAllTodayRead();
}
@Post('messages/:id/suppress')
@RequireRecentAuthentication()
suppress(@Param('id') id: string, @Body() body: SuppressRetirementMessageDto, @CurrentSessionUserId() operatorId?: string) {
return this.service.suppressMessage(id, body, operatorId);
}
@Get('suppressions')
listSuppressions() {
return this.service.listSuppressions();
}
@Post('suppressions/:id/cancel')
@RequireRecentAuthentication()
cancelSuppression(@Param('id') id: string, @Body() body: CancelRetirementSuppressionDto, @CurrentSessionUserId() operatorId?: string) {
return this.service.cancelSuppression(id, body, operatorId);
}
@Get('heatmap')
heatmap(@Query('date') date?: string) {
return this.service.heatmap(date);
}
@Get('unreported-signatures')
unreportedSignatures(
@Query('date') date?: string,
@Query('keyword') keyword?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const query: UnreportedSignatureQuery = { date, keyword, page: Number(page), pageSize: Number(pageSize) };
return this.service.unreportedSignatures(query);
}
@Get('legacy-report-tasks')
listLegacyReportTasks() {
return this.service.listLegacyReportTasks();
}
@Post('legacy-report-tasks/:id/confirm')
@RequireRecentAuthentication()
confirmLegacyReport(@Param('id') id: string, @Body() body: ConfirmLegacyReportDto, @CurrentSessionUserId() operatorId?: string) {
return this.service.confirmLegacyReport(id, body, operatorId);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SignatureRetirementController } from './signature-retirement.controller';
import { SignatureRetirementService } from './signature-retirement.service';
@Module({
controllers: [SignatureRetirementController],
providers: [SignatureRetirementService],
exports: [SignatureRetirementService],
})
export class SignatureRetirementModule {}
@@ -0,0 +1,152 @@
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
import { BadRequestException } from '@nestjs/common';
describe('SignatureRetirementService dimensions', () => {
const service = new SignatureRetirementService({} as never);
it('builds enterprise dimensions once and channel dimensions per approved channel and carrier', () => {
const rules = [
rule('enterprise_global', ''),
rule('enterprise_application', 'app-1'),
rule('channel_global', ''),
rule('channel', 'channel-2'),
];
const tasks = [
task('channel-1', '移动一号', 'mobile', '2026-06-01T00:00:00Z'),
task('channel-2', '移动二号', 'mobile', '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);
expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2);
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3);
expect(dimensions.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')?.approvedAt.toISOString()).toBe('2026-06-01T00:00:00.000Z');
expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true);
expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel');
});
it('does not monitor legacy carrier-null reporting facts', () => {
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }).buildDimensions(
[rule('enterprise_global', ''), rule('channel_global', '')],
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')],
);
expect(dimensions).toEqual([]);
});
it('rejects an approved legacy carrier confirmation without an explicit valid approval time', async () => {
await expect(service.confirmLegacyReport('legacy-1', {
results: [{ carrier: 'mobile', status: 'approved' }],
})).rejects.toBeInstanceOf(BadRequestException);
await expect(service.confirmLegacyReport('legacy-1', {
results: [{ carrier: 'mobile', status: 'approved', approvedAt: 'not-a-date' }],
})).rejects.toBeInstanceOf(BadRequestException);
});
it('schedules the next Beijing 04:00 and 08:00 precisely', () => {
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T19:00:00.000Z'), 4)).toBe(60 * 60_000);
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000);
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T23:30:00.000Z'), 8)).toBe(30 * 60_000);
});
it('publishes frozen alert content only in the notification phase', async () => {
const detection = {
id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1',
cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文',
};
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) },
signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) },
signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
signatureRetirementWebhookDelivery: { upsert: jest.fn() },
};
const notificationService = new SignatureRetirementService(prisma as never);
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 });
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) });
});
it('returns enterprise application metadata for heatmap hover and search', async () => {
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([]) },
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
tenant: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{
signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'),
signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } },
channel: { name: '移动通道' },
}]) },
};
const heatmapService = new SignatureRetirementService(prisma as never);
const result = await heatmapService.heatmap('2026-08-10');
expect(result.dimensions).toEqual(expect.arrayContaining([
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
]));
});
it('maps the real unreported-signature aggregation to an independent page', async () => {
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{
signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业',
applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3,
}]),
};
const unreportedService = new SignatureRetirementService(prisma as never);
await expect(unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 })).resolves.toEqual({
date: '2026-08-10',
items: [{ signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', applicationId: 'app-1', applicationName: '测试应用', messageCount: 7 }],
total: 3,
page: 2,
pageSize: 10,
});
});
it('returns a filtered historical message page with application metadata', async () => {
const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') };
const detection = { id: 'detection-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile' };
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]),
signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) },
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([detection]) },
smsSignature: { findMany: jest.fn().mockResolvedValue([{ id: 'signature-1', name: '测试签名' }]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', name: '测试通道' }]) },
tenant: { findMany: jest.fn().mockResolvedValue([{ id: 'tenant-1', name: '测试企业' }]) },
smsApplication: { findMany: jest.fn().mockResolvedValue([{ id: 'app-1', name: '测试应用' }]) },
};
const messageService = new SignatureRetirementService(prisma as never);
await expect(messageService.listMessages({ dateFrom: '2026-08-01', dateTo: '2026-08-10', tenantId: 'tenant-1', applicationId: 'app-1', signatureKeyword: '测试', channelId: 'channel-1', page: 2, pageSize: 10 })).resolves.toEqual({
items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })],
total: 21,
page: 2,
pageSize: 10,
});
});
it('requires a reason for temporary and permanent suppression', async () => {
const prisma = {
signatureRetirementMessage: {
findUnique: jest.fn().mockResolvedValue({ id: 'message-1', cycleId: 'cycle-1', detectionId: 'detection-1', createdAt: new Date() }),
findFirst: jest.fn().mockResolvedValue(null),
},
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
};
const suppressionService = new SignatureRetirementService(prisma as never);
await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空');
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空');
});
});
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 };
}
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 } };
}
@@ -0,0 +1,738 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { lookup } from 'node:dns/promises';
import { decryptSecret, encryptSecret } from '../open-api/open-api.crypto';
import { PrismaService } from '../prisma/prisma.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
import { normalizeChannelCarriers } from '../channels/channels.helpers';
import type { CancelRetirementSuppressionDto, ConfirmLegacyReportDto, CreateRetirementWebhookDto, RetirementMessageQuery, RetirementRuleType, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
const DAY_MS = 86_400_000;
const CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
const REPORT_STATUSES = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
const DEFAULT_DELIVERY_INTERVAL_MS = 60_000;
const carrierLabels: Record<string, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
type RuleRecord = Awaited<ReturnType<PrismaService['signatureRetirementRule']['findFirst']>>;
type DetectionDimension = {
dimensionType: 'enterprise' | 'channel';
tenantId: string;
applicationId: string | null;
signatureId: string;
signatureName: string;
tenantName: string;
channelId: string | null;
channelName: string | null;
carrier: string;
approvedAt: Date;
rule: NonNullable<RuleRecord>;
};
type ActivityCounts = {
submittedAttempts: number;
acceptedBusinessCount: number;
deliveredBusinessCount: number;
};
@Injectable()
export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SignatureRetirementService.name);
private detectionTimer?: ReturnType<typeof setTimeout>;
private notificationTimer?: ReturnType<typeof setTimeout>;
private deliveryTimer?: ReturnType<typeof setInterval>;
private startupTimer?: ReturnType<typeof setTimeout>;
constructor(private readonly prisma: PrismaService) {}
onModuleInit() {
if (process.env.NODE_ENV === 'test') return;
// 04:00检测、08:00发消息分别调度;启动补偿与数据库唯一键共同保证当天不漏、不重。
this.startupTimer = setTimeout(() => void this.runStartupCompensation(), 10_000);
this.startupTimer.unref?.();
this.scheduleDetection();
this.scheduleNotification();
this.deliveryTimer = setInterval(() => void this.deliverPendingWebhooks(), positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS));
this.deliveryTimer.unref?.();
}
onModuleDestroy() {
if (this.startupTimer) clearTimeout(this.startupTimer);
if (this.detectionTimer) clearTimeout(this.detectionTimer);
if (this.notificationTimer) clearTimeout(this.notificationTimer);
if (this.deliveryTimer) clearInterval(this.deliveryTimer);
}
async getConfiguration() {
const [rules, webhooks] = await Promise.all([
this.prisma.signatureRetirementRule.findMany({ orderBy: [{ ruleType: 'asc' }, { targetKey: 'asc' }] }),
this.prisma.signatureRetirementWebhook.findMany({ orderBy: { createdAt: 'asc' } }),
]);
return { rules, webhooks };
}
async upsertRule(data: UpsertRetirementRuleDto, operatorId?: string) {
assertRuleType(data.ruleType);
if (['enterprise_application', 'channel'].includes(data.ruleType) && !data.targetId?.trim()) {
throw new BadRequestException('特殊规则必须选择目标');
}
const values = CARRIERS.flatMap((carrier) => [
Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]),
Number(data[`${carrier}Threshold` as keyof UpsertRetirementRuleDto]),
]);
if (values.some((value) => !Number.isInteger(value) || value < 0)) throw new BadRequestException('检测天数和阈值必须为非负整数');
if (CARRIERS.some((carrier) => Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) < 1 || Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) > 365)) {
throw new BadRequestException('检测天数必须在1至365天之间');
}
const targetId = data.targetId?.trim() || null;
const targetKey = targetId ?? '';
const existing = await this.prisma.signatureRetirementRule.findUnique({ where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } } });
const rule = await this.prisma.signatureRetirementRule.upsert({
where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } },
create: { ...data, targetId, targetKey, createdById: operatorId },
update: { ...data, targetId, targetKey, version: { increment: 1 } },
});
await this.prisma.operationLog.create({ data: { userId: operatorId, action: existing ? 'signature_retirement.rule_updated' : 'signature_retirement.rule_created', resource: 'signature_retirement_rule', resourceId: rule.id, detail: { ruleType: rule.ruleType, targetId, version: rule.version } as Prisma.InputJsonValue } });
return rule;
}
async createWebhook(data: CreateRetirementWebhookDto) {
if (!data.name?.trim()) throw new BadRequestException('Webhook名称不能为空');
if (!['wecom', 'feishu'].includes(data.platform)) throw new BadRequestException('仅支持企业微信或飞书');
await assertSafeWebhookUrl(data.url);
return this.prisma.signatureRetirementWebhook.create({
data: { name: data.name.trim(), platform: data.platform, urlEncrypted: encryptSecret(data.url.trim()), urlMasked: maskWebhookUrl(data.url.trim()) },
});
}
async deleteWebhook(id: string) {
const webhook = await this.prisma.signatureRetirementWebhook.findUnique({ where: { id } });
if (!webhook) throw new NotFoundException('Webhook不存在');
return this.prisma.signatureRetirementWebhook.update({ where: { id }, data: { status: 'deleted' } });
}
async listMessages(query: RetirementMessageQuery) {
const page = Math.max(1, Math.floor(query.page || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(query.pageSize || 10)));
const range = shanghaiDateRange(query.dateFrom || shanghaiDateKey(), query.dateTo || query.dateFrom || shanghaiDateKey());
const dimensionType = query.dimensionType && query.dimensionType !== 'all' ? query.dimensionType : null;
if (dimensionType && !['enterprise', 'channel'].includes(dimensionType)) throw new BadRequestException('不支持的预警类型');
const tenantId = query.tenantId?.trim() || null;
const applicationId = query.applicationId?.trim() || null;
const signatureKeyword = query.signatureKeyword?.trim() || null;
const signaturePattern = signatureKeyword ? `%${signatureKeyword}%` : null;
const channelId = query.channelId?.trim() || null;
const messageRows = await this.prisma.$queryRaw<Array<{ id: string; totalCount: number }>>(Prisma.sql`
SELECT message.id, COUNT(*) OVER()::integer AS "totalCount"
FROM "SignatureRetirementMessage" message
JOIN "SignatureRetirementDetection" detection ON detection.id = message."detectionId"
JOIN "SmsSignature" signature ON signature.id = detection."signatureId"
LEFT JOIN "SmsApplication" application ON application.id = detection."applicationId"
WHERE message."createdAt" >= ${range?.gte}
AND message."createdAt" <= ${range?.lte}
AND (${dimensionType}::text IS NULL OR detection."dimensionType" = ${dimensionType})
AND (${tenantId}::text IS NULL OR detection."tenantId" = ${tenantId})
AND (${applicationId}::text IS NULL OR application.id = ${applicationId})
AND (${signatureKeyword}::text IS NULL OR signature.name ILIKE ${signaturePattern})
AND (${channelId}::text IS NULL OR detection."channelId" = ${channelId})
ORDER BY message."createdAt" DESC, message.id DESC
LIMIT ${pageSize}
OFFSET ${(page - 1) * pageSize}
`);
const orderedIds = messageRows.map((item) => item.id);
const unorderedItems = orderedIds.length ? await this.prisma.signatureRetirementMessage.findMany({ where: { id: { in: orderedIds } } }) : [];
const itemMap = new Map(unorderedItems.map((item) => [item.id, item]));
const items = orderedIds.flatMap((id) => itemMap.has(id) ? [itemMap.get(id)!] : []);
const total = messageRows[0]?.totalCount ?? 0;
const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { id: { in: items.map((item) => item.detectionId) } } });
const detectionMap = new Map(detections.map((item) => [item.id, item]));
const [signatures, channels, tenants, applications] = await Promise.all([
this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }),
this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }),
this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }),
this.prisma.smsApplication.findMany({ where: { id: { in: detections.flatMap((item) => item.applicationId ? [item.applicationId] : []) } }, select: { id: true, name: true } }),
]);
const signatureMap = new Map(signatures.map((item) => [item.id, item.name]));
const channelMap = new Map(channels.map((item) => [item.id, item.name]));
const tenantMap = new Map(tenants.map((item) => [item.id, item.name]));
const applicationMap = new Map(applications.map((item) => [item.id, item.name]));
return {
items: items.map((item) => {
const detection = detectionMap.get(item.detectionId);
return { ...item, detection, signatureName: detection ? signatureMap.get(detection.signatureId) : undefined, channelName: detection?.channelId ? channelMap.get(detection.channelId) : undefined, tenantName: detection ? tenantMap.get(detection.tenantId) : undefined, applicationName: detection?.applicationId ? applicationMap.get(detection.applicationId) : undefined };
}),
total,
page,
pageSize,
};
}
async unreadCount() {
const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey());
const count = await this.prisma.signatureRetirementMessage.count({ where: { createdAt: range, isRead: false, suppressed: false } });
return { count };
}
async markRead(id: string) {
return this.prisma.signatureRetirementMessage.update({ where: { id }, data: { isRead: true, readAt: new Date() } });
}
async markAllTodayRead() {
const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey());
const result = await this.prisma.signatureRetirementMessage.updateMany({ where: { createdAt: range, isRead: false }, data: { isRead: true, readAt: new Date() } });
return { count: result.count };
}
async suppressMessage(id: string, data: SuppressRetirementMessageDto, operatorId?: string) {
const message = await this.prisma.signatureRetirementMessage.findUnique({ where: { id } });
if (!message) throw new NotFoundException('预警消息不存在');
const newerMessage = await this.prisma.signatureRetirementMessage.findFirst({ where: { cycleId: message.cycleId, createdAt: { gt: message.createdAt } }, select: { id: true } });
if (newerMessage) throw new BadRequestException('只能从当前预警周期的最新消息设置抑制');
const detection = await this.prisma.signatureRetirementDetection.findUnique({ where: { id: message.detectionId } });
if (!detection) throw new NotFoundException('预警检测记录不存在');
if (!['temporary', 'permanent'].includes(data.mode)) throw new BadRequestException('不支持的抑制类型');
if (!data.reason?.trim()) throw new BadRequestException('抑制原因不能为空');
const days = data.mode === 'temporary' ? Math.floor(Number(data.days)) : undefined;
if (data.mode === 'temporary' && (!days || days < 1 || days > 3650)) throw new BadRequestException('临时抑制天数必须在1至3650之间');
const muteUntil = days ? databaseDate(addDays(shanghaiDateKey(), days)) : null;
const suppression = await this.prisma.signatureRetirementSuppression.upsert({
where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelKey: detection.channelKey, carrier: detection.carrier } },
create: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelId: detection.channelId, channelKey: detection.channelKey, carrier: detection.carrier, mode: data.mode, muteUntil, reason: data.reason?.trim(), operatorId },
update: { channelId: detection.channelId, mode: data.mode, muteUntil, active: true, reason: data.reason?.trim(), operatorId, cancelledAt: null, cancelledById: null, cancelReason: null },
});
await Promise.all([
this.prisma.signatureRetirementMessage.update({ where: { id }, data: { suppressed: true } }),
this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppressed', resource: 'signature_retirement_suppression', resourceId: suppression.id, detail: { mode: data.mode, days, reason: data.reason } as Prisma.InputJsonValue } }),
]);
return suppression;
}
listSuppressions() {
return this.prisma.signatureRetirementSuppression.findMany({
where: { active: true, OR: [{ mode: 'permanent' }, { muteUntil: { gte: databaseDate(shanghaiDateKey()) } }] },
orderBy: { updatedAt: 'desc' },
});
}
async cancelSuppression(id: string, data: CancelRetirementSuppressionDto, operatorId?: string) {
if (!data.reason?.trim()) throw new BadRequestException('取消抑制原因不能为空');
const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { id } });
if (!suppression) throw new NotFoundException('抑制记录不存在');
const updated = await this.prisma.signatureRetirementSuppression.update({ where: { id }, data: { active: false, cancelledAt: new Date(), cancelledById: operatorId, cancelReason: data.reason.trim() } });
await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppression_cancelled', resource: 'signature_retirement_suppression', resourceId: id, detail: { reason: data.reason.trim() } as Prisma.InputJsonValue } });
return updated;
}
async heatmap(date?: string) {
const endKey = assertDateKey(date || shanghaiDateKey());
const startKey = addDays(endKey, -30);
const detections = await this.prisma.signatureRetirementDetection.findMany({
where: { detectionDate: { gte: databaseDate(startKey), lt: databaseDate(endKey) } },
orderBy: [{ dimensionType: 'asc' }, { signatureId: 'asc' }, { channelKey: 'asc' }, { carrier: 'asc' }, { detectionDate: 'desc' }],
});
const [signatures, channels, tenants, approvedTasks] = await Promise.all([
this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }),
this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }),
this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }),
this.prisma.channelSignatureReportTask.findMany({
where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } },
include: { signature: { include: { tenant: true, application: true } }, channel: true },
}),
]);
const dimensionMap = new Map<string, { dimensionType: 'enterprise' | 'channel'; signatureId: string; channelId: string | null; carrier: string; approvedAt: Date; signatureName: string; channelName: string | null; tenantName: string; applicationName: string | null }>();
for (const task of approvedTasks) {
if (!task.carrier || !task.approvedAt) continue;
const channelDimension = { dimensionType: 'channel' as const, signatureId: task.signatureId, channelId: task.channelId, carrier: task.carrier, approvedAt: task.approvedAt, signatureName: task.signature.name, channelName: task.channel.name, tenantName: task.signature.tenant.name, applicationName: task.signature.application?.name ?? null };
dimensionMap.set(`channel:${task.signatureId}:${task.channelId}:${task.carrier}`, channelDimension);
const enterpriseKey = `enterprise:${task.signatureId}::${task.carrier}`;
const current = dimensionMap.get(enterpriseKey);
if (!current || task.approvedAt < current.approvedAt) dimensionMap.set(enterpriseKey, { ...channelDimension, dimensionType: 'enterprise', channelId: null, channelName: null });
}
return {
date: endKey,
dimensions: [...dimensionMap.values()],
items: detections.map((item) => ({ ...item, signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name })),
};
}
async unreportedSignatures(query: UnreportedSignatureQuery) {
const date = assertDateKey(query.date || shanghaiDateKey());
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
const keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null;
const rows = await this.prisma.$queryRaw<Array<{
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
applicationId: string | null;
applicationName: string | null;
messageCount: number;
rowCount: number;
}>>(Prisma.sql`
WITH unreported AS (
SELECT
signature.id AS signature_id,
signature.name AS signature_name,
tenant.id AS tenant_id,
tenant.name AS tenant_name,
application.id AS application_id,
application.name AS application_name,
COUNT(*)::integer AS message_count
FROM "SmsMessageRecord" message
JOIN "SmsSignature" signature ON signature.id = message."signatureId"
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"
WHERE message."queuedAt" >= ${shanghaiStart(date)}
AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))}
AND NOT EXISTS (
SELECT 1
FROM "ChannelSignatureReportTask" report
JOIN "SmsChannel" channel ON channel.id = report."channelId"
WHERE report."signatureId" = message."signatureId"
AND report."reportType" = 'signature'
AND report.status = 'approved'
AND channel.status <> 'deleted'
AND (
report."approvalScope" = 'legacy_channel'
OR (
report."approvalScope" = 'carrier_specific'
AND report.carrier = CASE
WHEN LOWER(COALESCE(message.carrier, '')) IN ('mobile', 'cmcc', '移动', '中国移动') THEN 'mobile'
WHEN LOWER(COALESCE(message.carrier, '')) IN ('unicom', 'cucc', '联通', '中国联通') THEN 'unicom'
WHEN LOWER(COALESCE(message.carrier, '')) IN ('telecom', 'ctcc', '电信', '中国电信') THEN 'telecom'
ELSE '__unknown__'
END
)
)
)
AND (
${keyword}::text IS NULL
OR signature.name ILIKE ${keywordPattern}
OR tenant.name ILIKE ${keywordPattern}
OR application.name ILIKE ${keywordPattern}
)
GROUP BY signature.id, signature.name, tenant.id, tenant.name, application.id, application.name
)
SELECT
signature_id AS "signatureId",
signature_name AS "signatureName",
tenant_id AS "tenantId",
tenant_name AS "tenantName",
application_id AS "applicationId",
application_name AS "applicationName",
message_count AS "messageCount",
COUNT(*) OVER()::integer AS "rowCount"
FROM unreported
ORDER BY message_count DESC, signature_name, application_name NULLS LAST
LIMIT ${pageSize}
OFFSET ${(page - 1) * pageSize}
`);
return {
date,
items: rows.map(({ rowCount: _rowCount, ...item }) => item),
total: rows[0]?.rowCount ?? 0,
page,
pageSize,
};
}
async runDetection(date?: string) {
const detectionKey = assertDateKey(date || shanghaiDateKey());
await this.prisma.signatureRetirementSuppression.updateMany({
where: { active: true, mode: 'temporary', muteUntil: { lt: databaseDate(detectionKey) } },
data: { active: false },
});
const [rules, approvedTasks] = await Promise.all([
this.prisma.signatureRetirementRule.findMany({ where: { enabled: true } }),
this.prisma.channelSignatureReportTask.findMany({
where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } },
include: { signature: { include: { tenant: true, application: true } }, channel: true },
}),
]);
const dimensions = this.buildDimensions(rules, approvedTasks);
let alerted = 0;
let healthy = 0;
let ineligible = 0;
for (const dimension of dimensions) {
const { windowDays, threshold } = carrierRule(dimension.rule, dimension.carrier);
const windowStartKey = addDays(detectionKey, -windowDays);
const windowStart = shanghaiStart(windowStartKey);
if (dimension.approvedAt > windowStart) {
ineligible += 1;
continue;
}
const counts = await this.activityCounts(dimension, windowStart, shanghaiStart(detectionKey));
const isAlert = counts.acceptedBusinessCount < threshold;
await this.persistDetection(detectionKey, dimension, windowDays, threshold, counts, isAlert);
if (isAlert) alerted += 1;
else healthy += 1;
}
return { detectionDate: detectionKey, dimensions: dimensions.length, alerted, healthy, ineligible };
}
async publishNotifications(date?: string) {
const notificationKey = assertDateKey(date || shanghaiDateKey());
const detections = await this.prisma.signatureRetirementDetection.findMany({
where: {
detectionDate: databaseDate(notificationKey), status: 'alert', suppressed: false,
cycleId: { not: null }, notificationTitle: { not: null }, notificationContent: { not: null },
},
});
let created = 0;
for (const detection of detections) {
if (!detection.cycleId || !detection.notificationTitle || !detection.notificationContent) continue;
try {
await this.prisma.signatureRetirementMessage.create({
data: { detectionId: detection.id, cycleId: detection.cycleId, tenantId: detection.tenantId, title: detection.notificationTitle, content: detection.notificationContent },
});
created += 1;
} catch (error) {
// 多实例08:00并发发布时,检测ID唯一键保证只产生一条站内消息。
if (!isPrismaUniqueError(error)) throw error;
}
}
await this.enqueueWebhookSummaries(notificationKey);
return { notificationDate: notificationKey, created };
}
async listLegacyReportTasks() {
return this.prisma.channelSignatureReportTask.findMany({
where: { reportType: 'signature', carrier: null, approvalScope: 'legacy_channel', signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } },
include: { signature: { include: { tenant: true, application: true } }, channel: true, records: { orderBy: { createdAt: 'desc' }, take: 5 } },
orderBy: { createdAt: 'desc' },
});
}
async confirmLegacyReport(id: string, data: ConfirmLegacyReportDto, operatorId?: string) {
if (!data.results?.length) throw new BadRequestException('至少确认一个运营商结果');
const carriers = new Set<string>(data.results.map((item) => item.carrier));
if (carriers.size !== data.results.length) throw new BadRequestException('运营商结果不能重复');
for (const result of data.results) {
if (!REPORT_STATUSES.has(result.status)) throw new BadRequestException('报备状态无效');
// 历史通道级通过时间不能代替运营商通过时间,否则仍是在自动伪造运营商事实。
if (result.status === 'approved' && !parseApprovedAt(result.approvedAt)) throw new BadRequestException(`${carrierLabels[result.carrier]}通过时间必填且必须有效`);
}
return this.prisma.$transaction(async (tx) => {
const legacy = await tx.channelSignatureReportTask.findUnique({ where: { id }, include: { channel: true } });
if (!legacy || legacy.reportType !== 'signature' || legacy.carrier !== null || legacy.approvalScope !== 'legacy_channel') throw new NotFoundException('历史通道级任务不存在');
const supported = normalizeChannelCarriers(legacy.channel.carriers, legacy.channel.carrier);
const resultTasks = [];
for (const result of data.results) {
if (!supported.includes(result.carrier)) throw new BadRequestException('确认运营商不在通道支持范围内');
const approvedAt = result.status === 'approved' ? parseApprovedAt(result.approvedAt) : null;
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: legacy.signatureId, channelId: legacy.channelId, carrier: result.carrier, reportType: 'signature', drainageItemId: null } });
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: result.status, approvedAt, reason: data.reason, approvalScope: 'carrier_specific' } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: legacy.tenantId, signatureId: legacy.signatureId, channelId: legacy.channelId, carrier: result.carrier, reportType: 'signature', status: result.status, approvedAt, reason: data.reason, approvalScope: 'carrier_specific', createdById: operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'legacy_carrier_confirmed', statusBefore: existing?.status, statusAfter: result.status, reason: data.reason, operatorId, sourceEntry: 'report_task' } });
resultTasks.push(task);
}
await tx.channelSignatureReportRecord.create({ data: { taskId: legacy.id, channelId: legacy.channelId, action: 'legacy_scope_split', statusBefore: legacy.status, statusAfter: legacy.status, reason: data.reason, operatorId, sourceEntry: 'report_task' } });
if (supported.every((carrier) => carriers.has(carrier))) {
// 全部适用运营商均已人工确认后,旧通道级事实退出发送链兼容读取,避免长期双口径。
await tx.channelSignatureReportTask.update({ where: { id: legacy.id }, data: { approvalScope: 'legacy_split' } });
}
await tx.operationLog.create({ data: { userId: operatorId, action: 'signature_report.legacy_carriers_confirmed', resource: 'channel_signature_report_task', resourceId: legacy.id, detail: { results: data.results, reason: data.reason } as Prisma.InputJsonValue } });
return { legacyTaskId: legacy.id, tasks: resultTasks };
});
}
private async runStartupCompensation() {
const now = new Date();
const hour = shanghaiHour(now);
try {
if (hour >= 4) await this.runDetection(shanghaiDateKey(now));
if (hour >= 8) {
await this.publishNotifications(shanghaiDateKey(now));
await this.deliverPendingWebhooks();
}
} catch (error) {
this.logger.error(`Signature retirement startup compensation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
private scheduleDetection() {
this.detectionTimer = setTimeout(() => {
void this.runDetection(shanghaiDateKey())
.catch((error) => this.logger.error(`Signature retirement 04:00 detection failed: ${error instanceof Error ? error.message : String(error)}`))
.finally(() => this.scheduleDetection());
}, millisecondsUntilShanghaiHour(new Date(), 4));
this.detectionTimer.unref?.();
}
private scheduleNotification() {
this.notificationTimer = setTimeout(() => {
void this.publishNotifications(shanghaiDateKey())
.then(() => this.deliverPendingWebhooks())
.catch((error) => this.logger.error(`Signature retirement 08:00 notification failed: ${error instanceof Error ? error.message : String(error)}`))
.finally(() => this.scheduleNotification());
}, millisecondsUntilShanghaiHour(new Date(), 8));
this.notificationTimer.unref?.();
}
private buildDimensions(rules: Array<NonNullable<RuleRecord>>, tasks: Array<{ signatureId: string; channelId: string; carrier: string | null; approvedAt: Date | null; signature: { tenantId: string; applicationId: string | null; name: string; tenant: { name: string } }; channel: { name: string } }>) {
const dimensions: DetectionDimension[] = [];
const enterprise = new Map<string, DetectionDimension>();
for (const task of tasks) {
if (!task.carrier || !task.approvedAt) continue;
const channelRule = selectRule(rules, 'channel', task.channelId);
if (channelRule) dimensions.push({ dimensionType: 'channel', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: task.channelId, channelName: task.channel.name, carrier: task.carrier, approvedAt: task.approvedAt, rule: channelRule });
const enterpriseRule = selectRule(rules, 'enterprise', task.signature.applicationId);
if (!enterpriseRule) continue;
const key = `${task.signatureId}:${task.carrier}`;
const current = enterprise.get(key);
if (!current || task.approvedAt < current.approvedAt) enterprise.set(key, { dimensionType: 'enterprise', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: null, channelName: null, carrier: task.carrier, approvedAt: task.approvedAt, rule: enterpriseRule });
}
return [...enterprise.values(), ...dimensions];
}
private async activityCounts(dimension: DetectionDimension, startAt: Date, endAt: Date): Promise<ActivityCounts> {
const channelFilter = dimension.channelId ? Prisma.sql`AND submit."channelId" = ${dimension.channelId}` : Prisma.empty;
const rows = await this.prisma.$queryRaw<Array<ActivityCounts>>(Prisma.sql`
WITH attempts AS (
SELECT
submit.id,
submit."messageRecordId" AS message_id,
submit."submitStatus" AS submit_status,
CASE
WHEN EXISTS (SELECT 1 FROM "SmsMessageSegmentAudit" segment WHERE segment."submitRecordId" = submit.id)
THEN NOT EXISTS (
SELECT 1 FROM "SmsMessageSegmentAudit" segment
WHERE segment."submitRecordId" = submit.id AND segment."receiptStatus" IS DISTINCT FROM 'delivered'
)
ELSE EXISTS (
SELECT 1 FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
)
END AS delivery_success
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
WHERE message."signatureId" = ${dimension.signatureId}
AND message.carrier = ${dimension.carrier}
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${endAt}
${channelFilter}
)
SELECT
COUNT(id)::integer AS "submittedAttempts",
COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedBusinessCount",
COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted' AND delivery_success)::integer AS "deliveredBusinessCount"
FROM attempts
`);
return rows[0] ?? { submittedAttempts: 0, acceptedBusinessCount: 0, deliveredBusinessCount: 0 };
}
private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean) {
const detectionDate = databaseDate(dateKey);
const channelKey = dimension.channelId ?? '';
const existingDetection = await this.prisma.signatureRetirementDetection.findUnique({
where: { detectionDate_dimensionType_signatureId_channelKey_carrier: { detectionDate, dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } },
select: { id: true },
});
// 同一检测日的结果冻结规则版本;规则变更只在下一检测日生效。
if (existingDetection) return;
const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } } });
const suppressed = Boolean(suppression?.active && (suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= detectionDate));
let cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } });
if (isAlert) {
if (!cycle) {
try {
cycle = await this.prisma.signatureRetirementCycle.create({ data: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, startedOn: detectionDate, lastDetectedOn: detectionDate } });
} catch (error) {
if (!isPrismaUniqueError(error)) throw error;
cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } });
}
} else {
cycle = await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { lastDetectedOn: detectionDate } });
}
} else if (cycle) {
await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { status: 'resolved', resolvedOn: detectionDate, lastDetectedOn: detectionDate } });
cycle = null;
}
const notificationTitle = isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null;
const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, counts.acceptedBusinessCount) : null;
try {
await this.prisma.signatureRetirementDetection.create({
data: { detectionDate, dimensionType: dimension.dimensionType, tenantId: dimension.tenantId, applicationId: dimension.applicationId, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, windowDays, threshold, ...counts, approvedAt: dimension.approvedAt, ruleId: dimension.rule.id, ruleVersion: dimension.rule.version, status: isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent },
});
} catch (error) {
// 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。
if (isPrismaUniqueError(error)) return;
throw error;
}
}
private async enqueueWebhookSummaries(dateKey: string) {
const detectionDate = databaseDate(dateKey);
const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { detectionDate, status: 'alert', suppressed: false } });
if (!detections.length) return;
const [webhooks, messages] = await Promise.all([
this.prisma.signatureRetirementWebhook.findMany({ where: { status: 'active' } }),
this.prisma.signatureRetirementMessage.findMany({ where: { detectionId: { in: detections.map((item) => item.id) }, suppressed: false } }),
]);
const messageMap = new Map(messages.map((item) => [item.detectionId, item.content]));
const groups = new Map<string, string[]>();
for (const detection of detections) {
const key = detection.dimensionType === 'enterprise' ? `enterprise:${detection.tenantId}` : 'channel:all';
const values = groups.get(key) ?? [];
const content = messageMap.get(detection.id);
if (content) values.push(content);
groups.set(key, values);
}
for (const webhook of webhooks) {
for (const [groupKey, contents] of groups) {
await this.prisma.signatureRetirementWebhookDelivery.upsert({
where: { webhookId_detectionDate_groupKey: { webhookId: webhook.id, detectionDate, groupKey } },
create: { webhookId: webhook.id, detectionDate, groupKey, payload: { content: contents.join('\n') } },
update: {},
});
}
}
}
private async deliverPendingWebhooks() {
await this.prisma.signatureRetirementWebhookDelivery.updateMany({
where: { status: 'sending', updatedAt: { lt: new Date(Date.now() - 5 * 60_000) } },
data: { status: 'retrying', nextRetryAt: new Date() },
});
const deliveries = await this.prisma.signatureRetirementWebhookDelivery.findMany({ where: { status: { in: ['pending', 'retrying'] }, OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }] }, orderBy: { createdAt: 'asc' }, take: 20 });
for (const delivery of deliveries) {
const claimed = await this.prisma.signatureRetirementWebhookDelivery.updateMany({ where: { id: delivery.id, status: { in: ['pending', 'retrying'] }, attemptCount: delivery.attemptCount }, data: { status: 'sending', attemptCount: { increment: 1 } } });
if (!claimed.count) continue;
const webhook = await this.prisma.signatureRetirementWebhook.findUnique({ where: { id: delivery.webhookId } });
if (!webhook || webhook.status !== 'active') {
await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'failed', lastError: 'Webhook已停用' } });
continue;
}
try {
const url = decryptSecret(webhook.urlEncrypted);
await assertSafeWebhookUrl(url);
const content = String((delivery.payload as { content?: unknown }).content ?? '');
const body = webhook.platform === 'feishu' ? { msg_type: 'text', content: { text: content } } : { msgtype: 'text', text: { content } };
const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const responseBody = await response.json().catch(() => null) as { errcode?: number; code?: number } | null;
if ((typeof responseBody?.errcode === 'number' && responseBody.errcode !== 0) || (typeof responseBody?.code === 'number' && responseBody.code !== 0)) {
throw new Error(`Webhook业务响应失败:${responseBody.errcode ?? responseBody.code}`);
}
await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'delivered', deliveredAt: new Date(), lastHttpStatus: response.status, lastError: null } });
} catch (error) {
const attempts = delivery.attemptCount + 1;
await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: attempts >= 5 ? 'failed' : 'retrying', nextRetryAt: attempts >= 5 ? null : new Date(Date.now() + Math.min(60 * 60_000, 2 ** attempts * 60_000)), lastError: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500) } });
}
}
}
}
function selectRule(rules: Array<NonNullable<RuleRecord>>, dimension: 'enterprise' | 'channel', targetId: string | null) {
const specialType = dimension === 'enterprise' ? 'enterprise_application' : 'channel';
const globalType = dimension === 'enterprise' ? 'enterprise_global' : 'channel_global';
return (targetId ? rules.find((rule) => rule.ruleType === specialType && rule.targetId === targetId) : undefined)
?? rules.find((rule) => rule.ruleType === globalType && rule.targetKey === '');
}
function carrierRule(rule: NonNullable<RuleRecord>, carrier: string) {
if (carrier === 'mobile') return { windowDays: rule.mobileWindowDays, threshold: rule.mobileThreshold };
if (carrier === 'unicom') return { windowDays: rule.unicomWindowDays, threshold: rule.unicomThreshold };
return { windowDays: rule.telecomWindowDays, threshold: rule.telecomThreshold };
}
function renderMessage(template: string | null, dimension: DetectionDimension, windowDays: number, threshold: number, actual: number) {
const fallback = dimension.dimensionType === 'enterprise'
? '请通知 {enterprise}{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。'
: '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。';
return (template?.trim() || fallback)
.replaceAll('{enterprise}', dimension.tenantName)
.replaceAll('{signature}', dimension.signatureName)
.replaceAll('{channel}', dimension.channelName ?? '-')
.replaceAll('{carrier}', carrierLabels[dimension.carrier] ?? dimension.carrier)
.replaceAll('{days}', String(windowDays))
.replaceAll('{threshold}', String(threshold))
.replaceAll('{actual}', String(actual));
}
function shanghaiDateKey(date = new Date()) {
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
}
export function shanghaiHour(date = new Date()) {
return Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', hour12: false }).format(date));
}
export function millisecondsUntilShanghaiHour(now: Date, targetHour: number) {
const target = new Date(`${shanghaiDateKey(now)}T${String(targetHour).padStart(2, '0')}:00:00+08:00`);
if (target.getTime() <= now.getTime()) target.setUTCDate(target.getUTCDate() + 1);
return target.getTime() - now.getTime();
}
function assertDateKey(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(new Date(`${value}T00:00:00+08:00`).getTime())) throw new BadRequestException('日期格式必须为YYYY-MM-DD');
return value;
}
function addDays(value: string, days: number) {
const date = new Date(`${assertDateKey(value)}T12:00:00+08:00`);
return shanghaiDateKey(new Date(date.getTime() + days * DAY_MS));
}
function shanghaiStart(value: string) {
return new Date(`${assertDateKey(value)}T00:00:00+08:00`);
}
function databaseDate(value: string) {
return new Date(`${assertDateKey(value)}T00:00:00.000Z`);
}
function parseApprovedAt(value?: string) {
if (!value) return null;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) throw new BadRequestException('报备通过时间无效');
return parsed;
}
function assertRuleType(value: string): asserts value is RetirementRuleType {
if (!['enterprise_global', 'enterprise_application', 'channel_global', 'channel'].includes(value)) throw new BadRequestException('不支持的规则类型');
}
function positiveIntegerEnv(name: string, fallback: number) {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 ? value : fallback;
}
function positiveInteger(value: number | undefined, fallback: number) {
return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
}
function isPrismaUniqueError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
async function assertSafeWebhookUrl(value: string) {
let url: URL;
try { url = new URL(value); } catch { throw new BadRequestException('Webhook地址无效'); }
if (url.protocol !== 'https:') throw new BadRequestException('Webhook必须使用HTTPS');
if (url.username || url.password) throw new BadRequestException('Webhook地址不能包含用户名或密码');
if (url.hostname === 'localhost' || url.hostname.endsWith('.local')) throw new BadRequestException('Webhook地址不能指向本地网络');
const addresses = await lookup(url.hostname, { all: true }).catch(() => []);
if (!addresses.length) throw new BadRequestException('Webhook域名无法解析');
if (addresses.some((entry) => isPrivateAddress(entry.address))) throw new BadRequestException('Webhook地址不能指向内网');
}
function isPrivateAddress(address: string) {
const normalized = address.toLowerCase();
if (normalized === '::1' || normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (!match) return false;
const [a, b] = [Number(match[1]), Number(match[2])];
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
function maskWebhookUrl(value: string) {
const url = new URL(value);
const suffix = url.pathname.slice(-6);
return `${url.origin}/***${suffix}`;
}