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,109 @@
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 reviewContinuation implementation. Cross-method calls return through the stable SendChainService seam.
*/
export class SendReviewContinuationService {
private readonly logger = new Logger('SendChainService');
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 handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) {
const reviewTask = await this.prisma.smsSendTask.findUnique({
where: { id: reviewTaskId },
});
if (!reviewTask) {
return { reviewTaskId, decision, affected: 0 };
}
const messageRecords = await this.prisma.smsMessageRecord.findMany({
where: {
status: 'pending_review',
OR: [
{ reviewTaskId },
{ batchTask: { riskTaskId: reviewTaskId } },
],
},
include: { batchTask: true },
});
if (messageRecords.length === 0) {
return { reviewTaskId, decision, affected: 0 };
}
const batchTaskIds = new Set<string>();
for (const message of messageRecords) {
if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue;
if (decision === 'approved') {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'queued', errorCode: null, errorMessage: null },
});
await this.prisma.smsBatchTask.update({
where: { id: message.batchTaskId },
data: { status: 'ready', auditStatus: 'approved', reviewReason: reason, rejectReason: null },
});
batchTaskIds.add(message.batchTaskId);
} else {
await this.releaseMessageReservation(
message as typeof message & { tenantId: string; batchTaskId: string },
'模板不匹配人工审核驳回释放冻结',
);
await this.prisma.smsBatchTask.update({
where: { id: message.batchTaskId },
data: { status: 'rejected', auditStatus: 'rejected', rejectReason: reason },
});
await this.recordCmppFailureReceipt(message, 'REVIEW_REJECTED', reason);
}
}
for (const batchTaskId of batchTaskIds) {
await this.facade.enqueueBatchTask(batchTaskId);
}
return { reviewTaskId, decision, affected: messageRecords.length };
}
}