1723 lines
66 KiB
TypeScript
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;
|
|
}
|