fix: make SMS retry side effects idempotent
This commit is contained in:
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user