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; type Hit = { channelId: string; count: number; samples: Array<{ id: string; word: string; version: number }> }; export class ChannelWordSnapshot { private readonly matches = new Map(); 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(); 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[0][number]>( messageId: string, content: string, items: T[], options: Parameters[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('通道敏感词读取失败'); } }