feat: filter SMS routes by channel sensitive words
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
// Only isolated local QA data. Never starts workers, publishes SMS, changes
|
||||
// live configuration, or fabricates supplier/customer delivery evidence.
|
||||
const require = createRequire(resolve('api/package.json'));
|
||||
for (const file of ['api/.env', '.env']) if (existsSync(file)) process.loadEnvFile(file);
|
||||
const name = process.env.CMPP_CHANNEL_WORD_QA_DATABASE;
|
||||
assert.match(name ?? '', /^cmpp_qa_channel_words_\d+$/);
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
assert(['127.0.0.1', 'localhost'].includes(url.hostname));
|
||||
url.pathname = `/${name}`;
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
process.env.CMPP_PROCESS_ROLE = 'api';
|
||||
const { PrismaService } = require('./dist/prisma/prisma.service.js');
|
||||
const { ChannelSensitiveWordsService } = require('./dist/dictionaries/channel-sensitive-words.service.js');
|
||||
const { SendChainService } = require('./dist/send-chain/send-chain.service.js');
|
||||
const { loadChannelWords } = require('./dist/send-chain/channel-sensitive-routing.js');
|
||||
const { OperationsMessageQueries } = require('./dist/operations/queries/messages.queries.js');
|
||||
const { clientMessageView } = require('./dist/operations/operations.helpers.js');
|
||||
const db = new PrismaService(),
|
||||
prefix = `qa-channel-word-${randomUUID()}`,
|
||||
checks = [];
|
||||
try {
|
||||
const role = await db.role.findUniqueOrThrow({ where: { code: 'platform_admin' } });
|
||||
const admin = await db.user.create({
|
||||
data: {
|
||||
username: prefix,
|
||||
displayName: '隔离验收管理员',
|
||||
passwordHash: 'isolated-no-login',
|
||||
roles: { create: { roleId: role.id } },
|
||||
},
|
||||
});
|
||||
const unauthorized = await db.user.create({
|
||||
data: { username: `${prefix}-unauthorized`, displayName: '隔离无权限用户', passwordHash: 'isolated-no-login' },
|
||||
});
|
||||
const tenant = await db.tenant.create({ data: { code: prefix, name: prefix } });
|
||||
const application = await db.smsApplication.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: prefix,
|
||||
cmppAccount: prefix,
|
||||
cmppEnterpriseCode: 'QA',
|
||||
secretHash: 'isolated-no-login',
|
||||
},
|
||||
});
|
||||
const signature = await db.smsSignature.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, name: prefix, auditStatus: 'approved' },
|
||||
});
|
||||
const group = await db.smsChannelGroup.create({ data: { code: prefix, name: prefix, carrier: 'mobile' } });
|
||||
const channels = [];
|
||||
for (const index of [0, 1]) {
|
||||
const channel = await db.smsChannel.create({
|
||||
data: {
|
||||
code: `${prefix}-${index}`,
|
||||
name: `验收通道${index ? 'B' : 'A'}`,
|
||||
carrier: 'all',
|
||||
carriers: ['mobile', 'unicom', 'telecom'],
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 1,
|
||||
account: prefix,
|
||||
passwordCipher: 'isolated-no-transport',
|
||||
srcId: '1069',
|
||||
},
|
||||
});
|
||||
channels.push(channel);
|
||||
await db.cmppConnectionState.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
connectionId: prefix,
|
||||
status: 'connected',
|
||||
currentConnections: 1,
|
||||
desiredConnections: 1,
|
||||
},
|
||||
});
|
||||
await db.smsChannelGroupItem.create({
|
||||
data: { groupId: group.id, channelId: channel.id, carrier: 'mobile', priority: index + 1 },
|
||||
});
|
||||
await db.channelSignatureReportTask.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
signatureId: signature.id,
|
||||
tenantId: tenant.id,
|
||||
carrier: 'mobile',
|
||||
approvalScope: 'carrier_specific',
|
||||
reportType: 'signature',
|
||||
status: 'approved',
|
||||
},
|
||||
});
|
||||
}
|
||||
await db.channelRouteRule.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile' },
|
||||
});
|
||||
const service = new ChannelSensitiveWordsService(db);
|
||||
await assert.rejects(service.list(unauthorized.id, {}), /权限/);
|
||||
await assert.rejects(service.save(admin.id, { channelId: channels[0].id, word: ' ', status: 'active' }), /字符/);
|
||||
const data = { channelId: channels[0].id, word: ' 贷款 ', status: 'active', remark: 'QA' };
|
||||
let word = await service.save(admin.id, data);
|
||||
assert.equal(word.word, '贷款');
|
||||
await assert.rejects(service.save(admin.id, data), /相同/);
|
||||
const concurrent = await Promise.allSettled([
|
||||
service.save(admin.id, { ...data, version: word.version, remark: 'first' }, word.id),
|
||||
service.save(admin.id, { ...data, version: word.version, remark: 'second' }, word.id),
|
||||
]);
|
||||
assert.equal(concurrent.filter((result) => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(concurrent.filter((result) => result.status === 'rejected').length, 1);
|
||||
word = await db.channelSensitiveWord.findUniqueOrThrow({ where: { id: word.id } });
|
||||
assert.equal(word.version, 2);
|
||||
checks.push('real PostgreSQL validation, permission, unique word, concurrent optimistic version conflict');
|
||||
const list = await service.list(admin.id, {
|
||||
channelId: channels[0].id,
|
||||
keyword: '贷',
|
||||
page: '1',
|
||||
pageSize: '25',
|
||||
status: 'active',
|
||||
});
|
||||
assert.equal(list.total, 1);
|
||||
assert.equal(list.items[0].id, word.id);
|
||||
const before = await loadChannelWords(
|
||||
db,
|
||||
channels.map((channel) => channel.id),
|
||||
);
|
||||
word = await service.save(admin.id, { ...data, version: word.version, status: 'inactive' }, word.id);
|
||||
assert.equal(before.hits('贷款').length, 1);
|
||||
assert.equal(
|
||||
(
|
||||
await loadChannelWords(
|
||||
db,
|
||||
channels.map((channel) => channel.id),
|
||||
)
|
||||
).hits('贷款').length,
|
||||
0,
|
||||
);
|
||||
await service.remove(admin.id, word.id, word.version);
|
||||
assert.equal((await service.list(admin.id, { channelId: channels[0].id })).total, 0);
|
||||
const restored = await service.save(admin.id, data);
|
||||
assert.equal(restored.id, word.id);
|
||||
assert(restored.version > word.version);
|
||||
assert.equal(await db.operationLog.count({ where: { resourceId: word.id, resource: 'channel_sensitive_word' } }), 5);
|
||||
checks.push('filter, disable, soft delete, audited restore retain ID; old snapshot unaffected by config edits');
|
||||
const messages = [];
|
||||
for (let index = 0; index < 100; index++)
|
||||
messages.push(
|
||||
await db.smsMessageRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
signatureId: signature.id,
|
||||
messageId: `${prefix}-${index}`,
|
||||
content: `【验收】贷款咨询${'字'.repeat(80)}`,
|
||||
phoneNumber: '13800000000',
|
||||
carrier: 'mobile',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sendChain = new SendChainService(db, {}, {}, {});
|
||||
const ordinary = await sendChain.selectChannelForMessage(messages[0]);
|
||||
assert.equal(ordinary.channel.id, channels[1].id);
|
||||
const started = performance.now();
|
||||
let reads = 0,
|
||||
writes = 0;
|
||||
const originalRead = db.channelSensitiveWord.findMany.bind(db.channelSensitiveWord);
|
||||
const originalWrite = db.smsChannelSensitiveDecision.createMany.bind(db.smsChannelSensitiveDecision);
|
||||
db.channelSensitiveWord.findMany = (...args) => {
|
||||
reads++;
|
||||
return originalRead(...args);
|
||||
};
|
||||
db.smsChannelSensitiveDecision.createMany = (...args) => {
|
||||
writes++;
|
||||
return originalWrite(...args);
|
||||
};
|
||||
const batch = await sendChain.submission.gatewaySubmit.planRoutesBatch(messages);
|
||||
assert.equal(batch.planned.length, 100);
|
||||
assert.equal(batch.failed.length, 0);
|
||||
assert(batch.planned.every((item) => item.routed.channel.id === channels[1].id));
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(writes, 1);
|
||||
const durationMs = Math.round(performance.now() - started);
|
||||
checks.push(
|
||||
`ordinary and 100-message batch choose B, one word SQL and one decision SQL; ${durationMs}ms includes existing drainage work`,
|
||||
);
|
||||
await service.save(admin.id, { ...data, channelId: channels[1].id });
|
||||
const failed = await sendChain.submission.gatewaySubmit.planRoutesBatch([messages[1]]);
|
||||
assert.equal(failed.failed[0].code, 'CHANNEL_SENSITIVE_WORD_NO_ROUTE');
|
||||
await assert.rejects(sendChain.selectChannelForMessage(messages[2]), /均命中/);
|
||||
const detail = await new OperationsMessageQueries(db).getMessage(messages[0].id);
|
||||
assert(
|
||||
detail.channelWordDecisions.some((decision) =>
|
||||
decision.snapshot.hits.some((hit) => hit.channelName === '验收通道A'),
|
||||
),
|
||||
);
|
||||
assert.equal(clientMessageView(detail).channelWordDecisions, undefined);
|
||||
assert.equal(
|
||||
(await db.smsMessageRecord.findUniqueOrThrow({ where: { id: messages[0].id } })).content,
|
||||
messages[0].content,
|
||||
);
|
||||
await before.persist(db);
|
||||
assert.equal(
|
||||
await db.gatewaySubmitOutbox.count({ where: { messageRecordId: { in: messages.map((message) => message.id) } } }),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await db.smsSubmitRecord.count({ where: { messageRecordId: { in: messages.map((message) => message.id) } } }),
|
||||
0,
|
||||
);
|
||||
checks.push(
|
||||
'all candidates rejected in ordinary/batch, historical admin explanation, client redaction, original text unchanged, no submit intents',
|
||||
);
|
||||
// Exercise the actual failure SQL before an isolated release failure.
|
||||
const task = await db.smsBatchTask.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
taskNo: prefix,
|
||||
sourceType: 'client',
|
||||
content: '贷款',
|
||||
phoneTotal: 1,
|
||||
},
|
||||
});
|
||||
const pending = await db.smsMessageRecord.update({
|
||||
where: { id: messages[3].id },
|
||||
data: { batchTaskId: task.id },
|
||||
include: { batchTask: true },
|
||||
});
|
||||
sendChain.releaseMessageReservation = async () => {
|
||||
throw Error('isolated release failure');
|
||||
};
|
||||
await assert.rejects(
|
||||
sendChain.submission.gatewaySubmit.failRouteBatch(
|
||||
[{ message: pending, reason: '可用通道均命中通道敏感词', code: 'CHANNEL_SENSITIVE_WORD_NO_ROUTE' }],
|
||||
new Map(),
|
||||
),
|
||||
/isolated release failure/,
|
||||
);
|
||||
const persisted = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: pending.id } });
|
||||
assert.equal(persisted.status, 'failed');
|
||||
assert.equal(persisted.channelWordFinalizationPending, true);
|
||||
assert.equal(persisted.drainageReceiptPending, false);
|
||||
checks.push(
|
||||
'real batch failure SQL marks non-CMPP finalization pending before injected release failure; no callback or SMS',
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ success: true, database: name, fixturePrefix: prefix, messageId: messages[0].id, checks }),
|
||||
);
|
||||
} finally {
|
||||
await db.$disconnect();
|
||||
}
|
||||
Reference in New Issue
Block a user