143 lines
6.3 KiB
TypeScript
143 lines
6.3 KiB
TypeScript
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { createHash } from 'node:crypto';
|
|
import { BillingService } from '../billing/billing.service';
|
|
import { moneyToNumber } from '../common/money';
|
|
import type { OpenApiService } from '../open-api/open-api.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
|
|
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
|
|
import type { SendSubmissionService } from './send-submission.service';
|
|
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
|
|
|
|
|
|
/**
|
|
* R10 accounting implementation.
|
|
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
|
|
*/
|
|
export class SendAccountingService {
|
|
private readonly logger = new Logger('SendChainService');
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly billing: BillingService,
|
|
private readonly openApi: OpenApiService | undefined,
|
|
private readonly facade: SendCompletionFacade,
|
|
private readonly callbacks: SendCompletionCallbacks,
|
|
) {}
|
|
|
|
async chargeAcceptedMessage(message: {
|
|
tenantId: string;
|
|
applicationId?: string | null;
|
|
batchTaskId: string;
|
|
messageId: string;
|
|
phoneNumber: string;
|
|
content: string;
|
|
billingUnits: number;
|
|
unitPrice: number | bigint;
|
|
amountCents: number | bigint;
|
|
}) {
|
|
const amountCents = moneyToNumber(message.amountCents);
|
|
const unitPrice = moneyToNumber(message.unitPrice);
|
|
const billingUnits = message.billingUnits ?? 0;
|
|
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
|
|
if (exists?.billingStatus === 'charged') {
|
|
return;
|
|
}
|
|
if (amountCents > 0) {
|
|
await this.billing.release({
|
|
tenantId: message.tenantId,
|
|
amountCents,
|
|
idempotencyKey: `sms-charge-release:${message.messageId}`,
|
|
relatedType: 'sms_batch_task',
|
|
relatedId: message.batchTaskId,
|
|
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
|
|
});
|
|
}
|
|
const transaction = await this.billing.charge({
|
|
tenantId: message.tenantId,
|
|
amountCents,
|
|
idempotencyKey: `sms-charge:${message.messageId}`,
|
|
relatedType: 'sms_message_record',
|
|
relatedId: message.messageId,
|
|
remark: '提交成功扣费',
|
|
});
|
|
const data = {
|
|
tenantId: message.tenantId,
|
|
applicationId: message.applicationId ?? undefined,
|
|
taskId: message.batchTaskId,
|
|
messageId: message.messageId,
|
|
phoneNumber: message.phoneNumber,
|
|
contentLength: [...message.content].length,
|
|
billingUnits,
|
|
unitPrice,
|
|
amountCents,
|
|
billingStatus: 'charged',
|
|
transactionId: transaction.id,
|
|
};
|
|
if (exists) {
|
|
await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data });
|
|
return;
|
|
}
|
|
await this.prisma.smsBillingRecord.create({ data });
|
|
}
|
|
|
|
async releaseMessageReservation(
|
|
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
|
remark: string,
|
|
) {
|
|
const amountCents = moneyToNumber(message.amountCents);
|
|
if (amountCents <= 0) {
|
|
return;
|
|
}
|
|
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
|
if (charged) {
|
|
return;
|
|
}
|
|
const released = await this.prisma.accountTransaction.findFirst({
|
|
where: { relatedType: 'sms_message_record', relatedId: message.messageId, transactionType: 'released' },
|
|
});
|
|
if (released) {
|
|
return;
|
|
}
|
|
await this.billing.release({
|
|
tenantId: message.tenantId,
|
|
amountCents,
|
|
idempotencyKey: `sms-reservation-release:${message.messageId}`,
|
|
relatedType: 'sms_message_record',
|
|
relatedId: message.messageId,
|
|
remark: `${remark}: ${message.messageId}`,
|
|
});
|
|
}
|
|
|
|
async refundMessage(
|
|
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
|
|
remark: string,
|
|
) {
|
|
const amountCents = moneyToNumber(message.amountCents);
|
|
if (amountCents <= 0) {
|
|
return;
|
|
}
|
|
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
|
|
if (refunded) {
|
|
return;
|
|
}
|
|
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
|
if (!charged) {
|
|
return;
|
|
}
|
|
const transaction = await this.billing.refund({
|
|
tenantId: message.tenantId,
|
|
amountCents,
|
|
idempotencyKey: `sms-refund:${message.messageId}`,
|
|
relatedType: 'sms_message_record',
|
|
relatedId: message.messageId,
|
|
remark,
|
|
});
|
|
await this.prisma.smsBillingRecord.updateMany({
|
|
where: { messageId: message.messageId },
|
|
data: { billingStatus: 'refunded', transactionId: transaction.id },
|
|
});
|
|
}
|
|
}
|