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