952 lines
39 KiB
TypeScript
952 lines
39 KiB
TypeScript
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<typeof setTimeout>;
|
|
private notificationTimer?: ReturnType<typeof setTimeout>;
|
|
private deliveryTimer?: ReturnType<typeof setInterval>;
|
|
private compensationRunning = false;
|
|
private publishedDate?: string;
|
|
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.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<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" 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<Array<{ count: number }>>(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<string, typeof detections>();
|
|
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}`;
|
|
}
|