104 lines
4.4 KiB
TypeScript
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('通道敏感词读取失败');
|
|
}
|
|
}
|