593 lines
19 KiB
TypeScript
593 lines
19 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
export interface CreateRiskRuleDto {
|
|
tenantId?: string;
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
metric: string;
|
|
thresholdValue: number;
|
|
action?: string;
|
|
status?: string;
|
|
priority?: number;
|
|
config?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface EvaluateSmsTaskDto {
|
|
tenantId: string;
|
|
applicationId?: string;
|
|
templateId?: string;
|
|
content: string;
|
|
category?: string;
|
|
phones: string[];
|
|
variables?: Record<string, unknown>;
|
|
createdById?: string;
|
|
requestedAt?: string;
|
|
}
|
|
|
|
export interface ReviewSmsTaskDto {
|
|
reviewerId?: string;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface BatchReviewSmsTasksDto extends ReviewSmsTaskDto {
|
|
ids: string[];
|
|
}
|
|
|
|
export interface AggregateTemplateMismatchDto {
|
|
tenantId: string;
|
|
applicationId: string;
|
|
account: string;
|
|
messageRecordId: string;
|
|
signatureId: string;
|
|
content: string;
|
|
}
|
|
|
|
interface RuleEvaluation {
|
|
ruleId?: string;
|
|
ruleCode: string;
|
|
ruleName: string;
|
|
thresholdValue: number;
|
|
actualValue: number;
|
|
action: string;
|
|
reason: string;
|
|
}
|
|
|
|
const DEFAULT_RULES: CreateRiskRuleDto[] = [
|
|
{
|
|
code: 'MAX_PHONES_PER_TASK',
|
|
name: '单任务最大号码数',
|
|
description: '单次提交号码数超过阈值时直接拒绝。',
|
|
metric: 'phoneTotal',
|
|
thresholdValue: 100000,
|
|
action: 'block',
|
|
priority: 10,
|
|
},
|
|
{
|
|
code: 'DUPLICATE_PHONE_RATIO',
|
|
name: '重复号码比例',
|
|
description: '重复号码比例过高时进入人工审核。',
|
|
metric: 'duplicateRatio',
|
|
thresholdValue: 0.2,
|
|
action: 'manual_review',
|
|
priority: 20,
|
|
},
|
|
{
|
|
code: 'ILLEGAL_PHONE_RATIO',
|
|
name: '非法号码比例',
|
|
description: '非法手机号比例超过阈值时直接拒绝。',
|
|
metric: 'illegalRatio',
|
|
thresholdValue: 0.05,
|
|
action: 'block',
|
|
priority: 30,
|
|
},
|
|
{
|
|
code: 'BLACKLIST_HIT_RATIO',
|
|
name: '黑名单命中比例',
|
|
description: '命中平台或企业黑名单比例过高时进入人工审核。',
|
|
metric: 'blacklistHitRatio',
|
|
thresholdValue: 0.01,
|
|
action: 'manual_review',
|
|
priority: 40,
|
|
},
|
|
{
|
|
code: 'NON_WORKING_MARKETING_BULK',
|
|
name: '非工作时间大批量营销发送',
|
|
description: '营销任务在非工作时间且号码数超过阈值时进入人工审核。',
|
|
metric: 'nonWorkingMarketingPhones',
|
|
thresholdValue: 5000,
|
|
action: 'manual_review',
|
|
priority: 50,
|
|
},
|
|
{
|
|
code: 'TASK_CREATE_FREQUENCY',
|
|
name: '短时间任务创建频控',
|
|
description: '同租户十分钟内创建任务数过高时进入人工审核。',
|
|
metric: 'recentTaskCount',
|
|
thresholdValue: 10,
|
|
action: 'manual_review',
|
|
priority: 60,
|
|
},
|
|
{
|
|
code: 'TEMPLATE_VARIABLE_ANOMALY',
|
|
name: '模板变量异常',
|
|
description: '模板变量缺失或多传时直接拒绝。',
|
|
metric: 'variableIssueCount',
|
|
thresholdValue: 0,
|
|
action: 'block',
|
|
priority: 70,
|
|
},
|
|
];
|
|
|
|
@Injectable()
|
|
export class RiskReviewService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async listRules(tenantId?: string) {
|
|
await this.ensureDefaultRules();
|
|
return this.prisma.riskRule.findMany({
|
|
where: tenantId ? { OR: [{ tenantId: null }, { tenantId }] } : undefined,
|
|
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
|
});
|
|
}
|
|
|
|
createRule(data: CreateRiskRuleDto) {
|
|
return this.prisma.riskRule.create({
|
|
data: {
|
|
tenantId: data.tenantId,
|
|
code: data.code,
|
|
name: data.name,
|
|
description: data.description,
|
|
metric: data.metric,
|
|
thresholdValue: data.thresholdValue,
|
|
action: data.action ?? 'manual_review',
|
|
status: data.status ?? 'active',
|
|
priority: data.priority ?? 100,
|
|
config: data.config as Prisma.InputJsonValue | undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
listHits(tenantId?: string, taskId?: string) {
|
|
return this.prisma.riskHitRecord.findMany({
|
|
where: {
|
|
tenantId,
|
|
taskId,
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
listTasks(tenantId?: string, status?: string) {
|
|
return this.prisma.smsSendTask.findMany({
|
|
where: {
|
|
tenantId,
|
|
status,
|
|
...(status === 'pending_review' ? {
|
|
OR: [
|
|
{ sourceType: { not: 'cmpp_template_mismatch' } },
|
|
{ windowEndsAt: { lte: new Date() } },
|
|
],
|
|
} : {}),
|
|
},
|
|
include: { riskHits: true, _count: { select: { messageRecords: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
listPendingTasks() {
|
|
return this.listTasks(undefined, 'pending_review');
|
|
}
|
|
|
|
async aggregateTemplateMismatch(data: AggregateTemplateMismatchDto) {
|
|
const normalizedContent = data.content.replace(/\r\n/g, '\n').trim();
|
|
const contentHash = createHash('sha256').update(normalizedContent, 'utf8').digest('hex');
|
|
const windowMs = Math.max(1_000, Number(process.env.CMPP_TEMPLATE_REVIEW_WINDOW_MS ?? 10_000));
|
|
const now = new Date();
|
|
const windowStartedAt = new Date(Math.floor(now.getTime() / windowMs) * windowMs);
|
|
const windowEndsAt = new Date(windowStartedAt.getTime() + windowMs);
|
|
const aggregationKey = createHash('sha256')
|
|
.update(`${data.applicationId}|${data.account}|${contentHash}|${windowStartedAt.toISOString()}`, 'utf8')
|
|
.digest('hex');
|
|
const task = await this.prisma.$transaction(async (tx) => {
|
|
const aggregated = await tx.smsSendTask.upsert({
|
|
where: { aggregationKey },
|
|
update: {
|
|
phoneTotal: { increment: 1 },
|
|
uniquePhoneTotal: { increment: 1 },
|
|
},
|
|
create: {
|
|
tenantId: data.tenantId,
|
|
applicationId: data.applicationId,
|
|
taskNo: `CMPP-REVIEW-${windowStartedAt.getTime()}-${aggregationKey.slice(0, 8)}`,
|
|
sourceType: 'cmpp_template_mismatch',
|
|
aggregationKey,
|
|
contentHash,
|
|
windowStartedAt,
|
|
windowEndsAt,
|
|
content: normalizedContent,
|
|
phoneTotal: 1,
|
|
uniquePhoneTotal: 1,
|
|
status: 'pending_review',
|
|
riskDecision: 'manual_review',
|
|
reviewReason: '企业应用已配置模板不匹配进入人工审核',
|
|
variableIssues: {
|
|
sourceType: 'cmpp',
|
|
account: data.account,
|
|
aggregationWindowMs: windowMs,
|
|
} as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
await tx.smsMessageRecord.update({
|
|
where: { id: data.messageRecordId },
|
|
data: {
|
|
reviewTaskId: aggregated.id,
|
|
signatureId: data.signatureId,
|
|
status: 'pending_review',
|
|
},
|
|
});
|
|
return aggregated;
|
|
});
|
|
return this.prisma.smsSendTask.findUnique({
|
|
where: { id: task.id },
|
|
include: { riskHits: true, _count: { select: { messageRecords: true } } },
|
|
});
|
|
}
|
|
|
|
async evaluateTask(data: EvaluateSmsTaskDto) {
|
|
await this.ensureDefaultRules();
|
|
if (data.createdById) {
|
|
const creator = await this.prisma.user.findUnique({ where: { id: data.createdById }, select: { id: true } });
|
|
if (!creator) {
|
|
throw new BadRequestException('createdById does not reference an existing user');
|
|
}
|
|
}
|
|
const phones = data.phones ?? [];
|
|
const uniquePhones = [...new Set(phones)];
|
|
const phoneTotal = phones.length;
|
|
const uniquePhoneTotal = uniquePhones.length;
|
|
const duplicateRatio = ratio(phoneTotal - uniquePhoneTotal, phoneTotal);
|
|
const illegalCount = phones.filter((phone) => !isMainlandMobile(phone)).length;
|
|
const illegalRatio = ratio(illegalCount, phoneTotal);
|
|
const blacklistHitCount = await this.countBlacklistHits(data.tenantId, data.applicationId, uniquePhones);
|
|
const blacklistHitRatio = ratio(blacklistHitCount, phoneTotal);
|
|
const [application, template, rules, recentTaskCount, sensitiveWords] = await Promise.all([
|
|
data.applicationId ? this.prisma.smsApplication.findUnique({ where: { id: data.applicationId } }) : null,
|
|
data.templateId
|
|
? this.prisma.smsTemplate.findUnique({ where: { id: data.templateId }, include: { variables: true } })
|
|
: null,
|
|
this.effectiveRules(data.tenantId),
|
|
this.countRecentTasks(data.tenantId),
|
|
this.prisma.sensitiveWord.findMany({ where: { status: 'active' }, select: { word: true, level: true } }),
|
|
]);
|
|
const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {});
|
|
const contentIssues = evaluateContent(data.content, sensitiveWords);
|
|
const requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date();
|
|
const nonWorkingMarketingPhones =
|
|
isMarketing(data.category ?? template?.category) && isNonWorkingTime(requestedAt) ? phoneTotal : 0;
|
|
const hits = this.evaluateRules(rules, {
|
|
phoneTotal,
|
|
applicationMaxPhones: application?.maxPhonesPerTask,
|
|
duplicateRatio,
|
|
illegalRatio,
|
|
blacklistHitRatio,
|
|
nonWorkingMarketingPhones,
|
|
recentTaskCount,
|
|
variableIssueCount: variableIssues.length,
|
|
});
|
|
hits.push(...contentIssues.map(contentIssueToHit));
|
|
const decision = decideRiskAction(hits);
|
|
const reason = hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null;
|
|
const task = await this.prisma.smsSendTask.create({
|
|
data: {
|
|
tenantId: data.tenantId,
|
|
applicationId: data.applicationId,
|
|
templateId: data.templateId,
|
|
taskNo: `SMS-${Date.now()}-${randomUUID().slice(0, 8)}`,
|
|
content: data.content,
|
|
category: data.category ?? template?.category,
|
|
phoneTotal,
|
|
uniquePhoneTotal,
|
|
duplicateRatio,
|
|
illegalRatio,
|
|
blacklistHitRatio,
|
|
variableIssues: { variables: variableIssues, content: contentIssues } as unknown as Prisma.InputJsonValue,
|
|
status: decision.status,
|
|
riskDecision: decision.riskDecision,
|
|
reviewReason: decision.status === 'pending_review' ? reason : null,
|
|
rejectReason: decision.status === 'rejected' ? reason : null,
|
|
createdById: data.createdById,
|
|
},
|
|
});
|
|
if (hits.length > 0) {
|
|
await this.prisma.riskHitRecord.createMany({
|
|
data: hits.map((hit) => ({
|
|
tenantId: data.tenantId,
|
|
taskId: task.id,
|
|
ruleId: hit.ruleId,
|
|
ruleCode: hit.ruleCode,
|
|
ruleName: hit.ruleName,
|
|
thresholdValue: hit.thresholdValue,
|
|
actualValue: hit.actualValue,
|
|
action: hit.action,
|
|
reason: hit.reason,
|
|
})),
|
|
});
|
|
}
|
|
const taskWithHits = await this.prisma.smsSendTask.findUnique({
|
|
where: { id: task.id },
|
|
include: { riskHits: true },
|
|
});
|
|
return {
|
|
canSubmit: decision.status === 'approved',
|
|
status: decision.status,
|
|
riskDecision: decision.riskDecision,
|
|
reason,
|
|
task: taskWithHits,
|
|
};
|
|
}
|
|
|
|
async approveTask(taskId: string, data: ReviewSmsTaskDto) {
|
|
const task = await this.prisma.smsSendTask.findUnique({ where: { id: taskId } });
|
|
if (!task) {
|
|
throw new NotFoundException('SMS send task not found');
|
|
}
|
|
this.assertAggregationWindowClosed(task);
|
|
return this.prisma.smsSendTask.update({
|
|
where: { id: taskId },
|
|
data: {
|
|
status: 'approved',
|
|
riskDecision: 'allow',
|
|
reviewReason: data.reason ?? task.reviewReason,
|
|
rejectReason: null,
|
|
reviewedById: data.reviewerId,
|
|
reviewedAt: new Date(),
|
|
},
|
|
include: { riskHits: true },
|
|
});
|
|
}
|
|
|
|
async rejectTask(taskId: string, data: ReviewSmsTaskDto) {
|
|
const task = await this.prisma.smsSendTask.findUnique({ where: { id: taskId } });
|
|
if (!task) {
|
|
throw new NotFoundException('SMS send task not found');
|
|
}
|
|
this.assertAggregationWindowClosed(task);
|
|
const reason = data.reason ?? task.reviewReason ?? '审核拒绝';
|
|
return this.prisma.smsSendTask.update({
|
|
where: { id: taskId },
|
|
data: {
|
|
status: 'rejected',
|
|
riskDecision: 'block',
|
|
rejectReason: reason,
|
|
reviewedById: data.reviewerId,
|
|
reviewedAt: new Date(),
|
|
},
|
|
include: { riskHits: true },
|
|
});
|
|
}
|
|
|
|
async rejectTasks(data: BatchReviewSmsTasksDto) {
|
|
const taskIds = [...new Set((data.ids ?? []).map((id) => id.trim()).filter(Boolean))];
|
|
if (taskIds.length === 0) {
|
|
throw new BadRequestException('At least one SMS send task id is required');
|
|
}
|
|
if (taskIds.length > 100) {
|
|
throw new BadRequestException('A maximum of 100 SMS send tasks can be rejected at once');
|
|
}
|
|
if (!data.reason?.trim()) {
|
|
throw new BadRequestException('Batch rejection reason is required');
|
|
}
|
|
|
|
const rejected = [];
|
|
for (const taskId of taskIds) {
|
|
rejected.push(await this.rejectTask(taskId, { ...data, reason: data.reason.trim() }));
|
|
}
|
|
return rejected;
|
|
}
|
|
|
|
private async ensureDefaultRules() {
|
|
for (const rule of DEFAULT_RULES) {
|
|
const exists = await this.prisma.riskRule.findFirst({
|
|
where: { tenantId: null, code: rule.code },
|
|
select: { id: true },
|
|
});
|
|
if (!exists) {
|
|
await this.createRule(rule);
|
|
}
|
|
}
|
|
}
|
|
|
|
private assertAggregationWindowClosed(task: { sourceType?: string | null; windowEndsAt?: Date | null }) {
|
|
if (task.sourceType === 'cmpp_template_mismatch' && task.windowEndsAt && task.windowEndsAt.getTime() > Date.now()) {
|
|
throw new BadRequestException('聚合窗口尚未关闭,请在窗口结束后审核');
|
|
}
|
|
}
|
|
|
|
private async effectiveRules(tenantId: string) {
|
|
const rules = await this.prisma.riskRule.findMany({
|
|
where: {
|
|
status: 'active',
|
|
OR: [{ tenantId: null }, { tenantId }],
|
|
},
|
|
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
|
});
|
|
const byCode = new Map<string, (typeof rules)[number]>();
|
|
for (const rule of rules) {
|
|
byCode.set(rule.code, rule);
|
|
}
|
|
return [...byCode.values()].sort((a, b) => a.priority - b.priority);
|
|
}
|
|
|
|
private async countBlacklistHits(tenantId: string, applicationId: string | undefined, phones: string[]) {
|
|
if (phones.length === 0) {
|
|
return 0;
|
|
}
|
|
const [globalHits, enterpriseHits] = await Promise.all([
|
|
this.prisma.globalBlacklist.findMany({
|
|
where: { phoneNumber: { in: phones }, status: 'active' },
|
|
select: { phoneNumber: true },
|
|
}),
|
|
applicationId ? this.prisma.enterpriseBlacklist.findMany({
|
|
where: { tenantId, applicationId, phoneNumber: { in: phones }, status: 'active' },
|
|
select: { phoneNumber: true },
|
|
}) : Promise.resolve([]),
|
|
]);
|
|
return new Set([...globalHits, ...enterpriseHits].map((hit) => hit.phoneNumber)).size;
|
|
}
|
|
|
|
private countRecentTasks(tenantId: string) {
|
|
const since = new Date(Date.now() - 10 * 60 * 1000);
|
|
return this.prisma.smsSendTask.count({
|
|
where: {
|
|
tenantId,
|
|
createdAt: { gte: since },
|
|
},
|
|
});
|
|
}
|
|
|
|
private evaluateRules(
|
|
rules: Awaited<ReturnType<RiskReviewService['effectiveRules']>>,
|
|
metrics: {
|
|
phoneTotal: number;
|
|
applicationMaxPhones?: number | null;
|
|
duplicateRatio: number;
|
|
illegalRatio: number;
|
|
blacklistHitRatio: number;
|
|
nonWorkingMarketingPhones: number;
|
|
recentTaskCount: number;
|
|
variableIssueCount: number;
|
|
},
|
|
): RuleEvaluation[] {
|
|
const hits: RuleEvaluation[] = [];
|
|
for (const rule of rules) {
|
|
const threshold =
|
|
rule.code === 'MAX_PHONES_PER_TASK' && metrics.applicationMaxPhones
|
|
? Math.min(rule.thresholdValue, metrics.applicationMaxPhones)
|
|
: rule.thresholdValue;
|
|
const actualValue = metricValue(rule.metric, metrics);
|
|
const shouldHit = rule.code === 'TEMPLATE_VARIABLE_ANOMALY' ? actualValue > threshold : actualValue > threshold;
|
|
if (!shouldHit) {
|
|
continue;
|
|
}
|
|
hits.push({
|
|
ruleId: rule.id,
|
|
ruleCode: rule.code,
|
|
ruleName: rule.name,
|
|
thresholdValue: threshold,
|
|
actualValue,
|
|
action: rule.action,
|
|
reason: `${rule.name}命中,阈值 ${formatNumber(threshold)},实际 ${formatNumber(actualValue)},处理动作 ${formatAction(rule.action)}`,
|
|
});
|
|
}
|
|
return hits;
|
|
}
|
|
}
|
|
|
|
function ratio(count: number, total: number) {
|
|
if (total <= 0) {
|
|
return 0;
|
|
}
|
|
return Number((count / total).toFixed(4));
|
|
}
|
|
|
|
function isMainlandMobile(phone: string) {
|
|
return /^1[3-9]\d{9}$/.test(phone);
|
|
}
|
|
|
|
function isMarketing(category?: string | null) {
|
|
return ['marketing', 'promo', 'promotion', '营销'].includes((category ?? '').toLowerCase());
|
|
}
|
|
|
|
function isNonWorkingTime(date: Date) {
|
|
const hour = date.getHours();
|
|
return hour < 8 || hour >= 21;
|
|
}
|
|
|
|
function evaluateTemplateVariables(
|
|
templateVariables: Array<{ name: string; required: boolean }>,
|
|
content: string,
|
|
variables: Record<string, unknown>,
|
|
) {
|
|
const expected = templateVariables.length > 0 ? templateVariables : inferVariables(content);
|
|
const providedNames = Object.keys(variables);
|
|
const missing = expected
|
|
.filter((variable) => variable.required && !providedNames.includes(variable.name))
|
|
.map((variable) => variable.name);
|
|
const expectedNames = new Set(expected.map((variable) => variable.name));
|
|
const extra = providedNames.filter((name) => !expectedNames.has(name));
|
|
return [
|
|
...missing.map((name) => ({ type: 'missing_required_variable', name })),
|
|
...extra.map((name) => ({ type: 'unexpected_variable', name })),
|
|
];
|
|
}
|
|
|
|
function evaluateContent(content: string, sensitiveWords: Array<{ word: string; level: string }>) {
|
|
const issues: RuleEvaluation[] = [];
|
|
const controlMatches = [...content].filter((char) => /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(char));
|
|
if (controlMatches.length > 0) {
|
|
issues.push({
|
|
ruleCode: 'CONTENT_CONTROL_CHAR',
|
|
ruleName: '短信内容非法控制字符',
|
|
thresholdValue: 0,
|
|
actualValue: controlMatches.length,
|
|
action: 'block',
|
|
reason: `短信内容包含 ${controlMatches.length} 个非法控制字符,处理动作 直接拒绝`,
|
|
});
|
|
}
|
|
const matchedWords = sensitiveWords
|
|
.filter((item) => item.word && content.includes(item.word))
|
|
.map((item) => item.word);
|
|
if (matchedWords.length > 0) {
|
|
issues.push({
|
|
ruleCode: 'SENSITIVE_WORD',
|
|
ruleName: '敏感词命中',
|
|
thresholdValue: 0,
|
|
actualValue: matchedWords.length,
|
|
action: 'block',
|
|
reason: `短信内容命中敏感词:${matchedWords.join('、')},处理动作 直接拒绝`,
|
|
});
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
function contentIssueToHit(issue: RuleEvaluation) {
|
|
return issue;
|
|
}
|
|
|
|
function inferVariables(content: string) {
|
|
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
|
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
|
}
|
|
|
|
function metricValue(metric: string, metrics: Record<string, number | null | undefined>) {
|
|
return Number(metrics[metric] ?? 0);
|
|
}
|
|
|
|
function decideRiskAction(hits: RuleEvaluation[]) {
|
|
if (hits.some((hit) => hit.action === 'block')) {
|
|
return { status: 'rejected', riskDecision: 'block' };
|
|
}
|
|
if (hits.some((hit) => hit.action === 'manual_review')) {
|
|
return { status: 'pending_review', riskDecision: 'manual_review' };
|
|
}
|
|
return { status: 'approved', riskDecision: 'allow' };
|
|
}
|
|
|
|
function formatNumber(value: number) {
|
|
return Number.isInteger(value) ? String(value) : value.toFixed(4);
|
|
}
|
|
|
|
function formatAction(action: string) {
|
|
if (action === 'block') {
|
|
return '直接拒绝';
|
|
}
|
|
if (action === 'manual_review') {
|
|
return '人工审核';
|
|
}
|
|
return '放行';
|
|
}
|