fix: correlate downstream receipts with submit responses

This commit is contained in:
hectorzhao
2026-07-14 16:22:41 +08:00
parent 135b4fd24e
commit 1ce02ef206
12 changed files with 487 additions and 91 deletions
@@ -0,0 +1,11 @@
ALTER TABLE "SmsMessageRecord"
ADD COLUMN "cmppSubmitSequenceId" TEXT;
UPDATE "CmppDownstreamDelivery"
SET
"status" = 'unconfirmed',
"deliveredAt" = NULL,
"lastError" = 'CMPP_DELIVER_RESP Msg_Id=0,仅确认协议收包,未确认业务回执关联'
WHERE "deliveryType" = 'receipt'
AND "status" = 'delivered'
AND COALESCE("ackMessageId", '0') = '0';
+1
View File
@@ -983,6 +983,7 @@ model SmsMessageRecord {
channelId String?
submitId String?
gatewayMessageId String?
cmppSubmitSequenceId String?
status String @default("queued")
submitStatus String?
receiptStatus String?
+38 -1
View File
@@ -551,15 +551,20 @@ describe('SendChainService', () => {
account: '100001',
phoneNumber: '13800000001',
content: 'unreported content',
sequenceId: 1216579149,
remoteIp: '127.0.0.1',
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ cmppSubmitSequenceId: '1216579149' }),
});
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE' }),
});
expect(service['postGatewayControl']).toHaveBeenCalledWith(
'/downstream/receipt',
expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE' }),
expect.objectContaining({ receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode: 'TEMPLATE', submitSequenceId: 1216579149 }),
);
expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled();
});
@@ -1484,6 +1489,29 @@ describe('SendChainService', () => {
}));
});
it('does not treat Result=0 with Msg_Id=0 as a business acknowledgement', async () => {
const { service, prisma } = createService();
await service.acknowledgeDownstreamDelivery({
id: 'delivery-1',
connectionId: 'conn-1',
sequenceId: '91',
messageId: '0',
result: 0,
acknowledgedAt: '2026-07-14T07:07:49.336Z',
});
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ ackResult: 0, ackMessageId: '0' }),
}));
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'pending', lastError: expect.stringContaining('Msg_Id=0') }),
}));
expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'delivered' }),
}));
});
it('does not automatically retry an unacknowledged delivery when its policy snapshot is disabled', async () => {
const { service, prisma } = createService();
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
@@ -1564,6 +1592,15 @@ describe('SendChainService', () => {
resourceId: 'delivery-1',
}),
});
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
acknowledgedAt: null,
ackResult: null,
ackMessageId: null,
deliveredAt: null,
}),
}));
});
it('supports batch requeue of downstream deliveries', async () => {
+24 -4
View File
@@ -119,7 +119,7 @@ export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentD
acknowledgedAt?: string;
}
export type GatewayDownstreamFailureType = 'send_failed' | 'ack_timeout' | 'ack_rejected' | 'connection_lost';
export type GatewayDownstreamFailureType = 'send_failed' | 'ack_timeout' | 'ack_rejected' | 'ack_invalid' | 'connection_lost';
type GatewayControlDeliveryResult = {
sent?: boolean;
@@ -987,7 +987,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
if (data.result === 0) {
const acknowledgedMessageId = String(data.messageId ?? '').trim();
if (data.result === 0 && acknowledgedMessageId !== '' && acknowledgedMessageId !== '0') {
await this.prisma.cmppDownstreamDelivery.updateMany({
where: { id: data.id, status: { not: 'delivered' } },
data: {
@@ -1015,6 +1016,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
connectionId: data.connectionId,
},
});
if (data.result === 0) {
return this.markDownstreamDeliveryFailed(data.id, 'CMPP_DELIVER_RESP Msg_Id=0,客户端仅确认协议收包,无法关联原短信', 'ack_invalid');
}
return this.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected');
}
@@ -1027,7 +1031,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return delivery;
}
const retryCount = (delivery.retryCount ?? 0) + 1;
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'connection_lost';
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'ack_invalid' || failureType === 'connection_lost';
const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false;
const finalFailure = !retryAllowed || retryCount >= downstreamMaxRetries();
const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed';
@@ -1265,7 +1269,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
};
await this.prisma.cmppDownstreamDelivery.update({
where: { id: delivery.id },
data: { status: 'pending', retryCount: 0, nextRetryAt: null, ackDeadlineAt: null, lastError: null },
data: {
status: 'pending',
retryCount: 0,
nextRetryAt: null,
sentAt: null,
acknowledgedAt: null,
ackDeadlineAt: null,
ackResult: null,
ackSequenceId: null,
ackMessageId: null,
connectionId: null,
deliveredAt: null,
lastError: null,
},
});
try {
const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
@@ -1649,6 +1666,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
unitPrice: billing.unitPrice,
amountCents: billing.amountCents,
queuePriority,
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
status: 'validating',
},
});
@@ -2140,6 +2158,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
applicationId?: string | null;
messageId: string;
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
},
errorCode: string,
reason: string,
@@ -2181,6 +2200,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
rawStatus: 'REJECTD',
errorCode,
errorMessage: reason,
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
deliveredAt: deliveredAt.toISOString(),
},
});