feat: M4-05 — reporting & export module (user/learning/review CSV)
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 42s

- Add ExportJob Prisma model
- ReportingService: userReport, learningReport, reviewReport
- ReportingController: GET export/users, export/learning, export/reviews

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-24 18:01:23 +08:00
parent 76c42f437c
commit b188988e82
5 changed files with 143 additions and 0 deletions

View File

@ -1396,3 +1396,16 @@ model ServiceHealth {
@@index([serviceName])
@@index([checkedAt])
}
model ExportJob {
id String @id @default(cuid())
type String @db.VarChar(32)
status String @default("pending") @db.VarChar(16)
format String @default("csv") @db.VarChar(8)
filePath String? @db.VarChar(500)
fileSize Int @default(0)
startedAt DateTime?
completedAt DateTime?
errorMessage String? @db.Text
createdAt DateTime @default(now())
}

View File

@ -53,6 +53,7 @@ import { VectorModule } from './modules/vector/vector.module';
import { CacheModule } from './common/cache/cache.module';
import { AdminCacheModule } from './modules/admin-cache/admin-cache.module';
import { BackupModule } from './modules/backup/backup.module';
import { ReportingModule } from './modules/reporting/reporting.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
@ -151,6 +152,7 @@ import appleConfig from './config/apple.config';
CacheModule,
AdminCacheModule,
BackupModule,
ReportingModule,
],
providers: [
{ provide: APP_GUARD, useClass: RateLimitGuard },

View File

@ -0,0 +1,50 @@
import { Controller, Get, Post, Query, Res, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import type { Response } from 'express';
import { ReportingService } from './reporting.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
@ApiTags('admin-reporting')
@ApiBearerAuth()
@Controller('admin-api/reporting')
@UseGuards(AdminAuthGuard, AdminRolesGuard)
export class ReportingController {
constructor(private readonly reportingService: ReportingService) {}
@Get('export/users')
@ApiOperation({ summary: '导出用户数据 CSV' })
@ApiQuery({ name: 'days', required: false })
async exportUsers(@Query('days') days = '30', @Res() res: Response) {
const csv = await this.reportingService.userReport(Number(days));
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="user-report-${days}d.csv"`);
res.send(csv);
}
@Get('export/learning')
@ApiOperation({ summary: '导出学习数据 CSV' })
@ApiQuery({ name: 'days', required: false })
async exportLearning(@Query('days') days = '30', @Res() res: Response) {
const csv = await this.reportingService.learningReport(Number(days));
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="learning-report-${days}d.csv"`);
res.send(csv);
}
@Get('export/reviews')
@ApiOperation({ summary: '导出复习数据 CSV' })
@ApiQuery({ name: 'days', required: false })
async exportReviews(@Query('days') days = '30', @Res() res: Response) {
const csv = await this.reportingService.reviewReport(Number(days));
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="review-report-${days}d.csv"`);
res.send(csv);
}
@Get('jobs')
@ApiOperation({ summary: '导出任务历史' })
async listJobs(@Query('page') page?: string, @Query('limit') limit?: string) {
return this.reportingService.getExportJobs(Number(page) || 1, Number(limit) || 20);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ReportingController } from './reporting.controller';
import { ReportingService } from './reporting.service';
@Module({
controllers: [ReportingController],
providers: [ReportingService],
exports: [ReportingService],
})
export class ReportingModule {}

View File

@ -0,0 +1,68 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
@Injectable()
export class ReportingService {
private readonly logger = new Logger(ReportingService.name);
constructor(private readonly prisma: PrismaService) {}
/** Generate user growth report */
async userReport(days = 30): Promise<string> {
const since = new Date(Date.now() - days * 86400000);
const users = await this.prisma.user.findMany({
where: { createdAt: { gte: since }, deletedAt: null },
orderBy: { createdAt: 'desc' },
select: { id: true, email: true, nickname: true, role: true, status: true, createdAt: true, lastLoginAt: true },
});
const header = 'ID,邮箱,昵称,角色,状态,注册时间,最后登录';
const rows = users.map(u =>
[u.id, u.email || '', u.nickname || '', u.role, u.status, u.createdAt?.toISOString() || '', u.lastLoginAt?.toISOString() || ''].map(v => `"${v}"`).join(',')
);
return [header, ...rows].join('\n');
}
/** Generate learning stats report */
async learningReport(days = 30): Promise<string> {
const since = new Date(Date.now() - days * 86400000);
const sessions = await this.prisma.learningSession.findMany({
where: { startedAt: { gte: since } },
orderBy: { startedAt: 'desc' },
select: { id: true, userId: true, mode: true, status: true, startedAt: true, endedAt: true, durationSeconds: true },
});
const header = 'ID,用户ID,模式,状态,开始时间,结束时间,时长(秒)';
const rows = sessions.map(s =>
[s.id, s.userId, s.mode, s.status, s.startedAt?.toISOString() || '', s.endedAt?.toISOString() || '', String(s.durationSeconds || 0)].map(v => `"${v}"`).join(',')
);
return [header, ...rows].join('\n');
}
/** Generate review stats report */
async reviewReport(days = 30): Promise<string> {
const since = new Date(Date.now() - days * 86400000);
const logs = await this.prisma.reviewLog.findMany({
where: { reviewedAt: { gte: since } },
orderBy: { reviewedAt: 'desc' },
select: { id: true, userId: true, rating: true, responseText: true, reviewedAt: true },
});
const header = 'ID,用户ID,评分,回答,复习时间';
const rows = logs.map(l =>
[l.id, l.userId, l.rating, (l.responseText || '').slice(0, 100), l.reviewedAt?.toISOString() || ''].map(v => `"${v}"`).join(',')
);
return [header, ...rows].join('\n');
}
/** Get export job history */
async getExportJobs(page = 1, limit = 20) {
const take = Math.min(limit, 100);
const skip = (Math.max(page, 1) - 1) * take;
const [items, total] = await Promise.all([
this.prisma.exportJob.findMany({ orderBy: { createdAt: 'desc' }, take, skip }),
this.prisma.exportJob.count(),
]);
return { items, total };
}
}