fix: 修复上行归属并实现签名质量日报优化
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
// Offline operator entry point. No SMS, detection, notification or history overwrite is performed.
|
||||
import { createRequire } from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
|
||||
const url = new URL(process.env.DATABASE_URL || '');
|
||||
assert(
|
||||
args.host && args.host === url.hostname,
|
||||
'Supply --host matching DATABASE_URL and an explicitly authorized target',
|
||||
);
|
||||
assert(args.dates, 'Supply --dates=YYYY-MM-DD,YYYY-MM-DD; no implicit historical range');
|
||||
const dates = [...new Set(args.dates.split(','))];
|
||||
assert(dates.length <= 366, 'At most 366 explicitly selected days per invocation');
|
||||
process.env.NODE_ENV = 'test'; // Disable all automatic schedulers; this script only creates the report writer.
|
||||
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 { analyticsDate, databaseDay, todayKey } = require('./dist/signature-analytics/analytics-date');
|
||||
const db = new PrismaService();
|
||||
try {
|
||||
for (const day of dates) {
|
||||
analyticsDate(day);
|
||||
assert(day < todayKey(), 'Only complete natural days may be backfilled');
|
||||
const current = await db.signatureAnalyticsDay.findUnique({ where: { businessDate: databaseDay(day) } });
|
||||
assert(!current?.publishedGenerationId, `${day}: published report exists; overwriting is forbidden`);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
host: url.hostname,
|
||||
dates,
|
||||
execute: args.execute === 'yes',
|
||||
provenance: 'backfill-current-source',
|
||||
}),
|
||||
);
|
||||
if (args.execute === 'yes') {
|
||||
const writer = new SignatureAnalyticsService(db);
|
||||
for (const day of dates) console.log(JSON.stringify(await writer.generate(day, true)));
|
||||
}
|
||||
} finally {
|
||||
await db.onModuleDestroy();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import Module from 'node:module';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
||||
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
|
||||
assert(url.hostname === '127.0.0.1' && url.pathname.startsWith('/cmpp_qa_signature_'));
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
process.env.NODE_ENV = 'test';
|
||||
require('reflect-metadata');
|
||||
const { PrismaService } = require('./dist/prisma/prisma.service'),
|
||||
{ Prisma } = require('@prisma/client');
|
||||
const { activityCounts } = require('./dist/signature-analytics/analytics-aggregate');
|
||||
const { todayKey, addDays, startOfDay } = require('./dist/signature-analytics/analytics-date');
|
||||
const oldSource = execFileSync(
|
||||
'git',
|
||||
['show', '4eb7b16d122da14f921093716d4ca1ed390d9e4c:api/src/signature-retirement/signature-retirement.service.ts'],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
const compiled = require('typescript').transpileModule(oldSource, {
|
||||
compilerOptions: { module: 1, target: 9, experimentalDecorators: true, emitDecoratorMetadata: true },
|
||||
}).outputText;
|
||||
const oldModule = new Module(require.resolve('./dist/signature-retirement/signature-retirement.service'));
|
||||
oldModule.filename = require.resolve('./dist/signature-retirement/signature-retirement.service');
|
||||
oldModule.paths = require.resolve.paths('@prisma/client');
|
||||
oldModule._compile(compiled, oldModule.filename);
|
||||
const db = new PrismaService(),
|
||||
prefix = 'bench' + randomUUID().slice(0, 8),
|
||||
N = 300,
|
||||
K = 100,
|
||||
D = addDays(todayKey(), -1),
|
||||
start = startOfDay(D),
|
||||
end = startOfDay(todayKey());
|
||||
let last;
|
||||
const traced = new Proxy(db, {
|
||||
get(t, k) {
|
||||
if (k === '$queryRaw')
|
||||
return async (q) => {
|
||||
last = q;
|
||||
return db.$queryRaw(q);
|
||||
};
|
||||
const v = t[k];
|
||||
return typeof v === 'function' ? v.bind(t) : v;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const tenant = await db.tenant.create({ data: { name: prefix, code: prefix } }),
|
||||
app = await db.smsApplication.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: prefix,
|
||||
cmppAccount: prefix,
|
||||
cmppEnterpriseCode: '000001',
|
||||
secretHash: 'isolated',
|
||||
interfaceEnabled: false,
|
||||
},
|
||||
}),
|
||||
channel = await db.smsChannel.create({
|
||||
data: {
|
||||
name: prefix,
|
||||
code: prefix,
|
||||
srcId: '1069',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 1,
|
||||
account: 'isolated',
|
||||
passwordCipher: 'not-a-secret',
|
||||
carriers: ['mobile'],
|
||||
status: 'disabled',
|
||||
},
|
||||
});
|
||||
await db.$executeRaw`INSERT INTO "SmsSignature"(id,"tenantId","applicationId",name,"updatedAt") SELECT ${prefix}||n,${tenant.id},${app.id},${prefix}||n,NOW() FROM generate_series(1,${N}) n`;
|
||||
await db.$executeRaw`INSERT INTO "SmsMessageRecord"(id,"messageId","tenantId","applicationId","signatureId","phoneNumber",content,carrier,"queuedAt","billingUnits","updatedAt") SELECT ${prefix}||n||'-'||k,${prefix}||n||'-'||k,${tenant.id},${app.id},${prefix}||n,'13800000200','isolated','mobile',${start},1,NOW() FROM generate_series(1,${N}) n CROSS JOIN generate_series(1,${K}) k`;
|
||||
await db.$executeRaw`INSERT INTO "SmsSubmitRecord"(id,"messageRecordId","submitId","channelId","submitStatus","submittedAt","createdAt","updatedAt") SELECT id,id,id,${channel.id},'accepted',${start},${start},NOW() FROM "SmsMessageRecord" WHERE "tenantId"=${tenant.id}`;
|
||||
await db.$executeRawUnsafe('ANALYZE "SmsMessageRecord"');
|
||||
await db.$executeRawUnsafe('ANALYZE "SmsSubmitRecord"');
|
||||
const dims = Array.from({ length: N }, (_, i) =>
|
||||
['enterprise', 'channel'].map((type) => ({
|
||||
dimensionKey: JSON.stringify([type, prefix + (i + 1)]),
|
||||
dimensionType: type,
|
||||
tenantId: tenant.id,
|
||||
applicationId: app.id,
|
||||
signatureId: prefix + (i + 1),
|
||||
channelKey: type === 'channel' ? channel.id : '',
|
||||
channelId: type === 'channel' ? channel.id : null,
|
||||
carrier: 'mobile',
|
||||
approvedAt: startOfDay(addDays(D, -60)),
|
||||
signatureName: prefix + (i + 1),
|
||||
tenantName: prefix,
|
||||
applicationName: prefix,
|
||||
channelName: prefix,
|
||||
})),
|
||||
).flat();
|
||||
const old = new oldModule.exports.SignatureRetirementService(traced);
|
||||
const before = performance.now();
|
||||
for (const d of dims) {
|
||||
const r = await old.activityCounts(d, start, end);
|
||||
assert.equal(r.acceptedBusinessCount, K);
|
||||
}
|
||||
const oldMs = performance.now() - before;
|
||||
const sampleOld = last;
|
||||
const now = performance.now(),
|
||||
counts = await activityCounts(traced, dims, D),
|
||||
newMs = performance.now() - now,
|
||||
sampleNew = last;
|
||||
for (const d of dims) assert.equal(counts.get(d.dimensionKey).acceptedBusinessCount, K);
|
||||
const explain = async (q) =>
|
||||
(await db.$queryRaw(Prisma.sql`EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) ${q}`))[0]['QUERY PLAN'][0];
|
||||
const oldPlan = await explain(sampleOld),
|
||||
newPlan = await explain(sampleNew);
|
||||
const scanRows = (p) => {
|
||||
let sum = 0;
|
||||
const walk = (n) => {
|
||||
if (
|
||||
n['Node Type']?.includes('Scan') &&
|
||||
['SmsSubmitRecord', 'SmsMessageRecord', 'SmsReceiptRecord', 'SmsMessageSegmentAudit'].includes(
|
||||
n['Relation Name'],
|
||||
)
|
||||
)
|
||||
sum += (n['Actual Rows'] + (n['Rows Removed by Filter'] || 0)) * (n['Actual Loops'] || 0);
|
||||
for (const c of n.Plans || []) walk(c);
|
||||
};
|
||||
walk(p.Plan);
|
||||
return sum;
|
||||
};
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
messages: N * K,
|
||||
dimensions: dims.length,
|
||||
oldMs,
|
||||
newMs,
|
||||
reduction: 1 - newMs / oldMs,
|
||||
oldSampleRows: scanRows(oldPlan),
|
||||
estimatedOldRows: scanRows(oldPlan) * dims.length,
|
||||
newRows: scanRows(newPlan),
|
||||
note: 'warm cache, per-dimension sample extrapolation; not production CPU or whole job capacity',
|
||||
oldPlan,
|
||||
newPlan,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await db.onModuleDestroy();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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(['localhost', '127.0.0.1'].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 { completionDatabase } = require('./dist/send-chain/completion-context');
|
||||
const { SendDownstreamDeliveryService } = require('./dist/send-chain/send-downstream-delivery.service');
|
||||
const { OpenApiService } = require('./dist/open-api/open-api.service');
|
||||
const { resolveUplinkMatch } = require('./dist/send-chain/uplink-matching');
|
||||
const db = new PrismaService(),
|
||||
proxy = completionDatabase(db),
|
||||
prefix = randomUUID().slice(0, 8);
|
||||
function delivery(openApi) {
|
||||
let service;
|
||||
const facade = {
|
||||
resolveUplinkMatch: (event, channel) => resolveUplinkMatch(proxy, event, channel),
|
||||
queueAndTryDownstreamDelivery: (data) => service.queueAndTryDownstreamDelivery(data),
|
||||
postGatewayControl: () => {
|
||||
throw new Error('External delivery is forbidden in QA');
|
||||
},
|
||||
};
|
||||
service = new SendDownstreamDeliveryService(proxy, undefined, openApi, facade, {});
|
||||
return service;
|
||||
}
|
||||
try {
|
||||
const tenant = await db.tenant.create({ data: { name: '上行验收', code: prefix } });
|
||||
const apps = [];
|
||||
for (const suffix of ['a', 'b'])
|
||||
apps.push(
|
||||
await db.smsApplication.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: '应用' + suffix,
|
||||
cmppAccount: prefix + suffix,
|
||||
cmppEnterpriseCode: '000001',
|
||||
secretHash: 'isolated',
|
||||
interfaceEnabled: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const channel = await db.smsChannel.create({
|
||||
data: {
|
||||
name: '仅入库隔离通道',
|
||||
code: prefix,
|
||||
srcId: '1069',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 1,
|
||||
account: 'isolated',
|
||||
passwordCipher: 'not-a-secret',
|
||||
carriers: ['mobile'],
|
||||
status: 'disabled',
|
||||
},
|
||||
});
|
||||
const received = new Date(),
|
||||
sent = new Date(received.getTime() - 60_000);
|
||||
for (const [phone, index] of [
|
||||
['13800000101', 0],
|
||||
['13800000101', 0],
|
||||
['13800000102', 0],
|
||||
['13800000102', 1],
|
||||
]) {
|
||||
const m = await db.smsMessageRecord.create({
|
||||
data: {
|
||||
messageId: randomUUID(),
|
||||
tenantId: tenant.id,
|
||||
applicationId: apps[index].id,
|
||||
phoneNumber: phone,
|
||||
content: '隔离上行匹配测试',
|
||||
},
|
||||
});
|
||||
await db.smsSubmitRecord.create({
|
||||
data: {
|
||||
messageRecordId: m.id,
|
||||
channelId: channel.id,
|
||||
submitId: randomUUID(),
|
||||
submitStatus: 'accepted',
|
||||
submittedAt: sent,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const app of apps) {
|
||||
await db.smsApplicationHttpConfig.create({ data: { applicationId: app.id, enabled: true, sendEnabled: false } });
|
||||
await db.httpWebhookEndpoint.create({
|
||||
data: {
|
||||
applicationId: app.id,
|
||||
eventType: 'uplink',
|
||||
url: 'http://127.0.0.1:1/never-called',
|
||||
secretEncrypted: 'isolated-no-delivery',
|
||||
secretLast4: 'test',
|
||||
},
|
||||
});
|
||||
}
|
||||
const service = delivery(new OpenApiService(db, undefined));
|
||||
const event = {
|
||||
eventId: randomUUID(),
|
||||
channelId: channel.id,
|
||||
phoneNumber: '13800000101',
|
||||
destId: '10690001',
|
||||
content: 'TD',
|
||||
receivedAt: received.toISOString(),
|
||||
gatewayMessageId: 'supplier-mo-id',
|
||||
};
|
||||
const copies = await Promise.all([service.handleUplink(event), service.handleUplink(event)]);
|
||||
assert.equal(copies[0].id, copies[1].id);
|
||||
assert.equal(copies[0].messageId, null);
|
||||
assert.equal(copies[0].messageRecordId, null);
|
||||
assert.equal(copies[0].gatewayMessageId, 'supplier-mo-id');
|
||||
assert.equal(await db.cmppDownstreamDelivery.count({ where: { dedupeKey: 'uplink:' + copies[0].id } }), 1);
|
||||
assert.equal(await db.httpWebhookEvent.count({ where: { uplinkMessageId: copies[0].id } }), 1);
|
||||
console.log('PASS 重复事件原子入库、无唯一原短信不伪造编号、CMPP与HTTP通知意图各一份');
|
||||
const ambiguous = await service.handleUplink({ ...event, eventId: randomUUID(), phoneNumber: '13800000102' });
|
||||
assert.equal(ambiguous.matchStatus, 'ambiguous');
|
||||
const candidates = await db.smsUplinkMatchCandidate.findMany({ where: { uplinkMessageId: ambiguous.id } });
|
||||
assert.equal(candidates.length, 2);
|
||||
const claims = await Promise.allSettled(candidates.map((c) => service.claimUplinkMatchCandidate(ambiguous.id, c.id)));
|
||||
assert.equal(claims.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
assert.equal(await db.cmppDownstreamDelivery.count({ where: { dedupeKey: 'uplink:' + ambiguous.id } }), 1);
|
||||
assert.equal(
|
||||
await db.smsUplinkMatchCandidate.count({ where: { uplinkMessageId: ambiguous.id, status: 'claimed' } }),
|
||||
1,
|
||||
);
|
||||
console.log('PASS 并发认领仅一个应用成功、候选及通知同事务');
|
||||
const failedEvent = { ...event, eventId: randomUUID() };
|
||||
await assert.rejects(
|
||||
delivery({
|
||||
queueWebhookEvent: async () => {
|
||||
throw new Error('injected notification storage failure');
|
||||
},
|
||||
}).handleUplink(failedEvent),
|
||||
/injected/,
|
||||
);
|
||||
assert.equal(await db.smsUplinkMessage.count({ where: { eventId: failedEvent.eventId } }), 0);
|
||||
await service.handleUplink(failedEvent);
|
||||
assert.equal(await db.smsUplinkMessage.count({ where: { eventId: failedEvent.eventId } }), 1);
|
||||
console.log('PASS 通知存储故障回滚上行、重试恢复,不调用外部发送');
|
||||
} finally {
|
||||
await db.onModuleDestroy();
|
||||
}
|
||||
Reference in New Issue
Block a user