1202 lines
43 KiB
TypeScript
1202 lines
43 KiB
TypeScript
import { AttemptCompletion } from './attempt-completion';
|
|
import { completionContext, completionDatabase, CompletionRouteRequired } from './completion-context';
|
|
import {
|
|
BadRequestException,
|
|
forwardRef,
|
|
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 { BillingService } from '../billing/billing.service';
|
|
|
|
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 { MetricsService } from '../metrics/metrics.service';
|
|
import { OpenApiService } from '../open-api/open-api.service';
|
|
import type {
|
|
CreateBatchTaskDto,
|
|
CreateHttpBatchTaskDto,
|
|
GatewayInboundAuthDto,
|
|
GatewayInboundSubmitDto,
|
|
GatewaySubmitResultDto,
|
|
GatewaySubmitSegmentResultDto,
|
|
GatewayReceiptEventDto,
|
|
GatewayUplinkEventDto,
|
|
UplinkMatchCandidateInput,
|
|
GatewayPendingDeliveryQueryDto,
|
|
GatewayDownstreamSentDto,
|
|
GatewayDownstreamAcknowledgedDto,
|
|
GatewayDownstreamFailureType,
|
|
GatewaySubmitDeadLetterDto,
|
|
RequeueGatewaySubmitExceptionDto,
|
|
GatewayDownstreamRecoveryStatusDto,
|
|
TimeoutUnknownDto,
|
|
ImportPreviewDto,
|
|
ConfirmImportDto,
|
|
SendJob,
|
|
QueuePriority,
|
|
RoutedChannel,
|
|
} from './send-chain.contracts';
|
|
import {
|
|
DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS,
|
|
RECEIPT_TIMEOUT_INITIAL_DELAY_MS,
|
|
DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS,
|
|
SCHEDULED_DISPATCH_INITIAL_DELAY_MS,
|
|
DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
|
|
INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS,
|
|
DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS,
|
|
UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS,
|
|
downstreamPendingTimeoutHours,
|
|
positiveInteger,
|
|
} from './send-chain.helpers';
|
|
|
|
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
|
|
import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service';
|
|
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
|
|
import { SendDownstreamRequeueTaskService, type DownstreamRequeueFilter } from './send-downstream-requeue-task.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 downstreamRequeueTaskIntervalTimer?: ReturnType<typeof setInterval>;
|
|
private readonly submission: SendSubmissionService;
|
|
private readonly completion: SendCompletionService;
|
|
private readonly downstreamRequeueTasks: SendDownstreamRequeueTaskService;
|
|
private readonly attemptCompletion?: AttemptCompletion;
|
|
|
|
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,
|
|
@Optional() metrics?: MetricsService,
|
|
) {
|
|
const rootPrisma = prisma;
|
|
prisma = completionDatabase(prisma);
|
|
this.prisma = prisma;
|
|
// Structural unit-test doubles may omit the durable delegate; real Prisma always has it.
|
|
const coordinatedBilling = rootPrisma.smsAttemptCompletionWork ? new BillingService(prisma) : billing;
|
|
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),
|
|
},
|
|
metrics,
|
|
);
|
|
this.completion = new SendCompletionService(
|
|
prisma,
|
|
coordinatedBilling,
|
|
openApi,
|
|
this as unknown as SendCompletionFacade,
|
|
);
|
|
if (rootPrisma.smsAttemptCompletionWork) {
|
|
this.attemptCompletion = new AttemptCompletion(
|
|
rootPrisma,
|
|
async (kind, payload) => {
|
|
const event = payload as unknown as {
|
|
data: GatewayReceiptEventDto & GatewaySubmitResultDto & GatewaySubmitSegmentResultDto;
|
|
incomingIdentity?: Parameters<SendChainService['handleReceipt']>[1];
|
|
olderThanHours?: number;
|
|
errorCode?: string;
|
|
reason?: string;
|
|
};
|
|
if (kind === 'receipt') return this.completion.handleReceipt(event.data, event.incomingIdentity);
|
|
if (kind === 'submit') return this.completion.handleSubmitResult(event.data);
|
|
if (kind === 'segment') return this.completion.handleSubmitSegmentResult(event.data);
|
|
if (kind === 'timeout') return this.completion.markUnknownTimeout({ olderThanHours: event.olderThanHours });
|
|
const message = await prisma.smsMessageRecord.findUniqueOrThrow({
|
|
where: { id: completionContext.getStore()!.messageRecordId },
|
|
});
|
|
if (
|
|
message.status === 'delivered' ||
|
|
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT')
|
|
)
|
|
return message;
|
|
return this.completion.recordCmppFailureReceipt(message, event.errorCode!, event.reason!);
|
|
},
|
|
(route) => this.submission.waitForChannelRateLimit(route.channel.id, route.channel.rateLimitPerSecond),
|
|
);
|
|
}
|
|
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
|
|
}
|
|
|
|
onModuleInit() {
|
|
const processRole = process.env.CMPP_PROCESS_ROLE?.trim() || 'all';
|
|
if (['all', 'worker', 'callback'].includes(processRole)) this.attemptCompletion?.start();
|
|
if (processRole === 'api' || processRole === 'callback') return;
|
|
if (processRole === 'outbox') {
|
|
this.submission.startSubmitOutboxPublisher();
|
|
return;
|
|
}
|
|
if (process.env.API_ENABLE_SEND_WORKER === 'true') {
|
|
this.startWorker();
|
|
}
|
|
if (process.env.CMPP_INBOUND_WORKFLOW_WORKER_ENABLED === 'true') {
|
|
this.submission.startInboundWorkflowWorker();
|
|
}
|
|
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?.();
|
|
}
|
|
if (process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED !== 'false') {
|
|
this.downstreamRequeueTaskIntervalTimer = setInterval(
|
|
() =>
|
|
void this.downstreamRequeueTasks
|
|
.runScan()
|
|
.catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)),
|
|
positiveInteger(process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_INTERVAL_MS, 1_000),
|
|
);
|
|
this.downstreamRequeueTaskIntervalTimer.unref?.();
|
|
}
|
|
}
|
|
|
|
async onModuleDestroy() {
|
|
this.attemptCompletion?.stop();
|
|
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);
|
|
if (this.downstreamRequeueTaskIntervalTimer) clearInterval(this.downstreamRequeueTaskIntervalTimer);
|
|
await this.worker?.close();
|
|
await this.sendQueue?.close();
|
|
await this.gatewayQueue?.close();
|
|
this.redis?.disconnect();
|
|
await this.submission.onModuleDestroy();
|
|
}
|
|
|
|
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, preparedMessage?: { messageRecordId: string; queuePriority?: string | null }) {
|
|
return this.submission.enqueueBatchTask(taskId, preparedMessage);
|
|
}
|
|
|
|
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) {
|
|
if (this.attemptCompletion && !completionContext.getStore()) {
|
|
const message = await this.completion.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
|
const source = await this.completion.resolveSubmitRecordForGatewaySegmentResult(message.id, data);
|
|
return this.attemptCompletion.enqueue(message.id, source.id, 'segment', { data });
|
|
}
|
|
return this.completion.handleSubmitSegmentResult(data);
|
|
}
|
|
|
|
private async resolveSubmitRecordForGatewaySegmentResult(
|
|
messageRecordId: string,
|
|
data: GatewaySubmitSegmentResultDto,
|
|
) {
|
|
return this.completion.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
|
|
}
|
|
|
|
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
|
if (this.attemptCompletion && !completionContext.getStore()) {
|
|
const message = await this.completion.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
|
const source = await this.completion.resolveSubmitRecordForGatewayResult(message.id, data);
|
|
return this.attemptCompletion.enqueue(message.id, source.id, 'submit', { data }, data.eventId);
|
|
}
|
|
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;
|
|
},
|
|
) {
|
|
if (this.attemptCompletion && !completionContext.getStore()) {
|
|
const resolved = await this.completion.resolveReceiptMessage(data, incomingIdentity);
|
|
if (!resolved.submitRecordId) throw new NotFoundException('回执缺少可确认的提交尝试关联');
|
|
return this.attemptCompletion.enqueue(resolved.message.id, resolved.submitRecordId, 'receipt', {
|
|
data,
|
|
incomingIdentity,
|
|
});
|
|
}
|
|
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 resolveGatewaySubmitDeadLetter(id: string, operatorId?: string) {
|
|
return this.completion.resolveGatewaySubmitDeadLetter(id, operatorId);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
previewDownstreamRequeueTask(filter: DownstreamRequeueFilter, operatorId?: string) {
|
|
return this.downstreamRequeueTasks.preview(filter, operatorId);
|
|
}
|
|
|
|
createDownstreamRequeueTask(
|
|
data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
|
|
operatorId?: string,
|
|
) {
|
|
return this.downstreamRequeueTasks.create(data, operatorId);
|
|
}
|
|
|
|
listDownstreamRequeueTasks(query: { status?: string; page?: number; pageSize?: number }) {
|
|
return this.downstreamRequeueTasks.list(query);
|
|
}
|
|
|
|
getDownstreamRequeueTask(id: string) {
|
|
return this.downstreamRequeueTasks.get(id);
|
|
}
|
|
|
|
listDownstreamRequeueTaskItems(
|
|
id: string,
|
|
query: { status?: string; keyword?: string; page?: number; pageSize?: number },
|
|
) {
|
|
return this.downstreamRequeueTasks.listItems(id, query);
|
|
}
|
|
|
|
changeDownstreamRequeueTaskStatus(id: string, action: 'pause' | 'resume' | 'terminate', operatorId?: string) {
|
|
return this.downstreamRequeueTasks.changeStatus(id, action, operatorId);
|
|
}
|
|
|
|
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
|
|
return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
|
|
}
|
|
|
|
private async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
|
|
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,
|
|
requestedMessageIds?: string[],
|
|
workflowKey?: string,
|
|
) {
|
|
return this.submission.submitCompleteInboundMessage(
|
|
data,
|
|
phoneNumbers,
|
|
application,
|
|
requestedGroupMessageId,
|
|
requestedMessageIds,
|
|
workflowKey,
|
|
);
|
|
}
|
|
|
|
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,
|
|
application: NonNullable<Awaited<ReturnType<SendChainService['findInboundApplication']>>>,
|
|
synchronousRejection?: { code: string; reason: string },
|
|
receiptRejection?: { code: string; reason: string },
|
|
workflowItemKey?: string,
|
|
) {
|
|
return this.submission.submitInboundSingleMessage(
|
|
data,
|
|
messageId,
|
|
submitGroupMessageId,
|
|
application,
|
|
synchronousRejection,
|
|
receiptRejection,
|
|
workflowItemKey,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* CMPP 单号码提交必须在生成最终入队决定前原子占用号码频次。
|
|
* 频次规则是直接拒绝,因此优先级高于其他规则产生的待人工审核结果。
|
|
*/
|
|
private async evaluateRiskWithPhoneFrequency(
|
|
input: {
|
|
tenantId: string;
|
|
applicationId: string;
|
|
templateId?: string;
|
|
content: string;
|
|
variables?: Record<string, unknown>;
|
|
phoneNumber: string;
|
|
sourceType: 'cmpp';
|
|
},
|
|
reservationKey?: string,
|
|
) {
|
|
return this.submission.evaluateRiskWithPhoneFrequency(input, reservationKey);
|
|
}
|
|
|
|
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
|
if (this.attemptCompletion && !completionContext.getStore()) {
|
|
const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, 72);
|
|
const candidates = await this.prisma.smsMessageRecord.findMany({
|
|
where: {
|
|
tenantId: { not: null },
|
|
OR: [
|
|
{
|
|
status: { in: ['submitted', 'unknown'] },
|
|
submittedAt: { lte: new Date(Date.now() - olderThanHours * 3600_000) },
|
|
},
|
|
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
|
|
],
|
|
},
|
|
select: { id: true, submitId: true, status: true },
|
|
take: 100,
|
|
});
|
|
let timeout = 0;
|
|
for (const message of candidates) {
|
|
const source = message.submitId
|
|
? await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: message.submitId } })
|
|
: null;
|
|
const result = await this.attemptCompletion.enqueue(message.id, source?.id, 'timeout', { olderThanHours });
|
|
if (message.status !== 'timeout' && result?.status === 'timeout') timeout++;
|
|
}
|
|
return { timeout };
|
|
}
|
|
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> {
|
|
const context = completionContext.getStore();
|
|
if (context) {
|
|
const prepare = () => this.submission.selectChannelForMessage(message, { ...options, previewOnly: true });
|
|
if (!context.routePlanned) throw new CompletionRouteRequired(prepare);
|
|
const current = await this.submission.selectChannelForMessage(message, options);
|
|
if (!context.route || current.channel.id !== context.route.channel.id) throw new CompletionRouteRequired(prepare);
|
|
return current;
|
|
}
|
|
return this.submission.selectChannelForMessage(message, options);
|
|
}
|
|
|
|
private async findApplicationRoute(
|
|
tenantId: string,
|
|
applicationId: string | undefined,
|
|
carrier: string,
|
|
signatureId?: string,
|
|
) {
|
|
return this.submission.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async recoverDrainageFailureReceipts() {
|
|
const messages = await this.prisma.smsMessageRecord.findMany({
|
|
where: {
|
|
OR: [
|
|
{ drainageReceiptPending: true, batchTask: { sourceType: 'cmpp' } },
|
|
{ channelWordFinalizationPending: true },
|
|
],
|
|
status: { in: ['failed', 'submit_failed'] },
|
|
},
|
|
include: { batchTask: { select: { sourceType: true } } },
|
|
orderBy: { updatedAt: 'asc' },
|
|
take: 50,
|
|
});
|
|
for (const message of messages) {
|
|
if (!message.tenantId || !message.batchTaskId) continue;
|
|
const reason = message.errorMessage ?? '引流发送资格校验未通过';
|
|
await this.releaseMessageReservation(
|
|
{ ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId },
|
|
reason,
|
|
);
|
|
if (message.channelWordFinalizationPending && message.batchTask?.sourceType !== 'cmpp') {
|
|
await this.refreshTaskProgress(message.batchTaskId);
|
|
await this.prisma.smsMessageRecord.update({
|
|
where: { id: message.id },
|
|
data: { channelWordFinalizationPending: false },
|
|
});
|
|
continue;
|
|
}
|
|
await this.recordCmppFailureReceipt(
|
|
message,
|
|
message.channelWordFinalizationPending
|
|
? 'CSW'
|
|
: message.errorCode?.startsWith('DRN')
|
|
? message.errorCode
|
|
: 'DRN',
|
|
reason,
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
) {
|
|
if (this.attemptCompletion && !completionContext.getStore()) {
|
|
return this.attemptCompletion.enqueue(message.id, undefined, 'rejection', { errorCode, reason });
|
|
}
|
|
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,
|
|
options?: SendResourceValidationOptions,
|
|
) {
|
|
return this.submission.validateSendResources(tenantId, applicationId, templateId, options);
|
|
}
|
|
|
|
private async reserveDailySendQuota(applicationId: string, requestedCount: number) {
|
|
return this.submission.reserveDailySendQuota(applicationId, requestedCount);
|
|
}
|
|
|
|
private async tryReserveDailySendQuota(applicationId: string, requestedCount: number, reservationKey?: string) {
|
|
return this.submission.tryReserveDailySendQuota(applicationId, requestedCount, reservationKey);
|
|
}
|
|
|
|
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,
|
|
carrier: string,
|
|
) {
|
|
return this.submission.ensureSignatureReportedForChannel(message, channelId, carrier);
|
|
}
|
|
|
|
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) {
|
|
if (completionContext.getStore()) return;
|
|
return this.submission.waitForChannelRateLimit(channelId, tps);
|
|
}
|
|
|
|
private async refreshTaskProgress(batchTaskId: string, knownSingleMessageStatus?: string) {
|
|
return this.submission.refreshTaskProgress(batchTaskId, knownSingleMessageStatus);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|