feat: add risk review phase

This commit is contained in:
hectorzhao
2026-07-01 13:29:24 +08:00
parent ee926fea04
commit 56f835a00f
10 changed files with 743 additions and 2 deletions
+453
View File
@@ -0,0 +1,453 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { 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;
}
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' }],
take: 200,
});
}
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' },
take: 200,
});
}
listTasks(tenantId?: string, status?: string) {
return this.prisma.smsSendTask.findMany({
where: {
tenantId,
status,
},
include: { riskHits: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
}
listPendingTasks() {
return this.listTasks(undefined, 'pending_review');
}
async evaluateTask(data: EvaluateSmsTaskDto) {
await this.ensureDefaultRules();
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, uniquePhones);
const blacklistHitRatio = ratio(blacklistHitCount, phoneTotal);
const [application, template, rules, recentTaskCount] = 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),
]);
const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {});
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,
});
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: variableIssues 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');
}
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');
}
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 },
});
}
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 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, 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 },
}),
this.prisma.enterpriseBlacklist.findMany({
where: { tenantId, phoneNumber: { in: phones }, status: 'active' },
select: { phoneNumber: true },
}),
]);
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 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 '放行';
}