fix: connect operations pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 15:16:34 +08:00
parent ab421cf8a7
commit 321cf716f2
22 changed files with 1255 additions and 429 deletions
@@ -39,6 +39,11 @@ export class AdminOperationsController {
return this.operations.dashboard({ tenantId });
}
@Get('dashboard/statistics')
dashboardStatistics(@Query('tenantId') tenantId?: string) {
return this.operations.dashboard({ tenantId });
}
@Get('statistics')
statistics(@Query('tenantId') tenantId?: string, @Query('groupBy') groupBy?: string) {
return this.operations.statistics({ tenantId, groupBy });
@@ -72,3 +77,22 @@ export class AdminOperationsController {
}
}
@ApiTags('admin-system-logs')
@Controller('admin/system-logs')
export class AdminSystemLogsController {
constructor(private readonly operations: OperationsService) {}
@Get()
list(
@Query('tenantId') tenantId?: string,
@Query('userId') userId?: string,
@Query('keyword') keyword?: string,
@Query('level') level?: string,
@Query('module') module?: string,
@Query('range') range?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
}
}
@@ -22,5 +22,22 @@ export class ClientOperationsController {
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string) {
return this.operations.listUplinkMessages({ tenantId, channelId });
}
}
@Get('dashboard')
dashboard(@TenantId() tenantId?: string) {
return this.operations.dashboard({ tenantId });
}
@Get('system-logs')
systemLogs(
@TenantId() tenantId?: string,
@Query('keyword') keyword?: string,
@Query('level') level?: string,
@Query('module') module?: string,
@Query('range') range?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.operations.systemLogs({ tenantId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
}
}
+2 -3
View File
@@ -1,14 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { AdminOperationsController } from './admin-operations.controller';
import { AdminOperationsController, AdminSystemLogsController } from './admin-operations.controller';
import { ClientOperationsController } from './client-operations.controller';
import { OperationsService } from './operations.service';
@Module({
imports: [PrismaModule],
controllers: [AdminOperationsController, ClientOperationsController],
controllers: [AdminOperationsController, AdminSystemLogsController, ClientOperationsController],
providers: [OperationsService],
exports: [OperationsService],
})
export class OperationsModule {}
+52 -3
View File
@@ -3,7 +3,7 @@ import { OperationsService } from './operations.service';
function createPrismaMock() {
return {
smsBatchTask: {
findMany: jest.fn(),
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
count: jest.fn().mockResolvedValue(3),
},
smsMessageRecord: {
@@ -25,12 +25,38 @@ function createPrismaMock() {
accountTransaction: {
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }),
},
tenantAccount: {
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 1000, tenant: { name: '租户A' } }]),
},
rechargeOrder: {
findMany: jest.fn().mockResolvedValue([{ id: 'order-1', tenantId: 'tenant-1', amountCents: 1000 }]),
},
smsTemplate: {
count: jest.fn().mockResolvedValue(1),
},
smsSignature: {
count: jest.fn().mockResolvedValue(1),
},
enterpriseCertification: {
count: jest.fn().mockResolvedValue(1),
},
cmppConnectionState: {
groupBy: jest.fn().mockResolvedValue([{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
},
operationLog: {
findMany: jest.fn(),
groupBy: jest.fn(),
findMany: jest.fn().mockResolvedValue([{
id: 'log-1',
tenantId: 'tenant-1',
tenant: { name: '租户A' },
user: { displayName: '运营' },
action: 'billing.manual_recharge',
resource: 'recharge_order',
resourceId: 'order-1',
detail: { amountCents: 1000 },
createdAt: new Date('2026-07-02T01:00:00.000Z'),
}]),
count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]),
},
};
}
@@ -72,6 +98,7 @@ describe('OperationsService', () => {
expect.objectContaining({
taskCount: 3,
uplinkCount: 1,
pendingAuditCount: 6,
gatewayConnections: [{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
}),
);
@@ -104,4 +131,26 @@ describe('OperationsService', () => {
}),
);
});
it('returns paginated operation logs with normalized detail cards', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.systemLogs({ tenantId: 'tenant-1', keyword: '充值', page: 1, pageSize: 5 })).resolves.toEqual(
expect.objectContaining({
total: 1,
page: 1,
pageSize: 5,
modules: ['recharge_order'],
items: [
expect.objectContaining({
level: 'success',
tenant: '租户A',
module: 'recharge_order',
action: 'billing.manual_recharge',
}),
],
}),
);
});
});
+187 -1
View File
@@ -15,6 +15,17 @@ 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;
}
@Injectable()
export class OperationsService {
constructor(private readonly prisma: PrismaService) {}
@@ -71,8 +82,22 @@ export class OperationsService {
}
async dashboard(query: { tenantId?: string }) {
const sinceToday = startOfToday();
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
const [taskCount, messageGroups, uplinkCount, billingAggregate, transactionAggregate, connectionGroups] = await Promise.all([
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
const [
taskCount,
messageGroups,
todayMessageGroups,
uplinkCount,
billingAggregate,
transactionAggregate,
connectionGroups,
pendingAuditCount,
tenantAccounts,
recentTasks,
recentRecharges,
] = await Promise.all([
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
this.prisma.smsMessageRecord.groupBy({
by: ['status'],
@@ -80,6 +105,12 @@ export class OperationsService {
_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 },
@@ -97,14 +128,50 @@ export class OperationsService {
_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,
}),
]);
const todayTotals = summarizeMessageGroups(todayMessageGroups);
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,
billingUnits: todayTotals.billingUnits,
},
uplinkCount,
billing: billingAggregate,
transactions: transactionAggregate,
gatewayConnections: connectionGroups,
pendingAuditCount,
accounts: tenantAccounts,
recentTasks,
recentRecharges,
};
}
@@ -142,6 +209,51 @@ export class OperationsService {
});
}
async systemLogs(query: OperationLogQuery) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(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,
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' },
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' },
}),
]);
const normalizedItems = items
.map((item) => normalizeOperationLog(item))
.filter((item) => !query.level || query.level === 'all' || item.level === query.level);
return {
items: normalizedItems,
total: query.level && query.level !== 'all' ? normalizedItems.length : total,
page,
pageSize,
modules: modules.map((item) => item.resource),
};
}
auditSummary(query: { tenantId?: string }) {
return this.prisma.operationLog.groupBy({
by: ['action', 'resource'],
@@ -223,6 +335,15 @@ export class OperationsService {
},
};
}
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.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
this.prisma.smsBatchTask.count({ where: { tenantId, auditStatus: 'pending' } }),
]).then((counts) => counts.reduce((sum, value) => sum + value, 0));
}
}
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
@@ -245,3 +366,68 @@ function normalizeGroupBy(groupBy?: string) {
}
return 'channelId';
}
function startOfToday() {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
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 summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | null; billingUnits: number | null } }>) {
return groups.reduce(
(summary, group) => {
const count = group._count._all;
summary.total += count;
summary.amountCents += group._sum.amountCents ?? 0;
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 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 ?? '',
};
}