perf: add missing indexes + fix learning admin query performance
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 47s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 47s
- Add 10 missing DB indexes across 5 tables (LearningSession, ReadingEvent, MaterialReadingProgress, DailyLearningActivity, LearningRecord) to eliminate full-table scans on count queries - Dashboard: accept knowledgeBaseId/dateRange filters from frontend - getAnomalies: add skip-based pagination, remove redundant counts - Remove duplicate AdminReadingController to fix route collision on admin/learning prefix Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1ed86b3ab3
commit
c0a8f7ef27
@ -415,6 +415,9 @@ model LearningSession {
|
|||||||
@@index([startedAt])
|
@@index([startedAt])
|
||||||
@@index([clientSessionId])
|
@@index([clientSessionId])
|
||||||
@@index([materialId])
|
@@index([materialId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([lastEventAt])
|
||||||
|
@@index([userId, startedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model LearningRecord {
|
model LearningRecord {
|
||||||
@ -434,6 +437,8 @@ model LearningRecord {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([occurredAt])
|
@@index([occurredAt])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([recordType, occurredAt])
|
||||||
|
@@index([userId, occurredAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ReadingEvent {
|
model ReadingEvent {
|
||||||
@ -466,6 +471,9 @@ model ReadingEvent {
|
|||||||
@@index([userId, readingTargetType, materialId, clientTimestampMs])
|
@@index([userId, readingTargetType, materialId, clientTimestampMs])
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@index([userId, createdAt])
|
@@index([userId, createdAt])
|
||||||
|
@@index([serverReceivedAt])
|
||||||
|
@@index([materialId])
|
||||||
|
@@index([activeSecondsDelta])
|
||||||
}
|
}
|
||||||
|
|
||||||
model MaterialReadingProgress {
|
model MaterialReadingProgress {
|
||||||
@ -494,6 +502,7 @@ model MaterialReadingProgress {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([knowledgeBaseId])
|
@@index([knowledgeBaseId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([isMarkedRead])
|
||||||
}
|
}
|
||||||
|
|
||||||
model TemporaryReadingMaterial {
|
model TemporaryReadingMaterial {
|
||||||
@ -714,6 +723,7 @@ model DailyLearningActivity {
|
|||||||
|
|
||||||
@@unique([userId, activityDate])
|
@@unique([userId, activityDate])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([activityDate])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
|
|||||||
@ -10,7 +10,11 @@ export class AdminLearningController {
|
|||||||
|
|
||||||
// ── Dashboard ──
|
// ── Dashboard ──
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
async getDashboard() { return this.service.getDashboard(); }
|
async getDashboard(
|
||||||
|
@Query('knowledgeBaseId') knowledgeBaseId?: string,
|
||||||
|
@Query('startDate') startDate?: string,
|
||||||
|
@Query('endDate') endDate?: string,
|
||||||
|
) { return this.service.getDashboard({ knowledgeBaseId, startDate, endDate }); }
|
||||||
|
|
||||||
// ── ReadingEvents ──
|
// ── ReadingEvents ──
|
||||||
@Get('reading-events')
|
@Get('reading-events')
|
||||||
|
|||||||
@ -11,33 +11,59 @@ export class AdminLearningService {
|
|||||||
|
|
||||||
// ══ Dashboard ══
|
// ══ Dashboard ══
|
||||||
|
|
||||||
async getDashboard() {
|
async getDashboard(filters?: { knowledgeBaseId?: string; startDate?: string; endDate?: string }) {
|
||||||
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 sessionWhere: any = {};
|
||||||
|
const progressWhere: any = {};
|
||||||
|
const activityWhere: any = {};
|
||||||
|
if (filters?.knowledgeBaseId) {
|
||||||
|
eventWhere.knowledgeBaseId = filters.knowledgeBaseId;
|
||||||
|
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);
|
||||||
|
if (filters?.endDate) eventWhere.serverReceivedAt.lte = new Date(filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayEventWhere = { ...eventWhere, serverReceivedAt: { ...(eventWhere.serverReceivedAt ?? {}), gte: today } };
|
||||||
|
const todayActivityWhere = { ...activityWhere, activityDate: today };
|
||||||
|
|
||||||
const [totalEvents, todayEvents, failedEvents, duplicateEvents,
|
const [totalEvents, todayEvents, failedEvents, duplicateEvents,
|
||||||
activeSessions, interruptedSessions, completedSessions, totalSessions,
|
activeSessions, interruptedSessions, completedSessions, totalSessions,
|
||||||
activeToday, totalWithEvents, totalRead, totalMarkedRead,
|
activeToday, totalRead, totalMarkedRead,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.prisma.readingEvent.count(),
|
this.prisma.readingEvent.count({ where: eventWhere }),
|
||||||
this.prisma.readingEvent.count({ where: { serverReceivedAt: { gte: today } } }),
|
this.prisma.readingEvent.count({ where: todayEventWhere }),
|
||||||
this.prisma.readingEvent.count({ where: { status: 'failed' } }),
|
this.prisma.readingEvent.count({ where: { ...eventWhere, status: 'failed' } }),
|
||||||
this.prisma.readingEvent.count({ where: { status: 'duplicate' } }),
|
this.prisma.readingEvent.count({ where: { ...eventWhere, status: 'duplicate' } }),
|
||||||
this.prisma.learningSession.count({ where: { status: 'active' } }),
|
this.prisma.learningSession.count({ where: { ...sessionWhere, status: 'active' } }),
|
||||||
this.prisma.learningSession.count({ where: { status: 'interrupted' } }),
|
this.prisma.learningSession.count({ where: { ...sessionWhere, status: 'interrupted' } }),
|
||||||
this.prisma.learningSession.count({ where: { status: 'completed' } }),
|
this.prisma.learningSession.count({ where: { ...sessionWhere, status: 'completed' } }),
|
||||||
this.prisma.learningSession.count(),
|
this.prisma.learningSession.count({ where: sessionWhere }),
|
||||||
this.prisma.dailyLearningActivity.count({ where: { activityDate: today } }),
|
this.prisma.dailyLearningActivity.groupBy({ by: ['userId'], where: todayActivityWhere, _count: true }),
|
||||||
this.prisma.dailyLearningActivity.groupBy({ by: ['userId'], _count: true }),
|
this.prisma.materialReadingProgress.count({ where: { ...progressWhere, status: { not: 'not_started' } } }),
|
||||||
this.prisma.materialReadingProgress.count({ where: { status: { not: 'not_started' } } }),
|
this.prisma.materialReadingProgress.count({ where: { ...progressWhere, isMarkedRead: true } }),
|
||||||
this.prisma.materialReadingProgress.count({ where: { isMarkedRead: true } }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Warning events count
|
||||||
|
const warningEvents = await this.prisma.readingEvent.count({ where: { ...eventWhere, status: 'warning' } });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overview: { totalEvents, todayEvents, failedEvents, duplicateEvents },
|
overview: {
|
||||||
|
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, totalWithEvents: totalWithEvents.length },
|
users: { activeToday: activeToday.length, totalWithEvents: 0 },
|
||||||
materials: { totalRead, totalMarkedRead },
|
materials: { totalRead, totalMarkedRead },
|
||||||
|
recentAnomalies: [], // separate endpoint for this
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,20 +244,18 @@ export class AdminLearningService {
|
|||||||
async getAnomalies(query: { page?: number; limit?: number }) {
|
async getAnomalies(query: { page?: number; limit?: number }) {
|
||||||
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 skip = (page - 1) * limit;
|
||||||
|
|
||||||
// Broad anomaly detection: failed + duplicate + events with unusually large activeSecondsDelta
|
const [failed, duplicates, failedTotal, duplicatesTotal] = await Promise.all([
|
||||||
const [failed, duplicates, outliers, stuckSessions] = await Promise.all([
|
this.prisma.readingEvent.findMany({ where: { status: 'failed' }, orderBy: { serverReceivedAt: 'desc' }, skip, take: limit }),
|
||||||
this.prisma.readingEvent.findMany({ where: { status: 'failed' }, orderBy: { serverReceivedAt: 'desc' }, take: limit }),
|
this.prisma.readingEvent.findMany({ where: { status: 'duplicate' }, orderBy: { serverReceivedAt: 'desc' }, skip, take: limit }),
|
||||||
this.prisma.readingEvent.findMany({ where: { status: 'duplicate' }, orderBy: { serverReceivedAt: 'desc' }, take: limit }),
|
this.prisma.readingEvent.count({ where: { status: 'failed' } }),
|
||||||
this.prisma.readingEvent.findMany({ where: { activeSecondsDelta: { gte: 3600 } }, orderBy: { activeSecondsDelta: 'desc' }, take: 10 }),
|
this.prisma.readingEvent.count({ where: { status: 'duplicate' } }),
|
||||||
this.prisma.learningSession.findMany({ where: { status: 'interrupted' }, orderBy: { startedAt: 'desc' }, take: 10 }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
failed: { items: failed, total: await this.prisma.readingEvent.count({ where: { status: 'failed' } }) },
|
failed: { items: failed, total: failedTotal },
|
||||||
duplicates: { items: duplicates, total: await this.prisma.readingEvent.count({ where: { status: 'duplicate' } }) },
|
duplicates: { items: duplicates, total: duplicatesTotal },
|
||||||
outliers: { items: outliers, note: 'activeSecondsDelta >= 3600' },
|
|
||||||
stuckSessions,
|
|
||||||
page, limit,
|
page, limit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { LearningSessionModule } from '../learning-session/learning-session.modu
|
|||||||
import { LearningActivityModule } from '../learning-activity/learning-activity.module';
|
import { LearningActivityModule } from '../learning-activity/learning-activity.module';
|
||||||
import { LearningRecordModule } from '../learning-record/learning-record.module';
|
import { LearningRecordModule } from '../learning-record/learning-record.module';
|
||||||
import { MaterialReadingProgressModule } from '../material-reading-progress/material-reading-progress.module';
|
import { MaterialReadingProgressModule } from '../material-reading-progress/material-reading-progress.module';
|
||||||
import { AdminReadingController } from './admin-reading.controller';
|
|
||||||
import { ReadingEventController } from './reading-event.controller';
|
import { ReadingEventController } from './reading-event.controller';
|
||||||
import { ReadingEventProcessorService } from './reading-event-processor.service';
|
import { ReadingEventProcessorService } from './reading-event-processor.service';
|
||||||
import { ReadingEventService } from './reading-event.service';
|
import { ReadingEventService } from './reading-event.service';
|
||||||
@ -12,7 +11,7 @@ import { AdminAuthModule } from '../admin-auth/admin-auth.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, AdminAuthModule, LearningSessionModule, MaterialReadingProgressModule, LearningActivityModule, LearningRecordModule],
|
imports: [PrismaModule, AdminAuthModule, LearningSessionModule, MaterialReadingProgressModule, LearningActivityModule, LearningRecordModule],
|
||||||
controllers: [ReadingEventController, AdminReadingController],
|
controllers: [ReadingEventController],
|
||||||
providers: [ReadingEventService, ReadingEventProcessorService],
|
providers: [ReadingEventService, ReadingEventProcessorService],
|
||||||
exports: [ReadingEventService, ReadingEventProcessorService],
|
exports: [ReadingEventService, ReadingEventProcessorService],
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user