import { AttemptCompletion } from './attempt-completion'; import { completionContext, completionDatabase, CompletionRouteRequired } from './completion-context'; import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Queue, Worker } from 'bullmq'; import IORedis from 'ioredis'; import { BillingService } from '../billing/billing.service'; 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 { MetricsService } from '../metrics/metrics.service'; import { OpenApiService } from '../open-api/open-api.service'; import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel, } from './send-chain.contracts'; import { DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, downstreamPendingTimeoutHours, positiveInteger, } from './send-chain.helpers'; import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service'; import { SendCompletionService, type SendCompletionFacade } from './send-completion.service'; import { SendDownstreamRequeueTaskService, type DownstreamRequeueFilter } from './send-downstream-requeue-task.service'; @Injectable() export class SendChainService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(SendChainService.name); private redis?: IORedis; private sendQueue?: Queue; private gatewayQueue?: Queue; private worker?: Worker; private receiptTimeoutInitialTimer?: ReturnType; private receiptTimeoutIntervalTimer?: ReturnType; private receiptTimeoutScanRunning = false; private scheduledDispatchInitialTimer?: ReturnType; private scheduledDispatchIntervalTimer?: ReturnType; private scheduledDispatchScanRunning = false; private inboundLongMessageInitialTimer?: ReturnType; private inboundLongMessageIntervalTimer?: ReturnType; private upstreamReceiptInboxInitialTimer?: ReturnType; private upstreamReceiptInboxIntervalTimer?: ReturnType; private downstreamRequeueTaskIntervalTimer?: ReturnType; private readonly submission: SendSubmissionService; private readonly completion: SendCompletionService; private readonly downstreamRequeueTasks: SendDownstreamRequeueTaskService; private readonly attemptCompletion?: AttemptCompletion; constructor( private readonly prisma: PrismaService, private readonly billing: BillingService, private readonly riskReview: RiskReviewService, private readonly phoneFrequency: PhoneFrequencyService, @Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService, @Optional() phoneRouting?: PhoneRoutingLookupService, @Optional() metrics?: MetricsService, ) { const rootPrisma = prisma; prisma = completionDatabase(prisma); this.prisma = prisma; // Structural unit-test doubles may omit the durable delegate; real Prisma always has it. const coordinatedBilling = rootPrisma.smsAttemptCompletionWork ? new BillingService(prisma) : billing; const resolvedPhoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma); this.submission = new SendSubmissionService( prisma, billing, riskReview, phoneFrequency, resolvedPhoneRouting, this as unknown as SendSubmissionService, { releaseMessageReservation: (message, remark) => this.releaseMessageReservation(message, remark), recordCmppFailureReceipt: (message, errorCode, reason) => this.recordCmppFailureReceipt(message, errorCode, reason), }, metrics, ); this.completion = new SendCompletionService( prisma, coordinatedBilling, openApi, this as unknown as SendCompletionFacade, ); if (rootPrisma.smsAttemptCompletionWork) { this.attemptCompletion = new AttemptCompletion( rootPrisma, async (kind, payload) => { const event = payload as unknown as { data: GatewayReceiptEventDto & GatewaySubmitResultDto & GatewaySubmitSegmentResultDto; incomingIdentity?: Parameters[1]; olderThanHours?: number; errorCode?: string; reason?: string; }; if (kind === 'receipt') return this.completion.handleReceipt(event.data, event.incomingIdentity); if (kind === 'submit') return this.completion.handleSubmitResult(event.data); if (kind === 'segment') return this.completion.handleSubmitSegmentResult(event.data); if (kind === 'timeout') return this.completion.markUnknownTimeout({ olderThanHours: event.olderThanHours }); const message = await prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: completionContext.getStore()!.messageRecordId }, }); if ( message.status === 'delivered' || (message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT') ) return message; return this.completion.recordCmppFailureReceipt(message, event.errorCode!, event.reason!); }, (route) => this.submission.waitForChannelRateLimit(route.channel.id, route.channel.rateLimitPerSecond), ); } this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this); } onModuleInit() { const processRole = process.env.CMPP_PROCESS_ROLE?.trim() || 'all'; if (['all', 'worker', 'callback'].includes(processRole)) this.attemptCompletion?.start(); if (processRole === 'api' || processRole === 'callback') return; if (processRole === 'outbox') { this.submission.startSubmitOutboxPublisher(); return; } if (process.env.API_ENABLE_SEND_WORKER === 'true') { this.startWorker(); } if (process.env.CMPP_INBOUND_WORKFLOW_WORKER_ENABLED === 'true') { this.submission.startInboundWorkflowWorker(); } if (process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED !== 'false') { this.receiptTimeoutInitialTimer = setTimeout( () => void this.runReceiptTimeoutScan(), RECEIPT_TIMEOUT_INITIAL_DELAY_MS, ); this.receiptTimeoutInitialTimer.unref?.(); this.receiptTimeoutIntervalTimer = setInterval( () => void this.runReceiptTimeoutScan(), positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS), ); this.receiptTimeoutIntervalTimer.unref?.(); } if (process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED !== 'false') { this.scheduledDispatchInitialTimer = setTimeout( () => void this.runScheduledDispatchScan(), SCHEDULED_DISPATCH_INITIAL_DELAY_MS, ); this.scheduledDispatchInitialTimer.unref?.(); this.scheduledDispatchIntervalTimer = setInterval( () => void this.runScheduledDispatchScan(), positiveInteger( process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, ), ); this.scheduledDispatchIntervalTimer.unref?.(); } if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') { this.inboundLongMessageInitialTimer = setTimeout( () => void this.expireInboundLongMessages().catch((error) => { this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`); }), INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, ); this.inboundLongMessageInitialTimer.unref?.(); this.inboundLongMessageIntervalTimer = setInterval( () => void this.expireInboundLongMessages().catch((error) => { this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`); }), positiveInteger( process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, ), ); this.inboundLongMessageIntervalTimer.unref?.(); } if (process.env.UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED !== 'false') { this.upstreamReceiptInboxInitialTimer = setTimeout( () => void this.runUpstreamReceiptInboxScan(), UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, ); this.upstreamReceiptInboxInitialTimer.unref?.(); this.upstreamReceiptInboxIntervalTimer = setInterval( () => void this.runUpstreamReceiptInboxScan(), positiveInteger( process.env.UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, ), ); this.upstreamReceiptInboxIntervalTimer.unref?.(); } if (process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED !== 'false') { this.downstreamRequeueTaskIntervalTimer = setInterval( () => void this.downstreamRequeueTasks .runScan() .catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)), positiveInteger(process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_INTERVAL_MS, 1_000), ); this.downstreamRequeueTaskIntervalTimer.unref?.(); } } async onModuleDestroy() { this.attemptCompletion?.stop(); if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer); if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer); if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer); if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer); if (this.inboundLongMessageInitialTimer) clearTimeout(this.inboundLongMessageInitialTimer); if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer); if (this.upstreamReceiptInboxInitialTimer) clearTimeout(this.upstreamReceiptInboxInitialTimer); if (this.upstreamReceiptInboxIntervalTimer) clearInterval(this.upstreamReceiptInboxIntervalTimer); if (this.downstreamRequeueTaskIntervalTimer) clearInterval(this.downstreamRequeueTaskIntervalTimer); await this.worker?.close(); await this.sendQueue?.close(); await this.gatewayQueue?.close(); this.redis?.disconnect(); await this.submission.onModuleDestroy(); } async createBatchTask(data: CreateBatchTaskDto) { return this.submission.createBatchTask(data); } async createHttpBatchTask(data: CreateHttpBatchTaskDto) { return this.submission.createHttpBatchTask(data); } async listBatchTasks(tenantId?: string, status?: string, sourceType = 'client') { const tasks = await this.prisma.smsBatchTask.findMany({ where: { tenantId, status, sourceType }, include: { tenant: true, application: true, template: true, apiRequests: true, }, orderBy: { createdAt: 'desc' }, }); const taskIds = tasks.map((task) => task.id); const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({ by: ['batchTaskId', 'carrier', 'province', 'status'], where: { batchTaskId: { in: taskIds } }, _count: { _all: true }, _sum: { billingUnits: true }, }) : []; return tasks.map((task) => ({ ...task, messageStats: messageStats.filter((item) => item.batchTaskId === task.id), })); } async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { return this.submission.getBatchTask(taskId, tenantId, sourceType); } async listClientTaskMessages(taskId: string, tenantId: string) { await this.getBatchTask(taskId, tenantId, 'client'); return this.listMessages({ tenantId, taskId }); } async listBatchTasksPage(query: { tenantId?: string; status?: string; sourceType?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number; }) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.SmsBatchTaskWhereInput = { tenantId: query.tenantId, status: query.status, sourceType: query.sourceType ?? 'client', taskNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined, application: query.applicationKeyword?.trim() ? { name: { contains: query.applicationKeyword.trim() } } : undefined, createdAt: query.createdAtFrom || query.createdAtTo ? { gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, } : undefined, }; const [tasks, total] = await Promise.all([ this.prisma.smsBatchTask.findMany({ where, include: { tenant: { select: { id: true, name: true } }, application: { select: { id: true, name: true } }, template: { select: { id: true, name: true, content: true } }, apiRequests: true, }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.smsBatchTask.count({ where }), ]); const taskIds = tasks.map((task) => task.id); const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({ by: ['batchTaskId', 'carrier', 'province', 'status'], where: { batchTaskId: { in: taskIds } }, _count: { _all: true }, _sum: { billingUnits: true }, }) : []; return { items: tasks.map((task) => ({ ...task, messageStats: messageStats.filter((item) => item.batchTaskId === task.id), })), total, page, pageSize, }; } async listAdminBatchTaskMessages(taskId: string, phone?: string, page = 1, pageSize = 20) { const task = await this.prisma.smsBatchTask.findFirst({ where: { id: taskId, sourceType: 'client' }, select: { id: true }, }); if (!task) { throw new NotFoundException('SMS batch task not found'); } const normalizedPage = Math.max(1, Math.floor(Number(page) || 1)); const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(Number(pageSize) || 20))); const where: Prisma.SmsMessageRecordWhereInput = { batchTaskId: taskId, ...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}), }; const [items, total] = await Promise.all([ this.prisma.smsMessageRecord.findMany({ where, select: { id: true, phoneNumber: true, province: true, carrier: true, status: true, }, orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }], skip: (normalizedPage - 1) * normalizedPageSize, take: normalizedPageSize, }), this.prisma.smsMessageRecord.count({ where }), ]); return { items, total, page: normalizedPage, pageSize: normalizedPageSize }; } listMessages( query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string; } = {}, ) { return this.prisma.smsMessageRecord.findMany({ where: { tenantId: query.tenantId, applicationId: query.applicationId, channelId: query.channelId, batchTaskId: query.taskId, phoneNumber: query.phoneNumber, status: query.status, }, include: { tenant: true, application: true, channel: true, submitRecords: { include: { channel: true }, orderBy: { createdAt: 'asc' } }, receiptRecords: { include: { channel: true }, orderBy: { createdAt: 'asc' } }, }, orderBy: { queuedAt: 'desc' }, }); } listSubmitRecords(taskId?: string) { return this.prisma.smsSubmitRecord.findMany({ where: { batchTaskId: taskId }, orderBy: { createdAt: 'desc' }, }); } listReceiptRecords(taskId?: string) { return this.prisma.smsReceiptRecord.findMany({ where: { batchTaskId: taskId }, orderBy: { createdAt: 'desc' }, }); } listUplinkMessages(tenantId?: string, channelId?: string) { return this.prisma.smsUplinkMessage.findMany({ where: { tenantId, channelId }, include: { application: true, channel: true, messageRecord: { include: { application: true } } }, orderBy: { receivedAt: 'desc' }, }); } async previewImport(data: ImportPreviewDto) { return this.submission.previewImport(data); } async confirmImport(data: ConfirmImportDto) { return this.submission.confirmImport(data); } async enqueueBatchTask(taskId: string, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) { return this.submission.enqueueBatchTask(taskId, preparedMessage); } async cancelBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { const task = await this.prisma.smsBatchTask.findFirst({ where: { id: taskId, tenantId, sourceType } }); if (!task) { throw new NotFoundException('SMS batch task not found'); } if (task.status !== 'scheduled') { throw new BadRequestException('Only scheduled SMS batch tasks can be canceled before dispatch'); } await this.prisma.smsMessageRecord.updateMany({ where: { batchTaskId: taskId, status: 'scheduled' }, data: { status: 'canceled', errorMessage: '定时任务已取消' }, }); return this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'canceled', canceledAt: new Date() }, }); } async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { return this.submission.handleReviewDecision(reviewTaskId, decision, reason); } async terminateBatchTask(taskId: string) { const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } }); if (!task) { throw new NotFoundException('SMS batch task not found'); } if (['finished', 'completed', 'failed', 'canceled', 'rejected'].includes(task.status)) { throw new BadRequestException('SMS batch task is already final'); } await this.prisma.smsMessageRecord.updateMany({ where: { batchTaskId: taskId, status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } }, data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' }, }); await this.refreshTaskProgress(taskId); return this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'canceled', canceledAt: new Date(), rejectReason: '运营终止任务' }, }); } async dispatchDueScheduledTasks(now = new Date()) { return this.submission.dispatchDueScheduledTasks(now); } private async runScheduledDispatchScan() { return this.submission.runScheduledDispatchScan(); } startWorker() { return this.submission.startWorker(); } async processSendJob(job: SendJob) { return this.submission.processSendJob(job); } async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) { if (this.attemptCompletion && !completionContext.getStore()) { const message = await this.completion.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); const source = await this.completion.resolveSubmitRecordForGatewaySegmentResult(message.id, data); return this.attemptCompletion.enqueue(message.id, source.id, 'segment', { data }); } return this.completion.handleSubmitSegmentResult(data); } private async resolveSubmitRecordForGatewaySegmentResult( messageRecordId: string, data: GatewaySubmitSegmentResultDto, ) { return this.completion.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data); } async handleSubmitResult(data: GatewaySubmitResultDto) { if (this.attemptCompletion && !completionContext.getStore()) { const message = await this.completion.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId); const source = await this.completion.resolveSubmitRecordForGatewayResult(message.id, data); return this.attemptCompletion.enqueue(message.id, source.id, 'submit', { data }, data.eventId); } return this.completion.handleSubmitResult(data); } private async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) { return this.completion.resolveSubmitRecordForGatewayResult(messageRecordId, data); } async intakeReceipt(data: GatewayReceiptEventDto) { return this.completion.intakeReceipt(data); } async processPendingUpstreamReceiptInbox(limit = 100) { return this.completion.processPendingUpstreamReceiptInbox(limit); } private async processUpstreamReceiptInboxRecord(id: string) { return this.completion.processUpstreamReceiptInboxRecord(id); } private async runUpstreamReceiptInboxScan() { return this.completion.runUpstreamReceiptInboxScan(); } async handleReceipt( data: GatewayReceiptEventDto, incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string; }, ) { if (this.attemptCompletion && !completionContext.getStore()) { const resolved = await this.completion.resolveReceiptMessage(data, incomingIdentity); if (!resolved.submitRecordId) throw new NotFoundException('回执缺少可确认的提交尝试关联'); return this.attemptCompletion.enqueue(resolved.message.id, resolved.submitRecordId, 'receipt', { data, incomingIdentity, }); } return this.completion.handleReceipt(data, incomingIdentity); } async handleUplink(data: GatewayUplinkEventDto) { return this.completion.handleUplink(data); } async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) { return this.completion.listPendingDownstreamDeliveries(data); } async markDownstreamDeliveryDelivered(id: string) { return this.completion.markDownstreamDeliveryDelivered(id); } async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) { return this.completion.markDownstreamDeliverySent(data); } async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) { return this.completion.acknowledgeDownstreamDelivery(data); } async markDownstreamDeliveryFailed( id: string, errorMessage?: string, failureType: GatewayDownstreamFailureType = 'send_failed', attempt?: GatewayDownstreamSentDto, ) { return this.completion.markDownstreamDeliveryFailed(id, errorMessage, failureType, attempt); } async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) { return this.completion.recordGatewaySubmitDeadLetter(data); } async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) { return this.completion.recordGatewayDownstreamRecoveryStatus(data); } async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) { return this.completion.requeueGatewaySubmitDeadLetter(id, data); } async resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) { return this.completion.resolveGatewaySubmitDeadLetter(id, operatorId); } async recoverStaleGatewaySubmitRequeues(now = new Date()) { return this.completion.recoverStaleGatewaySubmitRequeues(now); } async requeueDownstreamDelivery(id: string) { return this.completion.requeueDownstreamDelivery(id); } async recoverStaleDownstreamManualRequeues(now = new Date()) { return this.completion.recoverStaleDownstreamManualRequeues(now); } async batchRequeueDownstreamDeliveries(ids: string[]) { return this.completion.batchRequeueDownstreamDeliveries(ids); } previewDownstreamRequeueTask(filter: DownstreamRequeueFilter, operatorId?: string) { return this.downstreamRequeueTasks.preview(filter, operatorId); } createDownstreamRequeueTask( data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, operatorId?: string, ) { return this.downstreamRequeueTasks.create(data, operatorId); } listDownstreamRequeueTasks(query: { status?: string; page?: number; pageSize?: number }) { return this.downstreamRequeueTasks.list(query); } getDownstreamRequeueTask(id: string) { return this.downstreamRequeueTasks.get(id); } listDownstreamRequeueTaskItems( id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number }, ) { return this.downstreamRequeueTasks.listItems(id, query); } changeDownstreamRequeueTaskStatus(id: string, action: 'pause' | 'resume' | 'terminate', operatorId?: string) { return this.downstreamRequeueTasks.changeStatus(id, action, operatorId); } async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) { return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId); } private async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) { return this.completion.queueAndTryDownstreamDelivery(data); } private async resolveUplinkMatch( data: GatewayUplinkEventDto, channel: { id: string; srcId?: string | null }, ): Promise<{ tenantId?: string; applicationId?: string; messageRecordId?: string; matchStatus: string; matchReason: string; candidates: UplinkMatchCandidateInput[]; }> { return this.completion.resolveUplinkMatch(data, channel); } async authenticateInboundApplication(data: GatewayInboundAuthDto) { return this.submission.authenticateInboundApplication(data); } async submitInboundMessage(data: GatewayInboundSubmitDto) { return this.submission.submitInboundMessage(data); } private async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) { return this.submission.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers); } private async submitCompleteInboundMessage( data: GatewayInboundSubmitDto, phoneNumbers: string[], application: Awaited>, requestedGroupMessageId?: string, requestedMessageIds?: string[], workflowKey?: string, ) { return this.submission.submitCompleteInboundMessage( data, phoneNumbers, application, requestedGroupMessageId, requestedMessageIds, workflowKey, ); } private async collectInboundLongMessageFragment( data: GatewayInboundSubmitDto, application: NonNullable>>, phoneNumbers: string[], ) { return this.submission.collectInboundLongMessageFragment(data, application, phoneNumbers); } async expireInboundLongMessages(now = new Date()) { return this.submission.expireInboundLongMessages(now); } private async submitInboundSingleMessage( data: GatewayInboundSubmitDto & { phoneNumber: string }, messageId: string, submitGroupMessageId: string, application: NonNullable>>, synchronousRejection?: { code: string; reason: string }, receiptRejection?: { code: string; reason: string }, workflowItemKey?: string, ) { return this.submission.submitInboundSingleMessage( data, messageId, submitGroupMessageId, application, synchronousRejection, receiptRejection, workflowItemKey, ); } /** * CMPP 单号码提交必须在生成最终入队决定前原子占用号码频次。 * 频次规则是直接拒绝,因此优先级高于其他规则产生的待人工审核结果。 */ private async evaluateRiskWithPhoneFrequency( input: { tenantId: string; applicationId: string; templateId?: string; content: string; variables?: Record; phoneNumber: string; sourceType: 'cmpp'; }, reservationKey?: string, ) { return this.submission.evaluateRiskWithPhoneFrequency(input, reservationKey); } async markUnknownTimeout(data: TimeoutUnknownDto) { if (this.attemptCompletion && !completionContext.getStore()) { const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, 72); const candidates = await this.prisma.smsMessageRecord.findMany({ where: { tenantId: { not: null }, OR: [ { status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: new Date(Date.now() - olderThanHours * 3600_000) }, }, { status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null }, ], }, select: { id: true, submitId: true, status: true }, take: 100, }); let timeout = 0; for (const message of candidates) { const source = message.submitId ? await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: message.submitId } }) : null; const result = await this.attemptCompletion.enqueue(message.id, source?.id, 'timeout', { olderThanHours }); if (message.status !== 'timeout' && result?.status === 'timeout') timeout++; } return { timeout }; } return this.completion.markUnknownTimeout(data); } async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) { return this.completion.markExpiredDownstreamDeliveries(olderThanHours); } private async runReceiptTimeoutScan() { return this.completion.runReceiptTimeoutScan(); } private async submitMessageToGateway( message: { id: string; tenantId: string; batchTaskId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; submitId?: string | null; messageId: string; phoneNumber: string; content: string; billingUnits: number; queuePriority?: string | null; clientSrcId?: string | null; applicationExtension?: string | null; template?: { signature?: { id?: string | null; name?: string | null } | null } | null; signature?: { id?: string | null; name?: string | null } | null; }, routed: RoutedChannel, attempt: number, retryOfSubmitRecordId?: string, ) { return this.submission.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId); } private async retryMessageIfAllowed( message: { id: string; tenantId: string; batchTaskId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; submitId?: string | null; messageId: string; phoneNumber: string; content: string; billingUnits: number; queuedAt?: Date; clientSrcId?: string | null; applicationExtension?: string | null; carrier?: string | null; province?: string | null; }, reason: string, sourceSubmitRecordId?: string, ) { return this.completion.retryMessageIfAllowed(message, reason, sourceSubmitRecordId); } private async selectChannelForMessage( message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null; }, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, ): Promise { const context = completionContext.getStore(); if (context) { const prepare = () => this.submission.selectChannelForMessage(message, { ...options, previewOnly: true }); if (!context.routePlanned) throw new CompletionRouteRequired(prepare); const current = await this.submission.selectChannelForMessage(message, options); if (!context.route || current.channel.id !== context.route.channel.id) throw new CompletionRouteRequired(prepare); return current; } return this.submission.selectChannelForMessage(message, options); } private async findApplicationRoute( tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string, ) { return this.submission.findApplicationRoute(tenantId, applicationId, carrier, signatureId); } private async identifyCarrier(phoneNumber: string) { return this.submission.identifyCarrier(phoneNumber); } private async identifyProvince(phoneNumber: string) { return this.submission.identifyProvince(phoneNumber); } private async resolveUnitPrice(tenantId: string, applicationId?: string) { return this.submission.resolveUnitPrice(tenantId, applicationId); } private async resolveQueuePriority(tenantId: string, applicationId?: string): Promise { return this.submission.resolveQueuePriority(tenantId, applicationId); } private async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) { return this.submission.resolveApplicationAccessNumber(tenantId, applicationId); } private findInboundApplication(account: string) { return this.submission.findInboundApplication(account); } private async resolveInboundTemplateCandidate(applicationId: string, content: string) { return this.submission.resolveInboundTemplateCandidate(applicationId, content); } private resolveInboundSignatureCandidate(applicationId: string, content: string) { return this.submission.resolveInboundSignatureCandidate(applicationId, content); } private async resolveTemplateMessageClassification( tenantId: string, applicationId: string | undefined, templateId: string | undefined, content: string, ) { return this.submission.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content); } private async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) { return this.submission.resolveDrainageInfoMatch(signatureId, content); } private async attachMessageToReviewTask( reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string, ) { return this.submission.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId); } async recoverDrainageFailureReceipts() { const messages = await this.prisma.smsMessageRecord.findMany({ where: { OR: [ { drainageReceiptPending: true, batchTask: { sourceType: 'cmpp' } }, { channelWordFinalizationPending: true }, ], status: { in: ['failed', 'submit_failed'] }, }, include: { batchTask: { select: { sourceType: true } } }, orderBy: { updatedAt: 'asc' }, take: 50, }); for (const message of messages) { if (!message.tenantId || !message.batchTaskId) continue; const reason = message.errorMessage ?? '引流发送资格校验未通过'; await this.releaseMessageReservation( { ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId }, reason, ); if (message.channelWordFinalizationPending && message.batchTask?.sourceType !== 'cmpp') { await this.refreshTaskProgress(message.batchTaskId); await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { channelWordFinalizationPending: false }, }); continue; } await this.recordCmppFailureReceipt( message, message.channelWordFinalizationPending ? 'CSW' : message.errorCode?.startsWith('DRN') ? message.errorCode : 'DRN', reason, ); } } private async 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, ) { if (this.attemptCompletion && !completionContext.getStore()) { return this.attemptCompletion.enqueue(message.id, undefined, 'rejection', { errorCode, reason }); } return this.completion.recordCmppFailureReceipt(message, errorCode, reason); } private async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) { return this.submission.classifyRejectedPhones(tenantId, applicationId, phones); } private async validateSendResources( tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions, ) { return this.submission.validateSendResources(tenantId, applicationId, templateId, options); } private async reserveDailySendQuota(applicationId: string, requestedCount: number) { return this.submission.reserveDailySendQuota(applicationId, requestedCount); } private async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) { return this.submission.tryReserveDailySendQuota(applicationId, requestedCount, reservationKey); } private async chargeAcceptedMessage(message: { tenantId: string; applicationId?: string | null; batchTaskId: string; messageId: string; phoneNumber: string; content: string; billingUnits: number; unitPrice: number | bigint; amountCents: number | bigint; }) { return this.completion.chargeAcceptedMessage(message); } private async releaseMessageReservation( message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number; }, remark: string, ) { return this.completion.releaseMessageReservation(message, remark); } private async refundMessage( message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number }, remark: string, ) { return this.completion.refundMessage(message, remark); } private async ensureSignatureReportedForChannel( message: { id: string; templateId?: string | null; template?: { signature?: { id?: string | null; name?: string | null } | null } | null; signature?: { id?: string | null; name?: string | null } | null; }, channelId: string, carrier: string, ) { return this.submission.ensureSignatureReportedForChannel(message, channelId, carrier); } private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null; }) { return this.submission.resolveMessageSignatureId(message); } private async waitForChannelRateLimit(channelId: string, tps: number) { if (completionContext.getStore()) return; return this.submission.waitForChannelRateLimit(channelId, tps); } private async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) { return this.submission.refreshTaskProgress(batchTaskId, knownSingleMessageStatus); } private smsMessageSegmentAuditDelegate() { return this.completion.smsMessageSegmentAuditDelegate(); } private async recordSubmitSegments( message: { id: string; tenantId?: string | null; batchTaskId?: string | null; channelId?: string | null; submitId?: string | null; billingUnits?: number | null; }, data: GatewaySubmitResultDto, submittedAt: Date, ) { return this.completion.recordSubmitSegments(message, data, submittedAt); } private async recordReceiptSegment( message: { id: string; tenantId?: string | null; batchTaskId?: string | null; channelId?: string | null; submitId?: string | null; billingUnits?: number | null; }, data: GatewayReceiptEventDto, deliveredAt: Date, submitRecordId?: string, ) { return this.completion.recordReceiptSegment(message, data, deliveredAt, submitRecordId); } private async aggregateReceiptSegments( message: { id: string; billingUnits?: number | null; }, data: GatewayReceiptEventDto, deliveredAt: Date, submitRecordId?: string, submitId?: string, ) { return this.completion.aggregateReceiptSegments(message, data, deliveredAt, submitRecordId, submitId); } private async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { return this.completion.findMessageByGatewayEvent(messageId, gatewayMessageId); } private async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) { return this.completion.requireMessageByGatewayEvent(messageId, gatewayMessageId); } private async resolveReceiptMessage( data: GatewayReceiptEventDto, incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string; }, ) { return this.completion.resolveReceiptMessage(data, incomingIdentity); } private getSendQueue(): Queue { return this.submission.getSendQueue(); } private getGatewayQueue(): Queue { return this.submission.getGatewayQueue(); } private getRedis() { return this.submission.getRedis(); } private async postGatewayControl(path: string, payload: unknown) { return this.completion.postGatewayControl(path, payload); } private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) { return this.submission.publishGatewaySubmitCommand(command, idempotencyKey); } }