246 lines
9.6 KiB
TypeScript
246 lines
9.6 KiB
TypeScript
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<DrainageTarget & { materialIds: string[] }>;
|
|
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<string> | 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<typeof task> => task?.status === 'approved')
|
|
.map((task) => task.channelId),
|
|
),
|
|
);
|
|
allowed =
|
|
allowed === null
|
|
? channels
|
|
: new Set<string>(Array.from(allowed as Set<string>).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;
|
|
}
|