import { SignatureAnalyticsRead } from '../signature-analytics/analytics-read'; import { analyticsDate, analyticsPage, todayKey } from '../signature-analytics/analytics-date'; import { detectRetirement } from '../signature-analytics/retirement-batch'; 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 type { CancelRetirementSuppressionDto, CreateRetirementWebhookDto, RetirementMessageQuery, RetirementRuleType, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto, } from './signature-retirement.contracts'; const shanghaiDayFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }); const shanghaiHourFormatter = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', hour12: false, }); const DAY_MS = 86_400_000; const CARRIERS = ['mobile', 'unicom', 'telecom'] as const; const DEFAULT_DELIVERY_INTERVAL_MS = 60_000; @Injectable() export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(SignatureRetirementService.name); private detectionTimer?: ReturnType; private notificationTimer?: ReturnType; private deliveryTimer?: ReturnType; private compensationRunning = false; private publishedDate?: string; private startupTimer?: ReturnType; 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.runStartupCompensation(), 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>(Prisma.sql` SELECT message.id, COUNT(*) OVER()::integer AS "totalCount" FROM "SignatureRetirementMessage" message JOIN "SignatureRetirementDetection" detection ON (detection.id = message."detectionId" OR detection.id = ANY(message."detectionIds")) 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 signature."auditStatus" <> 'deleted' 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}) GROUP BY message.id 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.flatMap((item) => (item.detectionIds?.length ? item.detectionIds : [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, detections: (item.detectionIds?.length ? item.detectionIds : [item.detectionId]).flatMap((id) => { const entry = detectionMap.get(id); return entry ? [ { ...entry, signatureName: signatureMap.get(entry.signatureId), channelName: entry.channelId ? channelMap.get(entry.channelId) : undefined, }, ] : []; }), 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 rows = await this.prisma.$queryRaw>(Prisma.sql` SELECT COUNT(DISTINCT message.id)::integer AS count FROM "SignatureRetirementMessage" message JOIN "SignatureRetirementDetection" detection ON (detection.id = message."detectionId" OR detection.id = ANY(message."detectionIds")) JOIN "SmsSignature" signature ON signature.id = detection."signatureId" WHERE message."createdAt" >= ${range?.gte} AND message."createdAt" <= ${range?.lte} AND message."isRead" = FALSE AND message.suppressed = FALSE AND signature."auditStatus" <> 'deleted' `); return { count: rows[0]?.count ?? 0 }; } 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('预警消息不存在'); if (message.dailyGroupKey) return this.suppressDailyMessage(message, data, operatorId); 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; } private async suppressDailyMessage( message: { id: string; tenantId: string; applicationId: string | null; notificationDate: Date | null; detectionIds: string[]; }, data: SuppressRetirementMessageDto, operatorId?: string, ) { if (!['temporary', 'permanent'].includes(data.mode) || !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; return this.prisma.$transaction(async (tx) => { const newer = await tx.signatureRetirementMessage.findFirst({ where: { tenantId: message.tenantId, applicationId: message.applicationId, notificationDate: { gt: message.notificationDate! }, }, select: { id: true }, }); if (newer) throw new BadRequestException('只能从该企业应用最新预警消息设置整组抑制'); const detections = await tx.signatureRetirementDetection.findMany({ where: { id: { in: message.detectionIds } }, }); if (detections.length !== message.detectionIds.length) throw new BadRequestException('预警明细不完整'); for (const detection of detections) { const dimension = { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelKey: detection.channelKey, carrier: detection.carrier, }; const values = { channelId: detection.channelId, mode: data.mode, muteUntil, reason: data.reason!.trim(), operatorId, active: true, cancelledAt: null, cancelledById: null, cancelReason: null, }; await tx.signatureRetirementSuppression.upsert({ where: { dimensionType_signatureId_channelKey_carrier: dimension }, create: { ...dimension, ...values }, update: values, }); } await tx.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.group_suppressed', resource: 'signature_retirement_message', resourceId: message.id, detail: { mode: data.mode, days, count: detections.length, reason: data.reason }, }, }); return tx.signatureRetirementMessage.update({ where: { id: message.id }, data: { suppressed: true } }); }); } 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: { gt: databaseDate(startKey), lte: 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()], // 检测在次日04:00运行,因此检测日对应的活动自然日固定为T-1。 items: detections.map((item) => ({ ...item, activityDate: addDays(shanghaiDateKey(item.detectionDate), -1), 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) { analyticsPage(query.page, query.pageSize); const date = analyticsDate(query.date); if (date !== todayKey()) return new SignatureAnalyticsRead(this.prisma).unreported({ ...query, date }); return this.prisma.$transaction( async (tx) => { await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'"); const data = await new SignatureRetirementService(tx as PrismaService).unreportedSignaturesLive({ ...query, date, }); return { ...data, dataSource: 'live', reportState: 'ready', frozen: false, sourceAsOf: new Date(), serverBusinessDate: date, }; }, { isolationLevel: 'RepeatableRead', timeout: 15000 }, ); } async unreportedSignaturesLive(query: UnreportedSignatureQuery) { const date = assertDateKey(query.date || shanghaiDateKey()); const page = positiveInteger(query.page, 1); const pageSize = Math.min(100, positiveInteger(query.pageSize, 25)); 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 extracted AS ( SELECT message."tenantId" AS tenant_id, message."applicationId" AS application_id, SUBSTRING(message.content FROM '^【[^【】]+】') AS signature_name FROM "SmsMessageRecord" message WHERE message."queuedAt" >= ${shanghaiStart(date)} AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))} AND message."signatureId" IS NULL ), unreported AS ( SELECT CONCAT('unregistered:', MD5(extracted.tenant_id || ':' || extracted.application_id || ':' || extracted.signature_name)) AS signature_id, extracted.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 extracted JOIN "Tenant" tenant ON tenant.id = extracted.tenant_id JOIN "SmsApplication" application ON application.id = extracted.application_id WHERE extracted.signature_name IS NOT NULL -- 未报备签名指系统签名库中不存在,而不是已有签名缺少某个通道的运营商报备。 AND NOT EXISTS ( SELECT 1 FROM "SmsSignature" signature WHERE signature."tenantId" = extracted.tenant_id AND signature."applicationId" = extracted.application_id AND signature.name = extracted.signature_name AND signature."auditStatus" <> 'deleted' ) AND ( ${keyword}::text IS NULL OR extracted.signature_name ILIKE ${keywordPattern} OR tenant.name ILIKE ${keywordPattern} OR application.name ILIKE ${keywordPattern} ) GROUP BY extracted.tenant_id, extracted.application_id, extracted.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 }) => { void _rowCount; return item; }), total: rows[0]?.rowCount ?? 0, page, pageSize, }; } async runDetection(date?: string) { return detectRetirement(this.prisma, analyticsDate(date)); } async publishNotifications(date?: string) { const notificationKey = analyticsDate(date); if (this.publishedDate === notificationKey) return { notificationDate: notificationKey, created: 0 }; const completed = await this.prisma.signatureAnalyticsRun.findUnique({ where: { scope_businessDate: { scope: 'retirement', businessDate: databaseDate(notificationKey) } }, }); if (completed?.state !== 'succeeded') throw new Error('签名退网检测尚未完整完成,暂不发布通知'); const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { detectionDate: databaseDate(notificationKey), status: 'alert', suppressed: false, cycleId: { not: null }, notificationTitle: { not: null }, notificationContent: { not: null }, }, }); const groups = new Map(); for (const detection of detections) { if (!detection.cycleId || !detection.notificationTitle || !detection.notificationContent) continue; const key = JSON.stringify([notificationKey, detection.tenantId, detection.applicationId ?? null]); const group = groups.get(key); if (group) group.push(detection); else groups.set(key, [detection]); } const applications = await this.prisma.smsApplication.findMany({ where: { id: { in: [...new Set(detections.flatMap((item) => (item.applicationId ? [item.applicationId] : [])))] }, }, select: { id: true, name: true }, }); const names = new Map(applications.map((item) => [item.id, item.name])); let created = 0; for (const [dailyGroupKey, group] of groups) { group.sort((a, b) => a.id.localeCompare(b.id)); const detection = group[0]; const ids = group.map((item) => item.id); // Do not regenerate days already published by the old version or alter frozen messages on reruns. const existing = await this.prisma.signatureRetirementMessage.findFirst({ where: { OR: [{ dailyGroupKey }, { detectionId: { in: ids } }] }, select: { id: true }, }); if (existing) continue; try { await this.prisma.signatureRetirementMessage.create({ data: { dailyGroupKey, notificationDate: databaseDate(notificationKey), applicationId: detection.applicationId, detectionId: detection.id, detectionIds: ids, cycleId: detection.cycleId!, tenantId: detection.tenantId, title: `${names.get(detection.applicationId ?? '') ?? '未绑定企业应用'} · 签名清退预警`, content: group.map((item) => item.notificationContent).join('\n'), }, }); created += 1; } catch (error) { // The daily application key enforces idempotency across processes. if (!isPrismaUniqueError(error)) throw error; } } await this.enqueueWebhookSummaries(notificationKey); this.publishedDate = notificationKey; return { notificationDate: notificationKey, created }; } private async runStartupCompensation() { if (this.compensationRunning) return; this.compensationRunning = true; 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)}`, ); } finally { this.compensationRunning = false; } } 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 async enqueueWebhookSummaries(dateKey: string) { const detectionDate = databaseDate(dateKey); const [webhooks, messages] = await Promise.all([ this.prisma.signatureRetirementWebhook.findMany({ where: { status: 'active' } }), this.prisma.signatureRetirementMessage.findMany({ where: { notificationDate: detectionDate, dailyGroupKey: { not: null }, suppressed: false }, }), ]); for (const webhook of webhooks) { for (const message of messages) { const groupKey = JSON.stringify(['application', message.tenantId, message.applicationId ?? null]); await this.prisma.signatureRetirementWebhookDelivery.upsert({ where: { webhookId_detectionDate_groupKey: { webhookId: webhook.id, detectionDate, groupKey } }, create: { webhookId: webhook.id, detectionDate, groupKey, payload: { content: `${message.title}\n${message.content}` }, }, 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 shanghaiDateKey(date = new Date()) { return shanghaiDayFormatter.format(date); } export function shanghaiHour(date = new Date()) { return Number(shanghaiHourFormatter.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 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}`; }