fix: close receipt delivery workflows
This commit is contained in:
@@ -61,6 +61,27 @@ describe('GatewayEventsController protocol logging', () => {
|
||||
status: 'success',
|
||||
messageId: 'MSG-1',
|
||||
})).toEqual({ accepted: true });
|
||||
expect(controller.protocolLog({
|
||||
protocol: 'cmpp',
|
||||
direction: 'platform_to_client',
|
||||
eventType: 'deliver_receipt',
|
||||
status: 'success',
|
||||
messageId: 'MSG-1',
|
||||
})).toEqual({ accepted: true });
|
||||
expect(controller.protocolLog({
|
||||
protocol: 'cmpp',
|
||||
direction: 'platform_to_client',
|
||||
eventType: 'deliver_uplink',
|
||||
status: 'success',
|
||||
messageId: 'MSG-1',
|
||||
})).toEqual({ accepted: true });
|
||||
expect(controller.protocolLog({
|
||||
protocol: 'cmpp',
|
||||
direction: 'client_to_platform',
|
||||
eventType: 'deliver_resp',
|
||||
status: 'success',
|
||||
messageId: 'MSG-1',
|
||||
})).toEqual({ accepted: true });
|
||||
});
|
||||
|
||||
it('enriches an enterprise Submit packet with identifiers returned by the real service', async () => {
|
||||
|
||||
@@ -51,7 +51,10 @@ export class GatewayEventsController {
|
||||
&& body.eventType === 'submit_resp'
|
||||
) || (
|
||||
body.direction === 'platform_to_client'
|
||||
&& body.eventType === 'submit_resp'
|
||||
&& ['submit_resp', 'deliver_receipt', 'deliver_uplink'].includes(body.eventType)
|
||||
) || (
|
||||
body.direction === 'client_to_platform'
|
||||
&& body.eventType === 'deliver_resp'
|
||||
);
|
||||
if (body.protocol !== 'cmpp' || !allowedPacket || !['success', 'failed'].includes(body.status)) {
|
||||
throw new BadRequestException('Unsupported Gateway protocol log event');
|
||||
|
||||
@@ -322,7 +322,10 @@ function createPrismaMock() {
|
||||
return prisma;
|
||||
}
|
||||
|
||||
function createService(prisma = createPrismaMock()) {
|
||||
function createService(
|
||||
prisma = createPrismaMock(),
|
||||
openApi?: { queueWebhookEvent: jest.Mock },
|
||||
) {
|
||||
const billing = {
|
||||
estimateSmsCost: jest.fn().mockReturnValue({
|
||||
billingUnitsPerMessage: 1,
|
||||
@@ -347,7 +350,7 @@ function createService(prisma = createPrismaMock()) {
|
||||
reviewReason: '企业应用已配置模板不匹配进入人工审核',
|
||||
}),
|
||||
} as unknown as RiskReviewService;
|
||||
const service = new SendChainService(prisma as never, billing, riskReview);
|
||||
const service = new SendChainService(prisma as never, billing, riskReview, openApi as never);
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue(undefined);
|
||||
return { service, prisma, billing, riskReview };
|
||||
@@ -780,7 +783,6 @@ describe('SendChainService', () => {
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
|
||||
await expect(service.authenticateInboundApplication({
|
||||
account: '100001',
|
||||
password: 'secret-hash',
|
||||
@@ -788,7 +790,7 @@ describe('SendChainService', () => {
|
||||
})).rejects.toThrow('CMPP interface is disabled for this application');
|
||||
});
|
||||
|
||||
it('records and acknowledges Gateway submit with a failure receipt when the application interface was disabled after bind', async () => {
|
||||
it('does not create a CMPP downstream delivery when the application interface was disabled after bind', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
@@ -801,6 +803,15 @@ describe('SendChainService', () => {
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
interfaceEnabled: false,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
httpConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
@@ -813,9 +824,37 @@ describe('SendChainService', () => {
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ messageRecordId: 'record-1', receiptStatus: 'undelivered', errorCode: 'INTERFACE' }),
|
||||
});
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ messageRecordId: 'record-1', deliveryType: 'receipt', status: 'pending' }),
|
||||
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues an HTTP webhook but not CMPP delivery for an HTTP-only application', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ queued: true }) };
|
||||
const { service } = createService(prisma, openApi);
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
interfaceEnabled: false,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
httpConfig: { enabled: true },
|
||||
});
|
||||
|
||||
await service['queueAndTryDownstreamDelivery']({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageRecordId: 'record-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'receipt',
|
||||
payload: { receiptStatus: 'delivered' },
|
||||
});
|
||||
|
||||
expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
eventType: 'receipt',
|
||||
}));
|
||||
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('splits every destination in one inbound CMPP Submit into an independent real message record', async () => {
|
||||
@@ -2199,6 +2238,84 @@ describe('SendChainService', () => {
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks a long message failed when a non-primary segment returns an explicit failure', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
||||
id: 'record-long',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-LONG-FAIL',
|
||||
submitId: 'SUB-LONG-FAIL',
|
||||
phoneNumber: '18821203795',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-SEG-1',
|
||||
status: 'submitted',
|
||||
billingUnits: 2,
|
||||
amountCents: 6,
|
||||
unitPrice: 3,
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
|
||||
id: 'segment-2',
|
||||
messageRecordId: 'record-long',
|
||||
submitRecordId: 'submit-long',
|
||||
submitId: 'SUB-LONG-FAIL',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-SEG-2',
|
||||
segmentIndex: 2,
|
||||
segmentTotal: 2,
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findMany.mockResolvedValue([
|
||||
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null },
|
||||
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'YL:1014', deliveredAt: new Date() },
|
||||
]);
|
||||
prisma.smsBillingRecord.findFirst
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'billing-charged', billingStatus: 'charged' });
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
||||
id: 'route-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupId: 'group-1',
|
||||
carrier: 'mobile',
|
||||
group: {
|
||||
id: 'group-1',
|
||||
carrier: 'mobile',
|
||||
status: 'active',
|
||||
retryEnabled: false,
|
||||
retryTimeLimitHours: 72,
|
||||
retryTimeLimitMinutes: 4320,
|
||||
items: [],
|
||||
},
|
||||
});
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-LONG-FAIL',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-SEG-2',
|
||||
phoneNumber: '18821203795',
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'YL:1014',
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-long' },
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
receiptStatus: 'undelivered',
|
||||
receiptRawStatus: 'YL:1014',
|
||||
}),
|
||||
});
|
||||
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
messageRecordId: 'record-long',
|
||||
deliveryType: 'receipt',
|
||||
status: 'pending',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsReceiptRecord.findUnique
|
||||
|
||||
@@ -1868,7 +1868,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: data.applicationId },
|
||||
select: { cmppAccount: true, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, httpConfig: true },
|
||||
select: {
|
||||
cmppAccount: true,
|
||||
interfaceEnabled: true,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await this.openApi?.queueWebhookEvent({
|
||||
@@ -1883,10 +1889,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
} catch (error) {
|
||||
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
const deliveryMode = data.deliveryType === 'receipt'
|
||||
? application?.httpConfig?.receiptDeliveryMode ?? 'cmpp'
|
||||
: application?.httpConfig?.uplinkDeliveryMode ?? 'cmpp';
|
||||
if (!['cmpp', 'both'].includes(deliveryMode)) {
|
||||
if (application?.interfaceEnabled !== true) {
|
||||
return null;
|
||||
}
|
||||
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
|
||||
@@ -3833,6 +3836,24 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
|
||||
: null;
|
||||
if (exactMessage) {
|
||||
const segmentAudit = data.gatewayMessageId
|
||||
? await this.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,
|
||||
|
||||
Reference in New Issue
Block a user