fix: harden channel retry attribution and operations UI

This commit is contained in:
hectorzhao
2026-07-26 21:33:58 +08:00
parent 059b38e8fe
commit 0857de09d8
27 changed files with 888 additions and 117 deletions
+87 -2
View File
@@ -1863,7 +1863,7 @@ describe('SendChainService', () => {
});
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
where: { submitId: 'SUB-1' },
where: { id: 'submit-1' },
data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }),
});
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
@@ -1937,6 +1937,69 @@ describe('SendChainService', () => {
});
});
it('rejects a legacy aggregate SubmitResult when it cannot match one submit attempt uniquely', async () => {
const { service, prisma } = createService();
prisma.smsSubmitRecord.findMany.mockResolvedValue([
{ id: 'submit-1', submitId: 'SUB-1', channelId: 'channel-1', gatewayMessageId: null },
{ id: 'submit-2', submitId: 'SUB-2', channelId: 'channel-1', gatewayMessageId: null },
]);
await expect(service.handleSubmitResult({
messageId: 'MSG-1',
channelId: 'channel-1',
gatewayMessageId: 'GW-LEGACY',
submitStatus: 'accepted',
})).rejects.toThrow('cannot be matched uniquely');
expect(prisma.smsSubmitRecord.updateMany).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.updateMany).not.toHaveBeenCalled();
});
it('retries a direct-signature CMPP message through the next approved channel', async () => {
const { service, prisma } = createService();
const route = await prisma.channelRouteRule.findFirst();
const primary = route.group.items[0].channel;
const backup = { ...primary, id: 'channel-backup', code: 'CMPP-B' };
prisma.channelRouteRule.findFirst.mockResolvedValue({
...route,
group: {
...route.group,
items: [
{ ...route.group.items[0], channelId: primary.id, priority: 1, channel: primary },
{ ...route.group.items[0], id: 'item-2', channelId: backup.id, priority: 2, channel: backup },
],
},
});
prisma.smsSubmitRecord.findMany.mockResolvedValue([
{ id: 'submit-1', submitId: 'SUB-1', channelId: primary.id, createdAt: new Date() },
]);
const submitMessageToGateway = jest.spyOn(service as any, 'submitMessageToGateway')
.mockResolvedValue({ submitted: true, messageRecordId: 'record-1', channelId: backup.id, attempt: 1 });
await expect((service as any).retryMessageIfAllowed({
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
templateId: null,
signatureId: 'sig-direct',
messageId: 'MSG-DIRECT-SIGNATURE',
phoneNumber: '13800000001',
content: '【签名】无模板内容',
billingUnits: 1,
queuedAt: new Date(),
}, '回执失败补发')).resolves.toEqual(expect.objectContaining({ channelId: backup.id }));
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ signatureId: 'sig-direct' }),
}));
expect(submitMessageToGateway).toHaveBeenCalledWith(
expect.objectContaining({ signatureId: 'sig-direct' }),
expect.objectContaining({ channel: expect.objectContaining({ id: backup.id }) }),
1,
);
});
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
const { service, prisma, billing } = createService();
prisma.smsBillingRecord.findFirst
@@ -1955,6 +2018,7 @@ describe('SendChainService', () => {
await service.handleSubmitResult({
messageId: 'MSG-1',
channelId: 'channel-1',
submitId: 'SUB-1',
gatewayMessageId: 'GW-1',
submitStatus: 'rejected',
});
@@ -2858,11 +2922,32 @@ describe('SendChainService', () => {
}),
}));
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: { submitId: 'SUB-1', gatewayMessageId: null },
where: { id: 'submit-1', gatewayMessageId: null },
data: expect.objectContaining({ sequenceId: 71, gatewayMessageId: 'GW-SEG-1' }),
}));
});
it('rejects a legacy segment result when multiple channel attempts could match', async () => {
const { service, prisma } = createService();
prisma.smsSubmitRecord.findMany.mockResolvedValue([
{ id: 'submit-2', submitId: 'SUB-2', messageRecordId: 'record-1', channelId: 'channel-1' },
{ id: 'submit-1', submitId: 'SUB-1', messageRecordId: 'record-1', channelId: 'channel-1' },
]);
await expect(service.handleSubmitSegmentResult({
messageId: 'MSG-1',
channelId: 'channel-1',
segmentTotal: 2,
segmentIndex: 1,
sequenceId: 72,
gatewayMessageId: 'GW-SEG-2',
submitStatus: 'accepted',
})).rejects.toThrow('cannot be matched uniquely');
expect(prisma.smsMessageSegmentAudit.upsert).not.toHaveBeenCalled();
expect(prisma.smsSubmitRecord.updateMany).not.toHaveBeenCalled();
});
it('durably intakes an upstream receipt before asynchronous business matching', async () => {
const { service, prisma } = createService();
jest.spyOn(service as any, 'processUpstreamReceiptInboxRecord').mockResolvedValue(false);