fix: harden upstream and downstream receipt delivery

This commit is contained in:
hectorzhao
2026-07-25 23:32:13 +08:00
parent 5018167696
commit 54617c927e
18 changed files with 1797 additions and 779 deletions
+409 -11
View File
@@ -89,6 +89,21 @@ export interface GatewaySubmitResultDto {
}>;
}
export interface GatewaySubmitSegmentResultDto {
traceId?: string;
messageId: string;
channelId: string;
submitId?: string;
segmentTotal: number;
segmentIndex: number;
sequenceId?: number;
gatewayMessageId?: string;
submitStatus: 'accepted' | 'rejected' | 'timeout' | string;
errorCode?: string;
errorMessage?: string;
submittedAt?: string;
}
export interface GatewayReceiptEventDto {
traceId?: string;
messageId?: string;
@@ -101,6 +116,7 @@ export interface GatewayReceiptEventDto {
errorCode?: string;
errorMessage?: string;
deliveredAt?: string;
connectionId?: string;
}
export interface GatewayUplinkEventDto {
@@ -269,6 +285,11 @@ const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000;
const DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS = 60_000;
const INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS = 10_000;
const DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS = 30;
const DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS = 5_000;
const UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS = 1_000;
const DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS = 2 * 60_000;
const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS = 30;
const DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS = 72;
const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60;
const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
priority: 1,
@@ -290,6 +311,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private scheduledDispatchScanRunning = false;
private inboundLongMessageInitialTimer?: ReturnType<typeof setTimeout>;
private inboundLongMessageIntervalTimer?: ReturnType<typeof setInterval>;
private upstreamReceiptInboxInitialTimer?: ReturnType<typeof setTimeout>;
private upstreamReceiptInboxIntervalTimer?: ReturnType<typeof setInterval>;
private upstreamReceiptInboxScanRunning = false;
constructor(
private readonly prisma: PrismaService,
@@ -342,6 +366,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
);
this.inboundLongMessageIntervalTimer.unref?.();
}
if (process.env.UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED !== 'false') {
this.upstreamReceiptInboxInitialTimer = setTimeout(
() => void this.runUpstreamReceiptInboxScan(),
UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS,
);
this.upstreamReceiptInboxInitialTimer.unref?.();
this.upstreamReceiptInboxIntervalTimer = setInterval(
() => void this.runUpstreamReceiptInboxScan(),
positiveInteger(
process.env.UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS,
DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS,
),
);
this.upstreamReceiptInboxIntervalTimer.unref?.();
}
}
async onModuleDestroy() {
@@ -351,6 +390,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer);
if (this.inboundLongMessageInitialTimer) clearTimeout(this.inboundLongMessageInitialTimer);
if (this.inboundLongMessageIntervalTimer) clearInterval(this.inboundLongMessageIntervalTimer);
if (this.upstreamReceiptInboxInitialTimer) clearTimeout(this.upstreamReceiptInboxInitialTimer);
if (this.upstreamReceiptInboxIntervalTimer) clearInterval(this.upstreamReceiptInboxIntervalTimer);
await this.worker?.close();
await this.sendQueue?.close();
await this.gatewayQueue?.close();
@@ -907,6 +948,46 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
}
async handleSubmitSegmentResult(data: GatewaySubmitSegmentResultDto) {
const message = await this.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const submittedAt = data.submittedAt ? new Date(data.submittedAt) : new Date();
await this.recordSubmitSegments(message, {
messageId: data.messageId,
channelId: data.channelId,
submitId: data.submitId,
sequenceId: data.sequenceId,
gatewayMessageId: data.gatewayMessageId ?? '',
submitStatus: normalizeSubmitStatus(data.submitStatus),
errorCode: data.errorCode,
errorMessage: data.errorMessage,
submittedAt: submittedAt.toISOString(),
segments: [{
segmentTotal: data.segmentTotal,
segmentIndex: data.segmentIndex,
sequenceId: data.sequenceId,
gatewayMessageId: data.gatewayMessageId,
submitStatus: data.submitStatus,
errorCode: data.errorCode,
errorMessage: data.errorMessage,
submittedAt: submittedAt.toISOString(),
}],
}, submittedAt);
if (data.gatewayMessageId) {
await this.prisma.smsSubmitRecord.updateMany({
where: {
...(data.submitId ? { submitId: data.submitId } : { messageRecordId: message.id }),
gatewayMessageId: null,
},
data: {
sequenceId: data.sequenceId,
gatewayMessageId: data.gatewayMessageId,
submittedAt,
},
});
}
return { accepted: true };
}
async handleSubmitResult(data: GatewaySubmitResultDto) {
const message = await this.requireMessageByGatewayEvent(data.messageId, data.gatewayMessageId);
const batchTask = message.batchTaskId
@@ -933,6 +1014,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (data.submitStatus === 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
await this.chargeAcceptedMessage(businessMessage);
const latest = await this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
if (latest?.status === 'failed') {
await this.refundMessage(businessMessage, '先到失败回执补偿退款');
}
} else if (data.submitStatus !== 'accepted' && !isStandaloneChannelTest && message.tenantId && message.batchTaskId) {
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
const retried = await this.retryMessageIfAllowed(businessMessage, data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发');
@@ -942,8 +1027,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
await this.releaseMessageReservation(businessMessage, data.submitStatus === 'timeout' ? '提交超时释放冻结' : '提交失败释放冻结');
}
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
const protectedTerminalStatuses = ['delivered', 'failed', 'unknown'];
const updated = await this.prisma.smsMessageRecord.updateMany({
where: data.submitStatus === 'accepted'
? { id: message.id, status: { notIn: protectedTerminalStatuses } }
: { id: message.id, status: { not: 'delivered' } },
data: {
gatewayMessageId: data.gatewayMessageId,
submitStatus: data.submitStatus,
@@ -954,6 +1042,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined,
},
});
if (updated.count === 0 && data.submitStatus === 'accepted') {
await this.prisma.smsMessageRecord.updateMany({
where: { id: message.id, gatewayMessageId: null },
data: {
gatewayMessageId: data.gatewayMessageId,
submittedAt,
},
});
}
if (data.submitStatus !== 'accepted' && batchTask?.sourceType === 'cmpp' && message.tenantId && message.applicationId) {
await this.recordCmppFailureReceipt(
message as typeof message & { tenantId: string; applicationId: string; batchTaskId: string },
@@ -981,8 +1078,185 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
}
async handleReceipt(data: GatewayReceiptEventDto) {
const resolved = await this.resolveReceiptMessage(data);
async intakeReceipt(data: GatewayReceiptEventDto) {
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 = this.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: data.sequenceId,
gatewayMessageId: data.gatewayMessageId,
phoneNumber: data.phoneNumber?.trim() || null,
receiptStatus: data.receiptStatus,
rawStatus: data.rawStatus,
errorCode: data.errorCode,
errorMessage: data.errorMessage,
deliveredAt,
status: 'pending',
nextRetryAt: new Date(),
},
});
if (['pending', 'retrying'].includes(inbox.status)) {
setImmediate(() => void this.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.processUpstreamReceiptInboxRecord(candidate.id)) processed += 1;
}
return { scanned: candidates.length, processed };
}
private 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.handleReceipt({
messageId: inbox.provisionalMessageId ?? undefined,
channelId: inbox.incomingChannelId,
connectionId: inbox.incomingConnectionId ?? undefined,
sequenceId: inbox.sequenceId ?? undefined,
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;
}
}
private async runUpstreamReceiptInboxScan() {
if (this.upstreamReceiptInboxScanRunning) return;
this.upstreamReceiptInboxScanRunning = true;
try {
const result = await this.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 },
) {
const resolved = await this.resolveReceiptMessage(data, incomingIdentity);
const logicalChannelId = resolved.channelId ?? data.channelId;
const receiptKey = this.receiptEventKey(data, logicalChannelId);
const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({
@@ -1202,6 +1476,32 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) {
const sentAt = asDateOrNull(data.sentAt) ?? new Date();
const ackDeadlineAt = asDateOrNull(data.ackDeadlineAt) ?? new Date(sentAt.getTime() + downstreamAckTimeoutMs());
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
if (!delivery) {
throw new NotFoundException('Downstream delivery not found');
}
const attemptKey = downstreamDeliveryAttemptKey(data);
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
where: { attemptKey },
update: {
connectionId: data.connectionId,
sequenceId: data.sequenceId,
messageId: data.messageId,
sentAt,
ackDeadlineAt,
},
create: {
deliveryId: data.id,
attemptKey,
attemptNo: delivery.retryCount + 1,
connectionId: data.connectionId,
sequenceId: data.sequenceId,
messageId: data.messageId,
status: 'awaiting_ack',
sentAt,
ackDeadlineAt,
},
});
await this.prisma.cmppDownstreamDelivery.updateMany({
where: { id: data.id, status: { not: 'delivered' } },
data: {
@@ -1221,7 +1521,48 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
const acknowledgedMessageId = String(data.messageId ?? '').trim();
if (data.result === 0 && acknowledgedMessageId !== '' && acknowledgedMessageId !== '0') {
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
if (!delivery) {
throw new NotFoundException('Downstream delivery not found');
}
const attemptKey = downstreamDeliveryAttemptKey(data);
const acknowledgementAccepted = data.result === 0 && acknowledgedMessageId !== '' && acknowledgedMessageId !== '0';
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
where: { attemptKey },
update: {
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
connectionId: data.connectionId,
sequenceId: data.sequenceId,
messageId: data.messageId,
acknowledgedAt,
ackResult: data.result,
ackDeadlineAt: null,
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
errorMessage: acknowledgementAccepted
? null
: data.result === 0
? 'CMPP_DELIVER_RESP Msg_Id=0'
: `CMPP_DELIVER_RESP result=${data.result}`,
},
create: {
deliveryId: data.id,
attemptKey,
attemptNo: delivery.retryCount + 1,
connectionId: data.connectionId,
sequenceId: data.sequenceId,
messageId: data.messageId,
status: acknowledgementAccepted ? 'acknowledged' : 'rejected',
acknowledgedAt,
ackResult: data.result,
failureType: acknowledgementAccepted ? null : data.result === 0 ? 'ack_invalid' : 'ack_rejected',
errorMessage: acknowledgementAccepted
? null
: data.result === 0
? 'CMPP_DELIVER_RESP Msg_Id=0'
: `CMPP_DELIVER_RESP result=${data.result}`,
},
});
if (acknowledgementAccepted) {
await this.prisma.cmppDownstreamDelivery.updateMany({
where: { id: data.id, status: { not: 'delivered' } },
data: {
@@ -1255,7 +1596,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected');
}
async markDownstreamDeliveryFailed(id: string, errorMessage?: string, failureType: GatewayDownstreamFailureType = 'send_failed') {
async markDownstreamDeliveryFailed(
id: string,
errorMessage?: string,
failureType: GatewayDownstreamFailureType = 'send_failed',
attempt?: GatewayDownstreamSentDto,
) {
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id } });
if (!delivery) {
throw new NotFoundException('Downstream delivery not found');
@@ -1272,6 +1618,33 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const nonRetryableFailure = failureType === 'unrecoverable' || failureType === 'queue_timeout';
const finalFailure = nonRetryableFailure || !retryAllowed || retryCount >= downstreamMaxRetries();
const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed';
if (attempt && (attempt.connectionId || attempt.sequenceId || attempt.messageId)) {
const attemptKey = downstreamDeliveryAttemptKey({ ...attempt, id });
await this.prisma.cmppDownstreamDeliveryAttempt.upsert({
where: { attemptKey },
update: {
status: 'failed',
connectionId: attempt.connectionId,
sequenceId: attempt.sequenceId,
messageId: attempt.messageId,
failureType,
errorMessage: errorMessage ?? 'downstream delivery failed',
ackDeadlineAt: null,
},
create: {
deliveryId: id,
attemptKey,
attemptNo: delivery.retryCount + 1,
connectionId: attempt.connectionId,
sequenceId: attempt.sequenceId,
messageId: attempt.messageId,
status: 'failed',
sentAt: asDateOrNull(attempt.sentAt),
failureType,
errorMessage: errorMessage ?? 'downstream delivery failed',
},
});
}
const updated = await this.prisma.cmppDownstreamDelivery.update({
where: { id },
data: {
@@ -1914,11 +2287,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
) as GatewayControlDeliveryResult;
if (result.sent || result.delivered) {
await this.markDownstreamDeliverySent({ id: delivery.id, ...result });
} else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') {
return delivery;
} else {
await this.markDownstreamDeliveryFailed(
delivery.id,
downstreamControlFailureMessage(result),
result.retryable === false ? 'unrecoverable' : 'send_failed',
{ id: delivery.id, ...result },
);
}
} catch (error) {
@@ -3831,7 +4207,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return message;
}
private async resolveReceiptMessage(data: GatewayReceiptEventDto) {
private async resolveReceiptMessage(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
) {
const exactMessage = data.messageId
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } })
: null;
@@ -3896,7 +4275,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
throw new NotFoundException('SMS message record not found');
}
const incomingChannel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
const incomingChannel = incomingIdentity
?? await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
if (!incomingChannel) {
throw new NotFoundException('SMS message record not found');
}
@@ -3920,7 +4300,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
};
}
const sameSupplierSegments = segmentMatches.filter((candidate) =>
candidate.channel && this.isSameSupplierConnection(incomingChannel, candidate.channel));
candidate.channel && this.isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
return {
message: sameSupplierSegments[0].messageRecord,
@@ -3940,7 +4320,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
take: 10,
});
const sameSupplierSubmits = crossConnectionSubmits.filter((candidate) =>
candidate.channel && this.isSameSupplierConnection(incomingChannel, candidate.channel));
candidate.channel && this.isSameUpstreamEndpointIdentity(incomingChannel, candidate.channel));
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
return {
message: sameSupplierSubmits[0].messageRecord,
@@ -3988,7 +4368,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
};
}
private isSameSupplierConnection(
private isSameUpstreamEndpointIdentity(
left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
) {
@@ -4341,6 +4721,24 @@ function parseOptionalSequenceId(value: string | null | undefined) {
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff ? parsed : undefined;
}
function normalizeSubmitStatus(value: string): GatewaySubmitResultDto['submitStatus'] {
return value === 'accepted' || value === 'rejected' || value === 'timeout' ? value : 'timeout';
}
function normalizeReceiptStatus(value: string): GatewayReceiptEventDto['receiptStatus'] {
return value === 'delivered' || value === 'undelivered' || value === 'unknown' ? value : 'unknown';
}
function downstreamDeliveryAttemptKey(data: GatewayDownstreamSentDto) {
return createHash('sha256').update([
data.id,
data.connectionId ?? '',
data.sequenceId ?? '',
data.messageId ?? '',
data.sequenceId ? '' : data.sentAt ?? '',
].join('\u0000')).digest('hex');
}
function shanghaiDateKey(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',