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
+86
View File
@@ -26,6 +26,9 @@ model Tenant {
smsSignatures SmsSignature[]
smsTemplates SmsTemplate[]
auditRecords AuditRecord[]
riskRules RiskRule[]
smsSendTasks SmsSendTask[]
riskHitRecords RiskHitRecord[]
}
model User {
@@ -42,6 +45,8 @@ model User {
roles UserRole[]
operationLogs OperationLog[]
auditRecords AuditRecord[]
createdSmsSendTasks SmsSendTask[] @relation("SmsSendTaskCreator")
reviewedSmsSendTasks SmsSendTask[] @relation("SmsSendTaskReviewer")
}
model Role {
@@ -293,6 +298,7 @@ model SmsApplication {
ipAllowlist SmsApplicationIpAllowlist[]
signatures SmsSignature[]
templates SmsTemplate[]
sendTasks SmsSendTask[]
@@index([tenantId, status])
}
@@ -363,6 +369,7 @@ model SmsTemplate {
application SmsApplication @relation(fields: [applicationId], references: [id])
signature SmsSignature? @relation(fields: [signatureId], references: [id])
variables TemplateVariable[]
sendTasks SmsSendTask[]
@@index([tenantId, auditStatus])
@@index([applicationId])
@@ -599,3 +606,82 @@ model ReportReceiptImport {
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])
}
+2
View File
@@ -8,6 +8,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module';
import { FilesModule } from './files/files.module';
import { HealthController } from './health.controller';
import { PrismaModule } from './prisma/prisma.module';
import { RiskReviewModule } from './risk-review/risk-review.module';
import { SmsConfigModule } from './sms-config/sms-config.module';
import { TenantsModule } from './tenants/tenants.module';
import { UsersModule } from './users/users.module';
@@ -28,6 +29,7 @@ import { UsersModule } from './users/users.module';
BillingModule,
SmsConfigModule,
ChannelsModule,
RiskReviewModule,
],
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);
}
}
+14
View File
@@ -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 {}
+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 '放行';
}
File diff suppressed because one or more lines are too long
+61
View File
@@ -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` 通过。
+54
View File
@@ -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 Spike15000 条消息、并发 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
View File
@@ -16,7 +16,8 @@
"verify:phase2": "npm run verify:phase1",
"verify:phase3": "npm run verify:phase2",
"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": {
"@vitejs/plugin-react": "^6.0.2",