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 { detectDrainageContent } from './drainage-content-detection'; import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; /** * R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam. */ export class SendBatchEntryService { 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 createBatchTask(data: CreateBatchTaskDto) { const phones = [...new Set(data.phones ?? [])]; const schedule = parseSchedule(data); await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId); const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones); let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone)); const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([ this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content), this.facade.resolveUnitPrice(data.tenantId, data.applicationId), this.facade.resolveQueuePriority(data.tenantId, data.applicationId), this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId), detectDrainageContent(this.prisma, data.content), ]); const risk = messageClassification.rejectionReason ? { status: 'rejected', reason: messageClassification.rejectionReason, task: null } : await this.riskReview.evaluateTask({ tenantId: data.tenantId, applicationId: data.applicationId, templateId: data.templateId, content: data.content, category: data.category, phones, variables: messageClassification.variables ?? data.variables, createdById: data.createdById, sourceType: data.sourceType ?? 'client', }); let frequencyRejectedAll = false; let frequencyBatchReason: string | undefined; if (risk.status !== 'rejected' && sendablePhones.length > 0) { const frequencyRejections = await this.phoneFrequency.reserve( data.tenantId, data.applicationId, sendablePhones, data.sourceType ?? 'client', ); for (const [phone, rejection] of frequencyRejections) { phoneRejections.set(phone, rejection); } sendablePhones = sendablePhones.filter((phone) => !frequencyRejections.has(phone)); frequencyRejectedAll = frequencyRejections.size > 0 && sendablePhones.length === 0; frequencyBatchReason = frequencyRejectedAll ? [...frequencyRejections.values()][0]?.reason : undefined; } if (frequencyRejectedAll && risk.status === 'pending_review' && risk.task?.id) { await this.prisma.smsSendTask.update({ where: { id: risk.task.id }, data: { status: 'rejected', riskDecision: 'block', reviewReason: null, rejectReason: frequencyBatchReason, }, }); } const billing = this.billing.estimateSmsCost({ tenantId: data.tenantId, applicationId: data.applicationId, taskId: risk.task?.id, content: data.content, phoneCount: sendablePhones.length, unitPrice, }); const batchStatus = frequencyRejectedAll ? 'rejected' : risk.status === 'approved' && sendablePhones.length === 0 ? 'failed' : statusFromRisk(risk.status, Boolean(schedule.scheduledAt)); const shouldReserveBalance = batchStatus === 'ready'; if (risk.status === 'approved') { const accountCheck = await this.billing.checkAccount({ tenantId: data.tenantId, amountCents: billing.amountCents, }); if (!accountCheck.canSend) { throw new BadRequestException('企业账户余额不足'); } } if (data.applicationId && risk.status !== 'rejected' && sendablePhones.length > 0) { await this.facade.reserveDailySendQuota(data.applicationId, sendablePhones.length); } const task = await this.prisma.smsBatchTask.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, templateId: data.templateId, taskNo: `BT-${Date.now()}-${randomUUID().slice(0, 8)}`, sourceType: data.sourceType ?? 'client', content: data.content, category: data.category, phoneTotal: phones.length, status: batchStatus, riskTaskId: risk.task?.id, auditStatus: frequencyRejectedAll || risk.status === 'rejected' ? 'rejected' : risk.status === 'pending_review' ? 'pending' : 'approved', reviewReason: !frequencyRejectedAll && risk.status === 'pending_review' ? risk.reason : null, rejectReason: frequencyRejectedAll ? frequencyBatchReason : risk.status === 'rejected' ? risk.reason : null, progressTotal: phones.length, scheduledAt: schedule.scheduledAt, createdById: data.createdById, }, }); if (shouldReserveBalance && billing.amountCents > 0) { await this.billing.freeze({ tenantId: data.tenantId, amountCents: billing.amountCents, relatedType: 'sms_batch_task', relatedId: task.id, remark: '发送任务创建冻结', }); } await this.prisma.smsApiRequest.create({ data: { tenantId: data.tenantId, batchTaskId: task.id, requestId: `REQ-${Date.now()}-${randomUUID().slice(0, 8)}`, sourceIp: data.sourceIp, userAgent: data.userAgent, payloadSummary: { phoneTotal: phones.length, contentLength: [...data.content].length, category: data.category, sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate', scheduledAt: schedule.scheduledAt?.toISOString(), }, status: ['rejected', 'failed'].includes(batchStatus) ? 'rejected' : 'accepted', }, }); if (phones.length > 0) { await this.prisma.smsMessageRecord.createMany({ data: phones.map((phone) => { const rejection = phoneRejections.get(phone); const status = rejection ? 'submit_failed' : batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus; return { tenantId: data.tenantId, batchTaskId: task.id, applicationId: data.applicationId, templateId: data.templateId, signatureId: messageClassification.signatureId, drainageInfoId: messageClassification.drainageInfoId, reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined, messageId: `MSG-${randomUUID()}`, clientMessageId: data.clientMessageId, phoneNumber: phone, content: data.content, ...drainageDetection, billingUnits: billing.billingUnitsPerMessage, unitPrice: rejection ? 0 : billing.unitPrice, amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice, queuePriority, clientSrcId: accessNumber.clientSrcId, applicationExtension: accessNumber.applicationExtension, status, submitStatus: rejection ? 'rejected' : undefined, errorCode: rejection?.code, errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined), }; }), }); } if (batchStatus === 'ready' && sendablePhones.length > 0) { await this.facade.enqueueBatchTask(task.id); } else if (batchStatus === 'failed') { await this.facade.refreshTaskProgress(task.id); } return this.facade.getBatchTask(task.id, undefined, data.sourceType ?? 'client'); } async createHttpBatchTask(data: CreateHttpBatchTaskDto) { if (!data.applicationId) { throw new BadRequestException('公开 HTTP 发送必须关联企业应用'); } const template = await this.facade.resolveInboundTemplateCandidate(data.applicationId, data.content); if (!template || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') { throw new BadRequestException('短信内容未匹配当前应用已审核通过的签名和模板'); } const variables = matchTemplateContent(template.content, data.content); if (variables === null) { throw new BadRequestException('短信内容与已审核模板不匹配'); } return this.facade.createBatchTask({ ...data, templateId: template.id, variables, sourceType: 'api', }); } async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') { const task = await this.prisma.smsBatchTask.findFirst({ where: { id: taskId, tenantId, sourceType }, include: { apiRequests: true, messages: { take: 20, orderBy: { queuedAt: 'asc' } } }, }); if (!task) { throw new NotFoundException('SMS batch task not found'); } return task; } async previewImport(data: ImportPreviewDto) { const sizeBytes = Buffer.byteLength(data.content, 'utf8'); if (sizeBytes > 20 * 1024 * 1024) { throw new BadRequestException('导入文件不能超过 20MB'); } const rows = parseImportRows(data.content, data.delimiter); const phones: string[] = []; const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = []; const requiredVariables = data.requiredVariables ?? []; const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({ where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' }, select: { phoneNumber: true }, }) : []; const globalBlacklist = await this.prisma.globalBlacklist.findMany({ where: { status: 'active' }, select: { phoneNumber: true }, }); const blacklist = new Set([...enterpriseBlacklist, ...globalBlacklist].map((item) => item.phoneNumber)); const seen = new Set(); for (const row of rows) { if (!row.phoneNumber) { errors.push({ rowNumber: row.rowNumber, reason: '缺少手机号' }); continue; } if (!/^1[3-9]\d{9}$/.test(row.phoneNumber)) { errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '手机号格式非法' }); continue; } if (seen.has(row.phoneNumber)) { errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '重复号码' }); continue; } if (blacklist.has(row.phoneNumber)) { errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: '命中黑名单' }); continue; } const missingVariables = requiredVariables.filter((name) => !row.variables[name]); if (missingVariables.length > 0) { errors.push({ rowNumber: row.rowNumber, phoneNumber: row.phoneNumber, reason: `变量列缺失:${missingVariables.join(',')}` }); continue; } seen.add(row.phoneNumber); phones.push(row.phoneNumber); } return { fileName: data.fileName, encoding: data.encoding ?? 'utf8', totalRows: rows.length, validCount: phones.length, errorCount: errors.length, phones, errors, }; } async confirmImport(data: ConfirmImportDto) { const preview = await this.facade.previewImport({ tenantId: data.tenantId, applicationId: data.applicationId, content: data.importContent, requiredVariables: data.requiredVariables, }); if (preview.validCount === 0) { throw new BadRequestException('导入文件没有可发送号码'); } return this.facade.createBatchTask({ ...data, phones: preview.phones }); } async resolveUnitPrice(tenantId: string, applicationId?: string) { if (!applicationId) { return 0; } const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId }, select: { tenantId: true, customerUnitPrice: true }, }); if (!application || application.tenantId !== tenantId) { return 0; } return moneyToNumber(application.customerUnitPrice); } async resolveQueuePriority(tenantId: string, applicationId?: string): Promise { if (!applicationId) { return 'normal'; } const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId }, select: { tenantId: true, queuePriority: true }, }); if (!application || application.tenantId !== tenantId) { return 'normal'; } return normalizeQueuePriority(application.queuePriority); } async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) { if (!applicationId) { return { clientSrcId: null, applicationExtension: null }; } const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId }, select: { tenantId: true, cmppClientSrcId: true, cmppApplicationExtension: true }, }); if (!application || application.tenantId !== tenantId) { return { clientSrcId: null, applicationExtension: null }; } return { clientSrcId: application.cmppClientSrcId, applicationExtension: application.cmppApplicationExtension, }; } async resolveTemplateMessageClassification( tenantId: string, applicationId: string | undefined, templateId: string | undefined, content: string, ) { if (templateId) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId }, include: { signature: true }, }); if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') { throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); } const variables = matchTemplateContent(template.content, content); if (variables === null) { throw new BadRequestException('短信内容与选定的审核模板不匹配'); } const drainage = await this.facade.resolveDrainageInfoMatch(template.signatureId, content); return { signatureId: template.signatureId, drainageInfoId: drainage?.id, variables, // 引流资料只做关联与监控,报备审核状态不参与本期发送决策。 rejectionReason: undefined, }; } if (!applicationId) { throw new BadRequestException('自由内容短信必须关联企业应用'); } const [application, signature] = await Promise.all([ this.prisma.smsApplication.findUnique({ where: { id: applicationId }, select: { tenantId: true, templateMismatchMode: true }, }), this.facade.resolveInboundSignatureCandidate(applicationId, content), ]); if (!application || application.tenantId !== tenantId) { throw new BadRequestException('短信应用不存在或不属于当前企业'); } if (!signature) { throw new BadRequestException('短信内容未以当前应用已审核通过的签名开头'); } if (application.templateMismatchMode !== 'direct_send') { throw new BadRequestException('当前应用未允许无模板自由内容直接发送'); } const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, content); return { signatureId: signature.id, drainageInfoId: drainage?.id, variables: undefined, rejectionReason: undefined, }; } async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) { const rejected = new Map(); for (const phone of phones) { if (!/^1\d{10}$/.test(phone)) { rejected.set(phone, { code: 'INVALID_PHONE', reason: '手机号码必须是1开头的11位数字' }); } } const validPhones = phones.filter((phone) => !rejected.has(phone)); if (validPhones.length === 0) { return rejected; } const [globalHits, enterpriseHits] = await Promise.all([ this.prisma.globalBlacklist.findMany({ where: { phoneNumber: { in: validPhones }, status: 'active' }, select: { phoneNumber: true, reason: true }, }), applicationId ? this.prisma.enterpriseBlacklist.findMany({ where: { tenantId, applicationId, phoneNumber: { in: validPhones }, status: 'active' }, select: { phoneNumber: true, reason: true }, }) : Promise.resolve([]), ]); for (const hit of globalHits) { rejected.set(hit.phoneNumber, { code: 'GLOBAL_BLACKLIST', reason: hit.reason?.trim() || '号码命中平台黑名单', }); } for (const hit of enterpriseHits) { rejected.set(hit.phoneNumber, { code: 'ENTERPRISE_BLACKLIST', reason: hit.reason?.trim() || '号码命中企业应用黑名单', }); } return rejected; } async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant || tenant.status !== 'active') { throw new BadRequestException('企业客户不存在或已停用'); } if (tenant.certificationStatus !== 'approved') { throw new BadRequestException('企业认证未通过,不能发送短信'); } if (!applicationId) { return; } const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); if (!application || application.tenantId !== tenantId || application.status !== 'active') { throw new BadRequestException('短信应用不存在或已停用'); } if (!application.interfaceEnabled) { throw new BadRequestException('短信应用接口未开通,不能发送短信'); } if (!templateId) { return; } const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId }, include: { signature: true }, }); if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') { throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); } if (!template.signature || template.signature.auditStatus !== 'approved') { throw new BadRequestException('短信签名未审核通过'); } } async reserveDailySendQuota(applicationId: string, requestedCount: number) { const result = await this.facade.tryReserveDailySendQuota(applicationId, requestedCount); if (!result.reserved) { throw new HttpException({ code: 'DAILY_SEND_LIMIT_EXCEEDED', message: `应用当日发送上限${result.dailyLimit}条,本次${requestedCount}条超出剩余配额`, dailyLimit: result.dailyLimit, requestedCount, }, HttpStatus.TOO_MANY_REQUESTS); } return result; } async tryReserveDailySendQuota(applicationId: string, requestedCount: number) { if (!Number.isInteger(requestedCount) || requestedCount <= 0) { throw new BadRequestException('发送号码数量必须为正整数'); } const usageDate = shanghaiDateKey(); const reservationId = randomUUID(); const rows = await this.prisma.$queryRaw>(Prisma.sql` WITH application_limit AS ( SELECT id, COALESCE("dailyLimit", 100000)::integer AS "dailyLimit" FROM "SmsApplication" WHERE id = ${applicationId} ), reservation AS ( INSERT INTO "SmsApplicationDailyUsage" ( id, "applicationId", "usageDate", "usedCount", "createdAt", "updatedAt" ) SELECT ${reservationId}, id, ${usageDate}::date, ${requestedCount}, NOW(), NOW() FROM application_limit WHERE ${requestedCount} <= "dailyLimit" ON CONFLICT ("applicationId", "usageDate") DO UPDATE SET "usedCount" = "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount", "updatedAt" = NOW() WHERE "SmsApplicationDailyUsage"."usedCount" + EXCLUDED."usedCount" <= (SELECT "dailyLimit" FROM application_limit) RETURNING "usedCount" ) SELECT application_limit."dailyLimit", reservation."usedCount" FROM application_limit LEFT JOIN reservation ON TRUE `); if (rows.length === 0) { throw new NotFoundException('短信应用不存在'); } return { dailyLimit: Number(rows[0].dailyLimit), usedCount: rows[0].usedCount == null ? null : Number(rows[0].usedCount), reserved: rows[0].usedCount != null, }; } }