fix: 固化长短信回执终态并隔离发送尝试归属

This commit is contained in:
hectorzhao
2026-09-18 13:20:03 +08:00
parent 1676cfe622
commit c20c2246b2
11 changed files with 791 additions and 209 deletions
+46 -166
View File
@@ -1,3 +1,4 @@
import { resolveReceiptAttempt } from './receipt-attempt-resolver';
import { completionContext } from './completion-context';
import { Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -14,7 +15,6 @@ import {
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
aggregateReceiptSegmentState,
isSameUpstreamEndpointIdentity,
receiptEventKey,
longMessageReceiptMode,
} from './send-chain.helpers';
@@ -282,6 +282,34 @@ export class SendReceiptService {
throw error;
}
const logicalReceipt = { ...data, channelId: logicalChannelId };
// The message row is locked by AttemptCompletion. A committed final decision
// also commits accounting and notifications; later evidence cannot undo it.
const sameAttempt =
(!message.submitId || message.submitId === resolved.submitId) &&
(!message.channelId || message.channelId === logicalChannelId);
const frozen =
['failed', 'delivered'].includes(message.status) ||
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT');
if (sameAttempt && frozen) {
const contradicts =
message.status === 'delivered'
? !['delivered', 'unknown'].includes(data.receiptStatus)
: data.receiptStatus === 'delivered';
if (contradicts) {
if (!existingReceipt)
await this.recordReceiptConflict({
message,
submitRecordId: resolved.submitRecordId,
submitId: resolved.submitId,
receiptRecordId,
receiptKey,
data: logicalReceipt,
});
} else if (data.receiptStatus !== 'unknown') {
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
}
return message;
}
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
const receiptMode =
Number(message.billingUnits ?? 1) > 1 ? await this.getLongMessageReceiptMode(logicalChannelId) : 'per_segment';
@@ -455,7 +483,10 @@ export class SendReceiptService {
receiptKey: input.receiptKey,
gatewayMessageId: input.data.gatewayMessageId,
phoneNumber: input.data.phoneNumber,
reason: 'message_level_success_followed_by_failure',
reason:
input.message.status === 'delivered'
? 'message_level_success_followed_by_failure'
: 'final_failure_followed_by_success',
};
await this.prisma.smsReceiptAnomaly.upsert({
where: { anomalyKey },
@@ -479,7 +510,8 @@ export class SendReceiptService {
messageRecordId: input.message.id,
submitRecordId: input.submitRecordId,
receiptRecordId: input.receiptRecordId,
anomalyType: 'aggregate_success_then_failure',
anomalyType:
input.message.status === 'delivered' ? 'aggregate_success_then_failure' : 'final_failure_then_success',
previousStatus: input.message.status,
incomingStatus: input.data.receiptStatus,
rawStatus: input.data.rawStatus,
@@ -505,12 +537,21 @@ export class SendReceiptService {
submitRecordId?: string,
) {
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
const source = submitRecordId
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
: null;
if (submitRecordId && (!source || source.messageRecordId !== message.id || source.channelId !== data.channelId))
throw new Error('completion_receipt_source_mismatch');
if (!source) throw new NotFoundException('回执缺少可确认的提交尝试关联');
const updated = await segmentAudits.updateMany({
where: {
messageRecordId: message.id,
channelId: source.channelId,
OR: [{ submitRecordId: source.id }, { submitRecordId: null, submitId: source.submitId }],
gatewayMessageId: data.gatewayMessageId,
},
data: {
submitRecordId: source.id,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode ?? null,
@@ -520,12 +561,7 @@ export class SendReceiptService {
if (updated.count > 0) {
return;
}
const submitRecord = submitRecordId
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
: await this.prisma.smsSubmitRecord.findFirst({
where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
orderBy: { createdAt: 'desc' },
});
const submitRecord = source;
await segmentAudits.upsert({
where: {
messageRecordId_submitId_segmentIndex: {
@@ -600,162 +636,6 @@ export class SendReceiptService {
cmppVersion: string;
},
) {
const exactMessage = data.messageId
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
: null;
if (exactMessage) {
const segmentAudit = data.gatewayMessageId
? await this.facade.smsMessageSegmentAuditDelegate().findFirst({
where: {
messageRecordId: exactMessage.id,
gatewayMessageId: data.gatewayMessageId,
},
orderBy: { updatedAt: 'desc' },
})
: null;
if (segmentAudit) {
return {
message: exactMessage,
messageId: exactMessage.messageId,
submitRecordId: segmentAudit.submitRecordId ?? undefined,
submitId: segmentAudit.submitId,
channelId: segmentAudit.channelId ?? data.channelId,
};
}
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
where: {
messageRecordId: exactMessage.id,
channelId: data.channelId,
gatewayMessageId: data.gatewayMessageId,
},
orderBy: { createdAt: 'desc' },
});
return {
message: exactMessage,
messageId: exactMessage.messageId,
submitRecordId: submitRecord?.id,
submitId: submitRecord?.submitId,
channelId: submitRecord?.channelId ?? data.channelId,
};
}
const phoneNumber = data.phoneNumber?.trim();
const exactSubmits = await this.prisma.smsSubmitRecord.findMany({
where: {
channelId: data.channelId,
gatewayMessageId: data.gatewayMessageId,
...(phoneNumber ? { messageRecord: { phoneNumber } } : {}),
},
include: { messageRecord: true },
orderBy: { createdAt: 'desc' },
take: 2,
});
if (exactSubmits.length === 1 && exactSubmits[0]?.messageRecord) {
return {
message: exactSubmits[0].messageRecord,
messageId: exactSubmits[0].messageRecord.messageId,
submitRecordId: exactSubmits[0].id,
submitId: exactSubmits[0].submitId,
channelId: exactSubmits[0].channelId,
};
}
if (!phoneNumber) {
throw new NotFoundException('SMS message record not found');
}
const incomingChannel =
incomingIdentity ?? (await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }));
if (!incomingChannel) {
throw new NotFoundException('SMS message record not found');
}
const segmentMatches = await this.facade.smsMessageSegmentAuditDelegate().findMany({
where: {
gatewayMessageId: data.gatewayMessageId,
messageRecord: { phoneNumber },
},
include: { messageRecord: true, submitRecord: true, channel: true },
orderBy: { createdAt: 'desc' },
take: 10,
});
const exactSegmentMatches = segmentMatches.filter((candidate) => candidate.channelId === data.channelId);
if (exactSegmentMatches.length === 1 && exactSegmentMatches[0]?.messageRecord) {
return {
message: exactSegmentMatches[0].messageRecord,
messageId: exactSegmentMatches[0].messageRecord.messageId,
submitRecordId: exactSegmentMatches[0].submitRecordId ?? undefined,
submitId: exactSegmentMatches[0].submitRecord?.submitId ?? exactSegmentMatches[0].submitId,
channelId: exactSegmentMatches[0].channelId,
};
}
const sameSupplierSegments = segmentMatches.filter(
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
);
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
return {
message: sameSupplierSegments[0].messageRecord,
messageId: sameSupplierSegments[0].messageRecord.messageId,
submitRecordId: sameSupplierSegments[0].submitRecordId ?? undefined,
submitId: sameSupplierSegments[0].submitRecord?.submitId ?? sameSupplierSegments[0].submitId,
channelId: sameSupplierSegments[0].channelId,
};
}
const crossConnectionSubmits = await this.prisma.smsSubmitRecord.findMany({
where: {
gatewayMessageId: data.gatewayMessageId,
messageRecord: { phoneNumber },
},
include: { messageRecord: true, channel: true },
orderBy: { createdAt: 'desc' },
take: 10,
});
const sameSupplierSubmits = crossConnectionSubmits.filter(
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
);
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
return {
message: sameSupplierSubmits[0].messageRecord,
messageId: sameSupplierSubmits[0].messageRecord.messageId,
submitRecordId: sameSupplierSubmits[0].id,
submitId: sameSupplierSubmits[0].submitId,
channelId: sameSupplierSubmits[0].channelId,
};
}
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const submittedAfter = new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000);
const candidates = await this.prisma.smsSubmitRecord.findMany({
where: {
channelId: data.channelId,
gatewayMessageId: null,
submitStatus: 'timeout',
submittedAt: {
gte: submittedAfter,
lte: deliveredAt,
},
messageRecord: {
phoneNumber,
},
},
include: {
messageRecord: true,
},
orderBy: {
submittedAt: 'desc',
},
take: 10,
});
if (candidates.length !== 1 || !candidates[0]?.messageRecord) {
throw new NotFoundException('SMS message record not found');
}
return {
message: candidates[0].messageRecord,
messageId: candidates[0].messageRecord.messageId,
submitRecordId: candidates[0].id,
submitId: candidates[0].submitId,
channelId: candidates[0].channelId,
};
return resolveReceiptAttempt(this.prisma, data, incomingIdentity);
}
}