From 2bd416c8079fa04a2fc319c08d1d3678969bc194 Mon Sep 17 00:00:00 2001 From: WangDL Date: Fri, 22 May 2026 23:19:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M0-07=20Observability=20=E2=80=94=20Met?= =?UTF-8?q?ricsInterceptor=20+=20admin=20metrics=20AAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 8 +++ prisma/schema.prisma | 14 ++++++ src/app.module.ts | 4 ++ .../interceptors/metrics.interceptor.ts | 32 ++++++++++++ .../admin-metrics/admin-metrics.controller.ts | 50 +++++++++++++++++++ .../admin-metrics/admin-metrics.module.ts | 7 +++ 6 files changed, 115 insertions(+) create mode 100644 prisma/migrations/20260522231922_add_api_metric/migration.sql create mode 100644 src/common/interceptors/metrics.interceptor.ts create mode 100644 src/modules/admin-metrics/admin-metrics.controller.ts create mode 100644 src/modules/admin-metrics/admin-metrics.module.ts diff --git a/prisma/migrations/20260522231922_add_api_metric/migration.sql b/prisma/migrations/20260522231922_add_api_metric/migration.sql new file mode 100644 index 0000000..eda5438 --- /dev/null +++ b/prisma/migrations/20260522231922_add_api_metric/migration.sql @@ -0,0 +1,8 @@ +CREATE TABLE ApiMetric ( + id VARCHAR(191) NOT NULL, path VARCHAR(255) NOT NULL, method VARCHAR(10) NOT NULL, + statusCode INT NOT NULL, duration INT NOT NULL, + userId VARCHAR(100), ip VARCHAR(45), + createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX ApiMetric_path_idx(path), INDEX ApiMetric_createdAt_idx(createdAt), + PRIMARY KEY (id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1eb0852..cc71e86 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -948,3 +948,17 @@ model ContentReport { @@index([status]) @@index([createdAt]) } + +model ApiMetric { + id String @id @default(cuid()) + path String @db.VarChar(255) + method String @db.VarChar(10) + statusCode Int + duration Int + userId String? @db.VarChar(100) + ip String? @db.VarChar(45) + createdAt DateTime @default(now()) + + @@index([path]) + @@index([createdAt]) +} diff --git a/src/app.module.ts b/src/app.module.ts index e9893ad..afa6941 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { AdminAuthModule } from './modules/admin-auth/admin-auth.module'; import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module'; import { AdminUsersModule } from './modules/admin-users/admin-users.module'; import { ContentSafetyModule } from './modules/content-safety/content-safety.module'; +import { AdminMetricsModule } from './modules/admin-metrics/admin-metrics.module'; import { AdminThrottleModule } from './modules/admin-throttle/admin-throttle.module'; import { AppConfigModule } from './modules/config/config.module'; import { AdminEventsModule } from './modules/admin-events/admin-events.module'; @@ -52,6 +53,7 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter' import { StrictValidationPipe } from './common/pipes/strict-validation.pipe'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { TraceIdInterceptor } from './common/interceptors/trace-id.interceptor'; +import { MetricsInterceptor } from './common/interceptors/metrics.interceptor'; import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor'; import { AppThrottleModule } from './common/throttle/throttle.module'; @@ -105,6 +107,7 @@ import appleConfig from './config/apple.config'; AdminDashboardModule, AdminUsersModule, ContentSafetyModule, + AdminMetricsModule, AdminThrottleModule, AppConfigModule, AdminEventsModule, @@ -140,6 +143,7 @@ import appleConfig from './config/apple.config'; { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useClass: StrictValidationPipe }, { provide: APP_INTERCEPTOR, useClass: TraceIdInterceptor }, + { provide: APP_INTERCEPTOR, useClass: MetricsInterceptor }, { provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor }, { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }, AiAnalysisWorker, diff --git a/src/common/interceptors/metrics.interceptor.ts b/src/common/interceptors/metrics.interceptor.ts new file mode 100644 index 0000000..51cb99d --- /dev/null +++ b/src/common/interceptors/metrics.interceptor.ts @@ -0,0 +1,32 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@Injectable() +export class MetricsInterceptor implements NestInterceptor { + constructor(private readonly prisma: PrismaService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const start = Date.now(); + const req = context.switchToHttp().getRequest(); + + return next.handle().pipe( + tap(async () => { + const duration = Date.now() - start; + const res = context.switchToHttp().getResponse(); + // Fire-and-forget: don't block the response + this.prisma.apiMetric.create({ + data: { + path: req.route?.path || req.url?.split('?')[0] || '/', + method: req.method, + statusCode: res.statusCode, + duration, + userId: req.user?.id || req.adminUser?.id || null, + ip: req.ip || null, + }, + }).catch(() => {}); + }), + ); + } +} diff --git a/src/modules/admin-metrics/admin-metrics.controller.ts b/src/modules/admin-metrics/admin-metrics.controller.ts new file mode 100644 index 0000000..b782e20 --- /dev/null +++ b/src/modules/admin-metrics/admin-metrics.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; +import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; +import type { AdminRole } from '../../common/types/admin-role.enum'; + +@ApiTags('admin-metrics') +@Controller('admin-api/metrics') +@UseGuards(AdminAuthGuard, AdminRolesGuard) +@ApiBearerAuth() +export class AdminMetricsController { + constructor(private readonly prisma: PrismaService) {} + + @Get('overview') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '指标概览' }) + async overview() { + const today = new Date(); today.setHours(0, 0, 0, 0); + const [todayCalls, avgDuration, errorCount] = await Promise.all([ + this.prisma.apiMetric.count({ where: { createdAt: { gte: today } } }), + this.prisma.apiMetric.aggregate({ _avg: { duration: true }, where: { createdAt: { gte: today } } }), + this.prisma.apiMetric.count({ where: { createdAt: { gte: today }, statusCode: { gte: 400 } } }), + ]); + const total = await this.prisma.apiMetric.count(); + return { todayCalls, avgDuration: Math.round(avgDuration._avg.duration || 0), errorCount, total }; + } + + @Get('top') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '接口耗时排行' }) + async top(@Query('limit') limit = 10) { + const metrics = await this.prisma.apiMetric.groupBy({ + by: ['path', 'method'], + _avg: { duration: true }, + _count: { id: true }, + orderBy: { _avg: { duration: 'desc' } }, + take: parseInt(String(limit)), + }); + return metrics.map(m => ({ path: m.path, method: m.method, avgDuration: Math.round(m._avg.duration || 0), calls: m._count.id })); + } + + @Get('recent') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '最近请求' }) + async recent(@Query('limit') limit = 30) { + return this.prisma.apiMetric.findMany({ orderBy: { createdAt: 'desc' }, take: parseInt(String(limit)) }); + } +} diff --git a/src/modules/admin-metrics/admin-metrics.module.ts b/src/modules/admin-metrics/admin-metrics.module.ts new file mode 100644 index 0000000..6462f6e --- /dev/null +++ b/src/modules/admin-metrics/admin-metrics.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { AdminMetricsController } from './admin-metrics.controller'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; +@Module({ controllers: [AdminMetricsController], providers: [PrismaService, AdminAuthGuard, AdminRolesGuard] }) +export class AdminMetricsModule {}