Files
lislgosms/api/src/operations/operations.service.ts
T

1723 lines
66 KiB
TypeScript

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;
status?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
}
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;
page?: number;
pageSize?: number;
}
export interface MessageSegmentAuditQuery {
messageId?: string;
messageRecordId?: string;
}
@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 } },
receiptRecords: { include: { channel: true } },
downstreamDeliveries: {
where: { deliveryType: 'receipt' },
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
},
},
orderBy: { queuedAt: 'desc' },
});
}
async listClientMessages(query: MessageQuery) {
const items = await this.listMessages(query);
return items.map(clientMessageView);
}
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) {
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' },
take: 500,
});
}
async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) {
const items = await this.listUplinkMessages(query);
return items.map(clientUplinkView);
}
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 sinceToday = startOfToday();
const downstreamAlertWindow = downstreamAlertWindows();
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
const [
taskCount,
messageGroups,
todayMessageGroups,
uplinkCount,
billingAggregate,
transactionAggregate,
connectionGroups,
pendingAudits,
tenantAccounts,
recentTasks,
recentRecharges,
downstreamPendingCount,
downstreamFailedCount,
downstreamDeliveredCount,
downstreamStalledPendingCount,
downstreamStalledAckCount,
downstreamRecentFailedCount,
] = 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.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 },
},
}),
]);
const todayTotals = summarizeMessageGroups(todayMessageGroups);
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,
downstreamDeliverySummary: {
pending: downstreamPendingCount,
failed: downstreamFailedCount,
delivered: downstreamDeliveredCount,
stalledPending: downstreamStalledPendingCount,
stalledAck: downstreamStalledAckCount,
recentFailed: downstreamRecentFailedCount,
alertCount: downstreamAlertCount,
},
accounts: tenantAccounts,
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,
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
accounts: dashboard.accounts.map(clientAccountView),
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<Array<{
channelId: string;
channelName: string;
total: number;
acceptedCount: number;
submitFailureCount: number;
submitFailureRate: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
unknownRate: number;
failureRate: number;
averageArrivalMs: number | null;
}>>(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<Array<{
id: string;
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
hasDrainage: boolean;
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
(message."drainageInfoId" IS NOT NULL) AS has_drainage,
message.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(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected')::integer AS total,
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'rejected'
AND 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(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected') = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0
/ COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected'),
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<Array<{
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
}>>(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<Array<{
applicationId: string;
applicationName: string;
tenantId: string;
tenantName: string;
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
}>>(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 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<string, string>(applications.map((item) => [item.id, item.name]));
const applicationAlertMap = new Map<string, number>(
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<string, unknown>) => Promise<any[]>;
};
}).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: [{ submitId: 'asc' }, { segmentIndex: '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<string, unknown>) => Promise<any[]>;
count: (args: Record<string, unknown>) => Promise<number>;
findUnique: (args: Record<string, unknown>) => Promise<any | null>;
groupBy: (args: Record<string, unknown>) => Promise<any[]>;
};
}).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,
...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) } : {}),
},
} : {}),
};
}
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 startOfToday() {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
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<typeof downstreamAlertWindows>,
): 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) {
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,
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<string, any> | null) {
if (!application) return null;
return { id: application.id, name: application.name };
}
function clientReceiptView(receipt: Record<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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<Record<string, { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number }>>((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<string, string>,
applicationAlertMap: Map<string, number>,
) {
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
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<string, unknown>;
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<string, Prisma.JsonValue | null> = {};
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;
}