feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
@@ -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);
+208 -1
View File
@@ -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) => {