import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { moneyToNumber } from '../common/money'; import { randomUUID } from 'node:crypto'; export interface MessageQuery { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number; } export interface TraceQuery extends MessageQuery { messageId?: string; } export interface OperationLogQuery { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number; } export interface GatewaySubmitDeadLetterQuery { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number; } export interface DownstreamDeliveryQuery { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number; createdAtFrom?: string; createdAtTo?: string; } export interface DownstreamDeliveryDashboardQuery { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string; } export interface DownstreamRecoveryStatusQuery { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; updatedAtFrom?: string; updatedAtTo?: string; page?: number; pageSize?: number; } export interface MessageSegmentAuditQuery { messageId?: string; messageRecordId?: string; } export interface SignatureQualityQuery { date?: string; keyword?: string; page?: number; pageSize?: number; } @Injectable() export class OperationsService { 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, 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 }, }, }, orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.smsMessageRecord.count({ where }), ]); return { items, total, page, pageSize }; } 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, 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.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) }; } listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { return this.prisma.smsUplinkMessage.findMany({ where: { tenantId: query.tenantId, channelId: query.channelId, applicationId: query.applicationId, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, content: query.keyword ? { contains: query.keyword } : undefined, receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined } : undefined, }, include: { tenant: true, application: true, channel: true, messageRecord: { include: { application: true } }, matchCandidates: { include: { tenant: true, application: true, messageRecord: { include: { application: true, tenant: true, channel: true } }, }, orderBy: [{ status: 'asc' }, { confidence: 'desc' }, { createdAt: 'asc' }], }, }, orderBy: { receivedAt: 'desc' }, skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined, take: query.pageSize ?? 500, }); } async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { const items = await this.listUplinkMessages(query); return items.map(clientUplinkView); } async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.SmsUplinkMessageWhereInput = { tenantId: query.tenantId, channelId: query.channelId, applicationId: query.applicationId, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, content: query.keyword ? { contains: query.keyword } : undefined, receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined, } : undefined, }; const [rawItems, total] = await Promise.all([ this.listUplinkMessages({ ...query, page, pageSize }), this.prisma.smsUplinkMessage.count({ where }), ]); return { items: clientView ? rawItems.map(clientUplinkView) : rawItems, total, page, pageSize, }; } async monitor(query: { tenantId?: string; channelId?: string }) { const where = messageWhere(query); const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([ this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }), this.prisma.smsMessageRecord.findMany({ where, include: { submitRecords: true, receiptRecords: true }, orderBy: { queuedAt: 'desc' }, take: 20, }), this.prisma.smsReceiptRecord.findMany({ where: { tenantId: query.tenantId, channelId: query.channelId }, orderBy: { createdAt: 'desc' }, take: 20, }), this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }), ]); return { byStatus, recentMessages, recentReceipts, recentUplinks: recentUplinks.slice(0, 20), }; } async dashboard(query: { tenantId?: string }) { const businessDay = qualityBusinessDay(); const sinceToday = businessDay.startAt; const downstreamAlertWindow = downstreamAlertWindows(); const messageWhereClause = messageWhere({ tenantId: query.tenantId }); const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday, lt: businessDay.endAt }, }; const [ taskCount, messageGroups, todayMessageGroups, uplinkCount, billingAggregate, transactionAggregate, connectionGroups, pendingAudits, tenantAccounts, recentTasks, recentRecharges, enterpriseSpendRows, downstreamPendingCount, downstreamFailedCount, downstreamDeliveredCount, downstreamStalledPendingCount, downstreamStalledAckCount, downstreamRecentFailedCount, hourlySendRows, auditSpeedRows, ] = await Promise.all([ this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }), this.prisma.smsMessageRecord.groupBy({ by: ['status'], where: messageWhereClause, _count: { _all: true }, _sum: { amountCents: true, billingUnits: true }, }), this.prisma.smsMessageRecord.groupBy({ by: ['status'], where: todayMessageWhereClause, _count: { _all: true }, _sum: { amountCents: true, billingUnits: true }, }), this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }), this.prisma.smsBillingRecord.aggregate({ where: { tenantId: query.tenantId }, _sum: { amountCents: true, billingUnits: true }, _count: { _all: true }, }), this.prisma.accountTransaction.aggregate({ where: returnedTransactionWhere(sinceToday, query.tenantId), _sum: { amountCents: true }, _count: { _all: true }, }), this.prisma.cmppConnectionState.groupBy({ by: ['status'], where: { tenantId: query.tenantId }, _count: { _all: true }, _sum: { currentConnections: true, desiredConnections: true }, }), this.countPendingAudits(query.tenantId), this.prisma.tenantAccount.findMany({ where: query.tenantId ? { tenantId: query.tenantId } : undefined, include: { tenant: true }, orderBy: { updatedAt: 'desc' }, take: 20, }), this.prisma.smsBatchTask.findMany({ where: query.tenantId ? { tenantId: query.tenantId } : undefined, include: { application: true, messages: { take: 1, include: { channel: true } } }, orderBy: { createdAt: 'desc' }, take: 10, }), this.prisma.rechargeOrder.findMany({ where: { tenantId: query.tenantId, payMethod: 'manual_topup', }, include: { tenant: true }, orderBy: { createdAt: 'desc' }, take: 10, }), this.prisma.$queryRaw>(Prisma.sql` SELECT tenant.id AS "tenantId", tenant.name AS "tenantName", COALESCE(SUM(billing."amountCents") FILTER (WHERE billing."billingStatus" = 'charged'), 0)::bigint AS "todaySpendCents", account."balanceCents" AS "balanceCents", account."creditCents" AS "creditCents" FROM "TenantAccount" account JOIN "Tenant" tenant ON tenant.id = account."tenantId" LEFT JOIN "SmsBillingRecord" billing ON billing."tenantId" = tenant.id AND billing."createdAt" >= ${businessDay.startAt} AND billing."createdAt" < ${businessDay.endAt} WHERE tenant.status <> 'deleted' AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null}) GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents" ORDER BY "todaySpendCents" DESC, tenant.name ASC `), this.prisma.cmppDownstreamDelivery.count({ where: { tenantId: query.tenantId, status: 'pending' }, }), this.prisma.cmppDownstreamDelivery.count({ where: { tenantId: query.tenantId, status: 'failed' }, }), this.prisma.cmppDownstreamDelivery.count({ where: { tenantId: query.tenantId, status: 'delivered' }, }), this.prisma.cmppDownstreamDelivery.count({ where: { tenantId: query.tenantId, ...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt), }, }), this.prisma.cmppDownstreamDelivery.count({ where: { tenantId: query.tenantId, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now }, }, }), this.prisma.cmppDownstreamDelivery.count({ where: { tenantId: query.tenantId, status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, }, }), this.prisma.$queryRaw>(Prisma.sql` SELECT EXTRACT(HOUR FROM message."queuedAt" AT TIME ZONE 'Asia/Shanghai')::integer AS hour, COUNT(*)::bigint AS "submittedCount", COUNT(*) FILTER (WHERE message.status = 'delivered')::bigint AS "successCount" FROM "SmsMessageRecord" message WHERE message."queuedAt" >= ${businessDay.startAt} AND message."queuedAt" < ${businessDay.endAt} AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null}) GROUP BY 1 ORDER BY 1 `), // Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit. this.prisma.$queryRaw>(Prisma.sql` WITH review_samples AS ( SELECT 'enterpriseCertifications'::text AS category, certification."submittedAt" AS "submittedAt", certification."reviewedAt" AS "reviewedAt" FROM "EnterpriseCertification" certification WHERE certification."reviewedAt" >= ${businessDay.startAt} AND certification."reviewedAt" < ${businessDay.endAt} AND (${query.tenantId ?? null}::text IS NULL OR certification."tenantId" = ${query.tenantId ?? null}) UNION ALL SELECT 'smsAudits'::text, task."createdAt", task."reviewedAt" FROM "SmsSendTask" task WHERE task."reviewedAt" >= ${businessDay.startAt} AND task."reviewedAt" < ${businessDay.endAt} AND (${query.tenantId ?? null}::text IS NULL OR task."tenantId" = ${query.tenantId ?? null}) UNION ALL SELECT 'drainageInfos'::text, drainage."submittedAt", drainage."reviewedAt" FROM "SmsDrainageInfo" drainage WHERE drainage."reviewedAt" >= ${businessDay.startAt} AND drainage."reviewedAt" < ${businessDay.endAt} AND (${query.tenantId ?? null}::text IS NULL OR drainage."tenantId" = ${query.tenantId ?? null}) UNION ALL SELECT CASE review."targetType" WHEN 'sms_signature' THEN 'signatures' WHEN 'sms_template' THEN 'templates' END, submission."createdAt", review."createdAt" FROM "AuditRecord" review JOIN LATERAL ( SELECT pending."createdAt" FROM "AuditRecord" pending WHERE pending."targetType" = review."targetType" AND pending."targetId" = review."targetId" AND pending."statusAfter" = 'pending' AND pending."createdAt" <= review."createdAt" ORDER BY pending."createdAt" DESC LIMIT 1 ) submission ON true WHERE review."targetType" IN ('sms_signature', 'sms_template') AND review."statusBefore" = 'pending' AND review."statusAfter" IN ('approved', 'rejected') AND review."createdAt" >= ${businessDay.startAt} AND review."createdAt" < ${businessDay.endAt} AND (${query.tenantId ?? null}::text IS NULL OR review."tenantId" = ${query.tenantId ?? null}) ) SELECT category, COUNT(*)::bigint AS count, ROUND(AVG(EXTRACT(EPOCH FROM ("reviewedAt" - "submittedAt")) * 1000))::bigint AS "averageProcessingMs" FROM review_samples WHERE "reviewedAt" >= "submittedAt" GROUP BY category `), ]); const todayTotals = summarizeMessageGroups(todayMessageGroups); const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row])); // Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data. const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => { const row = hourlyRowsByHour.get(hour); return { hour, label: `${String(hour).padStart(2, '0')}:00`, submittedCount: Number(row?.submittedCount ?? 0), successCount: Number(row?.successCount ?? 0), }; }); const auditSpeedByCategory = new Map(auditSpeedRows.map((row) => [row.category, row])); const auditProcessingSpeed = [ ['enterpriseCertifications', '企业认证'], ['smsAudits', '短信审核'], ['templates', '模板'], ['signatures', '签名'], ['drainageInfos', '引流信息'], ].map(([category, label]) => { const row = auditSpeedByCategory.get(category); return { category, label, count: Number(row?.count ?? 0), averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs), }; }); const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount; return { taskCount, messageStatus: messageGroups, today: { sent: todayTotals.total, delivered: todayTotals.delivered, failed: todayTotals.failed, unknown: todayTotals.unknown, successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0, spendCents: todayTotals.amountCents, returnedCents: moneyToNumber(transactionAggregate._sum.amountCents), billingUnits: todayTotals.billingUnits, }, uplinkCount, billing: billingAggregate, transactions: transactionAggregate, gatewayConnections: connectionGroups, pendingAuditCount: pendingAudits.total, pendingAudits, hourlySendTrend, auditProcessingSpeed, downstreamDeliverySummary: { pending: downstreamPendingCount, failed: downstreamFailedCount, delivered: downstreamDeliveredCount, stalledPending: downstreamStalledPendingCount, stalledAck: downstreamStalledAckCount, recentFailed: downstreamRecentFailedCount, alertCount: downstreamAlertCount, }, accounts: tenantAccounts, enterpriseSpendRanks: enterpriseSpendRows.map((row) => ({ tenantId: row.tenantId, tenantName: row.tenantName, todaySpendCents: moneyToNumber(row.todaySpendCents), balanceCents: moneyToNumber(row.balanceCents), creditCents: moneyToNumber(row.creditCents), })), recentTasks, recentRecharges, }; } async clientDashboard(query: { tenantId?: string }) { const dashboard = await this.dashboard(query); return { taskCount: dashboard.taskCount, messageStatus: dashboard.messageStatus, today: dashboard.today, uplinkCount: dashboard.uplinkCount, billing: dashboard.billing, transactions: dashboard.transactions, gatewayConnections: [], pendingAuditCount: dashboard.pendingAuditCount, pendingAudits: dashboard.pendingAudits, hourlySendTrend: dashboard.hourlySendTrend, auditProcessingSpeed: dashboard.auditProcessingSpeed, downstreamDeliverySummary: dashboard.downstreamDeliverySummary, accounts: dashboard.accounts.map(clientAccountView), enterpriseSpendRanks: dashboard.enterpriseSpendRanks, recentTasks: dashboard.recentTasks.map(clientBatchTaskView), recentRecharges: dashboard.recentRecharges.map(clientRechargeView), }; } async statistics(query: { tenantId?: string; groupBy?: string }) { const groupBy = normalizeGroupBy(query.groupBy); if (groupBy === 'tenantId') { return this.prisma.smsMessageRecord.groupBy({ by: ['tenantId'], where: messageWhere({ tenantId: query.tenantId }), _count: { _all: true }, _sum: { amountCents: true, billingUnits: true }, }); } if (groupBy === 'applicationId') { return this.prisma.smsMessageRecord.groupBy({ by: ['applicationId'], where: messageWhere({ tenantId: query.tenantId }), _count: { _all: true }, _sum: { amountCents: true, billingUnits: true }, }); } return this.prisma.smsMessageRecord.groupBy({ by: ['channelId'], where: messageWhere({ tenantId: query.tenantId }), _count: { _all: true }, _sum: { amountCents: true, billingUnits: true }, }); } async sendQuality(date?: string) { const day = qualityBusinessDay(date); const [channels, signatures, summaryRows, applications] = await Promise.all([ this.prisma.$queryRaw>(Prisma.sql` WITH base AS ( SELECT submit."channelId" AS channel_id, channel.name AS channel_name, submit."submitStatus" AS submit_status, receipt."deliveredAt" AS delivered_at, failed_receipt."failedAt" AS failed_at, COALESCE(segment_summary.segment_count, 0) AS segment_count, COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count, COALESCE(segment_summary.failure_count, 0) AS segment_failure_count, CASE WHEN segment_summary.segment_count > 0 AND segment_summary.delivered_count = segment_summary.segment_count AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt") THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 WHEN segment_summary.segment_count = 0 AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt") THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms FROM "SmsSubmitRecord" submit JOIN "SmsChannel" channel ON channel.id = submit."channelId" LEFT JOIN LATERAL ( SELECT COUNT(*)::integer AS segment_count, COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at FROM "SmsMessageSegmentAudit" segment WHERE segment."submitRecordId" = submit.id ) segment_summary ON TRUE LEFT JOIN LATERAL ( SELECT MIN(receipt."deliveredAt") AS "deliveredAt" FROM "SmsReceiptRecord" receipt WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" AND receipt."channelId" = submit."channelId" AND receipt."receiptStatus" = 'delivered' ) receipt ON TRUE LEFT JOIN LATERAL ( SELECT MIN(receipt."deliveredAt") AS "failedAt" FROM "SmsReceiptRecord" receipt WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" AND receipt."channelId" = submit."channelId" AND receipt."receiptStatus" = 'undelivered' ) failed_receipt ON TRUE WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout') AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} ), classified AS ( SELECT *, CASE WHEN submit_status <> 'accepted' THEN 'submit_failed' WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure' WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success' WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure' WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success' ELSE 'unknown' END AS delivery_status FROM base ) SELECT channel_id AS "channelId", MAX(channel_name) AS "channelName", COUNT(*)::integer AS total, COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount", COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount", CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'submit_failed') * 100.0 / COUNT(*), 1)::double precision END AS "submitFailureRate", COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount", COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount", COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount", CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'success') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "successRate", CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'unknown') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "unknownRate", CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'failure') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "failureRate", ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" FROM classified GROUP BY channel_id ORDER BY COUNT(*) DESC, channel_id `), this.prisma.$queryRaw>(Prisma.sql` WITH base AS ( SELECT message."signatureId" AS signature_id, (message."drainageInfoId" IS NOT NULL) AS has_drainage, message.status, message."submitStatus" AS submit_status, message."receiptStatus" AS receipt_status, CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt" THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms FROM "SmsMessageRecord" message WHERE message."signatureId" IS NOT NULL AND message."queuedAt" >= ${day.startAt} AND message."queuedAt" < ${day.endAt} ) SELECT signature.id || ':' || CASE WHEN base.has_drainage THEN 'drainage' ELSE 'plain' END AS id, signature.id AS "signatureId", signature.name AS "signatureName", tenant.id AS "tenantId", tenant.name AS "tenantName", base.has_drainage AS "hasDrainage", COUNT(*)::integer AS total, COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') )::integer AS "acceptedCount", COUNT(*) FILTER ( WHERE base.status = 'submit_failed' OR base.submit_status IN ('rejected', 'timeout') )::integer AS "submitFailureCount", COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "unknownCount", COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "failureCount", CASE WHEN COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') ) = 0 THEN 0 ELSE ROUND( COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') * 100.0 / COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') ), 1 )::double precision END AS "successRate", ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" FROM base JOIN "SmsSignature" signature ON signature.id = base.signature_id JOIN "Tenant" tenant ON tenant.id = signature."tenantId" GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage ORDER BY "successCount" DESC, total DESC, signature.name `), this.prisma.$queryRaw>(Prisma.sql` WITH base AS ( SELECT message.status, message."receiptStatus" AS receipt_status FROM "SmsMessageRecord" message WHERE message."queuedAt" >= ${day.startAt} AND message."queuedAt" < ${day.endAt} AND COALESCE(message.status, '') <> 'rejected' ) SELECT COUNT(*)::integer AS total, COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", COUNT(*) FILTER ( WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "unknownCount", COUNT(*) FILTER ( WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "failureCount", CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND( COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') * 100.0 / COUNT(*), 1 )::double precision END AS "successRate" FROM base `), this.prisma.$queryRaw>(Prisma.sql` WITH base AS ( SELECT message."applicationId" AS application_id, message.status, message."receiptStatus" AS receipt_status FROM "SmsMessageRecord" message WHERE message."applicationId" IS NOT NULL AND message."queuedAt" >= ${day.startAt} AND message."queuedAt" < ${day.endAt} AND COALESCE(message.status, '') <> 'rejected' ) SELECT application.id AS "applicationId", application.name AS "applicationName", tenant.id AS "tenantId", tenant.name AS "tenantName", COUNT(*)::integer AS total, COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", COUNT(*) FILTER ( WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "unknownCount", COUNT(*) FILTER ( WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "failureCount", CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND( COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') * 100.0 / COUNT(*), 1 )::double precision END AS "successRate" FROM base JOIN "SmsApplication" application ON application.id = base.application_id JOIN "Tenant" tenant ON tenant.id = application."tenantId" GROUP BY application.id, application.name, tenant.id, tenant.name ORDER BY total DESC, application.name `), ]); const summary = summaryRows[0] ?? { total: 0, successCount: 0, unknownCount: 0, failureCount: 0, successRate: 0, }; return { date: day.key, summary, channels, signatures, applications }; } async signatureQuality(query: SignatureQualityQuery) { const day = qualityBusinessDay(query.date); const page = positiveInteger(query.page, 1); const pageSize = Math.min(50, positiveInteger(query.pageSize, 10)); const keyword = query.keyword?.trim() || null; const keywordPattern = keyword ? `%${keyword}%` : null; const summaries = await this.prisma.$queryRaw>(Prisma.sql` WITH base AS ( SELECT message."signatureId" AS signature_id, message."applicationId" AS application_id, message.status, message."submitStatus" AS submit_status, message."receiptStatus" AS receipt_status, CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt" THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms FROM "SmsMessageRecord" message WHERE message."signatureId" IS NOT NULL AND message."queuedAt" >= ${day.startAt} AND message."queuedAt" < ${day.endAt} ) SELECT signature.id AS "signatureId", signature.name AS "signatureName", tenant.id AS "tenantId", tenant.name AS "tenantName", STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames", COUNT(*)::integer AS total, COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') )::integer AS "acceptedCount", COUNT(*) FILTER ( WHERE base.status = 'submit_failed' OR base.submit_status IN ('rejected', 'timeout') )::integer AS "submitFailureCount", COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "unknownCount", COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "failureCount", CASE WHEN COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') ) = 0 THEN 0 ELSE ROUND( COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') * 100.0 / COUNT(*) FILTER ( WHERE COALESCE(base.status, '') <> 'submit_failed' AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') ), 1 )::double precision END AS "successRate", ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs", COUNT(*) OVER()::integer AS "rowCount" FROM base JOIN "SmsSignature" signature ON signature.id = base.signature_id JOIN "Tenant" tenant ON tenant.id = signature."tenantId" LEFT JOIN "SmsApplication" application ON application.id = base.application_id WHERE ( ${keyword}::text IS NULL OR signature.name ILIKE ${keywordPattern} OR tenant.name ILIKE ${keywordPattern} OR application.name ILIKE ${keywordPattern} ) GROUP BY signature.id, signature.name, tenant.id, tenant.name ORDER BY total DESC, signature.name LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize} `); const signatureIds = summaries.map((item) => item.signatureId); const breakdowns = signatureIds.length === 0 ? [] : await this.prisma.$queryRaw>(Prisma.sql` WITH base AS ( SELECT message."signatureId" AS signature_id, submit."channelId" AS channel_id, channel.name AS channel_name, COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, submit."submitStatus" AS submit_status, receipt."deliveredAt" AS delivered_at, failed_receipt."failedAt" AS failed_at, COALESCE(segment_summary.segment_count, 0) AS segment_count, COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count, COALESCE(segment_summary.failure_count, 0) AS segment_failure_count, CASE WHEN segment_summary.segment_count > 0 AND segment_summary.delivered_count = segment_summary.segment_count AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt") THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 WHEN segment_summary.segment_count = 0 AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt") THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms FROM "SmsSubmitRecord" submit JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" JOIN "SmsChannel" channel ON channel.id = submit."channelId" LEFT JOIN LATERAL ( SELECT COUNT(*)::integer AS segment_count, COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at FROM "SmsMessageSegmentAudit" segment WHERE segment."submitRecordId" = submit.id ) segment_summary ON TRUE LEFT JOIN LATERAL ( SELECT MIN(receipt."deliveredAt") AS "deliveredAt" FROM "SmsReceiptRecord" receipt WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" AND receipt."channelId" = submit."channelId" AND receipt."receiptStatus" = 'delivered' ) receipt ON TRUE LEFT JOIN LATERAL ( SELECT MIN(receipt."deliveredAt") AS "failedAt" FROM "SmsReceiptRecord" receipt WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" AND receipt."channelId" = submit."channelId" AND receipt."receiptStatus" = 'undelivered' ) failed_receipt ON TRUE WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout') AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} ), classified AS ( SELECT *, CASE WHEN submit_status <> 'accepted' THEN 'submit_failed' WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure' WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success' WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure' WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success' ELSE 'unknown' END AS delivery_status FROM base ) SELECT signature_id AS "signatureId", channel_id AS "channelId", MAX(channel_name) AS "channelName", carrier, COUNT(*)::integer AS total, COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount", COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount", COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount", COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount", COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount", CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND( COUNT(*) FILTER (WHERE delivery_status = 'success') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1 )::double precision END AS "successRate", ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs" FROM classified GROUP BY signature_id, channel_id, carrier ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier `); const carrierOverview = signatureIds.length === 0 ? [] : await this.prisma.$queryRaw>(Prisma.sql` SELECT message."signatureId" AS "signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, COUNT(*)::integer AS "businessMessageCount", COUNT(*) FILTER ( WHERE message.status = 'delivered' OR message."receiptStatus" = 'delivered' )::integer AS "finalSuccessCount", CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND( COUNT(*) FILTER ( WHERE message.status = 'delivered' OR message."receiptStatus" = 'delivered' ) * 100.0 / COUNT(*), 1 )::double precision END AS "finalSuccessRate", ROUND(AVG( CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt" THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END ))::integer AS "averageArrivalMs" FROM "SmsMessageRecord" message WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) AND message."queuedAt" >= ${day.startAt} AND message."queuedAt" < ${day.endAt} GROUP BY message."signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown') ORDER BY message."signatureId", COUNT(*) DESC, carrier `); const items = summaries.map(({ rowCount: _rowCount, ...summary }) => { const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId); return { ...summary, channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0), carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId), breakdowns: signatureBreakdowns, }; }); return { date: day.key, items, total: summaries[0]?.rowCount ?? 0, page, pageSize, }; } async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) { const page = positiveInteger(query.page, 1); const pageSize = Math.min(100, positiveInteger(query.pageSize, 20)); const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId }; const [items, total] = await Promise.all([ this.prisma.operationLog.findMany({ where, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.operationLog.count({ where }), ]); return { items, total, page, pageSize }; } async systemLogs(query: OperationLogQuery) { const page = positiveInteger(query.page, 1); const pageSize = Math.min(100, positiveInteger(query.pageSize, 10)); const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId, createdAt: createdAtRange(query.range), resource: query.module && query.module !== 'all' ? query.module : undefined, AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined, OR: query.keyword ? [ { action: { contains: query.keyword } }, { resource: { contains: query.keyword } }, { resourceId: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, { user: { displayName: { contains: query.keyword } } }, { user: { username: { contains: query.keyword } } }, ] : undefined, }; const [items, total, modules] = await Promise.all([ this.prisma.operationLog.findMany({ where, include: { tenant: true, user: true }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.operationLog.count({ where }), this.prisma.operationLog.groupBy({ by: ['resource'], where: { tenantId: query.tenantId }, _count: { _all: true }, orderBy: { resource: 'asc' }, }), ]); return { items: items.map((item) => normalizeOperationLog(item)), total, page, pageSize, modules: modules.map((item) => item.resource), }; } async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) { const clientTenantId = clientUserId ? await this.resolveClientTenantId(clientUserId) : undefined; const effectiveQuery = { ...query, tenantId: clientTenantId ?? query.tenantId }; const where: Prisma.OperationLogWhereInput = { tenantId: effectiveQuery.tenantId, userId: effectiveQuery.userId, createdAt: createdAtRange(effectiveQuery.range), resource: effectiveQuery.module && effectiveQuery.module !== 'all' ? effectiveQuery.module : undefined, AND: effectiveQuery.level && effectiveQuery.level !== 'all' ? operationLogLevelWhere(effectiveQuery.level) : undefined, OR: effectiveQuery.keyword ? [ { action: { contains: effectiveQuery.keyword } }, { resource: { contains: effectiveQuery.keyword } }, { resourceId: { contains: effectiveQuery.keyword } }, { tenant: { name: { contains: effectiveQuery.keyword } } }, { user: { displayName: { contains: effectiveQuery.keyword } } }, { user: { username: { contains: effectiveQuery.keyword } } }, ] : undefined, }; const rows = await this.prisma.operationLog.findMany({ where, include: { tenant: true, user: true }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: 10_001, }); const truncated = rows.length > 10_000; const exportedRows = rows.slice(0, 10_000).map(normalizeOperationLog); const clientExport = Boolean(clientUserId); const headers = clientExport ? ['时间', '级别', '模块', '操作人', '动作', '资源ID'] : ['时间', '级别', '企业', '模块', '操作人', '动作', '资源ID', '详情', 'IP']; const values = exportedRows.map((item) => clientExport ? [item.time, item.level, item.module, item.operator, item.action, item.resourceId] : [item.time, item.level, item.tenant, item.module, item.operator, item.action, item.resourceId, JSON.stringify(item.detail), item.ip]); return { operationId: randomUUID(), status: 'completed' as const, fileName: `system-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`, recordCount: exportedRows.length, truncated, content: [headers, ...values].map((row) => row.map((cell) => escapeCsvCell(String(cell ?? ''))).join(',')).join('\n'), filters: { keyword: effectiveQuery.keyword, level: effectiveQuery.level, module: effectiveQuery.module, range: effectiveQuery.range }, }; } private async resolveClientTenantId(userId: string) { const user = await this.prisma.user.findFirst({ where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } }, select: { tenantId: true }, }); if (!user?.tenantId) throw new NotFoundException('Client tenant not found'); return user.tenantId; } async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) { const page = Math.max(1, Number(query.page ?? 1)); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); const baseWhere: Prisma.GatewaySubmitDeadLetterWhereInput = { tenantId: query.tenantId, applicationId: query.applicationId, channelId: query.channelId, OR: query.keyword ? [ { streamMessageId: { contains: query.keyword } }, { traceId: { contains: query.keyword } }, { messageId: { contains: query.keyword } }, { submitId: { contains: query.keyword } }, { failureCode: { contains: query.keyword } }, { failureMessage: { contains: query.keyword } }, ] : undefined, }; const where: Prisma.GatewaySubmitDeadLetterWhereInput = { ...baseWhere, status: query.status && query.status !== 'all' ? query.status : undefined, }; const [items, total, statusGroups, oldestPending] = await Promise.all([ this.prisma.gatewaySubmitDeadLetter.findMany({ where, include: { tenant: true, application: true, channel: true }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.gatewaySubmitDeadLetter.count({ where }), this.prisma.gatewaySubmitDeadLetter.groupBy({ by: ['status'], where: baseWhere, _count: { _all: true }, }), this.prisma.gatewaySubmitDeadLetter.findFirst({ where: { ...baseWhere, status: 'pending' }, orderBy: { createdAt: 'asc' }, select: { createdAt: true }, }), ]); const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all])); const messageIds = items.map((item) => item.messageId).filter((value): value is string => Boolean(value)); const messageStates = messageIds.length > 0 ? await this.prisma.smsMessageRecord.findMany({ where: { messageId: { in: messageIds } }, select: { messageId: true, status: true, submitStatus: true, receiptStatus: true, phoneNumber: true, content: true }, }) : []; const messageStateById = new Map(messageStates.map((item) => [item.messageId, item])); return { items: items.map((item) => sanitizeGatewaySubmitException(item, item.messageId ? messageStateById.get(item.messageId) : undefined)), total, page, pageSize, summary: { pending: statusCounts.get('pending') ?? 0, requeueing: statusCounts.get('requeueing') ?? 0, requeued: statusCounts.get('requeued') ?? 0, resolved: statusCounts.get('resolved') ?? 0, oldestPendingAt: oldestPending?.createdAt ?? null, }, }; } async listDownstreamDeliveries(query: DownstreamDeliveryQuery) { const page = Math.max(1, Number(query.page ?? 1)); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); const where: Prisma.CmppDownstreamDeliveryWhereInput = { ...downstreamDeliveryScopedWhere(query), status: query.status && query.status !== 'all' ? query.status : undefined, OR: query.keyword ? [ { messageId: { contains: query.keyword } }, { payload: { path: ['account'], string_contains: query.keyword } }, { payload: { path: ['phoneNumber'], string_contains: query.keyword } }, { lastError: { contains: query.keyword } }, ] : undefined, }; const [items, total] = await Promise.all([ this.prisma.cmppDownstreamDelivery.findMany({ where, include: { tenant: true, application: true, messageRecord: true, attempts: { orderBy: [{ attemptNo: 'desc' }, { createdAt: 'desc' }] }, }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.cmppDownstreamDelivery.count({ where }), ]); return { items, total, page, pageSize }; } async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) { const scopedWhere = downstreamDeliveryScopedWhere(query); const downstreamAlertWindow = downstreamAlertWindows(); const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, applicationAlertGroups, retryZero, retryLow, retryHigh] = await Promise.all([ this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'delivered' } }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'failed' } }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'unconfirmed' } }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'rejected' } }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, ...stalledPendingWhere(downstreamAlertWindow.stalledPendingAt), }, }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now } }, }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: downstreamAlertWindow.recentFailedAt }, }, }), this.prisma.cmppDownstreamDelivery.groupBy({ by: ['deliveryType', 'status'], where: scopedWhere, _count: { _all: true }, }), this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId', 'status'], where: scopedWhere, _count: { _all: true }, }), this.prisma.cmppDownstreamDelivery.groupBy({ by: ['applicationId'], where: downstreamAlertWhere(scopedWhere, downstreamAlertWindow), _count: { _all: true }, }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] }, retryCount: 0, }, }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] }, retryCount: { gte: 1, lte: 3 }, }, }), this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] }, retryCount: { gte: 4 }, }, }), ]); const applicationIds = [...new Set(applicationGroups.map((item) => item.applicationId).filter((value): value is string => Boolean(value)))]; const applications: Array<{ id: string; name: string }> = applicationIds.length > 0 ? await this.prisma.smsApplication.findMany({ where: { id: { in: applicationIds } }, select: { id: true, name: true }, }) : []; const applicationMap = new Map(applications.map((item) => [item.id, item.name])); const applicationAlertMap = new Map( applicationAlertGroups.map((item) => [item.applicationId, item._count._all]), ); const groupedByType = groupDownstreamByType(typeGroups); const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap, applicationAlertMap); return { summary: { total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, alertCount: stalledPending + stalledAck + recentFailed, }, typeBreakdown: ['receipt', 'uplink'].map((deliveryType) => ({ deliveryType, total: groupedByType[deliveryType]?.total ?? 0, pending: groupedByType[deliveryType]?.pending ?? 0, awaitingAck: groupedByType[deliveryType]?.awaitingAck ?? 0, delivered: groupedByType[deliveryType]?.delivered ?? 0, failed: groupedByType[deliveryType]?.failed ?? 0, unconfirmed: groupedByType[deliveryType]?.unconfirmed ?? 0, rejected: groupedByType[deliveryType]?.rejected ?? 0, })), retryBuckets: [ { label: '0次', count: retryZero }, { label: '1-3次', count: retryLow }, { label: '4次及以上', count: retryHigh }, ], topApplications: groupedByApplication .sort((left, right) => ( right.alertCount - left.alertCount || right.failed - left.failed || right.pending - left.pending || left.name.localeCompare(right.name, 'zh-CN') )) .slice(0, 5), }; } async listDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) { const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate(); const page = Math.max(1, Number(query.page ?? 1)); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); const where = downstreamRecoveryStatusWhere(query); const now = new Date(); const [items, total, runningCount, successCount, failedCount, waitingConnectionCount, backoffCount, categoryGroups] = await Promise.all([ recoveryStatuses.findMany({ where, include: { tenant: true, application: true }, orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }], skip: (page - 1) * pageSize, take: pageSize, }), recoveryStatuses.count({ where }), recoveryStatuses.count({ where: { ...where, state: 'running' } }), recoveryStatuses.count({ where: { ...where, state: 'success' } }), recoveryStatuses.count({ where: { ...where, state: 'failed' } }), recoveryStatuses.count({ where: { ...where, state: 'waiting_connection' } }), recoveryStatuses.count({ where: { ...where, nextRetryAt: { gt: now }, }, }), recoveryStatuses.groupBy({ by: ['failureCategory'], where, _count: { _all: true }, }), ]); return { items, total, page, pageSize, summary: { total, running: runningCount, success: successCount, failed: failedCount, waitingConnection: waitingConnectionCount, backoff: backoffCount, failureCategories: categoryGroups .filter((item) => item.failureCategory) .map((item) => ({ category: String(item.failureCategory), count: item._count?._all ?? 0, })) .sort((left, right) => right.count - left.count || left.category.localeCompare(right.category)), }, }; } async listMessageSegmentAudits(query: MessageSegmentAuditQuery) { const segmentAudits = (this.prisma as PrismaService & { smsMessageSegmentAudit: { findMany: (args: Record) => Promise; }; }).smsMessageSegmentAudit; if (!query.messageId && !query.messageRecordId) { return []; } return segmentAudits.findMany({ where: { messageRecordId: query.messageRecordId, messageRecord: query.messageId ? { messageId: query.messageId } : undefined, }, include: { channel: true, submitRecord: true }, orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }], }); } async getDownstreamRecoveryStatus(id: string) { const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate(); const item = await recoveryStatuses.findUnique({ where: { id }, include: { tenant: true, application: true }, }); if (!item) { throw new NotFoundException('Recovery status not found'); } return item; } async exportDownstreamRecoveryStatuses(query: DownstreamRecoveryStatusQuery) { const recoveryStatuses = this.gatewayDownstreamRecoveryStatusDelegate(); const where = downstreamRecoveryStatusWhere(query); const items = await recoveryStatuses.findMany({ where, include: { tenant: true, application: true }, orderBy: [{ updatedAt: 'desc' }, { account: 'asc' }], take: 5000, }); const rows = [ [ '账号', '企业', '应用', 'Gateway实例', '恢复状态', '锁持有实例', '锁过期时间', '失败分类', '尝试次数', '最后尝试时间', '恢复成功时间', '恢复失败时间', '下次恢复时间', '最后错误', '最后跳过原因', '创建时间', '更新时间', ], ...items.map((item) => [ item.account ?? '', item.tenant?.name ?? '', item.application?.name ?? '', item.gatewayInstanceId ?? '', item.state ?? '', (item as { lockOwner?: string | null }).lockOwner ?? '', formatCsvDate((item as { lockExpiresAt?: Date | string | null }).lockExpiresAt), (item as { failureCategory?: string | null }).failureCategory ?? '', String(item.attemptCount ?? 0), formatCsvDate(item.lastAttemptAt), formatCsvDate(item.lastSuccessAt), formatCsvDate(item.lastFailureAt), formatCsvDate(item.nextRetryAt), item.lastError ?? '', item.lastSkipReason ?? '', formatCsvDate(item.createdAt), formatCsvDate(item.updatedAt), ]), ]; return { fileName: `gateway-downstream-recovery-statuses-${formatExportTimestamp(new Date())}.csv`, content: rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n'), total: items.length, }; } auditSummary(query: { tenantId?: string }) { return this.prisma.operationLog.groupBy({ by: ['action', 'resource'], where: { tenantId: query.tenantId }, _count: { _all: true }, orderBy: { _count: { action: 'desc' } }, take: 100, }); } async trace(query: TraceQuery) { const messages = await this.prisma.smsMessageRecord.findMany({ where: { ...messageWhere(query), messageId: query.messageId, }, include: { batchTask: { include: { apiRequests: true } }, submitRecords: { include: { session: true } }, receiptRecords: true, }, orderBy: { queuedAt: 'desc' }, take: 100, }); const messageIds = messages.map((message) => message.messageId); const [billingRecords, uplinks] = await Promise.all([ this.prisma.smsBillingRecord.findMany({ where: { tenantId: query.tenantId, taskId: query.taskId, messageId: messageIds.length > 0 ? { in: messageIds } : undefined, }, orderBy: { createdAt: 'desc' }, }), this.prisma.smsUplinkMessage.findMany({ where: { tenantId: query.tenantId, messageId: messageIds.length > 0 ? { in: messageIds } : undefined, }, orderBy: { receivedAt: 'desc' }, }), ]); return { messages, billingRecords, uplinks }; } async reconciliation(query: { tenantId?: string; taskId?: string }) { const [messages, billing, transactions] = await Promise.all([ this.prisma.smsMessageRecord.aggregate({ where: messageWhere({ tenantId: query.tenantId, taskId: query.taskId }), _count: { _all: true }, _sum: { amountCents: true, billingUnits: true }, }), this.prisma.smsBillingRecord.aggregate({ where: { tenantId: query.tenantId, taskId: query.taskId }, _count: { _all: true }, _sum: { amountCents: true, billingUnits: true }, }), this.prisma.accountTransaction.aggregate({ where: { tenantId: query.tenantId, relatedType: query.taskId ? { in: ['sms_batch_task', 'sms_message_record'] } : undefined, relatedId: query.taskId, }, _count: { _all: true }, _sum: { amountCents: true }, }), ]); const messageAmount = moneyToNumber(messages._sum.amountCents); const billingAmount = moneyToNumber(billing._sum.amountCents); const transactionAmount = moneyToNumber(transactions._sum.amountCents); return { messages, billing, transactions, diff: { messageVsBillingAmountCents: messageAmount - billingAmount, billingVsTransactionAmountCents: billingAmount + transactionAmount, messageVsBillingUnits: (messages._sum.billingUnits ?? 0) - (billing._sum.billingUnits ?? 0), }, }; } private countPendingAudits(tenantId?: string) { return Promise.all([ this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }), this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }), this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }), this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }), this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }), ]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({ templates, signatures, drainageInfos, enterpriseCertifications, smsAudits, total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits, })); } private gatewayDownstreamRecoveryStatusDelegate() { return (this.prisma as PrismaService & { gatewayDownstreamRecoveryStatus: { findMany: (args: Record) => Promise; count: (args: Record) => Promise; findUnique: (args: Record) => Promise; groupBy: (args: Record) => Promise; }; }).gatewayDownstreamRecoveryStatus; } } function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { const statusWhere = query.status === 'submit_failed' ? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] } : query.status === 'failed' ? { status: 'failed', submitStatus: 'accepted' } : query.status ? { status: query.status } : {}; return { tenantId: query.tenantId, applicationId: query.applicationId, channelId: query.channelId, batchTaskId: query.taskId, messageId: query.messageId, phoneNumber: query.phoneNumber, ...carrierWhere(query.carrier), ...statusWhere, ...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}), ...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}), ...(query.queuedAtFrom || query.queuedAtTo ? { queuedAt: { ...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}), ...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}), }, } : {}), }; } const recognizedCarrierValues = [ 'mobile', 'cmcc', '移动', '中国移动', 'unicom', 'cucc', '联通', '中国联通', 'telecom', 'ctcc', '电信', '中国电信', ]; function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput { if (!carrier) return {}; // Keep historical aliases queryable while treating null and future/nonstandard values as unrecognized. if (carrier === 'unknown') { return { AND: [ { OR: [ { carrier: null }, { carrier: { notIn: recognizedCarrierValues } }, ], }, ], }; } const valuesByCarrier: Record = { mobile: ['mobile', 'cmcc', '移动', '中国移动'], unicom: ['unicom', 'cucc', '联通', '中国联通'], telecom: ['telecom', 'ctcc', '电信', '中国电信'], }; return valuesByCarrier[carrier] ? { carrier: { in: valuesByCarrier[carrier] } } : {}; } function startOfShanghaiDay(value: string) { return new Date(`${value}T00:00:00+08:00`); } function endOfShanghaiDay(value: string) { return new Date(`${value}T23:59:59.999+08:00`); } function qualityBusinessDay(value?: string) { const key = value || shanghaiDateKey(); if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) { throw new BadRequestException('统计日期格式必须为 YYYY-MM-DD'); } const startAt = startOfShanghaiDay(key); if (Number.isNaN(startAt.getTime()) || shanghaiDateKey(startAt) !== key) { throw new BadRequestException('统计日期无效'); } return { key, startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1000), }; } function shanghaiDateKey(value = new Date()) { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(value); const byType = new Map(parts.map((part) => [part.type, part.value])); return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`; } function normalizeGroupBy(groupBy?: string) { if (groupBy === 'tenant' || groupBy === 'tenantId') { return 'tenantId'; } if (groupBy === 'application' || groupBy === 'applicationId') { return 'applicationId'; } return 'channelId'; } function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput { return { tenantId, createdAt: { gte: since }, OR: [ { transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }, ], }; } function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined { if (!range || range === 'all') { return undefined; } const date = new Date(); date.setHours(0, 0, 0, 0); if (range === '7d') { date.setDate(date.getDate() - 6); } else if (range === '30d') { date.setDate(date.getDate() - 29); } return { gte: date }; } function downstreamAlertPendingMinutes() { const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES ?? 10); return Number.isFinite(value) && value > 0 ? value : 10; } function downstreamAlertRecentFailedHours() { const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS ?? 1); return Number.isFinite(value) && value > 0 ? value : 1; } function downstreamAlertWindows(now = new Date()) { return { now, stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000), recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000), }; } function downstreamAlertWhere( scopedWhere: Prisma.CmppDownstreamDeliveryWhereInput, window: ReturnType, ): Prisma.CmppDownstreamDeliveryWhereInput { return { AND: [ scopedWhere, { OR: [ stalledPendingWhere(window.stalledPendingAt), { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } }, { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } }, ], }, ], }; } function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput { return { status: 'pending', OR: [ { lastRetriedAt: null, createdAt: { lte: cutoff } }, { lastRetriedAt: { lte: cutoff } }, ], }; } function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput { const createdAtFrom = parseDateBoundary(query.createdAtFrom, false); const createdAtTo = parseDateBoundary(query.createdAtTo, true); return { tenantId: query.tenantId, applicationId: query.applicationId, deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined, createdAt: createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : undefined, }; } function parseDateBoundary(value?: string, endOfDay = false) { if (!value) return undefined; const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}+08:00`); return Number.isNaN(parsed.getTime()) ? undefined : parsed; } function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) { const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false); const updatedAtTo = parseDateBoundary(query.updatedAtTo, true); return { tenantId: query.tenantId, applicationId: query.applicationId, state: query.state && query.state !== 'all' ? query.state : undefined, failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined, updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined, OR: query.keyword ? [ { account: { contains: query.keyword } }, { gatewayInstanceId: { contains: query.keyword } }, { lastError: { contains: query.keyword } }, { lastSkipReason: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } }, ] : undefined, }; } function escapeCsvCell(value: string) { let normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); if (/^[=+\-@]/.test(normalized)) { normalized = `'${normalized}`; } if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) { return `"${normalized.replace(/"/g, '""')}"`; } return normalized; } function formatCsvDate(value?: Date | string | null) { if (!value) { return ''; } return value instanceof Date ? value.toISOString() : value; } function formatExportTimestamp(date: Date) { const parts = [ date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0'), String(date.getHours()).padStart(2, '0'), String(date.getMinutes()).padStart(2, '0'), String(date.getSeconds()).padStart(2, '0'), ]; return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`; } function clientApplicationView(application?: Record | null) { if (!application) return null; return { id: application.id, name: application.name }; } function clientReceiptView(receipt: Record) { return { id: receipt.id, messageId: receipt.messageId, receiptStatus: receipt.receiptStatus, rawStatus: receipt.rawStatus, errorCode: receipt.errorCode ?? null, errorMessage: receipt.errorMessage ?? null, deliveredAt: receipt.deliveredAt, createdAt: receipt.createdAt, }; } function clientMessageView(message: Record) { return { id: message.id, batchTaskId: message.batchTaskId ?? null, applicationId: message.applicationId ?? null, messageId: message.messageId, phoneNumber: message.phoneNumber, carrier: message.carrier ?? null, province: message.province ?? null, content: message.content, billingUnits: message.billingUnits, amountCents: moneyToNumber(message.amountCents), status: message.status, submitStatus: message.submitStatus ?? null, receiptStatus: message.receiptStatus ?? null, errorCode: message.errorCode ?? null, errorMessage: message.errorMessage ?? null, queuedAt: message.queuedAt, submittedAt: message.submittedAt ?? null, deliveredAt: message.deliveredAt ?? null, application: clientApplicationView(message.application), receiptRecords: Array.isArray(message.receiptRecords) ? message.receiptRecords.map(clientReceiptView) : [], }; } function clientBatchTaskView(task: Record) { return { id: task.id, taskNo: task.taskNo, applicationId: task.applicationId ?? null, templateId: task.templateId ?? null, content: task.content, category: task.category ?? null, phoneTotal: task.phoneTotal, status: task.status, auditStatus: task.auditStatus ?? null, reviewReason: task.reviewReason ?? null, rejectReason: task.rejectReason ?? null, progressTotal: task.progressTotal, progressSent: task.progressSent ?? 0, progressDelivered: task.progressDelivered ?? 0, progressFailed: task.progressFailed ?? 0, submittedTotal: task.submittedTotal ?? 0, successTotal: task.successTotal ?? 0, failedTotal: task.failedTotal ?? 0, unknownTotal: task.unknownTotal ?? 0, timeoutTotal: task.timeoutTotal ?? 0, scheduledAt: task.scheduledAt ?? null, canceledAt: task.canceledAt ?? null, createdAt: task.createdAt, application: clientApplicationView(task.application), messages: Array.isArray(task.messages) ? task.messages.map(clientMessageView) : [], }; } function clientUplinkView(message: Record) { return { id: message.id, applicationId: message.applicationId ?? null, messageRecordId: message.messageRecordId ?? null, messageId: message.messageId ?? null, phoneNumber: message.phoneNumber, destId: message.destId, content: message.content, matchStatus: message.matchStatus, matchReason: message.matchReason ?? null, receivedAt: message.receivedAt, createdAt: message.createdAt, application: clientApplicationView(message.application), messageRecord: message.messageRecord ? clientMessageView(message.messageRecord) : null, }; } function clientAccountView(account: Record) { return { id: account.id, tenantId: account.tenantId, balanceCents: moneyToNumber(account.balanceCents), creditCents: moneyToNumber(account.creditCents), status: account.status, updatedAt: account.updatedAt, tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name, status: account.tenant.status } : null, }; } function clientRechargeView(order: Record) { return { id: order.id, orderNo: order.orderNo, amountCents: moneyToNumber(order.amountCents), status: order.status, payMethod: order.payMethod, remark: order.remark ?? null, createdAt: order.createdAt, completedAt: order.completedAt ?? null, }; } function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) { return groups.reduce( (summary, group) => { const count = group._count._all; summary.total += count; summary.amountCents += moneyToNumber(group._sum.amountCents); summary.billingUnits += group._sum.billingUnits ?? 0; if (group.status === 'delivered') { summary.delivered += count; } else if (['undelivered', 'submit_failed', 'timeout', 'failed', 'rejected'].includes(group.status)) { summary.failed += count; } else if (group.status === 'unknown') { summary.unknown += count; } return summary; }, { total: 0, delivered: 0, failed: 0, unknown: 0, amountCents: 0, billingUnits: 0 }, ); } function groupDownstreamByType( groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>, ) { return groups.reduce>((accumulator, item) => { const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 }; current.total += item._count._all; if (item.status === 'pending') { current.pending += item._count._all; } else if (item.status === 'awaiting_ack') { current.awaitingAck += item._count._all; } else if (item.status === 'delivered') { current.delivered += item._count._all; } else if (item.status === 'failed') { current.failed += item._count._all; } else if (item.status === 'unconfirmed') { current.unconfirmed += item._count._all; } else if (item.status === 'rejected') { current.rejected += item._count._all; } accumulator[item.deliveryType] = current; return accumulator; }, {}); } function groupDownstreamByApplication( groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>, applicationMap: Map, applicationAlertMap: Map, ) { const summaryMap = new Map(); groups.forEach((item) => { const current = summaryMap.get(item.applicationId) ?? { applicationId: item.applicationId, name: applicationMap.get(item.applicationId) ?? item.applicationId, pending: 0, awaitingAck: 0, failed: 0, unconfirmed: 0, rejected: 0, delivered: 0, alertCount: 0, }; if (item.status === 'pending') { current.pending += item._count._all; } else if (item.status === 'awaiting_ack') { current.awaitingAck += item._count._all; } else if (item.status === 'failed') { current.failed += item._count._all; } else if (item.status === 'unconfirmed') { current.unconfirmed += item._count._all; } else if (item.status === 'rejected') { current.rejected += item._count._all; } else if (item.status === 'delivered') { current.delivered += item._count._all; } current.alertCount = applicationAlertMap.get(item.applicationId) ?? 0; summaryMap.set(item.applicationId, current); }); return [...summaryMap.values()]; } function positiveInteger(value: number | undefined, fallback: number) { const normalized = Number(value); return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback; } function operationLogLevelWhere(level: string): Prisma.OperationLogWhereInput { const error: Prisma.OperationLogWhereInput = { OR: [ { action: { contains: 'failed' } }, { action: { contains: 'reject' } }, { detail: { path: ['result'], string_contains: 'fail' } }, { detail: { path: ['status'], string_contains: 'fail' } }, ], }; const warning: Prisma.OperationLogWhereInput = { OR: [ { action: { contains: 'warning' } }, { action: { contains: 'risk' } }, ], }; const success: Prisma.OperationLogWhereInput = { OR: [ { action: { contains: 'approve' } }, { action: { contains: 'recharge' } }, { action: { contains: 'connected' } }, ], }; if (level === 'error') { return error; } if (level === 'warning') { return { AND: [{ NOT: error }, warning] }; } if (level === 'success') { return { AND: [{ NOT: error }, { NOT: warning }, success] }; } if (level === 'info') { return { NOT: { OR: [error, warning, success] } }; } return {}; } function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) { const detail = (log.detail ?? {}) as Record; const result = String(detail.result ?? detail.status ?? ''); const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject') ? 'error' : log.action.includes('warning') || log.action.includes('risk') ? 'warning' : log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected') ? 'success' : 'info'; return { id: log.id, time: log.createdAt, level, tenant: log.tenant?.name ?? (log.tenantId ? log.tenantId : '平台'), module: log.resource, operator: log.user?.displayName ?? log.user?.username ?? log.userId ?? 'system', action: log.action, resourceId: log.resourceId ?? '', detail, ip: log.ipAddress ?? '', userAgent: log.userAgent ?? '', }; } function sanitizeGatewaySubmitException( item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>, messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string }, ) { const { rawPayload, commandPayload, tenant, application, channel, ...record } = item; return { ...record, tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null, application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null, channel: channel ? { id: channel.id, code: channel.code, name: channel.name, status: channel.status, carrier: channel.carrier, sendRegion: channel.sendRegion, rateLimitPerSecond: channel.rateLimitPerSecond, } : null, rawPayloadAvailable: Boolean(rawPayload), commandPayload: redactGatewayCommandValue(commandPayload), messageState: messageState ?? null, }; } function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prisma.JsonValue | null { if (Array.isArray(value)) { return value.map((item) => redactGatewayCommandValue(item)); } if (value && typeof value === 'object') { const redacted: Record = {}; for (const [key, child] of Object.entries(value)) { const normalizedKey = key.toLowerCase(); redacted[key] = [ 'password', 'passwordcipher', 'secret', 'secrethash', 'authsource', 'token', 'apikey', 'accesskey', 'secretkey', ].includes(normalizedKey) ? '[REDACTED]' : redactGatewayCommandValue(child as Prisma.JsonValue); } return redacted; } return value; }