diff --git a/src/app.module.ts b/src/app.module.ts index 800cea5..6b7c42f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,7 +26,7 @@ import { AdminEventsModule } from './modules/admin-events/admin-events.module'; import { AdminKnowledgeModule } from './modules/admin-knowledge/admin-knowledge.module'; import { AdminCostsModule } from './modules/admin-costs/admin-costs.module'; import { AdminBillingModule } from './modules/admin-billing/admin-billing.module'; -import { AdminLearningModule } from './modules/admin-learning/admin-learning.module'; + import { AdminServersModule } from './modules/admin-servers/admin-servers.module'; import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module'; import { AdminAiChatModule } from './modules/admin-ai-chat/admin-ai-chat.module'; @@ -139,7 +139,6 @@ import appleConfig from './config/apple.config'; AdminCostsModule, AdminBillingModule, AdminServersModule, - AdminLearningModule, AdminConversationModule, AdminAiChatModule, AdminAuditLogModule, diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index 3255bdd..65dae14 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -33,7 +33,7 @@ export class JwtAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); // Admin and internal routes use their own auth guards - if (request.path.startsWith('/api/admin') || request.path.startsWith('/admin-api') || request.path.startsWith('/internal')) { + if (request.path.startsWith('/admin-api') || request.path.startsWith('/internal')) { return true; } diff --git a/src/modules/admin-learning/admin-learning.controller.ts b/src/modules/admin-learning/admin-learning.controller.ts deleted file mode 100644 index d1b78c1..0000000 --- a/src/modules/admin-learning/admin-learning.controller.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Controller, Get, Post, Param, Query, Body, UseGuards } from '@nestjs/common'; -import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; -import { AdminLearningService } from './admin-learning.service'; -import { EventFilterQuery, SessionFilterQuery, ProgressFilterQuery, RecordFilterQuery, PaginationQuery, BatchReprocessDto, RecalculateDto } from './dto/admin-learning.dto'; - -@Controller('admin/learning') -@UseGuards(AdminAuthGuard) -export class AdminLearningController { - constructor(private readonly service: AdminLearningService) {} - - // ── Dashboard ── - @Get('dashboard') - async getDashboard( - @Query('knowledgeBaseId') knowledgeBaseId?: string, - @Query('startDate') startDate?: string, - @Query('endDate') endDate?: string, - ) { return this.service.getDashboard({ knowledgeBaseId, startDate, endDate }); } - - // ── Knowledge Bases ── - @Get('knowledge-bases') - async getKnowledgeBases() { return this.service.getKnowledgeBases(); } - - // ── ReadingEvents ── - @Get('reading-events') - async getReadingEvents(@Query() query: EventFilterQuery) { return this.service.getReadingEvents(query); } - @Get('reading-events/failed') - async getFailedEvents(@Query() query: PaginationQuery) { return this.service.getFailedEvents(query); } - @Get('reading-events/:id') - async getReadingEvent(@Param('id') id: string) { return this.service.getReadingEvent(id); } - @Post('reading-events/:id/reprocess') - async reprocessEvent(@Param('id') id: string) { return this.service.reprocessEvent(id); } - @Post('reading-events/reprocess-batch') - async batchReprocess(@Body() dto: BatchReprocessDto) { return this.service.batchReprocess(dto.eventIds); } - - // ── Sessions ── - @Get('sessions') - async getSessions(@Query() query: SessionFilterQuery) { return this.service.getSessions(query); } - @Get('sessions/interrupted') - async getInterruptedSessions(@Query() query: PaginationQuery) { return this.service.getInterruptedSessions(query); } - @Get('sessions/:id') - async getSession(@Param('id') id: string) { return this.service.getSession(id); } - - // ── Progress ── - @Get('progress') - async getProgress(@Query() query: ProgressFilterQuery) { return this.service.getProgress(query); } - @Get('progress/:id') - async getProgressDetail(@Param('id') id: string) { return this.service.getProgressDetail(id); } - - // ── DailyActivities ── - @Get('daily-activities') - async getDailyActivities(@Query() query: PaginationQuery & { userId?: string; startDate?: string; endDate?: string }) { return this.service.getDailyActivities(query); } - - // ── Records ── - @Get('records') - async getRecords(@Query() query: RecordFilterQuery) { return this.service.getRecords(query); } - @Get('records/:id') - async getRecord(@Param('id') id: string) { return this.service.getRecord(id); } - - // ── Timeline ── - @Get('user-timeline') - async getUserTimeline(@Query('userId') userId: string, @Query('limit') limit?: string) { - return this.service.getUserTimeline(userId, limit ? parseInt(limit) : undefined); - } - - // ── Diagnose ── - @Get('user-diagnose') - async diagnoseUser(@Query('userId') userId: string) { return this.service.diagnoseUser(userId); } - @Get('material-diagnose') - async diagnoseMaterial(@Query('materialId') materialId: string) { return this.service.diagnoseMaterial(materialId); } - - // ── Anomalies ── - @Get('anomalies') - async getAnomalies(@Query() query: PaginationQuery) { return this.service.getAnomalies(query); } - - // ── Temporary Materials ── - @Get('temporary-materials') - async getTemporaryMaterials(@Query() query: PaginationQuery) { return this.service.getTemporaryMaterials(query); } - - // ── Operations ── - @Post('recalculate') - async recalculateLearningData(@Body() dto: RecalculateDto) { return this.service.recalculateLearningData(dto.userId); } - @Get('export') - async export(@Query('startDate') startDate?: string, @Query('endDate') endDate?: string, @Query('type') type?: string) { - return this.service.export({ startDate, endDate, type }); - } -} diff --git a/src/modules/admin-learning/admin-learning.module.ts b/src/modules/admin-learning/admin-learning.module.ts deleted file mode 100644 index f6901b3..0000000 --- a/src/modules/admin-learning/admin-learning.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PrismaModule } from '../../infrastructure/database/prisma.module'; -import { AiRuntimeModule } from '../ai-runtime/ai-runtime.module'; -import { AdminLearningController } from './admin-learning.controller'; -import { AdminLearningService } from './admin-learning.service'; - -@Module({ - imports: [PrismaModule, AiRuntimeModule], - controllers: [AdminLearningController], - providers: [AdminLearningService], -}) -export class AdminLearningModule {} diff --git a/src/modules/learning-session/admin-learning.controller.ts b/src/modules/learning-session/admin-learning.controller.ts index 6f9cd58..c22b8fc 100644 --- a/src/modules/learning-session/admin-learning.controller.ts +++ b/src/modules/learning-session/admin-learning.controller.ts @@ -1,100 +1,95 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; -import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { Controller, Get, Post, Param, Query, Body, UseGuards } from '@nestjs/common'; import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; +import { AdminLearningService } from './admin-learning.service'; +import { EventFilterQuery, SessionFilterQuery, ProgressFilterQuery, RecordFilterQuery, PaginationQuery, BatchReprocessDto, RecalculateDto } from './dto/admin-learning.dto'; -@ApiTags('admin-learning') -@ApiBearerAuth() @Controller('admin-api/learning') @UseGuards(AdminAuthGuard, AdminRolesGuard) export class AdminLearningController { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly service: AdminLearningService) {} + // ── Dashboard ── + @Get('dashboard') + async getDashboard( + @Query('knowledgeBaseId') knowledgeBaseId?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { return this.service.getDashboard({ knowledgeBaseId, startDate, endDate }); } + + // ── Knowledge Bases ── + @Get('knowledge-bases') + async getKnowledgeBases() { return this.service.getKnowledgeBases(); } + + // ── ReadingEvents ── + @Get('reading-events') + async getReadingEvents(@Query() query: EventFilterQuery) { return this.service.getReadingEvents(query); } + @Get('reading-events/:id') + async getReadingEvent(@Param('id') id: string) { return this.service.getReadingEvent(id); } + @Post('reading-events/:id/reprocess') + async reprocessEvent(@Param('id') id: string) { return this.service.reprocessEvent(id); } + @Post('reading-events/reprocess-batch') + async batchReprocess(@Body() dto: BatchReprocessDto) { return this.service.batchReprocess(dto.eventIds); } + + // ── Sessions ── @Get('sessions') - @ApiOperation({ summary: '学习会话列表' }) - @ApiQuery({ name: 'userId', required: false }) - @ApiQuery({ name: 'page', required: false }) - @ApiQuery({ name: 'limit', required: false }) - async listSessions( - @Query('userId') userId?: string, - @Query('page') page?: string, - @Query('limit') limit?: string, - ) { - const take = Math.min(Number(limit) || 20, 100); - const skip = (Math.max(Number(page) || 1, 1) - 1) * take; - const where: any = {}; - if (userId) where.userId = userId; + async getSessions(@Query() query: SessionFilterQuery) { return this.service.getSessions(query); } + @Get('sessions/:id') + async getSession(@Param('id') id: string) { return this.service.getSession(id); } - const [items, total] = await Promise.all([ - this.prisma.learningSession.findMany({ - where, - orderBy: { startedAt: 'desc' }, - take, - skip, - select: { id: true, userId: true, knowledgeBaseId: true, knowledgeItemId: true, mode: true, status: true, startedAt: true, endedAt: true, durationSeconds: true, focusMinutes: true }, - }), - this.prisma.learningSession.count({ where }), - ]); - return { items, total }; - } + // ── Progress ── + @Get('progress') + async getProgress(@Query() query: ProgressFilterQuery) { return this.service.getProgress(query); } + @Get('progress/:id') + async getProgressDetail(@Param('id') id: string) { return this.service.getProgressDetail(id); } + // ── DailyActivities ── + @Get('daily-activities') + async getDailyActivities(@Query() query: PaginationQuery & { userId?: string; startDate?: string; endDate?: string }) { return this.service.getDailyActivities(query); } + + // ── Records ── + @Get('records') + async getRecords(@Query() query: RecordFilterQuery) { return this.service.getRecords(query); } + @Get('records/:id') + async getRecord(@Param('id') id: string) { return this.service.getRecord(id); } + + // ── AI Analysis ── @Get('analysis') - @ApiOperation({ summary: 'AI 分析结果列表' }) - @ApiQuery({ name: 'userId', required: false }) - @ApiQuery({ name: 'page', required: false }) - @ApiQuery({ name: 'limit', required: false }) - async listAnalysis( - @Query('userId') userId?: string, - @Query('page') page?: string, - @Query('limit') limit?: string, - ) { - const take = Math.min(Number(limit) || 20, 100); - const skip = (Math.max(Number(page) || 1, 1) - 1) * take; - const where: any = {}; - if (userId) where.userId = userId; - - const [items, total] = await Promise.all([ - this.prisma.aiAnalysisResult.findMany({ - where, - orderBy: { createdAt: 'desc' }, - take, - skip, - select: { id: true, userId: true, jobId: true, summary: true, masteryScore: true, weaknesses: true, strengths: true, createdAt: true }, - }), - this.prisma.aiAnalysisResult.count({ where }), - ]); - return { items, total }; + async getAnalysis(@Query('userId') userId?: string, @Query('page') page?: string, @Query('limit') limit?: string) { + return this.service.getAnalysis({ userId, page: page ? parseInt(page) : undefined, limit: limit ? parseInt(limit) : undefined }); } + // ── AI Usage ── @Get('ai-usage') - @ApiOperation({ summary: 'AI 调用日志' }) - @ApiQuery({ name: 'userId', required: false }) - @ApiQuery({ name: 'model', required: false }) - @ApiQuery({ name: 'page', required: false }) - @ApiQuery({ name: 'limit', required: false }) - async listAiUsage( - @Query('userId') userId?: string, - @Query('model') model?: string, - @Query('page') page?: string, - @Query('limit') limit?: string, - ) { - const take = Math.min(Number(limit) || 20, 100); - const skip = (Math.max(Number(page) || 1, 1) - 1) * take; - const where: any = {}; - if (userId) where.userId = userId; - if (model) where.model = { contains: model }; + async getAiUsage(@Query('userId') userId?: string, @Query('model') model?: string, @Query('page') page?: string, @Query('limit') limit?: string) { + return this.service.getAiUsage({ userId, model, page: page ? parseInt(page) : undefined, limit: limit ? parseInt(limit) : undefined }); + } - const [items, total] = await Promise.all([ - this.prisma.aiUsageLog.findMany({ - where, - orderBy: { createdAt: 'desc' }, - take, - skip, - select: { id: true, userId: true, model: true, provider: true, inputTokens: true, outputTokens: true, estimatedCost: true, success: true, createdAt: true }, - }), - this.prisma.aiUsageLog.count({ where }), - ]); - return { items, total }; + // ── Timeline ── + @Get('user-timeline') + async getUserTimeline(@Query('userId') userId: string, @Query('limit') limit?: string) { + return this.service.getUserTimeline(userId, limit ? parseInt(limit) : undefined); + } + + // ── Diagnose ── + @Get('user-diagnose') + async diagnoseUser(@Query('userId') userId: string) { return this.service.diagnoseUser(userId); } + @Get('material-diagnose') + async diagnoseMaterial(@Query('materialId') materialId: string) { return this.service.diagnoseMaterial(materialId); } + + // ── Anomalies ── + @Get('anomalies') + async getAnomalies(@Query() query: PaginationQuery) { return this.service.getAnomalies(query); } + + // ── Temporary Materials ── + @Get('temporary-materials') + async getTemporaryMaterials(@Query() query: PaginationQuery) { return this.service.getTemporaryMaterials(query); } + + // ── Operations ── + @Post('recalculate') + async recalculateLearningData(@Body() dto: RecalculateDto) { return this.service.recalculateLearningData(dto.userId); } + @Get('export') + async export(@Query('startDate') startDate?: string, @Query('endDate') endDate?: string, @Query('type') type?: string) { + return this.service.export({ startDate, endDate, type }); } } diff --git a/src/modules/admin-learning/admin-learning.service.ts b/src/modules/learning-session/admin-learning.service.ts similarity index 90% rename from src/modules/admin-learning/admin-learning.service.ts rename to src/modules/learning-session/admin-learning.service.ts index 7300d35..d39e316 100644 --- a/src/modules/admin-learning/admin-learning.service.ts +++ b/src/modules/learning-session/admin-learning.service.ts @@ -12,7 +12,6 @@ export class AdminLearningService { // ══ Knowledge Bases (for filter dropdown) ══ async getKnowledgeBases() { - // Return knowledge bases that appear in reading_events, with real names from KB table const ids = await this.prisma.readingEvent.findMany({ where: { knowledgeBaseId: { not: null } }, select: { knowledgeBaseId: true }, @@ -37,7 +36,6 @@ export class AdminLearningService { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - // Base where clause for knowledgeBaseId filtering const eventWhere: any = {}; const sessionWhere: any = {}; const progressWhere: any = {}; @@ -47,7 +45,6 @@ export class AdminLearningService { sessionWhere.knowledgeBaseId = filters.knowledgeBaseId; progressWhere.knowledgeBaseId = filters.knowledgeBaseId; } - // Date range if (filters?.startDate || filters?.endDate) { eventWhere.serverReceivedAt = {}; if (filters?.startDate) eventWhere.serverReceivedAt.gte = new Date(filters.startDate); @@ -74,18 +71,14 @@ export class AdminLearningService { this.prisma.materialReadingProgress.count({ where: { ...progressWhere, isMarkedRead: true } }), ]); - // Warning events count const warningEvents = await this.prisma.readingEvent.count({ where: { ...eventWhere, status: 'warning' } }); return { - overview: { - totalEvents, todayEvents, failedEvents, duplicateEvents, warningEvents, - totalActiveSeconds: 0, // requires aggregation query, keep as 0 for now - }, + overview: { totalEvents, todayEvents, failedEvents, duplicateEvents, warningEvents, totalActiveSeconds: 0 }, sessions: { active: activeSessions, interrupted: interruptedSessions, completed: completedSessions, total: totalSessions }, users: { activeToday: activeToday.length, totalWithEvents: 0 }, materials: { totalRead, totalMarkedRead }, - recentAnomalies: [], // separate endpoint for this + recentAnomalies: [], }; } @@ -93,7 +86,8 @@ export class AdminLearningService { async getReadingEvents(query: { userId?: string; materialId?: string; readingTargetType?: string; - eventType?: string; status?: string; startDate?: string; endDate?: string; + eventType?: string; status?: string; errorCode?: string; + startDate?: string; endDate?: string; page?: number; limit?: number; sortBy?: string; order?: 'asc' | 'desc'; }) { const where: any = {}; @@ -102,6 +96,7 @@ export class AdminLearningService { if (query.readingTargetType) where.readingTargetType = query.readingTargetType; if (query.eventType) where.eventType = query.eventType; if (query.status) where.status = query.status; + if (query.errorCode) where.errorCode = query.errorCode; if (query.startDate || query.endDate) { where.serverReceivedAt = {}; if (query.startDate) where.serverReceivedAt.gte = new Date(query.startDate); @@ -117,7 +112,6 @@ export class AdminLearningService { this.prisma.readingEvent.findMany({ where, orderBy, skip: (page - 1) * limit, take: limit }), this.prisma.readingEvent.count({ where }), ]); - return { items, total, page, limit }; } @@ -127,17 +121,6 @@ export class AdminLearningService { return event; } - async getFailedEvents(query: { page?: number; limit?: number }) { - const where = { status: { in: ['failed', 'warning', 'duplicate'] } }; - const page = query.page ?? 1; - const limit = Math.min(query.limit ?? 20, 100); - const [items, total] = await Promise.all([ - this.prisma.readingEvent.findMany({ where, orderBy: { serverReceivedAt: 'desc' }, skip: (page - 1) * limit, take: limit }), - this.prisma.readingEvent.count({ where }), - ]); - return { items, total, page, limit }; - } - // ══ Sessions ══ async getSessions(query: { userId?: string; materialId?: string; status?: string; clientSessionId?: string; startDate?: string; endDate?: string; page?: number; limit?: number }) { @@ -151,12 +134,10 @@ export class AdminLearningService { const page = query.page ?? 1; const limit = Math.min(query.limit ?? 20, 100); - const [items, total] = await Promise.all([ this.prisma.learningSession.findMany({ where, orderBy: { startedAt: 'desc' }, skip: (page - 1) * limit, take: limit }), this.prisma.learningSession.count({ where }), ]); - return { items, total, page, limit }; } @@ -166,17 +147,6 @@ export class AdminLearningService { return session; } - async getInterruptedSessions(query: { page?: number; limit?: number }) { - const where = { status: 'interrupted' }; - const page = query.page ?? 1; - const limit = Math.min(query.limit ?? 20, 100); - const [items, total] = await Promise.all([ - this.prisma.learningSession.findMany({ where, orderBy: { startedAt: 'desc' }, skip: (page - 1) * limit, take: limit }), - this.prisma.learningSession.count({ where }), - ]); - return { items, total, page, limit }; - } - // ══ Progress ══ async getProgress(query: { userId?: string; materialId?: string; status?: string; page?: number; limit?: number }) { @@ -241,6 +211,41 @@ export class AdminLearningService { return r; } + // ══ AI Analysis ══ + + async getAnalysis(query: { userId?: string; page?: number; limit?: number }) { + const where: any = {}; + if (query.userId) where.userId = query.userId; + const page = query.page ?? 1; + const limit = Math.min(query.limit ?? 20, 100); + const [items, total] = await Promise.all([ + this.prisma.aiAnalysisResult.findMany({ + where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, + select: { id: true, userId: true, jobId: true, summary: true, masteryScore: true, weaknesses: true, strengths: true, createdAt: true }, + }), + this.prisma.aiAnalysisResult.count({ where }), + ]); + return { items, total, page, limit }; + } + + // ══ AI Usage ══ + + async getAiUsage(query: { userId?: string; model?: string; page?: number; limit?: number }) { + const where: any = {}; + if (query.userId) where.userId = query.userId; + if (query.model) where.model = { contains: query.model }; + const page = query.page ?? 1; + const limit = Math.min(query.limit ?? 20, 100); + const [items, total] = await Promise.all([ + this.prisma.aiUsageLog.findMany({ + where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, + select: { id: true, userId: true, model: true, provider: true, inputTokens: true, outputTokens: true, estimatedCost: true, success: true, createdAt: true }, + }), + this.prisma.aiUsageLog.count({ where }), + ]); + return { items, total, page, limit }; + } + // ══ Timeline ══ async getUserTimeline(userId: string, limit: number = 50) { @@ -250,14 +255,12 @@ export class AdminLearningService { take: Math.min(limit, 200), select: { eventId: true, eventType: true, materialId: true, clientTimestampMs: true, status: true }, }); - const sessions = await this.prisma.learningSession.findMany({ where: { userId }, orderBy: { startedAt: 'desc' }, take: Math.min(limit, 50), select: { id: true, mode: true, status: true, startedAt: true, endedAt: true, totalActiveSeconds: true }, }); - return { events, sessions }; } @@ -274,12 +277,7 @@ export class AdminLearningService { this.prisma.readingEvent.count({ where: { status: 'failed' } }), this.prisma.readingEvent.count({ where: { status: 'duplicate' } }), ]); - - return { - failed: { items: failed, total: failedTotal }, - duplicates: { items: duplicates, total: duplicatesTotal }, - page, limit, - }; + return { failed: { items: failed, total: failedTotal }, duplicates: { items: duplicates, total: duplicatesTotal }, page, limit }; } // ══ Diagnose ══ @@ -337,7 +335,6 @@ export class AdminLearningService { const snapshot = await this.snapshotBuilder.buildSnapshot(userId, 'user', userId); return { status: 'recalculated', userId, snapshotId: snapshot.id }; } - // Recalculate for all active users (limit to 100) const users = await this.prisma.dailyLearningActivity.groupBy({ by: ['userId'], _count: true, take: 100, orderBy: { _count: { userId: 'desc' } } }); const snapshots = await Promise.all(users.map(u => this.snapshotBuilder.buildSnapshot(u.userId, 'user', u.userId).catch(() => null) diff --git a/src/modules/admin-learning/dto/admin-learning.dto.ts b/src/modules/learning-session/dto/admin-learning.dto.ts similarity index 100% rename from src/modules/admin-learning/dto/admin-learning.dto.ts rename to src/modules/learning-session/dto/admin-learning.dto.ts diff --git a/src/modules/learning-session/learning-session.module.ts b/src/modules/learning-session/learning-session.module.ts index 852ae1b..85e4189 100644 --- a/src/modules/learning-session/learning-session.module.ts +++ b/src/modules/learning-session/learning-session.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../infrastructure/database/prisma.module'; +import { AiRuntimeModule } from '../ai-runtime/ai-runtime.module'; import { LearningSessionController } from './learning-session.controller'; import { AdminLearningController } from './admin-learning.controller'; +import { AdminLearningService } from './admin-learning.service'; import { LearningSessionService } from './learning-session.service'; import { LearningSessionRepository } from './learning-session.repository'; @Module({ + imports: [PrismaModule, AiRuntimeModule], controllers: [LearningSessionController, AdminLearningController], - providers: [LearningSessionService, LearningSessionRepository], + providers: [AdminLearningService, LearningSessionService, LearningSessionRepository], exports: [LearningSessionService, LearningSessionRepository], }) export class LearningSessionModule {}