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 scheduledDispatch implementation. Cross-method calls return through the stable SendChainService seam. */ export class SendScheduledDispatchService { private readonly logger = new Logger('SendChainService'); private scheduledDispatchScanRunning = false; 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 dispatchDueScheduledTasks(now = new Date()) { const staleCutoff = new Date(now.getTime() - positiveInteger( process.env.SMS_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, )); const tasks = await this.prisma.smsBatchTask.findMany({ where: { OR: [ { status: 'scheduled', scheduledAt: { lte: now } }, { status: { in: ['scheduled_dispatching', 'scheduled_recovering'] }, updatedAt: { lt: staleCutoff } }, ], }, orderBy: { scheduledAt: 'asc' }, }); const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = []; for (const task of tasks) { const candidateStatus = task.status || 'scheduled'; const claimedStatus = candidateStatus === 'scheduled_dispatching' ? 'scheduled_recovering' : 'scheduled_dispatching'; const claimed = await this.prisma.smsBatchTask.updateMany({ where: { id: task.id, status: candidateStatus, ...(candidateStatus === 'scheduled' ? {} : { updatedAt: { lt: staleCutoff } }), }, data: { status: claimedStatus }, }); if (claimed.count !== 1) continue; let reservationEstablished = false; let dispatchPrepared = false; try { await this.facade.validateSendResources( task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined, { usePersistedTemplateSnapshot: true }, ); const messages = await this.prisma.smsMessageRecord.findMany({ where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } }, select: { id: true, amountCents: true, billingUnits: true }, take: 100000, }); const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0); const existingReservation = await this.prisma.accountTransaction.findFirst({ where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id }, select: { id: true }, }); reservationEstablished = Boolean(existingReservation); if (!reservationEstablished) { const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents }); if (!accountCheck.canSend) { throw new BadRequestException('定时任务到点时企业账户余额不足'); } if (amountCents > 0) { await this.billing.freeze({ tenantId: task.tenantId, amountCents, relatedType: 'sms_batch_task', relatedId: task.id, remark: '定时任务到点冻结', }); reservationEstablished = true; } } dispatchPrepared = true; await this.prisma.smsMessageRecord.updateMany({ where: { batchTaskId: task.id, status: 'scheduled' }, data: { status: 'queued' }, }); const enqueued = await this.facade.enqueueBatchTask(task.id); results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued }); } catch (error) { const reason = error instanceof Error ? error.message : '定时任务到点执行失败'; if (reservationEstablished || dispatchPrepared) { await this.prisma.smsBatchTask.update({ where: { id: task.id }, data: { status: claimedStatus, rejectReason: `调度将在超时后恢复:${reason}` }, }); results.push({ taskId: task.id, status: 'retrying', reason }); continue; } await this.prisma.smsMessageRecord.updateMany({ where: { batchTaskId: task.id, status: 'scheduled' }, data: { status: 'rejected', errorMessage: reason }, }); await this.prisma.smsBatchTask.update({ where: { id: task.id }, data: { status: 'failed', rejectReason: reason }, }); results.push({ taskId: task.id, status: 'failed', reason }); } } return { dispatched: results.filter((result) => result.status === 'queued').length, results }; } async runScheduledDispatchScan() { if (this.scheduledDispatchScanRunning) return; this.scheduledDispatchScanRunning = true; try { await this.facade.dispatchDueScheduledTasks(); } catch (error) { this.logger.error(`Scheduled SMS dispatch scan failed: ${error instanceof Error ? error.message : String(error)}`); } finally { this.scheduledDispatchScanRunning = false; } } }