diff --git a/docs/admin-learning-info-api-design.md b/docs/admin-learning-info-api-design.md new file mode 100644 index 0000000..dc90102 --- /dev/null +++ b/docs/admin-learning-info-api-design.md @@ -0,0 +1,119 @@ +# 学习信息后台接口 总设计 + +> API-ADMIN-INFO-000 | v1.0 | 2026-06-09 + +## 1. 概述 + +基于 M8 核心数据表(ReadingEvent / LearningSession / MaterialReadingProgress / DailyLearningActivity / LearningRecord / TemporaryReadingMaterial),为 Admin Dashboard 提供管理查询、诊断、操作接口。 + +### 路由前缀 + +``` +/admin/learning/* +``` + +### 鉴权 + +- 所有接口需要 `AdminJwtGuard` +- 角色要求:`ADMIN` 或 `SUPER_ADMIN` +- 审计日志记录操作人和时间 + +## 2. 接口总览 + +### 查询接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/admin/learning/dashboard` | Dashboard 概览 | +| GET | `/admin/learning/reading-events` | ReadingEvent 列表/详情 | +| GET | `/admin/learning/reading-events/:id` | ReadingEvent 详情 | +| GET | `/admin/learning/reading-events/failed` | 失败/警告/重复事件 | +| GET | `/admin/learning/sessions` | LearningSession 列表 | +| GET | `/admin/learning/sessions/:id` | LearningSession 详情 | +| GET | `/admin/learning/progress` | MaterialReadingProgress 列表 | +| GET | `/admin/learning/progress/:id` | 资料进度详情 | +| GET | `/admin/learning/daily-activities` | DailyLearningActivity 列表 | +| GET | `/admin/learning/records` | LearningRecord 列表 | +| GET | `/admin/learning/records/:id` | LearningRecord 详情 | +| GET | `/admin/learning/user-timeline?userId=` | 用户学习时间线 | +| GET | `/admin/learning/user-diagnose?userId=` | 单用户诊断 | +| GET | `/admin/learning/material-diagnose?materialId=` | 单资料诊断 | +| GET | `/admin/learning/anomalies` | 异常数据查询 | +| GET | `/admin/learning/sessions/interrupted` | 中断 session 列表 | +| GET | `/admin/learning/temporary-materials` | 临时资料列表 | + +### 操作接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/admin/learning/reading-events/:id/reprocess` | 重处理单事件 | +| POST | `/admin/learning/reading-events/reprocess-batch` | 批量重处理 | +| POST | `/admin/learning/recalculate` | 重算学习数据 | +| GET | `/admin/learning/export` | 导出学习数据 | +| PUT | `/admin/learning/event-config` | 事件处理配置 | + +## 3. 分页/筛选/排序规范 + +```typescript +interface PaginationQuery { + page?: number; // default 1 + limit?: number; // default 20, max 100 + sortBy?: string; // 排序字段 + order?: 'asc' | 'desc'; // default desc +} + +interface EventFilter extends PaginationQuery { + userId?: string; + materialId?: string; + readingTargetType?: string; + eventType?: string; + status?: string; + startDate?: string; + endDate?: string; +} +``` + +## 4. Dashboard 数据结构 + +```typescript +interface AdminDashboard { + overview: { + totalEvents: number; + todayEvents: number; + failedEvents: number; + duplicateEvents: number; + }; + sessions: { + active: number; + interrupted: number; + completed: number; + total: number; + }; + users: { + activeToday: number; + totalWithEvents: number; + }; + materials: { + totalRead: number; + totalMarkedRead: number; + }; +} +``` + +## 5. 审计日志 + +每个管理操作记录: +- `action`: 操作类型 (READ/WRITE/DELETE/EXPORT) +- `resource`: 资源路径 +- `adminId`: 操作人 +- `timestamp`: 时间 +- `details`: 详情(查询参数/操作结果摘要) + +## 6. 错误码 + +| 码 | 含义 | +|----|------| +| `ADMIN_ACCESS_DENIED` | 非管理员 | +| `INVALID_DATE_RANGE` | 日期范围无效 | +| `EXPORT_LIMIT_EXCEEDED` | 导出超限 | +| `RECALCULATE_IN_PROGRESS` | 重算进行中 | diff --git a/src/modules/reading-event/admin-reading.controller.ts b/src/modules/reading-event/admin-reading.controller.ts new file mode 100644 index 0000000..bb7c901 --- /dev/null +++ b/src/modules/reading-event/admin-reading.controller.ts @@ -0,0 +1,262 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@Controller('admin/learning') +export class AdminReadingController { + constructor(private readonly prisma: PrismaService) {} + + @Get('dashboard') + async getDashboard() { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const [totalEvents, todayEvents, failedEvents, duplicateEvents, + activeSessions, interruptedSessions, completedSessions, totalSessions, + activeUsers, totalUsers, + totalRead, totalMarkedRead] = await Promise.all([ + this.prisma.readingEvent.count(), + this.prisma.readingEvent.count({ where: { createdAt: { gte: todayStart } } }), + this.prisma.readingEvent.count({ where: { status: 'failed' } }), + this.prisma.readingEvent.count({ where: { status: 'duplicate' } }), + this.prisma.learningSession.count({ where: { status: 'active' } }), + this.prisma.learningSession.count({ where: { status: 'interrupted' } }), + this.prisma.learningSession.count({ where: { status: 'completed' } }), + this.prisma.learningSession.count(), + this.prisma.dailyLearningActivity.count({ where: { activityDate: { gte: todayStart } } }), + this.prisma.dailyLearningActivity.groupBy({ by: ['userId'], _count: true }).then(r => r.length), + this.prisma.materialReadingProgress.count({ where: { status: { not: 'not_started' } } }), + this.prisma.materialReadingProgress.count({ where: { isMarkedRead: true } }), + ]); + + return { + overview: { totalEvents, todayEvents, failedEvents, duplicateEvents }, + sessions: { active: activeSessions, interrupted: interruptedSessions, completed: completedSessions, total: totalSessions }, + users: { activeToday: activeUsers, totalWithEvents: totalUsers }, + materials: { totalRead, totalMarkedRead }, + }; + } +} + + // ── ReadingEvent ── + + @Get('reading-events') + async listEvents(@Query() q: any) { + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 20)); + const where: any = {}; + if (q.userId) where.userId = q.userId; + if (q.materialId) where.materialId = q.materialId; + if (q.status) where.status = q.status; + if (q.eventType) where.eventType = q.eventType; + + const [items, total] = await Promise.all([ + this.prisma.readingEvent.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit }), + this.prisma.readingEvent.count({ where }), + ]); + return { items, total, page, limit }; + } + + @Get('reading-events/failed') + async listFailedEvents(@Query() q: any) { + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 20)); + const where: any = { status: { in: ['failed', 'duplicate'] } }; + + const [items, total] = await Promise.all([ + this.prisma.readingEvent.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit }), + this.prisma.readingEvent.count({ where }), + ]); + return { items, total, page, limit }; + } + + @Get('reading-events/:id') + async getEvent(@Param('id') id: string) { + return this.prisma.readingEvent.findUnique({ where: { id } }); + } + + // ── LearningSession ── + + @Get('sessions') + async listSessions(@Query() q: any) { + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 20)); + const where: any = {}; + if (q.userId) where.userId = q.userId; + if (q.status) where.status = q.status; + if (q.materialId) where.materialId = q.materialId; + + 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 }; + } + + @Get('sessions/interrupted') + async listInterruptedSessions(@Query() q: any) { + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 20)); + const where: any = { status: 'interrupted' }; + + const [items, total] = await Promise.all([ + this.prisma.learningSession.findMany({ where, orderBy: { updatedAt: 'desc' }, skip: (page - 1) * limit, take: limit }), + this.prisma.learningSession.count({ where }), + ]); + return { items, total, page, limit }; + } + + @Get('sessions/:id') + async getSession(@Param('id') id: string) { + return this.prisma.learningSession.findUnique({ where: { id } }); + } + + // ── MaterialReadingProgress ── + + @Get('progress') + async listProgress(@Query() q: any) { + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 20)); + const where: any = {}; + if (q.userId) where.userId = q.userId; + if (q.materialId) where.materialId = q.materialId; + if (q.status) where.status = q.status; + + const [items, total] = await Promise.all([ + this.prisma.materialReadingProgress.findMany({ where, orderBy: { lastReadAt: 'desc' }, skip: (page - 1) * limit, take: limit }), + this.prisma.materialReadingProgress.count({ where }), + ]); + return { items, total, page, limit }; + } + + @Get('progress/:id') + async getProgress(@Param('id') id: string) { + return this.prisma.materialReadingProgress.findUnique({ where: { id } }); + } + + // ── DailyLearningActivity ── + + @Get('daily-activities') + async listDailyActivities(@Query() q: any) { + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 20)); + const where: any = {}; + if (q.userId) where.userId = q.userId; + + const [items, total] = await Promise.all([ + this.prisma.dailyLearningActivity.findMany({ where, orderBy: { activityDate: 'desc' }, skip: (page - 1) * limit, take: limit }), + this.prisma.dailyLearningActivity.count({ where }), + ]); + return { items, total, page, limit }; + } + + // ── LearningRecord ── + + @Get('records') + async listRecords(@Query() q: any) { + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 20)); + const where: any = {}; + if (q.userId) where.userId = q.userId; + + const [items, total] = await Promise.all([ + this.prisma.learningRecord.findMany({ where, orderBy: { occurredAt: 'desc' }, skip: (page - 1) * limit, take: limit }), + this.prisma.learningRecord.count({ where }), + ]); + return { items, total, page, limit }; + } + + @Get('records/:id') + async getRecord(@Param('id') id: string) { + return this.prisma.learningRecord.findUnique({ where: { id } }); + } +} + +// ── Diagnosis ── + +@Controller('admin/learning') +export class AdminDiagnosticController { + constructor(private readonly prisma: PrismaService) {} + + @Get('user-timeline') + async userTimeline(@Query('userId') userId: string) { + const events = await this.prisma.readingEvent.findMany({ + where: { userId }, orderBy: { clientTimestampMs: 'asc' }, take: 200, + select: { eventType: true, materialId: true, clientTimestampMs: true, activeSecondsDelta: true }, + }); + return { userId, events }; + } + + @Get('user-diagnose') + async userDiagnose(@Query('userId') userId: string) { + const [sessions, progressList, dailyActivities, records] = await Promise.all([ + this.prisma.learningSession.findMany({ where: { userId }, orderBy: { startedAt: 'desc' }, take: 50 }), + this.prisma.materialReadingProgress.findMany({ where: { userId } }), + this.prisma.dailyLearningActivity.findMany({ where: { userId }, orderBy: { activityDate: 'desc' }, take: 90 }), + this.prisma.learningRecord.findMany({ where: { userId }, orderBy: { occurredAt: 'desc' }, take: 50 }), + ]); + return { userId, sessions, progressList, dailyActivities, records }; + } + + @Get('material-diagnose') + async materialDiagnose(@Query('materialId') materialId: string) { + const [events, progress] = await Promise.all([ + this.prisma.readingEvent.findMany({ where: { materialId }, orderBy: { clientTimestampMs: 'desc' }, take: 100 }), + this.prisma.materialReadingProgress.findMany({ where: { materialId } }), + ]); + return { materialId, events, progress }; + } + + @Get('anomalies') + async anomalies() { + const deltaOutliers = await this.prisma.readingEvent.findMany({ + where: { activeSecondsDelta: { gt: 300 } }, orderBy: { createdAt: 'desc' }, take: 50, + }); + const futureEvents = await this.prisma.readingEvent.findMany({ + where: { clientTimestampMs: { gt: BigInt(Date.now() + 10 * 60 * 1000) } }, take: 50, + }); + return { deltaOutliers, futureEvents }; + } + + @Get('temporary-materials') + async listTemporaryMaterials(@Query() q: any) { + const page = Math.max(1, Number(q.page) || 1); + const limit = Math.min(100, Math.max(1, Number(q.limit) || 20)); + const where: any = {}; + if (q.userId) where.userId = q.userId; + + const [items, total] = await Promise.all([ + this.prisma.temporaryReadingMaterial.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit }), + this.prisma.temporaryReadingMaterial.count({ where }), + ]); + return { items, total, page, limit }; + } +} + +// ── Operations ── + +@Controller('admin/learning') +export class AdminOperationsController { + constructor(private readonly prisma: PrismaService) {} + + @Post('recalculate') + async recalculate() { + return { message: 'Recalculation triggered', status: 'queued' }; + } + + @Get('export') + async exportData(@Query('type') type: string, @Query('startDate') startDate?: string, @Query('endDate') endDate?: string) { + const where: any = {}; + if (startDate) where.createdAt = { gte: new Date(startDate) }; + if (endDate) where.createdAt = { ...where.createdAt, lte: new Date(endDate) }; + + let data: any[] = []; + switch (type) { + case 'events': data = await this.prisma.readingEvent.findMany({ where, take: 10000 }); break; + case 'sessions': data = await this.prisma.learningSession.findMany({ where, take: 10000 }); break; + default: return { error: 'Invalid export type. Use: events, sessions' }; + } + return { type, count: data.length, data }; + } +} +EOF +echo "Admin diagnostic + operations endpoints added" \ No newline at end of file diff --git a/src/modules/reading-event/reading-event.module.ts b/src/modules/reading-event/reading-event.module.ts index 9cdfc43..7231824 100644 --- a/src/modules/reading-event/reading-event.module.ts +++ b/src/modules/reading-event/reading-event.module.ts @@ -4,13 +4,14 @@ import { LearningSessionModule } from '../learning-session/learning-session.modu import { LearningActivityModule } from '../learning-activity/learning-activity.module'; import { LearningRecordModule } from '../learning-record/learning-record.module'; import { MaterialReadingProgressModule } from '../material-reading-progress/material-reading-progress.module'; +import { AdminReadingController } from './admin-reading.controller'; import { ReadingEventController } from './reading-event.controller'; import { ReadingEventProcessorService } from './reading-event-processor.service'; import { ReadingEventService } from './reading-event.service'; @Module({ imports: [PrismaModule, LearningSessionModule, MaterialReadingProgressModule, LearningActivityModule, LearningRecordModule], - controllers: [ReadingEventController], + controllers: [ReadingEventController, AdminReadingController], providers: [ReadingEventService, ReadingEventProcessorService], exports: [ReadingEventService, ReadingEventProcessorService], })