feat: harden platform workflows and UI governance
This commit is contained in:
@@ -2,6 +2,7 @@ import { 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;
|
||||
@@ -89,6 +90,11 @@ export class OperationsService {
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -97,6 +103,11 @@ export class OperationsService {
|
||||
});
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -126,6 +137,11 @@ export class OperationsService {
|
||||
});
|
||||
}
|
||||
|
||||
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([
|
||||
@@ -293,6 +309,25 @@ export class OperationsService {
|
||||
};
|
||||
}
|
||||
|
||||
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') {
|
||||
@@ -378,6 +413,59 @@ export class OperationsService {
|
||||
};
|
||||
}
|
||||
|
||||
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)));
|
||||
@@ -989,7 +1077,10 @@ function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
||||
}
|
||||
|
||||
function escapeCsvCell(value: string) {
|
||||
const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
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, '""')}"`;
|
||||
}
|
||||
@@ -1015,6 +1106,122 @@ function formatExportTimestamp(date: Date) {
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user