fix: close sms scheduling and billing gaps

This commit is contained in:
hectorzhao
2026-07-01 18:56:05 +08:00
parent 8ba4ef8a13
commit f8c9b78c21
28 changed files with 1480 additions and 26 deletions
+45 -4
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
@@ -168,6 +168,12 @@ export class RiskReviewService {
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;
@@ -177,15 +183,17 @@ export class RiskReviewService {
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([
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;
@@ -199,6 +207,7 @@ export class RiskReviewService {
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({
@@ -214,7 +223,7 @@ export class RiskReviewService {
duplicateRatio,
illegalRatio,
blacklistHitRatio,
variableIssues: variableIssues as Prisma.InputJsonValue,
variableIssues: { variables: variableIssues, content: contentIssues } as unknown as Prisma.InputJsonValue,
status: decision.status,
riskDecision: decision.riskDecision,
reviewReason: decision.status === 'pending_review' ? reason : null,
@@ -418,6 +427,39 @@ function evaluateTemplateVariables(
];
}
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 }));
@@ -450,4 +492,3 @@ function formatAction(action: string) {
}
return '放行';
}