fix: reconcile shared-channel receipts and protocol logs

This commit is contained in:
hectorzhao
2026-07-24 12:52:06 +08:00
parent 3997349c21
commit ca12f14b00
14 changed files with 1110 additions and 43 deletions
+206 -21
View File
@@ -982,7 +982,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
async handleReceipt(data: GatewayReceiptEventDto) {
const receiptKey = this.receiptEventKey(data);
const resolved = await this.resolveReceiptMessage(data);
const logicalChannelId = resolved.channelId ?? data.channelId;
const receiptKey = this.receiptEventKey(data, logicalChannelId);
const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({
where: { receiptKey },
include: { messageRecord: true },
@@ -990,11 +992,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (existingReceipt?.messageRecord) {
return existingReceipt.messageRecord;
}
const resolved = await this.resolveReceiptMessage(data);
const message = resolved.message;
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const status =
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
if (resolved.submitRecordId) {
await this.prisma.smsSubmitRecord.updateMany({
where: {
@@ -1014,7 +1013,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
receiptKey,
channelId: data.channelId,
channelId: logicalChannelId,
messageId: resolved.messageId,
gatewayMessageId: data.gatewayMessageId,
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
@@ -1036,10 +1035,26 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
throw error;
}
await this.recordReceiptSegment(message, data, deliveredAt, resolved.submitRecordId);
const logicalReceipt = { ...data, channelId: logicalChannelId };
await this.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
const aggregate = await this.aggregateReceiptSegments(
message,
logicalReceipt,
deliveredAt,
resolved.submitRecordId,
resolved.submitId,
);
if (!aggregate.terminal) {
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
const status = aggregate.status;
const isCurrentAttempt =
(!message.channelId || message.channelId === data.channelId)
&& (!message.gatewayMessageId || message.gatewayMessageId === data.gatewayMessageId);
(!message.channelId || message.channelId === logicalChannelId)
&& (
!message.gatewayMessageId
|| message.gatewayMessageId === data.gatewayMessageId
|| (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))
);
if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) {
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
@@ -1056,14 +1071,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: {
channelId: data.channelId,
gatewayMessageId: data.gatewayMessageId,
receiptStatus: data.receiptStatus,
receiptRawStatus: data.rawStatus,
channelId: logicalChannelId,
gatewayMessageId: message.gatewayMessageId ?? data.gatewayMessageId,
receiptStatus: aggregate.receiptStatus,
receiptRawStatus: aggregate.rawStatus,
status,
errorCode: data.errorCode,
errorMessage: data.errorMessage ?? (status === 'delivered' ? null : data.rawStatus),
deliveredAt,
errorCode: aggregate.errorCode,
errorMessage: aggregate.errorMessage ?? (status === 'delivered' ? null : aggregate.rawStatus),
deliveredAt: aggregate.deliveredAt,
},
});
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
@@ -1077,12 +1092,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
messageId: message.messageId,
gatewayMessageId: data.gatewayMessageId,
phoneNumber: message.phoneNumber,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode,
receiptStatus: aggregate.receiptStatus,
rawStatus: aggregate.rawStatus,
errorCode: aggregate.errorCode,
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
deliveredAt: deliveredAt.toISOString(),
deliveredAt: aggregate.deliveredAt.toISOString(),
},
});
}
@@ -2071,6 +2086,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (!collection.complete) {
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
messageId: collection.messageId,
status: 'fragment_pending',
fragmentPending: true,
@@ -3522,6 +3539,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
upsert: (args: Record<string, unknown>) => Promise<any>;
updateMany: (args: Record<string, unknown>) => Promise<{ count: number }>;
findFirst: (args: Record<string, unknown>) => Promise<any | null>;
findMany: (args: Record<string, unknown>) => Promise<any[]>;
};
}).smsMessageSegmentAudit;
}
@@ -3690,6 +3708,101 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
});
}
private async aggregateReceiptSegments(
message: {
id: string;
billingUnits?: number | null;
},
data: GatewayReceiptEventDto,
deliveredAt: Date,
submitRecordId?: string,
submitId?: string,
) {
const audits = await this.smsMessageSegmentAuditDelegate().findMany({
where: submitRecordId
? { messageRecordId: message.id, submitRecordId }
: submitId
? { messageRecordId: message.id, submitId }
: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
orderBy: { segmentIndex: 'asc' },
});
if (audits.length === 0) {
const status = data.receiptStatus === 'delivered'
? 'delivered'
: data.receiptStatus === 'unknown'
? 'unknown'
: 'failed';
return {
terminal: true,
segmentTotal: 1,
status,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode,
errorMessage: data.errorMessage,
deliveredAt,
};
}
const segmentTotal = Math.max(
1,
Number(message.billingUnits ?? 1),
...audits.map((audit) => Number(audit.segmentTotal ?? 1)),
);
const received = audits.filter((audit) => Boolean(audit.receiptStatus));
const failed = received.find((audit) => !['delivered', 'unknown'].includes(audit.receiptStatus ?? ''));
if (failed) {
return {
terminal: true,
segmentTotal,
status: 'failed',
receiptStatus: failed.receiptStatus ?? 'undelivered',
rawStatus: failed.rawStatus ?? data.rawStatus,
errorCode: failed.errorCode ?? data.errorCode,
errorMessage: failed.errorMessage ?? data.errorMessage,
deliveredAt: failed.deliveredAt ?? deliveredAt,
};
}
const delivered = received.filter((audit) => audit.receiptStatus === 'delivered');
if (delivered.length >= segmentTotal) {
const latest = delivered.reduce((current, audit) =>
(audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current);
return {
terminal: true,
segmentTotal,
status: 'delivered',
receiptStatus: 'delivered',
rawStatus: latest.rawStatus ?? data.rawStatus,
errorCode: latest.errorCode ?? undefined,
errorMessage: undefined,
deliveredAt: latest.deliveredAt ?? deliveredAt,
};
}
if (received.length >= segmentTotal) {
const latest = received[received.length - 1];
return {
terminal: true,
segmentTotal,
status: 'unknown',
receiptStatus: 'unknown',
rawStatus: latest.rawStatus ?? data.rawStatus,
errorCode: latest.errorCode ?? data.errorCode,
errorMessage: latest.errorMessage ?? data.errorMessage,
deliveredAt: latest.deliveredAt ?? deliveredAt,
};
}
return {
terminal: false,
segmentTotal,
status: 'submitted',
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode,
errorMessage: data.errorMessage,
deliveredAt,
};
}
private async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(
Boolean,
@@ -3732,6 +3845,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
message: exactMessage,
messageId: exactMessage.messageId,
submitRecordId: submitRecord?.id,
submitId: submitRecord?.submitId,
channelId: submitRecord?.channelId ?? data.channelId,
};
}
@@ -3751,6 +3866,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
message: exactSubmits[0].messageRecord,
messageId: exactSubmits[0].messageRecord.messageId,
submitRecordId: exactSubmits[0].id,
submitId: exactSubmits[0].submitId,
channelId: exactSubmits[0].channelId,
};
}
@@ -3758,6 +3875,61 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
throw new NotFoundException('SMS message record not found');
}
const incomingChannel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
if (!incomingChannel) {
throw new NotFoundException('SMS message record not found');
}
const segmentMatches = await this.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 && this.isSameSupplierConnection(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 && this.isSameSupplierConnection(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({
@@ -3790,12 +3962,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
message: candidates[0].messageRecord,
messageId: candidates[0].messageRecord.messageId,
submitRecordId: candidates[0].id,
submitId: candidates[0].submitId,
channelId: candidates[0].channelId,
};
}
private receiptEventKey(data: GatewayReceiptEventDto) {
private isSameSupplierConnection(
left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
) {
return left.account.trim() === right.account.trim()
&& left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase()
&& left.gatewayPort === right.gatewayPort
&& left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase()
&& left.cmppVersion.trim() === right.cmppVersion.trim();
}
private receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) {
return createHash('sha256').update([
data.channelId,
channelId,
data.gatewayMessageId,
data.phoneNumber?.trim() ?? '',
data.receiptStatus,