fix: 固化长短信回执终态并隔离发送尝试归属

This commit is contained in:
hectorzhao
2026-09-18 13:20:03 +08:00
parent 1676cfe622
commit c20c2246b2
11 changed files with 791 additions and 209 deletions
@@ -0,0 +1,109 @@
import { resolveReceiptAttempt } from './receipt-attempt-resolver';
import { PrismaService } from '../prisma/prisma.service';
const channel = {
account: 'supplier',
gatewayHost: 'localhost',
gatewayPort: 7890,
protocol: 'CMPP',
cmppVersion: '3.0',
};
const message = { id: 'm', messageId: 'MSG', phoneNumber: '13800138000', tenantId: 'tenant' };
const source = (id = 's', channelId = 'c') => ({
id,
submitId: id,
channelId,
tenantId: 'tenant',
messageRecordId: 'm',
messageRecord: message,
channel,
});
const fragment = (submit = source()) => ({
messageRecordId: 'm',
messageRecord: message,
channelId: submit.channelId,
channel,
submitId: submit.submitId,
submitRecord: submit,
});
const event = {
messageId: 'MSG',
channelId: 'c',
gatewayMessageId: 'GW',
phoneNumber: message.phoneNumber,
receiptStatus: 'delivered' as const,
rawStatus: 'DELIVRD',
};
function fixture(submits: unknown[] = [], fragments: unknown[] = []) {
return {
smsMessageRecord: { findUnique: jest.fn().mockResolvedValue(message) },
smsSubmitRecord: {
findMany: jest.fn().mockResolvedValue(submits),
findUnique: jest.fn().mockResolvedValue(source()),
},
smsMessageSegmentAudit: { findMany: jest.fn().mockResolvedValue(fragments) },
smsChannel: { findUnique: jest.fn().mockResolvedValue(channel) },
};
}
const resolve = (db: ReturnType<typeof fixture>, data = event) =>
resolveReceiptAttempt(db as unknown as PrismaService, data);
describe('receipt attempt identity', () => {
it('deduplicates primary and fragment evidence for one attempt', async () => {
expect((await resolve(fixture([source()], [fragment()]))).submitRecordId).toBe('s');
});
it('rejects a primary ID colliding with another attempt fragment on the same channel', async () => {
await expect(resolve(fixture([source()], [fragment(source('other'))]))).rejects.toThrow('提交尝试关联');
});
it('selects the incoming channel, irrespective of newest candidate order', async () => {
expect((await resolve(fixture([source('new', 'other'), source()]))).submitRecordId).toBe('s');
});
it('rejects two same-channel submit candidates', async () => {
await expect(resolve(fixture([source(), source('other')]))).rejects.toThrow('提交尝试关联');
});
it('accepts one other connection of the same supplier', async () => {
expect((await resolve(fixture([], [fragment(source('s', 'other'))]))).channelId).toBe('other');
});
it('rejects ambiguous connections of the same supplier', async () => {
await expect(resolve(fixture([], [fragment(source('a', 'a')), fragment(source('b', 'b'))]))).rejects.toThrow(
'提交尝试关联',
);
});
it('does not silently accept changed supplier credentials captured by Inbox', async () => {
const db = fixture([source()]);
await expect(
resolveReceiptAttempt(db as unknown as PrismaService, event, { ...channel, account: 'old-supplier' }),
).rejects.toThrow('提交尝试关联');
});
it('rejects an exact business ID with a different destination', async () => {
await expect(resolve(fixture([source()]), { ...event, phoneNumber: '13900139000' })).rejects.toThrow(
'提交尝试关联',
);
});
it('rejects malformed cross-tenant fragment relations', async () => {
await expect(resolve(fixture([], [fragment({ ...source(), tenantId: 'other' })]))).rejects.toThrow('提交尝试关联');
});
it('recovers a legacy fragment relation from its globally unique submit ID', async () => {
const db = fixture([], [{ ...fragment(), submitRecord: null }]);
expect((await resolve(db)).submitRecordId).toBe('s');
expect(db.smsSubmitRecord.findUnique).toHaveBeenCalledWith({ where: { submitId: 's' } });
});
it('rejects a truncated candidate set instead of pretending it is unique', async () => {
await expect(resolve(fixture(Array.from({ length: 101 }, () => source())))).rejects.toThrow('提交尝试关联');
});
it('limits submit-response-loss recovery to one timed-out submit in 72 hours', async () => {
const db = fixture();
db.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([source()]);
expect((await resolve(db)).submitRecordId).toBe('s');
expect(db.smsSubmitRecord.findMany).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({
submitStatus: 'timeout',
gatewayMessageId: null,
channelId: 'c',
messageRecordId: 'm',
}),
take: 2,
}),
);
});
});
@@ -0,0 +1,125 @@
import { NotFoundException } from '@nestjs/common';
import { SmsMessageRecord } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { GatewayReceiptEventDto } from './send-chain.contracts';
import { isSameUpstreamEndpointIdentity } from './send-chain.helpers';
type UpstreamIdentity = {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
};
type Candidate = {
message: SmsMessageRecord;
messageId: string;
submitRecordId: string;
submitId: string;
channelId: string;
channel: UpstreamIdentity | null;
};
const unmatched = () => new NotFoundException('回执缺少唯一且可信的提交尝试关联');
/** A supplier Msg_Id is not globally unique. Combine submit and fragment evidence
* before accepting a candidate; a fragment can collide with another attempt's
* primary Msg_Id, including on the same logical channel. */
export async function resolveReceiptAttempt(
db: PrismaService,
data: GatewayReceiptEventDto,
identity?: UpstreamIdentity,
) {
if (!data.gatewayMessageId) throw unmatched();
const exact = data.messageId ? await db.smsMessageRecord.findUnique({ where: { messageId: data.messageId } }) : null;
const phone = data.phoneNumber?.trim();
if (exact && phone && exact.phoneNumber !== phone) throw unmatched();
const scope = exact
? { messageRecordId: exact.id }
: phone
? { messageRecord: { phoneNumber: phone } }
: { channelId: data.channelId };
const [submits, segments] = await Promise.all([
db.smsSubmitRecord.findMany({
where: { ...scope, gatewayMessageId: data.gatewayMessageId },
include: { messageRecord: true, channel: true },
take: 101,
}),
db.smsMessageSegmentAudit.findMany({
where: { ...scope, gatewayMessageId: data.gatewayMessageId },
include: { messageRecord: true, submitRecord: true, channel: true },
take: 101,
}),
]);
// A truncated set must never look unique after filtering.
if (submits.length > 100 || segments.length > 100) throw unmatched();
const candidates = new Map<string, Candidate>();
for (const submit of submits)
candidates.set(submit.id, {
message: submit.messageRecord,
messageId: submit.messageRecord.messageId,
submitRecordId: submit.id,
submitId: submit.submitId,
channelId: submit.channelId,
channel: submit.channel,
});
for (const segment of segments) {
const submit =
segment.submitRecord ?? (await db.smsSubmitRecord.findUnique({ where: { submitId: segment.submitId } }));
if (
!submit ||
submit.messageRecordId !== segment.messageRecordId ||
submit.channelId !== segment.channelId ||
submit.tenantId !== segment.messageRecord.tenantId
)
throw unmatched();
candidates.set(submit.id, {
message: segment.messageRecord,
messageId: segment.messageRecord.messageId,
submitRecordId: submit.id,
submitId: submit.submitId,
channelId: submit.channelId,
channel: segment.channel,
});
}
const all = [...candidates.values()];
const direct = all.filter((c) => c.channelId === data.channelId);
if (direct.length > 1) throw unmatched();
if (direct.length === 1) {
// Inbox supplies the identity captured at intake; changed channel credentials
// cannot silently reassign an older supplier's receipt.
if (identity && (!direct[0].channel || !isSameUpstreamEndpointIdentity(identity, direct[0].channel)))
throw unmatched();
return direct[0];
}
const incoming = identity ?? (await db.smsChannel.findUnique({ where: { id: data.channelId } }));
if (!incoming) throw unmatched();
const shared = all.filter((c) => c.channel && isSameUpstreamEndpointIdentity(incoming, c.channel));
if (shared.length > 1) throw unmatched();
if (shared.length === 1 && (exact || phone)) return shared[0];
if (!phone) throw unmatched();
// Preserve the existing narrowly bounded recovery of one timed-out submission
// whose provider identity was not recorded before its first receipt arrived.
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const legacy = await db.smsSubmitRecord.findMany({
where: {
channelId: data.channelId,
gatewayMessageId: null,
submitStatus: 'timeout',
...(exact ? { messageRecordId: exact.id } : {}),
submittedAt: { gte: new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000), lte: deliveredAt },
messageRecord: { phoneNumber: phone },
},
include: { messageRecord: true, channel: true },
take: 2,
});
if (legacy.length !== 1 || (identity && !isSameUpstreamEndpointIdentity(identity, legacy[0].channel)))
throw unmatched();
const source = legacy[0];
return {
message: source.messageRecord,
messageId: source.messageRecord.messageId,
submitRecordId: source.id,
submitId: source.submitId,
channelId: source.channelId,
};
}
+100 -38
View File
@@ -466,6 +466,34 @@ function createPrismaMock() {
return prisma;
}
// Supply relational receipt evidence separately from aggregate result fixtures.
async function receiptEvidence(
prisma: ReturnType<typeof createPrismaMock>,
overrides: Record<string, unknown> = {},
attempt: Record<string, unknown> = {},
) {
const message = { ...(await prisma.smsMessageRecord.findUnique()), ...overrides };
const prior = await prisma.smsSubmitRecord.findFirst();
const source = {
...prior,
messageRecordId: message.id,
tenantId: message.tenantId,
channelId: overrides.channelId ?? message.channelId,
submitId: message.submitId ?? prior.submitId,
messageRecord: message,
channel: await prisma.smsChannel.findUnique(),
...attempt,
};
prisma.smsSubmitRecord.findMany.mockResolvedValue([source]);
prisma.smsSubmitRecord.findUnique.mockImplementation(({ where }) =>
Promise.resolve(where.retryOfSubmitRecordId ? null : source),
);
const aggregate = prisma.smsMessageSegmentAudit.findMany;
prisma.smsMessageSegmentAudit.findMany = jest
.fn()
.mockImplementation((args) => (args.include?.messageRecord ? Promise.resolve([]) : aggregate(args)));
}
function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEvent: jest.Mock }) {
const billing = {
estimateSmsCost: jest.fn().mockReturnValue({
@@ -3547,6 +3575,7 @@ describe('SendChainService', () => {
expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }),
);
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-1',
@@ -3601,6 +3630,7 @@ describe('SendChainService', () => {
},
});
await receiptEvidence(prisma, { ...(await prisma.smsMessageRecord.findFirst()), submitId: 'SUB-1' });
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-1',
@@ -3636,6 +3666,10 @@ describe('SendChainService', () => {
unitPrice: 3,
});
await receiptEvidence(prisma, await prisma.smsMessageRecord.findFirst(), {
channelId: 'channel-old',
submitId: 'SUB-OLD',
});
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-old',
@@ -3661,29 +3695,28 @@ describe('SendChainService', () => {
it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => {
const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
prisma.smsSubmitRecord.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: 'submit-timeout-1',
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'submit-timeout-1',
submitId: 'SUB-1',
messageRecordId: 'record-1',
channelId: 'channel-1',
gatewayMessageId: null,
submitStatus: 'timeout',
submittedAt: new Date('2026-07-01T10:00:00.000Z'),
messageRecord: {
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
channelId: 'channel-1',
gatewayMessageId: null,
submitStatus: 'timeout',
submittedAt: new Date('2026-07-01T10:00:00.000Z'),
messageRecord: {
id: 'record-1',
tenantId: 'tenant-1',
batchTaskId: 'task-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
channelId: 'channel-1',
gatewayMessageId: null,
status: 'timeout',
},
status: 'timeout',
},
]);
},
]);
await service.handleReceipt({
messageId: 'receipt-123456789',
@@ -3721,6 +3754,8 @@ describe('SendChainService', () => {
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([
{
id: 'submit-channel-b',
submitId: 'SUB-B',
messageRecordId: 'record-channel-b',
channelId: 'channel-b',
gatewayMessageId: 'SHARED-UPSTREAM-ID',
messageRecord: {
@@ -3737,6 +3772,12 @@ describe('SendChainService', () => {
},
]);
prisma.smsSubmitRecord.findUnique.mockResolvedValue({
id: 'submit-channel-b',
submitId: 'SUB-B',
messageRecordId: 'record-channel-b',
channelId: 'channel-b',
});
await service.handleReceipt({
messageId: 'receipt-SHARED-UPSTREAM-ID',
channelId: 'channel-b',
@@ -3750,7 +3791,6 @@ describe('SendChainService', () => {
expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
channelId: 'channel-b',
gatewayMessageId: 'SHARED-UPSTREAM-ID',
messageRecord: { phoneNumber: '15601992925' },
}),
@@ -3788,7 +3828,14 @@ describe('SendChainService', () => {
submitRecordId: 'submit-original',
channelId: 'channel-original',
gatewayMessageId: '736070230367350788',
submitRecord: { id: 'submit-original', submitId: 'SUB-LONG-1' },
messageRecordId: 'record-long',
submitRecord: {
id: 'submit-original',
submitId: 'SUB-LONG-1',
messageRecordId: 'record-long',
channelId: 'channel-original',
tenantId: 'tenant-1',
},
channel: {
id: 'channel-original',
account: 'C59748',
@@ -3814,6 +3861,12 @@ describe('SendChainService', () => {
])
.mockResolvedValueOnce([]);
prisma.smsSubmitRecord.findUnique.mockResolvedValue({
id: 'submit-original',
submitId: 'SUB-LONG-1',
messageRecordId: 'record-long',
channelId: 'channel-original',
});
await service.handleReceipt({
messageId: 'receipt-736070230367350788',
channelId: 'channel-copy',
@@ -3851,7 +3904,13 @@ describe('SendChainService', () => {
submitId: 'SUB-ORIGINAL',
channelId: 'channel-original',
gatewayMessageId: 'SHARED-ID',
submitRecord: { id: 'submit-original', submitId: 'SUB-ORIGINAL' },
messageRecordId: 'record-original',
submitRecord: {
id: 'submit-original',
submitId: 'SUB-ORIGINAL',
messageRecordId: 'record-original',
channelId: 'channel-original',
},
channel: {
id: 'channel-original',
account: 'C59748',
@@ -3877,7 +3936,7 @@ describe('SendChainService', () => {
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
}),
).rejects.toThrow('SMS message record not found');
).rejects.toThrow('提交尝试关联');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
});
@@ -3925,6 +3984,7 @@ describe('SendChainService', () => {
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
]);
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-LONG-1',
channelId: 'channel-1',
@@ -4014,6 +4074,7 @@ describe('SendChainService', () => {
},
]);
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-MESSAGE-LEVEL',
channelId: 'channel-1',
@@ -4078,6 +4139,7 @@ describe('SendChainService', () => {
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date() },
]);
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-CONFLICT',
channelId: 'channel-1',
@@ -4238,6 +4300,7 @@ describe('SendChainService', () => {
},
});
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-LONG-FAIL',
channelId: 'channel-1',
@@ -4279,6 +4342,7 @@ describe('SendChainService', () => {
rawStatus: 'DELIVRD',
deliveredAt: '2026-07-01T10:01:00.000Z',
};
await receiptEvidence(prisma);
await service.handleReceipt(receipt);
await service.handleReceipt(receipt);
@@ -4289,19 +4353,16 @@ describe('SendChainService', () => {
it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => {
const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
prisma.smsSubmitRecord.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: 'submit-timeout-1',
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
},
{
id: 'submit-timeout-2',
messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
},
]);
prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'submit-timeout-1',
messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' },
},
{
id: 'submit-timeout-2',
messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' },
},
]);
await expect(
service.handleReceipt({
@@ -4312,7 +4373,7 @@ describe('SendChainService', () => {
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
}),
).rejects.toThrow('SMS message record not found');
).rejects.toThrow('提交尝试关联');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
});
@@ -4424,6 +4485,7 @@ describe('SendChainService', () => {
it('records receipts and uplink messages from gateway events', async () => {
const { service, prisma } = createService();
await receiptEvidence(prisma);
await service.handleReceipt({
messageId: 'MSG-1',
channelId: 'channel-1',
@@ -190,7 +190,8 @@ export class SendGatewayResultService {
data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed';
if (
data.submitStatus !== 'accepted' &&
(message.status === 'delivered' || (message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT'))
(['delivered', 'failed'].includes(message.status) ||
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT'))
) {
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
return message;
@@ -381,10 +382,8 @@ export class SendGatewayResultService {
const submitRecord = await this.prisma.smsSubmitRecord.findFirst({
where: {
messageRecordId: message.id,
OR: [
data.submitId ? { submitId: data.submitId } : undefined,
data.gatewayMessageId ? { gatewayMessageId: data.gatewayMessageId } : undefined,
].filter(Boolean) as Array<{ submitId?: string; gatewayMessageId?: string }>,
channelId: data.channelId,
...(data.submitId ? { submitId: data.submitId } : { gatewayMessageId: data.gatewayMessageId }),
},
orderBy: { createdAt: 'desc' },
});
+46 -166
View File
@@ -1,3 +1,4 @@
import { resolveReceiptAttempt } from './receipt-attempt-resolver';
import { completionContext } from './completion-context';
import { Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -14,7 +15,6 @@ import {
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS,
DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS,
aggregateReceiptSegmentState,
isSameUpstreamEndpointIdentity,
receiptEventKey,
longMessageReceiptMode,
} from './send-chain.helpers';
@@ -282,6 +282,34 @@ export class SendReceiptService {
throw error;
}
const logicalReceipt = { ...data, channelId: logicalChannelId };
// The message row is locked by AttemptCompletion. A committed final decision
// also commits accounting and notifications; later evidence cannot undo it.
const sameAttempt =
(!message.submitId || message.submitId === resolved.submitId) &&
(!message.channelId || message.channelId === logicalChannelId);
const frozen =
['failed', 'delivered'].includes(message.status) ||
(message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT');
if (sameAttempt && frozen) {
const contradicts =
message.status === 'delivered'
? !['delivered', 'unknown'].includes(data.receiptStatus)
: data.receiptStatus === 'delivered';
if (contradicts) {
if (!existingReceipt)
await this.recordReceiptConflict({
message,
submitRecordId: resolved.submitRecordId,
submitId: resolved.submitId,
receiptRecordId,
receiptKey,
data: logicalReceipt,
});
} else if (data.receiptStatus !== 'unknown') {
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
}
return message;
}
await this.facade.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
const receiptMode =
Number(message.billingUnits ?? 1) > 1 ? await this.getLongMessageReceiptMode(logicalChannelId) : 'per_segment';
@@ -455,7 +483,10 @@ export class SendReceiptService {
receiptKey: input.receiptKey,
gatewayMessageId: input.data.gatewayMessageId,
phoneNumber: input.data.phoneNumber,
reason: 'message_level_success_followed_by_failure',
reason:
input.message.status === 'delivered'
? 'message_level_success_followed_by_failure'
: 'final_failure_followed_by_success',
};
await this.prisma.smsReceiptAnomaly.upsert({
where: { anomalyKey },
@@ -479,7 +510,8 @@ export class SendReceiptService {
messageRecordId: input.message.id,
submitRecordId: input.submitRecordId,
receiptRecordId: input.receiptRecordId,
anomalyType: 'aggregate_success_then_failure',
anomalyType:
input.message.status === 'delivered' ? 'aggregate_success_then_failure' : 'final_failure_then_success',
previousStatus: input.message.status,
incomingStatus: input.data.receiptStatus,
rawStatus: input.data.rawStatus,
@@ -505,12 +537,21 @@ export class SendReceiptService {
submitRecordId?: string,
) {
const segmentAudits = this.facade.smsMessageSegmentAuditDelegate();
const source = submitRecordId
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
: null;
if (submitRecordId && (!source || source.messageRecordId !== message.id || source.channelId !== data.channelId))
throw new Error('completion_receipt_source_mismatch');
if (!source) throw new NotFoundException('回执缺少可确认的提交尝试关联');
const updated = await segmentAudits.updateMany({
where: {
messageRecordId: message.id,
channelId: source.channelId,
OR: [{ submitRecordId: source.id }, { submitRecordId: null, submitId: source.submitId }],
gatewayMessageId: data.gatewayMessageId,
},
data: {
submitRecordId: source.id,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode ?? null,
@@ -520,12 +561,7 @@ export class SendReceiptService {
if (updated.count > 0) {
return;
}
const submitRecord = submitRecordId
? await this.prisma.smsSubmitRecord.findUnique({ where: { id: submitRecordId } })
: await this.prisma.smsSubmitRecord.findFirst({
where: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
orderBy: { createdAt: 'desc' },
});
const submitRecord = source;
await segmentAudits.upsert({
where: {
messageRecordId_submitId_segmentIndex: {
@@ -600,162 +636,6 @@ export class SendReceiptService {
cmppVersion: string;
},
) {
const exactMessage = data.messageId
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
: null;
if (exactMessage) {
const segmentAudit = data.gatewayMessageId
? await this.facade.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,
channelId: data.channelId,
gatewayMessageId: data.gatewayMessageId,
},
orderBy: { createdAt: 'desc' },
});
return {
message: exactMessage,
messageId: exactMessage.messageId,
submitRecordId: submitRecord?.id,
submitId: submitRecord?.submitId,
channelId: submitRecord?.channelId ?? data.channelId,
};
}
const phoneNumber = data.phoneNumber?.trim();
const exactSubmits = await this.prisma.smsSubmitRecord.findMany({
where: {
channelId: data.channelId,
gatewayMessageId: data.gatewayMessageId,
...(phoneNumber ? { messageRecord: { phoneNumber } } : {}),
},
include: { messageRecord: true },
orderBy: { createdAt: 'desc' },
take: 2,
});
if (exactSubmits.length === 1 && exactSubmits[0]?.messageRecord) {
return {
message: exactSubmits[0].messageRecord,
messageId: exactSubmits[0].messageRecord.messageId,
submitRecordId: exactSubmits[0].id,
submitId: exactSubmits[0].submitId,
channelId: exactSubmits[0].channelId,
};
}
if (!phoneNumber) {
throw new NotFoundException('SMS message record not found');
}
const incomingChannel =
incomingIdentity ?? (await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }));
if (!incomingChannel) {
throw new NotFoundException('SMS message record not found');
}
const segmentMatches = await this.facade.smsMessageSegmentAuditDelegate().findMany({
where: {
gatewayMessageId: data.gatewayMessageId,
messageRecord: { phoneNumber },
},
include: { messageRecord: true, submitRecord: true, channel: true },
orderBy: { createdAt: 'desc' },
take: 10,
});
const exactSegmentMatches = segmentMatches.filter((candidate) => candidate.channelId === data.channelId);
if (exactSegmentMatches.length === 1 && exactSegmentMatches[0]?.messageRecord) {
return {
message: exactSegmentMatches[0].messageRecord,
messageId: exactSegmentMatches[0].messageRecord.messageId,
submitRecordId: exactSegmentMatches[0].submitRecordId ?? undefined,
submitId: exactSegmentMatches[0].submitRecord?.submitId ?? exactSegmentMatches[0].submitId,
channelId: exactSegmentMatches[0].channelId,
};
}
const sameSupplierSegments = segmentMatches.filter(
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
);
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
return {
message: sameSupplierSegments[0].messageRecord,
messageId: sameSupplierSegments[0].messageRecord.messageId,
submitRecordId: sameSupplierSegments[0].submitRecordId ?? undefined,
submitId: sameSupplierSegments[0].submitRecord?.submitId ?? sameSupplierSegments[0].submitId,
channelId: sameSupplierSegments[0].channelId,
};
}
const crossConnectionSubmits = await this.prisma.smsSubmitRecord.findMany({
where: {
gatewayMessageId: data.gatewayMessageId,
messageRecord: { phoneNumber },
},
include: { messageRecord: true, channel: true },
orderBy: { createdAt: 'desc' },
take: 10,
});
const sameSupplierSubmits = crossConnectionSubmits.filter(
(candidate) => candidate.channel && isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel),
);
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
return {
message: sameSupplierSubmits[0].messageRecord,
messageId: sameSupplierSubmits[0].messageRecord.messageId,
submitRecordId: sameSupplierSubmits[0].id,
submitId: sameSupplierSubmits[0].submitId,
channelId: sameSupplierSubmits[0].channelId,
};
}
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
const submittedAfter = new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000);
const candidates = await this.prisma.smsSubmitRecord.findMany({
where: {
channelId: data.channelId,
gatewayMessageId: null,
submitStatus: 'timeout',
submittedAt: {
gte: submittedAfter,
lte: deliveredAt,
},
messageRecord: {
phoneNumber,
},
},
include: {
messageRecord: true,
},
orderBy: {
submittedAt: 'desc',
},
take: 10,
});
if (candidates.length !== 1 || !candidates[0]?.messageRecord) {
throw new NotFoundException('SMS message record not found');
}
return {
message: candidates[0].messageRecord,
messageId: candidates[0].messageRecord.messageId,
submitRecordId: candidates[0].id,
submitId: candidates[0].submitId,
channelId: candidates[0].channelId,
};
return resolveReceiptAttempt(this.prisma, data, incomingIdentity);
}
}