fix: make SMS retry side effects idempotent

This commit is contained in:
hectorzhao
2026-07-26 22:31:30 +08:00
parent 04f78695ed
commit e0f6eed0d4
11 changed files with 516 additions and 105 deletions
+130 -3
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { BillingService } from '../billing/billing.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { SendChainService } from './send-chain.service';
@@ -153,7 +154,11 @@ function createPrismaMock() {
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findFirst: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted', createdAt: new Date('2026-07-01T10:00:00.000Z') }),
findUnique: jest.fn().mockResolvedValue({ id: 'submit-1', submitId: 'SUB-1', submitStatus: 'accepted' }),
findUnique: jest.fn().mockImplementation(({ where }) => Promise.resolve(
where.retryOfSubmitRecordId
? null
: { id: 'submit-1', messageRecordId: 'record-1', channelId: 'channel-1', submitId: 'SUB-1', submitStatus: 'accepted' },
)),
count: jest.fn().mockResolvedValue(1),
findMany: jest.fn().mockResolvedValue([]),
},
@@ -1971,7 +1976,7 @@ describe('SendChainService', () => {
},
});
prisma.smsSubmitRecord.findMany.mockResolvedValue([
{ id: 'submit-1', submitId: 'SUB-1', channelId: primary.id, createdAt: new Date() },
{ id: 'submit-1', submitId: 'SUB-1', messageRecordId: 'record-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 });
@@ -1997,9 +2002,67 @@ describe('SendChainService', () => {
expect.objectContaining({ signatureId: 'sig-direct' }),
expect.objectContaining({ channel: expect.objectContaining({ id: backup.id }) }),
1,
'submit-1',
);
});
it('allows only one retry submit when three long-message failure receipts race', async () => {
const { service, prisma } = createService();
const channel = await prisma.smsChannel.findUnique();
const queueAdd = jest.fn().mockResolvedValue(undefined);
jest.spyOn(service as any, 'getGatewayQueue').mockReturnValue({ add: queueAdd });
jest.spyOn(service as any, 'waitForChannelRateLimit').mockResolvedValue(undefined);
let claimedRetry: Record<string, unknown> | null = null;
prisma.smsSubmitRecord.create.mockImplementation(async ({ data }) => {
if (data.retryOfSubmitRecordId) {
if (claimedRetry) {
throw new Prisma.PrismaClientKnownRequestError('duplicate retry claim', {
code: 'P2002',
clientVersion: '7.9.0',
meta: { target: ['retryOfSubmitRecordId'] },
});
}
claimedRetry = { id: 'retry-submit-1', ...data };
return claimedRetry;
}
return { id: 'submit-1', ...data };
});
prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) => Promise.resolve(
where.retryOfSubmitRecordId ? claimedRetry : null,
));
const message = {
id: 'record-long-race',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
templateId: 'tpl-1',
messageId: 'MSG-LONG-RACE',
phoneNumber: '13800000001',
content: '长短信'.repeat(136),
billingUnits: 3,
template: { signature: { id: 'sig-1', name: '签名' } },
};
const routed = {
channel,
groupId: 'group-1',
carrier: 'mobile',
province: '山东',
routeScope: 'national',
};
const results = await Promise.all([
(service as any).submitMessageToGateway(message, routed, 1, 'source-submit-1'),
(service as any).submitMessageToGateway(message, routed, 1, 'source-submit-1'),
(service as any).submitMessageToGateway(message, routed, 1, 'source-submit-1'),
]);
expect(results.filter((result) => result.submitted)).toHaveLength(1);
expect(results.filter((result) => result.duplicateRetry)).toHaveLength(2);
expect(queueAdd).toHaveBeenCalledTimes(1);
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledTimes(1);
expect(prisma.smsMessageRecord.update).toHaveBeenCalledTimes(1);
});
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
const { service, prisma, billing } = createService();
prisma.smsBillingRecord.findFirst
@@ -2031,7 +2094,10 @@ describe('SendChainService', () => {
receiptStatus: 'undelivered',
rawStatus: 'UNDELIV',
});
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({
idempotencyKey: 'sms-refund:MSG-1',
remark: '最终失败退款',
}));
});
it('stops failed receipt retry after the configured minute limit', async () => {
@@ -2403,6 +2469,56 @@ describe('SendChainService', () => {
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
});
it('creates and sends only one downstream final receipt under concurrent completion', async () => {
const { service, prisma } = createService();
let claimedDelivery: Record<string, unknown> | null = null;
prisma.cmppDownstreamDelivery.create.mockImplementation(async ({ data }) => {
if (claimedDelivery) {
throw new Prisma.PrismaClientKnownRequestError('duplicate downstream receipt', {
code: 'P2002',
clientVersion: '7.9.0',
meta: { target: ['dedupeKey'] },
});
}
claimedDelivery = {
id: 'delivery-once',
...data,
createdAt: new Date(),
updatedAt: new Date(),
};
return claimedDelivery;
});
prisma.cmppDownstreamDelivery.findUnique.mockImplementation(({ where }) => Promise.resolve(
where.dedupeKey || where.id === 'delivery-once' ? claimedDelivery : null,
));
const payload = {
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-long-race',
messageId: 'MSG-LONG-RACE',
deliveryType: 'receipt' as const,
payload: {
messageId: 'MSG-LONG-RACE',
receiptStatus: 'undelivered',
rawStatus: 'FLNIGLK',
},
};
const results = await Promise.all([
(service as any).queueAndTryDownstreamDelivery(payload),
(service as any).queueAndTryDownstreamDelivery(payload),
(service as any).queueAndTryDownstreamDelivery(payload),
]);
expect(results.map((result) => result.id)).toEqual([
'delivery-once',
'delivery-once',
'delivery-once',
]);
expect(service['postGatewayControl']).toHaveBeenCalledTimes(1);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(3);
});
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({
@@ -2434,6 +2550,17 @@ describe('SendChainService', () => {
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null },
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'YL:1014', deliveredAt: new Date() },
]);
prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) => Promise.resolve(
where.id === 'submit-long'
? {
id: 'submit-long',
messageRecordId: 'record-long',
channelId: 'channel-1',
submitId: 'SUB-LONG-FAIL',
submitStatus: 'accepted',
}
: null,
));
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'billing-charged', billingStatus: 'charged' });