import { protocolUint32ToDb, protocolUint32FromDb } from '../common/protocol-uint32'; import { resolveReceiptAttempt } from './receipt-attempt-resolver'; import { completionContext } from './completion-context'; import { Logger, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { BillingService } from '../billing/billing.service'; import type { OpenApiService } from '../open-api/open-api.service'; import { PrismaService } from '../prisma/prisma.service'; import type { GatewayReceiptEventDto } from './send-chain.contracts'; import { positiveInteger, normalizeReceiptStatus, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, aggregateReceiptSegmentState, receiptEventKey, longMessageReceiptMode, } from './send-chain.helpers'; import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service'; import { queueFinalReceiptDeliveries } from './downstream-receipt-targets'; /** * R10 receipt implementation. * Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability. */ export class SendReceiptService { private readonly logger = new Logger('SendChainService'); private upstreamReceiptInboxScanRunning = false; constructor( private readonly prisma: PrismaService, private readonly billing: BillingService, private readonly openApi: OpenApiService | undefined, private readonly facade: SendCompletionFacade, private readonly callbacks: SendCompletionCallbacks, ) {} async intakeReceipt(data: GatewayReceiptEventDto) { protocolUint32ToDb(data.sequenceId); const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId }, select: { id: true, account: true, gatewayHost: true, gatewayPort: true, protocol: true, cmppVersion: true, }, }); if (!channel) { throw new NotFoundException('SMS channel not found'); } const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); const receiptKey = receiptEventKey(data, data.channelId); const inbox = await this.prisma.upstreamReceiptInbox .upsert({ where: { receiptKey }, update: { incomingConnectionId: data.connectionId, }, create: { receiptKey, incomingChannelId: data.channelId, incomingConnectionId: data.connectionId, upstreamAccount: channel.account, upstreamHost: channel.gatewayHost, upstreamPort: channel.gatewayPort, protocol: channel.protocol, protocolVersion: channel.cmppVersion, provisionalMessageId: data.messageId, sequenceId: protocolUint32ToDb(data.sequenceId), gatewayMessageId: data.gatewayMessageId, phoneNumber: data.phoneNumber?.trim() || null, receiptStatus: data.receiptStatus, rawStatus: data.rawStatus, errorCode: data.errorCode, errorMessage: data.errorMessage, deliveredAt, gatewayReceivedAt: data.deliveredAt ? new Date(data.deliveredAt) : null, status: 'pending', nextRetryAt: new Date(), }, }) .catch(async (error: unknown) => { // Prisma can emulate an upsert when the optional update is empty. Another // callback may win the unique receiptKey insert; acknowledge only the // exact durable fact, never swallow unrelated persistence failures. if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { const existing = await this.prisma.upstreamReceiptInbox.findUnique({ where: { receiptKey } }); if (existing) return existing; } throw error; }); if (['pending', 'retrying'].includes(inbox.status)) { setImmediate(() => void this.facade.processUpstreamReceiptInboxRecord(inbox.id)); } return { accepted: true, inboxId: inbox.id, status: inbox.status }; } async processPendingUpstreamReceiptInbox(limit = 100) { const now = new Date(); const staleBefore = new Date( now.getTime() - positiveInteger( process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, ), ); const candidates = await this.prisma.upstreamReceiptInbox.findMany({ where: { OR: [ { status: { in: ['pending', 'retrying'] }, OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }], }, { status: 'processing', updatedAt: { lte: staleBefore } }, ], }, orderBy: [{ receivedAt: 'asc' }, { id: 'asc' }], take: Math.min(Math.max(limit, 1), 500), select: { id: true }, }); let processed = 0; for (const candidate of candidates) { if (await this.facade.processUpstreamReceiptInboxRecord(candidate.id)) processed += 1; } return { scanned: candidates.length, processed }; } async processUpstreamReceiptInboxRecord(id: string) { const staleBefore = new Date( Date.now() - positiveInteger( process.env.UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, ), ); const claimed = await this.prisma.upstreamReceiptInbox.updateMany({ where: { id, OR: [{ status: { in: ['pending', 'retrying'] } }, { status: 'processing', updatedAt: { lte: staleBefore } }], }, data: { status: 'processing', attemptCount: { increment: 1 }, nextRetryAt: null }, }); if (claimed.count !== 1) return false; const inbox = await this.prisma.upstreamReceiptInbox.findUnique({ where: { id } }); if (!inbox) return false; try { const message = await this.facade.handleReceipt( { messageId: inbox.provisionalMessageId ?? undefined, channelId: inbox.incomingChannelId, connectionId: inbox.incomingConnectionId ?? undefined, sequenceId: protocolUint32FromDb(inbox.sequenceId), gatewayMessageId: inbox.gatewayMessageId, phoneNumber: inbox.phoneNumber ?? undefined, receiptStatus: normalizeReceiptStatus(inbox.receiptStatus), rawStatus: inbox.rawStatus, errorCode: inbox.errorCode ?? undefined, errorMessage: inbox.errorMessage ?? undefined, deliveredAt: inbox.deliveredAt.toISOString(), }, { account: inbox.upstreamAccount, gatewayHost: inbox.upstreamHost, gatewayPort: inbox.upstreamPort, protocol: inbox.protocol, cmppVersion: inbox.protocolVersion, }, ); const matchedMessageRecordId = message && 'id' in message ? message.id : message?.messageRecordId; const matchedChannelId = message && 'channelId' in message ? message.channelId : undefined; await this.prisma.upstreamReceiptInbox.update({ where: { id }, data: { status: 'matched', matchedMessageRecordId: matchedMessageRecordId ?? null, matchedChannelId: matchedChannelId ?? null, lastError: null, processedAt: new Date(), }, }); return true; } catch (error) { const maxAttempts = positiveInteger( process.env.UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, ); const maxAgeHours = positiveInteger( process.env.UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, ); const exhausted = inbox.attemptCount >= maxAttempts || inbox.receivedAt.getTime() <= Date.now() - maxAgeHours * 60 * 60_000; const retryDelayMs = Math.min(30 * 60_000, 5_000 * 2 ** Math.min(Math.max(inbox.attemptCount - 1, 0), 8)); await this.prisma.upstreamReceiptInbox.update({ where: { id }, data: { status: exhausted ? 'unmatched' : 'retrying', nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelayMs), lastError: error instanceof Error ? error.message : String(error), processedAt: exhausted ? new Date() : null, }, }); return false; } } async runUpstreamReceiptInboxScan() { if (this.upstreamReceiptInboxScanRunning) return; this.upstreamReceiptInboxScanRunning = true; try { const result = await this.facade.processPendingUpstreamReceiptInbox(); if (result.processed > 0) { this.logger.log(`Matched ${result.processed}/${result.scanned} pending upstream receipts`); } } catch (error) { this.logger.error('Upstream receipt inbox scan failed', error instanceof Error ? error.stack : String(error)); } finally { this.upstreamReceiptInboxScanRunning = false; } } async handleReceipt( data: GatewayReceiptEventDto, incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string; }, ) { protocolUint32ToDb(data.sequenceId); const resolved = await this.facade.resolveReceiptMessage(data, incomingIdentity); const logicalChannelId = resolved.channelId ?? data.channelId; const receiptKey = receiptEventKey(data, logicalChannelId); const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({ where: { receiptKey }, include: { messageRecord: true }, }); if (existingReceipt?.messageRecord && !completionContext.getStore()) { return existingReceipt.messageRecord; } const message = resolved.message; if (existingReceipt && existingReceipt.messageRecordId !== message.id) throw new Error('completion_receipt_owner_mismatch'); const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); if (resolved.submitRecordId) { await this.prisma.smsSubmitRecord.updateMany({ where: { id: resolved.submitRecordId, gatewayMessageId: null, }, data: { gatewayMessageId: data.gatewayMessageId, sequenceId: protocolUint32ToDb(data.sequenceId), }, }); } let receiptRecordId: string | undefined = existingReceipt?.id; if (!existingReceipt) try { const createdReceipt = await this.prisma.smsReceiptRecord.create({ data: { tenantId: message.tenantId, batchTaskId: message.batchTaskId, messageRecordId: message.id, receiptKey, channelId: logicalChannelId, messageId: resolved.messageId, gatewayMessageId: data.gatewayMessageId, phoneNumber: data.phoneNumber?.trim() || message.phoneNumber, sequenceId: protocolUint32ToDb(data.sequenceId), receiptStatus: data.receiptStatus, rawStatus: data.rawStatus, errorCode: data.errorCode, errorMessage: data.errorMessage, deliveredAt, }, }); receiptRecordId = createdReceipt.id; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { const duplicate = await this.prisma.smsReceiptRecord.findUnique({ where: { receiptKey }, include: { messageRecord: true }, }); if (duplicate?.messageRecord) return duplicate.messageRecord; } 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'; if (receiptMode === 'message_level' && data.receiptStatus === 'delivered') { await this.applyMessageLevelSuccess( message, logicalReceipt, deliveredAt, resolved.submitRecordId, resolved.submitId, ); } const aggregate = await this.facade.aggregateReceiptSegments( message, logicalReceipt, deliveredAt, resolved.submitRecordId, resolved.submitId, ); if (!aggregate.terminal) { return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); } if (message.status === 'timeout' && message.errorCode === 'RECEIPT_TIMEOUT') return message; const status = aggregate.status; const isCurrentAttempt = (!message.submitId || message.submitId === resolved.submitId) && (!message.channelId || message.channelId === logicalChannelId) && (!message.gatewayMessageId || message.gatewayMessageId === data.gatewayMessageId || (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))); if (!isCurrentAttempt) { return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); } if (status === 'failed' && message.status === 'delivered') { if (receiptMode === 'message_level') { // A delivered result may already have been exposed to the customer and settled. // Preserve that terminal decision; the contradictory late receipt is evidence for operations, not a second state transition. await this.recordReceiptConflict({ message, submitRecordId: resolved.submitRecordId, submitId: resolved.submitId, receiptRecordId, receiptKey, data: logicalReceipt, }); } return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); } const isStandaloneChannelTest = !message.tenantId && !message.batchTaskId; if (status === 'failed' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) { const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string }; const retried = await this.facade.retryMessageIfAllowed(businessMessage, '回执失败补发', resolved.submitRecordId); if (retried) { await this.facade.refreshTaskProgress(businessMessage.batchTaskId); return retried; } await this.facade.refundMessage(businessMessage, '最终失败退款'); } await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { channelId: logicalChannelId, gatewayMessageId: message.gatewayMessageId ?? data.gatewayMessageId, receiptStatus: aggregate.receiptStatus, receiptRawStatus: aggregate.rawStatus, status, errorCode: aggregate.errorCode, errorMessage: aggregate.errorMessage ?? (status === 'delivered' ? null : aggregate.rawStatus), deliveredAt: aggregate.deliveredAt, }, }); if (!isStandaloneChannelTest && message.tenantId && message.applicationId) { await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), { message, payload: { messageId: message.messageId, gatewayMessageId: data.gatewayMessageId, phoneNumber: message.phoneNumber, receiptStatus: aggregate.receiptStatus, rawStatus: aggregate.rawStatus, errorCode: aggregate.errorCode, deliveredAt: aggregate.deliveredAt.toISOString(), }, segmentPayloads: Object.fromEntries( aggregate.segments .filter((segment) => segment.receiptStatus) .map((segment) => [ segment.segmentIndex, { receiptStatus: segment.receiptStatus, rawStatus: segment.rawStatus, errorCode: segment.errorCode, deliveredAt: segment.deliveredAt?.toISOString() ?? aggregate.deliveredAt.toISOString(), }, ]), ), }); } if (message.batchTaskId) { await this.facade.refreshTaskProgress(message.batchTaskId); } return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); } private async getLongMessageReceiptMode(channelId?: string | null) { if (!channelId) return 'per_segment' as const; const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { config: true }, }); return longMessageReceiptMode(channel?.config); } private async applyMessageLevelSuccess( message: { id: string; channelId?: string | null; submitId?: string | null }, data: GatewayReceiptEventDto, deliveredAt: Date, submitRecordId?: string, submitId?: string, ) { const belongsToCurrentAttempt = (!message.channelId || message.channelId === data.channelId) && (!message.submitId || message.submitId === submitId); if (!belongsToCurrentAttempt) return; const attemptWhere = submitRecordId ? { messageRecordId: message.id, submitRecordId } : submitId ? { messageRecordId: message.id, submitId } : null; if (!attemptWhere) return; const segments = await this.prisma.smsMessageSegmentAudit.findMany({ where: attemptWhere, select: { id: true, receiptStatus: true }, }); if (segments.length <= 1) return; if ( segments.some((segment) => segment.receiptStatus && !['delivered', 'unknown'].includes(segment.receiptStatus)) ) { return; } // This supplier contract reports one message-level success for a multipart SMS. // Mark only missing segments as inferred so the raw receipt remains singular and auditable. await this.prisma.smsMessageSegmentAudit.updateMany({ where: { ...attemptWhere, receiptStatus: null }, data: { receiptStatus: 'delivered', rawStatus: data.rawStatus, errorCode: data.errorCode ?? null, errorMessage: data.errorMessage ?? null, compensationType: 'supplier_message_level_receipt', deliveredAt, }, }); } private async recordReceiptConflict(input: { message: { id: string; tenantId?: string | null; applicationId?: string | null; status: string; messageId: string }; submitRecordId?: string; submitId?: string; receiptRecordId?: string; receiptKey: string; data: GatewayReceiptEventDto; }) { // One logical conflict per message attempt keeps repeated supplier packets auditable // without creating an unbounded queue of operationally identical anomalies. const anomalyKey = `aggregate-receipt-conflict:${input.message.id}:${input.submitId ?? input.submitRecordId ?? 'unknown'}`; const occurredAt = new Date(); const detail = { messageId: input.message.messageId, submitId: input.submitId, receiptKey: input.receiptKey, gatewayMessageId: input.data.gatewayMessageId, phoneNumber: input.data.phoneNumber, reason: input.message.status === 'delivered' ? 'message_level_success_followed_by_failure' : 'final_failure_followed_by_success', }; await this.prisma.smsReceiptAnomaly.upsert({ where: { anomalyKey }, update: { status: 'pending', receiptRecordId: input.receiptRecordId, incomingStatus: input.data.receiptStatus, rawStatus: input.data.rawStatus, errorCode: input.data.errorCode ?? null, detail, occurrenceCount: { increment: 1 }, lastOccurredAt: occurredAt, resolvedAt: null, resolutionNote: null, }, create: { anomalyKey, tenantId: input.message.tenantId, applicationId: input.message.applicationId, channelId: input.data.channelId, messageRecordId: input.message.id, submitRecordId: input.submitRecordId, receiptRecordId: input.receiptRecordId, 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, errorCode: input.data.errorCode ?? null, detail, firstOccurredAt: occurredAt, lastOccurredAt: occurredAt, }, }); } async recordReceiptSegment( message: { id: string; tenantId?: string | null; batchTaskId?: string | null; channelId?: string | null; submitId?: string | null; billingUnits?: number | null; }, data: GatewayReceiptEventDto, deliveredAt: Date, 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, deliveredAt, }, }); if (updated.count > 0) { return; } const submitRecord = source; await segmentAudits.upsert({ where: { messageRecordId_submitId_segmentIndex: { messageRecordId: message.id, submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`, segmentIndex: 1, }, }, update: { submitRecordId: submitRecord?.id ?? submitRecordId ?? null, channelId: data.channelId ?? message.channelId ?? null, sequenceId: protocolUint32ToDb(data.sequenceId) ?? null, gatewayMessageId: data.gatewayMessageId, receiptStatus: data.receiptStatus, rawStatus: data.rawStatus, errorCode: data.errorCode ?? null, deliveredAt, }, create: { tenantId: message.tenantId, batchTaskId: message.batchTaskId, messageRecordId: message.id, submitRecordId: submitRecord?.id ?? submitRecordId ?? null, channelId: data.channelId ?? message.channelId ?? null, submitId: submitRecord?.submitId ?? message.submitId ?? `RECEIPT-AUDIT-${data.gatewayMessageId}`, attempt: 0, segmentTotal: Math.max(1, Number(message.billingUnits ?? 1)), segmentIndex: 1, sequenceId: protocolUint32ToDb(data.sequenceId) ?? null, gatewayMessageId: data.gatewayMessageId, submitStatus: submitRecord?.submitStatus ?? 'accepted', receiptStatus: data.receiptStatus, rawStatus: data.rawStatus, compensationType: 'receipt_recovered', errorCode: data.errorCode ?? null, deliveredAt, }, }); } async aggregateReceiptSegments( message: { id: string; billingUnits?: number | null; }, data: GatewayReceiptEventDto, deliveredAt: Date, submitRecordId?: string, submitId?: string, ) { const audits = await this.facade.smsMessageSegmentAuditDelegate().findMany({ where: submitRecordId ? { messageRecordId: message.id, submitRecordId } : submitId ? { messageRecordId: message.id, submitId } : { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId }, orderBy: { segmentIndex: 'asc' }, }); return { ...aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt), segments: audits, }; } async resolveReceiptMessage( data: GatewayReceiptEventDto, incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string; }, ) { return resolveReceiptAttempt(this.prisma, data, incomingIdentity); } }