import { Prisma } from '@prisma/client'; import { BadRequestException } from '@nestjs/common'; import { moneyToNumber } from '../common/money'; import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts'; // Pure query builders and response mappers shared by the R2 query domains. export 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.hasDrainage === 'true' ? { hasDrainageContent: true } : query.hasDrainage === 'false' ? { hasDrainageContent: false } : query.hasDrainage === 'unknown' ? { hasDrainageContent: null } : {}), ...(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) } : {}), }, } : {}), }; } export const recognizedCarrierValues = [ 'mobile', 'cmcc', '移动', '中国移动', 'unicom', 'cucc', '联通', '中国联通', 'telecom', 'ctcc', '电信', '中国电信', ]; export 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] } } : {}; } export function startOfShanghaiDay(value: string) { return new Date(`${value}T00:00:00+08:00`); } export function endOfShanghaiDay(value: string) { return new Date(`${value}T23:59:59.999+08:00`); } export 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), }; } export 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')}`; } export function normalizeGroupBy(groupBy?: string) { if (groupBy === 'tenant' || groupBy === 'tenantId') { return 'tenantId'; } if (groupBy === 'application' || groupBy === 'applicationId') { return 'applicationId'; } return 'channelId'; } export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma.AccountTransactionWhereInput { return { tenantId, createdAt: { gte: since }, OR: [ { transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }, ], }; } export 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 }; } export function downstreamAlertPendingMinutes() { const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_PENDING_MINUTES ?? 10); return Number.isFinite(value) && value > 0 ? value : 10; } export function downstreamAlertRecentFailedHours() { const value = Number(process.env.CMPP_DOWNSTREAM_ALERT_RECENT_FAILED_HOURS ?? 1); return Number.isFinite(value) && value > 0 ? value : 1; } export function downstreamAlertWindows(now = new Date()) { return { now, stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000), recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000), }; } export 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 } }, ], }, ], }; } export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput { return { status: 'pending', OR: [ { lastRetriedAt: null, createdAt: { lte: cutoff } }, { lastRetriedAt: { lte: cutoff } }, ], }; } export 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, }; } export 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; } export 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, }; } export 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; } export function formatCsvDate(value?: Date | string | null) { if (!value) { return ''; } return value instanceof Date ? value.toISOString() : value; } export 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]}`; } export function clientApplicationView(application?: Record | null) { if (!application) return null; return { id: application.id, name: application.name }; } export 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, }; } export 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) : [], }; } export 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) : [], }; } export function clientUplinkView(message: Record) { return { id: message.id, applicationId: message.applicationId ?? null, messageRecordId: message.messageRecordId ?? null, messageId: message.messageId ?? null, gatewayMessageId: message.gatewayMessageId ?? 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, }; } export 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, }; } export 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, }; } export 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 }, ); } export 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; }, {}); } export 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()]; } export function positiveInteger(value: number | undefined, fallback: number) { const normalized = Number(value); return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback; } export 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 {}; } export 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 ?? '', }; } export 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, }; } export 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; }