Files
lislgosms/api/src/send-chain/drainage-content-detection.ts
T

209 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, NormalizedContent>();
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));
}