fix: 修复上行归属并实现签名质量日报优化

This commit is contained in:
hectorzhao
2026-09-17 11:12:58 +08:00
parent 4eb7b16d12
commit 572290308c
40 changed files with 2854 additions and 778 deletions
@@ -0,0 +1,400 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_signature_'));
process.env.DATABASE_URL = url.toString();
process.env.NODE_ENV = 'test';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SignatureAnalyticsService } = require('./dist/signature-analytics/signature-analytics.service');
const { SignatureAnalyticsRead } = require('./dist/signature-analytics/analytics-read');
const { OperationsQualityQueries } = require('./dist/operations/queries/quality.queries');
const { analyticsJob } = require('./dist/signature-analytics/analytics-job');
const { detectRetirement } = require('./dist/signature-analytics/retirement-batch');
const { resolveUplinkMatch } = require('./dist/send-chain/uplink-matching');
const { todayKey, addDays, startOfDay, databaseDay } = require('./dist/signature-analytics/analytics-date');
const db = new PrismaService(),
writer = new SignatureAnalyticsService(db),
reader = new SignatureAnalyticsRead(db);
const prefix = randomUUID().slice(0, 8),
T = todayKey(),
D = addDays(T, -1),
old = addDays(T, -5);
const pass = (name) => console.log('PASS', name);
const at = (date, seconds = 3600) => new Date(startOfDay(date).getTime() + seconds * 1000);
try {
const tenant = await db.tenant.create({ data: { name: `签名日报验收${prefix}`, code: prefix } });
const app = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '应用甲',
cmppAccount: prefix,
cmppEnterpriseCode: '000001',
secretHash: 'isolated-no-login',
interfaceEnabled: false,
},
});
const channel = await db.smsChannel.create({
data: {
name: '隔离通道',
code: prefix,
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
srcId: '10690000',
carriers: ['mobile'],
status: 'disabled',
},
});
const signature = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: app.id, name: `【验收${prefix}`, auditStatus: 'approved' },
});
await db.channelSignatureReportTask.create({
data: {
tenantId: tenant.id,
signatureId: signature.id,
channelId: channel.id,
reportType: 'signature',
carrier: 'mobile',
approvalScope: 'carrier_specific',
status: 'approved',
approvedAt: at(addDays(T, -60)),
createdAt: at(addDays(T, -60)),
},
});
async function message({
date = D,
submitDate = date,
units = 1,
segments = units,
delivered = true,
phone = '13800000001',
sig = signature.id,
content = signature.name + '隔离数据',
} = {}) {
const m = await db.smsMessageRecord.create({
data: {
messageId: randomUUID(),
tenantId: tenant.id,
applicationId: app.id,
signatureId: sig,
phoneNumber: phone,
content,
carrier: 'mobile',
queuedAt: at(date),
submittedAt: at(submitDate),
billingUnits: units,
status: delivered && segments === units ? 'delivered' : 'submitted',
submitStatus: 'accepted',
receiptStatus: delivered && segments === units ? 'delivered' : null,
deliveredAt: delivered && segments === units ? at(submitDate, 3602) : null,
},
});
const s = await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
tenantId: tenant.id,
channelId: channel.id,
submitId: randomUUID(),
submitStatus: 'accepted',
submittedAt: at(submitDate),
gatewayMessageId: randomUUID(),
createdAt: at(submitDate),
},
});
for (let index = 1; index <= segments; index++)
await db.smsMessageSegmentAudit.create({
data: {
messageRecordId: m.id,
submitRecordId: s.id,
channelId: channel.id,
submitId: s.submitId,
segmentTotal: units,
segmentIndex: index,
submitStatus: 'accepted',
receiptStatus: delivered ? 'delivered' : null,
deliveredAt: delivered ? at(submitDate, 3600 + index) : null,
},
});
return { m, s };
}
const complete = await message({ units: 3, phone: '13800000011' });
const partial = await message({ units: 3, segments: 2, phone: '13800000012' });
await message({ date: addDays(D, -1), submitDate: D, phone: '13800000013' });
await message({ sig: null, content: `【未登记${prefix}】隔离`, phone: '13800000014' });
await message({ date: T, phone: '13800000015' });
const oldMessage = await message({ date: old, phone: '13800000016' });
// A rerun uses an isolated database; do not reset or touch any real tenant environment.
await writer.generate(D);
const quality = await reader.quality({ date: D, keyword: prefix });
const item = quality.items.find((x) => x.signatureId === signature.id);
assert(item);
assert.equal(item.total, 2);
assert.equal(item.channelSubmitTotal, 3);
assert.equal(item.breakdowns[0].successCount, 2);
assert.equal(item.breakdowns[0].unknownCount, 1);
pass('日报业务/尝试归日分离、三段成功与缺片未知');
const activity = await reader.activity({ date: T, dimensionType: 'enterprise', signatureName: prefix });
assert.equal(activity.dimensions.length, 1);
assert.equal(activity.items.length, 1);
assert.equal(activity.items[0].acceptedBusinessCount, 3);
assert.equal(activity.items[0].deliveredBusinessCount, 2);
assert.equal(activity.coverage[0].date, D);
assert.equal(activity.complete, false);
pass('企业/通道服务端分页与D-1至D-30缺口元数据');
assert.equal((await reader.unreported({ date: D, keyword: prefix })).total, 1);
const live = await new OperationsQualityQueries(db).signatureQuality({ date: T, keyword: prefix });
assert.equal(live.dataSource, 'live');
assert.equal(live.items[0].total, 1);
pass('当天真实查询、历史只读日报、未报备日报');
await writer.generate(old, true);
const before = await reader.quality({ date: old, keyword: prefix });
await db.smsMessageRecord.update({
where: { id: oldMessage.m.id },
data: { status: 'failed', receiptStatus: 'undelivered' },
});
assert.equal((await writer.generate(old)).skipped, true);
assert.deepEqual((await reader.quality({ date: old, keyword: prefix })).items, before.items);
await assert.rejects(writer.generate(old, true), /覆盖/);
pass('T-4及更早冻结、显式补建不覆盖');
await db.signatureRetirementRule.create({
data: {
ruleType: 'enterprise_global',
targetKey: '',
mobileWindowDays: 7,
mobileThreshold: 100,
unicomWindowDays: 7,
unicomThreshold: 100,
telecomWindowDays: 7,
telecomThreshold: 100,
},
});
await db.smsSubmitRecord.create({
data: {
messageRecordId: oldMessage.m.id,
channelId: channel.id,
submitId: randomUUID(),
submitStatus: 'accepted',
submittedAt: at(addDays(old, 1)),
},
});
const detection = await detectRetirement(db, T);
const decision = await db.signatureRetirementDetection.findFirst({
where: { signatureId: signature.id, dimensionType: 'enterprise', detectionDate: databaseDay(T) },
});
assert(decision.notificationContent.includes('发送4条'));
assert(detection.alerted >= 1);
const count = await db.signatureRetirementDetection.count({ where: { signatureId: signature.id } });
assert.equal((await detectRetirement(db, T)).skipped, true);
assert.equal(await db.signatureRetirementDetection.count({ where: { signatureId: signature.id } }), count);
pass('批量退网窗口聚合、重复任务提前退出');
await message({ phone: '13800000021' });
await message({ phone: '13800000021' });
const matched = await resolveUplinkMatch(
db,
{ channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 7200).toISOString() },
channel,
);
assert.equal(matched.matchStatus, 'matched');
assert.equal(matched.applicationId, app.id);
assert.equal(matched.messageRecordId, undefined);
const beforeSending = await resolveUplinkMatch(
db,
{ channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 3000).toISOString() },
channel,
);
assert.equal(beforeSending.matchStatus, 'unmatched');
pass('上行同应用多短信仍归属唯一、未来下发不参与');
const channel2 = await db.smsChannel.create({
data: {
name: '隔离通道乙',
code: prefix + 'b',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
srcId: '10690001',
carriers: ['mobile'],
status: 'disabled',
},
});
const wrong = await resolveUplinkMatch(
db,
{
channelId: channel2.id,
messageId: complete.m.messageId,
phoneNumber: complete.m.phoneNumber,
destId: '1069000199',
receivedAt: at(D, 7200).toISOString(),
},
channel2,
);
assert.equal(wrong.matchStatus, 'unmatched');
assert.equal(wrong.messageId, undefined);
const app2 = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '应用乙',
cmppAccount: prefix + 'b',
cmppEnterpriseCode: '000002',
secretHash: 'isolated-no-login',
interfaceEnabled: false,
},
});
const other = await message({ phone: '13800000021' });
await db.smsMessageRecord.update({ where: { id: other.m.id }, data: { applicationId: app2.id } });
const multi = await resolveUplinkMatch(
db,
{ channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 7200).toISOString() },
channel,
);
assert.equal(multi.matchStatus, 'ambiguous');
assert.equal(multi.candidates.length, 2);
pass('错误messageId/其他通道拒绝归属、第三条不同应用不会被截断漏掉');
// Fault injection is confined to this disposable QA database.
const late = await db.smsMessageSegmentAudit.create({
data: {
messageRecordId: partial.m.id,
submitRecordId: partial.s.id,
channelId: channel.id,
submitId: partial.s.submitId,
segmentTotal: 3,
segmentIndex: 3,
submitStatus: 'accepted',
receiptStatus: 'delivered',
deliveredAt: at(D, 3610),
},
});
await db.signatureAnalyticsRun.update({
where: { scope_businessDate: { scope: 'daily', businessDate: databaseDay(D) } },
data: { refreshFor: databaseDay(D) },
});
const prior = quality.generationId;
await writer.generate(D);
const refreshed = await reader.quality({ date: D, keyword: prefix });
assert.notEqual(refreshed.generationId, prior);
assert.equal(refreshed.items[0].breakdowns[0].successCount, 6);
assert((await db.signatureQualityDaily.count({ where: { generationId: prior } })) > 0);
const secondDay = addDays(T, -2);
await message({ date: secondDay, units: 2 });
await message({ date: secondDay, units: 4 });
const legacy = await message({ date: secondDay, units: 4, segments: 0 });
await db.smsReceiptRecord.create({
data: {
receiptKey: randomUUID(),
messageRecordId: legacy.m.id,
messageId: legacy.m.messageId,
channelId: channel.id,
gatewayMessageId: legacy.s.gatewayMessageId,
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
deliveredAt: at(secondDay, 3605),
},
});
await writer.generate(secondDay);
const multiLength = await reader.quality({ date: secondDay, keyword: prefix });
assert.equal(multiLength.items[0].breakdowns[0].successCount, 3);
assert.equal(multiLength.items[0].breakdowns[0].averageArrivalMs, 3667);
const outOfRange = await reader.activity({ date: T, dimensionType: 'enterprise', signatureName: prefix, page: 99 });
assert.equal(outOfRange.total, 1);
assert.equal(outOfRange.dimensions.length, 0);
pass('两段/四段、历史无分段明确回执兼容、耗时加权、超出页码仍有正确总数');
await writer.generate(addDays(T, -3));
pass('最近三日生成、迟到长短信分段刷新、旧版本保留');
const failedScope = 'qa-failure-' + prefix,
rollbackGeneration = randomUUID();
await assert.rejects(
analyticsJob(db, failedScope, D, async (tx) => {
await tx.signatureAnalyticsGeneration.create({
data: { id: rollbackGeneration, businessDate: databaseDay(D), sourceAsOf: new Date() },
});
throw new Error('injected');
}),
/injected/,
);
assert.equal(await db.signatureAnalyticsGeneration.count({ where: { id: rollbackGeneration } }), 0);
const failed = await db.signatureAnalyticsRun.findUnique({
where: { scope_businessDate: { scope: failedScope, businessDate: databaseDay(D) } },
});
assert.equal(failed.state, 'retry_wait');
await db.signatureAnalyticsRun.update({ where: { id: failed.id }, data: { nextAttemptAt: new Date(0) } });
assert.equal((await analyticsJob(db, failedScope, D, async () => 42)).result, 42);
pass('中途失败原子回滚、持久重试与恢复');
const scope = 'qa-concurrent-' + prefix;
let release, entered;
const gate = new Promise((r) => (release = r)),
ready = new Promise((r) => (entered = r));
const owner = analyticsJob(db, scope, D, async () => {
entered();
await gate;
return 1;
});
await ready;
assert.equal((await analyticsJob(db, scope, D, async () => 2)).skipped, true);
release();
assert.equal((await owner).result, 1);
const fencedScope = 'qa-fenced-' + prefix,
fencedGeneration = randomUUID();
await assert.rejects(
analyticsJob(db, fencedScope, D, async (tx) => {
await tx.signatureAnalyticsGeneration.create({
data: { id: fencedGeneration, businessDate: databaseDay(D), sourceAsOf: new Date() },
});
await db.signatureAnalyticsRun.update({
where: { scope_businessDate: { scope: fencedScope, businessDate: databaseDay(D) } },
data: { fence: { increment: 1 }, owner: 'replacement' },
});
}),
);
assert.equal(await db.signatureAnalyticsGeneration.count({ where: { id: fencedGeneration } }), 0);
pass('双worker唯一认领、旧fence禁止发布并回滚候选版本');
const checkpointScope = 'qa-checkpoint-' + prefix;
await assert.rejects(
analyticsJob(
db,
checkpointScope,
D,
async () => {
throw new Error('checkpoint fault');
},
new Date(),
async () => ({ version: 1 }),
),
/checkpoint fault/,
);
await db.signatureAnalyticsRun.update({
where: { scope_businessDate: { scope: checkpointScope, businessDate: databaseDay(D) } },
data: { nextAttemptAt: new Date(0) },
});
const resumed = await analyticsJob(
db,
checkpointScope,
D,
async (_tx, _generation, checkpoint) => checkpoint,
new Date(),
async () => ({ version: 2 }),
);
assert.deepEqual(resumed.result, { version: 1 });
pass('重试沿用首轮冻结规则快照');
assert(late.id);
console.log(
JSON.stringify({
fixturePrefix: prefix,
tenantId: tenant.id,
applicationId: app.id,
signatureId: signature.id,
partial: partial.s.id,
complete: complete.s.id,
}),
);
} finally {
await db.onModuleDestroy();
}