feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { SendChainService, TimeoutUnknownDto } from './send-chain.service';
|
||||
import { TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
@ApiTags('send-chain')
|
||||
@Controller('admin/send')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto, SendChainService } from './send-chain.service';
|
||||
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto } from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
@ApiTags('client-send-chain')
|
||||
@Controller('client/send')
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
GatewaySubmitResultDto,
|
||||
GatewaySubmitSegmentResultDto,
|
||||
GatewayUplinkEventDto,
|
||||
SendChainService,
|
||||
} from './send-chain.service';
|
||||
import { GatewayDownstreamConnectionEventDto, SmsConfigService } from '../sms-config/sms-config.service';
|
||||
} from './send-chain.contracts';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
import { GatewayDownstreamConnectionEventDto } from '../sms-config/sms-config.contracts';
|
||||
import { SmsConfigService } from '../sms-config/sms-config.service';
|
||||
import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service';
|
||||
|
||||
@ApiTags('gateway-events')
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 accounting implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendAccountingService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async chargeAcceptedMessage(message: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
batchTaskId: string;
|
||||
messageId: string;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
unitPrice: number | bigint;
|
||||
amountCents: number | bigint;
|
||||
}) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
const unitPrice = moneyToNumber(message.unitPrice);
|
||||
const billingUnits = message.billingUnits ?? 0;
|
||||
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
|
||||
if (exists?.billingStatus === 'charged') {
|
||||
return;
|
||||
}
|
||||
if (amountCents > 0) {
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-charge-release:${message.messageId}`,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: message.batchTaskId,
|
||||
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
|
||||
});
|
||||
}
|
||||
const transaction = await this.billing.charge({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-charge:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: '提交成功扣费',
|
||||
});
|
||||
const data = {
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? undefined,
|
||||
taskId: message.batchTaskId,
|
||||
messageId: message.messageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
contentLength: [...message.content].length,
|
||||
billingUnits,
|
||||
unitPrice,
|
||||
amountCents,
|
||||
billingStatus: 'charged',
|
||||
transactionId: transaction.id,
|
||||
};
|
||||
if (exists) {
|
||||
await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data });
|
||||
return;
|
||||
}
|
||||
await this.prisma.smsBillingRecord.create({ data });
|
||||
}
|
||||
|
||||
async releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
if (amountCents <= 0) {
|
||||
return;
|
||||
}
|
||||
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
||||
if (charged) {
|
||||
return;
|
||||
}
|
||||
const released = await this.prisma.accountTransaction.findFirst({
|
||||
where: { relatedType: 'sms_message_record', relatedId: message.messageId, transactionType: 'released' },
|
||||
});
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-reservation-release:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: `${remark}: ${message.messageId}`,
|
||||
});
|
||||
}
|
||||
|
||||
async refundMessage(
|
||||
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
const amountCents = moneyToNumber(message.amountCents);
|
||||
if (amountCents <= 0) {
|
||||
return;
|
||||
}
|
||||
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
|
||||
if (refunded) {
|
||||
return;
|
||||
}
|
||||
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
||||
if (!charged) {
|
||||
return;
|
||||
}
|
||||
const transaction = await this.billing.refund({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
idempotencyKey: `sms-refund:${message.messageId}`,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark,
|
||||
});
|
||||
await this.prisma.smsBillingRecord.updateMany({
|
||||
where: { messageId: message.messageId },
|
||||
data: { billingStatus: 'refunded', transactionId: transaction.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
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 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] = 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),
|
||||
]);
|
||||
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,
|
||||
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<string>();
|
||||
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<QueuePriority> {
|
||||
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: drainageRejectionReason(drainage),
|
||||
};
|
||||
}
|
||||
|
||||
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: drainageRejectionReason(drainage),
|
||||
};
|
||||
}
|
||||
|
||||
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
|
||||
const rejected = new Map<string, { code: string; reason: string }>();
|
||||
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<Array<{ dailyLimit: number; usedCount: number | null }>>(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// R8 contract-only declarations. Runtime behavior remains in SendChainService.
|
||||
|
||||
export interface CreateBatchTaskDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
phones: string[];
|
||||
sendMode?: 'immediate' | 'scheduled';
|
||||
scheduledAt?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
createdById?: string;
|
||||
sourceIp?: string;
|
||||
userAgent?: string;
|
||||
sourceType?: 'client' | 'api' | 'cmpp';
|
||||
clientMessageId?: string;
|
||||
}
|
||||
|
||||
export type CreateHttpBatchTaskDto = Omit<CreateBatchTaskDto, 'templateId' | 'variables' | 'sourceType'>;
|
||||
|
||||
export interface GatewayInboundAuthDto {
|
||||
account: string;
|
||||
password?: string;
|
||||
authSource?: string;
|
||||
timestamp?: number;
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export interface GatewayInboundSubmitDto {
|
||||
account: string;
|
||||
phoneNumber?: string;
|
||||
phoneNumbers?: string[];
|
||||
content: string;
|
||||
srcId?: string;
|
||||
destId?: string;
|
||||
sequenceId?: number;
|
||||
remoteIp?: string;
|
||||
longMessage?: {
|
||||
reference: number;
|
||||
total: number;
|
||||
index: number;
|
||||
format: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GatewayInboundSingleSubmitResult {
|
||||
accepted: boolean;
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
taskId: string;
|
||||
messageId: string;
|
||||
messageRecordId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitResultDto {
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
submitId?: string;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId: string;
|
||||
submitStatus: 'accepted' | 'rejected' | 'timeout';
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
segments?: Array<{
|
||||
segmentTotal?: number;
|
||||
segmentIndex?: number;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId?: string;
|
||||
submitStatus?: 'accepted' | 'rejected' | 'timeout' | string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GatewaySubmitSegmentResultDto {
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
submitId?: string;
|
||||
segmentTotal: number;
|
||||
segmentIndex: number;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId?: string;
|
||||
submitStatus: 'accepted' | 'rejected' | 'timeout' | string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
submittedAt?: string;
|
||||
}
|
||||
|
||||
export interface GatewayReceiptEventDto {
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId: string;
|
||||
sequenceId?: number;
|
||||
gatewayMessageId: string;
|
||||
phoneNumber?: string;
|
||||
receiptStatus: 'delivered' | 'undelivered' | 'unknown';
|
||||
rawStatus: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
deliveredAt?: string;
|
||||
connectionId?: string;
|
||||
}
|
||||
|
||||
export interface GatewayUplinkEventDto {
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId: string;
|
||||
sequenceId?: number;
|
||||
phoneNumber: string;
|
||||
destId: string;
|
||||
content: string;
|
||||
receivedAt?: string;
|
||||
}
|
||||
|
||||
export type UplinkMatchCandidateInput = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
messageRecordId?: string;
|
||||
matchSource: 'access_number' | 'phone_window';
|
||||
confidence: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export interface GatewayPendingDeliveryQueryDto {
|
||||
account: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamSentDto {
|
||||
id: string;
|
||||
connectionId?: string;
|
||||
sequenceId?: string;
|
||||
messageId?: string;
|
||||
sentAt?: string;
|
||||
ackDeadlineAt?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentDto {
|
||||
result: number;
|
||||
acknowledgedAt?: string;
|
||||
}
|
||||
|
||||
export type GatewayDownstreamFailureType =
|
||||
| 'send_failed'
|
||||
| 'ack_timeout'
|
||||
| 'ack_rejected'
|
||||
| 'ack_invalid'
|
||||
| 'connection_lost'
|
||||
| 'unrecoverable'
|
||||
| 'queue_timeout';
|
||||
|
||||
export type GatewayControlDeliveryResult = {
|
||||
sent?: boolean;
|
||||
delivered?: boolean;
|
||||
retryable?: boolean;
|
||||
reasonCode?: string;
|
||||
errorMessage?: string;
|
||||
connectionId?: string;
|
||||
sequenceId?: string;
|
||||
messageId?: string;
|
||||
sentAt?: string;
|
||||
ackDeadlineAt?: string;
|
||||
};
|
||||
|
||||
export interface GatewaySubmitDeadLetterDto {
|
||||
streamMessageId: string;
|
||||
traceId?: string;
|
||||
messageId?: string;
|
||||
channelId?: string;
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
submitId?: string;
|
||||
failureCode: string;
|
||||
failureMessage: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
commandPayload?: Record<string, unknown>;
|
||||
rawPayload?: string;
|
||||
deadLetteredAt?: string;
|
||||
}
|
||||
|
||||
export interface RequeueGatewaySubmitExceptionDto {
|
||||
confirmedNotSubmitted?: boolean;
|
||||
reason?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamRecoveryStatusDto {
|
||||
account: string;
|
||||
gatewayInstanceId?: string;
|
||||
state: string;
|
||||
lockOwner?: string;
|
||||
lockExpiresAt?: string;
|
||||
lastAttemptAt?: string;
|
||||
lastSuccessAt?: string;
|
||||
lastFailureAt?: string;
|
||||
nextRetryAt?: string;
|
||||
attemptCount?: number;
|
||||
failureCategory?: string;
|
||||
lastError?: string;
|
||||
lastSkipReason?: string;
|
||||
}
|
||||
|
||||
export interface TimeoutUnknownDto {
|
||||
olderThanHours?: number;
|
||||
}
|
||||
|
||||
export interface ImportPreviewDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
content: string;
|
||||
fileName?: string;
|
||||
encoding?: 'utf8' | 'gbk';
|
||||
delimiter?: ',' | '\t';
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export interface ConfirmImportDto extends CreateBatchTaskDto {
|
||||
importContent: string;
|
||||
requiredVariables?: string[];
|
||||
}
|
||||
|
||||
export interface SendJob {
|
||||
messageRecordId: string;
|
||||
}
|
||||
|
||||
export type QueuePriority = 'normal' | 'priority';
|
||||
|
||||
export type RoutedChannel = {
|
||||
channel: {
|
||||
id: string;
|
||||
code: string;
|
||||
account: string;
|
||||
srcId: string;
|
||||
rateLimitPerSecond: number;
|
||||
unitPrice: number;
|
||||
status: string;
|
||||
carrier?: string | null;
|
||||
sendRegion: string;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
passwordCipher: string;
|
||||
cmppVersion: string;
|
||||
config?: unknown;
|
||||
};
|
||||
carrier: string;
|
||||
province?: string | null;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
routeScope: 'province' | 'national';
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
aggregateReceiptSegmentState,
|
||||
isSameUpstreamEndpointIdentity,
|
||||
receiptEventKey,
|
||||
selectChannelCandidate,
|
||||
} from './send-chain.helpers';
|
||||
|
||||
const connected = [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }];
|
||||
|
||||
describe('send-chain pure policies', () => {
|
||||
it('prefers an approved online province channel while preserving priority order', () => {
|
||||
const items = [
|
||||
{ channelId: 'national', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
{ channelId: 'province', carrier: 'mobile', province: '安徽省', channel: { carrier: 'mobile', sendRegion: '安徽', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
|
||||
expect(selectChannelCandidate(items, {
|
||||
carrier: 'mobile',
|
||||
province: '安徽省',
|
||||
excludedChannelIds: new Set(),
|
||||
approvedChannelIds: new Set(['national', 'province']),
|
||||
})?.channelId).toBe('province');
|
||||
});
|
||||
|
||||
it('falls back to an approved online national channel', () => {
|
||||
const items = [
|
||||
{ channelId: 'offline', carrier: 'mobile', province: '安徽', channel: { carrier: 'mobile', sendRegion: '安徽', status: 'active', connectionStates: [] } },
|
||||
{ channelId: 'national', carrier: 'mobile', province: null, channel: { carrier: 'all', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
|
||||
expect(selectChannelCandidate(items, {
|
||||
carrier: 'mobile',
|
||||
province: '安徽',
|
||||
excludedChannelIds: new Set(),
|
||||
approvedChannelIds: new Set(['offline', 'national']),
|
||||
})?.channelId).toBe('national');
|
||||
});
|
||||
|
||||
it('does not select excluded or unreported channels', () => {
|
||||
const items = [
|
||||
{ channelId: 'excluded', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
{ channelId: 'unreported', carrier: 'mobile', province: null, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } },
|
||||
];
|
||||
|
||||
expect(selectChannelCandidate(items, {
|
||||
carrier: 'mobile',
|
||||
excludedChannelIds: new Set(['excluded']),
|
||||
approvedChannelIds: new Set(['excluded']),
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a segmented message non-terminal until all receipts arrive', () => {
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:00:00Z') }],
|
||||
2,
|
||||
{ channelId: 'channel-1', gatewayMessageId: 'gw-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
|
||||
new Date('2026-07-31T00:01:00Z'),
|
||||
);
|
||||
expect(result).toMatchObject({ terminal: false, segmentTotal: 2, status: 'submitted' });
|
||||
});
|
||||
|
||||
it('marks all delivered segments successful at the latest receipt time', () => {
|
||||
const latest = new Date('2026-07-31T00:02:00Z');
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[
|
||||
{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:01:00Z') },
|
||||
{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: latest },
|
||||
],
|
||||
2,
|
||||
{ channelId: 'channel-1', gatewayMessageId: 'gw-2', receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
|
||||
latest,
|
||||
);
|
||||
expect(result).toMatchObject({ terminal: true, segmentTotal: 2, status: 'delivered', deliveredAt: latest });
|
||||
});
|
||||
|
||||
it('lets a failed segment decide the terminal message result', () => {
|
||||
const result = aggregateReceiptSegmentState(
|
||||
[
|
||||
{ segmentTotal: 2, receiptStatus: 'delivered' },
|
||||
{ segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'ERR' },
|
||||
],
|
||||
2,
|
||||
{ channelId: 'channel-1', gatewayMessageId: 'gw-3', receiptStatus: 'undelivered', rawStatus: 'REJECTD' },
|
||||
new Date('2026-07-31T00:03:00Z'),
|
||||
);
|
||||
expect(result).toMatchObject({ terminal: true, status: 'failed', receiptStatus: 'undelivered', errorCode: 'ERR' });
|
||||
});
|
||||
|
||||
it('normalizes upstream endpoint identity without weakening port or version equality', () => {
|
||||
expect(isSameUpstreamEndpointIdentity(
|
||||
{ account: ' acct ', gatewayHost: 'SMSC.EXAMPLE', gatewayPort: 7890, protocol: 'cmpp', cmppVersion: '2.0' },
|
||||
{ account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0' },
|
||||
)).toBe(true);
|
||||
expect(isSameUpstreamEndpointIdentity(
|
||||
{ account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7890, protocol: 'CMPP', cmppVersion: '2.0' },
|
||||
{ account: 'acct', gatewayHost: 'smsc.example', gatewayPort: 7891, protocol: 'CMPP', cmppVersion: '2.0' },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('generates a stable receipt event key and changes it with logical channel identity', () => {
|
||||
const event = {
|
||||
channelId: 'physical',
|
||||
gatewayMessageId: 'gw-4',
|
||||
phoneNumber: '13800000000',
|
||||
receiptStatus: 'delivered' as const,
|
||||
rawStatus: 'DELIVRD',
|
||||
};
|
||||
expect(receiptEventKey(event, 'logical')).toBe(receiptEventKey({ ...event }, 'logical'));
|
||||
expect(receiptEventKey(event, 'logical')).not.toBe(receiptEventKey(event, 'other'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,602 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { CreateBatchTaskDto, GatewayControlDeliveryResult, GatewayDownstreamRecoveryStatusDto, GatewayDownstreamSentDto, GatewayInboundAuthDto, GatewayReceiptEventDto, GatewaySubmitResultDto, QueuePriority } from './send-chain.contracts';
|
||||
|
||||
// R8 pure policies and deterministic key/status helpers. No database, queue or network access.
|
||||
|
||||
export const SEND_QUEUE = 'sms.send.queue';
|
||||
|
||||
export const GATEWAY_SUBMIT_QUEUE = 'gateway.submit.queue';
|
||||
|
||||
export const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_RETRY_DELAY_MS = 60_000;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS = 30 * 60_000;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_MAX_RETRIES = 10;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS = 72;
|
||||
|
||||
export const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72;
|
||||
|
||||
export const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
export const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000;
|
||||
|
||||
export const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000;
|
||||
|
||||
export const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000;
|
||||
|
||||
export const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS = 60_000;
|
||||
|
||||
export const INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS = 10_000;
|
||||
|
||||
export const DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS = 30;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS = 5_000;
|
||||
|
||||
export const UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS = 1_000;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS = 2 * 60_000;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS = 30;
|
||||
|
||||
export const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS = 72;
|
||||
|
||||
export const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
export const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
|
||||
priority: 1,
|
||||
normal: 100,
|
||||
};
|
||||
|
||||
export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
|
||||
return `gateway:submit:requeue:${deadLetterId}:${attempt}`;
|
||||
}
|
||||
|
||||
export function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) {
|
||||
if (!drainage || drainage.auditStatus === 'approved') return undefined;
|
||||
return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`;
|
||||
}
|
||||
|
||||
export function statusFromRisk(status: string, scheduled: boolean) {
|
||||
if (status === 'rejected') {
|
||||
return 'rejected';
|
||||
}
|
||||
if (status === 'pending_review') {
|
||||
return 'pending_review';
|
||||
}
|
||||
if (scheduled) {
|
||||
return 'scheduled';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
export function parseSchedule(data: CreateBatchTaskDto) {
|
||||
if (data.sendMode !== 'scheduled' && !data.scheduledAt) {
|
||||
return { scheduledAt: null };
|
||||
}
|
||||
if (!data.scheduledAt) {
|
||||
throw new BadRequestException('定时发送必须提供 scheduledAt');
|
||||
}
|
||||
const scheduledAt = new Date(data.scheduledAt);
|
||||
if (Number.isNaN(scheduledAt.getTime())) {
|
||||
throw new BadRequestException('scheduledAt 时间格式无效');
|
||||
}
|
||||
if (scheduledAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('scheduledAt 必须晚于当前时间');
|
||||
}
|
||||
return { scheduledAt };
|
||||
}
|
||||
|
||||
export function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asDateOrNull(value?: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
export function downstreamRetryDelayMs(retryCount = 1) {
|
||||
const base = downstreamRetryBaseDelayMs();
|
||||
const max = downstreamRetryMaxDelayMs();
|
||||
const attempt = Math.max(1, Math.floor(retryCount));
|
||||
const delay = base * Math.pow(2, Math.max(0, attempt - 1));
|
||||
return Math.min(delay, max);
|
||||
}
|
||||
|
||||
export function downstreamAckTimeoutMs() {
|
||||
const configured = Number(process.env.CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS ?? 30);
|
||||
return Math.max(5, Number.isFinite(configured) ? configured : 30) * 1000;
|
||||
}
|
||||
|
||||
export function downstreamRetryBaseDelayMs() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_DELAY_MS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_DELAY_MS;
|
||||
}
|
||||
|
||||
export function downstreamRetryMaxDelayMs() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_MAX_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS;
|
||||
}
|
||||
|
||||
export function downstreamMaxRetries() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_MAX_RETRIES ?? DEFAULT_DOWNSTREAM_MAX_RETRIES);
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_DOWNSTREAM_MAX_RETRIES;
|
||||
}
|
||||
|
||||
export function downstreamPendingTimeoutHours() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS ?? DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS;
|
||||
}
|
||||
|
||||
export function downstreamControlFailureMessage(result: GatewayControlDeliveryResult) {
|
||||
const reason = String(result.errorMessage ?? '').trim();
|
||||
const code = String(result.reasonCode ?? '').trim();
|
||||
if (reason && code) return `${reason} (${code})`;
|
||||
if (reason) return reason;
|
||||
if (code) return `Gateway 未完成下游投递 (${code})`;
|
||||
return 'Gateway 未完成下游投递,等待自动重试';
|
||||
}
|
||||
|
||||
export function parseImportRows(content: string, delimiter?: ',' | '\t') {
|
||||
const normalized = content.replace(/^\uFEFF/, '');
|
||||
const lines = normalized.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const firstDelimiter = delimiter ?? (lines[0].includes(',') ? ',' : '\t');
|
||||
const firstCells = splitImportLine(lines[0], firstDelimiter);
|
||||
const hasHeader = firstCells.some((cell) => ['phone', 'phoneNumber', 'mobile', '手机号'].includes(cell));
|
||||
const headers = hasHeader ? firstCells : ['phoneNumber'];
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
return dataLines.map((line, index) => {
|
||||
const cells = splitImportLine(line, firstDelimiter);
|
||||
const row: { rowNumber: number; phoneNumber?: string; variables: Record<string, string> } = {
|
||||
rowNumber: (hasHeader ? index + 2 : index + 1),
|
||||
phoneNumber: hasHeader ? cellByHeader(headers, cells, ['phone', 'phoneNumber', 'mobile', '手机号']) : cells[0],
|
||||
variables: {},
|
||||
};
|
||||
headers.forEach((header, cellIndex) => {
|
||||
if (!['phone', 'phoneNumber', 'mobile', '手机号'].includes(header)) {
|
||||
row.variables[header] = cells[cellIndex] ?? '';
|
||||
}
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
export function splitImportLine(line: string, delimiter: ',' | '\t') {
|
||||
return line.split(delimiter).map((cell) => cell.trim().replace(/^"|"$/g, ''));
|
||||
}
|
||||
|
||||
export function cellByHeader(headers: string[], cells: string[], candidates: string[]) {
|
||||
const index = headers.findIndex((header) => candidates.includes(header));
|
||||
return index >= 0 ? cells[index] : undefined;
|
||||
}
|
||||
|
||||
export function normalizeCarrier(carrier?: string | null) {
|
||||
const value = String(carrier ?? '').trim().toLowerCase();
|
||||
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
|
||||
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
|
||||
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
|
||||
if (['all', 'tri', '三网', '全网'].includes(value)) return 'all';
|
||||
return value || 'mobile';
|
||||
}
|
||||
|
||||
export function normalizeQueuePriority(queuePriority?: string | null): QueuePriority {
|
||||
return queuePriority === 'priority' ? 'priority' : 'normal';
|
||||
}
|
||||
|
||||
export function getPositiveConfigInteger(config: unknown, key: string, fallback: number) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||
const value = Number((config as Record<string, unknown>)[key]);
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getNonNegativeConfigInteger(config: unknown, key: string, fallback: number) {
|
||||
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
|
||||
const value = Number((config as Record<string, unknown>)[key]);
|
||||
if (Number.isInteger(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
|
||||
const normalized = normalizeCarrier(channelCarrier);
|
||||
return normalized === 'all' || normalized === targetCarrier;
|
||||
}
|
||||
|
||||
export function normalizeRegion(region?: string | null) {
|
||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||
}
|
||||
|
||||
export function matchTemplateContent(templateContent: string, actualContent: string) {
|
||||
if (templateContent === actualContent) {
|
||||
return {} as Record<string, string>;
|
||||
}
|
||||
const tokenPattern = /\$\{([a-zA-Z0-9_]+)\}/g;
|
||||
const names: string[] = [];
|
||||
let cursor = 0;
|
||||
let pattern = '^';
|
||||
for (const match of templateContent.matchAll(tokenPattern)) {
|
||||
const index = match.index ?? 0;
|
||||
pattern += escapeRegularExpression(templateContent.slice(cursor, index));
|
||||
pattern += '([\\s\\S]+?)';
|
||||
names.push(match[1]);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
pattern += `${escapeRegularExpression(templateContent.slice(cursor))}$`;
|
||||
const matched = new RegExp(pattern, 'u').exec(actualContent);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
const variables: Record<string, string> = {};
|
||||
for (let index = 0; index < names.length; index += 1) {
|
||||
const name = names[index];
|
||||
const value = matched[index + 1];
|
||||
if (variables[name] !== undefined && variables[name] !== value) {
|
||||
return null;
|
||||
}
|
||||
variables[name] = value;
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
export function escapeRegularExpression(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) {
|
||||
const itemProvince = normalizeRegion(item.province);
|
||||
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
||||
return !itemProvince || itemProvince === '全国' || !sendRegion || sendRegion === '全国';
|
||||
}
|
||||
|
||||
export function isProvinceChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }, province?: string | null) {
|
||||
if (!province) {
|
||||
return false;
|
||||
}
|
||||
const target = normalizeRegion(province);
|
||||
const itemProvince = normalizeRegion(item.province);
|
||||
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
||||
return itemProvince === target || sendRegion === target;
|
||||
}
|
||||
|
||||
export function validateInboundApplicationSrcId(
|
||||
srcId: string | undefined,
|
||||
application: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
cmppClientSrcId?: string | null;
|
||||
},
|
||||
) {
|
||||
const submittedSrcId = srcId?.trim() ?? '';
|
||||
const applicationExtension = application.cmppApplicationExtension?.trim() ?? '';
|
||||
if (!applicationExtension) {
|
||||
return submittedSrcId || null;
|
||||
}
|
||||
|
||||
const fillPrefix = application.cmppAccessNumberFillEnabled
|
||||
? application.cmppAccessNumberFillPrefix?.trim() ?? ''
|
||||
: '';
|
||||
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
|
||||
if (!submittedSrcId || submittedSrcId !== expectedSrcId) {
|
||||
throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`);
|
||||
}
|
||||
return submittedSrcId;
|
||||
}
|
||||
|
||||
export function composeUpstreamSrcId(baseSrcId: string, applicationExtension?: string | null) {
|
||||
const upstreamSrcId = `${baseSrcId.trim()}${applicationExtension?.trim() ?? ''}`;
|
||||
if (upstreamSrcId.length > 21) {
|
||||
throw new BadRequestException('channel base access number plus application extension must not exceed 21 digits');
|
||||
}
|
||||
return upstreamSrcId;
|
||||
}
|
||||
|
||||
export function positiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function parseOptionalSequenceId(value: string | null | undefined) {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] {
|
||||
return value === 'accepted' || value === 'rejected' || value === 'timeout' ? value : 'timeout';
|
||||
}
|
||||
|
||||
export function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['receiptStatus'] {
|
||||
return value === 'delivered' || value === 'undelivered' || value === 'unknown' ? value : 'unknown';
|
||||
}
|
||||
|
||||
export function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) {
|
||||
return createHash('sha256').update([
|
||||
data.id,
|
||||
data.connectionId ?? '',
|
||||
data.sequenceId ?? '',
|
||||
data.messageId ?? '',
|
||||
data.sequenceId ? '' : data.sentAt ?? '',
|
||||
].join('\u0000')).digest('hex');
|
||||
}
|
||||
|
||||
export function shanghaiDateKey(now = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(now);
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${values.year}-${values.month}-${values.day}`;
|
||||
}
|
||||
|
||||
export function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port || 6379),
|
||||
username: redisUrl.username || undefined,
|
||||
password: redisUrl.password || undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesApplicationSecret(data: GatewayInboundAuthDto, secretHash: string) {
|
||||
if (data.authSource && data.timestamp !== undefined) {
|
||||
const expected = createHash('md5')
|
||||
.update(Buffer.concat([
|
||||
Buffer.from(octetString(data.account, 6), 'binary'),
|
||||
Buffer.alloc(9),
|
||||
Buffer.from(secretHash),
|
||||
Buffer.from(String(data.timestamp).padStart(10, '0')),
|
||||
]))
|
||||
.digest('base64');
|
||||
return expected === data.authSource;
|
||||
}
|
||||
if (!data.password) {
|
||||
return false;
|
||||
}
|
||||
return data.password === secretHash || createHash('sha256').update(data.password).digest('hex') === secretHash;
|
||||
}
|
||||
|
||||
export function octetString(value: string, fixedLength: number) {
|
||||
if (value.length === fixedLength) {
|
||||
return value;
|
||||
}
|
||||
if (value.length > fixedLength) {
|
||||
return value.slice(value.length - fixedLength);
|
||||
}
|
||||
return value + '\0'.repeat(fixedLength - value.length);
|
||||
}
|
||||
|
||||
export function hasRecoveryAuditStateChanged(
|
||||
previous: Record<string, unknown> | null,
|
||||
current: Record<string, unknown>,
|
||||
) {
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason']
|
||||
.some((key) => (previous[key] ?? null) !== (current[key] ?? null));
|
||||
}
|
||||
|
||||
export function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
const explicit = String(data.failureCategory ?? '').trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
if (data.state === 'success' || data.state === 'running') {
|
||||
return null;
|
||||
}
|
||||
if (data.lastSkipReason === 'backoff') {
|
||||
return 'backoff';
|
||||
}
|
||||
if (data.lastSkipReason === 'locked') {
|
||||
return 'lock_contended';
|
||||
}
|
||||
if (data.lastSkipReason === 'lock_lost') {
|
||||
return 'lock_lost';
|
||||
}
|
||||
if (data.state === 'waiting_connection') {
|
||||
return 'client_disconnected';
|
||||
}
|
||||
if (data.state === 'partial') {
|
||||
return 'partial_delivery_failed';
|
||||
}
|
||||
if (data.state === 'failed' && data.lastError) {
|
||||
return 'flush_failed';
|
||||
}
|
||||
return data.state ? 'unknown' : null;
|
||||
}
|
||||
|
||||
export type ChannelCandidate = {
|
||||
channelId: string;
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
channel: {
|
||||
carrier?: string | null;
|
||||
sendRegion?: string | null;
|
||||
status: string;
|
||||
connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }>;
|
||||
};
|
||||
};
|
||||
|
||||
export function isChannelSendAvailable(channel: ChannelCandidate['channel']) {
|
||||
if (channel.status !== 'active') {
|
||||
return false;
|
||||
}
|
||||
return (channel.connectionStates ?? []).some((connection) =>
|
||||
connection.desiredConnections > 0 && connection.currentConnections > 0 && connection.status === 'connected',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve database priority order while preferring matching province routes
|
||||
* over national fallbacks. Filtering remains deterministic and side-effect free.
|
||||
*/
|
||||
export function selectChannelCandidate<T extends ChannelCandidate>(
|
||||
items: T[],
|
||||
options: {
|
||||
carrier: string;
|
||||
province?: string | null;
|
||||
forceNational?: boolean;
|
||||
excludedChannelIds: ReadonlySet<string>;
|
||||
approvedChannelIds: ReadonlySet<string>;
|
||||
},
|
||||
) {
|
||||
const eligible = items.filter((item) =>
|
||||
!options.excludedChannelIds.has(item.channelId)
|
||||
&& options.approvedChannelIds.has(item.channelId)
|
||||
&& normalizeCarrier(item.carrier) === options.carrier
|
||||
&& isCarrierCompatible(item.channel.carrier, options.carrier),
|
||||
);
|
||||
const provinceCandidates = options.forceNational
|
||||
? []
|
||||
: eligible.filter((item) => isProvinceChannel(item, options.province));
|
||||
const nationalCandidates = eligible.filter((item) => isNationalChannel(item));
|
||||
return [...provinceCandidates, ...nationalCandidates].find((item) => isChannelSendAvailable(item.channel));
|
||||
}
|
||||
|
||||
export type ReceiptSegmentAudit = {
|
||||
segmentTotal?: number | null;
|
||||
receiptStatus?: string | null;
|
||||
rawStatus?: string | null;
|
||||
errorCode?: string | null;
|
||||
errorMessage?: string | null;
|
||||
deliveredAt?: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate one message's terminal state without reading or writing storage.
|
||||
* A failed segment wins; success requires every expected segment to be delivered.
|
||||
*/
|
||||
export function aggregateReceiptSegmentState(
|
||||
audits: ReceiptSegmentAudit[],
|
||||
billingUnits: number | null | undefined,
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
) {
|
||||
if (audits.length === 0) {
|
||||
const status = data.receiptStatus === 'delivered'
|
||||
? 'delivered'
|
||||
: data.receiptStatus === 'unknown'
|
||||
? 'unknown'
|
||||
: 'failed';
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal: 1,
|
||||
status,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
const segmentTotal = Math.max(
|
||||
1,
|
||||
Number(billingUnits ?? 1),
|
||||
...audits.map((audit) => Number(audit.segmentTotal ?? 1)),
|
||||
);
|
||||
const received = audits.filter((audit) => Boolean(audit.receiptStatus));
|
||||
const failed = received.find((audit) => !['delivered', 'unknown'].includes(audit.receiptStatus ?? ''));
|
||||
if (failed) {
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'failed',
|
||||
receiptStatus: failed.receiptStatus ?? 'undelivered',
|
||||
rawStatus: failed.rawStatus ?? data.rawStatus,
|
||||
errorCode: failed.errorCode ?? data.errorCode,
|
||||
errorMessage: failed.errorMessage ?? data.errorMessage,
|
||||
deliveredAt: failed.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
const delivered = received.filter((audit) => audit.receiptStatus === 'delivered');
|
||||
if (delivered.length >= segmentTotal) {
|
||||
const latest = delivered.reduce((current, audit) =>
|
||||
(audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current);
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'delivered',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: latest.rawStatus ?? data.rawStatus,
|
||||
errorCode: latest.errorCode ?? undefined,
|
||||
errorMessage: undefined,
|
||||
deliveredAt: latest.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
if (received.length >= segmentTotal) {
|
||||
const latest = received[received.length - 1];
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'unknown',
|
||||
receiptStatus: 'unknown',
|
||||
rawStatus: latest.rawStatus ?? data.rawStatus,
|
||||
errorCode: latest.errorCode ?? data.errorCode,
|
||||
errorMessage: latest.errorMessage ?? data.errorMessage,
|
||||
deliveredAt: latest.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
terminal: false,
|
||||
segmentTotal,
|
||||
status: 'submitted',
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSameUpstreamEndpointIdentity(
|
||||
left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
return left.account.trim() === right.account.trim()
|
||||
&& left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase()
|
||||
&& left.gatewayPort === right.gatewayPort
|
||||
&& left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase()
|
||||
&& left.cmppVersion.trim() === right.cmppVersion.trim();
|
||||
}
|
||||
|
||||
export function receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) {
|
||||
return createHash('sha256').update([
|
||||
channelId,
|
||||
data.gatewayMessageId,
|
||||
data.phoneNumber?.trim() ?? '',
|
||||
data.receiptStatus,
|
||||
data.rawStatus.trim(),
|
||||
data.errorCode ?? '',
|
||||
].join('\u0000')).digest('hex');
|
||||
}
|
||||
@@ -111,6 +111,7 @@ function createPrismaMock() {
|
||||
},
|
||||
smsSendTask: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }),
|
||||
},
|
||||
smsBatchTask: {
|
||||
create: jest.fn().mockResolvedValue(task),
|
||||
@@ -374,10 +375,19 @@ function createService(
|
||||
reviewReason: '企业应用已配置模板不匹配进入人工审核',
|
||||
}),
|
||||
} as unknown as RiskReviewService;
|
||||
const service = new SendChainService(prisma as never, billing, riskReview, openApi as never);
|
||||
const phoneFrequency = {
|
||||
reserve: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
const service = new SendChainService(
|
||||
prisma as never,
|
||||
billing,
|
||||
riskReview,
|
||||
phoneFrequency as never,
|
||||
openApi as never,
|
||||
);
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
|
||||
return { service, prisma, billing, riskReview };
|
||||
return { service, prisma, billing, riskReview, phoneFrequency };
|
||||
}
|
||||
|
||||
describe('SendChainService', () => {
|
||||
@@ -451,6 +461,94 @@ describe('SendChainService', () => {
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('rejects only phones that hit application frequency rules and excludes them from billing', async () => {
|
||||
const { service, prisma, billing, phoneFrequency } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
phoneFrequency.reserve.mockResolvedValue(new Map([
|
||||
['13800000002', {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
||||
}],
|
||||
]));
|
||||
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
|
||||
billingUnitsPerMessage: 1,
|
||||
totalBillingUnits: 1,
|
||||
unitPrice: 3,
|
||||
amountCents: 3,
|
||||
});
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001', '13800000002'],
|
||||
});
|
||||
|
||||
expect(phoneFrequency.reserve).toHaveBeenCalledWith(
|
||||
'tenant-1',
|
||||
'app-1',
|
||||
['13800000001', '13800000002'],
|
||||
'client',
|
||||
);
|
||||
expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({ phoneCount: 1 }));
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
|
||||
expect.objectContaining({
|
||||
phoneNumber: '13800000002',
|
||||
status: 'submit_failed',
|
||||
submitStatus: 'rejected',
|
||||
errorCode: 'PHONE_FREQUENCY_LIMIT',
|
||||
amountCents: 0,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('marks a batch and its pending review task rejected when every phone hits frequency rules', async () => {
|
||||
const { service, prisma, riskReview, phoneFrequency } = createService();
|
||||
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
||||
status: 'pending_review',
|
||||
reason: '命中人工审核规则',
|
||||
task: { id: 'review-task-1' },
|
||||
});
|
||||
phoneFrequency.reserve.mockResolvedValue(new Map([
|
||||
['13800000001', {
|
||||
code: 'PHONE_FREQUENCY_LIMIT',
|
||||
reason: '单号码5分钟发送频次命中:本周期最多5条,当前第6条',
|
||||
}],
|
||||
]));
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
});
|
||||
|
||||
expect(prisma.smsSendTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'review-task-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'rejected',
|
||||
riskDecision: 'block',
|
||||
reviewReason: null,
|
||||
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
status: 'rejected',
|
||||
auditStatus: 'rejected',
|
||||
reviewReason: null,
|
||||
rejectReason: expect.stringContaining('单号码5分钟发送频次命中'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the review task id on every message waiting for manual review', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { downstreamPendingTimeoutHours } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import { SendAccountingService } from './send-accounting.service';
|
||||
import { SendDownstreamDeliveryService } from './send-downstream-delivery.service';
|
||||
import { SendDownstreamStateService } from './send-downstream-state.service';
|
||||
import { SendGatewayResultService } from './send-gateway-result.service';
|
||||
import { SendReceiptService } from './send-receipt.service';
|
||||
import { SendRetryService } from './send-retry.service';
|
||||
import { SendTimeoutService } from './send-timeout.service';
|
||||
|
||||
|
||||
export type SendCompletionCallbacks = Record<string, never>;
|
||||
export type SendCompletionFacade = SendCompletionService & SendSubmissionService;
|
||||
|
||||
/**
|
||||
* R10 internal compatibility facade. SendChainService remains the only public NestJS provider.
|
||||
*/
|
||||
export class SendCompletionService {
|
||||
private readonly gatewayResult: SendGatewayResultService;
|
||||
private readonly receipt: SendReceiptService;
|
||||
private readonly retry: SendRetryService;
|
||||
private readonly accounting: SendAccountingService;
|
||||
private readonly downstreamState: SendDownstreamStateService;
|
||||
private readonly downstreamDelivery: SendDownstreamDeliveryService;
|
||||
private readonly timeout: SendTimeoutService;
|
||||
|
||||
constructor(
|
||||
prisma: PrismaService,
|
||||
billing: BillingService,
|
||||
openApi: OpenApiService | undefined,
|
||||
facade: SendCompletionFacade,
|
||||
callbacks: SendCompletionCallbacks = {},
|
||||
) {
|
||||
this.gatewayResult = new SendGatewayResultService(prisma, billing, openApi, facade, callbacks);
|
||||
this.receipt = new SendReceiptService(prisma, billing, openApi, facade, callbacks);
|
||||
this.retry = new SendRetryService(prisma, billing, openApi, facade, callbacks);
|
||||
this.accounting = new SendAccountingService(prisma, billing, openApi, facade, callbacks);
|
||||
this.downstreamState = new SendDownstreamStateService(prisma, billing, openApi, facade, callbacks);
|
||||
this.downstreamDelivery = new SendDownstreamDeliveryService(prisma, billing, openApi, facade, callbacks);
|
||||
this.timeout = new SendTimeoutService(prisma, billing, openApi, facade, callbacks);
|
||||
}
|
||||
|
||||
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
|
||||
return this.gatewayResult.handleSubmitSegmentResult(data);
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewaySegmentResult(
|
||||
messageRecordId: string,
|
||||
data: GatewaySubmitSegmentResultDto,
|
||||
) {
|
||||
return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
|
||||
}
|
||||
|
||||
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||
return this.gatewayResult.handleSubmitResult(data);
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||
return this.gatewayResult.resolveSubmitRecordForGatewayResult(messageRecordId, data);
|
||||
}
|
||||
|
||||
smsMessageSegmentAuditDelegate() {
|
||||
return this.gatewayResult.smsMessageSegmentAuditDelegate();
|
||||
}
|
||||
|
||||
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.gatewayResult.recordSubmitSegments(message, data, submittedAt);
|
||||
}
|
||||
|
||||
async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
return this.gatewayResult.findMessageByGatewayEvent(messageId, gatewayMessageId);
|
||||
}
|
||||
|
||||
async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
return this.gatewayResult.requireMessageByGatewayEvent(messageId, gatewayMessageId);
|
||||
}
|
||||
|
||||
async intakeReceipt(data: GatewayReceiptEventDto) {
|
||||
return this.receipt.intakeReceipt(data);
|
||||
}
|
||||
|
||||
async processPendingUpstreamReceiptInbox(limit = 100) {
|
||||
return this.receipt.processPendingUpstreamReceiptInbox(limit);
|
||||
}
|
||||
|
||||
async processUpstreamReceiptInboxRecord(id: string) {
|
||||
return this.receipt.processUpstreamReceiptInboxRecord(id);
|
||||
}
|
||||
|
||||
async runUpstreamReceiptInboxScan() {
|
||||
return this.receipt.runUpstreamReceiptInboxScan();
|
||||
}
|
||||
|
||||
async handleReceipt(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
return this.receipt.handleReceipt(data, incomingIdentity);
|
||||
}
|
||||
|
||||
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.receipt.recordReceiptSegment(message, data, deliveredAt, submitRecordId);
|
||||
}
|
||||
|
||||
async aggregateReceiptSegments(
|
||||
message: {
|
||||
id: string;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
return this.receipt.aggregateReceiptSegments(message, data, deliveredAt, submitRecordId, submitId);
|
||||
}
|
||||
|
||||
async resolveReceiptMessage(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
return this.receipt.resolveReceiptMessage(data, incomingIdentity);
|
||||
}
|
||||
|
||||
async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) {
|
||||
return this.retry.recordGatewaySubmitDeadLetter(data);
|
||||
}
|
||||
|
||||
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
|
||||
return this.retry.requeueGatewaySubmitDeadLetter(id, data);
|
||||
}
|
||||
|
||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||
return this.retry.recoverStaleGatewaySubmitRequeues(now);
|
||||
}
|
||||
|
||||
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.retry.retryMessageIfAllowed(message, reason, sourceSubmitRecordId);
|
||||
}
|
||||
|
||||
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.accounting.chargeAcceptedMessage(message);
|
||||
}
|
||||
|
||||
async releaseMessageReservation(
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.accounting.releaseMessageReservation(message, remark);
|
||||
}
|
||||
|
||||
async refundMessage(
|
||||
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
return this.accounting.refundMessage(message, remark);
|
||||
}
|
||||
|
||||
async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) {
|
||||
return this.downstreamState.listPendingDownstreamDeliveries(data);
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryDelivered(id: string) {
|
||||
return this.downstreamState.markDownstreamDeliveryDelivered(id);
|
||||
}
|
||||
|
||||
async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) {
|
||||
return this.downstreamState.markDownstreamDeliverySent(data);
|
||||
}
|
||||
|
||||
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
||||
return this.downstreamState.acknowledgeDownstreamDelivery(data);
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryFailed(
|
||||
id: string,
|
||||
errorMessage?: string,
|
||||
failureType: GatewayDownstreamFailureType = 'send_failed',
|
||||
attempt?: GatewayDownstreamSentDto,
|
||||
) {
|
||||
return this.downstreamState.markDownstreamDeliveryFailed(id, errorMessage, failureType, attempt);
|
||||
}
|
||||
|
||||
async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
return this.downstreamState.recordGatewayDownstreamRecoveryStatus(data);
|
||||
}
|
||||
|
||||
async requeueDownstreamDelivery(id: string) {
|
||||
return this.downstreamState.requeueDownstreamDelivery(id);
|
||||
}
|
||||
|
||||
async recoverStaleDownstreamManualRequeues(now = new Date()) {
|
||||
return this.downstreamState.recoverStaleDownstreamManualRequeues(now);
|
||||
}
|
||||
|
||||
async batchRequeueDownstreamDeliveries(ids: string[]) {
|
||||
return this.downstreamState.batchRequeueDownstreamDeliveries(ids);
|
||||
}
|
||||
|
||||
async handleUplink(data: GatewayUplinkEventDto) {
|
||||
return this.downstreamDelivery.handleUplink(data);
|
||||
}
|
||||
|
||||
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
|
||||
return this.downstreamDelivery.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
|
||||
}
|
||||
|
||||
async queueAndTryDownstreamDelivery(data: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
messageRecordId?: string | null;
|
||||
messageId?: string | null;
|
||||
deliveryType: 'receipt' | 'uplink';
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
return this.downstreamDelivery.queueAndTryDownstreamDelivery(data);
|
||||
}
|
||||
|
||||
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.downstreamDelivery.resolveUplinkMatch(data, channel);
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
return this.downstreamDelivery.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
async postGatewayControl(path: string, payload: unknown) {
|
||||
return this.downstreamDelivery.postGatewayControl(path, payload);
|
||||
}
|
||||
|
||||
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||
return this.timeout.markUnknownTimeout(data);
|
||||
}
|
||||
|
||||
async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) {
|
||||
return this.timeout.markExpiredDownstreamDeliveries(olderThanHours);
|
||||
}
|
||||
|
||||
async runReceiptTimeoutScan() {
|
||||
return this.timeout.runReceiptTimeoutScan();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 downstreamDelivery implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendDownstreamDeliveryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async handleUplink(data: GatewayUplinkEventDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('SMS channel not found');
|
||||
}
|
||||
const match = await this.facade.resolveUplinkMatch(data, channel);
|
||||
const record = await this.prisma.smsUplinkMessage.create({
|
||||
data: {
|
||||
tenantId: match.tenantId,
|
||||
applicationId: match.applicationId,
|
||||
messageRecordId: match.messageRecordId,
|
||||
channelId: data.channelId,
|
||||
messageId: data.messageId,
|
||||
sequenceId: data.sequenceId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
destId: data.destId,
|
||||
content: data.content,
|
||||
matchStatus: match.matchStatus,
|
||||
matchReason: match.matchReason,
|
||||
receivedAt: data.receivedAt ? new Date(data.receivedAt) : new Date(),
|
||||
},
|
||||
});
|
||||
if (match.candidates.length > 0) {
|
||||
await this.prisma.smsUplinkMatchCandidate.createMany({
|
||||
data: match.candidates.map((candidate) => ({
|
||||
uplinkMessageId: record.id,
|
||||
tenantId: candidate.tenantId,
|
||||
applicationId: candidate.applicationId,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
matchSource: candidate.matchSource,
|
||||
confidence: candidate.confidence,
|
||||
reason: candidate.reason,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
if (match.tenantId && match.applicationId) {
|
||||
await this.facade.queueAndTryDownstreamDelivery({
|
||||
tenantId: match.tenantId,
|
||||
applicationId: match.applicationId,
|
||||
messageRecordId: match.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
deliveryType: 'uplink',
|
||||
payload: {
|
||||
messageId: data.messageId,
|
||||
applicationId: match.applicationId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
destId: data.destId,
|
||||
content: data.content,
|
||||
receivedAt: record.receivedAt.toISOString(),
|
||||
uplinkMessageId: record.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
|
||||
const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({
|
||||
where: { id: candidateId, uplinkMessageId },
|
||||
include: {
|
||||
application: { select: { id: true, name: true, cmppAccount: true } },
|
||||
messageRecord: { select: { id: true, messageId: true, content: true } },
|
||||
uplinkMessage: true,
|
||||
},
|
||||
});
|
||||
if (!candidate) {
|
||||
throw new NotFoundException('Uplink match candidate not found');
|
||||
}
|
||||
if (candidate.status === 'rejected') {
|
||||
throw new BadRequestException('该候选已被排除,不能认领');
|
||||
}
|
||||
if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') {
|
||||
throw new BadRequestException('该上行记录已完成匹配,不能重复认领');
|
||||
}
|
||||
const claimedAt = new Date();
|
||||
const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null;
|
||||
const [updatedUplink] = await this.prisma.$transaction([
|
||||
this.prisma.smsUplinkMessage.update({
|
||||
where: { id: uplinkMessageId },
|
||||
data: {
|
||||
tenantId: candidate.tenantId,
|
||||
applicationId: candidate.applicationId,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
messageId,
|
||||
matchStatus: 'matched',
|
||||
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
|
||||
},
|
||||
}),
|
||||
this.prisma.smsUplinkMatchCandidate.updateMany({
|
||||
where: {
|
||||
uplinkMessageId,
|
||||
id: { not: candidate.id },
|
||||
status: 'pending',
|
||||
},
|
||||
data: { status: 'rejected' },
|
||||
}),
|
||||
this.prisma.smsUplinkMatchCandidate.update({
|
||||
where: { id: candidate.id },
|
||||
data: {
|
||||
status: 'claimed',
|
||||
claimedAt,
|
||||
claimedById: operatorId,
|
||||
},
|
||||
}),
|
||||
this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: candidate.tenantId,
|
||||
userId: operatorId,
|
||||
action: 'gateway.uplink_manual_claim',
|
||||
resource: 'sms_uplink_message',
|
||||
resourceId: uplinkMessageId,
|
||||
detail: {
|
||||
candidateId: candidate.id,
|
||||
applicationId: candidate.applicationId,
|
||||
applicationName: candidate.application.name,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
messageId,
|
||||
matchSource: candidate.matchSource,
|
||||
phoneNumber: candidate.uplinkMessage.phoneNumber,
|
||||
destId: candidate.uplinkMessage.destId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.facade.queueAndTryDownstreamDelivery({
|
||||
tenantId: candidate.tenantId,
|
||||
applicationId: candidate.applicationId,
|
||||
messageRecordId: candidate.messageRecordId,
|
||||
messageId,
|
||||
deliveryType: 'uplink',
|
||||
payload: {
|
||||
messageId,
|
||||
applicationId: candidate.applicationId,
|
||||
phoneNumber: candidate.uplinkMessage.phoneNumber,
|
||||
destId: candidate.uplinkMessage.destId,
|
||||
content: candidate.uplinkMessage.content,
|
||||
receivedAt: candidate.uplinkMessage.receivedAt.toISOString(),
|
||||
manualClaim: true,
|
||||
uplinkMessageId,
|
||||
},
|
||||
});
|
||||
return this.prisma.smsUplinkMessage.findUnique({
|
||||
where: { id: updatedUplink.id },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
messageRecord: { include: { application: true } },
|
||||
matchCandidates: {
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
messageRecord: { include: { application: true, tenant: true, channel: true } },
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async queueAndTryDownstreamDelivery(data: {
|
||||
tenantId: string;
|
||||
applicationId?: string | null;
|
||||
messageRecordId?: string | null;
|
||||
messageId?: string | null;
|
||||
deliveryType: 'receipt' | 'uplink';
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
if (!data.applicationId) {
|
||||
return null;
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: data.applicationId },
|
||||
select: {
|
||||
cmppAccount: true,
|
||||
interfaceEnabled: true,
|
||||
status: true,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
});
|
||||
const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling';
|
||||
if (deliveryAllowed) {
|
||||
try {
|
||||
await this.openApi?.queueWebhookEvent({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined,
|
||||
eventType: data.deliveryType,
|
||||
payload: data.payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
if (application?.interfaceEnabled !== true) {
|
||||
return null;
|
||||
}
|
||||
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
|
||||
const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId
|
||||
? `receipt:${data.messageRecordId}`
|
||||
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
|
||||
? `uplink:${data.payload.uplinkMessageId}`
|
||||
: null;
|
||||
let delivery;
|
||||
try {
|
||||
delivery = await this.prisma.cmppDownstreamDelivery.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
dedupeKey,
|
||||
deliveryType: data.deliveryType,
|
||||
payload,
|
||||
retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink'
|
||||
? application?.downstreamUplinkRetryEnabled ?? true
|
||||
: application?.downstreamReceiptRetryEnabled ?? true),
|
||||
status: deliveryAllowed ? 'pending' : 'abandoned',
|
||||
lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
dedupeKey
|
||||
&& error instanceof Prisma.PrismaClientKnownRequestError
|
||||
&& error.code === 'P2002'
|
||||
) {
|
||||
const existing = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||
where: { dedupeKey },
|
||||
});
|
||||
if (existing) {
|
||||
this.logger.warn(`downstream_delivery_deduplicated ${JSON.stringify({
|
||||
deliveryType: data.deliveryType,
|
||||
messageRecordId: data.messageRecordId,
|
||||
messageId: data.messageId,
|
||||
dedupeKey,
|
||||
deliveryId: existing.id,
|
||||
})}`);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!deliveryAllowed) {
|
||||
return delivery;
|
||||
}
|
||||
try {
|
||||
const result = await this.facade.postGatewayControl(
|
||||
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
|
||||
{ deliveryId: delivery.id, ...payload },
|
||||
) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
await this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
} else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') {
|
||||
return delivery;
|
||||
} else {
|
||||
await this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
downstreamControlFailureMessage(result),
|
||||
result.retryable === false ? 'unrecoverable' : 'send_failed',
|
||||
{ id: delivery.id, ...result },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.facade.markDownstreamDeliveryFailed(delivery.id, error instanceof Error ? error.message : 'Gateway control delivery failed');
|
||||
}
|
||||
return delivery;
|
||||
}
|
||||
|
||||
async resolveUplinkMatch(
|
||||
data: GatewayUplinkEventDto,
|
||||
channel: { id: string; srcId?: string | null },
|
||||
): Promise<{
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
messageRecordId?: string;
|
||||
matchStatus: string;
|
||||
matchReason: string;
|
||||
candidates: UplinkMatchCandidateInput[];
|
||||
}> {
|
||||
if (data.messageId) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } });
|
||||
if (message?.tenantId) {
|
||||
return {
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? undefined,
|
||||
messageRecordId: message.id,
|
||||
matchStatus: message.applicationId ? 'matched' : 'unmatched',
|
||||
matchReason: message.applicationId ? 'messageId 精确匹配' : 'messageId 匹配到下发记录但无应用',
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const accessNumber = data.destId || channel.srcId || '';
|
||||
const accessRoutes = accessNumber
|
||||
? await this.prisma.channelRouteRule.findMany({
|
||||
where: {
|
||||
applicationId: { not: null },
|
||||
status: 'active',
|
||||
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
|
||||
},
|
||||
select: { applicationId: true },
|
||||
take: 10,
|
||||
})
|
||||
: [];
|
||||
const accessApplicationIds = [...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value)))];
|
||||
const accessApplications = accessApplicationIds.length > 0
|
||||
? await this.prisma.smsApplication.findMany({
|
||||
where: { id: { in: accessApplicationIds }, status: 'active' },
|
||||
select: { id: true, tenantId: true, name: true },
|
||||
})
|
||||
: [];
|
||||
if (accessApplications.length === 1) {
|
||||
return {
|
||||
tenantId: accessApplications[0].tenantId,
|
||||
applicationId: accessApplications[0].id,
|
||||
matchStatus: 'matched',
|
||||
matchReason: '接入号唯一匹配应用',
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
if (accessApplications.length > 1) {
|
||||
return {
|
||||
matchStatus: 'ambiguous',
|
||||
matchReason: '接入号匹配多个应用',
|
||||
candidates: accessApplications.map((application) => ({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
matchSource: 'access_number',
|
||||
confidence: 70,
|
||||
reason: `接入号 ${accessNumber} 可匹配应用 ${application.name}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const windowHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
|
||||
const since = new Date(Date.now() - Math.max(1, windowHours) * 60 * 60 * 1000);
|
||||
const recentMessages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
phoneNumber: data.phoneNumber,
|
||||
tenantId: { not: null },
|
||||
applicationId: { not: null },
|
||||
submittedAt: { gte: since },
|
||||
},
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
const matchableRecentMessages = recentMessages.filter((message) => message.tenantId && message.applicationId);
|
||||
if (matchableRecentMessages.length === 1) {
|
||||
return {
|
||||
tenantId: matchableRecentMessages[0].tenantId ?? undefined,
|
||||
applicationId: matchableRecentMessages[0].applicationId ?? undefined,
|
||||
messageRecordId: matchableRecentMessages[0].id,
|
||||
matchStatus: 'matched',
|
||||
matchReason: `手机号 ${windowHours} 小时窗口唯一匹配`,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
if (matchableRecentMessages.length > 1) {
|
||||
return {
|
||||
matchStatus: 'ambiguous',
|
||||
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
|
||||
candidates: matchableRecentMessages
|
||||
.map((message) => ({
|
||||
tenantId: String(message.tenantId),
|
||||
applicationId: String(message.applicationId),
|
||||
messageRecordId: message.id,
|
||||
matchSource: 'phone_window',
|
||||
confidence: 55,
|
||||
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] };
|
||||
}
|
||||
|
||||
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 (!message.tenantId || !message.applicationId) return null;
|
||||
const existing = await this.prisma.smsReceiptRecord.findFirst({
|
||||
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
|
||||
});
|
||||
if (existing) return existing;
|
||||
const deliveredAt = new Date();
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt },
|
||||
});
|
||||
const gatewayMessageId = `PLATFORM:${message.messageId}`;
|
||||
const receipt = await this.prisma.smsReceiptRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
receiptKey: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'),
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'REJECTD',
|
||||
errorCode,
|
||||
errorMessage: reason,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
await this.facade.queueAndTryDownstreamDelivery({
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId,
|
||||
messageRecordId: message.id,
|
||||
messageId: message.messageId,
|
||||
deliveryType: 'receipt',
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: `PLATFORM:${message.messageId}`,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'REJECTD',
|
||||
errorCode,
|
||||
errorMessage: reason,
|
||||
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
|
||||
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
|
||||
deliveredAt: deliveredAt.toISOString(),
|
||||
},
|
||||
});
|
||||
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async postGatewayControl(path: string, payload: unknown) {
|
||||
const baseUrl = (process.env.GATEWAY_CONTROL_URL ?? 'http://127.0.0.1:8090').replace(/\/$/, '');
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`Gateway control ${path} returned ${response.status}${body ? `: ${body}` : ''}`);
|
||||
}
|
||||
return response.json().catch(() => ({}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 downstreamState implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendDownstreamStateService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) {
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account is invalid');
|
||||
}
|
||||
const expiredAcknowledgements = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { applicationId: application.id, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } },
|
||||
select: { id: true },
|
||||
take: 500,
|
||||
});
|
||||
for (const expired of expiredAcknowledgements) {
|
||||
await this.facade.markDownstreamDeliveryFailed(expired.id, 'CMPP_DELIVER_RESP timeout recovered after Gateway restart', 'ack_timeout');
|
||||
}
|
||||
return this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: {
|
||||
applicationId: application.id,
|
||||
status: 'pending',
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: Math.min(Math.max(data.limit ?? 100, 1), 500),
|
||||
});
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryDelivered(id: string) {
|
||||
return this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'delivered',
|
||||
deliveredAt: new Date(),
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) {
|
||||
const sentAt = asDateOrNull(data.sentAt) ?? new Date();
|
||||
const ackDeadlineAt = asDateOrNull(data.ackDeadlineAt) ?? new Date(sentAt.getTime() + downstreamAckTimeoutMs());
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
const attemptKey = downstreamDeliveryAttemptKey(data);
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
|
||||
where: { attemptKey },
|
||||
update: {
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
},
|
||||
create: {
|
||||
deliveryId: data.id,
|
||||
attemptKey,
|
||||
attemptNo: delivery.retryCount + 1,
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
status: 'awaiting_ack',
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
},
|
||||
});
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
status: 'awaiting_ack',
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
}
|
||||
|
||||
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
||||
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
|
||||
const acknowledgedMessageId = String(data.messageId ?? '').trim();
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
const attemptKey = downstreamDeliveryAttemptKey(data);
|
||||
const acknowledgementAccepted = data.result === 0 && acknowledgedMessageId !== '' && acknowledgedMessageId !== '0';
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
|
||||
where: { attemptKey },
|
||||
update: {
|
||||
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
ackDeadlineAt: null,
|
||||
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
|
||||
errorMessage: acknowledgementAccepted
|
||||
? null
|
||||
: data.result === 0
|
||||
? 'CMPP_DELIVER_RESP Msg_Id=0'
|
||||
: `CMPP_DELIVER_RESP result=${data.result}`,
|
||||
},
|
||||
create: {
|
||||
deliveryId: data.id,
|
||||
attemptKey,
|
||||
attemptNo: delivery.retryCount + 1,
|
||||
connectionId: data.connectionId,
|
||||
sequenceId: data.sequenceId,
|
||||
messageId: data.messageId,
|
||||
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
|
||||
errorMessage: acknowledgementAccepted
|
||||
? null
|
||||
: data.result === 0
|
||||
? 'CMPP_DELIVER_RESP Msg_Id=0'
|
||||
: `CMPP_DELIVER_RESP result=${data.result}`,
|
||||
},
|
||||
});
|
||||
if (acknowledgementAccepted) {
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
status: 'delivered',
|
||||
acknowledgedAt,
|
||||
deliveredAt: acknowledgedAt,
|
||||
ackDeadlineAt: null,
|
||||
ackResult: data.result,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
}
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
if (data.result === 0) {
|
||||
return this.facade.markDownstreamDeliveryFailed(data.id, 'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信', 'ack_invalid');
|
||||
}
|
||||
return this.facade.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected');
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryFailed(
|
||||
id: string,
|
||||
errorMessage?: string,
|
||||
failureType: GatewayDownstreamFailureType = 'send_failed',
|
||||
attempt?: GatewayDownstreamSentDto,
|
||||
) {
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
if (delivery.status === 'delivered') {
|
||||
return delivery;
|
||||
}
|
||||
if (failureType === 'queue_timeout' && delivery.status !== 'pending') {
|
||||
return delivery;
|
||||
}
|
||||
const retryCount = (delivery.retryCount ?? 0) + 1;
|
||||
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'ack_invalid' || failureType === 'connection_lost';
|
||||
const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false;
|
||||
const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout';
|
||||
const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries();
|
||||
const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed';
|
||||
if (attempt && (attempt.connectionId || attempt.sequenceId || attempt.messageId)) {
|
||||
const attemptKey = downstreamDeliveryAttemptKey({ ...attempt, id });
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
|
||||
where: { attemptKey },
|
||||
update: {
|
||||
status: 'failed',
|
||||
connectionId: attempt.connectionId,
|
||||
sequenceId: attempt.sequenceId,
|
||||
messageId: attempt.messageId,
|
||||
failureType,
|
||||
errorMessage: errorMessage ?? 'downstream delivery failed',
|
||||
ackDeadlineAt: null,
|
||||
},
|
||||
create: {
|
||||
deliveryId: id,
|
||||
attemptKey,
|
||||
attemptNo: delivery.retryCount + 1,
|
||||
connectionId: attempt.connectionId,
|
||||
sequenceId: attempt.sequenceId,
|
||||
messageId: attempt.messageId,
|
||||
status: 'failed',
|
||||
sentAt: asDateOrNull(attempt.sentAt),
|
||||
failureType,
|
||||
errorMessage: errorMessage ?? 'downstream delivery failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: finalFailure ? finalStatus : 'pending',
|
||||
retryCount,
|
||||
nextRetryAt: finalFailure ? null : new Date(Date.now() + downstreamRetryDelayMs(retryCount)),
|
||||
ackDeadlineAt: null,
|
||||
lastError: errorMessage ?? 'downstream delivery failed',
|
||||
},
|
||||
});
|
||||
if (finalFailure) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId,
|
||||
action: 'gateway.downstream_delivery_failed',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
deliveryType: updated.deliveryType,
|
||||
applicationId: updated.applicationId,
|
||||
messageId: updated.messageId,
|
||||
retryCount,
|
||||
failureType,
|
||||
retryEnabled: updated.retryEnabled,
|
||||
errorMessage: updated.lastError,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
const account = String(data.account ?? '').trim();
|
||||
if (!account) {
|
||||
throw new BadRequestException('account is required');
|
||||
}
|
||||
const recoveryStatuses = (this.prisma as PrismaService & {
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findUnique: (args: Record<string, unknown>) => Promise<any>;
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
};
|
||||
}).gatewayDownstreamRecoveryStatus;
|
||||
const previous = await recoveryStatuses.findUnique({
|
||||
where: { account },
|
||||
select: {
|
||||
state: true,
|
||||
gatewayInstanceId: true,
|
||||
lockOwner: true,
|
||||
failureCategory: true,
|
||||
lastError: true,
|
||||
lastSkipReason: true,
|
||||
},
|
||||
});
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { cmppAccount: account },
|
||||
select: { id: true, tenantId: true, name: true },
|
||||
});
|
||||
const failureCategory = normalizeRecoveryFailureCategory(data);
|
||||
const updated = await recoveryStatuses.upsert({
|
||||
where: { account },
|
||||
update: {
|
||||
tenantId: application?.tenantId ?? null,
|
||||
applicationId: application?.id ?? null,
|
||||
gatewayInstanceId: data.gatewayInstanceId ?? null,
|
||||
state: data.state,
|
||||
lockOwner: data.lockOwner ?? null,
|
||||
lockExpiresAt: asDateOrNull(data.lockExpiresAt),
|
||||
lastAttemptAt: asDateOrNull(data.lastAttemptAt),
|
||||
lastSuccessAt: asDateOrNull(data.lastSuccessAt),
|
||||
lastFailureAt: asDateOrNull(data.lastFailureAt),
|
||||
nextRetryAt: asDateOrNull(data.nextRetryAt),
|
||||
attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0,
|
||||
failureCategory,
|
||||
lastError: data.lastError ?? null,
|
||||
lastSkipReason: data.lastSkipReason ?? null,
|
||||
},
|
||||
create: {
|
||||
account,
|
||||
tenantId: application?.tenantId,
|
||||
applicationId: application?.id,
|
||||
gatewayInstanceId: data.gatewayInstanceId,
|
||||
state: data.state,
|
||||
lockOwner: data.lockOwner,
|
||||
lockExpiresAt: asDateOrNull(data.lockExpiresAt),
|
||||
lastAttemptAt: asDateOrNull(data.lastAttemptAt),
|
||||
lastSuccessAt: asDateOrNull(data.lastSuccessAt),
|
||||
lastFailureAt: asDateOrNull(data.lastFailureAt),
|
||||
nextRetryAt: asDateOrNull(data.nextRetryAt),
|
||||
attemptCount: Number.isFinite(Number(data.attemptCount)) ? Number(data.attemptCount) : 0,
|
||||
failureCategory,
|
||||
lastError: data.lastError,
|
||||
lastSkipReason: data.lastSkipReason,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
},
|
||||
});
|
||||
const normalizedUpdated = updated as typeof updated & {
|
||||
failureCategory?: string | null;
|
||||
lockOwner?: string | null;
|
||||
lockExpiresAt?: Date | null;
|
||||
};
|
||||
if (hasRecoveryAuditStateChanged(previous, updated)) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
action: 'gateway.downstream_recovery_status_changed',
|
||||
resource: 'gateway_downstream_recovery_status',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
account,
|
||||
previousState: previous?.state ?? null,
|
||||
state: updated.state,
|
||||
gatewayInstanceId: updated.gatewayInstanceId,
|
||||
lockOwner: normalizedUpdated.lockOwner,
|
||||
attemptCount: updated.attemptCount,
|
||||
nextRetryAt: updated.nextRetryAt,
|
||||
failureCategory: normalizedUpdated.failureCategory,
|
||||
applicationId: updated.applicationId,
|
||||
applicationName: application?.name,
|
||||
lastError: updated.lastError,
|
||||
lastSkipReason: updated.lastSkipReason,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async requeueDownstreamDelivery(id: string) {
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({
|
||||
where: { id },
|
||||
include: { application: { select: { cmppAccount: true } } },
|
||||
});
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
if (delivery.status === 'awaiting_ack') {
|
||||
throw new BadRequestException('该记录正在等待客户端确认,不允许并发重投');
|
||||
}
|
||||
const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null;
|
||||
if (!payload) {
|
||||
throw new BadRequestException('下游投递记录缺少可重放 payload');
|
||||
}
|
||||
const path =
|
||||
delivery.deliveryType === 'receipt'
|
||||
? '/downstream/receipt'
|
||||
: delivery.deliveryType === 'uplink'
|
||||
? '/downstream/uplink'
|
||||
: null;
|
||||
if (!path) {
|
||||
throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`);
|
||||
}
|
||||
|
||||
const requestPayload = {
|
||||
deliveryId: delivery.id,
|
||||
account: String(payload.account ?? delivery.application?.cmppAccount ?? ''),
|
||||
...payload,
|
||||
};
|
||||
const retriedAt = new Date();
|
||||
const claimed = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: {
|
||||
id: delivery.id,
|
||||
status: delivery.status,
|
||||
updatedAt: delivery.updatedAt,
|
||||
},
|
||||
data: {
|
||||
status: 'manual_requeueing',
|
||||
retryCount: 0,
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetriedAt: retriedAt,
|
||||
nextRetryAt: null,
|
||||
sentAt: null,
|
||||
acknowledgedAt: null,
|
||||
ackDeadlineAt: null,
|
||||
ackResult: null,
|
||||
ackSequenceId: null,
|
||||
ackMessageId: null,
|
||||
connectionId: null,
|
||||
deliveredAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new BadRequestException('该下游投递记录已被其他操作处理,请刷新后重试');
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: delivery.tenantId,
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: delivery.id,
|
||||
detail: {
|
||||
deliveryType: delivery.deliveryType,
|
||||
applicationId: delivery.applicationId,
|
||||
messageId: delivery.messageId,
|
||||
previousStatus: delivery.status,
|
||||
previousRetryCount: delivery.retryCount,
|
||||
manualRetryCount: (delivery.manualRetryCount ?? 0) + 1,
|
||||
lastRetriedAt: retriedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await this.facade.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
return this.facade.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
}
|
||||
return this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
downstreamControlFailureMessage(result),
|
||||
result.retryable === false ? 'unrecoverable' : 'send_failed',
|
||||
);
|
||||
} catch (error) {
|
||||
return this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
error instanceof Error ? error.message : 'Gateway control delivery failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async recoverStaleDownstreamManualRequeues(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS,
|
||||
));
|
||||
const stale = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } },
|
||||
select: { id: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 500,
|
||||
});
|
||||
let recovered = 0;
|
||||
for (const delivery of stale) {
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: delivery.id, status: 'manual_requeueing', updatedAt: delivery.updatedAt },
|
||||
data: {
|
||||
status: 'pending',
|
||||
nextRetryAt: null,
|
||||
lastError: '人工重投进程中断,已恢复为待投递',
|
||||
},
|
||||
});
|
||||
recovered += updated.count;
|
||||
}
|
||||
return { recovered };
|
||||
}
|
||||
|
||||
async batchRequeueDownstreamDeliveries(ids: string[]) {
|
||||
const uniqueIds = [...new Set(ids.filter(Boolean))];
|
||||
if (uniqueIds.length === 0) {
|
||||
throw new BadRequestException('请选择至少一条下游投递记录');
|
||||
}
|
||||
const results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }> = [];
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
await this.facade.requeueDownstreamDelivery(id);
|
||||
results.push({ id, status: 'success' });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
id,
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : '批量重投失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: uniqueIds.length,
|
||||
successCount: results.filter((item) => item.status === 'success').length,
|
||||
failedCount: results.filter((item) => item.status === 'failed').length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 gatewayResult implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendGatewayResultService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
|
||||
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
|
||||
const effectiveSubmitId = submitRecord.submitId;
|
||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||
await this.facade.recordSubmitSegments(message, {
|
||||
messageId: data.messageId,
|
||||
channelId: data.channelId,
|
||||
submitId: effectiveSubmitId,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId ?? '',
|
||||
submitStatus: normalizeSubmitStatus(data.submitStatus),
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: submittedAt.toISOString(),
|
||||
segments: [{
|
||||
segmentTotal: data.segmentTotal,
|
||||
segmentIndex: data.segmentIndex,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: submittedAt.toISOString(),
|
||||
}],
|
||||
}, submittedAt);
|
||||
if (data.gatewayMessageId) {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
id: submitRecord.id,
|
||||
gatewayMessageId: null,
|
||||
},
|
||||
data: {
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewaySegmentResult(
|
||||
messageRecordId: string,
|
||||
data: GatewaySubmitSegmentResultDto,
|
||||
) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { submitId: data.submitId },
|
||||
});
|
||||
if (
|
||||
!exact ||
|
||||
(exact.messageRecordId && exact.messageRecordId !== messageRecordId) ||
|
||||
(exact.channelId && exact.channelId !== data.channelId)
|
||||
) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
submitId: data.submitId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
})}`);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult submitId does not match the SMS message and channel',
|
||||
);
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_segment_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException(
|
||||
'Gateway SubmitSegmentResult without submitId cannot be matched uniquely',
|
||||
);
|
||||
}
|
||||
this.logger.warn(`gateway_submit_segment_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
segmentIndex: data.segmentIndex,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data);
|
||||
const effectiveData = { ...data, submitId: submitRecord.submitId };
|
||||
const batchTask = message.batchTaskId
|
||||
? await this.prisma.smsBatchTask.findUnique({ where: { id: message.batchTaskId }, select: { sourceType: true } })
|
||||
: null;
|
||||
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: { id: submitRecord.id },
|
||||
data: {
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
await this.facade.recordSubmitSegments(message, effectiveData, submittedAt);
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (message.submitId && effectiveData.submitId !== message.submitId) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
await this.facade.chargeAcceptedMessage(businessMessage);
|
||||
const latest = await this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
if (latest?.status === 'failed') {
|
||||
await this.facade.refundMessage(businessMessage, '先到失败回执补偿退款');
|
||||
}
|
||||
} else if (data.submitStatus !== 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.facade.retryMessageIfAllowed(
|
||||
businessMessage,
|
||||
data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发',
|
||||
submitRecord.id,
|
||||
);
|
||||
if (retried) {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return retried;
|
||||
}
|
||||
await this.facade.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
||||
}
|
||||
const protectedTerminalStatuses = ['delivered', 'failed', 'unknown'];
|
||||
const updated = await this.prisma.smsMessageRecord.updateMany({
|
||||
where: data.submitStatus === 'accepted'
|
||||
? { id: message.id, status: { notIn: protectedTerminalStatuses } }
|
||||
: { id: message.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
status,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt,
|
||||
timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined,
|
||||
},
|
||||
});
|
||||
if (updated.count === 0 && data.submitStatus === 'accepted') {
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { id: message.id, gatewayMessageId: null },
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submittedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (data.submitStatus !== 'accepted' && batchTask?.sourceType === 'cmpp' && message.tenantId && message.applicationId) {
|
||||
await this.facade.recordCmppFailureReceipt(
|
||||
message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string },
|
||||
data.errorCode || 'SUBMIT',
|
||||
data.errorMessage || (data.submitStatus === 'timeout' ? '上游提交超时' : '上游拒绝短信'),
|
||||
);
|
||||
}
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: {
|
||||
status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] },
|
||||
OR: [
|
||||
{ submitId: effectiveData.submitId },
|
||||
data.messageId ? { messageId: data.messageId } : undefined,
|
||||
].filter(Boolean) as Array<{ submitId?: string; messageId?: string }>,
|
||||
},
|
||||
data: {
|
||||
status: 'resolved',
|
||||
resolvedAt: submittedAt,
|
||||
resolvedStatus: data.submitStatus,
|
||||
},
|
||||
});
|
||||
if (message.batchTaskId) {
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } });
|
||||
if (!exact
|
||||
|| (exact.messageRecordId && exact.messageRecordId !== messageRecordId)
|
||||
|| (exact.channelId && exact.channelId !== data.channelId)) {
|
||||
throw new BadRequestException('Gateway SubmitResult submitId does not match the SMS message and channel');
|
||||
}
|
||||
return exact;
|
||||
}
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
OR: [
|
||||
data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined,
|
||||
{ gatewayMessageId: null },
|
||||
].filter(Boolean) as Array<{ gatewayMessageId?: string | null }>,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (candidates.length !== 1) {
|
||||
this.logger.error(`gateway_submit_result_unmatched ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
candidateCount: candidates.length,
|
||||
})}`);
|
||||
throw new BadRequestException('Gateway SubmitResult without submitId cannot be matched uniquely');
|
||||
}
|
||||
this.logger.warn(`gateway_submit_result_legacy_match ${JSON.stringify({
|
||||
messageId: data.messageId,
|
||||
messageRecordId,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitId: candidates[0].submitId,
|
||||
})}`);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
smsMessageSegmentAuditDelegate() {
|
||||
return (this.prisma as PrismaService & {
|
||||
smsMessageSegmentAudit: {
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
updateMany: (args: Record<string, unknown>) => Promise<{ count: number }>;
|
||||
findFirst: (args: Record<string, unknown>) => Promise<any | null>;
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).smsMessageSegmentAudit;
|
||||
}
|
||||
|
||||
async recordSubmitSegments(
|
||||
message: {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
batchTaskId?: string | null;
|
||||
channelId?: string | null;
|
||||
submitId?: string | null;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewaySubmitResultDto,
|
||||
submittedAt: Date,
|
||||
) {
|
||||
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
|
||||
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
OR: [
|
||||
data.submitId ? { submitId: data.submitId } : undefined,
|
||||
data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined,
|
||||
].filter(Boolean) as Array<{ submitId?: string; gatewayMessageId?: string }>,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const submitId = data.submitId ?? submitRecord?.submitId ?? message.submitId ?? `SUB-AUDIT-${message.id}`;
|
||||
const attempt = submitRecord
|
||||
? Math.max(0, await this.prisma.smsSubmitRecord.count({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
createdAt: { lte: submitRecord.createdAt },
|
||||
},
|
||||
}) - 1)
|
||||
: 0;
|
||||
const fallbackSegments = [{
|
||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||
segmentIndex: 1,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: data.submitStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
submittedAt: data.submittedAt,
|
||||
}];
|
||||
const segments = data.segments && data.segments.length > 0 ? data.segments : fallbackSegments;
|
||||
const segmentTotal = Math.max(1, ...segments.map((item) => Number(item.segmentTotal ?? segments.length ?? 1)));
|
||||
await Promise.all(segments.map((segment, index) => {
|
||||
const segmentIndex = Math.max(1, Number(segment.segmentIndex ?? index + 1));
|
||||
const status = segment.submitStatus ?? data.submitStatus;
|
||||
return segmentAudits.upsert({
|
||||
where: {
|
||||
messageRecordId_submitId_segmentIndex: {
|
||||
messageRecordId: message.id,
|
||||
submitId,
|
||||
segmentIndex,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
create: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
submitRecordId: submitRecord?.id ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
submitId,
|
||||
attempt,
|
||||
segmentTotal,
|
||||
segmentIndex,
|
||||
sequenceId: segment.sequenceId ?? data.sequenceId ?? null,
|
||||
gatewayMessageId: segment.gatewayMessageId ?? data.gatewayMessageId ?? null,
|
||||
submitStatus: status,
|
||||
compensationType: submitRecord && submitRecord.submitId !== message.submitId ? 'retry_submit' : null,
|
||||
errorCode: segment.errorCode ?? data.errorCode ?? null,
|
||||
errorMessage: segment.errorMessage ?? data.errorMessage ?? null,
|
||||
submittedAt: segment.submittedAt ? new Date(segment.submittedAt) : submittedAt,
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(
|
||||
Boolean,
|
||||
) as Array<{
|
||||
messageId?: string;
|
||||
gatewayMessageId?: string;
|
||||
}>;
|
||||
if (conditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findFirst({
|
||||
where: {
|
||||
OR: conditions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
const message = await this.facade.findMessageByGatewayEvent(messageId, gatewayMessageId);
|
||||
if (!message) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
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 gatewaySubmit implementation. Cross-method calls return through the stable SendChainService seam.
|
||||
*/
|
||||
export class SendGatewaySubmitService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private redis?: IORedis;
|
||||
private sendQueue?: Queue<SendJob, unknown, 'send-message'>;
|
||||
private gatewayQueue?: Queue;
|
||||
private worker?: Worker<SendJob>;
|
||||
|
||||
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,
|
||||
) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.worker?.close();
|
||||
await this.sendQueue?.close();
|
||||
await this.gatewayQueue?.close();
|
||||
this.redis?.disconnect();
|
||||
}
|
||||
|
||||
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 enqueueBatchTask(taskId: string) {
|
||||
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
if (task.status === 'canceled') {
|
||||
throw new BadRequestException('SMS batch task is canceled');
|
||||
}
|
||||
const messages = await this.prisma.smsMessageRecord.findMany({
|
||||
where: { batchTaskId: taskId, status: 'queued' },
|
||||
select: { id: true, queuePriority: true },
|
||||
take: 100000,
|
||||
});
|
||||
const queue = this.facade.getSendQueue();
|
||||
for (const message of messages) {
|
||||
const queuePriority = normalizeQueuePriority(message.queuePriority);
|
||||
await queue.add('send-message', { messageRecordId: message.id }, {
|
||||
jobId: message.id,
|
||||
attempts: 3,
|
||||
priority: BULLMQ_PRIORITY[queuePriority],
|
||||
});
|
||||
}
|
||||
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
|
||||
return { taskId, enqueued: messages.length };
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.worker) {
|
||||
return { status: 'already_started' };
|
||||
}
|
||||
const connection = bullmqConnection();
|
||||
this.worker = new Worker<SendJob>(
|
||||
SEND_QUEUE,
|
||||
async (job) => this.facade.processSendJob(job.data),
|
||||
{ connection, concurrency: Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20) },
|
||||
);
|
||||
return { status: 'started' };
|
||||
}
|
||||
|
||||
async processSendJob(job: SendJob) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({
|
||||
where: { id: job.messageRecordId },
|
||||
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
|
||||
});
|
||||
if (!message || message.status !== 'queued') {
|
||||
return { skipped: true };
|
||||
}
|
||||
if (!message.tenantId || !message.batchTaskId) {
|
||||
return { skipped: true, reason: 'standalone channel test message' };
|
||||
}
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
try {
|
||||
const routed = await this.facade.selectChannelForMessage(businessMessage);
|
||||
return await this.facade.submitMessageToGateway(businessMessage, routed, 0);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'failed', errorMessage: reason },
|
||||
});
|
||||
await this.releaseMessageReservation(businessMessage, reason);
|
||||
if (message.batchTask?.sourceType === 'cmpp') {
|
||||
await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason);
|
||||
} else {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
}
|
||||
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
const channel = routed.channel;
|
||||
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
|
||||
await this.facade.ensureSignatureReportedForChannel(message, channel.id);
|
||||
await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
|
||||
const submitId = `SUB-${randomUUID()}`;
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const session = await tx.cmppSubmitSession.upsert({
|
||||
where: { sessionNo: `OPEN-${channel.id}` },
|
||||
update: { submitTotal: { increment: 1 } },
|
||||
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
|
||||
});
|
||||
await tx.smsSubmitRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
channelId: channel.id,
|
||||
channelGroupId: routed.groupId,
|
||||
channelGroupName: routed.groupName,
|
||||
sessionId: session.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
costUnitPrice: channel.unitPrice ?? 0,
|
||||
costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1),
|
||||
},
|
||||
});
|
||||
await tx.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
carrier: routed.carrier,
|
||||
province: routed.province,
|
||||
submitId,
|
||||
status: 'submit_queued',
|
||||
submitStatus: 'queued',
|
||||
receiptStatus: null,
|
||||
errorCode: null,
|
||||
errorMessage: attempt > 0 ? `第 ${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (retryOfSubmitRecordId) {
|
||||
this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId,
|
||||
channelId: channel.id,
|
||||
})}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
retryOfSubmitRecordId
|
||||
&& error instanceof Prisma.PrismaClientKnownRequestError
|
||||
&& error.code === 'P2002'
|
||||
) {
|
||||
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { retryOfSubmitRecordId },
|
||||
});
|
||||
if (existingRetry) {
|
||||
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId: existingRetry.submitId,
|
||||
channelId: existingRetry.channelId,
|
||||
})}`);
|
||||
return {
|
||||
submitted: false,
|
||||
duplicateRetry: true,
|
||||
messageRecordId: message.id,
|
||||
channelId: existingRetry.channelId,
|
||||
attempt,
|
||||
submitId: existingRetry.submitId,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const command = {
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
traceId: randomUUID(),
|
||||
messageId: message.messageId,
|
||||
channelId: channel.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId ?? 'unknown',
|
||||
taskId: message.batchTaskId,
|
||||
submitId,
|
||||
queuePriority: normalizeQueuePriority(message.queuePriority),
|
||||
phoneNumber: message.phoneNumber,
|
||||
content: message.content,
|
||||
signature: message.template?.signature?.name ?? message.signature?.name ?? 'SMS',
|
||||
templateId: message.templateId ?? 'unknown',
|
||||
billingUnits: message.billingUnits,
|
||||
route: {
|
||||
channelCode: channel.code,
|
||||
cmppAccountCode: channel.account,
|
||||
priority: attempt,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
carrier: routed.carrier,
|
||||
province: routed.province ?? undefined,
|
||||
scope: routed.routeScope,
|
||||
groupId: routed.groupId,
|
||||
},
|
||||
cmpp: {
|
||||
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
|
||||
? String(channel.config.serviceId)
|
||||
: 'SMS',
|
||||
srcId: upstreamSrcId,
|
||||
extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0),
|
||||
registeredDelivery: 1,
|
||||
msgFmt: 8,
|
||||
},
|
||||
upstream: {
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
account: channel.account,
|
||||
passwordCipher: channel.passwordCipher,
|
||||
cmppVersion: channel.cmppVersion,
|
||||
desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1),
|
||||
windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16),
|
||||
heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30),
|
||||
heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3),
|
||||
},
|
||||
retry: { attempt, maxAttempts: 1 },
|
||||
};
|
||||
await this.facade.getGatewayQueue().add('submit-command', command);
|
||||
await this.facade.publishGatewaySubmitCommand(command);
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt };
|
||||
}
|
||||
|
||||
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<RoutedChannel> {
|
||||
if (!message.applicationId) {
|
||||
throw new BadRequestException('短信应用未配置,无法选择通道组');
|
||||
}
|
||||
const hasPersistedRouting = Boolean(message.carrier);
|
||||
const [carrier, province] = hasPersistedRouting
|
||||
? [normalizeCarrier(message.carrier), message.province ?? null]
|
||||
: await Promise.all([
|
||||
this.facade.identifyCarrier(message.phoneNumber),
|
||||
this.facade.identifyProvince(message.phoneNumber),
|
||||
]);
|
||||
if (!hasPersistedRouting) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { carrier, province },
|
||||
});
|
||||
}
|
||||
const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId, carrier);
|
||||
const excluded = new Set(options.excludeChannelIds ?? []);
|
||||
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
||||
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
|
||||
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||
where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } },
|
||||
select: { channelId: true },
|
||||
});
|
||||
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
|
||||
const selected = selectChannelCandidate(route.group.items, {
|
||||
carrier,
|
||||
province,
|
||||
forceNational: options.forceNational,
|
||||
excludedChannelIds: excluded,
|
||||
approvedChannelIds,
|
||||
});
|
||||
if (!selected) {
|
||||
throw new NotFoundException('无已报备通过且在线的可用通道');
|
||||
}
|
||||
return {
|
||||
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
|
||||
carrier,
|
||||
province,
|
||||
groupId: route.groupId,
|
||||
groupName: route.group.name,
|
||||
routeScope: isNationalChannel(selected) ? 'national' : 'province',
|
||||
};
|
||||
}
|
||||
|
||||
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
|
||||
const route = await this.prisma.channelRouteRule.findFirst({
|
||||
where: {
|
||||
status: 'active',
|
||||
tenantId,
|
||||
applicationId,
|
||||
carrier,
|
||||
channelId: null,
|
||||
province: null,
|
||||
},
|
||||
include: { group: { include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: { priority: 'asc' } } } } },
|
||||
orderBy: { priority: 'asc' },
|
||||
});
|
||||
if (!route) {
|
||||
throw new NotFoundException('企业应用未配置对应运营商通道组');
|
||||
}
|
||||
if (route.group.status !== 'active') {
|
||||
throw new BadRequestException('企业应用绑定的通道组已停用');
|
||||
}
|
||||
if (normalizeCarrier(route.group.carrier) !== carrier) {
|
||||
throw new BadRequestException('企业应用绑定的通道组运营商与路由规则不一致');
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
async identifyCarrier(phoneNumber: string) {
|
||||
return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber));
|
||||
}
|
||||
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
return this.phoneRouting.identifyProvince(phoneNumber);
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
const signatureId = await this.facade.resolveMessageSignatureId(message);
|
||||
if (!signatureId) {
|
||||
throw new BadRequestException('短信签名未配置,不能提交到通道');
|
||||
}
|
||||
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
|
||||
where: { signatureId, channelId, reportType: 'signature', status: 'approved' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!reportTask) {
|
||||
throw new BadRequestException('短信签名未在最终通道报备通过');
|
||||
}
|
||||
}
|
||||
|
||||
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||
const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null;
|
||||
if (direct || !message.templateId) return direct;
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } });
|
||||
return template?.signature?.id ?? null;
|
||||
}
|
||||
|
||||
async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
const redis = this.facade.getRedis();
|
||||
for (;;) {
|
||||
const bucket = `rate:channel:${channelId}:${Math.floor(Date.now() / 1000)}`;
|
||||
const count = await redis.incr(bucket);
|
||||
if (count === 1) {
|
||||
await redis.expire(bucket, 2);
|
||||
}
|
||||
if (count <= Math.max(1, tps)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshTaskProgress(batchTaskId: string) {
|
||||
const groups = await this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: { batchTaskId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const count = (statuses: string[]) =>
|
||||
groups.filter((group) => statuses.includes(group.status)).reduce((sum, group) => sum + group._count._all, 0);
|
||||
const progressTotal = groups.reduce((sum, group) => sum + group._count._all, 0);
|
||||
const submittedTotal = count(['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout']);
|
||||
const successTotal = count(['delivered']);
|
||||
const failedTotal = count(['submit_failed', 'failed']);
|
||||
const unknownTotal = count(['unknown']);
|
||||
const timeoutTotal = count(['timeout']);
|
||||
const doneTotal = successTotal + failedTotal + timeoutTotal;
|
||||
const status = progressTotal > 0 && doneTotal >= progressTotal ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued';
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: batchTaskId },
|
||||
data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status },
|
||||
});
|
||||
}
|
||||
|
||||
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
|
||||
if (!this.sendQueue) {
|
||||
this.sendQueue = new Queue<SendJob, unknown, 'send-message'>(SEND_QUEUE, { connection: bullmqConnection() });
|
||||
}
|
||||
return this.sendQueue;
|
||||
}
|
||||
|
||||
getGatewayQueue(): Queue {
|
||||
if (!this.gatewayQueue) {
|
||||
this.gatewayQueue = new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
|
||||
}
|
||||
return this.gatewayQueue;
|
||||
}
|
||||
|
||||
getRedis() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
|
||||
const redis = this.facade.getRedis();
|
||||
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
|
||||
const payload = JSON.stringify(command);
|
||||
if (!idempotencyKey) {
|
||||
return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload);
|
||||
}
|
||||
const result = await redis.eval(
|
||||
`local existing = redis.call('GET', KEYS[2])
|
||||
if existing then return existing end
|
||||
local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1])
|
||||
redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2])
|
||||
return streamId`,
|
||||
2,
|
||||
stream,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS),
|
||||
);
|
||||
return typeof result === 'string' ? result : String(result ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
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 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;
|
||||
},
|
||||
errorCode: string,
|
||||
reason: string,
|
||||
) {
|
||||
return this.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
|
||||
}
|
||||
|
||||
|
||||
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
||||
const application = await this.facade.findInboundApplication(data.account);
|
||||
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');
|
||||
}
|
||||
return {
|
||||
applicationId: application.id,
|
||||
tenantId: application.tenantId,
|
||||
account: application.cmppAccount,
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
passwordCipher: application.secretHash,
|
||||
maxConnections: application.cmppMaxConnections,
|
||||
status: 'authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
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,
|
||||
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<ReturnType<SendSubmissionService['findInboundApplication']>>,
|
||||
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<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
return {
|
||||
complete: false,
|
||||
groupId: group.id,
|
||||
messageId: group.messageId,
|
||||
receivedSegments: group.segments.length,
|
||||
response: group.response as any,
|
||||
content: '',
|
||||
sequenceId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const existing = group.segments.find((item) => item.segmentIndex === fragment.index);
|
||||
if (existing && (existing.contentHash !== contentHash
|
||||
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) {
|
||||
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),
|
||||
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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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 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,
|
||||
billingUnits: billing.billingUnitsPerMessage,
|
||||
unitPrice: receiptRejection ? 0 : billing.unitPrice,
|
||||
amountCents: receiptRejection ? 0 : billing.amountCents,
|
||||
queuePriority,
|
||||
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||
cmppSubmitGroupMessageId: submitGroupMessageId,
|
||||
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 drainageReason = drainageRejectionReason(drainage);
|
||||
if (drainageReason) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { drainageInfoId, signatureId: options.signatureId },
|
||||
});
|
||||
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
|
||||
return;
|
||||
}
|
||||
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 drainageReason = drainageRejectionReason(drainage);
|
||||
if (drainageReason) {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { drainageInfoId: drainage?.id, signatureId: signature.id },
|
||||
});
|
||||
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
|
||||
return {
|
||||
accepted: true,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
messageId,
|
||||
messageRecordId: message.id,
|
||||
taskId: task.id,
|
||||
status: 'rejected',
|
||||
};
|
||||
}
|
||||
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<string, unknown>;
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 receipt implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendReceiptService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private upstreamReceiptInboxScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async intakeReceipt(data: GatewayReceiptEventDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: data.channelId },
|
||||
select: {
|
||||
id: true,
|
||||
account: true,
|
||||
gatewayHost: true,
|
||||
gatewayPort: true,
|
||||
protocol: true,
|
||||
cmppVersion: true,
|
||||
},
|
||||
});
|
||||
if (!channel) {
|
||||
throw new NotFoundException('SMS channel not found');
|
||||
}
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
const receiptKey = receiptEventKey(data, data.channelId);
|
||||
const inbox = await this.prisma.upstreamReceiptInbox.upsert({
|
||||
where: { receiptKey },
|
||||
update: {
|
||||
incomingConnectionId: data.connectionId,
|
||||
},
|
||||
create: {
|
||||
receiptKey,
|
||||
incomingChannelId: data.channelId,
|
||||
incomingConnectionId: data.connectionId,
|
||||
upstreamAccount: channel.account,
|
||||
upstreamHost: channel.gatewayHost,
|
||||
upstreamPort: channel.gatewayPort,
|
||||
protocol: channel.protocol,
|
||||
protocolVersion: channel.cmppVersion,
|
||||
provisionalMessageId: data.messageId,
|
||||
sequenceId: data.sequenceId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || null,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
status: 'pending',
|
||||
nextRetryAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (['pending', 'retrying'].includes(inbox.status)) {
|
||||
setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id));
|
||||
}
|
||||
return { accepted: true, inboxId: inbox.id, status: inbox.status };
|
||||
}
|
||||
|
||||
async processPendingUpstreamReceiptInbox(limit = 100) {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(
|
||||
now.getTime()
|
||||
- positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
),
|
||||
);
|
||||
const candidates = await this.prisma.upstreamReceiptInbox.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
status: { in: ['pending', 'retrying'] },
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }],
|
||||
},
|
||||
{ status: 'processing', updatedAt: { lte: staleBefore } },
|
||||
],
|
||||
},
|
||||
orderBy: [{ receivedAt: 'asc' }, { id: 'asc' }],
|
||||
take: Math.min(Math.max(limit, 1), 500),
|
||||
select: { id: true },
|
||||
});
|
||||
let processed = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (await this.facade.processUpstreamReceiptInboxRecord(candidate.id)) processed += 1;
|
||||
}
|
||||
return { scanned: candidates.length, processed };
|
||||
}
|
||||
|
||||
async processUpstreamReceiptInboxRecord(id: string) {
|
||||
const staleBefore = new Date(
|
||||
Date.now()
|
||||
- positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS,
|
||||
),
|
||||
);
|
||||
const claimed = await this.prisma.upstreamReceiptInbox.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{ status: { in: ['pending', 'retrying'] } },
|
||||
{ status: 'processing', updatedAt: { lte: staleBefore } },
|
||||
],
|
||||
},
|
||||
data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null },
|
||||
});
|
||||
if (claimed.count !== 1) return false;
|
||||
const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } });
|
||||
if (!inbox) return false;
|
||||
try {
|
||||
const message = await this.facade.handleReceipt({
|
||||
messageId: inbox.provisionalMessageId ?? undefined,
|
||||
channelId: inbox.incomingChannelId,
|
||||
connectionId: inbox.incomingConnectionId ?? undefined,
|
||||
sequenceId: inbox.sequenceId ?? undefined,
|
||||
gatewayMessageId: inbox.gatewayMessageId,
|
||||
phoneNumber: inbox.phoneNumber ?? undefined,
|
||||
receiptStatus: normalizeReceiptStatus(inbox.receiptStatus),
|
||||
rawStatus: inbox.rawStatus,
|
||||
errorCode: inbox.errorCode ?? undefined,
|
||||
errorMessage: inbox.errorMessage ?? undefined,
|
||||
deliveredAt: inbox.deliveredAt.toISOString(),
|
||||
}, {
|
||||
account: inbox.upstreamAccount,
|
||||
gatewayHost: inbox.upstreamHost,
|
||||
gatewayPort: inbox.upstreamPort,
|
||||
protocol: inbox.protocol,
|
||||
cmppVersion: inbox.protocolVersion,
|
||||
});
|
||||
const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId;
|
||||
const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined;
|
||||
await this.prisma.upstreamReceiptInbox.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'matched',
|
||||
matchedMessageRecordId: matchedMessageRecordId ?? null,
|
||||
matchedChannelId: matchedChannelId ?? null,
|
||||
lastError: null,
|
||||
processedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const maxAttempts = positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
|
||||
);
|
||||
const maxAgeHours = positiveInteger(
|
||||
process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
|
||||
);
|
||||
const exhausted = inbox.attemptCount >= maxAttempts
|
||||
|| inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000;
|
||||
const retryDelayMs = Math.min(30 * 60_000, 5_000 * 2 ** Math.min(Math.max(inbox.attemptCount - 1, 0), 8));
|
||||
await this.prisma.upstreamReceiptInbox.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: exhausted ? 'unmatched' : 'retrying',
|
||||
nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelayMs),
|
||||
lastError: error instanceof Error ? error.message : String(error),
|
||||
processedAt: exhausted ? new Date() : null,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async runUpstreamReceiptInboxScan() {
|
||||
if (this.upstreamReceiptInboxScanRunning) return;
|
||||
this.upstreamReceiptInboxScanRunning = true;
|
||||
try {
|
||||
const result = await this.facade.processPendingUpstreamReceiptInbox();
|
||||
if (result.processed > 0) {
|
||||
this.logger.log(`Matched ${result.processed}/${result.scanned} pending upstream receipts`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Upstream receipt inbox scan failed', error instanceof Error ? error.stack : String(error));
|
||||
} finally {
|
||||
this.upstreamReceiptInboxScanRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleReceipt(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity);
|
||||
const logicalChannelId = resolved.channelId ?? data.channelId;
|
||||
const receiptKey = receiptEventKey(data, logicalChannelId);
|
||||
const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({
|
||||
where: { receiptKey },
|
||||
include: { messageRecord: true },
|
||||
});
|
||||
if (existingReceipt?.messageRecord) {
|
||||
return existingReceipt.messageRecord;
|
||||
}
|
||||
const message = resolved.message;
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
if (resolved.submitRecordId) {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
id: resolved.submitRecordId,
|
||||
gatewayMessageId: null,
|
||||
},
|
||||
data: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
sequenceId: data.sequenceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.prisma.smsReceiptRecord.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
receiptKey,
|
||||
channelId: logicalChannelId,
|
||||
messageId: resolved.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
|
||||
sequenceId: data.sequenceId,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
const duplicate = await this.prisma.smsReceiptRecord.findUnique({
|
||||
where: { receiptKey },
|
||||
include: { messageRecord: true },
|
||||
});
|
||||
if (duplicate?.messageRecord) return duplicate.messageRecord;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const logicalReceipt = { ...data, channelId: logicalChannelId };
|
||||
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
|
||||
const aggregate = await this.facade.aggregateReceiptSegments(
|
||||
message,
|
||||
logicalReceipt,
|
||||
deliveredAt,
|
||||
resolved.submitRecordId,
|
||||
resolved.submitId,
|
||||
);
|
||||
if (!aggregate.terminal) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = aggregate.status;
|
||||
const isCurrentAttempt =
|
||||
(!message.channelId || message.channelId === logicalChannelId)
|
||||
&& (
|
||||
!message.gatewayMessageId
|
||||
|| message.gatewayMessageId === data.gatewayMessageId
|
||||
|| (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))
|
||||
);
|
||||
if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.facade.retryMessageIfAllowed(
|
||||
businessMessage,
|
||||
'回执失败补发',
|
||||
resolved.submitRecordId,
|
||||
);
|
||||
if (retried) {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
return retried;
|
||||
}
|
||||
await this.facade.refundMessage(businessMessage, '最终失败退款');
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
channelId: logicalChannelId,
|
||||
gatewayMessageId: message.gatewayMessageId ?? data.gatewayMessageId,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
receiptRawStatus: aggregate.rawStatus,
|
||||
status,
|
||||
errorCode: aggregate.errorCode,
|
||||
errorMessage: aggregate.errorMessage ?? (status === 'delivered' ? null : aggregate.rawStatus),
|
||||
deliveredAt: aggregate.deliveredAt,
|
||||
},
|
||||
});
|
||||
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
|
||||
await this.facade.queueAndTryDownstreamDelivery({
|
||||
tenantId: message.tenantId,
|
||||
applicationId: message.applicationId,
|
||||
messageRecordId: message.id,
|
||||
messageId: message.messageId,
|
||||
deliveryType: 'receipt',
|
||||
payload: {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
rawStatus: aggregate.rawStatus,
|
||||
errorCode: aggregate.errorCode,
|
||||
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
|
||||
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
|
||||
deliveredAt: aggregate.deliveredAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (message.batchTaskId) {
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
|
||||
const updated = await segmentAudits.updateMany({
|
||||
where: {
|
||||
messageRecordId: message.id,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
},
|
||||
data: {
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode ?? null,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
if (updated.count > 0) {
|
||||
return;
|
||||
}
|
||||
const submitRecord = submitRecordId
|
||||
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
|
||||
: await this.prisma.smsSubmitRecord.findFirst({
|
||||
where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
await segmentAudits.upsert({
|
||||
where: {
|
||||
messageRecordId_submitId_segmentIndex: {
|
||||
messageRecordId: message.id,
|
||||
submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`,
|
||||
segmentIndex: 1,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
sequenceId: data.sequenceId ?? null,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode ?? null,
|
||||
deliveredAt,
|
||||
},
|
||||
create: {
|
||||
tenantId: message.tenantId,
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
submitRecordId: submitRecord?.id ?? submitRecordId ?? null,
|
||||
channelId: data.channelId ?? message.channelId ?? null,
|
||||
submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`,
|
||||
attempt: 0,
|
||||
segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)),
|
||||
segmentIndex: 1,
|
||||
sequenceId: data.sequenceId ?? null,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
submitStatus: submitRecord?.submitStatus ?? 'accepted',
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
compensationType: 'receipt_recovered',
|
||||
errorCode: data.errorCode ?? null,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async aggregateReceiptSegments(
|
||||
message: {
|
||||
id: string;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
const audits = await this.facade.smsMessageSegmentAuditDelegate().findMany({
|
||||
where: submitRecordId
|
||||
? { messageRecordId: message.id, submitRecordId }
|
||||
: submitId
|
||||
? { messageRecordId: message.id, submitId }
|
||||
: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
|
||||
orderBy: { segmentIndex: 'asc' },
|
||||
});
|
||||
return aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt);
|
||||
}
|
||||
|
||||
async resolveReceiptMessage(
|
||||
data: GatewayReceiptEventDto,
|
||||
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
const exactMessage = data.messageId
|
||||
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
|
||||
: null;
|
||||
if (exactMessage) {
|
||||
const segmentAudit = data.gatewayMessageId
|
||||
? await this.facade.smsMessageSegmentAuditDelegate().findFirst({
|
||||
where: {
|
||||
messageRecordId: exactMessage.id,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
: null;
|
||||
if (segmentAudit) {
|
||||
return {
|
||||
message: exactMessage,
|
||||
messageId: exactMessage.messageId,
|
||||
submitRecordId: segmentAudit.submitRecordId ?? undefined,
|
||||
submitId: segmentAudit.submitId,
|
||||
channelId: segmentAudit.channelId ?? data.channelId,
|
||||
};
|
||||
}
|
||||
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
|
||||
where: {
|
||||
messageRecordId: exactMessage.id,
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return {
|
||||
message: exactMessage,
|
||||
messageId: exactMessage.messageId,
|
||||
submitRecordId: submitRecord?.id,
|
||||
submitId: submitRecord?.submitId,
|
||||
channelId: submitRecord?.channelId ?? data.channelId,
|
||||
};
|
||||
}
|
||||
|
||||
const phoneNumber = data.phoneNumber?.trim();
|
||||
const exactSubmits = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
...(phoneNumber ? { messageRecord: { phoneNumber } } : {}),
|
||||
},
|
||||
include: { messageRecord: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 2,
|
||||
});
|
||||
if (exactSubmits.length === 1 && exactSubmits[0]?.messageRecord) {
|
||||
return {
|
||||
message: exactSubmits[0].messageRecord,
|
||||
messageId: exactSubmits[0].messageRecord.messageId,
|
||||
submitRecordId: exactSubmits[0].id,
|
||||
submitId: exactSubmits[0].submitId,
|
||||
channelId: exactSubmits[0].channelId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!phoneNumber) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
|
||||
const incomingChannel = incomingIdentity
|
||||
?? await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
if (!incomingChannel) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
const segmentMatches = await this.facade.smsMessageSegmentAuditDelegate().findMany({
|
||||
where: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
messageRecord: { phoneNumber },
|
||||
},
|
||||
include: { messageRecord: true, submitRecord: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const exactSegmentMatches = segmentMatches.filter((candidate) => candidate.channelId === data.channelId);
|
||||
if (exactSegmentMatches.length === 1 && exactSegmentMatches[0]?.messageRecord) {
|
||||
return {
|
||||
message: exactSegmentMatches[0].messageRecord,
|
||||
messageId: exactSegmentMatches[0].messageRecord.messageId,
|
||||
submitRecordId: exactSegmentMatches[0].submitRecordId ?? undefined,
|
||||
submitId: exactSegmentMatches[0].submitRecord?.submitId ?? exactSegmentMatches[0].submitId,
|
||||
channelId: exactSegmentMatches[0].channelId,
|
||||
};
|
||||
}
|
||||
const sameSupplierSegments = segmentMatches.filter((candidate) =>
|
||||
candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
|
||||
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
|
||||
return {
|
||||
message: sameSupplierSegments[0].messageRecord,
|
||||
messageId: sameSupplierSegments[0].messageRecord.messageId,
|
||||
submitRecordId: sameSupplierSegments[0].submitRecordId ?? undefined,
|
||||
submitId: sameSupplierSegments[0].submitRecord?.submitId ?? sameSupplierSegments[0].submitId,
|
||||
channelId: sameSupplierSegments[0].channelId,
|
||||
};
|
||||
}
|
||||
const crossConnectionSubmits = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
messageRecord: { phoneNumber },
|
||||
},
|
||||
include: { messageRecord: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const sameSupplierSubmits = crossConnectionSubmits.filter((candidate) =>
|
||||
candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
|
||||
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
|
||||
return {
|
||||
message: sameSupplierSubmits[0].messageRecord,
|
||||
messageId: sameSupplierSubmits[0].messageRecord.messageId,
|
||||
submitRecordId: sameSupplierSubmits[0].id,
|
||||
submitId: sameSupplierSubmits[0].submitId,
|
||||
channelId: sameSupplierSubmits[0].channelId,
|
||||
};
|
||||
}
|
||||
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
const submittedAfter = new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000);
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: null,
|
||||
submitStatus: 'timeout',
|
||||
submittedAt: {
|
||||
gte: submittedAfter,
|
||||
lte: deliveredAt,
|
||||
},
|
||||
messageRecord: {
|
||||
phoneNumber,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
messageRecord: true,
|
||||
},
|
||||
orderBy: {
|
||||
submittedAt: 'desc',
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
if (candidates.length !== 1 || !candidates[0]?.messageRecord) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
|
||||
return {
|
||||
message: candidates[0].messageRecord,
|
||||
messageId: candidates[0].messageRecord.messageId,
|
||||
submitRecordId: candidates[0].id,
|
||||
submitId: candidates[0].submitId,
|
||||
channelId: candidates[0].channelId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 retry implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendRetryService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) {
|
||||
const createdAt = data.deadLetteredAt ? new Date(data.deadLetteredAt) : new Date();
|
||||
return this.prisma.gatewaySubmitDeadLetter.upsert({
|
||||
where: { streamMessageId: data.streamMessageId },
|
||||
update: {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
channelId: data.channelId,
|
||||
traceId: data.traceId,
|
||||
messageId: data.messageId,
|
||||
submitId: data.submitId,
|
||||
failureCode: data.failureCode,
|
||||
failureMessage: data.failureMessage,
|
||||
attempts: data.attempts,
|
||||
maxAttempts: data.maxAttempts,
|
||||
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
|
||||
rawPayload: data.rawPayload,
|
||||
},
|
||||
create: {
|
||||
streamMessageId: data.streamMessageId,
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
channelId: data.channelId,
|
||||
traceId: data.traceId,
|
||||
messageId: data.messageId,
|
||||
submitId: data.submitId,
|
||||
failureCode: data.failureCode,
|
||||
failureMessage: data.failureMessage,
|
||||
attempts: data.attempts,
|
||||
maxAttempts: data.maxAttempts,
|
||||
commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined,
|
||||
rawPayload: data.rawPayload,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
|
||||
const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||
if (!deadLetter) {
|
||||
throw new NotFoundException('Gateway提交异常记录不存在');
|
||||
}
|
||||
if (deadLetter.status !== 'pending') {
|
||||
throw new BadRequestException('该提交异常当前状态不允许重新入队');
|
||||
}
|
||||
if (!data.confirmedNotSubmitted) {
|
||||
throw new BadRequestException('请确认运营商未接收该短信后再重新入队');
|
||||
}
|
||||
const reason = String(data.reason ?? '').trim();
|
||||
if (reason.length < 5 || reason.length > 500) {
|
||||
throw new BadRequestException('请填写5至500字的重新入队原因');
|
||||
}
|
||||
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
|
||||
throw new BadRequestException('该提交异常缺少可重新入队的SubmitCommand');
|
||||
}
|
||||
if (deadLetter.manualRetryCount >= 3) {
|
||||
throw new BadRequestException('该提交异常已达到人工重新入队次数上限');
|
||||
}
|
||||
const message = deadLetter.messageId
|
||||
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: deadLetter.messageId } })
|
||||
: null;
|
||||
if (message && (
|
||||
message.submitStatus === 'accepted'
|
||||
|| ['submitted', 'delivered', 'unknown'].includes(message.status)
|
||||
|| ['delivered', 'unknown'].includes(message.receiptStatus ?? '')
|
||||
)) {
|
||||
throw new BadRequestException('该短信已有成功或不确定的上游结果,为避免重复发送,禁止重新入队');
|
||||
}
|
||||
const commandChannelId = String(deadLetter.commandPayload.channelId ?? deadLetter.channelId ?? '').trim();
|
||||
if (!commandChannelId) {
|
||||
throw new BadRequestException('该提交异常缺少通道信息');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: commandChannelId },
|
||||
include: { connectionStates: true },
|
||||
});
|
||||
if (!channel || channel.status !== 'active') {
|
||||
throw new BadRequestException('原通道不存在或已停用,不能重新入队');
|
||||
}
|
||||
if (!channel.connectionStates.some((state) => state.status === 'connected' && state.currentConnections > 0)) {
|
||||
throw new BadRequestException('原通道当前没有可用CMPP连接,请先恢复通道');
|
||||
}
|
||||
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'pending' },
|
||||
data: { status: 'requeueing' },
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
|
||||
}
|
||||
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
|
||||
let retryStreamMessageId: string;
|
||||
try {
|
||||
const publishedStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
|
||||
if (!publishedStreamMessageId) {
|
||||
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
|
||||
}
|
||||
retryStreamMessageId = publishedStreamMessageId;
|
||||
} catch (error) {
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'requeueing' },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id, status: 'requeueing' },
|
||||
data: {
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetryStreamId: retryStreamMessageId,
|
||||
lastRetriedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const updated = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Gateway提交异常记录不存在');
|
||||
}
|
||||
if (finalized.count !== 1 && updated.status !== 'resolved') {
|
||||
throw new BadRequestException('该提交异常状态已变化,请刷新后确认处理结果');
|
||||
}
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
userId: data.operatorId,
|
||||
action: 'gateway.submit_dead_letter_requeue',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
streamMessageId: updated.streamMessageId,
|
||||
retryStreamMessageId,
|
||||
submitId: updated.submitId,
|
||||
messageId: updated.messageId,
|
||||
reason,
|
||||
confirmedNotSubmitted: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
|
||||
const staleCutoff = new Date(now.getTime() - positiveInteger(
|
||||
process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
||||
DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS,
|
||||
));
|
||||
const stale = await this.prisma.gatewaySubmitDeadLetter.findMany({
|
||||
where: { status: 'requeueing', updatedAt: { lt: staleCutoff } },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
let recovered = 0;
|
||||
let failed = 0;
|
||||
for (const deadLetter of stale) {
|
||||
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt },
|
||||
data: { status: 'requeue_recovering' },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
try {
|
||||
const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1);
|
||||
const retryStreamMessageId = await this.facade.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey);
|
||||
if (!retryStreamMessageId) throw new Error('Gateway提交异常恢复未返回Stream消息编号');
|
||||
const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeue_recovering' },
|
||||
data: {
|
||||
status: 'requeued',
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetryStreamId: retryStreamMessageId,
|
||||
lastRetriedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (finalized.count === 1) {
|
||||
recovered += 1;
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: deadLetter.tenantId ?? undefined,
|
||||
action: 'gateway.submit_dead_letter_requeue_recovered',
|
||||
resource: 'gateway_submit_dead_letter',
|
||||
resourceId: deadLetter.id,
|
||||
detail: { retryStreamMessageId, requeueKey },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
await this.prisma.gatewaySubmitDeadLetter.updateMany({
|
||||
where: { id: deadLetter.id, status: 'requeue_recovering' },
|
||||
data: { status: 'requeueing' },
|
||||
});
|
||||
this.logger.error(`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return { recovered, failed };
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
const attempts = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: { messageRecordId: message.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
const attemptedChannelIds = attempts.map((attempt) => attempt.channelId);
|
||||
let sourceAttempt = sourceSubmitRecordId
|
||||
? attempts.find((attempt) => attempt.id === sourceSubmitRecordId)
|
||||
: attempts.find((attempt) => attempt.submitId === message.submitId) ?? attempts[attempts.length - 1];
|
||||
if (!sourceAttempt && sourceSubmitRecordId) {
|
||||
sourceAttempt = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { id: sourceSubmitRecordId },
|
||||
}) ?? undefined;
|
||||
}
|
||||
if (!sourceAttempt || sourceAttempt.messageRecordId !== message.id) {
|
||||
this.logger.error(`sms_retry_route_failed ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
sourceSubmitRecordId,
|
||||
sourceMessageRecordId: sourceAttempt?.messageRecordId,
|
||||
error: sourceAttempt ? 'retry_source_submit_record_mismatch' : 'retry_source_submit_record_missing',
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
|
||||
where: { retryOfSubmitRecordId: sourceAttempt.id },
|
||||
});
|
||||
if (existingRetry) {
|
||||
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
retryOfSubmitRecordId: sourceAttempt.id,
|
||||
submitId: existingRetry.submitId,
|
||||
channelId: existingRetry.channelId,
|
||||
})}`);
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000;
|
||||
this.logger.log(`sms_retry_route_started ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
attemptedChannelIds,
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
})}`);
|
||||
if (ageMinutes >= 72 * 60) {
|
||||
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason: 'maximum_message_age_exceeded',
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
const retryCarrier = message.carrier
|
||||
? normalizeCarrier(message.carrier)
|
||||
: await this.facade.identifyCarrier(message.phoneNumber);
|
||||
const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, retryCarrier);
|
||||
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
|
||||
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
|
||||
this.logger.warn(`sms_retry_route_skipped ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
groupId: route.groupId,
|
||||
reason: !route.group.retryEnabled ? 'group_retry_disabled' : 'group_retry_time_limit_exceeded',
|
||||
ageMinutes: Math.round(ageMinutes * 100) / 100,
|
||||
retryTimeLimitMinutes,
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const routed = await this.facade.selectChannelForMessage({ ...message, carrier: retryCarrier }, {
|
||||
forceNational: true,
|
||||
excludeChannelIds: attemptedChannelIds,
|
||||
});
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { errorMessage: reason },
|
||||
});
|
||||
const retried = await this.facade.submitMessageToGateway(
|
||||
message,
|
||||
routed,
|
||||
attempts.length,
|
||||
sourceAttempt.id,
|
||||
);
|
||||
this.logger.log(`sms_retry_route_selected ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
groupId: routed.groupId,
|
||||
channelId: routed.channel.id,
|
||||
attempt: attempts.length,
|
||||
})}`);
|
||||
return retried;
|
||||
} catch (error) {
|
||||
this.logger.error(`sms_retry_route_failed ${JSON.stringify({
|
||||
messageId: message.messageId,
|
||||
messageRecordId: message.id,
|
||||
reason,
|
||||
attemptedChannelIds,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { Queue } from 'bullmq';
|
||||
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, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
|
||||
import { SendBatchEntryService } from './send-batch-entry.service';
|
||||
import { SendGatewaySubmitService } from './send-gateway-submit.service';
|
||||
import { SendInboundEntryService } from './send-inbound-entry.service';
|
||||
import { SendReviewContinuationService } from './send-review-continuation.service';
|
||||
import { SendScheduledDispatchService } from './send-scheduled-dispatch.service';
|
||||
|
||||
|
||||
export type SendSubmissionCallbacks = {
|
||||
releaseMessageReservation: (
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
||||
remark: string,
|
||||
) => Promise<void>;
|
||||
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,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* R9 internal compatibility facade. SendChainService remains the only public NestJS provider.
|
||||
*/
|
||||
export class SendSubmissionService {
|
||||
private readonly batchEntry: SendBatchEntryService;
|
||||
private readonly inboundEntry: SendInboundEntryService;
|
||||
private readonly reviewContinuation: SendReviewContinuationService;
|
||||
private readonly scheduledDispatch: SendScheduledDispatchService;
|
||||
private readonly gatewaySubmit: SendGatewaySubmitService;
|
||||
|
||||
constructor(
|
||||
prisma: PrismaService,
|
||||
billing: BillingService,
|
||||
riskReview: RiskReviewService,
|
||||
phoneFrequency: PhoneFrequencyService,
|
||||
phoneRouting: PhoneRoutingLookupService,
|
||||
facade: SendSubmissionService,
|
||||
callbacks: SendSubmissionCallbacks,
|
||||
) {
|
||||
this.batchEntry = new SendBatchEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.inboundEntry = new SendInboundEntryService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.reviewContinuation = new SendReviewContinuationService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.scheduledDispatch = new SendScheduledDispatchService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
this.gatewaySubmit = new SendGatewaySubmitService(prisma, billing, riskReview, phoneFrequency, phoneRouting, facade, callbacks);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
return this.gatewaySubmit.onModuleDestroy();
|
||||
}
|
||||
|
||||
async createBatchTask(data: CreateBatchTaskDto) {
|
||||
return this.batchEntry.createBatchTask(data);
|
||||
}
|
||||
|
||||
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
|
||||
return this.batchEntry.createHttpBatchTask(data);
|
||||
}
|
||||
|
||||
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
|
||||
return this.batchEntry.getBatchTask(taskId, tenantId, sourceType);
|
||||
}
|
||||
|
||||
async previewImport(data: ImportPreviewDto) {
|
||||
return this.batchEntry.previewImport(data);
|
||||
}
|
||||
|
||||
async confirmImport(data: ConfirmImportDto) {
|
||||
return this.batchEntry.confirmImport(data);
|
||||
}
|
||||
|
||||
async resolveUnitPrice(tenantId: string, applicationId?: string) {
|
||||
return this.batchEntry.resolveUnitPrice(tenantId, applicationId);
|
||||
}
|
||||
|
||||
async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
|
||||
return this.batchEntry.resolveQueuePriority(tenantId, applicationId);
|
||||
}
|
||||
|
||||
async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
|
||||
return this.batchEntry.resolveApplicationAccessNumber(tenantId, applicationId);
|
||||
}
|
||||
|
||||
async resolveTemplateMessageClassification(
|
||||
tenantId: string,
|
||||
applicationId: string | undefined,
|
||||
templateId: string | undefined,
|
||||
content: string,
|
||||
) {
|
||||
return this.batchEntry.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content);
|
||||
}
|
||||
|
||||
async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
|
||||
return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones);
|
||||
}
|
||||
|
||||
async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
|
||||
return this.batchEntry.validateSendResources(tenantId, applicationId, templateId);
|
||||
}
|
||||
|
||||
async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
return this.batchEntry.reserveDailySendQuota(applicationId, requestedCount);
|
||||
}
|
||||
|
||||
async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
|
||||
return this.batchEntry.tryReserveDailySendQuota(applicationId, requestedCount);
|
||||
}
|
||||
|
||||
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
|
||||
return this.inboundEntry.authenticateInboundApplication(data);
|
||||
}
|
||||
|
||||
async submitInboundMessage(data: GatewayInboundSubmitDto) {
|
||||
return this.inboundEntry.submitInboundMessage(data);
|
||||
}
|
||||
|
||||
async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
|
||||
return this.inboundEntry.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers);
|
||||
}
|
||||
|
||||
async submitCompleteInboundMessage(
|
||||
data: GatewayInboundSubmitDto,
|
||||
phoneNumbers: string[],
|
||||
application: Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>,
|
||||
requestedGroupMessageId?: string,
|
||||
) {
|
||||
return this.inboundEntry.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId);
|
||||
}
|
||||
|
||||
async collectInboundLongMessageFragment(
|
||||
data: GatewayInboundSubmitDto,
|
||||
application: NonNullable<Awaited<ReturnType<SendSubmissionService['findInboundApplication']>>>,
|
||||
phoneNumbers: string[],
|
||||
) {
|
||||
return this.inboundEntry.collectInboundLongMessageFragment(data, application, phoneNumbers);
|
||||
}
|
||||
|
||||
async expireInboundLongMessages(now = new Date()) {
|
||||
return this.inboundEntry.expireInboundLongMessages(now);
|
||||
}
|
||||
|
||||
async submitInboundSingleMessage(
|
||||
data: GatewayInboundSubmitDto & { phoneNumber: string },
|
||||
messageId: string,
|
||||
submitGroupMessageId: string,
|
||||
synchronousRejection?: { code: string; reason: string },
|
||||
receiptRejection?: { code: string; reason: string },
|
||||
) {
|
||||
return this.inboundEntry.submitInboundSingleMessage(data, messageId, submitGroupMessageId, synchronousRejection, receiptRejection);
|
||||
}
|
||||
|
||||
async evaluateRiskWithPhoneFrequency(input: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
variables?: Record<string, unknown>;
|
||||
phoneNumber: string;
|
||||
sourceType: 'cmpp';
|
||||
}) {
|
||||
return this.inboundEntry.evaluateRiskWithPhoneFrequency(input);
|
||||
}
|
||||
|
||||
findInboundApplication(account: string) {
|
||||
return this.inboundEntry.findInboundApplication(account);
|
||||
}
|
||||
|
||||
async resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||
return this.inboundEntry.resolveInboundTemplateCandidate(applicationId, content);
|
||||
}
|
||||
|
||||
resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
||||
return this.inboundEntry.resolveInboundSignatureCandidate(applicationId, content);
|
||||
}
|
||||
|
||||
async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
|
||||
return this.inboundEntry.resolveDrainageInfoMatch(signatureId, content);
|
||||
}
|
||||
|
||||
async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
|
||||
return this.inboundEntry.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
|
||||
}
|
||||
|
||||
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
|
||||
return this.reviewContinuation.handleReviewDecision(reviewTaskId, decision, reason);
|
||||
}
|
||||
|
||||
async dispatchDueScheduledTasks(now = new Date()) {
|
||||
return this.scheduledDispatch.dispatchDueScheduledTasks(now);
|
||||
}
|
||||
|
||||
async runScheduledDispatchScan() {
|
||||
return this.scheduledDispatch.runScheduledDispatchScan();
|
||||
}
|
||||
|
||||
async enqueueBatchTask(taskId: string) {
|
||||
return this.gatewaySubmit.enqueueBatchTask(taskId);
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
return this.gatewaySubmit.startWorker();
|
||||
}
|
||||
|
||||
async processSendJob(job: SendJob) {
|
||||
return this.gatewaySubmit.processSendJob(job);
|
||||
}
|
||||
|
||||
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.gatewaySubmit.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId);
|
||||
}
|
||||
|
||||
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<RoutedChannel> {
|
||||
return this.gatewaySubmit.selectChannelForMessage(message, options);
|
||||
}
|
||||
|
||||
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
|
||||
return this.gatewaySubmit.findApplicationRoute(tenantId, applicationId, carrier);
|
||||
}
|
||||
|
||||
async identifyCarrier(phoneNumber: string) {
|
||||
return this.gatewaySubmit.identifyCarrier(phoneNumber);
|
||||
}
|
||||
|
||||
async identifyProvince(phoneNumber: string) {
|
||||
return this.gatewaySubmit.identifyProvince(phoneNumber);
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId);
|
||||
}
|
||||
|
||||
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
|
||||
return this.gatewaySubmit.resolveMessageSignatureId(message);
|
||||
}
|
||||
|
||||
async waitForChannelRateLimit(channelId: string, tps: number) {
|
||||
return this.gatewaySubmit.waitForChannelRateLimit(channelId, tps);
|
||||
}
|
||||
|
||||
async refreshTaskProgress(batchTaskId: string) {
|
||||
return this.gatewaySubmit.refreshTaskProgress(batchTaskId);
|
||||
}
|
||||
|
||||
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
|
||||
return this.gatewaySubmit.getSendQueue();
|
||||
}
|
||||
|
||||
getGatewayQueue(): Queue {
|
||||
return this.gatewaySubmit.getGatewayQueue();
|
||||
}
|
||||
|
||||
getRedis() {
|
||||
return this.gatewaySubmit.getRedis();
|
||||
}
|
||||
|
||||
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
|
||||
return this.gatewaySubmit.publishGatewaySubmitCommand(command, idempotencyKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import type { OpenApiService } from '../open-api/open-api.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
||||
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
||||
import type { SendSubmissionService } from './send-submission.service';
|
||||
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
||||
|
||||
|
||||
/**
|
||||
* R10 timeout implementation.
|
||||
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
||||
*/
|
||||
export class SendTimeoutService {
|
||||
private readonly logger = new Logger('SendChainService');
|
||||
private receiptTimeoutScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly openApi: OpenApiService | undefined,
|
||||
private readonly facade: SendCompletionFacade,
|
||||
private readonly callbacks: SendCompletionCallbacks,
|
||||
) {}
|
||||
|
||||
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||
const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS);
|
||||
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
|
||||
const candidates = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
tenantId: { not: null },
|
||||
status: { in: ['submitted', 'unknown'] },
|
||||
submittedAt: { lte: cutoff },
|
||||
},
|
||||
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
|
||||
take: 10000,
|
||||
});
|
||||
const timedOutTaskIds = new Set<string>();
|
||||
let timeout = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.tenantId) continue;
|
||||
const transitioned = await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } },
|
||||
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时` },
|
||||
});
|
||||
if (transitioned.count !== 1) continue;
|
||||
timeout += 1;
|
||||
await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`);
|
||||
if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId);
|
||||
}
|
||||
for (const batchTaskId of timedOutTaskIds) {
|
||||
await this.facade.refreshTaskProgress(batchTaskId);
|
||||
}
|
||||
return { timeout };
|
||||
}
|
||||
|
||||
async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) {
|
||||
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60_000);
|
||||
const expired = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: {
|
||||
status: 'pending',
|
||||
OR: [
|
||||
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
|
||||
{ lastRetriedAt: { lte: cutoff } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
take: 500,
|
||||
});
|
||||
for (const delivery of expired) {
|
||||
await this.facade.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
`下游投递排队超过 ${olderThanHours} 小时,系统自动终止重试`,
|
||||
'queue_timeout',
|
||||
);
|
||||
}
|
||||
return { failed: expired.length };
|
||||
}
|
||||
|
||||
async runReceiptTimeoutScan() {
|
||||
if (this.receiptTimeoutScanRunning) return;
|
||||
this.receiptTimeoutScanRunning = true;
|
||||
try {
|
||||
const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([
|
||||
this.facade.markUnknownTimeout({}),
|
||||
this.facade.markExpiredDownstreamDeliveries(),
|
||||
this.facade.recoverStaleGatewaySubmitRequeues(),
|
||||
this.facade.recoverStaleDownstreamManualRequeues(),
|
||||
]);
|
||||
if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`);
|
||||
if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`);
|
||||
if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`);
|
||||
if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`);
|
||||
} catch (error) {
|
||||
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
|
||||
} finally {
|
||||
this.receiptTimeoutScanRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user