fix: 修复上行归属并实现签名质量日报优化
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user