879 lines
34 KiB
TypeScript
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);
|
|
}
|
|
|
|
}
|