feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
@@ -0,0 +1,840 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { createHash, randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
/**
* R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam.
*/
export class SendInboundEntryService {
private readonly logger = new Logger('SendChainService');
constructor(
private readonly prisma: PrismaService,
private readonly billing: BillingService,
private readonly riskReview: RiskReviewService,
private readonly phoneFrequency: PhoneFrequencyService,
private readonly phoneRouting: PhoneRoutingLookupService,
private readonly facade: SendSubmissionService,
private readonly callbacks: SendSubmissionCallbacks,
) {}
private releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
remark: string,
) {
return this.callbacks.releaseMessageReservation(message, remark);
}
private recordCmppFailureReceipt(
message: {
id: string;
tenantId?: string | null;
batchTaskId?: string | null;
applicationId?: string | null;
messageId: string;
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
},
errorCode: string,
reason: string,
) {
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
}
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
const application = await this.facade.findInboundApplication(data.account);
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
}
if (!application.interfaceEnabled) {
throw new BadRequestException('CMPP interface is disabled for this application');
}
if (application.tenant.certificationStatus !== 'approved') {
throw new BadRequestException('Enterprise certification is not approved');
}
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
return {
applicationId: application.id,
tenantId: application.tenantId,
account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash,
maxConnections: application.cmppMaxConnections,
status: 'authenticated',
};
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
const phoneNumbers = data.phoneNumbers?.length
? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim())
: data.phoneNumber
? [data.phoneNumber.trim()]
: [];
if (phoneNumbers.length === 0) {
throw new BadRequestException('CMPP submit phone number is invalid');
}
const application = await this.facade.findInboundApplication(data.account);
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) {
throw new BadRequestException('CMPP account is disabled for new submissions');
}
if (data.longMessage) {
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
validateInboundApplicationSrcId(data.srcId, application);
const collection = await this.facade.collectInboundLongMessageFragment(data, application, phoneNumbers);
if (collection.response) {
return collection.response;
}
if (!collection.complete) {
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
messageId: collection.messageId,
status: 'fragment_pending',
fragmentPending: true,
receivedSegments: collection.receivedSegments,
segmentTotal: data.longMessage.total,
phoneCount: phoneNumbers.length,
messages: phoneNumbers.map((phoneNumber) => ({
phoneNumber,
messageId: collection.messageId,
status: 'fragment_pending',
})),
};
}
try {
const response = await this.facade.recoverCompletedInboundLongMessageResponse(
collection.messageId,
phoneNumbers,
) ?? await this.facade.submitCompleteInboundMessage({
...data,
content: collection.content,
sequenceId: collection.sequenceId,
longMessage: undefined,
}, phoneNumbers, application, collection.messageId);
await this.prisma.cmppInboundLongMessage.update({
where: { id: collection.groupId },
data: {
status: 'completed',
response: JSON.parse(JSON.stringify(response)) as Prisma.InputJsonValue,
completedAt: new Date(),
},
});
return response;
} catch (error) {
await this.prisma.cmppInboundLongMessage.update({
where: { id: collection.groupId },
data: {
status: 'rejected',
completedAt: new Date(),
},
}).catch(() => undefined);
throw error;
}
}
return this.facade.submitCompleteInboundMessage(data, phoneNumbers, application);
}
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
const existing = await this.prisma.smsMessageRecord.findMany({
where: {
cmppSubmitGroupMessageId: messageId,
phoneNumber: { in: phoneNumbers },
},
select: {
id: true,
tenantId: true,
applicationId: true,
batchTaskId: true,
messageId: true,
phoneNumber: true,
status: true,
errorCode: true,
},
});
const byPhone = new Map(existing.map((item) => [item.phoneNumber, item]));
const ordered = phoneNumbers.map((phoneNumber) => byPhone.get(phoneNumber));
if (ordered.some((item) => !item)) {
return null;
}
const messages = ordered.map((item, index) => ({
phoneNumber: phoneNumbers[index],
messageId: item!.messageId,
messageRecordId: item!.id,
taskId: item!.batchTaskId ?? '',
status: item!.status,
}));
const first = ordered[0]!;
const dailyLimitRejected = ordered.every((item) => item!.errorCode === 'DAILY_LIMIT');
return {
accepted: !dailyLimitRejected,
tenantId: first.tenantId ?? '',
applicationId: first.applicationId ?? '',
taskId: first.batchTaskId ?? '',
messageId: first.messageId,
messageRecordId: first.id,
status: dailyLimitRejected ? 'rejected' : 'accepted',
result: dailyLimitRejected ? 8 : undefined,
phoneCount: messages.length,
messages,
};
}
async submitCompleteInboundMessage(
data: GatewayInboundSubmitDto,
phoneNumbers: string[],
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
requestedGroupMessageId?: string,
) {
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
const persisted = requestedGroupMessageId
? await this.prisma.smsMessageRecord.findMany({
where: {
cmppSubmitGroupMessageId: requestedGroupMessageId,
phoneNumber: { in: phoneNumbers },
},
select: {
id: true,
tenantId: true,
applicationId: true,
batchTaskId: true,
messageId: true,
phoneNumber: true,
status: true,
errorCode: true,
},
})
: [];
const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item]));
const phoneRejections = await this.facade.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers);
const missingPhoneCount = phoneNumbers.filter((phoneNumber) => (
!persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber)
)).length;
const dailyQuota = missingPhoneCount > 0
? await this.facade.tryReserveDailySendQuota(application.id, missingPhoneCount)
: { reserved: true, dailyLimit: application.dailyLimit ?? 100000 };
const dailyLimitRejection = dailyQuota.reserved
? undefined
: {
code: 'DAILY_LIMIT',
reason: `应用当日发送上限${dailyQuota.dailyLimit}条,本次${missingPhoneCount}条超出剩余配额`,
};
const submitGroupMessageId = requestedGroupMessageId ?? `MSG-${randomUUID()}`;
const submissions = phoneNumbers.map((phoneNumber, index) => ({
phoneNumber,
persisted: persistedByPhone.get(phoneNumber),
receiptRejection: phoneRejections.get(phoneNumber),
messageId: persistedByPhone.get(phoneNumber)?.messageId
?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`),
}));
const results: GatewayInboundSingleSubmitResult[] = [];
const concurrency = 10;
for (let offset = 0; offset < submissions.length; offset += concurrency) {
const batch = submissions.slice(offset, offset + concurrency);
results.push(...await Promise.all(batch.map((submission) => submission.persisted
? Promise.resolve({
accepted: submission.persisted.errorCode !== 'DAILY_LIMIT',
tenantId: submission.persisted.tenantId ?? application.tenantId,
applicationId: submission.persisted.applicationId ?? application.id,
taskId: submission.persisted.batchTaskId ?? '',
messageId: submission.persisted.messageId,
messageRecordId: submission.persisted.id,
status: submission.persisted.status,
})
: this.facade.submitInboundSingleMessage({
...data,
phoneNumber: submission.phoneNumber,
phoneNumbers: undefined,
}, submission.messageId, submitGroupMessageId, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection))));
}
const first = results[0];
return {
...first,
result: dailyLimitRejection ? 8 : undefined,
phoneCount: results.length,
messages: results.map((result, index) => ({
phoneNumber: phoneNumbers[index],
messageId: result.messageId,
messageRecordId: result.messageRecordId,
taskId: result.taskId,
status: result.status,
})),
};
}
async collectInboundLongMessageFragment(
data: GatewayInboundSubmitDto,
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
phoneNumbers: string[],
) {
const fragment = data.longMessage;
if (!fragment || !Number.isInteger(fragment.reference) || fragment.reference < 0 || fragment.reference > 65535
|| !Number.isInteger(fragment.total) || fragment.total < 2 || fragment.total > 255
|| !Number.isInteger(fragment.index) || fragment.index < 1 || fragment.index > fragment.total
|| !Number.isInteger(fragment.format) || fragment.format < 0 || fragment.format > 255) {
throw new BadRequestException('CMPP long message fragment metadata is invalid');
}
const groupKey = createHash('sha256').update(JSON.stringify({
applicationId: application.id,
account: data.account,
srcId: data.srcId?.trim() ?? '',
phoneNumbers,
reference: fragment.reference,
total: fragment.total,
format: fragment.format,
})).digest('hex');
const contentHash = createHash('sha256').update(data.content).digest('hex');
const now = new Date();
const expiresAt = new Date(now.getTime() + positiveInteger(
process.env.CMPP_INBOUND_LONG_MESSAGE_TTL_SECONDS,
300,
) * 1000);
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${groupKey}, 0))`;
await tx.cmppInboundLongMessage.updateMany({
where: {
groupKey,
status: { in: ['collecting', 'processing'] },
expiresAt: { lte: now },
},
data: { status: 'expired', completedAt: now },
});
const recent = await tx.cmppInboundLongMessage.findFirst({
where: {
groupKey,
expiresAt: { gt: now },
},
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
orderBy: { createdAt: 'desc' },
});
const matchingRecentSegment = recent?.segments.find((item) => item.segmentIndex === fragment.index);
if (recent && ['completed', 'rejected'].includes(recent.status)
&& matchingRecentSegment?.contentHash === contentHash
&& matchingRecentSegment.sequenceId === (data.sequenceId == null ? null : String(data.sequenceId))) {
return {
complete: recent.status === 'completed',
groupId: recent.id,
messageId: recent.messageId,
receivedSegments: recent.segments.length,
response: recent.response as any,
content: recent.segments.map((item) => item.content).join(''),
sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId),
};
}
let group = recent && ['collecting', 'processing'].includes(recent.status) ? recent : null;
if (!group) {
group = await tx.cmppInboundLongMessage.create({
data: {
tenantId: application.tenantId,
applicationId: application.id,
groupKey,
account: data.account,
srcId: data.srcId?.trim() || null,
phoneNumbers,
concatReference: fragment.reference,
segmentTotal: fragment.total,
msgFmt: fragment.format,
messageId: `MSG-${randomUUID()}`,
expiresAt,
},
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
});
}
if (group.status === 'processing') {
const processingStaleMs = positiveInteger(
process.env.CMPP_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS,
) * 1000;
const complete = group.segments.length === fragment.total
&& group.segments.every((item, index) => item.segmentIndex === index + 1);
if (complete && now.getTime() - group.updatedAt.getTime() >= processingStaleMs) {
await tx.cmppInboundLongMessage.update({
where: { id: group.id },
data: { status: 'processing', expiresAt },
});
return {
complete: true,
groupId: group.id,
messageId: group.messageId,
receivedSegments: group.segments.length,
response: null,
content: group.segments.map((item) => item.content).join(''),
sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId),
};
}
return {
complete: false,
groupId: group.id,
messageId: group.messageId,
receivedSegments: group.segments.length,
response: group.response as any,
content: '',
sequenceId: undefined,
};
}
const existing = group.segments.find((item) => item.segmentIndex === fragment.index);
if (existing && (existing.contentHash !== contentHash
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) {
throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`);
}
if (!existing) {
await tx.cmppInboundLongMessageSegment.create({
data: {
groupId: group.id,
segmentIndex: fragment.index,
sequenceId: data.sequenceId == null ? null : String(data.sequenceId),
content: data.content,
contentHash,
},
});
}
const segments = await tx.cmppInboundLongMessageSegment.findMany({
where: { groupId: group.id },
orderBy: { segmentIndex: 'asc' },
});
const complete = segments.length === fragment.total
&& segments.every((item, index) => item.segmentIndex === index + 1);
if (complete) {
await tx.cmppInboundLongMessage.update({
where: { id: group.id },
data: { status: 'processing', expiresAt },
});
}
return {
complete,
groupId: group.id,
messageId: group.messageId,
receivedSegments: segments.length,
response: null,
content: complete ? segments.map((item) => item.content).join('') : '',
sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId),
};
});
}
async expireInboundLongMessages(now = new Date()) {
return this.prisma.cmppInboundLongMessage.updateMany({
where: {
status: { in: ['collecting', 'processing'] },
expiresAt: { lte: now },
},
data: {
status: 'expired',
completedAt: now,
},
});
}
async submitInboundSingleMessage(
data: GatewayInboundSubmitDto & { phoneNumber: string },
messageId: string,
submitGroupMessageId: string,
synchronousRejection?: { code: string; reason: string },
receiptRejection?: { code: string; reason: string },
) {
const application = await this.facade.findInboundApplication(data.account);
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
const template = await this.facade.resolveInboundTemplateCandidate(application.id, data.content);
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
const unitPrice = moneyToNumber(application.customerUnitPrice);
const queuePriority = normalizeQueuePriority(application.queuePriority);
const billing = this.billing.estimateSmsCost({
tenantId: application.tenantId,
applicationId: application.id,
content: data.content,
phoneCount: 1,
unitPrice,
});
const task = await this.prisma.smsBatchTask.create({
data: {
tenantId: application.tenantId,
applicationId: application.id,
templateId: template?.id,
taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`,
sourceType: 'cmpp',
content: data.content,
phoneTotal: 1,
status: synchronousRejection ? 'rejected' : 'validating',
auditStatus: synchronousRejection ? 'rejected' : undefined,
rejectReason: synchronousRejection?.reason,
progressTotal: 1,
},
});
await this.prisma.smsApiRequest.create({
data: {
tenantId: application.tenantId,
batchTaskId: task.id,
requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`,
sourceIp: data.remoteIp,
userAgent: 'cmpp-gateway',
payloadSummary: { phoneTotal: 1, contentLength: [...data.content].length, account: data.account },
status: synchronousRejection ? 'rejected' : 'accepted',
},
});
const message = await this.prisma.smsMessageRecord.create({
data: {
tenantId: application.tenantId,
batchTaskId: task.id,
applicationId: application.id,
templateId: template?.id,
messageId,
phoneNumber: data.phoneNumber,
content: data.content,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: receiptRejection ? 0 : billing.unitPrice,
amountCents: receiptRejection ? 0 : billing.amountCents,
queuePriority,
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
cmppSubmitGroupMessageId: submitGroupMessageId,
clientSrcId,
applicationExtension: application.cmppApplicationExtension,
status: synchronousRejection ? 'rejected' : 'validating',
errorCode: synchronousRejection?.code,
errorMessage: synchronousRejection?.reason,
},
});
if (synchronousRejection) {
return {
accepted: false,
tenantId: application.tenantId,
applicationId: application.id,
taskId: task.id,
messageId: message.messageId,
messageRecordId: message.id,
status: 'rejected',
};
}
const reject = async (code: string, reason: string) => {
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason },
});
await this.recordCmppFailureReceipt(message, code, reason);
};
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
const drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content);
const drainageInfoId = drainage?.id;
const drainageReason = drainageRejectionReason(drainage);
if (drainageReason) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { drainageInfoId, signatureId: options.signatureId },
});
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
return;
}
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
tenantId: application.tenantId,
applicationId: application.id,
templateId: options.templateId,
content: data.content,
variables: options.templateId ? templateVariables : undefined,
phoneNumber: data.phoneNumber,
sourceType: 'cmpp',
});
if (risk.status === 'rejected') {
await reject('RISK', risk.reason || '短信被风控拒绝');
return;
}
if (risk.status === 'pending_review') {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: {
status: 'pending_review',
reviewTaskId: risk.task?.id,
signatureId: options.signatureId,
drainageInfoId,
},
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
});
return;
}
const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId,
amountCents: billing.amountCents,
});
if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额不足');
return;
}
if (billing.amountCents > 0) {
await this.billing.freeze({
tenantId: application.tenantId,
amountCents: billing.amountCents,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 入站短信冻结',
});
}
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'queued', signatureId: options.signatureId, drainageInfoId },
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
});
await this.facade.enqueueBatchTask(task.id);
};
if (receiptRejection) {
await reject(receiptRejection.code, receiptRejection.reason);
} else if (application.status !== 'active' || application.tenant.status !== 'active') {
await reject('ACCOUNT', '企业或短信应用已停用');
} else if (!application.interfaceEnabled) {
await reject('INTERFACE', '短信应用 CMPP 接口已停用');
} else if (application.tenant.certificationStatus !== 'approved') {
await reject('CERT', '企业认证未通过');
} else if (!template && application.templateMismatchMode === 'manual_review') {
const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content);
if (!signature) {
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
} else {
const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, data.content);
const drainageReason = drainageRejectionReason(drainage);
if (drainageReason) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { drainageInfoId: drainage?.id, signatureId: signature.id },
});
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
messageId,
messageRecordId: message.id,
taskId: task.id,
status: 'rejected',
};
}
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
tenantId: application.tenantId,
applicationId: application.id,
content: data.content,
phoneNumber: data.phoneNumber,
sourceType: 'cmpp',
});
if (risk.status === 'rejected') {
await reject('RISK', risk.reason || '短信被风控拒绝');
} else {
const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId,
amountCents: billing.amountCents,
});
if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额不足');
} else {
if (billing.amountCents > 0) {
await this.billing.freeze({
tenantId: application.tenantId,
amountCents: billing.amountCents,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: 'CMPP 模板不匹配待审核短信冻结',
});
}
const reviewTask = risk.status === 'pending_review' && risk.task
? await this.facade.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id)
: await this.riskReview.aggregateTemplateMismatch({
tenantId: application.tenantId,
applicationId: application.id,
account: data.account,
messageRecordId: message.id,
signatureId: signature.id,
content: data.content,
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: {
status: 'pending_review',
riskTaskId: reviewTask?.id,
auditStatus: 'pending',
reviewReason: reviewTask?.reviewReason ?? '模板不匹配,等待人工审核',
},
});
}
}
}
} else if (!template && application.templateMismatchMode === 'direct_send') {
const signature = await this.facade.resolveInboundSignatureCandidate(application.id, data.content);
if (!signature) {
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
} else {
await queueAfterRiskChecks({ signatureId: signature.id });
}
} else if (!template) {
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
} else if (template.auditStatus !== 'approved') {
await reject('TEMPLATE', '短信模板尚未审核通过');
} else if (!template.signature || template.signature.auditStatus !== 'approved') {
await reject('SIGNATURE', '短信签名尚未审核通过');
} else {
await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id });
}
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
taskId: task.id,
messageId: message.messageId,
messageRecordId: message.id,
status: 'accepted',
};
}
async evaluateRiskWithPhoneFrequency(input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
}) {
const risk = await this.riskReview.evaluateTask({
tenantId: input.tenantId,
applicationId: input.applicationId,
templateId: input.templateId,
content: input.content,
variables: input.variables,
phones: [input.phoneNumber],
sourceType: input.sourceType,
});
if (risk.status === 'rejected') return risk;
const frequencyRejections = await this.phoneFrequency.reserve(
input.tenantId,
input.applicationId,
[input.phoneNumber],
input.sourceType,
);
const rejection = frequencyRejections.get(input.phoneNumber);
return rejection
? { ...risk, status: 'rejected' as const, reason: rejection.reason }
: risk;
}
findInboundApplication(account: string) {
return this.prisma.smsApplication.findFirst({
where: { cmppAccount: account },
include: {
tenant: true,
ipAllowlist: true,
},
});
}
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
const exact = await this.prisma.smsTemplate.findFirst({
where: {
applicationId,
content,
auditStatus: 'approved',
signature: { auditStatus: 'approved' },
},
include: { signature: true },
orderBy: { updatedAt: 'desc' },
});
if (exact) return exact;
const variableTemplates = await this.prisma.smsTemplate.findMany({
where: {
applicationId,
content: { contains: '${' },
auditStatus: 'approved',
signature: { auditStatus: 'approved' },
},
include: { signature: true },
orderBy: { updatedAt: 'desc' },
});
return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null;
}
resolveInboundSignatureCandidate(applicationId: string, content: string) {
const match = content.match(/^【[^】]+】/);
if (!match?.[0]) return null;
return this.prisma.smsSignature.findFirst({
where: {
applicationId,
name: match[0],
auditStatus: 'approved',
},
orderBy: { updatedAt: 'desc' },
});
}
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
if (!signatureId) return undefined;
const candidates = await this.prisma.smsDrainageInfo.findMany({
where: { signatureId, auditStatus: { not: 'deleted' } },
select: { id: true, url: true, auditStatus: true, updatedAt: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
});
const matches = candidates
.map((item) => ({ ...item, normalizedUrl: item.url.trim() }))
.filter((item) => item.normalizedUrl.length > 0 && content.includes(item.normalizedUrl))
.sort((left, right) => right.normalizedUrl.length - left.normalizedUrl.length || right.updatedAt.getTime() - left.updatedAt.getTime());
if (matches.length === 0) return undefined;
const longestLength = matches[0].normalizedUrl.length;
const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength);
if (longestMatches.length !== 1) {
throw new BadRequestException({
code: 'DRAINAGE_MATCH_AMBIGUOUS',
message: '短信内容同时匹配多条等长引流地址,无法确定报备资料',
drainageInfoIds: longestMatches.map((item) => item.id),
});
}
const matched = longestMatches[0];
return { id: matched.id, auditStatus: matched.auditStatus };
}
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
await this.prisma.smsMessageRecord.update({
where: { id: messageRecordId },
data: { reviewTaskId, signatureId, drainageInfoId, status: 'pending_review' },
});
return this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId } });
}
}