import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomUUID } from 'node:crypto'; import { moneyToNumber } from '../../common/money'; import { PrismaService } from '../../prisma/prisma.service'; import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; // R2 messages query domain. Method bodies are preserved byte-for-byte from the facade baseline. export class OperationsMessageQueries { constructor(private readonly prisma: PrismaService) {} listBatchTasks(query: { tenantId?: string; status?: string }) { return this.prisma.smsBatchTask.findMany({ where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' }, include: { apiRequests: true }, orderBy: { createdAt: 'desc' }, }); } async listClientBatchTasks(query: { tenantId?: string; status?: string }) { const items = await this.listBatchTasks(query); return items.map(clientBatchTaskView); } listMessages(query: MessageQuery) { return this.prisma.smsMessageRecord.findMany({ where: messageWhere(query), include: { tenant: true, application: true, channel: true, submitRecords: { include: { channel: true, channelGroup: true } }, receiptRecords: { include: { channel: true } }, downstreamDeliveries: { where: { deliveryType: 'receipt' }, select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, }, }, orderBy: { queuedAt: 'desc' }, }); } async listMessagesPage(query: MessageQuery) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25))); const where = messageWhere(query); const [items, total] = await Promise.all([ this.prisma.smsMessageRecord.findMany({ where, select: { id: true, tenantId: true, applicationId: true, channelId: true, messageId: true, phoneNumber: true, carrier: true, province: true, content: true, hasDrainageContent: true, drainageDetection: true, billingUnits: true, amountCents: true, status: true, submitStatus: true, queuedAt: true, tenant: { select: { id: true, name: true } }, application: { select: { id: true, name: true } }, channel: { select: { id: true, name: true } }, }, orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.smsMessageRecord.count({ where }), ]); return { items, total, page, pageSize }; } async getMessage(id: string) { const item = await this.prisma.smsMessageRecord.findUnique({ where: { id }, include: { tenant: { select: { id: true, name: true } }, application: { select: { id: true, name: true } }, channel: { select: { id: true, name: true, srcId: true } }, submitRecords: { select: { id: true, submitId: true, channelId: true, channelGroupId: true, channelGroupName: true, gatewayMessageId: true, submitStatus: true, submittedAt: true, createdAt: true, channel: { select: { id: true, name: true } }, channelGroup: { select: { id: true, name: true } }, }, }, receiptRecords: { select: { id: true, messageId: true, gatewayMessageId: true, receiptStatus: true, rawStatus: true, errorCode: true, errorMessage: true, deliveredAt: true, createdAt: true, channelId: true, channel: { select: { id: true, name: true } }, }, }, downstreamDeliveries: { where: { deliveryType: 'receipt' }, select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true }, }, }, }); if (!item) throw new NotFoundException('Message record not found'); return item; } async exportMessages(query: MessageQuery) { const items = await this.prisma.smsMessageRecord.findMany({ where: messageWhere(query), select: { messageId: true, queuedAt: true, phoneNumber: true, province: true, carrier: true, billingUnits: true, amountCents: true, status: true, submitStatus: true, deliveredAt: true, content: true, hasDrainageContent: true, tenant: { select: { name: true } }, application: { select: { name: true } }, channel: { select: { name: true } }, }, orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], }); const rows = [ ['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '是否含引流', '回执时间', '短信内容'], ...items.map((item) => [ item.messageId, item.tenant?.name ?? '', item.application?.name ?? '', item.queuedAt.toISOString(), item.phoneNumber, item.province ?? '', item.carrier ?? '', String(item.billingUnits), String(moneyToNumber(item.amountCents)), item.channel?.name ?? '', item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status, item.hasDrainageContent === true ? '是' : item.hasDrainageContent === false ? '否' : '未检测', item.deliveredAt?.toISOString() ?? '', item.content, ]), ]; return { fileName: `sms-records-${formatExportTimestamp(new Date())}.csv`, content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'), }; } async listClientMessages(query: MessageQuery) { const items = await this.listMessages(query); return items.map(clientMessageView); } async listClientMessagesPage(query: MessageQuery) { const result = await this.listMessagesPage(query); return { ...result, items: result.items.map(clientMessageView) }; } }