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
+104
View File
@@ -0,0 +1,104 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.service';
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, 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_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
/**
* R10 timeout implementation.
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
*/
export class SendTimeoutService {
private readonly logger = new Logger('SendChainService');
private receiptTimeoutScanRunning = false;
constructor(
private readonly prisma: PrismaService,
private readonly billing: BillingService,
private readonly openApi: OpenApiService | undefined,
private readonly facade: SendCompletionFacade,
private readonly callbacks: SendCompletionCallbacks,
) {}
async markUnknownTimeout(data: TimeoutUnknownDto) {
const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS);
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
const candidates = await this.prisma.smsMessageRecord.findMany({
where: {
tenantId: { not: null },
status: { in: ['submitted', 'unknown'] },
submittedAt: { lte: cutoff },
},
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
take: 10000,
});
const timedOutTaskIds = new Set<string>();
let timeout = 0;
for (const candidate of candidates) {
if (!candidate.tenantId) continue;
const transitioned = await this.prisma.smsMessageRecord.updateMany({
where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } },
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时` },
});
if (transitioned.count !== 1) continue;
timeout += 1;
await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`);
if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId);
}
for (const batchTaskId of timedOutTaskIds) {
await this.facade.refreshTaskProgress(batchTaskId);
}
return { timeout };
}
async markExpiredDownstreamDeliveries(olderThanHours = downstreamPendingTimeoutHours()) {
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60_000);
const expired = await this.prisma.cmppDownstreamDelivery.findMany({
where: {
status: 'pending',
OR: [
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
{ lastRetriedAt: { lte: cutoff } },
],
},
select: { id: true },
take: 500,
});
for (const delivery of expired) {
await this.facade.markDownstreamDeliveryFailed(
delivery.id,
`下游投递排队超过 ${olderThanHours} 小时,系统自动终止重试`,
'queue_timeout',
);
}
return { failed: expired.length };
}
async runReceiptTimeoutScan() {
if (this.receiptTimeoutScanRunning) return;
this.receiptTimeoutScanRunning = true;
try {
const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([
this.facade.markUnknownTimeout({}),
this.facade.markExpiredDownstreamDeliveries(),
this.facade.recoverStaleGatewaySubmitRequeues(),
this.facade.recoverStaleDownstreamManualRequeues(),
]);
if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`);
if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`);
if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`);
if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`);
} catch (error) {
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
} finally {
this.receiptTimeoutScanRunning = false;
}
}
}