feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
@@ -0,0 +1,491 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { createHash, randomUUID } 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 type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
/**
* R9 gatewaySubmit implementation. Cross-method calls return through the stable SendChainService seam.
*/
export class SendGatewaySubmitService {
private readonly logger = new Logger('SendChainService');
private redis?: IORedis;
private sendQueue?: Queue<SendJob, unknown, 'send-message'>;
private gatewayQueue?: Queue;
private worker?: Worker<SendJob>;
constructor(
private readonly prisma: PrismaService,
private readonly billing: BillingService,
private readonly riskReview: RiskReviewService,
private readonly phoneFrequency: PhoneFrequencyService,
private readonly phoneRouting: PhoneRoutingLookupService,
private readonly facade: SendSubmissionService,
private readonly callbacks: SendSubmissionCallbacks,
) {}
async onModuleDestroy() {
await this.worker?.close();
await this.sendQueue?.close();
await this.gatewayQueue?.close();
this.redis?.disconnect();
}
private releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
remark: string,
) {
return this.callbacks.releaseMessageReservation(message, remark);
}
private 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.callbacks.recordCmppFailureReceipt(message, errorCode, reason);
}
async enqueueBatchTask(taskId: string) {
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
if (!task) {
throw new NotFoundException('SMS batch task not found');
}
if (task.status === 'canceled') {
throw new BadRequestException('SMS batch task is canceled');
}
const messages = await this.prisma.smsMessageRecord.findMany({
where: { batchTaskId: taskId, status: 'queued' },
select: { id: true, queuePriority: true },
take: 100000,
});
const queue = this.facade.getSendQueue();
for (const message of messages) {
const queuePriority = normalizeQueuePriority(message.queuePriority);
await queue.add('send-message', { messageRecordId: message.id }, {
jobId: message.id,
attempts: 3,
priority: BULLMQ_PRIORITY[queuePriority],
});
}
await this.prisma.smsBatchTask.update({ where: { id: taskId }, data: { status: 'queued' } });
return { taskId, enqueued: messages.length };
}
startWorker() {
if (this.worker) {
return { status: 'already_started' };
}
const connection = bullmqConnection();
this.worker = new Worker<SendJob>(
SEND_QUEUE,
async (job) => this.facade.processSendJob(job.data),
{ connection, concurrency: Number(process.env.API_SEND_WORKER_CONCURRENCY ?? 20) },
);
return { status: 'started' };
}
async processSendJob(job: SendJob) {
const message = await this.prisma.smsMessageRecord.findUnique({
where: { id: job.messageRecordId },
include: { batchTask: true, template: { include: { signature: true } }, signature: true },
});
if (!message || message.status !== 'queued') {
return { skipped: true };
}
if (!message.tenantId || !message.batchTaskId) {
return { skipped: true, reason: 'standalone channel test message' };
}
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
try {
const routed = await this.facade.selectChannelForMessage(businessMessage);
return await this.facade.submitMessageToGateway(businessMessage, routed, 0);
} catch (error) {
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'failed', errorMessage: reason },
});
await this.releaseMessageReservation(businessMessage, reason);
if (message.batchTask?.sourceType === 'cmpp') {
await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason);
} else {
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
}
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
}
}
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,
) {
const channel = routed.channel;
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
await this.facade.ensureSignatureReportedForChannel(message, channel.id);
await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
const submitId = `SUB-${randomUUID()}`;
try {
await this.prisma.$transaction(async (tx) => {
const session = await tx.cmppSubmitSession.upsert({
where: { sessionNo: `OPEN-${channel.id}` },
update: { submitTotal: { increment: 1 } },
create: { channelId: channel.id, sessionNo: `OPEN-${channel.id}`, submitTotal: 1 },
});
await tx.smsSubmitRecord.create({
data: {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
channelId: channel.id,
channelGroupId: routed.groupId,
channelGroupName: routed.groupName,
sessionId: session.id,
retryOfSubmitRecordId,
submitId,
submitStatus: 'queued',
costUnitPrice: channel.unitPrice ?? 0,
costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1),
},
});
await tx.smsMessageRecord.update({
where: { id: message.id },
data: {
channelId: channel.id,
carrier: routed.carrier,
province: routed.province,
submitId,
status: 'submit_queued',
submitStatus: 'queued',
receiptStatus: null,
errorCode: null,
errorMessage: attempt > 0 ? `${attempt + 1} 次提交,路由至${routed.routeScope === 'national' ? '全国' : '省网'}通道` : undefined,
},
});
});
if (retryOfSubmitRecordId) {
this.logger.log(`sms_retry_claim_acquired ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
retryOfSubmitRecordId,
submitId,
channelId: channel.id,
})}`);
}
} catch (error) {
if (
retryOfSubmitRecordId
&& error instanceof Prisma.PrismaClientKnownRequestError
&& error.code === 'P2002'
) {
const existingRetry = await this.prisma.smsSubmitRecord.findUnique({
where: { retryOfSubmitRecordId },
});
if (existingRetry) {
this.logger.warn(`sms_retry_claim_reused ${JSON.stringify({
messageId: message.messageId,
messageRecordId: message.id,
retryOfSubmitRecordId,
submitId: existingRetry.submitId,
channelId: existingRetry.channelId,
})}`);
return {
submitted: false,
duplicateRetry: true,
messageRecordId: message.id,
channelId: existingRetry.channelId,
attempt,
submitId: existingRetry.submitId,
};
}
}
throw error;
}
const command = {
schemaVersion: 'v1',
messageType: 'SubmitCommand',
traceId: randomUUID(),
messageId: message.messageId,
channelId: channel.id,
createdAt: new Date().toISOString(),
tenantId: message.tenantId,
applicationId: message.applicationId ?? 'unknown',
taskId: message.batchTaskId,
submitId,
queuePriority: normalizeQueuePriority(message.queuePriority),
phoneNumber: message.phoneNumber,
content: message.content,
signature: message.template?.signature?.name ?? message.signature?.name ?? 'SMS',
templateId: message.templateId ?? 'unknown',
billingUnits: message.billingUnits,
route: {
channelCode: channel.code,
cmppAccountCode: channel.account,
priority: attempt,
rateLimitPerSecond: channel.rateLimitPerSecond,
carrier: routed.carrier,
province: routed.province ?? undefined,
scope: routed.routeScope,
groupId: routed.groupId,
},
cmpp: {
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
? String(channel.config.serviceId)
: 'SMS',
srcId: upstreamSrcId,
extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0),
registeredDelivery: 1,
msgFmt: 8,
},
upstream: {
gatewayHost: channel.gatewayHost,
gatewayPort: channel.gatewayPort,
account: channel.account,
passwordCipher: channel.passwordCipher,
cmppVersion: channel.cmppVersion,
desiredConnections: getPositiveConfigInteger(channel.config, 'desiredConnections', 1),
windowSize: getPositiveConfigInteger(channel.config, 'windowSize', 16),
heartbeatIntervalSeconds: getPositiveConfigInteger(channel.config, 'heartbeatIntervalSeconds', 30),
heartbeatMissThreshold: getPositiveConfigInteger(channel.config, 'heartbeatMissThreshold', 3),
},
retry: { attempt, maxAttempts: 1 },
};
await this.facade.getGatewayQueue().add('submit-command', command);
await this.facade.publishGatewaySubmitCommand(command);
await this.facade.refreshTaskProgress(message.batchTaskId);
return { submitted: true, messageRecordId: message.id, channelId: channel.id, attempt };
}
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> {
if (!message.applicationId) {
throw new BadRequestException('短信应用未配置,无法选择通道组');
}
const hasPersistedRouting = Boolean(message.carrier);
const [carrier, province] = hasPersistedRouting
? [normalizeCarrier(message.carrier), message.province ?? null]
: await Promise.all([
this.facade.identifyCarrier(message.phoneNumber),
this.facade.identifyProvince(message.phoneNumber),
]);
if (!hasPersistedRouting) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { carrier, province },
});
}
const route = await this.facade.findApplicationRoute(message.tenantId, message.applicationId, carrier);
const excluded = new Set(options.excludeChannelIds ?? []);
const signatureId = await this.facade.resolveMessageSignatureId(message);
if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道');
const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({
where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } },
select: { channelId: true },
});
const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId));
const selected = selectChannelCandidate(route.group.items, {
carrier,
province,
forceNational: options.forceNational,
excludedChannelIds: excluded,
approvedChannelIds,
});
if (!selected) {
throw new NotFoundException('无已报备通过且在线的可用通道');
}
return {
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier,
province,
groupId: route.groupId,
groupName: route.group.name,
routeScope: isNationalChannel(selected) ? 'national' : 'province',
};
}
async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string) {
const route = await this.prisma.channelRouteRule.findFirst({
where: {
status: 'active',
tenantId,
applicationId,
carrier,
channelId: null,
province: null,
},
include: { group: { include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: { priority: 'asc' } } } } },
orderBy: { priority: 'asc' },
});
if (!route) {
throw new NotFoundException('企业应用未配置对应运营商通道组');
}
if (route.group.status !== 'active') {
throw new BadRequestException('企业应用绑定的通道组已停用');
}
if (normalizeCarrier(route.group.carrier) !== carrier) {
throw new BadRequestException('企业应用绑定的通道组运营商与路由规则不一致');
}
return route;
}
async identifyCarrier(phoneNumber: string) {
return normalizeCarrier(await this.phoneRouting.identifyCarrier(phoneNumber));
}
async identifyProvince(phoneNumber: string) {
return this.phoneRouting.identifyProvince(phoneNumber);
}
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,
) {
const signatureId = await this.facade.resolveMessageSignatureId(message);
if (!signatureId) {
throw new BadRequestException('短信签名未配置,不能提交到通道');
}
const reportTask = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId, channelId, reportType: 'signature', status: 'approved' },
select: { id: true },
});
if (!reportTask) {
throw new BadRequestException('短信签名未在最终通道报备通过');
}
}
async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
const direct = message.signatureId ?? message.template?.signature?.id ?? message.signature?.id ?? null;
if (direct || !message.templateId) return direct;
const template = await this.prisma.smsTemplate.findUnique({ where: { id: message.templateId }, include: { signature: true } });
return template?.signature?.id ?? null;
}
async waitForChannelRateLimit(channelId: string, tps: number) {
const redis = this.facade.getRedis();
for (;;) {
const bucket = `rate:channel:${channelId}:${Math.floor(Date.now() / 1000)}`;
const count = await redis.incr(bucket);
if (count === 1) {
await redis.expire(bucket, 2);
}
if (count <= Math.max(1, tps)) {
return;
}
await sleep(100);
}
}
async refreshTaskProgress(batchTaskId: string) {
const groups = await this.prisma.smsMessageRecord.groupBy({
by: ['status'],
where: { batchTaskId },
_count: { _all: true },
});
const count = (statuses: string[]) =>
groups.filter((group) => statuses.includes(group.status)).reduce((sum, group) => sum + group._count._all, 0);
const progressTotal = groups.reduce((sum, group) => sum + group._count._all, 0);
const submittedTotal = count(['submit_queued', 'submitted', 'delivered', 'failed', 'unknown', 'timeout']);
const successTotal = count(['delivered']);
const failedTotal = count(['submit_failed', 'failed']);
const unknownTotal = count(['unknown']);
const timeoutTotal = count(['timeout']);
const doneTotal = successTotal + failedTotal + timeoutTotal;
const status = progressTotal > 0 && doneTotal >= progressTotal ? 'finished' : submittedTotal > 0 ? 'sending' : 'queued';
await this.prisma.smsBatchTask.update({
where: { id: batchTaskId },
data: { progressTotal, submittedTotal, successTotal, failedTotal, unknownTotal, timeoutTotal, status },
});
}
getSendQueue(): Queue<SendJob, unknown, 'send-message'> {
if (!this.sendQueue) {
this.sendQueue = new Queue<SendJob, unknown, 'send-message'>(SEND_QUEUE, { connection: bullmqConnection() });
}
return this.sendQueue;
}
getGatewayQueue(): Queue {
if (!this.gatewayQueue) {
this.gatewayQueue = new Queue(GATEWAY_SUBMIT_QUEUE, { connection: bullmqConnection() });
}
return this.gatewayQueue;
}
getRedis() {
if (!this.redis) {
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
maxRetriesPerRequest: null,
});
}
return this.redis;
}
async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
const redis = this.facade.getRedis();
const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM;
const payload = JSON.stringify(command);
if (!idempotencyKey) {
return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload);
}
const result = await redis.eval(
`local existing = redis.call('GET', KEYS[2])
if existing then return existing end
local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1])
redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2])
return streamId`,
2,
stream,
idempotencyKey,
payload,
String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS),
);
return typeof result === 'string' ? result : String(result ?? '');
}
}