Files
lislgosms/api/src/send-chain/send-chain.service.ts
T
2026-09-16 18:27:29 +08:00

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);
}
}