feat: add carrier-aware signature retirement alerts

This commit is contained in:
hectorzhao
2026-08-10 20:54:05 +08:00
parent 232d1c22a3
commit 55aa054005
52 changed files with 3074 additions and 152 deletions
@@ -0,0 +1,152 @@
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
import { BadRequestException } from '@nestjs/common';
describe('SignatureRetirementService dimensions', () => {
const service = new SignatureRetirementService({} as never);
it('builds enterprise dimensions once and channel dimensions per approved channel and carrier', () => {
const rules = [
rule('enterprise_global', ''),
rule('enterprise_application', 'app-1'),
rule('channel_global', ''),
rule('channel', 'channel-2'),
];
const tasks = [
task('channel-1', '移动一号', 'mobile', '2026-06-01T00:00:00Z'),
task('channel-2', '移动二号', 'mobile', '2026-06-05T00:00:00Z'),
task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'),
];
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }> }).buildDimensions(rules, tasks);
expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2);
expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3);
expect(dimensions.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')?.approvedAt.toISOString()).toBe('2026-06-01T00:00:00.000Z');
expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true);
expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel');
});
it('does not monitor legacy carrier-null reporting facts', () => {
const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }).buildDimensions(
[rule('enterprise_global', ''), rule('channel_global', '')],
[task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')],
);
expect(dimensions).toEqual([]);
});
it('rejects an approved legacy carrier confirmation without an explicit valid approval time', async () => {
await expect(service.confirmLegacyReport('legacy-1', {
results: [{ carrier: 'mobile', status: 'approved' }],
})).rejects.toBeInstanceOf(BadRequestException);
await expect(service.confirmLegacyReport('legacy-1', {
results: [{ carrier: 'mobile', status: 'approved', approvedAt: 'not-a-date' }],
})).rejects.toBeInstanceOf(BadRequestException);
});
it('schedules the next Beijing 04:00 and 08:00 precisely', () => {
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T19:00:00.000Z'), 4)).toBe(60 * 60_000);
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000);
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T23:30:00.000Z'), 8)).toBe(30 * 60_000);
});
it('publishes frozen alert content only in the notification phase', async () => {
const detection = {
id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1',
cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文',
};
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) },
signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) },
signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) },
signatureRetirementWebhookDelivery: { upsert: jest.fn() },
};
const notificationService = new SignatureRetirementService(prisma as never);
await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 });
expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) });
});
it('returns enterprise application metadata for heatmap hover and search', async () => {
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([]) },
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
tenant: { findMany: jest.fn().mockResolvedValue([]) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{
signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'),
signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } },
channel: { name: '移动通道' },
}]) },
};
const heatmapService = new SignatureRetirementService(prisma as never);
const result = await heatmapService.heatmap('2026-08-10');
expect(result.dimensions).toEqual(expect.arrayContaining([
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
]));
});
it('maps the real unreported-signature aggregation to an independent page', async () => {
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{
signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业',
applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3,
}]),
};
const unreportedService = new SignatureRetirementService(prisma as never);
await expect(unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 })).resolves.toEqual({
date: '2026-08-10',
items: [{ signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', applicationId: 'app-1', applicationName: '测试应用', messageCount: 7 }],
total: 3,
page: 2,
pageSize: 10,
});
});
it('returns a filtered historical message page with application metadata', async () => {
const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') };
const detection = { id: 'detection-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile' };
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]),
signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) },
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([detection]) },
smsSignature: { findMany: jest.fn().mockResolvedValue([{ id: 'signature-1', name: '测试签名' }]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', name: '测试通道' }]) },
tenant: { findMany: jest.fn().mockResolvedValue([{ id: 'tenant-1', name: '测试企业' }]) },
smsApplication: { findMany: jest.fn().mockResolvedValue([{ id: 'app-1', name: '测试应用' }]) },
};
const messageService = new SignatureRetirementService(prisma as never);
await expect(messageService.listMessages({ dateFrom: '2026-08-01', dateTo: '2026-08-10', tenantId: 'tenant-1', applicationId: 'app-1', signatureKeyword: '测试', channelId: 'channel-1', page: 2, pageSize: 10 })).resolves.toEqual({
items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })],
total: 21,
page: 2,
pageSize: 10,
});
});
it('requires a reason for temporary and permanent suppression', async () => {
const prisma = {
signatureRetirementMessage: {
findUnique: jest.fn().mockResolvedValue({ id: 'message-1', cycleId: 'cycle-1', detectionId: 'detection-1', createdAt: new Date() }),
findFirst: jest.fn().mockResolvedValue(null),
},
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
};
const suppressionService = new SignatureRetirementService(prisma as never);
await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空');
await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空');
});
});
function rule(ruleType: string, targetKey: string) {
return { id: `${ruleType}-${targetKey}`, ruleType, targetId: targetKey || null, targetKey, enabled: true, mobileWindowDays: 30, mobileThreshold: 1, unicomWindowDays: 30, unicomThreshold: 1, telecomWindowDays: 30, telecomThreshold: 1, messageTemplate: null, version: 1 };
}
function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) {
return { signatureId: 'signature-1', channelId, carrier, approvedAt: new Date(approvedAt), signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } }, channel: { name: channelName } };
}