diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 00c0272..3acfb3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) +} diff --git a/src/app.module.ts b/src/app.module.ts index 1965dda..9e270a1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 }, diff --git a/src/modules/reporting/reporting.controller.ts b/src/modules/reporting/reporting.controller.ts new file mode 100644 index 0000000..6971ee0 --- /dev/null +++ b/src/modules/reporting/reporting.controller.ts @@ -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); + } +} diff --git a/src/modules/reporting/reporting.module.ts b/src/modules/reporting/reporting.module.ts new file mode 100644 index 0000000..1560f5b --- /dev/null +++ b/src/modules/reporting/reporting.module.ts @@ -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 {} diff --git a/src/modules/reporting/reporting.service.ts b/src/modules/reporting/reporting.service.ts new file mode 100644 index 0000000..31ffee2 --- /dev/null +++ b/src/modules/reporting/reporting.service.ts @@ -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 { + 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 { + 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 { + 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 }; + } +}