fix: 固化长短信回执终态并隔离发送尝试归属
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user