feat: integrate analytics and fragment receipt improvements

This commit is contained in:
hectorzhao
2026-08-03 15:34:41 +08:00
parent 3357ace7e1
commit 530a65de80
89 changed files with 1964 additions and 324 deletions
+63 -9
View File
@@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece
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';
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
/**
@@ -33,23 +34,76 @@ export class SendTimeoutService {
const candidates = await this.prisma.smsMessageRecord.findMany({
where: {
tenantId: { not: null },
status: { in: ['submitted', 'unknown'] },
submittedAt: { lte: cutoff },
OR: [
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: cutoff } },
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
],
},
select: {
id: true,
tenantId: true,
batchTaskId: true,
applicationId: true,
messageId: true,
phoneNumber: true,
amountCents: true,
billingUnits: true,
status: true,
cmppSubmitSequenceId: true,
cmppSubmitGroupMessageId: true,
cmppRegisteredDelivery: true,
timeoutAt: true,
},
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;
const timedOutAt = candidate.timeoutAt ?? new Date();
if (candidate.status !== 'timeout') {
const transitioned = await this.prisma.smsMessageRecord.updateMany({
where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } },
data: {
status: 'timeout',
receiptStatus: 'undelivered',
receiptRawStatus: 'EXPIRED',
errorCode: 'RECEIPT_TIMEOUT',
errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`,
timeoutAt: timedOutAt,
},
});
if (transitioned.count !== 1) continue;
timeout += 1;
}
// Refund uses the platform-message idempotency key. Re-running it for a
// timeout whose downstream outbox was not fully queued also recovers a
// crash between the state transition and the original refund call.
await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`);
const queued = await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message: candidate,
payload: {
messageId: candidate.messageId,
gatewayMessageId: `PLATFORM_TIMEOUT:${candidate.messageId}`,
phoneNumber: candidate.phoneNumber,
receiptStatus: 'undelivered',
rawStatus: 'EXPIRED',
errorCode: 'RECEIPT_TIMEOUT',
errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`,
deliveredAt: timedOutAt.toISOString(),
},
propagateHttpQueueError: true,
},
);
if (queued.queued) {
await this.prisma.smsMessageRecord.updateMany({
where: { id: candidate.id, status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: new Date() },
});
}
if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId);
}
for (const batchTaskId of timedOutTaskIds) {