166 lines
7.7 KiB
TypeScript
166 lines
7.7 KiB
TypeScript
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,
|
|
{ usePersistedTemplateSnapshot: true },
|
|
);
|
|
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;
|
|
}
|
|
}
|
|
}
|