feat: harden platform workflows and UI governance
This commit is contained in:
@@ -274,4 +274,9 @@ export class AdminSystemLogsController {
|
||||
) {
|
||||
return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
|
||||
}
|
||||
|
||||
@Post('exports')
|
||||
export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string }) {
|
||||
return this.operations.exportSystemLogs(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
@@ -10,12 +11,12 @@ export class ClientOperationsController {
|
||||
|
||||
@Get('batch-tasks')
|
||||
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
|
||||
return this.operations.listBatchTasks({ tenantId, status });
|
||||
return this.operations.listClientBatchTasks({ tenantId, status });
|
||||
}
|
||||
|
||||
@Get('batch-tasks/:id/messages')
|
||||
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
|
||||
return this.operations.listMessages({ tenantId, taskId, phoneNumber });
|
||||
return this.operations.listClientMessages({ tenantId, taskId, phoneNumber });
|
||||
}
|
||||
|
||||
@Get('messages')
|
||||
@@ -27,17 +28,17 @@ export class ClientOperationsController {
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.operations.listMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status });
|
||||
return this.operations.listClientMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status });
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string) {
|
||||
return this.operations.listUplinkMessages({ tenantId, channelId, applicationId, phoneNumber, keyword, startTime, endTime });
|
||||
return this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(@TenantId() tenantId?: string) {
|
||||
return this.operations.dashboard({ tenantId });
|
||||
return this.operations.clientDashboard({ tenantId });
|
||||
}
|
||||
|
||||
@Get('system-logs')
|
||||
@@ -52,4 +53,12 @@ export class ClientOperationsController {
|
||||
) {
|
||||
return this.operations.systemLogs({ tenantId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
|
||||
}
|
||||
|
||||
@Post('system-logs/exports')
|
||||
exportSystemLogs(
|
||||
@CurrentSessionUserId() userId: string | undefined,
|
||||
@Body() body: { keyword?: string; level?: string; module?: string; range?: string },
|
||||
) {
|
||||
return this.operations.exportSystemLogs(body, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { OperationsService } from './operations.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
user: {
|
||||
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }),
|
||||
},
|
||||
smsBatchTask: {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
||||
count: jest.fn().mockResolvedValue(3),
|
||||
@@ -250,6 +253,64 @@ describe('OperationsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([{
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
channelId: 'channel-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
carrier: 'mobile',
|
||||
province: '上海',
|
||||
content: '验证码1234',
|
||||
billingUnits: 1,
|
||||
amountCents: 352,
|
||||
status: 'delivered',
|
||||
queuedAt: new Date('2026-07-21T01:00:00.000Z'),
|
||||
application: { id: 'app-1', name: '应用A', secretHash: 'secret' },
|
||||
tenant: { id: 'tenant-1', name: '企业A' },
|
||||
channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 },
|
||||
submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }],
|
||||
receiptRecords: [{
|
||||
id: 'receipt-1', messageId: 'MSG-1', gatewayMessageId: 'GW-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD',
|
||||
errorCode: null, errorMessage: null, deliveredAt: new Date('2026-07-21T01:00:05.000Z'), createdAt: new Date('2026-07-21T01:00:05.000Z'),
|
||||
}],
|
||||
}]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
const [message] = await service.listClientMessages({ tenantId: 'tenant-1' });
|
||||
|
||||
expect(message).toMatchObject({
|
||||
id: 'record-1', messageId: 'MSG-1', carrier: 'mobile', province: '上海', application: { id: 'app-1', name: '应用A' },
|
||||
receiptRecords: [expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD' })],
|
||||
});
|
||||
expect(message).not.toHaveProperty('tenant');
|
||||
expect(message).not.toHaveProperty('tenantId');
|
||||
expect(message).not.toHaveProperty('channel');
|
||||
expect(message).not.toHaveProperty('channelId');
|
||||
expect(message).not.toHaveProperty('submitRecords');
|
||||
expect(JSON.stringify(message)).not.toMatch(/passwordCipher|gatewayMessageId|unitPrice|supplier|cipher/);
|
||||
});
|
||||
|
||||
it('returns a client dashboard without gateway state or supplier channel secrets', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsBatchTask.findMany.mockResolvedValue([{
|
||||
id: 'task-1', taskNo: 'BATCH-1', tenantId: 'tenant-1', applicationId: 'app-1', phoneTotal: 1, status: 'finished',
|
||||
createdAt: new Date('2026-07-21T01:00:00.000Z'), application: { id: 'app-1', name: '应用A' },
|
||||
messages: [{ channel: { account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 } }],
|
||||
}]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
const dashboard = await service.clientDashboard({ tenantId: 'tenant-1' });
|
||||
|
||||
expect(dashboard.gatewayConnections).toEqual([]);
|
||||
expect(dashboard.recentTasks).toEqual([expect.objectContaining({ id: 'task-1', taskNo: 'BATCH-1' })]);
|
||||
expect(JSON.stringify(dashboard)).not.toMatch(/passwordCipher|supplier|cipher|unitPrice/);
|
||||
});
|
||||
|
||||
it('builds dashboard and statistics aggregates', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.cmppDownstreamDelivery.count = jest.fn()
|
||||
@@ -390,6 +451,32 @@ describe('OperationsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('exports filtered operation logs with a traceable operation id', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual(expect.objectContaining({
|
||||
operationId: expect.any(String),
|
||||
status: 'completed',
|
||||
recordCount: 1,
|
||||
truncated: false,
|
||||
content: expect.stringContaining('billing.manual_recharge'),
|
||||
}));
|
||||
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 10_001 }));
|
||||
});
|
||||
|
||||
it('derives client log export tenant from session user and removes internal detail and IP columns', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
const exported = await service.exportSystemLogs({ tenantId: 'spoofed-tenant' }, 'client-user');
|
||||
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'client-user' }) }));
|
||||
expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }) }));
|
||||
expect(exported.content.split('\n')[0]).toBe('时间,级别,模块,操作人,动作,资源ID');
|
||||
expect(exported.content).not.toContain('amountCents');
|
||||
});
|
||||
|
||||
it('caps legacy audit-log reads with pagination', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
@@ -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