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, 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'; import { detectDrainageContent } from './drainage-content-detection'; /** * 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; cmppRegisteredDelivery?: boolean | null; }, errorCode: string, reason: string, ) { return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason); } async authenticateInboundApplication(data: GatewayInboundAuthDto) { let tenantId: string | undefined; let applicationId: string | undefined; try { const application = await this.facade.findInboundApplication(data.account); tenantId = application?.tenantId; applicationId = application?.id; 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'); } await this.recordInboundConnectRequest(data, { tenantId, applicationId, result: 'authenticated' }); return { applicationId: application.id, tenantId: application.tenantId, account: application.cmppAccount, enterpriseCode: application.cmppEnterpriseCode, passwordCipher: application.secretHash, maxConnections: application.cmppMaxConnections, status: 'authenticated', }; } catch (error) { await this.recordInboundConnectRequest(data, { tenantId, applicationId, result: 'failed', error: error instanceof Error ? error.message : 'unknown error', }); throw error; } } private recordInboundConnectRequest( data: GatewayInboundAuthDto, outcome: { tenantId?: string; applicationId?: string; result: 'authenticated' | 'failed'; error?: string }, ) { return this.prisma.operationLog.create({ data: { tenantId: outcome.tenantId, action: 'cmpp_connection.connect_requested', resource: 'cmpp_downstream_connection', resourceId: outcome.applicationId ?? data.account, ipAddress: data.remoteIp?.trim() || undefined, detail: { direction: 'client_to_platform', result: outcome.result, applicationId: outcome.applicationId ?? null, request: { remoteIp: data.remoteIp?.trim() || null, account: data.account, // Standard CMPP sends AuthenticatorSource rather than a plaintext password; keep both fields truthful. password: data.password ?? null, authSource: data.authSource ?? null, timestamp: data.timestamp ?? null, version: data.version ?? null, requestedVersion: data.requestedVersion ?? null, }, error: outcome.error ?? null, } as Prisma.InputJsonValue, }, }); } async submitInboundMessage(data: GatewayInboundSubmitDto) { if (data.registeredDelivery != null && ![0, 1].includes(data.registeredDelivery)) { throw new BadRequestException('CMPP Registered_Delivery must be 0 or 1'); } 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, registeredDelivery: collection.registeredDelivery ? 1 : 0, 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>, 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>>, 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), registeredDelivery: recent.segments[0]?.registeredDelivery ?? true, }; } 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), registeredDelivery: group.segments[0]?.registeredDelivery ?? true, }; } return { complete: false, groupId: group.id, messageId: group.messageId, receivedSegments: group.segments.length, response: group.response as any, content: '', sequenceId: undefined, registeredDelivery: true, }; } const existing = group.segments.find((item) => item.segmentIndex === fragment.index); if (existing && (existing.contentHash !== contentHash || existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)) || existing.registeredDelivery !== (data.registeredDelivery !== 0))) { 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), registeredDelivery: data.registeredDelivery !== 0, 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), registeredDelivery: segments[0]?.registeredDelivery ?? true, }; }); } 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 drainageDetection = await detectDrainageContent(this.prisma, data.content); 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, ...drainageDetection, billingUnits: billing.billingUnitsPerMessage, unitPrice: receiptRejection ? 0 : billing.unitPrice, amountCents: receiptRejection ? 0 : billing.amountCents, queuePriority, cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId), cmppSubmitGroupMessageId: submitGroupMessageId, cmppRegisteredDelivery: data.registeredDelivery !== 0, 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 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 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; 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 } }); } }