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; } export interface EvaluateSmsTaskDto { tenantId: string; applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; variables?: Record; 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(); 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>, 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, ) { 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) { 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 '放行'; }