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,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();
}
+143
View File
@@ -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();
}