Files
lislgosms/api/src/send-chain/channel-sensitive-routing.ts
T

104 lines
4.4 KiB
TypeScript

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],
contentForChannel?: (channelId: string) => string,
) {
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 = (
contentForChannel
? [...candidateIds].flatMap((id) => this.hits(contentForChannel(id)).filter((hit) => hit.channelId === id))
: 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'),
...(contentForChannel
? {
candidateContentHashes: Object.fromEntries(
[...candidateIds].map((id) => [id, createHash('sha256').update(contentForChannel(id)).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('通道敏感词读取失败');
}
}