release: prepare RealeseV2.3

This commit is contained in:
hectorzhao
2026-08-06 10:48:36 +08:00
parent 57b58f1c40
commit 8ad8e61793
37 changed files with 997 additions and 64 deletions
+134 -3
View File
@@ -6,7 +6,7 @@ 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 { 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, longMessageReceiptMode } 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';
@@ -230,8 +230,9 @@ export class SendReceiptService {
},
});
}
let receiptRecordId: string | undefined;
try {
await this.prisma.smsReceiptRecord.create({
const createdReceipt = await this.prisma.smsReceiptRecord.create({
data: {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
@@ -249,6 +250,7 @@ export class SendReceiptService {
deliveredAt,
},
});
receiptRecordId = createdReceipt.id;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const duplicate = await this.prisma.smsReceiptRecord.findUnique({
@@ -261,6 +263,18 @@ export class SendReceiptService {
}
const logicalReceipt = { ...data, channelId: logicalChannelId };
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
const receiptMode = Number(message.billingUnits ?? 1) > 1
? await this.getLongMessageReceiptMode(logicalChannelId)
: 'per_segment';
if (receiptMode === 'message_level' && data.receiptStatus === 'delivered') {
await this.applyMessageLevelSuccess(
message,
logicalReceipt,
deliveredAt,
resolved.submitRecordId,
resolved.submitId,
);
}
const aggregate = await this.facade.aggregateReceiptSegments(
message,
logicalReceipt,
@@ -279,7 +293,22 @@ export class SendReceiptService {
|| message.gatewayMessageId === data.gatewayMessageId
|| (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))
);
if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) {
if (!isCurrentAttempt) {
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
if (status === 'failed' && message.status === 'delivered') {
if (receiptMode === 'message_level') {
// A delivered result may already have been exposed to the customer and settled.
// Preserve that terminal decision; the contradictory late receipt is evidence for operations, not a second state transition.
await this.recordReceiptConflict({
message,
submitRecordId: resolved.submitRecordId,
submitId: resolved.submitId,
receiptRecordId,
receiptKey,
data: logicalReceipt,
});
}
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
@@ -343,6 +372,108 @@ export class SendReceiptService {
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
private async getLongMessageReceiptMode(channelId?: string | null) {
if (!channelId) return 'per_segment' as const;
const channel = await this.prisma.smsChannel.findUnique({
where: { id: channelId },
select: { config: true },
});
return longMessageReceiptMode(channel?.config);
}
private async applyMessageLevelSuccess(
message: { id: string; channelId?: string | null; submitId?: string | null },
data: GatewayReceiptEventDto,
deliveredAt: Date,
submitRecordId?: string,
submitId?: string,
) {
const belongsToCurrentAttempt = (!message.channelId || message.channelId === data.channelId)
&& (!message.submitId || message.submitId === submitId);
if (!belongsToCurrentAttempt) return;
const attemptWhere = submitRecordId
? { messageRecordId: message.id, submitRecordId }
: submitId
? { messageRecordId: message.id, submitId }
: null;
if (!attemptWhere) return;
const segments = await this.prisma.smsMessageSegmentAudit.findMany({
where: attemptWhere,
select: { id: true, receiptStatus: true },
});
if (segments.length <= 1) return;
if (segments.some((segment) => segment.receiptStatus && !['delivered', 'unknown'].includes(segment.receiptStatus))) {
return;
}
// This supplier contract reports one message-level success for a multipart SMS.
// Mark only missing segments as inferred so the raw receipt remains singular and auditable.
await this.prisma.smsMessageSegmentAudit.updateMany({
where: { ...attemptWhere, receiptStatus: null },
data: {
receiptStatus: 'delivered',
rawStatus: data.rawStatus,
errorCode: data.errorCode ?? null,
errorMessage: data.errorMessage ?? null,
compensationType: 'supplier_message_level_receipt',
deliveredAt,
},
});
}
private async recordReceiptConflict(input: {
message: { id: string; tenantId?: string | null; applicationId?: string | null; status: string; messageId: string };
submitRecordId?: string;
submitId?: string;
receiptRecordId?: string;
receiptKey: string;
data: GatewayReceiptEventDto;
}) {
// One logical conflict per message attempt keeps repeated supplier packets auditable
// without creating an unbounded queue of operationally identical anomalies.
const anomalyKey = `aggregate-receipt-conflict:${input.message.id}:${input.submitId ?? input.submitRecordId ?? 'unknown'}`;
const occurredAt = new Date();
const detail = {
messageId: input.message.messageId,
submitId: input.submitId,
receiptKey: input.receiptKey,
gatewayMessageId: input.data.gatewayMessageId,
phoneNumber: input.data.phoneNumber,
reason: 'message_level_success_followed_by_failure',
};
await this.prisma.smsReceiptAnomaly.upsert({
where: { anomalyKey },
update: {
status: 'pending',
receiptRecordId: input.receiptRecordId,
incomingStatus: input.data.receiptStatus,
rawStatus: input.data.rawStatus,
errorCode: input.data.errorCode ?? null,
detail,
occurrenceCount: { increment: 1 },
lastOccurredAt: occurredAt,
resolvedAt: null,
resolutionNote: null,
},
create: {
anomalyKey,
tenantId: input.message.tenantId,
applicationId: input.message.applicationId,
channelId: input.data.channelId,
messageRecordId: input.message.id,
submitRecordId: input.submitRecordId,
receiptRecordId: input.receiptRecordId,
anomalyType: 'aggregate_success_then_failure',
previousStatus: input.message.status,
incomingStatus: input.data.receiptStatus,
rawStatus: input.data.rawStatus,
errorCode: input.data.errorCode ?? null,
detail,
firstOccurredAt: occurredAt,
lastOccurredAt: occurredAt,
},
});
}
async recordReceiptSegment(
message: {
id: string;