Files
lislgosms/api/src/send-chain/send-receipt.service.ts
T
hectorzhao 001d5f2cbd
CSS quality / css-quality (push) Has been cancelled
fix: 修复 CMPP 协议字段容量与版本兼容性
2026-09-20 15:49:37 +08:00

656 lines
25 KiB
TypeScript

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);
}
}