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 reviewContinuation implementation. Cross-method calls return through the stable SendChainService seam. */ export class SendReviewContinuationService { 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 handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { const reviewTask = await this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId }, }); if (!reviewTask) { return { reviewTaskId, decision, affected: 0 }; } const messageRecords = await this.prisma.smsMessageRecord.findMany({ where: { status: 'pending_review', OR: [ { reviewTaskId }, { batchTask: { riskTaskId: reviewTaskId } }, ], }, include: { batchTask: true }, }); if (messageRecords.length === 0) { return { reviewTaskId, decision, affected: 0 }; } const batchTaskIds = new Set(); for (const message of messageRecords) { if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue; if (decision === 'approved') { await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { status: 'queued', errorCode: null, errorMessage: null }, }); await this.prisma.smsBatchTask.update({ where: { id: message.batchTaskId }, data: { status: 'ready', auditStatus: 'approved', reviewReason: reason, rejectReason: null }, }); batchTaskIds.add(message.batchTaskId); } else { await this.releaseMessageReservation( message as typeof message & { tenantId: string; batchTaskId: string }, '模板不匹配人工审核驳回释放冻结', ); await this.prisma.smsBatchTask.update({ where: { id: message.batchTaskId }, data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason }, }); await this.recordCmppFailureReceipt(message, 'REVIEW_REJECTED', reason); } } for (const batchTaskId of batchTaskIds) { await this.facade.enqueueBatchTask(batchTaskId); } return { reviewTaskId, decision, affected: messageRecords.length }; } }