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, CreateRetirementWebhookDto, RetirementMessageQuery, RetirementRuleType, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts'; const DAY_MS = 86_400_000; const CARRIERS = ['mobile', 'unicom', 'telecom'] as const; const DEFAULT_DELIVERY_INTERVAL_MS = 60_000; const carrierLabels: Record = { mobile: '移动', unicom: '联通', telecom: '电信' }; type RuleRecord = Awaited>; 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; }; 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; private notificationTimer?: ReturnType; private deliveryTimer?: ReturnType; 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.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>(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(); 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>(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 }; } 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>, 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(); 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 { const channelFilter = dimension.channelId ? Prisma.sql`AND submit."channelId" = ${dimension.channelId}` : Prisma.empty; const rows = await this.prisma.$queryRaw>(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(); 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>, 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, 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 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}`; }