feat: filter SMS routes by channel sensitive words
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ChannelSensitiveWord, Prisma } from '@prisma/client';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { selectChannelCandidate } from './send-chain.helpers';
|
||||
|
||||
export const CHANNEL_WORD_NO_ROUTE = 'CHANNEL_SENSITIVE_WORD_NO_ROUTE';
|
||||
export class ChannelWordRejection extends BadRequestException {
|
||||
readonly reasonCode = CHANNEL_WORD_NO_ROUTE;
|
||||
constructor() {
|
||||
super('可用通道均命中通道敏感词');
|
||||
}
|
||||
}
|
||||
type Rule = Pick<ChannelSensitiveWord, 'id' | 'channelId' | 'word' | 'version'>;
|
||||
type Hit = { channelId: string; count: number; samples: Array<{ id: string; word: string; version: number }> };
|
||||
export class ChannelWordSnapshot {
|
||||
private readonly matches = new Map<string, Hit[]>();
|
||||
readonly decisions: Prisma.SmsChannelSensitiveDecisionCreateManyInput[] = [];
|
||||
constructor(
|
||||
private readonly rules: Rule[],
|
||||
readonly readAt = new Date().toISOString(),
|
||||
) {}
|
||||
hits(content: string): Hit[] {
|
||||
const cached = this.matches.get(content);
|
||||
if (cached) return cached;
|
||||
const matched = new Map<string, Hit>();
|
||||
for (const rule of this.rules) {
|
||||
if (!rule.word || !content.includes(rule.word)) continue;
|
||||
const hit = matched.get(rule.channelId) ?? { channelId: rule.channelId, count: 0, samples: [] };
|
||||
hit.count++;
|
||||
if (hit.samples.length < 20) hit.samples.push({ id: rule.id, word: rule.word, version: rule.version });
|
||||
matched.set(rule.channelId, hit);
|
||||
}
|
||||
const result = [...matched.values()];
|
||||
this.matches.set(content, result);
|
||||
return result;
|
||||
}
|
||||
select<T extends Parameters<typeof selectChannelCandidate>[0][number]>(
|
||||
messageId: string,
|
||||
content: string,
|
||||
items: T[],
|
||||
options: Parameters<typeof selectChannelCandidate>[1],
|
||||
) {
|
||||
const candidates = items.filter((item) => selectChannelCandidate([item], options));
|
||||
const candidateIds = new Set(candidates.map((item) => item.channelId));
|
||||
const names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name]));
|
||||
const hits = this.hits(content)
|
||||
.filter((hit) => candidateIds.has(hit.channelId))
|
||||
.map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId }));
|
||||
const excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]);
|
||||
const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded });
|
||||
const rejected = !selected && candidates.length > 0 && hits.length > 0;
|
||||
const routeAttemptId = randomUUID();
|
||||
this.decisions.push({
|
||||
id: randomUUID(),
|
||||
messageRecordId: messageId,
|
||||
routeAttemptId,
|
||||
snapshot: {
|
||||
readAt: this.readAt,
|
||||
stage: 'route',
|
||||
contentHash: createHash('sha256').update(content).digest('hex'),
|
||||
candidateChannelIds: [...candidateIds],
|
||||
excludedChannelIds: hits.map((hit) => hit.channelId),
|
||||
hits,
|
||||
selectedChannelId: selected?.channelId ?? null,
|
||||
reason: rejected ? '可用通道均命中通道敏感词' : null,
|
||||
},
|
||||
});
|
||||
return { selected, rejected };
|
||||
}
|
||||
async persist(prisma: PrismaService) {
|
||||
if (!this.decisions.length) return;
|
||||
try {
|
||||
await prisma.smsChannelSensitiveDecision.createMany({ data: this.decisions, skipDuplicates: true });
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('通道敏感词选路记录保存失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function loadChannelWords(prisma: PrismaService, channelIds: string[]) {
|
||||
try {
|
||||
const rules = channelIds.length
|
||||
? await prisma.channelSensitiveWord.findMany({
|
||||
where: { channelId: { in: [...new Set(channelIds)] }, status: 'active' },
|
||||
select: { id: true, channelId: true, word: true, version: true },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
: [];
|
||||
return new ChannelWordSnapshot(rules);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('通道敏感词读取失败');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user