feat: 优化签名热力图与金额展示

This commit is contained in:
hectorzhao
2026-08-12 11:41:47 +08:00
parent e64b5e23fe
commit 0cd353450a
33 changed files with 344 additions and 121 deletions
@@ -58,7 +58,7 @@ describe('SignatureRetirementService dimensions', () => {
it('returns enterprise application metadata for heatmap hover and search', async () => {
const prisma = {
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([]) },
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([{ id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00Z'), signatureId: 'signature-1', channelId: null, tenantId: 'tenant-1' }]) },
smsSignature: { findMany: jest.fn().mockResolvedValue([]) },
smsChannel: { findMany: jest.fn().mockResolvedValue([]) },
tenant: { findMany: jest.fn().mockResolvedValue([]) },
@@ -76,6 +76,24 @@ describe('SignatureRetirementService dimensions', () => {
expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }),
expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }),
]));
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
});
it('persists daily observing snapshots without opening alert cycles', async () => {
const prisma = {
signatureRetirementSuppression: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), findUnique: jest.fn().mockResolvedValue(null) },
signatureRetirementRule: { findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]) },
channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]) },
signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'detection-1' }) },
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
$queryRaw: jest.fn().mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
};
const observingService = new SignatureRetirementService(prisma as never);
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({ detectionDate: '2026-08-10', dimensions: 2, alerted: 0, healthy: 0, ineligible: 2 });
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'observing', acceptedBusinessCount: 10, cycleId: undefined, notificationTitle: null, notificationContent: null }) });
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
});
it('maps the real unreported-signature aggregation to an independent page', async () => {
@@ -225,7 +225,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
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) } },
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([
@@ -249,7 +249,8 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
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 })),
// 检测在次日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 })),
};
}
@@ -357,13 +358,23 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
const { windowDays, threshold } = carrierRule(dimension.rule, dimension.carrier);
const windowStartKey = addDays(detectionKey, -windowDays);
const windowStart = shanghaiStart(windowStartKey);
if (dimension.approvedAt > windowStart) {
const activityStart = shanghaiStart(addDays(detectionKey, -1));
const activityEnd = shanghaiStart(detectionKey);
const effectiveActivityStart = dimension.approvedAt > activityStart ? dimension.approvedAt : activityStart;
if (effectiveActivityStart >= activityEnd) {
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);
const dailyCounts = await this.activityCounts(dimension, effectiveActivityStart, activityEnd);
if (dimension.approvedAt > windowStart) {
// 观察期只禁止预警,不能吞掉真实发送快照,否则热力图会错误显示无数据。
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, false, true);
ineligible += 1;
continue;
}
const windowCounts = await this.activityCounts(dimension, windowStart, activityEnd);
const isAlert = windowCounts.acceptedBusinessCount < threshold;
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, isAlert, false, windowCounts);
if (isAlert) alerted += 1;
else healthy += 1;
}
@@ -482,7 +493,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
return rows[0] ?? { submittedAttempts: 0, acceptedBusinessCount: 0, deliveredBusinessCount: 0 };
}
private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean) {
private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean, observing = false, alertCounts = counts) {
const detectionDate = databaseDate(dateKey);
const channelKey = dimension.channelId ?? '';
const existingDetection = await this.prisma.signatureRetirementDetection.findUnique({
@@ -494,7 +505,9 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
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 (observing) {
cycle = null;
} else 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 } });
@@ -510,10 +523,10 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
cycle = null;
}
const notificationTitle = isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null;
const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, counts.acceptedBusinessCount) : null;
const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, alertCounts.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 },
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: observing ? 'observing' : isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent },
});
} catch (error) {
// 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。