feat: filter SMS routes by channel sensitive words

This commit is contained in:
hectorzhao
2026-09-10 15:50:56 +08:00
parent 86947827cc
commit 8e4bc5a20e
26 changed files with 1615 additions and 41 deletions
@@ -0,0 +1,81 @@
import { ChannelWordSnapshot, loadChannelWords } from './channel-sensitive-routing';
import { PrismaService } from '../prisma/prisma.service';
const channel = {
status: 'active',
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
sendRegion: '全国',
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
};
const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobile', priority: index + 1, channel }));
const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) };
const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 };
describe('channel sensitive routing snapshot', () => {
it('removes only matching eligible channels before original priority selection', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
expect(snapshot.select('n', '正常业务', items, options).selected?.channelId).toBe('a');
expect(snapshot.decisions[0].snapshot).toEqual(
expect.objectContaining({ excludedChannelIds: ['a'], stage: 'route' }),
);
});
it('keeps original exclusions and distinguishes no original route from all hit', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '贷款', items, { ...options, excludedChannelIds: new Set(['b']) }).rejected).toBe(true);
expect(snapshot.select('m', '贷款', items, { ...options, approvedChannelIds: new Set() }).rejected).toBe(false);
expect(
snapshot.select(
'm',
'贷款',
items.map((item) => ({ ...item, channel: { ...channel, status: 'inactive' } })),
options,
).rejected,
).toBe(false);
});
it('allows national fallback when matching province channel is excluded', () => {
const provincial = [{ ...items[0], province: '上海', channel: { ...channel, sendRegion: '上海' } }, items[1]];
expect(
new ChannelWordSnapshot([rule]).select('m', '贷款', provincial, { ...options, province: '上海' }).selected
?.channelId,
).toBe('b');
});
it('matches original complete content, case sensitively without removing separators', () => {
const snapshot = new ChannelWordSnapshot([{ ...rule, word: 'Ab贷款' }]);
expect(snapshot.hits('【签名】Ab贷款')[0].count).toBe(1);
for (const value of ['ab贷款', 'b贷款', 'Ab贷-款']) expect(snapshot.hits(value)).toHaveLength(0);
});
it('caps sample words but never truncates excluded channels or hit counts', () => {
const rules = Array.from({ length: 21 }, (_, index) => ({ ...rule, id: String(index) }));
const snapshot = new ChannelWordSnapshot([...rules, { ...rule, channelId: 'b' }]);
expect(snapshot.hits('贷款')[0]).toEqual(expect.objectContaining({ count: 21, samples: expect.any(Array) }));
expect(snapshot.hits('贷款')[0].samples).toHaveLength(20);
expect(snapshot.select('m', '贷款', items, options).rejected).toBe(true);
});
it('reads rules once per batch and persists all decisions in one idempotent write', async () => {
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([rule]) },
smsChannelSensitiveDecision: { createMany: jest.fn() },
};
const snapshot = await loadChannelWords(prisma as unknown as PrismaService, ['a', 'b', 'a']);
for (let index = 0; index < 100; index++) snapshot.select(String(index), '贷款', items, options);
expect(snapshot.hits('贷款')).toBe(snapshot.hits('贷款'));
await snapshot.persist(prisma as unknown as PrismaService);
expect(prisma.channelSensitiveWord.findMany).toHaveBeenCalledTimes(1);
expect(prisma.smsChannelSensitiveDecision.createMany).toHaveBeenCalledWith({
data: expect.any(Array),
skipDuplicates: true,
});
expect(snapshot.decisions).toHaveLength(100);
expect(new Set(snapshot.decisions.map((value) => value.routeAttemptId)).size).toBe(100);
});
it('fails closed for technical read and persistence errors, not as a word hit', async () => {
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
smsChannelSensitiveDecision: { createMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
};
await expect(loadChannelWords(prisma as unknown as PrismaService, ['a'])).rejects.toMatchObject({ status: 503 });
const snapshot = new ChannelWordSnapshot([rule]);
snapshot.select('m', '贷款', items, options);
await expect(snapshot.persist(prisma as unknown as PrismaService)).rejects.toMatchObject({ status: 503 });
});
});