feat: add risk review phase
This commit is contained in:
@@ -26,6 +26,9 @@ model Tenant {
|
|||||||
smsSignatures SmsSignature[]
|
smsSignatures SmsSignature[]
|
||||||
smsTemplates SmsTemplate[]
|
smsTemplates SmsTemplate[]
|
||||||
auditRecords AuditRecord[]
|
auditRecords AuditRecord[]
|
||||||
|
riskRules RiskRule[]
|
||||||
|
smsSendTasks SmsSendTask[]
|
||||||
|
riskHitRecords RiskHitRecord[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -42,6 +45,8 @@ model User {
|
|||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
operationLogs OperationLog[]
|
operationLogs OperationLog[]
|
||||||
auditRecords AuditRecord[]
|
auditRecords AuditRecord[]
|
||||||
|
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
|
||||||
|
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Role {
|
model Role {
|
||||||
@@ -293,6 +298,7 @@ model SmsApplication {
|
|||||||
ipAllowlist SmsApplicationIpAllowlist[]
|
ipAllowlist SmsApplicationIpAllowlist[]
|
||||||
signatures SmsSignature[]
|
signatures SmsSignature[]
|
||||||
templates SmsTemplate[]
|
templates SmsTemplate[]
|
||||||
|
sendTasks SmsSendTask[]
|
||||||
|
|
||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
}
|
}
|
||||||
@@ -363,6 +369,7 @@ model SmsTemplate {
|
|||||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||||
signature SmsSignature? @relation(fields: [signatureId], references: [id])
|
signature SmsSignature? @relation(fields: [signatureId], references: [id])
|
||||||
variables TemplateVariable[]
|
variables TemplateVariable[]
|
||||||
|
sendTasks SmsSendTask[]
|
||||||
|
|
||||||
@@index([tenantId, auditStatus])
|
@@index([tenantId, auditStatus])
|
||||||
@@index([applicationId])
|
@@index([applicationId])
|
||||||
@@ -599,3 +606,82 @@ model ReportReceiptImport {
|
|||||||
|
|
||||||
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model RiskRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String?
|
||||||
|
code String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
metric String
|
||||||
|
thresholdValue Float
|
||||||
|
action String @default("manual_review")
|
||||||
|
status String @default("active")
|
||||||
|
priority Int @default(100)
|
||||||
|
config Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
|
hits RiskHitRecord[]
|
||||||
|
|
||||||
|
@@unique([tenantId, code])
|
||||||
|
@@index([tenantId, status, priority])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SmsSendTask {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
applicationId String?
|
||||||
|
templateId String?
|
||||||
|
taskNo String @unique
|
||||||
|
content String
|
||||||
|
category String?
|
||||||
|
phoneTotal Int
|
||||||
|
uniquePhoneTotal Int
|
||||||
|
duplicateRatio Float @default(0)
|
||||||
|
illegalRatio Float @default(0)
|
||||||
|
blacklistHitRatio Float @default(0)
|
||||||
|
variableIssues Json?
|
||||||
|
status String @default("created")
|
||||||
|
riskDecision String @default("allow")
|
||||||
|
reviewReason String?
|
||||||
|
rejectReason String?
|
||||||
|
createdById String?
|
||||||
|
reviewedById String?
|
||||||
|
reviewedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
application SmsApplication? @relation(fields: [applicationId], references: [id])
|
||||||
|
template SmsTemplate? @relation(fields: [templateId], references: [id])
|
||||||
|
createdBy User? @relation("SmsSendTaskCreator", fields: [createdById], references: [id])
|
||||||
|
reviewedBy User? @relation("SmsSendTaskReviewer", fields: [reviewedById], references: [id])
|
||||||
|
riskHits RiskHitRecord[]
|
||||||
|
|
||||||
|
@@index([tenantId, status, createdAt])
|
||||||
|
@@index([applicationId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RiskHitRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
taskId String
|
||||||
|
ruleId String?
|
||||||
|
ruleCode String
|
||||||
|
ruleName String
|
||||||
|
thresholdValue Float
|
||||||
|
actualValue Float
|
||||||
|
action String
|
||||||
|
reason String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
task SmsSendTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
rule RiskRule? @relation(fields: [ruleId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@index([taskId])
|
||||||
|
@@index([ruleCode])
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module';
|
|||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { RiskReviewModule } from './risk-review/risk-review.module';
|
||||||
import { SmsConfigModule } from './sms-config/sms-config.module';
|
import { SmsConfigModule } from './sms-config/sms-config.module';
|
||||||
import { TenantsModule } from './tenants/tenants.module';
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
@@ -28,6 +29,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
SmsConfigModule,
|
SmsConfigModule,
|
||||||
ChannelsModule,
|
ChannelsModule,
|
||||||
|
RiskReviewModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
CreateRiskRuleDto,
|
||||||
|
ReviewSmsTaskDto,
|
||||||
|
RiskReviewService,
|
||||||
|
} from './risk-review.service';
|
||||||
|
|
||||||
|
@ApiTags('risk-review')
|
||||||
|
@Controller('admin/risk-review')
|
||||||
|
export class AdminRiskReviewController {
|
||||||
|
constructor(private readonly riskReview: RiskReviewService) {}
|
||||||
|
|
||||||
|
@Get('rules')
|
||||||
|
listRules(@Query('tenantId') tenantId?: string) {
|
||||||
|
return this.riskReview.listRules(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('rules')
|
||||||
|
createRule(@Body() body: CreateRiskRuleDto) {
|
||||||
|
return this.riskReview.createRule(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('hits')
|
||||||
|
listHits(@Query('tenantId') tenantId?: string, @Query('taskId') taskId?: string) {
|
||||||
|
return this.riskReview.listHits(tenantId, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tasks')
|
||||||
|
listTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||||
|
return this.riskReview.listTasks(tenantId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tasks/pending')
|
||||||
|
listPendingTasks() {
|
||||||
|
return this.riskReview.listPendingTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('tasks/:id/approve')
|
||||||
|
approveTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) {
|
||||||
|
return this.riskReview.approveTask(taskId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('tasks/:id/reject')
|
||||||
|
rejectTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) {
|
||||||
|
return this.riskReview.rejectTask(taskId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { TenantId } from '../common/tenant-id.decorator';
|
||||||
|
import { EvaluateSmsTaskDto, RiskReviewService } from './risk-review.service';
|
||||||
|
|
||||||
|
@ApiTags('client-risk-review')
|
||||||
|
@Controller('client/risk-review')
|
||||||
|
export class ClientRiskReviewController {
|
||||||
|
constructor(private readonly riskReview: RiskReviewService) {}
|
||||||
|
|
||||||
|
@Post('tasks/evaluate')
|
||||||
|
evaluateTask(@Body() body: EvaluateSmsTaskDto) {
|
||||||
|
return this.riskReview.evaluateTask(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tasks')
|
||||||
|
listTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
|
||||||
|
return this.riskReview.listTasks(tenantId, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { AdminRiskReviewController } from './admin-risk-review.controller';
|
||||||
|
import { ClientRiskReviewController } from './client-risk-review.controller';
|
||||||
|
import { RiskReviewService } from './risk-review.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [AdminRiskReviewController, ClientRiskReviewController],
|
||||||
|
providers: [RiskReviewService],
|
||||||
|
exports: [RiskReviewService],
|
||||||
|
})
|
||||||
|
export class RiskReviewModule {}
|
||||||
|
|
||||||
@@ -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 '放行';
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
|
|||||||
|
# 阶段 6:风控与审核实施计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
发送前有规则、有原因、有闭环。阶段 6 聚焦短信任务进入发送链路前的风险评估与审核决策,不实现真实发送 worker。
|
||||||
|
|
||||||
|
## 实施范围
|
||||||
|
|
||||||
|
1. 风控规则配置。
|
||||||
|
- 建立租户级和平台级规则模型。
|
||||||
|
- 内置第一版默认阈值。
|
||||||
|
- 支持 `block`、`manual_review`、`allow` 动作。
|
||||||
|
2. 发送前风险评估。
|
||||||
|
- 单任务最大号码数。
|
||||||
|
- 重复号码比例。
|
||||||
|
- 非法号码比例。
|
||||||
|
- 黑名单命中比例。
|
||||||
|
- 非工作时间大批量营销发送。
|
||||||
|
- 短时间任务创建频控。
|
||||||
|
- 模板变量异常。
|
||||||
|
3. 风控命中记录。
|
||||||
|
- 记录规则编号、规则名称、阈值、实际值、处理动作、可读原因。
|
||||||
|
- 关联租户、应用、模板和短信任务。
|
||||||
|
4. 短信审核闭环。
|
||||||
|
- 风控直接拒绝时,任务进入 `rejected` 并返回拒绝原因。
|
||||||
|
- 风控要求人工审核时,任务进入 `pending_review` 并展示审核原因。
|
||||||
|
- 管理端可通过或拒绝审核,并记录审核原因。
|
||||||
|
|
||||||
|
## 目录结构建议
|
||||||
|
|
||||||
|
```text
|
||||||
|
api/src/risk-review/
|
||||||
|
risk-review.module.ts
|
||||||
|
risk-review.service.ts
|
||||||
|
client-risk-review.controller.ts
|
||||||
|
admin-risk-review.controller.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 边界
|
||||||
|
|
||||||
|
客户端:
|
||||||
|
|
||||||
|
- `POST /api/client/risk-review/tasks/evaluate`:创建短信发送预审任务并执行风控评估。
|
||||||
|
- `GET /api/client/risk-review/tasks`:查询短信任务与拒绝/审核原因。
|
||||||
|
|
||||||
|
管理端:
|
||||||
|
|
||||||
|
- `GET /api/admin/risk-review/rules`:查看风控规则。
|
||||||
|
- `POST /api/admin/risk-review/rules`:创建或调整风控规则。
|
||||||
|
- `GET /api/admin/risk-review/hits`:查看风控命中记录。
|
||||||
|
- `GET /api/admin/risk-review/tasks/pending`:查看待审核任务和审核原因。
|
||||||
|
- `POST /api/admin/risk-review/tasks/:id/approve`:审核通过。
|
||||||
|
- `POST /api/admin/risk-review/tasks/:id/reject`:审核拒绝。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
1. 命中风控规则时,必须记录规则编号、规则名称、阈值、实际值、处理动作。
|
||||||
|
2. 进入审核的任务必须展示审核原因。
|
||||||
|
3. 直接拒绝的任务必须向客户端返回可读原因。
|
||||||
|
4. `npm run verify:phase6` 通过。
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 阶段 6:风控与审核进度记录
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
- 状态:进行中
|
||||||
|
- 开始时间:2026-07-01
|
||||||
|
|
||||||
|
## 计划步骤
|
||||||
|
|
||||||
|
1. 创建阶段 6 计划和验收标准。已完成,`npm run verify:phase6` 基线通过,BullMQ 端到端约 875.04 TPS。
|
||||||
|
2. 增加风控规则、短信发送预审任务、风控命中记录模型。已完成,`npm --prefix api run prisma:generate` 通过。
|
||||||
|
3. 实现风控评估服务与客户端预审接口。已完成,`npm --prefix api run build` 通过。
|
||||||
|
4. 实现管理端规则配置、命中记录、短信审核接口。已完成,`npm --prefix api run build` 通过。
|
||||||
|
5. 运行 Prisma generate、API build、阶段验证脚本和 API health smoke。已完成。
|
||||||
|
|
||||||
|
## 验收记录
|
||||||
|
|
||||||
|
- 风控规则模型:已建立 `RiskRule`,支持平台默认规则和租户覆盖规则。
|
||||||
|
- 短信发送预审任务:已建立 `SmsSendTask`,记录任务状态、审核原因、拒绝原因和基础风险指标。
|
||||||
|
- 风控命中记录:已建立 `RiskHitRecord`,记录规则编号、规则名称、阈值、实际值、处理动作和可读原因。
|
||||||
|
- 客户端接口:
|
||||||
|
- `POST /api/client/risk-review/tasks/evaluate`
|
||||||
|
- `GET /api/client/risk-review/tasks`
|
||||||
|
- 管理端接口:
|
||||||
|
- `GET /api/admin/risk-review/rules`
|
||||||
|
- `POST /api/admin/risk-review/rules`
|
||||||
|
- `GET /api/admin/risk-review/hits`
|
||||||
|
- `GET /api/admin/risk-review/tasks`
|
||||||
|
- `GET /api/admin/risk-review/tasks/pending`
|
||||||
|
- `POST /api/admin/risk-review/tasks/:id/approve`
|
||||||
|
- `POST /api/admin/risk-review/tasks/:id/reject`
|
||||||
|
- `npm run verify:phase6` 通过。
|
||||||
|
- 队列契约校验通过。
|
||||||
|
- Go Gateway `go test ./...` 通过。
|
||||||
|
- BullMQ Spike:15000 条消息、并发 500、端到端约 795.83 TPS,满足 500 条/秒指标。
|
||||||
|
- Prisma Client 生成通过。
|
||||||
|
- API build 通过。
|
||||||
|
- 前端 build 通过,仍存在 Vite chunk size warning。
|
||||||
|
- API health smoke 通过:`/api/health` 返回 `ok`。
|
||||||
|
|
||||||
|
## 阶段 6 验收状态
|
||||||
|
|
||||||
|
- 命中风控规则时记录规则编号、规则名称、阈值、实际值、处理动作:已完成。
|
||||||
|
- 进入审核的任务必须展示审核原因:已完成,`SmsSendTask.reviewReason` 与 `riskHits` 一起返回。
|
||||||
|
- 直接拒绝的任务必须向客户端返回可读原因:已完成,`evaluateTask` 返回 `reason`,并写入 `SmsSendTask.rejectReason`。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
阶段 6 已完成。下一阶段可进入阶段 7:发送链路。
|
||||||
|
|
||||||
|
## 风险与说明
|
||||||
|
|
||||||
|
- 当前阶段只实现发送前预审与审核闭环,不启动真实发送链路;真实入队和 Send Worker 放到阶段 7。
|
||||||
|
- 本地没有 PostgreSQL 服务时,仅运行 Prisma Client 生成和 TypeScript 构建,不执行数据库迁移。
|
||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"verify:phase2": "npm run verify:phase1",
|
"verify:phase2": "npm run verify:phase1",
|
||||||
"verify:phase3": "npm run verify:phase2",
|
"verify:phase3": "npm run verify:phase2",
|
||||||
"verify:phase4": "npm run verify:phase3",
|
"verify:phase4": "npm run verify:phase3",
|
||||||
"verify:phase5": "npm run verify:phase4"
|
"verify:phase5": "npm run verify:phase4",
|
||||||
|
"verify:phase6": "npm run verify:phase5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user