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
+35 -5
View File
@@ -24,9 +24,9 @@ describe('ReportsService', () => {
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1' }]);
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
prisma.dailyProfitReport.count.mockResolvedValue(1);
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), refundCents: BigInt(100), costCents: BigInt(600), profitCents: BigInt(400) } });
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), costCents: BigInt(600), profitCents: BigInt(400) } });
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
prisma.dailyQualityReport.count.mockResolvedValue(1);
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
@@ -57,6 +57,19 @@ describe('ReportsService', () => {
expect(profitQueries).not.toContain('SUM(submit."costAmountCents")');
});
it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
);
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
expect(profitQueries).toContain('message."submitId" = submit."submitId"');
expect(profitQueries).not.toContain('"billingStatus"');
expect(profitQueries).not.toContain('billing.refund');
});
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
await expect(service.listReconciliation({
dateFrom: '2026-07-01',
@@ -74,9 +87,12 @@ describe('ReportsService', () => {
});
it('keeps application and channel profit filters separate', async () => {
await expect(service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' })).resolves.toEqual(expect.objectContaining({
const result = await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
expect(result).toEqual(expect.objectContaining({
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
}));
expect(result.summary).not.toHaveProperty('refundCents');
expect(result.items[0]).not.toHaveProperty('refundCents');
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
dimensionType: 'channel',
@@ -91,15 +107,29 @@ describe('ReportsService', () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, refundCents: null, costCents: null, profitCents: null },
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, costCents: null, profitCents: null },
});
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({
total: 0,
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, refundCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
}));
});
it('exports income without refund columns', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{
id: 'profit-export', reportDate: new Date('2026-07-14'), dimensionName: '应用A', tenantName: '示例企业',
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1,
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400),
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'),
}]);
const exported = await service.exportProfit({ dimensionType: 'application' });
expect(exported.content).toContain('收入金额(元)');
expect(exported.content).not.toContain('净消费金额(元)');
expect(exported.content).not.toContain('返还金额(元)');
});
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
+35 -31
View File
@@ -56,18 +56,19 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
async listProfit(query: ReportListQuery) {
const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = profitWhere(query);
const [items, total, aggregate] = await Promise.all([
const [storedItems, total, aggregate] = await Promise.all([
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyProfitReport.count({ where }),
this.prisma.dailyProfitReport.aggregate({
where,
_sum: { ...reportVolumeSumSelection, revenueCents: true, refundCents: true, costCents: true, profitCents: true },
_sum: { ...reportVolumeSumSelection, revenueCents: true, costCents: true, profitCents: true },
}),
]);
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item);
const summary = {
...volumeSummary(aggregate._sum),
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
refundCents: Number(aggregate._sum.refundCents ?? 0),
costCents: Number(aggregate._sum.costCents ?? 0),
profitCents: Number(aggregate._sum.profitCents ?? 0),
// 利润率必须用全量筛选结果的合计利润/合计收入重新计算,不能对每日百分比求和或简单平均。
@@ -100,7 +101,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
async exportProfit(query: ReportListQuery) {
const { dimensionType, where } = profitWhere(query);
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '收入金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
}
async exportQuality(query: ReportListQuery) {
@@ -169,13 +170,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId",
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
FROM "SmsBillingRecord"
GROUP BY "messageId"
), costs AS (
WITH costs AS (
SELECT
submit."messageRecordId",
SUM(submit."costUnitPrice" * CASE
@@ -230,18 +225,24 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
COALESCE(SUM(CASE WHEN NOT (COALESCE(message.status = 'delivered', false) OR COALESCE(message."receiptStatus" = 'delivered', false))
AND (COALESCE(message.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(message."receiptStatus" = 'undelivered', false))
THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(billing.revenue), 0)::bigint,
COALESCE(SUM(billing.refund), 0)::bigint,
-- 收入按每条最终成功短信的计费条数和发送时客户价快照计算,不能依赖随后可能变为 refunded 的账单状态。
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
0::bigint,
COALESCE(SUM(costs.cost), 0)::bigint,
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
(COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
CASE WHEN COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 /
SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsMessageRecord" message
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
JOIN "SmsApplication" application ON application.id = message."applicationId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
LEFT JOIN costs ON costs."messageRecordId" = message.id
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
@@ -249,13 +250,6 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
`);
await tx.$executeRaw(Prisma.sql`
WITH billing AS (
SELECT "messageId",
SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue,
SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund
FROM "SmsBillingRecord"
GROUP BY "messageId"
)
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
@@ -297,31 +291,41 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) OR submit."submitStatus" IN ('rejected', 'timeout')) THEN message."billingUnits" ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint,
-- 补发可能产生多次提交,收入只归属最终成功提交,避免同一短信在多个通道重复计收。
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0)::bigint,
0::bigint,
COALESCE(SUM(submit."costUnitPrice" * CASE
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
WHEN legacy_receipt.delivered THEN message."billingUnits"
ELSE 0
END), 0)::bigint,
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
WHEN legacy_receipt.delivered THEN message."billingUnits"
ELSE 0
END), 0))::bigint,
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END), 0) - COALESCE(SUM(submit."costUnitPrice" * CASE
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
WHEN legacy_receipt.delivered THEN message."billingUnits"
ELSE 0
END), 0)) * 10000.0 /
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
SUM(CASE WHEN message."submitId" = submit."submitId"
AND (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
THEN message."billingUnits" * message."unitPrice" ELSE 0 END))::integer END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN billing ON billing."messageId" = message."messageId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS audit_count,
@@ -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) {
// 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。