feat: M1-06 — Quota/Cost closing, AI cost aggregation + reports + CSV export
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 39s

- CostAggregationService: AiUsageLog → CostDailySummary daily aggregation
- AAPI: cost report by provider/model/daily trend, CSV export, top consumers
- Manual aggregation trigger endpoint

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-24 11:03:52 +08:00
parent 809f125107
commit eb62868e8f
5 changed files with 218 additions and 4 deletions

View File

@ -1,6 +1,8 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Body, Param, Query, Res, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import type { Response } from 'express';
import { AdminCostsService } from './admin-costs.service'; import { AdminCostsService } from './admin-costs.service';
import { CostAggregationService } from './cost-aggregation.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; import { AdminRoles } from '../../common/decorators/admin-roles.decorator';
import type { AdminRole } from '../../common/types/admin-role.enum'; import type { AdminRole } from '../../common/types/admin-role.enum';
@ -10,11 +12,52 @@ import type { AdminRole } from '../../common/types/admin-role.enum';
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class AdminCostsController { 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() @AdminRoles('SUPER_ADMIN' as AdminRole) async list() { return this.svc.list(); }
@Get('summary') @AdminRoles('SUPER_ADMIN' as AdminRole) async summary() { return this.svc.summary(); } @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); } @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); } @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); } @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) };
}
} }

View File

@ -1,7 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminCostsController } from './admin-costs.controller'; import { AdminCostsController } from './admin-costs.controller';
import { AdminCostsService } from './admin-costs.service'; import { AdminCostsService } from './admin-costs.service';
import { CostAggregationService } from './cost-aggregation.service';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; 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 {} export class AdminCostsModule {}

View File

@ -80,4 +80,21 @@ export class AdminCostsService {
expiringSoon: expiringSoon.sort((a: any, b: any) => a.daysLeft - b.daysLeft), 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),
}));
}
} }

View File

@ -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<void> {
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<string, { calls: number; tokens: number; cost: number }> = {};
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<string> {
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');
}
}

View File

@ -323,4 +323,50 @@ describe('M1 E2E Tests', () => {
expect(res.body.data).toHaveProperty('queues'); 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');
});
});
}); });