release: prepare RealeseV2.3
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user