import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; import { selectDrainageReportTask } from '../common/drainage-report-task'; import { isIP } from 'node:net'; import { parse } from 'tldts'; import type { PrismaService } from '../prisma/prisma.service'; import { detectDrainageContent, normalizeContent, type DrainageDetectionMatch } from './drainage-content-detection'; export const DRAINAGE_POLICY_VERSION = 'domain-boundary-v1'; export class DrainageRejection extends BadRequestException { constructor( public readonly reasonCode: string, reason: string, ) { super({ code: reasonCode, message: reason }); } } export type DrainageTarget = { key: string; category: string; value: string; text: string; start: number; end: number }; export type DrainageMaterial = { id: string; url: string; auditStatus: string; materialVersion: number; reportTasks: Array<{ id: string; channelId: string; carrier: string | null; approvalScope?: string; status: string }>; }; export type DrainageAssessment = { version: string; evaluatedAt: string; targets: Array; materials: DrainageMaterial[]; allowedChannelIds: string[] | null; reasonCode: string | null; reason: string | null; }; export function drainageHost(raw: string) { const value = raw .normalize('NFKC') .replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '') .replace(/[。。]/g, '.') .trim(); try { const parsed = new URL(/^[a-z]+:\/\//i.test(value) ? value : `https://${value}`); if (!['http:', 'https:'].includes(parsed.protocol)) return null; const host = parsed.hostname.toLowerCase().replace(/\.$/, ''); if (isIP(host)) return host; const domain = parse(host, { allowPrivateDomains: true }); return domain.domain && domain.isIcann !== false ? host : domain.domain && domain.isPrivate ? host : null; } catch { return null; } } export function normalizeDrainagePhone(value: string) { return normalizeContent(value, 'landline').text.replace(/[()]/g, ''); } export function materialMatches(target: DrainageTarget, raw: string) { if (target.category !== 'url') return normalizeDrainagePhone(raw) === target.value; const host = drainageHost(raw); if (!host || !(target.value === host || (!isIP(host) && target.value.endsWith(`.${host}`)))) return false; // Existing path-specific material does not silently authorize unrelated paths. const normalizedRaw = normalizeContent(raw, 'url').text.trim(); const registered = new URL(/^[a-z]+:\/\//i.test(normalizedRaw) ? normalizedRaw : `https://${normalizedRaw}`); if (registered.pathname !== '/' || registered.search) { const candidate = new URL(/^[a-z]+:\/\//i.test(target.text) ? target.text : `https://${target.text}`); return ( (candidate.pathname === registered.pathname || candidate.pathname.startsWith(`${registered.pathname.replace(/\/$/, '')}/`)) && (!registered.search || candidate.search === registered.search) ); } return true; } export function drainageTargets(content: string, matches: DrainageDetectionMatch[]) { const urls: DrainageTarget[] = []; const normalized = normalizeContent(content, 'url'); for (const match of matches.filter((item) => item.category === 'url')) { let start = normalized.sourceStarts.findIndex((offset) => offset >= match.start); let end = normalized.sourceEnds.findIndex((offset) => offset >= match.end) + 1; if (start < 0 || end <= 0) throw new ServiceUnavailableException('引流识别位置无效'); // Extend the entire URL token, including suffix labels, userInfo and query. const token = /[a-z0-9:/?&=.%_+@#~!$*()[\]-]/i; while (start > 0 && token.test(normalized.text[start - 1])) start--; while (end < normalized.text.length && token.test(normalized.text[end])) end++; const text = normalized.text.slice(start, end).replace(/[.,;!]+$/, ''); const value = drainageHost(text); if (!value) throw new DrainageRejection('DRAINAGE_INVALID', '引流URL格式无效或不是可登记域名'); urls.push({ key: `url:${value}:${text}`, category: 'url', value, text, start: normalized.sourceStarts[start], end: normalized.sourceEnds[end - 1], }); } const targets = [...urls]; for (const match of matches.filter((item) => item.category !== 'url')) { if (urls.some((url) => match.start < url.end && match.end > url.start)) continue; if (!['mobile', 'landline'].includes(match.category)) throw new ServiceUnavailableException('引流识别类型尚未支持发送校验'); const value = normalizeDrainagePhone(match.normalizedText); targets.push({ key: `phone:${value}`, category: match.category, value, text: match.text, start: match.start, end: match.end, }); } return [...new Map(targets.map((target) => [target.key, target])).values()]; } export function assessDrainage( targets: DrainageTarget[], materials: DrainageMaterial[], carrier?: string, ): DrainageAssessment { let allowed: Set | null = null; const assessment: DrainageAssessment = { version: DRAINAGE_POLICY_VERSION, evaluatedAt: new Date().toISOString(), targets: [], materials: [], allowedChannelIds: null, reasonCode: null, reason: null, }; for (const target of targets) { const matching = materials.filter((item) => item.auditStatus !== 'deleted' && materialMatches(target, item.url)); const approved = matching.filter((item) => item.auditStatus === 'approved'); assessment.targets.push({ ...target, materialIds: approved.map((item) => item.id) }); const code = !matching.length ? 'DRAINAGE_NOT_REGISTERED' : !approved.length ? 'DRAINAGE_NOT_APPROVED' : null; if (code && !assessment.reasonCode) { assessment.reasonCode = code; assessment.reason = `引流信息“${target.text.slice(0, 160)}”${!matching.length ? '未在当前签名下添加' : '尚未审核通过'}`; } const channels = new Set( approved.flatMap((item) => [...new Set(item.reportTasks.map((task) => task.channelId))] .map((channelId) => selectDrainageReportTask(item.reportTasks, channelId, carrier)) .filter((task): task is NonNullable => task?.status === 'approved') .map((task) => task.channelId), ), ); allowed = allowed === null ? channels : new Set(Array.from(allowed as Set).filter((id: string) => channels.has(id))); } const used = new Set(assessment.targets.flatMap((target) => target.materialIds)); assessment.materials = materials .filter((item) => used.has(item.id)) .map(({ id, url, auditStatus, materialVersion, reportTasks }) => ({ id, url, auditStatus, materialVersion, reportTasks, })); assessment.allowedChannelIds = allowed === null ? null : [...allowed]; if (targets.length && allowed?.size === 0 && !assessment.reasonCode) { assessment.reasonCode = 'DRAINAGE_CHANNEL_NOT_APPROVED'; assessment.reason = '当前签名下的全部引流信息没有共同报备通过的通道'; } return assessment; } export async function evaluateMessageDrainage( prisma: PrismaService, message: { id: string; content: string; tenantId?: string | null; applicationId?: string | null; signatureId?: string | null; }, carrier?: string, materials?: DrainageMaterial[], fresh = false, ) { let detected; try { detected = await detectDrainageContent(prisma, message.content, fresh); } catch (error) { throw new ServiceUnavailableException('引流检测暂不可用', { cause: error }); } const detection = detected.drainageDetection as unknown as { matches: DrainageDetectionMatch[]; truncated: boolean; ruleCount: number; }; if (detection.truncated || detection.ruleCount === 0) throw new ServiceUnavailableException('引流检测不完整,暂不能发送'); let targets: DrainageTarget[] = []; let invalid: DrainageRejection | undefined; try { targets = drainageTargets(message.content, detection.matches); } catch (error) { if (!(error instanceof DrainageRejection)) throw error; invalid = error; } const rows = materials ?? (targets.length && message.signatureId ? await prisma.smsDrainageInfo.findMany({ where: { signatureId: message.signatureId, tenantId: message.tenantId ?? '', applicationId: message.applicationId ?? '', auditStatus: { not: 'deleted' }, }, include: { reportTasks: { where: { reportType: 'drainage', signatureId: message.signatureId, tenantId: message.tenantId ?? '' }, }, }, }) : []); const assessment = assessDrainage(targets, rows, carrier); if (invalid) { assessment.reasonCode = invalid.reasonCode; assessment.reason = invalid.message; assessment.allowedChannelIds = []; } // Append-only evidence survives later approval changes and subsequent routing attempts. await prisma.smsDrainageDecision.create({ data: { messageRecordId: message.id, snapshot: JSON.parse(JSON.stringify(assessment)) }, }); await prisma.smsMessageRecord.update({ where: { id: message.id }, data: { ...detected, drainageGate: JSON.parse(JSON.stringify(assessment)), drainageInfoId: assessment.targets.length === 1 && assessment.targets[0].materialIds.length === 1 ? assessment.targets[0].materialIds[0] : null, }, }); if (assessment.reasonCode) throw new DrainageRejection(assessment.reasonCode, assessment.reason!); return assessment; }