perf: add missing indexes + fix learning admin query performance
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:
wangdl 2026-06-18 18:20:44 +08:00
parent 1ed86b3ab3
commit c0a8f7ef27
4 changed files with 66 additions and 29 deletions

View File

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

View File

@ -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')

View File

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

View File

@ -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],
}) })