feat: M0-07 Observability — MetricsInterceptor + admin metrics AAPI
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 36s

This commit is contained in:
WangDL 2026-05-22 23:19:31 +08:00
parent c7052ee48e
commit 2bd416c807
6 changed files with 115 additions and 0 deletions

View File

@ -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;

View File

@ -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])
}

View File

@ -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,

View File

@ -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<any> {
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(() => {});
}),
);
}
}

View File

@ -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)) });
}
}

View File

@ -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 {}