perf(cmpp): decouple supplier result callbacks
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "SmsSubmitRecord"
|
||||
ADD COLUMN "resultEventId" TEXT,
|
||||
ADD COLUMN "resultProcessedAt" TIMESTAMP(3);
|
||||
|
||||
CREATE UNIQUE INDEX "SmsSubmitRecord_resultEventId_key"
|
||||
ON "SmsSubmitRecord"("resultEventId");
|
||||
@@ -1760,6 +1760,8 @@ model SmsSubmitRecord {
|
||||
sequenceId Int?
|
||||
gatewayMessageId String?
|
||||
submitStatus String @default("queued")
|
||||
resultEventId String? @unique
|
||||
resultProcessedAt DateTime?
|
||||
costUnitPrice BigInt @default(0)
|
||||
costAmountCents BigInt @default(0)
|
||||
errorCode String?
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface GatewayInboundSingleSubmitResult {
|
||||
}
|
||||
|
||||
export interface GatewaySubmitResultDto {
|
||||
eventId?: string;
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
@@ -81,6 +82,7 @@ export interface GatewaySubmitResultDto {
|
||||
}
|
||||
|
||||
export interface GatewaySubmitSegmentResultDto {
|
||||
eventId?: string;
|
||||
traceId?: string;
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
|
||||
@@ -2216,6 +2216,46 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a redelivered Outbox aggregate event as idempotent', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
const baseSubmit = {
|
||||
id: 'submit-1',
|
||||
messageRecordId: 'record-1',
|
||||
channelId: 'channel-1',
|
||||
submitId: 'SUB-1',
|
||||
submitStatus: 'accepted',
|
||||
};
|
||||
prisma.smsSubmitRecord.findUnique
|
||||
.mockResolvedValueOnce({ ...baseSubmit, resultEventId: null })
|
||||
.mockResolvedValueOnce({ ...baseSubmit, resultEventId: 'submit:SUB-1:aggregate' });
|
||||
const event = {
|
||||
eventId: 'submit:SUB-1:aggregate',
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
submitId: 'SUB-1',
|
||||
sequenceId: 7,
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'accepted' as const,
|
||||
submittedAt: '2026-07-01T10:00:00.000Z',
|
||||
};
|
||||
|
||||
await service.handleSubmitResult(event);
|
||||
await service.handleSubmitResult(event);
|
||||
|
||||
expect(billing.charge).toHaveBeenCalledTimes(1);
|
||||
expect(billing.release).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'submit-1',
|
||||
OR: [{ resultEventId: null }, { resultEventId: 'submit:SUB-1:aggregate' }],
|
||||
},
|
||||
data: {
|
||||
resultEventId: 'submit:SUB-1:aggregate',
|
||||
resultProcessedAt: new Date('2026-07-01T10:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a legacy aggregate SubmitResult when it cannot match one submit attempt uniquely', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsSubmitRecord.findMany.mockResolvedValue([
|
||||
|
||||
@@ -128,6 +128,12 @@ export class SendGatewayResultService {
|
||||
async handleSubmitResult(data: GatewaySubmitResultDto) {
|
||||
const message = await this.facade.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
|
||||
const submitRecord = await this.facade.resolveSubmitRecordForGatewayResult(message.id, data);
|
||||
if (data.eventId && submitRecord.resultEventId) {
|
||||
if (submitRecord.resultEventId !== data.eventId) {
|
||||
throw new BadRequestException('Gateway SubmitResult eventId conflicts with the submit attempt');
|
||||
}
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const effectiveData = { ...data, submitId: submitRecord.submitId };
|
||||
const batchTask = message.batchTaskId
|
||||
? await this.prisma.smsBatchTask.findUnique({ where: { id: message.batchTaskId }, select: { sourceType: true } })
|
||||
@@ -147,6 +153,7 @@ export class SendGatewayResultService {
|
||||
await this.facade.recordSubmitSegments(message, effectiveData, submittedAt);
|
||||
const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId;
|
||||
if (message.submitId && effectiveData.submitId !== message.submitId) {
|
||||
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
|
||||
@@ -166,6 +173,7 @@ export class SendGatewayResultService {
|
||||
);
|
||||
if (retried) {
|
||||
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
|
||||
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
||||
return retried;
|
||||
}
|
||||
await this.facade.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
|
||||
@@ -218,9 +226,29 @@ export class SendGatewayResultService {
|
||||
if (message.batchTaskId) {
|
||||
await this.facade.refreshTaskProgress(message.batchTaskId);
|
||||
}
|
||||
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
|
||||
private async markSubmitResultProcessed(submitRecordId: string, eventId: string | undefined, processedAt: Date) {
|
||||
if (!eventId) {
|
||||
return;
|
||||
}
|
||||
const updated = await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
id: submitRecordId,
|
||||
OR: [{ resultEventId: null }, { resultEventId: eventId }],
|
||||
},
|
||||
data: {
|
||||
resultEventId: eventId,
|
||||
resultProcessedAt: processedAt,
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) {
|
||||
throw new BadRequestException('Gateway SubmitResult eventId conflicts with the submit attempt');
|
||||
}
|
||||
}
|
||||
|
||||
async resolveSubmitRecordForGatewayResult(messageRecordId: string, data: GatewaySubmitResultDto) {
|
||||
if (data.submitId) {
|
||||
const exact = await this.prisma.smsSubmitRecord.findUnique({ where: { submitId: data.submitId } });
|
||||
|
||||
Reference in New Issue
Block a user