From 809f12510734a45e402ce6b33a9e4cff42541ca4 Mon Sep 17 00:00:00 2001 From: WangDL Date: Sun, 24 May 2026 10:56:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M1-05=20=E2=80=94=20Observability=20dee?= =?UTF-8?q?pening,=20AI=20+=20Worker=20performance=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /admin-api/metrics/ai — AI调用耗时按provider/模型分组 - GET /admin-api/metrics/worker — Worker任务按队列统计成功率 - Admin page: AI performance + Worker performance tabs Co-Authored-By: Claude Opus 4.7 --- .../admin-metrics/admin-metrics.controller.ts | 66 +++++++++++++++++++ test/m1.e2e-spec.ts | 35 ++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/modules/admin-metrics/admin-metrics.controller.ts b/src/modules/admin-metrics/admin-metrics.controller.ts index b782e20..b8466de 100644 --- a/src/modules/admin-metrics/admin-metrics.controller.ts +++ b/src/modules/admin-metrics/admin-metrics.controller.ts @@ -47,4 +47,70 @@ export class AdminMetricsController { async recent(@Query('limit') limit = 30) { return this.prisma.apiMetric.findMany({ orderBy: { createdAt: 'desc' }, take: parseInt(String(limit)) }); } + + @Get('ai') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: 'AI 调用耗时统计' }) + async aiMetrics(@Query('days') days = '7') { + const since = new Date(Date.now() - parseInt(days) * 86400000); + const logs = await this.prisma.aiUsageLog.findMany({ + where: { createdAt: { gte: since } }, + select: { provider: true, model: true, latencyMs: true, success: true, estimatedCost: true }, + }); + + const byProvider: Record = {}; + const byModel: Record = {}; + for (const l of logs) { + const pk = l.provider; + if (!byProvider[pk]) byProvider[pk] = { calls: 0, totalLatency: 0, totalCost: 0, failures: 0 }; + byProvider[pk].calls++; + byProvider[pk].totalLatency += l.latencyMs; + byProvider[pk].totalCost += l.estimatedCost; + if (!l.success) byProvider[pk].failures++; + + const mk = `${l.provider}/${l.model}`; + if (!byModel[mk]) byModel[mk] = { calls: 0, totalLatency: 0, totalCost: 0, failures: 0 }; + byModel[mk].calls++; + byModel[mk].totalLatency += l.latencyMs; + byModel[mk].totalCost += l.estimatedCost; + if (!l.success) byModel[mk].failures++; + } + + const providers = Object.entries(byProvider).map(([name, d]) => ({ + name, calls: d.calls, avgLatencyMs: Math.round(d.totalLatency / d.calls), + totalCost: d.totalCost.toFixed(4), failureRate: ((d.failures / d.calls) * 100).toFixed(1) + '%', + })); + const models = Object.entries(byModel).map(([name, d]) => ({ + name, calls: d.calls, avgLatencyMs: Math.round(d.totalLatency / d.calls), + totalCost: d.totalCost.toFixed(4), failureRate: ((d.failures / d.calls) * 100).toFixed(1) + '%', + })); + + return { totalCalls: logs.length, days: parseInt(days), providers, models }; + } + + @Get('worker') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: 'Worker 任务耗时统计' }) + async workerMetrics(@Query('days') days = '7') { + const since = new Date(Date.now() - parseInt(days) * 86400000); + const logs = await this.prisma.taskLog.findMany({ + where: { createdAt: { gte: since } }, + select: { queueName: true, status: true }, + }); + + const byQueue: Record = {}; + for (const l of logs) { + if (!byQueue[l.queueName]) byQueue[l.queueName] = { total: 0, completed: 0, failed: 0 }; + byQueue[l.queueName].total++; + if (l.status === 'completed' || l.status === 'retried') byQueue[l.queueName].completed++; + if (l.status === 'failed' || l.status === 'error') byQueue[l.queueName].failed++; + } + + const queues = Object.entries(byQueue).map(([name, d]) => ({ + name, total: d.total, completed: d.completed, failed: d.failed, + successRate: d.total > 0 ? ((d.completed / d.total) * 100).toFixed(1) + '%' : '0%', + })); + + return { totalTasks: logs.length, days: parseInt(days), queues }; + } } diff --git a/test/m1.e2e-spec.ts b/test/m1.e2e-spec.ts index e4d6e52..339786e 100644 --- a/test/m1.e2e-spec.ts +++ b/test/m1.e2e-spec.ts @@ -288,4 +288,39 @@ describe('M1 E2E Tests', () => { expect(res.body.success).toBe(true); }); }); + + // ══════════════════════════════════════════════ + // M1-05: Observability 深化 + // ══════════════════════════════════════════════ + describe('M1-05 Observability Deepening', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + it('GET /admin-api/metrics/ai → 200 AI performance stats', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/metrics/ai?days=7') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body.data).toHaveProperty('totalCalls'); + expect(res.body.data).toHaveProperty('providers'); + expect(res.body.data).toHaveProperty('models'); + }); + + it('GET /admin-api/metrics/ai → 401 without token', async () => { + await request(app.getHttpServer()) + .get('/admin-api/metrics/ai') + .expect(401); + }); + + it('GET /admin-api/metrics/worker → 200 Worker performance stats', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/metrics/worker?days=7') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body.data).toHaveProperty('totalTasks'); + expect(res.body.data).toHaveProperty('queues'); + }); + }); });