import { BadRequestException } from '@nestjs/common'; import { createHash } from 'node:crypto'; import type { Prisma } from '@prisma/client'; import type { PrismaService } from '../prisma/prisma.service'; export type DrainageDetectionCategory = 'url' | 'mobile' | 'landline' | string; export type DrainageDetectionRuleSnapshot = { id: string; code: string; name: string; category: DrainageDetectionCategory; pattern: string; flags: string; priority: number; version: number; }; export type DrainageDetectionMatch = { ruleId: string; ruleCode: string; ruleName: string; category: DrainageDetectionCategory; text: string; normalizedText: string; start: number; end: number; }; export type DrainageDetectionResult = { hasDrainageContent: boolean; drainageDetection: Prisma.InputJsonValue; drainageDetectionVersion: string; drainageEvaluatedAt: Date; }; type NormalizedContent = { text: string; sourceStarts: number[]; sourceEnds: number[]; }; const RULE_CACHE_TTL_MS = 30_000; const MAX_PATTERN_LENGTH = 1_000; const MAX_CONTENT_LENGTH = 20_000; const MAX_MATCHES = 50; let cachedRules: { expiresAt: number; rules: DrainageDetectionRuleSnapshot[] } | undefined; export function invalidateDrainageDetectionRuleCache() { cachedRules = undefined; } export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') { if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空'); if (pattern.length > MAX_PATTERN_LENGTH) throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`); if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) { throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复'); } // 可配置规则会运行在发送入口,禁止容易造成灾难性回溯或跨文本引用的结构。 if (/\\[1-9]/.test(pattern) || /\(\?<([=!])/.test(pattern) || /\([^)]*[+*][^)]*\)[+*{]/.test(pattern)) { throw new BadRequestException('表达式包含不安全的回溯、后行断言或嵌套量词'); } try { // 强制全局匹配,避免配置遗漏 g 后只能识别首个命中。 new RegExp(pattern, flags.includes('g') ? flags : `${flags}g`); } catch { throw new BadRequestException('识别表达式格式不正确'); } } function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent { let text = ''; const sourceStarts: number[] = []; const sourceEnds: number[] = []; let sourceIndex = 0; for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) { const sourceEnd = sourceIndex + sourceChar.length; let normalized = sourceChar.normalize('NFKC') .replace(/[.。]/g, '.') .replace(/[:﹕]/g, ':') .replace(/[/]/g, '/') .replace(/[()]/g, (char) => char === '(' ? '(' : ')') .replace(/[+]/g, '+'); if (category === 'url') { // 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。 normalized = normalized.replace(/\s+/gu, '').replace(/。/g, '.'); } else if (category === 'mobile' || category === 'landline') { // 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。 normalized = normalized.replace(/[\s\-‐‑‒–—―.。·,,、]/gu, ''); } for (const char of normalized) { text += char; // RegExp.index 使用 UTF-16 code unit,映射数组必须采用相同计数方式,避免表情符号导致高亮偏移。 for (let codeUnit = 0; codeUnit < char.length; codeUnit += 1) { sourceStarts.push(sourceIndex); sourceEnds.push(sourceEnd); } } sourceIndex = sourceEnd; } return { text, sourceStarts, sourceEnds }; } function sourceRange(normalized: NormalizedContent, start: number, end: number) { const safeStart = Math.max(0, Math.min(start, normalized.sourceStarts.length - 1)); const safeEnd = Math.max(safeStart, Math.min(end - 1, normalized.sourceEnds.length - 1)); return { start: normalized.sourceStarts[safeStart] ?? 0, end: normalized.sourceEnds[safeEnd] ?? 0, }; } function emailRanges(normalized: NormalizedContent) { const ranges: Array<{ start: number; end: number }> = []; const email = /[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu; for (const match of normalized.text.matchAll(email)) { ranges.push({ start: match.index, end: match.index + match[0].length }); } return ranges; } function overlaps(start: number, end: number, range: { start: number; end: number }) { return start < range.end && end > range.start; } export function detectDrainageContentWithRules( content: string, rules: DrainageDetectionRuleSnapshot[], evaluatedAt = new Date(), ): DrainageDetectionResult { const matches: DrainageDetectionMatch[] = []; const normalizedByCategory = new Map(); const emailNormalized = normalizeContent(content, 'url'); const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end)); for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) { validateDrainageDetectionPattern(rule.pattern, rule.flags); const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category); normalizedByCategory.set(rule.category, normalized); const regex = new RegExp(rule.pattern, rule.flags.includes('g') ? rule.flags : `${rule.flags}g`); for (const match of normalized.text.matchAll(regex)) { const captured = match[1] || match[0]; const capturedOffset = match[0].indexOf(captured); const normalizedStart = match.index + Math.max(0, capturedOffset); const normalizedEnd = normalizedStart + captured.length; const range = sourceRange(normalized, normalizedStart, normalizedEnd); if (range.end <= range.start) continue; // 邮箱整体不是引流信息;不仅排除其中的域名,也排除数字本地部分被电话规则误识别。 if (originalEmailRanges.some((emailRange) => overlaps(range.start, range.end, emailRange))) continue; const candidate: DrainageDetectionMatch = { ruleId: rule.id, ruleCode: rule.code, ruleName: rule.name, category: rule.category, text: content.slice(range.start, range.end), normalizedText: captured, start: range.start, end: range.end, }; if (!matches.some((item) => item.category === candidate.category && item.start === candidate.start && item.end === candidate.end)) { matches.push(candidate); } if (matches.length >= MAX_MATCHES) break; } if (matches.length >= MAX_MATCHES) break; } matches.sort((a, b) => a.start - b.start || a.end - b.end); const versionSource = rules .map((rule) => `${rule.code}:${rule.version}`) .sort() .join('|'); const drainageDetectionVersion = createHash('sha256').update(versionSource).digest('hex').slice(0, 16); return { hasDrainageContent: matches.length > 0, drainageDetection: { matches, categories: [...new Set(matches.map((item) => item.category))], ruleCount: rules.length, truncated: content.length > MAX_CONTENT_LENGTH || matches.length >= MAX_MATCHES, } as Prisma.InputJsonValue, drainageDetectionVersion, drainageEvaluatedAt: evaluatedAt, }; } async function activeRules(prisma: PrismaService) { if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules; const rules = await prisma.drainageDetectionRule.findMany({ where: { status: 'active' }, orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], select: { id: true, code: true, name: true, category: true, pattern: true, flags: true, priority: true, version: true, }, }); cachedRules = { rules, expiresAt: Date.now() + RULE_CACHE_TTL_MS }; return rules; } export async function detectDrainageContent(prisma: PrismaService, content: string) { return detectDrainageContentWithRules(content, await activeRules(prisma)); }