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
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:
parent
809f125107
commit
eb62868e8f
@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
107
src/modules/admin-costs/cost-aggregation.service.ts
Normal file
107
src/modules/admin-costs/cost-aggregation.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user