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([serviceName])
|
||||||
@@index([checkedAt])
|
@@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 { CacheModule } from './common/cache/cache.module';
|
||||||
import { AdminCacheModule } from './modules/admin-cache/admin-cache.module';
|
import { AdminCacheModule } from './modules/admin-cache/admin-cache.module';
|
||||||
import { BackupModule } from './modules/backup/backup.module';
|
import { BackupModule } from './modules/backup/backup.module';
|
||||||
|
import { ReportingModule } from './modules/reporting/reporting.module';
|
||||||
|
|
||||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './common/guards/roles.guard';
|
import { RolesGuard } from './common/guards/roles.guard';
|
||||||
@ -151,6 +152,7 @@ import appleConfig from './config/apple.config';
|
|||||||
CacheModule,
|
CacheModule,
|
||||||
AdminCacheModule,
|
AdminCacheModule,
|
||||||
BackupModule,
|
BackupModule,
|
||||||
|
ReportingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: RateLimitGuard },
|
{ 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