feat: enforce signature-scoped drainage authorization before SMS submission

This commit is contained in:
hectorzhao
2026-09-10 13:29:04 +08:00
parent 5bcdbb2a03
commit 0c3f820cc9
35 changed files with 2769 additions and 791 deletions
+228 -71
View File
@@ -1,22 +1,63 @@
import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
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 { 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 { MetricsService } from '../metrics/metrics.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 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';
@@ -68,12 +109,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
},
metrics,
);
this.completion = new SendCompletionService(
prisma,
billing,
openApi,
this as unknown as SendCompletionFacade,
);
this.completion = new SendCompletionService(prisma, billing, openApi, this as unknown as SendCompletionFacade);
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
}
@@ -91,7 +127,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
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 = setTimeout(
() => void this.runReceiptTimeoutScan(),
RECEIPT_TIMEOUT_INITIAL_DELAY_MS,
);
this.receiptTimeoutInitialTimer.unref?.();
this.receiptTimeoutIntervalTimer = setInterval(
() => void this.runReceiptTimeoutScan(),
@@ -107,22 +146,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.scheduledDispatchInitialTimer.unref?.();
this.scheduledDispatchIntervalTimer = setInterval(
() => void this.runScheduledDispatchScan(),
positiveInteger(process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS),
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)}`);
}),
() =>
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)}`);
}),
() =>
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,
@@ -147,7 +191,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
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)}`)),
() =>
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?.();
@@ -191,12 +238,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
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 },
}) : [];
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),
@@ -232,11 +282,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
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,
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({
@@ -254,12 +309,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
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 },
}) : [];
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,
@@ -304,14 +362,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return { items, total, page: normalizedPage, pageSize: normalizedPageSize };
}
listMessages(query: {
tenantId?: string;
applicationId?: string;
channelId?: string;
taskId?: string;
phoneNumber?: string;
status?: string;
} = {}) {
listMessages(
query: {
tenantId?: string;
applicationId?: string;
channelId?: string;
taskId?: string;
phoneNumber?: string;
status?: string;
} = {},
) {
return this.prisma.smsMessageRecord.findMany({
where: {
tenantId: query.tenantId,
@@ -460,7 +520,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async handleReceipt(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.completion.handleReceipt(data, incomingIdentity);
}
@@ -530,7 +596,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.downstreamRequeueTasks.preview(filter, operatorId);
}
createDownstreamRequeueTask(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, operatorId?: string) {
createDownstreamRequeueTask(
data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
operatorId?: string,
) {
return this.downstreamRequeueTasks.create(data, operatorId);
}
@@ -542,7 +611,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.downstreamRequeueTasks.get(id);
}
listDownstreamRequeueTaskItems(id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number }) {
listDownstreamRequeueTaskItems(
id: string,
query: { status?: string; keyword?: string; page?: number; pageSize?: number },
) {
return this.downstreamRequeueTasks.listItems(id, query);
}
@@ -592,7 +664,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
requestedMessageIds?: string[],
workflowKey?: string,
) {
return this.submission.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId, requestedMessageIds, workflowKey);
return this.submission.submitCompleteInboundMessage(
data,
phoneNumbers,
application,
requestedGroupMessageId,
requestedMessageIds,
workflowKey,
);
}
private async collectInboundLongMessageFragment(
@@ -616,22 +695,33 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
receiptRejection?: { code: string; reason: string },
workflowItemKey?: string,
) {
return this.submission.submitInboundSingleMessage(data, messageId, submitGroupMessageId, application, synchronousRejection, receiptRejection, workflowItemKey);
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) {
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);
}
@@ -699,13 +789,29 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
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 },
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, signatureId?: string) {
private async findApplicationRoute(
tenantId: string,
applicationId: string | undefined,
carrier: string,
signatureId?: string,
) {
return this.submission.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
}
@@ -754,10 +860,40 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.resolveDrainageInfoMatch(signatureId, content);
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
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: {
drainageReceiptPending: true,
status: { in: ['failed', 'submit_failed'] },
batchTask: { sourceType: 'cmpp' },
},
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,
);
await this.recordCmppFailureReceipt(
message,
message.errorCode?.startsWith('DRN') ? message.errorCode : 'DRN',
reason,
);
}
}
private async recordCmppFailureReceipt(
message: {
id: string;
@@ -779,7 +915,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones);
}
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
private async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
options?: SendResourceValidationOptions,
) {
return this.submission.validateSendResources(tenantId, applicationId, templateId, options);
}
@@ -806,7 +947,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string,
) {
return this.completion.releaseMessageReservation(message, remark);
@@ -832,7 +979,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
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 }) {
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);
}
@@ -902,7 +1054,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private async resolveReceiptMessage(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.completion.resolveReceiptMessage(data, incomingIdentity);
}
@@ -926,5 +1084,4 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
return this.submission.publishGatewaySubmitCommand(command, idempotencyKey);
}
}