diff --git a/src/modules/admin-costs/admin-costs.controller.ts b/src/modules/admin-costs/admin-costs.controller.ts index a9dcf3d..c067f28 100644 --- a/src/modules/admin-costs/admin-costs.controller.ts +++ b/src/modules/admin-costs/admin-costs.controller.ts @@ -1,6 +1,8 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { Controller, Get, Post, Patch, Delete, Body, Param, Query, Res, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import type { Response } from 'express'; import { AdminCostsService } from './admin-costs.service'; +import { CostAggregationService } from './cost-aggregation.service'; import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; import type { AdminRole } from '../../common/types/admin-role.enum'; @@ -10,11 +12,52 @@ import type { AdminRole } from '../../common/types/admin-role.enum'; @UseGuards(AdminAuthGuard) @ApiBearerAuth() export class AdminCostsController { - constructor(private readonly svc: AdminCostsService) {} + constructor( + private readonly svc: AdminCostsService, + private readonly costAgg: CostAggregationService, + ) {} @Get() @AdminRoles('SUPER_ADMIN' as AdminRole) async list() { return this.svc.list(); } @Get('summary') @AdminRoles('SUPER_ADMIN' as AdminRole) async summary() { return this.svc.summary(); } @Post() @AdminRoles('SUPER_ADMIN' as AdminRole) async create(@Body() d: any) { return this.svc.create(d); } @Patch(':id') @AdminRoles('SUPER_ADMIN' as AdminRole) async update(@Param('id') id: string, @Body() d: any) { return this.svc.update(id, d); } @Delete(':id') @AdminRoles('SUPER_ADMIN' as AdminRole) async delete(@Param('id') id: string) { return this.svc.delete(id); } + + // ── AI Cost Report ── + + @Get('report') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: 'AI 成本报表(按provider/模型/日趋势)' }) + async report(@Query('days') days = '30') { + return this.costAgg.getReport(parseInt(days)); + } + + @Post('aggregate') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '手动触发成本汇总' }) + async aggregate() { + await this.costAgg.aggregateToday(); + return { success: true }; + } + + @Get('export-csv') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '导出成本 CSV' }) + async exportCsv(@Query('days') days = '30', @Res() res: Response) { + const csv = await this.costAgg.exportCsv(parseInt(days)); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="cost-report-${days}d.csv"`); + res.send(csv); + } + + // ── Top consumers ── + + @Get('top-users') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: 'Top 消耗用户' }) + async topUsers(@Query('days') days = '30', @Query('limit') limit = '10') { + const since = new Date(Date.now() - parseInt(days) * 86400000); + const top = await this.svc.getTopConsumers(since, parseInt(limit)); + return { top, days: parseInt(days) }; + } } diff --git a/src/modules/admin-costs/admin-costs.module.ts b/src/modules/admin-costs/admin-costs.module.ts index 7c33760..c029324 100644 --- a/src/modules/admin-costs/admin-costs.module.ts +++ b/src/modules/admin-costs/admin-costs.module.ts @@ -1,7 +1,8 @@ import { Module } from '@nestjs/common'; import { AdminCostsController } from './admin-costs.controller'; import { AdminCostsService } from './admin-costs.service'; +import { CostAggregationService } from './cost-aggregation.service'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; -@Module({ controllers: [AdminCostsController], providers: [AdminCostsService, PrismaService, AdminAuthGuard] }) +@Module({ controllers: [AdminCostsController], providers: [AdminCostsService, CostAggregationService, PrismaService, AdminAuthGuard] }) export class AdminCostsModule {} diff --git a/src/modules/admin-costs/admin-costs.service.ts b/src/modules/admin-costs/admin-costs.service.ts index 70e8a2a..6cb699c 100644 --- a/src/modules/admin-costs/admin-costs.service.ts +++ b/src/modules/admin-costs/admin-costs.service.ts @@ -80,4 +80,21 @@ export class AdminCostsService { expiringSoon: expiringSoon.sort((a: any, b: any) => a.daysLeft - b.daysLeft), }; } + + async getTopConsumers(since: Date, limit: number) { + const top = await this.prisma.aiUsageLog.groupBy({ + by: ['userId'], + where: { createdAt: { gte: since } }, + _sum: { estimatedCost: true, inputTokens: true, outputTokens: true }, + _count: { id: true }, + orderBy: { _sum: { estimatedCost: 'desc' } }, + take: limit, + }); + return top.map(t => ({ + userId: t.userId, + calls: t._count.id, + tokens: (t._sum.inputTokens || 0) + (t._sum.outputTokens || 0), + cost: (t._sum.estimatedCost || 0).toFixed(4), + })); + } } diff --git a/src/modules/admin-costs/cost-aggregation.service.ts b/src/modules/admin-costs/cost-aggregation.service.ts new file mode 100644 index 0000000..53485da --- /dev/null +++ b/src/modules/admin-costs/cost-aggregation.service.ts @@ -0,0 +1,107 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@Injectable() +export class CostAggregationService { + private readonly logger = new Logger(CostAggregationService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** Aggregate today's AiUsageLog into CostDailySummary */ + async aggregateToday(): Promise { + const today = new Date(); today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); + + const logs = await this.prisma.aiUsageLog.findMany({ + where: { createdAt: { gte: today, lt: tomorrow }, success: true }, + select: { provider: true, model: true, inputTokens: true, outputTokens: true, estimatedCost: true }, + }); + + if (logs.length === 0) return; + + // Group by provider+model + const groups: Record = {}; + for (const l of logs) { + const key = `${l.provider}|${l.model}`; + if (!groups[key]) groups[key] = { calls: 0, tokens: 0, cost: 0 }; + groups[key].calls++; + groups[key].tokens += l.inputTokens + l.outputTokens; + groups[key].cost += l.estimatedCost; + } + + // Upsert into CostDailySummary + for (const [key, data] of Object.entries(groups)) { + const [provider, model] = key.split('|'); + await this.prisma.costDailySummary.upsert({ + where: { date_provider_model: { date: today, provider, model } }, + update: { calls: data.calls, tokens: data.tokens, cost: data.cost }, + create: { date: today, provider, model, calls: data.calls, tokens: data.tokens, cost: data.cost }, + }); + } + + this.logger.log(`Aggregated ${logs.length} AI calls into CostDailySummary`); + } + + /** Get cost report by time range */ + async getReport(days = 30) { + const since = new Date(Date.now() - days * 86400000); + + const [byProvider, byModel, dailyTrend] = await Promise.all([ + this.prisma.costDailySummary.groupBy({ + by: ['provider'], + where: { date: { gte: since } }, + _sum: { calls: true, tokens: true, cost: true }, + }), + this.prisma.costDailySummary.groupBy({ + by: ['model'], + where: { date: { gte: since } }, + _sum: { calls: true, tokens: true, cost: true }, + }), + this.prisma.costDailySummary.findMany({ + where: { date: { gte: since } }, + orderBy: { date: 'asc' }, + }), + ]); + + const totalCost = byProvider.reduce((s, p) => s + (p._sum.cost || 0), 0); + const totalCalls = byProvider.reduce((s, p) => s + (p._sum.calls || 0), 0); + + return { + period: `${days} days`, + totalCost: totalCost.toFixed(4), + totalCalls, + byProvider: byProvider.map(p => ({ + provider: p.provider, + calls: p._sum.calls || 0, + tokens: p._sum.tokens || 0, + cost: (p._sum.cost || 0).toFixed(4), + })), + byModel: byModel.map(m => ({ + model: m.model, + calls: m._sum.calls || 0, + tokens: m._sum.tokens || 0, + cost: (m._sum.cost || 0).toFixed(4), + })), + dailyTrend: dailyTrend.map(d => ({ + date: d.date.toISOString().slice(0, 10), + provider: d.provider, + model: d.model, + calls: d.calls, + cost: d.cost, + })), + }; + } + + /** Generate CSV export string */ + async exportCsv(days = 30): Promise { + const since = new Date(Date.now() - days * 86400000); + const rows = await this.prisma.costDailySummary.findMany({ + where: { date: { gte: since } }, + orderBy: { date: 'asc' }, + }); + + const header = 'date,provider,model,calls,tokens,cost'; + const lines = rows.map(r => `${r.date.toISOString().slice(0,10)},${r.provider},${r.model},${r.calls},${r.tokens},${r.cost}`); + return [header, ...lines].join('\n'); + } +} diff --git a/test/m1.e2e-spec.ts b/test/m1.e2e-spec.ts index 339786e..258eb6e 100644 --- a/test/m1.e2e-spec.ts +++ b/test/m1.e2e-spec.ts @@ -323,4 +323,50 @@ describe('M1 E2E Tests', () => { expect(res.body.data).toHaveProperty('queues'); }); }); + + // ══════════════════════════════════════════════ + // M1-06: Quota/Cost 闭环 + // ══════════════════════════════════════════════ + describe('M1-06 Quota/Cost Closing', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + it('GET /admin-api/costs/report → 200 AI cost report', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/costs/report?days=30') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body.data).toHaveProperty('totalCost'); + expect(res.body.data).toHaveProperty('byProvider'); + expect(res.body.data).toHaveProperty('dailyTrend'); + }); + + it('POST /admin-api/costs/aggregate → 200 trigger aggregation', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .post('/admin-api/costs/aggregate') + .set('Authorization', `Bearer ${token}`) + .expect([200, 201]); + expect(res.body.success).toBe(true); + }); + + it('GET /admin-api/costs/top-users → 200 top consumers', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/costs/top-users?days=30&limit=10') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body.data).toHaveProperty('top'); + }); + + it('GET /admin-api/costs/export-csv → 200 CSV download', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/costs/export-csv?days=7') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.headers['content-type']).toContain('text/csv'); + }); + }); });