feat: add risk review phase
This commit is contained in:
@@ -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 '放行';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user