fix: 固化长短信回执终态并隔离发送尝试归属
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user