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,160 @@
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 scheduledDispatch implementation. Cross-method calls return through the stable SendChainService seam.
*/
export class SendScheduledDispatchService {
private readonly logger = new Logger('SendChainService');
private scheduledDispatchScanRunning = false;
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,
) {}
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 dispatchDueScheduledTasks(now = new Date()) {
const staleCutoff = new Date(now.getTime() - positiveInteger(
process.env.SMS_SCHEDULED_DISPATCH_STALE_MS,
DEFAULT_SCHEDULED_DISPATCH_STALE_MS,
));
const tasks = await this.prisma.smsBatchTask.findMany({
where: {
OR: [
{ status: 'scheduled', scheduledAt: { lte: now } },
{ status: { in: ['scheduled_dispatching', 'scheduled_recovering'] }, updatedAt: { lt: staleCutoff } },
],
},
orderBy: { scheduledAt: 'asc' },
});
const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = [];
for (const task of tasks) {
const candidateStatus = task.status || 'scheduled';
const claimedStatus = candidateStatus === 'scheduled_dispatching' ? 'scheduled_recovering' : 'scheduled_dispatching';
const claimed = await this.prisma.smsBatchTask.updateMany({
where: {
id: task.id,
status: candidateStatus,
...(candidateStatus === 'scheduled' ? {} : { updatedAt: { lt: staleCutoff } }),
},
data: { status: claimedStatus },
});
if (claimed.count !== 1) continue;
let reservationEstablished = false;
let dispatchPrepared = false;
try {
await this.facade.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined);
const messages = await this.prisma.smsMessageRecord.findMany({
where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } },
select: { id: true, amountCents: true, billingUnits: true },
take: 100000,
});
const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0);
const existingReservation = await this.prisma.accountTransaction.findFirst({
where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id },
select: { id: true },
});
reservationEstablished = Boolean(existingReservation);
if (!reservationEstablished) {
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
if (!accountCheck.canSend) {
throw new BadRequestException('定时任务到点时企业账户余额不足');
}
if (amountCents > 0) {
await this.billing.freeze({
tenantId: task.tenantId,
amountCents,
relatedType: 'sms_batch_task',
relatedId: task.id,
remark: '定时任务到点冻结',
});
reservationEstablished = true;
}
}
dispatchPrepared = true;
await this.prisma.smsMessageRecord.updateMany({
where: { batchTaskId: task.id, status: 'scheduled' },
data: { status: 'queued' },
});
const enqueued = await this.facade.enqueueBatchTask(task.id);
results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued });
} catch (error) {
const reason = error instanceof Error ? error.message : '定时任务到点执行失败';
if (reservationEstablished || dispatchPrepared) {
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: { status: claimedStatus, rejectReason: `调度将在超时后恢复:${reason}` },
});
results.push({ taskId: task.id, status: 'retrying', reason });
continue;
}
await this.prisma.smsMessageRecord.updateMany({
where: { batchTaskId: task.id, status: 'scheduled' },
data: { status: 'rejected', errorMessage: reason },
});
await this.prisma.smsBatchTask.update({
where: { id: task.id },
data: { status: 'failed', rejectReason: reason },
});
results.push({ taskId: task.id, status: 'failed', reason });
}
}
return { dispatched: results.filter((result) => result.status === 'queued').length, results };
}
async runScheduledDispatchScan() {
if (this.scheduledDispatchScanRunning) return;
this.scheduledDispatchScanRunning = true;
try {
await this.facade.dispatchDueScheduledTasks();
} catch (error) {
this.logger.error(`Scheduled SMS dispatch scan failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
this.scheduledDispatchScanRunning = false;
}
}
}