689 lines
41 KiB
TypeScript
689 lines
41 KiB
TypeScript
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<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 };
|
||
}
|
||
|
||
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 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}`;
|
||
}
|