Files
lislgosms/api/src/send-chain/send-chain.service.ts
T

879 lines
34 KiB
TypeScript

import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { randomUUID } from 'node:crypto';
import { createHash } 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 { OpenApiService } from '../open-api/open-api.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, 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_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers';
import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import { SendSubmissionService } from './send-submission.service';
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
@Injectable()
export class SendChainService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SendChainService.name);
private redis?: IORedis;
private sendQueue?: Queue<SendJob, unknown, 'send-message'>;
private gatewayQueue?: Queue;
private worker?: Worker<SendJob>;
private receiptTimeoutInitialTimer?: ReturnType<typeof setTimeout>;
private receiptTimeoutIntervalTimer?: ReturnType<typeof setInterval>;
private receiptTimeoutScanRunning = false;
private scheduledDispatchInitialTimer?: ReturnType<typeof setTimeout>;
private scheduledDispatchIntervalTimer?: ReturnType<typeof setInterval>;
private scheduledDispatchScanRunning = false;
private inboundLongMessageInitialTimer?: ReturnType<typeof setTimeout>;
private inboundLongMessageIntervalTimer?: ReturnType<typeof setInterval>;
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
private readonly submission: SendSubmissionService;
private readonly completion: SendCompletionService;
constructor(
private readonly prisma: PrismaService,
private readonly billing: BillingService,
private readonly riskReview: RiskReviewService,
private readonly phoneFrequency: PhoneFrequencyService,
@Optional() @Inject(forwardRef(() => OpenApiService)) private readonly openApi?: OpenApiService,
@Optional() phoneRouting?: PhoneRoutingLookupService,
) {
const resolvedPhoneRouting = phoneRouting ?? new PhoneRoutingLookupService(prisma);
this.submission = new SendSubmissionService(
prisma,
billing,
riskReview,
phoneFrequency,
resolvedPhoneRouting,
this as unknown as SendSubmissionService,
{
releaseMessageReservation: (message, remark) => this.releaseMessageReservation(message, remark),
recordCmppFailureReceipt: (message, errorCode, reason) =>
this.recordCmppFailureReceipt(message, errorCode, reason),
},
);
this.completion = new SendCompletionService(
prisma,
billing,
openApi,
this as unknown as SendCompletionFacade,
);
}
onModuleInit() {
if (process.env.API_ENABLE_SEND_WORKER === 'true') {
this.startWorker();
}
if (process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED !== 'false') {
this.receiptTimeoutInitialTimer = setTimeout(() => void this.runReceiptTimeoutScan(), RECEIPT_TIMEOUT_INITIAL_DELAY_MS);
this.receiptTimeoutInitialTimer.unref?.();
this.receiptTimeoutIntervalTimer = setInterval(
() => void this.runReceiptTimeoutScan(),
positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS),
);
this.receiptTimeoutIntervalTimer.unref?.();
}
if (process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED !== 'false') {
this.scheduledDispatchInitialTimer = setTimeout(
() => void this.runScheduledDispatchScan(),
SCHEDULED_DISPATCH_INITIAL_DELAY_MS,
);
this.scheduledDispatchInitialTimer.unref?.();
this.scheduledDispatchIntervalTimer = setInterval(
() => void this.runScheduledDispatchScan(),
positiveInteger(process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS),
);
this.scheduledDispatchIntervalTimer.unref?.();
}
if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') {
this.inboundLongMessageInitialTimer = setTimeout(
() => void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS,
);
this.inboundLongMessageInitialTimer.unref?.();
this.inboundLongMessageIntervalTimer = setInterval(
() => void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
positiveInteger(
process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
),
);
this.inboundLongMessageIntervalTimer.unref?.();
}
if (process.env.UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED !== 'false') {
this.upstreamReceiptInboxInitialTimer = setTimeout(
() => void this.runUpstreamReceiptInboxScan(),
UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS,
);
this.upstreamReceiptInboxInitialTimer.unref?.();
this.upstreamReceiptInboxIntervalTimer = setInterval(
() => void this.runUpstreamReceiptInboxScan(),
positiveInteger(
process.env.UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS,
DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS,
),
);
this.upstreamReceiptInboxIntervalTimer.unref?.();
}
}
async onModuleDestroy() {
if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer);
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer);
if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer);
if (this.inboundLongMessageInitialTimer) clearTimeout(this.inboundLongMessageInitialTimer);
if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer);
if (this.upstreamReceiptInboxInitialTimer) clearTimeout(this.upstreamReceiptInboxInitialTimer);
if (this.upstreamReceiptInboxIntervalTimer) clearInterval(this.upstreamReceiptInboxIntervalTimer);
await this.worker?.close();
await this.sendQueue?.close();
await this.gatewayQueue?.close();
this.redis?.disconnect();
}
async createBatchTask(data: CreateBatchTaskDto) {
return this.submission.createBatchTask(data);
}
async createHttpBatchTask(data: CreateHttpBatchTaskDto) {
return this.submission.createHttpBatchTask(data);
}
async listBatchTasks(tenantId?: string, status?: string, sourceType = 'client') {
const tasks = await this.prisma.smsBatchTask.findMany({
where: { tenantId, status, sourceType },
include: {
tenant: true,
application: true,
template: true,
apiRequests: true,
},
orderBy: { createdAt: 'desc' },
});
const taskIds = tasks.map((task) => task.id);
const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
}) : [];
return tasks.map((task) => ({
...task,
messageStats: messageStats.filter((item) => item.batchTaskId === task.id),
}));
}
async getBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
return this.submission.getBatchTask(taskId, tenantId, sourceType);
}
async listClientTaskMessages(taskId: string, tenantId: string) {
await this.getBatchTask(taskId, tenantId, 'client');
return this.listMessages({ tenantId, taskId });
}
async listBatchTasksPage(query: {
tenantId?: string;
status?: string;
sourceType?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.SmsBatchTaskWhereInput = {
tenantId: query.tenantId,
status: query.status,
sourceType: query.sourceType ?? 'client',
taskNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined,
application: query.applicationKeyword?.trim() ? { name: { contains: query.applicationKeyword.trim() } } : undefined,
createdAt: query.createdAtFrom || query.createdAtTo ? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
} : undefined,
};
const [tasks, total] = await Promise.all([
this.prisma.smsBatchTask.findMany({
where,
include: {
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
template: { select: { id: true, name: true, content: true } },
apiRequests: true,
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.smsBatchTask.count({ where }),
]);
const taskIds = tasks.map((task) => task.id);
const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
}) : [];
return {
items: tasks.map((task) => ({
...task,
messageStats: messageStats.filter((item) => item.batchTaskId === task.id),
})),
total,
page,
pageSize,
};
}
async listAdminBatchTaskMessages(taskId: string, phone?: string, page = 1, pageSize = 20) {
const task = await this.prisma.smsBatchTask.findFirst({
where: { id: taskId, sourceType: 'client' },
select: { id: true },
});
if (!task) {
throw new NotFoundException('SMS batch task not found');
}
const normalizedPage = Math.max(1, Math.floor(Number(page) || 1));
const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(Number(pageSize) || 20)));
const where: Prisma.SmsMessageRecordWhereInput = {
batchTaskId: taskId,
...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}),
};
const [items, total] = await Promise.all([
this.prisma.smsMessageRecord.findMany({
where,
select: {
id: true,
phoneNumber: true,
province: true,
carrier: true,
status: true,
},
orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }],
skip: (normalizedPage - 1) * normalizedPageSize,
take: normalizedPageSize,
}),
this.prisma.smsMessageRecord.count({ where }),
]);
return { items, total, page: normalizedPage, pageSize: normalizedPageSize };
}
listMessages(query: {
tenantId?: string;
applicationId?: string;
channelId?: string;
taskId?: string;
phoneNumber?: string;
status?: string;
} = {}) {
return this.prisma.smsMessageRecord.findMany({
where: {
tenantId: query.tenantId,
applicationId: query.applicationId,
channelId: query.channelId,
batchTaskId: query.taskId,
phoneNumber: query.phoneNumber,
status: query.status,
},
include: {
tenant: true,
application: true,
channel: true,
submitRecords: { include: { channel: true }, orderBy: { createdAt: 'asc' } },
receiptRecords: { include: { channel: true }, orderBy: { createdAt: 'asc' } },
},
orderBy: { queuedAt: 'desc' },
});
}
listSubmitRecords(taskId?: string) {
return this.prisma.smsSubmitRecord.findMany({
where: { batchTaskId: taskId },
orderBy: { createdAt: 'desc' },
});
}
listReceiptRecords(taskId?: string) {
return this.prisma.smsReceiptRecord.findMany({
where: { batchTaskId: taskId },
orderBy: { createdAt: 'desc' },
});
}
listUplinkMessages(tenantId?: string, channelId?: string) {
return this.prisma.smsUplinkMessage.findMany({
where: { tenantId, channelId },
include: { application: true, channel: true, messageRecord: { include: { application: true } } },
orderBy: { receivedAt: 'desc' },
});
}
async previewImport(data: ImportPreviewDto) {
return this.submission.previewImport(data);
}
async confirmImport(data: ConfirmImportDto) {
return this.submission.confirmImport(data);
}
async enqueueBatchTask(taskId: string) {
return this.submission.enqueueBatchTask(taskId);
}
async cancelBatchTask(taskId: string, tenantId?: string, sourceType = 'client') {
const task = await this.prisma.smsBatchTask.findFirst({ where: { id: taskId, tenantId, sourceType } });
if (!task) {
throw new NotFoundException('SMS batch task not found');
}
if (task.status !== 'scheduled') {
throw new BadRequestException('Only scheduled SMS batch tasks can be canceled before dispatch');
}
await this.prisma.smsMessageRecord.updateMany({
where: { batchTaskId: taskId, status: 'scheduled' },
data: { status: 'canceled', errorMessage: '定时任务已取消' },
});
return this.prisma.smsBatchTask.update({
where: { id: taskId },
data: { status: 'canceled', canceledAt: new Date() },
});
}
async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
return this.submission.handleReviewDecision(reviewTaskId, decision, reason);
}
async terminateBatchTask(taskId: string) {
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
if (!task) {
throw new NotFoundException('SMS batch task not found');
}
if (['finished', 'completed', 'failed', 'canceled', 'rejected'].includes(task.status)) {
throw new BadRequestException('SMS batch task is already final');
}
await this.prisma.smsMessageRecord.updateMany({
where: { batchTaskId: taskId, status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } },
data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' },
});
await this.refreshTaskProgress(taskId);
return this.prisma.smsBatchTask.update({
where: { id: taskId },
data: { status: 'canceled', canceledAt: new Date(), rejectReason: '运营终止任务' },
});
}
async dispatchDueScheduledTasks(now = new Date()) {
return this.submission.dispatchDueScheduledTasks(now);
}
private async runScheduledDispatchScan() {
return this.submission.runScheduledDispatchScan();
}
startWorker() {
return this.submission.startWorker();
}
async processSendJob(job: SendJob) {
return this.submission.processSendJob(job);
}
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
return this.completion.handleSubmitSegmentResult(data);
}
private async resolveSubmitRecordForGatewaySegmentResult(
messageRecordId: string,
data: GatewaySubmitSegmentResultDto,
) {
return this.completion.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
}
async handleSubmitResult(data: GatewaySubmitResultDto) {
return this.completion.handleSubmitResult(data);
}
private async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
return this.completion.resolveSubmitRecordForGatewayResult(messageRecordId, data);
}
async intakeReceipt(data: GatewayReceiptEventDto) {
return this.completion.intakeReceipt(data);
}
async processPendingUpstreamReceiptInbox(limit = 100) {
return this.completion.processPendingUpstreamReceiptInbox(limit);
}
private async processUpstreamReceiptInboxRecord(id: string) {
return this.completion.processUpstreamReceiptInboxRecord(id);
}
private async runUpstreamReceiptInboxScan() {
return this.completion.runUpstreamReceiptInboxScan();
}
async handleReceipt(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
) {
return this.completion.handleReceipt(data, incomingIdentity);
}
async handleUplink(data: GatewayUplinkEventDto) {
return this.completion.handleUplink(data);
}
async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) {
return this.completion.listPendingDownstreamDeliveries(data);
}
async markDownstreamDeliveryDelivered(id: string) {
return this.completion.markDownstreamDeliveryDelivered(id);
}
async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) {
return this.completion.markDownstreamDeliverySent(data);
}
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
return this.completion.acknowledgeDownstreamDelivery(data);
}
async markDownstreamDeliveryFailed(
id: string,
errorMessage?: string,
failureType: GatewayDownstreamFailureType = 'send_failed',
attempt?: GatewayDownstreamSentDto,
) {
return this.completion.markDownstreamDeliveryFailed(id, errorMessage, failureType, attempt);
}
async recordGatewaySubmitDeadLetter(data: GatewaySubmitDeadLetterDto) {
return this.completion.recordGatewaySubmitDeadLetter(data);
}
async recordGatewayDownstreamRecoveryStatus(data: GatewayDownstreamRecoveryStatusDto) {
return this.completion.recordGatewayDownstreamRecoveryStatus(data);
}
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
return this.completion.requeueGatewaySubmitDeadLetter(id, data);
}
async recoverStaleGatewaySubmitRequeues(now = new Date()) {
return this.completion.recoverStaleGatewaySubmitRequeues(now);
}
async requeueDownstreamDelivery(id: string) {
return this.completion.requeueDownstreamDelivery(id);
}
async recoverStaleDownstreamManualRequeues(now = new Date()) {
return this.completion.recoverStaleDownstreamManualRequeues(now);
}
async batchRequeueDownstreamDeliveries(ids: string[]) {
return this.completion.batchRequeueDownstreamDeliveries(ids);
}
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
}
private async queueAndTryDownstreamDelivery(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
return this.completion.queueAndTryDownstreamDelivery(data);
}
private async resolveUplinkMatch(
data: GatewayUplinkEventDto,
channel: { id: string; srcId?: string | null },
): Promise<{
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
}> {
return this.completion.resolveUplinkMatch(data, channel);
}
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
return this.submission.authenticateInboundApplication(data);
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
return this.submission.submitInboundMessage(data);
}
private async recoverCompletedInboundLongMessageResponse(messageId: string, phoneNumbers: string[]) {
return this.submission.recoverCompletedInboundLongMessageResponse(messageId, phoneNumbers);
}
private async submitCompleteInboundMessage(
data: GatewayInboundSubmitDto,
phoneNumbers: string[],
application: Awaited<ReturnType<SendChainService['findInboundApplication']>>,
requestedGroupMessageId?: string,
) {
return this.submission.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId);
}
private async collectInboundLongMessageFragment(
data: GatewayInboundSubmitDto,
application: NonNullable<Awaited<ReturnType<SendChainService['findInboundApplication']>>>,
phoneNumbers: string[],
) {
return this.submission.collectInboundLongMessageFragment(data, application, phoneNumbers);
}
async expireInboundLongMessages(now = new Date()) {
return this.submission.expireInboundLongMessages(now);
}
private async submitInboundSingleMessage(
data: GatewayInboundSubmitDto & { phoneNumber: string },
messageId: string,
submitGroupMessageId: string,
synchronousRejection?: { code: string; reason: string },
receiptRejection?: { code: string; reason: string },
) {
return this.submission.submitInboundSingleMessage(data, messageId, submitGroupMessageId, synchronousRejection, receiptRejection);
}
/**
* CMPP 单号码提交必须在生成最终入队决定前原子占用号码频次。
* 频次规则是直接拒绝,因此优先级高于其他规则产生的待人工审核结果。
*/
private async evaluateRiskWithPhoneFrequency(input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
}) {
return this.submission.evaluateRiskWithPhoneFrequency(input);
}
async markUnknownTimeout(data: TimeoutUnknownDto) {
return this.completion.markUnknownTimeout(data);
}
async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) {
return this.completion.markExpiredDownstreamDeliveries(olderThanHours);
}
private async runReceiptTimeoutScan() {
return this.completion.runReceiptTimeoutScan();
}
private async submitMessageToGateway(
message: {
id: string;
tenantId: string;
batchTaskId: string;
applicationId?: string | null;
templateId?: string | null;
signatureId?: string | null;
submitId?: string | null;
messageId: string;
phoneNumber: string;
content: string;
billingUnits: number;
queuePriority?: string | null;
clientSrcId?: string | null;
applicationExtension?: string | null;
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
signature?: { id?: string | null; name?: string | null } | null;
},
routed: RoutedChannel,
attempt: number,
retryOfSubmitRecordId?: string,
) {
return this.submission.submitMessageToGateway(message, routed, attempt, retryOfSubmitRecordId);
}
private async retryMessageIfAllowed(
message: {
id: string;
tenantId: string;
batchTaskId: string;
applicationId?: string | null;
templateId?: string | null;
signatureId?: string | null;
submitId?: string | null;
messageId: string;
phoneNumber: string;
content: string;
billingUnits: number;
queuedAt?: Date;
clientSrcId?: string | null;
applicationExtension?: string | null;
carrier?: string | null;
province?: string | null;
},
reason: string,
sourceSubmitRecordId?: string,
) {
return this.completion.retryMessageIfAllowed(message, reason, sourceSubmitRecordId);
}
private async selectChannelForMessage(
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): Promise<RoutedChannel> {
return this.submission.selectChannelForMessage(message, options);
}
private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
return this.submission.findApplicationRoute(tenantId, applicationId, carrier);
}
private async identifyCarrier(phoneNumber: string) {
return this.submission.identifyCarrier(phoneNumber);
}
private async identifyProvince(phoneNumber: string) {
return this.submission.identifyProvince(phoneNumber);
}
private async resolveUnitPrice(tenantId: string, applicationId?: string) {
return this.submission.resolveUnitPrice(tenantId, applicationId);
}
private async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
return this.submission.resolveQueuePriority(tenantId, applicationId);
}
private async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
return this.submission.resolveApplicationAccessNumber(tenantId, applicationId);
}
private findInboundApplication(account: string) {
return this.submission.findInboundApplication(account);
}
private async resolveInboundTemplateCandidate(applicationId: string, content: string) {
return this.submission.resolveInboundTemplateCandidate(applicationId, content);
}
private resolveInboundSignatureCandidate(applicationId: string, content: string) {
return this.submission.resolveInboundSignatureCandidate(applicationId, content);
}
private async resolveTemplateMessageClassification(
tenantId: string,
applicationId: string | undefined,
templateId: string | undefined,
content: string,
) {
return this.submission.resolveTemplateMessageClassification(tenantId, applicationId, templateId, content);
}
private async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) {
return this.submission.resolveDrainageInfoMatch(signatureId, content);
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
return this.submission.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
}
private async recordCmppFailureReceipt(
message: {
id: string;
tenantId?: string | null;
batchTaskId?: string | null;
applicationId?: string | null;
messageId: string;
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
},
errorCode: string,
reason: string,
) {
return this.completion.recordCmppFailureReceipt(message, errorCode, reason);
}
private async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) {
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones);
}
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) {
return this.submission.validateSendResources(tenantId, applicationId, templateId);
}
private async reserveDailySendQuota(applicationId: string, requestedCount: number) {
return this.submission.reserveDailySendQuota(applicationId, requestedCount);
}
private async tryReserveDailySendQuota(applicationId: string, requestedCount: number) {
return this.submission.tryReserveDailySendQuota(applicationId, requestedCount);
}
private async chargeAcceptedMessage(message: {
tenantId: string;
applicationId?: string | null;
batchTaskId: string;
messageId: string;
phoneNumber: string;
content: string;
billingUnits: number;
unitPrice: number | bigint;
amountCents: number | bigint;
}) {
return this.completion.chargeAcceptedMessage(message);
}
private async releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
remark: string,
) {
return this.completion.releaseMessageReservation(message, remark);
}
private async refundMessage(
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
remark: string,
) {
return this.completion.refundMessage(message, remark);
}
private async ensureSignatureReportedForChannel(
message: {
id: string;
templateId?: string | null;
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
signature?: { id?: string | null; name?: string | null } | null;
},
channelId: string,
) {
return this.submission.ensureSignatureReportedForChannel(message, channelId);
}
private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
return this.submission.resolveMessageSignatureId(message);
}
private async waitForChannelRateLimit(channelId: string, tps: number) {
return this.submission.waitForChannelRateLimit(channelId, tps);
}
private async refreshTaskProgress(batchTaskId: string) {
return this.submission.refreshTaskProgress(batchTaskId);
}
private smsMessageSegmentAuditDelegate() {
return this.completion.smsMessageSegmentAuditDelegate();
}
private async recordSubmitSegments(
message: {
id: string;
tenantId?: string | null;
batchTaskId?: string | null;
channelId?: string | null;
submitId?: string | null;
billingUnits?: number | null;
},
data: GatewaySubmitResultDto,
submittedAt: Date,
) {
return this.completion.recordSubmitSegments(message, data, submittedAt);
}
private async recordReceiptSegment(
message: {
id: string;
tenantId?: string | null;
batchTaskId?: string | null;
channelId?: string | null;
submitId?: string | null;
billingUnits?: number | null;
},
data: GatewayReceiptEventDto,
deliveredAt: Date,
submitRecordId?: string,
) {
return this.completion.recordReceiptSegment(message, data, deliveredAt, submitRecordId);
}
private async aggregateReceiptSegments(
message: {
id: string;
billingUnits?: number | null;
},
data: GatewayReceiptEventDto,
deliveredAt: Date,
submitRecordId?: string,
submitId?: string,
) {
return this.completion.aggregateReceiptSegments(message, data, deliveredAt, submitRecordId, submitId);
}
private async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
return this.completion.findMessageByGatewayEvent(messageId, gatewayMessageId);
}
private async requireMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
return this.completion.requireMessageByGatewayEvent(messageId, gatewayMessageId);
}
private async resolveReceiptMessage(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
) {
return this.completion.resolveReceiptMessage(data, incomingIdentity);
}
private getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
return this.submission.getSendQueue();
}
private getGatewayQueue(): Queue {
return this.submission.getGatewayQueue();
}
private getRedis() {
return this.submission.getRedis();
}
private async postGatewayControl(path: string, payload: unknown) {
return this.completion.postGatewayControl(path, payload);
}
private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
return this.submission.publishGatewaySubmitCommand(command, idempotencyKey);
}
}