feat: M4-05 — reporting & export module (user/learning/review CSV)
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 42s
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:
parent
76c42f437c
commit
b188988e82
@ -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())
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
50
src/modules/reporting/reporting.controller.ts
Normal file
50
src/modules/reporting/reporting.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
src/modules/reporting/reporting.module.ts
Normal file
10
src/modules/reporting/reporting.module.ts
Normal 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 {}
|
||||
68
src/modules/reporting/reporting.service.ts
Normal file
68
src/modules/reporting/reporting.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user