fix: correlate downstream receipts with submit responses
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user