refactor: merge admin/learning into admin-api/learning, restore API isolation
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 45s

- Move AdminLearningService + DTOs to learning-session module
- Merge 21 new endpoints into existing admin-api/learning controller
- Add analysis and ai-usage methods to unified service
- Delete admin-learning module (no longer needed)
- Revert JwtAuthGuard /api/admin bypass (was breaking isolation)
- Fix: /api/* now exclusively serves user/iOS traffic again

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-18 19:26:37 +08:00
parent 04da939a22
commit 82e3a60101
8 changed files with 126 additions and 229 deletions

View File

@ -26,7 +26,7 @@ import { AdminEventsModule } from './modules/admin-events/admin-events.module';
import { AdminKnowledgeModule } from './modules/admin-knowledge/admin-knowledge.module'; import { AdminKnowledgeModule } from './modules/admin-knowledge/admin-knowledge.module';
import { AdminCostsModule } from './modules/admin-costs/admin-costs.module'; import { AdminCostsModule } from './modules/admin-costs/admin-costs.module';
import { AdminBillingModule } from './modules/admin-billing/admin-billing.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 { AdminServersModule } from './modules/admin-servers/admin-servers.module';
import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module'; import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module';
import { AdminAiChatModule } from './modules/admin-ai-chat/admin-ai-chat.module'; import { AdminAiChatModule } from './modules/admin-ai-chat/admin-ai-chat.module';
@ -139,7 +139,6 @@ import appleConfig from './config/apple.config';
AdminCostsModule, AdminCostsModule,
AdminBillingModule, AdminBillingModule,
AdminServersModule, AdminServersModule,
AdminLearningModule,
AdminConversationModule, AdminConversationModule,
AdminAiChatModule, AdminAiChatModule,
AdminAuditLogModule, AdminAuditLogModule,

View File

@ -33,7 +33,7 @@ export class JwtAuthGuard implements CanActivate {
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
// Admin and internal routes use their own auth guards // 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; return true;
} }

View File

@ -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 });
}
}

View File

@ -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 {}

View File

@ -1,100 +1,95 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Post, Param, Query, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.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') @Controller('admin-api/learning')
@UseGuards(AdminAuthGuard, AdminRolesGuard) @UseGuards(AdminAuthGuard, AdminRolesGuard)
export class AdminLearningController { 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') @Get('sessions')
@ApiOperation({ summary: '学习会话列表' }) async getSessions(@Query() query: SessionFilterQuery) { return this.service.getSessions(query); }
@ApiQuery({ name: 'userId', required: false }) @Get('sessions/:id')
@ApiQuery({ name: 'page', required: false }) async getSession(@Param('id') id: string) { return this.service.getSession(id); }
@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;
const [items, total] = await Promise.all([ // ── Progress ──
this.prisma.learningSession.findMany({ @Get('progress')
where, async getProgress(@Query() query: ProgressFilterQuery) { return this.service.getProgress(query); }
orderBy: { startedAt: 'desc' }, @Get('progress/:id')
take, async getProgressDetail(@Param('id') id: string) { return this.service.getProgressDetail(id); }
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 };
}
// ── 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') @Get('analysis')
@ApiOperation({ summary: 'AI 分析结果列表' }) async getAnalysis(@Query('userId') userId?: string, @Query('page') page?: string, @Query('limit') limit?: string) {
@ApiQuery({ name: 'userId', required: false }) return this.service.getAnalysis({ userId, page: page ? parseInt(page) : undefined, limit: limit ? parseInt(limit) : undefined });
@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 };
} }
// ── AI Usage ──
@Get('ai-usage') @Get('ai-usage')
@ApiOperation({ summary: 'AI 调用日志' }) async getAiUsage(@Query('userId') userId?: string, @Query('model') model?: string, @Query('page') page?: string, @Query('limit') limit?: string) {
@ApiQuery({ name: 'userId', required: false }) return this.service.getAiUsage({ userId, model, page: page ? parseInt(page) : undefined, limit: limit ? parseInt(limit) : undefined });
@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 };
const [items, total] = await Promise.all([ // ── Timeline ──
this.prisma.aiUsageLog.findMany({ @Get('user-timeline')
where, async getUserTimeline(@Query('userId') userId: string, @Query('limit') limit?: string) {
orderBy: { createdAt: 'desc' }, return this.service.getUserTimeline(userId, limit ? parseInt(limit) : undefined);
take, }
skip,
select: { id: true, userId: true, model: true, provider: true, inputTokens: true, outputTokens: true, estimatedCost: true, success: true, createdAt: true }, // ── Diagnose ──
}), @Get('user-diagnose')
this.prisma.aiUsageLog.count({ where }), async diagnoseUser(@Query('userId') userId: string) { return this.service.diagnoseUser(userId); }
]); @Get('material-diagnose')
return { items, total }; 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 });
} }
} }

View File

@ -12,7 +12,6 @@ export class AdminLearningService {
// ══ Knowledge Bases (for filter dropdown) ══ // ══ Knowledge Bases (for filter dropdown) ══
async getKnowledgeBases() { async getKnowledgeBases() {
// Return knowledge bases that appear in reading_events, with real names from KB table
const ids = await this.prisma.readingEvent.findMany({ const ids = await this.prisma.readingEvent.findMany({
where: { knowledgeBaseId: { not: null } }, where: { knowledgeBaseId: { not: null } },
select: { knowledgeBaseId: true }, select: { knowledgeBaseId: true },
@ -37,7 +36,6 @@ export class AdminLearningService {
const now = new Date(); const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Base where clause for knowledgeBaseId filtering
const eventWhere: any = {}; const eventWhere: any = {};
const sessionWhere: any = {}; const sessionWhere: any = {};
const progressWhere: any = {}; const progressWhere: any = {};
@ -47,7 +45,6 @@ export class AdminLearningService {
sessionWhere.knowledgeBaseId = filters.knowledgeBaseId; sessionWhere.knowledgeBaseId = filters.knowledgeBaseId;
progressWhere.knowledgeBaseId = filters.knowledgeBaseId; progressWhere.knowledgeBaseId = filters.knowledgeBaseId;
} }
// Date range
if (filters?.startDate || filters?.endDate) { if (filters?.startDate || filters?.endDate) {
eventWhere.serverReceivedAt = {}; eventWhere.serverReceivedAt = {};
if (filters?.startDate) eventWhere.serverReceivedAt.gte = new Date(filters.startDate); 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 } }), this.prisma.materialReadingProgress.count({ where: { ...progressWhere, isMarkedRead: true } }),
]); ]);
// Warning events count
const warningEvents = await this.prisma.readingEvent.count({ where: { ...eventWhere, status: 'warning' } }); const warningEvents = await this.prisma.readingEvent.count({ where: { ...eventWhere, status: 'warning' } });
return { return {
overview: { overview: { totalEvents, todayEvents, failedEvents, duplicateEvents, warningEvents, totalActiveSeconds: 0 },
totalEvents, todayEvents, failedEvents, duplicateEvents, warningEvents,
totalActiveSeconds: 0, // requires aggregation query, keep as 0 for now
},
sessions: { active: activeSessions, interrupted: interruptedSessions, completed: completedSessions, total: totalSessions }, sessions: { active: activeSessions, interrupted: interruptedSessions, completed: completedSessions, total: totalSessions },
users: { activeToday: activeToday.length, totalWithEvents: 0 }, users: { activeToday: activeToday.length, totalWithEvents: 0 },
materials: { totalRead, totalMarkedRead }, materials: { totalRead, totalMarkedRead },
recentAnomalies: [], // separate endpoint for this recentAnomalies: [],
}; };
} }
@ -93,7 +86,8 @@ export class AdminLearningService {
async getReadingEvents(query: { async getReadingEvents(query: {
userId?: string; materialId?: string; readingTargetType?: string; 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'; page?: number; limit?: number; sortBy?: string; order?: 'asc' | 'desc';
}) { }) {
const where: any = {}; const where: any = {};
@ -102,6 +96,7 @@ export class AdminLearningService {
if (query.readingTargetType) where.readingTargetType = query.readingTargetType; if (query.readingTargetType) where.readingTargetType = query.readingTargetType;
if (query.eventType) where.eventType = query.eventType; if (query.eventType) where.eventType = query.eventType;
if (query.status) where.status = query.status; if (query.status) where.status = query.status;
if (query.errorCode) where.errorCode = query.errorCode;
if (query.startDate || query.endDate) { if (query.startDate || query.endDate) {
where.serverReceivedAt = {}; where.serverReceivedAt = {};
if (query.startDate) where.serverReceivedAt.gte = new Date(query.startDate); 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.findMany({ where, orderBy, skip: (page - 1) * limit, take: limit }),
this.prisma.readingEvent.count({ where }), this.prisma.readingEvent.count({ where }),
]); ]);
return { items, total, page, limit }; return { items, total, page, limit };
} }
@ -127,17 +121,6 @@ export class AdminLearningService {
return event; 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 ══ // ══ Sessions ══
async getSessions(query: { userId?: string; materialId?: string; status?: string; clientSessionId?: string; startDate?: string; endDate?: string; page?: number; limit?: number }) { 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 page = query.page ?? 1;
const limit = Math.min(query.limit ?? 20, 100); const limit = Math.min(query.limit ?? 20, 100);
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.learningSession.findMany({ where, orderBy: { startedAt: 'desc' }, skip: (page - 1) * limit, take: limit }), this.prisma.learningSession.findMany({ where, orderBy: { startedAt: 'desc' }, skip: (page - 1) * limit, take: limit }),
this.prisma.learningSession.count({ where }), this.prisma.learningSession.count({ where }),
]); ]);
return { items, total, page, limit }; return { items, total, page, limit };
} }
@ -166,17 +147,6 @@ export class AdminLearningService {
return session; 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 ══ // ══ Progress ══
async getProgress(query: { userId?: string; materialId?: string; status?: string; page?: number; limit?: number }) { async getProgress(query: { userId?: string; materialId?: string; status?: string; page?: number; limit?: number }) {
@ -241,6 +211,41 @@ export class AdminLearningService {
return r; 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 ══ // ══ Timeline ══
async getUserTimeline(userId: string, limit: number = 50) { async getUserTimeline(userId: string, limit: number = 50) {
@ -250,14 +255,12 @@ export class AdminLearningService {
take: Math.min(limit, 200), take: Math.min(limit, 200),
select: { eventId: true, eventType: true, materialId: true, clientTimestampMs: true, status: true }, select: { eventId: true, eventType: true, materialId: true, clientTimestampMs: true, status: true },
}); });
const sessions = await this.prisma.learningSession.findMany({ const sessions = await this.prisma.learningSession.findMany({
where: { userId }, where: { userId },
orderBy: { startedAt: 'desc' }, orderBy: { startedAt: 'desc' },
take: Math.min(limit, 50), take: Math.min(limit, 50),
select: { id: true, mode: true, status: true, startedAt: true, endedAt: true, totalActiveSeconds: true }, select: { id: true, mode: true, status: true, startedAt: true, endedAt: true, totalActiveSeconds: true },
}); });
return { events, sessions }; return { events, sessions };
} }
@ -274,12 +277,7 @@ export class AdminLearningService {
this.prisma.readingEvent.count({ where: { status: 'failed' } }), this.prisma.readingEvent.count({ where: { status: 'failed' } }),
this.prisma.readingEvent.count({ where: { status: 'duplicate' } }), 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 ══ // ══ Diagnose ══
@ -337,7 +335,6 @@ export class AdminLearningService {
const snapshot = await this.snapshotBuilder.buildSnapshot(userId, 'user', userId); const snapshot = await this.snapshotBuilder.buildSnapshot(userId, 'user', userId);
return { status: 'recalculated', userId, snapshotId: snapshot.id }; 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 users = await this.prisma.dailyLearningActivity.groupBy({ by: ['userId'], _count: true, take: 100, orderBy: { _count: { userId: 'desc' } } });
const snapshots = await Promise.all(users.map(u => const snapshots = await Promise.all(users.map(u =>
this.snapshotBuilder.buildSnapshot(u.userId, 'user', u.userId).catch(() => null) this.snapshotBuilder.buildSnapshot(u.userId, 'user', u.userId).catch(() => null)

View File

@ -1,12 +1,16 @@
import { Module } from '@nestjs/common'; 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 { LearningSessionController } from './learning-session.controller';
import { AdminLearningController } from './admin-learning.controller'; import { AdminLearningController } from './admin-learning.controller';
import { AdminLearningService } from './admin-learning.service';
import { LearningSessionService } from './learning-session.service'; import { LearningSessionService } from './learning-session.service';
import { LearningSessionRepository } from './learning-session.repository'; import { LearningSessionRepository } from './learning-session.repository';
@Module({ @Module({
imports: [PrismaModule, AiRuntimeModule],
controllers: [LearningSessionController, AdminLearningController], controllers: [LearningSessionController, AdminLearningController],
providers: [LearningSessionService, LearningSessionRepository], providers: [AdminLearningService, LearningSessionService, LearningSessionRepository],
exports: [LearningSessionService, LearningSessionRepository], exports: [LearningSessionService, LearningSessionRepository],
}) })
export class LearningSessionModule {} export class LearningSessionModule {}