From 38a8629e422e2e1ee3f82b5bd0b98093c8db3c26 Mon Sep 17 00:00:00 2001 From: wangdl Date: Mon, 8 Jun 2026 21:09:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M8=20=E5=AD=A6=E4=B9=A0=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E6=94=B6=E9=9B=86=E7=B3=BB=E7=BB=9F=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1-2: 设计文档 + 数据库 (ReadingEvent/MaterialReadingProgress/TemporaryReadingMaterial/LearningSession扩展/DailyLearningActivity扩展/LearningRecord) Phase 3: 批量上报 + 校验去重 + ReadingEventProcessorService Phase 4: 4表聚合管线 (LearningSession/MaterialReadingProgress/DailyLearningActivity/LearningRecord) Phase 5: 查询接口 (progress/continue/summary/trend/heatmap/history/reprocess) Phase 6: 权限校验 + session中断清理 + API文档 Co-Authored-By: Claude Opus 4.7 --- docs/learning-info-api.md | 263 ++++++++++++++ docs/learning-info-design.md | 311 ++++++++++++++++ docs/reading-event-api-protocol.md | 205 +++++++++++ prisma/schema.prisma | 93 +++++ .../learning-activity.controller.ts | 8 +- .../learning-activity.module.ts | 2 +- .../learning-activity.repository.ts | 46 +++ .../learning-activity.service.ts | 17 +- .../learning-record/learning-record.module.ts | 10 + .../learning-record.service.ts | 149 ++++++++ .../learning-session.module.ts | 2 +- .../learning-session.repository.ts | 52 ++- .../learning-session.service.ts | 16 + .../material-reading-progress.module.ts | 10 + .../material-reading-progress.service.ts | 136 +++++++ .../reading-event/reading-event-codes.ts | 43 +++ .../reading-event-processor.service.ts | 340 ++++++++++++++++++ .../reading-event/reading-event.controller.ts | 107 ++++++ .../reading-event/reading-event.dto.ts | 51 +++ .../reading-event/reading-event.module.ts | 17 + .../reading-event/reading-event.service.ts | 14 + src/modules/reading/reading.controller.ts | 212 +++++++++++ src/modules/reading/reading.module.ts | 13 + .../temporary-reading-material.module.ts | 10 + .../temporary-reading-material.service.ts | 51 +++ 25 files changed, 2171 insertions(+), 7 deletions(-) create mode 100644 docs/learning-info-api.md create mode 100644 docs/learning-info-design.md create mode 100644 docs/reading-event-api-protocol.md create mode 100644 src/modules/learning-record/learning-record.module.ts create mode 100644 src/modules/learning-record/learning-record.service.ts create mode 100644 src/modules/material-reading-progress/material-reading-progress.module.ts create mode 100644 src/modules/material-reading-progress/material-reading-progress.service.ts create mode 100644 src/modules/reading-event/reading-event-codes.ts create mode 100644 src/modules/reading-event/reading-event-processor.service.ts create mode 100644 src/modules/reading-event/reading-event.controller.ts create mode 100644 src/modules/reading-event/reading-event.dto.ts create mode 100644 src/modules/reading-event/reading-event.module.ts create mode 100644 src/modules/reading-event/reading-event.service.ts create mode 100644 src/modules/reading/reading.controller.ts create mode 100644 src/modules/reading/reading.module.ts create mode 100644 src/modules/temporary-reading-material/temporary-reading-material.module.ts create mode 100644 src/modules/temporary-reading-material/temporary-reading-material.service.ts diff --git a/docs/learning-info-api.md b/docs/learning-info-api.md new file mode 100644 index 0000000..51831ba --- /dev/null +++ b/docs/learning-info-api.md @@ -0,0 +1,263 @@ +# 学习信息收集 API Contract + +> M8 | 版本 v1.0 | 2026-06-08 +> +> 所有响应 shape、错误码以本文档为准。 +> 设计逻辑参见 [学习信息收集总设计](./learning-info-design.md)。 +> 上传协议详见 [阅读事件上传协议](./reading-event-api-protocol.md)。 + +--- + +## 1. 基础信息 + +| 项目 | 值 | +|------|----| +| Base Path | `/learning` / `/materials` / `/activity` | +| Auth | Bearer JWT (所有端点需要) | +| Content-Type (Request) | `application/json` | +| Content-Type (Response) | `application/json` | +| Batch Limit | 100 条/次 | + +--- + +## 2. 端点总览 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/learning/reading-events/batch` | 批量上报阅读事件 | +| GET | `/materials/:id/reading-progress` | 查询资料阅读进度 | +| GET | `/learning/continue` | 首页继续学习 | +| GET | `/learning/summary` | 学习摘要 | +| GET | `/learning/trend?days=7` | 阅读趋势 | +| GET | `/activity/heatmap?days=365` | 学习热力图 | +| GET | `/learning/records?cursor=&limit=20&type=reading` | 学习历史记录 | +| POST | `/internal/learning/reading-events/:id/reprocess` | 重处理单事件 | +| POST | `/internal/learning/reading-events/reprocess-failed` | 批量重处理失败事件 | + +--- + +## 3. 上报阅读事件 + +### POST /learning/reading-events/batch + +```json +// Request +{ + "events": [{ + "eventId": "550e8400-e29b-41d4-a716-446655440001", + "clientSessionId": "550e8400-e29b-41d4-a716-446655440000", + "materialId": "cuid_mat_001", + "readingTargetType": "knowledge_source", + "eventType": "material_opened", + "position": { "type": "Markdown", "blockId": "intro", "scrollProgress": 0.25 }, + "activeSecondsDelta": 0, + "clientTimestampMs": 1717800000000, + "sequence": 1, + "platform": "ios", + "appVersion": "1.2.3", + "clientTimezoneOffsetMinutes": -480 + }] +} + +// Response +{ + "processed": 1, + "duplicate": 0, + "failed": 0, + "warnings": [] +} +``` + +### 校验规则 + +| 字段 | 规则 | 失败处理 | +|------|------|----------| +| eventId | UUID v4, userId+eventId unique | DUPLICATE_EVENT | +| activeSecondsDelta | 0 ✅, <0 ❌, >300 截断+warning | INVALID_ACTIVE_SECONDS | +| readingTargetType | knowledge_source / temporary_file | INVALID_TARGET_TYPE | +| eventType | 5 种之一 | INVALID_EVENT_TYPE | +| materialId | knowledge_source: KnowledgeSource 存在+归属,temporary_file: 存在+归属+未过期 | MATERIAL_ACCESS_DENIED / SOURCE_DELETED | + +--- + +## 4. 查询资料阅读进度 + +### GET /materials/:id/reading-progress?readingTargetType=knowledge_source + +```json +// Response (有记录) +{ + "status": "reading", + "lastPosition": { "type": "Markdown", "blockId": "ch1", "scrollProgress": 0.5 }, + "lastProgress": 0.5, + "totalActiveSeconds": 120, + "isMarkedRead": false, + "firstOpenedAt": "2026-06-01T00:00:00Z", + "lastReadAt": "2026-06-08T12:00:00Z" +} + +// Response (无记录) +{ + "status": "not_started", + "lastPosition": null, + "lastProgress": null, + "totalActiveSeconds": 0, + "isMarkedRead": false +} + +// Response (权限拒绝) +{ + "status": "not_started", + "reason": "MATERIAL_ACCESS_DENIED" +} +``` + +--- + +## 5. 首页继续学习 + +### GET /learning/continue + +```json +// Response (有数据) +{ + "type": "knowledge_source", + "materialId": "cuid_mat_001", + "title": "Document Title", + "lastPosition": { "type": "Pdf", "pageNumber": 3, "pageProgress": 0.5, "overallProgress": 0.32 }, + "lastProgress": 0.32, + "totalActiveSeconds": 1200, + "lastReadAt": "2026-06-08T12:00:00Z" +} + +// Response (无数据) +{ "type": "none" } +``` + +--- + +## 6. 学习摘要 + +### GET /learning/summary + +```json +{ + "todaySeconds": 300, + "weekSeconds": 1800, + "totalSeconds": 7200, + "activeDays": 12, + "sessionsCount": 20, + "materialsReadCount": 5, + "markedReadCount": 2, + "dailyAverageSeconds": 600 +} +``` + +--- + +## 7. 阅读趋势 + +### GET /learning/trend?days=7 + +| 参数 | 默认 | 最大 | +|------|------|------| +| days | 7 | 90 | + +```json +{ + "days": 7, + "series": [ + { "date": "2026-06-02", "value": 120 }, + { "date": "2026-06-03", "value": 0 }, + { "date": "2026-06-04", "value": 300 } + ] +} +``` + +--- + +## 8. 学习热力图 + +### GET /activity/heatmap?days=365 + +| 参数 | 默认 | 最大 | +|------|------|------| +| days | 365 | 365 | + +```json +{ + "2026-06-01": 120, + "2026-06-02": 0, + "2026-06-03": 300 +} +``` + +--- + +## 9. 学习历史记录 + +### GET /learning/records?cursor=&limit=20&type=reading + +| 参数 | 默认 | 说明 | +|------|------|------| +| cursor | — | 分页游标(记录 id) | +| limit | 20 | 最大 50 | +| type | — | recordType 过滤 | + +```json +{ + "items": [{ + "id": "cuid_rec_001", + "recordType": "reading", + "title": "Reading started", + "description": null, + "durationSeconds": 120, + "occurredAt": "2026-06-08T12:00:00Z", + "metadata": { + "materialId": "cuid_mat_001", + "readingTargetType": "knowledge_source", + "knowledgeBaseId": "kb_001", + "totalActiveSeconds": 120, + "lastPosition": { "type": "progress", "progress": 0.5 } + }, + "createdAt": "2026-06-08T12:00:00Z" + }], + "nextCursor": "cuid_rec_021" +} +``` + +--- + +## 10. 重处理(Internal) + +### POST /internal/learning/reading-events/:id/reprocess?force=true + +- failed/pending 事件可重处理 +- processed 事件需 `?force=true` +- 返回 `{ id, result: { outcome, warnings } }` + +### POST /internal/learning/reading-events/reprocess-failed?limit=50 + +- 批量重处理 status=failed 事件 +- limit 默认 50,最大 200 +- 返回 `{ reprocessed: N, results: [{ id, outcome }] }` + +--- + +## 11. 错误码 + +| 码 | 类型 | 含义 | +|----|------|------| +| MATERIAL_NOT_FOUND | error | knowledge_source 不存在 | +| TEMPORARY_MATERIAL_NOT_FOUND | error | temporary_file 不存在 | +| MATERIAL_ACCESS_DENIED | error | 不属于当前用户 | +| TEMPORARY_MATERIAL_EXPIRED | error | 临时文件已过期 | +| INVALID_TARGET_TYPE | error | 未知 readingTargetType | +| INVALID_EVENT_TYPE | error | 未知 eventType | +| INVALID_ACTIVE_SECONDS | error | delta < 0 | +| BATCH_LIMIT_EXCEEDED | error | 超过 100 条 | +| ACTIVE_SECONDS_CAPPED | warning | delta > 300 截断 | +| CLIENT_TIMESTAMP_SKEWED | warning | 时钟偏差 > 5min | +| POSITION_IGNORED | warning | position 无效 | +| DUPLICATE_EVENT | warning | 幂等重放 | +| SOURCE_DELETED | warning | 来源已删除 | diff --git a/docs/learning-info-design.md b/docs/learning-info-design.md new file mode 100644 index 0000000..e542e9c --- /dev/null +++ b/docs/learning-info-design.md @@ -0,0 +1,311 @@ +# 学习信息收集 总设计 + +## 1. 概述 + +M8 里程碑实现从 iOS 客户端(via Rust document runtime)→ API 服务端的学习行为信息收集闭环。 + +### 数据流 + +``` +iOS App → Rust zx_document_core (ReadingEventV2) + → iOS 适配层(补充 readingTargetType/platform/appVersion/timezone) + → POST /reading/events (批量上报) + → ReadingEventProcessorService(校验/去重/聚合) + → LearningSession / MaterialReadingProgress / DailyLearningActivity / LearningRecord + → 查询接口(进度/继续学习/summary/trend/heatmap/历史) +``` + +## 2. readingTargetType + +Rust 侧不存储 `readingTargetType`,由 iOS 适配层在上传时补充。 + +| readingTargetType | materialId 映射 | knowledgeBaseId | +|---|---|---| +| `knowledge_source` | `KnowledgeSource.id` | `KnowledgeSource.knowledgeBaseId` | +| `temporary_file` | `TemporaryReadingMaterial.id` | `null`(后续可补) | + +### iOS 上传时补充逻辑 + +```typescript +// iOS 适配层在构造上传请求时: +const item = { + eventId: rustEvent.eventId, + clientSessionId: rustEvent.clientSessionId, + materialId: rustEvent.materialId, + eventType: rustEvent.eventType, + position: rustEvent.position, + activeSecondsDelta: rustEvent.activeSecondsDelta, + clientTimestampMs: rustEvent.timestampMs, + sequence: rustEvent.sequence, + // iOS 补充字段: + readingTargetType: resolveTargetType(rustEvent.materialId), // 'knowledge_source' | 'temporary_file' + platform: 'ios', + appVersion: getAppVersion(), + clientTimezoneOffsetMinutes: getTimezoneOffset(), +}; +``` + +## 3. 实体映射 + +### 3.1 新增表 + +#### ReadingEvent(原始事件日志) + +```prisma +model ReadingEvent { + id String @id @default(cuid()) + userId String + eventId String + clientSessionId String + readingTargetType String @db.VarChar(32) + materialId String + knowledgeBaseId String? + eventType String @db.VarChar(32) + position Json? + activeSecondsDelta Int @default(0) + clientTimestampMs BigInt + clientTimezoneOffsetMinutes Int? + sequence Int + platform String? @db.VarChar(16) + appVersion String? @db.VarChar(32) + status String @default("pending") @db.VarChar(32) + errorCode String? @db.VarChar(32) + warningCodes Json? + serverReceivedAt DateTime @default(now()) + processedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + + @@unique([userId, eventId]) + @@index([userId, clientSessionId]) + @@index([userId, readingTargetType, materialId, clientTimestampMs]) + @@index([status, createdAt]) + @@index([userId, createdAt]) +} +``` + +#### MaterialReadingProgress(资料阅读进度) + +```prisma +model MaterialReadingProgress { + id String @id @default(cuid()) + userId String + materialId String // 关联的 materialId + readingTargetType String @db.VarChar(32) + knowledgeBaseId String? // 从 KnowledgeSource 反查 + lastClientSessionId String? + lastPosition Json? // camelCase ReadingPosition + lastProgress Float? // 0~1 归一化进度值 + totalActiveSeconds Int @default(0) // 累计活跃阅读秒数 + sessionCount Int @default(0) // 阅读会话次数 + status String @default("not_started") @db.VarChar(32) + firstOpenedAt DateTime? + lastOpenedAt DateTime? + lastReadAt DateTime? + isMarkedRead Boolean @default(false) + markedReadAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + + @@unique([userId, materialId]) + @@index([userId]) + @@index([knowledgeBaseId]) + @@index([status]) +} +``` + +#### TemporaryReadingMaterial(临时阅读资料) + +```prisma +model TemporaryReadingMaterial { + id String @id @default(cuid()) + userId String + title String? @db.VarChar(255) + originalFilename String? @db.VarChar(255) + mimeType String? @db.VarChar(100) + sizeBytes BigInt @default(0) + storageKey String? @db.VarChar(500) + sourceStatus String @default("active") @db.VarChar(32) + expiresAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + + @@index([userId]) + @@index([expiresAt]) +} +``` + +### 3.2 扩展现有表 + +#### LearningSession(扩展字段) + +在现有 `LearningSession` 基础上新增: + +```prisma +model LearningSession { + // ... 现有字段 ... + + // M8 新增字段: + clientSessionId String? // Rust client_session_id(关联上报事件) + materialId String? // 正在阅读的资料 materialId + readingTargetType String? @db.VarChar(32) + totalActiveSeconds Int @default(0) // 来自 Rust 的累计活跃秒数 + lastPosition Json? // 最后阅读位置 + lastEventAt DateTime? // 最后事件时间 +} +``` + +> 现有字段 `mode` 保留,新增 `readingTargetType` 不冲突。`durationSeconds` 兼容:优先使用 `totalActiveSeconds`(Rust tracker),无 Rust 数据则保留旧逻辑。 + +#### DailyLearningActivity(扩展字段) + +```prisma +model DailyLearningActivity { + // ... 现有字段 (durationSeconds, sessionsCount, activeRecallCount, reviewCount, aiAnalysisCount, completedLoopCount, activityLevel) ... + + // M8 新增字段: + readingSeconds Int @default(0) // 当日阅读时长(秒) + materialsReadCount Int @default(0) // 当日阅读资料数 + markedReadCount Int @default(0) // 当日标记已读数 +} +``` + +### 3.3 复用现有表 + +#### LearningRecord(无需改 schema) + +`recordType` 取值扩展: +- `reading` — 阅读记录(新增) +- `read_completed` — 完成阅读(新增) + +`metadata` JSON 扩展字段: +```json +{ + "materialId": "...", + "readingTargetType": "knowledge_source", + "knowledgeBaseId": "...", + "totalActiveSeconds": 120, + "lastPosition": {...} +} +``` + +## 4. 核心聚合链路 + +``` +POST /reading/events (批量上报) + │ + ▼ +ReadingEventProcessorService.processBatch(events) + │ + ├─ 1. 幂等去重(eventId unique) + ├─ 2. 校验(activeSecondsDelta >= 0 且 <= 300) + ├─ 3. 写入 ReadingEvent 表(status=pending→processed) + │ + ├─ 4. 聚合 → LearningSession + │ - 按 clientSessionId 找已存在 session + │ - 存在:更新 lastPosition / totalActiveSeconds / lastEventAt + │ - 不存在(MaterialOpened):新建 LearningSession + │ - MaterialClosed:结束 session(status=ended) + │ + ├─ 5. 聚合 → MaterialReadingProgress + │ - UPSERT (userId, materialId) + │ - 累加 totalActiveSeconds / sessionCount + │ - 更新 latestPosition / progressValue + │ - 时间更新:firstOpenedAt / lastReadAt / completedAt + │ + ├─ 6. 聚合 → DailyLearningActivity + │ - UPSERT (userId, activityDate) + │ - 累加 readingDurationSeconds / materialCount + │ + └─ 7. 写入 LearningRecord(当 MarkedAsRead / MaterialClosed / 首次打开) +``` + +### 聚合时机 + +**同步聚合**(在请求处理中完成): +- 校验通过后立即写入 ReadingEvent +- 立即聚合到 LearningSession / MaterialReadingProgress / DailyLearningActivity +- 暂不使用 worker/队列 + +### 特殊情况处理 + +| 场景 | 处理 | +|------|------| +| 重复 eventId | status=duplicate, 跳过聚合 | +| activeSecondsDelta < 0 | status=failed, errorCode=INVALID_DELTA | +| activeSecondsDelta > 300 | 截断为 300(单次 tick 不超过 5 分钟) | +| activeSecondsDelta = 0 | 合法(MaterialOpened/PositionChanged/MarkedAsRead) | +| MaterialClosed 无 position | 不覆盖已有 position | +| 乱序事件(时间倒退) | 不拒绝,正常处理(客户端时钟漂移容忍) | + +## 5. 错误码与警告码 + +### 错误码(事件被拒绝,status=failed) + +| 码 | 含义 | +|----|------| +| `MATERIAL_NOT_FOUND` | knowledge_source 不存在 | +| `TEMPORARY_MATERIAL_NOT_FOUND` | temporary_file 不存在 | +| `MATERIAL_ACCESS_DENIED` | 不属于当前用户 | +| `TEMPORARY_MATERIAL_EXPIRED` | 临时文件已过期 | +| `INVALID_TARGET_TYPE` | 未知 readingTargetType | +| `INVALID_EVENT_TYPE` | 未知 eventType | +| `INVALID_TIMESTAMP` | 时间戳格式错误 | +| `INVALID_POSITION` | position JSON 格式错误 | +| `INVALID_ACTIVE_SECONDS` | activeSecondsDelta < 0 | +| `BATCH_LIMIT_EXCEEDED` | 超过批量上限(100) | +| `MISSING_CLIENT_SESSION` | 缺少 clientSessionId | +| `MISSING_MATERIAL_ID` | 缺少 materialId | + +### 警告码(事件被接受但标记) + +| 码 | 含义 | +|----|------| +| `ACTIVE_SECONDS_CAPPED` | delta > 300,已截断 | +| `CLIENT_TIMESTAMP_SKEWED` | 时钟偏差 > 5 min | +| `POSITION_IGNORED` | position 存在但对 eventType 无效 | +| `DUPLICATE_EVENT` | 幂等重放 | +| `OUT_OF_ORDER_EVENT` | 乱序事件 | +| `SOURCE_DELETED` | 来源资料已删除 | + +## 6. 权限校验 + +### 上报接口 +- `readingTargetType=knowledge_source`:验证 `KnowledgeSource` 存在且属于当前用户 +- `readingTargetType=temporary_file`:验证 `TemporaryReadingMaterial` 存在且属于当前用户 +- 未知 materialId:记录 warning,仍接受事件(避免丢失数据) + +### 查询接口 +- `GET /reading/progress/:materialId`:验证用户权限 +- `GET /reading/continue-learning`:返回当前用户的资料 +- 所有查询接口通过 JWT guard 获取 userId + +## 7. 接口列表 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/reading/events` | 批量上报阅读事件 | +| GET | `/reading/progress/:materialId` | 查询单资料阅读进度 | +| GET | `/reading/continue-learning` | 首页继续学习 | +| GET | `/reading/summary` | 学习 summary | +| GET | `/reading/trend` | 纯数据 trend | +| GET | `/reading/heatmap` | 热力图数据 | +| GET | `/reading/history` | 学习历史记录 | +| POST | `/reading/events/replay` | 事件重放/修复 | + +## 8. 验收清单 + +- [x] `docs/learning-info-design.md` 存在 +- [x] readingTargetType 定义:knowledge_source / temporary_file +- [x] materialId 映射:→ KnowledgeSource.id / TemporaryReadingMaterial.id +- [x] 权限校验方式:JWT guard + userId + 资源归属检查 +- [x] Rust ReadingEventV2 → API ReadingEvent 字段映射 +- [x] 核心聚合链路:ReadingEvent → LearningSession → MaterialReadingProgress → DailyLearningActivity → LearningRecord +- [x] 错误码定义:8 种 +- [x] 同步聚合策略 diff --git a/docs/reading-event-api-protocol.md b/docs/reading-event-api-protocol.md new file mode 100644 index 0000000..b30b128 --- /dev/null +++ b/docs/reading-event-api-protocol.md @@ -0,0 +1,205 @@ +# 阅读事件上传协议 + +## 1. 概述 + +本文档定义 iOS 客户端 → API 服务端的阅读事件上传协议。 + +**核心原则:Rust 事件和 API 上传事件不是同一个结构。** iOS 适配层负责将 Rust `ReadingEventV2` 转换为 API `ReadingEventUploadItem`,补充 `readingTargetType` 等业务字段。 + +## 2. 端点 + +``` +POST /reading/events +Content-Type: application/json +Authorization: Bearer +``` + +## 3. 请求体 + +```typescript +interface ReadingEventUploadRequest { + events: ReadingEventUploadItem[]; +} + +interface ReadingEventUploadItem { + // ── 来自 Rust ReadingEventV2 ── + eventId: string; // UUID v4,全局唯一,幂等键 + clientSessionId: string; // UUID v4,Rust ReadingSessionV2.clientSessionId + materialId: string; // Rust ReadingMaterialRef.materialId + eventType: ReadingEventType; // 事件类型 + position?: ReadingPosition; // 阅读位置(camelCase JSON, clamped 0~1) + activeSecondsDelta: number; // 增量活跃秒数(非累计!) + clientTimestampMs: number; // 客户端时间戳(毫秒) + sequence: number; // session 内递增序号(1-based) + + // ── iOS 适配层补充 ── + readingTargetType: 'knowledge_source' | 'temporary_file'; + platform: string; // 'ios' | 'android' + appVersion?: string; // App 版本号 + clientTimezoneOffsetMinutes?: number; // 客户端时区偏移(分钟) +} + +type ReadingEventType = + | 'material_opened' + | 'material_closed' + | 'position_changed' + | 'heartbeat' + | 'marked_as_read'; +``` + +## 4. 字段映射:Rust → API + +| Rust ReadingEventV2 | API ReadingEventUploadItem | 说明 | +|---------------------|---------------------------|------| +| `event_id` | `eventId` | UUID v4,幂等键 | +| `client_session_id` | `clientSessionId` | 会话标识 | +| `material_id` | `materialId` | 资料 ID | +| `event_type` | `eventType` | snake_case(MaterialOpened→material_opened) | +| `position` | `position` | camelCase JSON,已 clamp | +| `active_seconds_delta` | `activeSecondsDelta` | **增量值**,非累计 | +| `timestamp_ms` | `clientTimestampMs` | 客户端时间戳 | +| `sequence` | `sequence` | session 内递增 | +| — | `readingTargetType` | **iOS 补充** | +| — | `platform` | **iOS 补充**(= "ios") | +| — | `appVersion` | **iOS 补充** | +| — | `clientTimezoneOffsetMinutes` | **iOS 补充** | + +## 5. eventType 取值 + +| Rust 枚举 | API 字符串 | 说明 | +|-----------|-----------|------| +| `MaterialOpened` | `material_opened` | 打开资料 | +| `MaterialClosed` | `material_closed` | 关闭资料 | +| `PositionChanged` | `position_changed` | 位置变化 | +| `Heartbeat` | `heartbeat` | 心跳(含 delta) | +| `MarkedAsRead` | `marked_as_read` | 标记已读 | + +## 6. activeSecondsDelta 规则 + +| 规则 | 处理 | +|------|------| +| `= 0` | ✅ 合法(MaterialOpened / PositionChanged / MarkedAsRead 的 delta 为 0) | +| `> 0 且 <= 300` | ✅ 正常 | +| `> 300` | ⚠️ 截断为 300 + warning `DELTA_EXCEEDED` | +| `< 0` | ❌ 拒绝:status=failed, errorCode=`INVALID_DELTA` | +| 缺失 | ❌ 拒绝:status=failed, errorCode=`MISSING_DELTA` | + +> **为什么是增量而非累计?** Rust `ActiveTimeTracker` 每次 tick 输出增量 `active_seconds_delta`,不是累计值。API 侧做累加。 + +## 7. 校验规则 + +| 校验项 | 规则 | 失败处理 | +|--------|------|----------| +| eventId 唯一性 | 全局唯一,重复视为幂等重放 | status=duplicate, 跳过聚合 | +| clientSessionId | 必填 | status=failed, errorCode=`MISSING_CLIENT_SESSION` | +| materialId | 必填 | status=failed, errorCode=`MISSING_MATERIAL_ID` | +| readingTargetType | 必须为 `knowledge_source` 或 `temporary_file` | status=failed, errorCode=`INVALID_TARGET_TYPE` | +| knowledge_source 存在性 | KnowledgeSource 存在且属于当前用户 | warning `MATERIAL_NOT_FOUND`,仍接受 | +| temporary_file 存在性 | TemporaryReadingMaterial 存在且属于当前用户 | warning `MATERIAL_NOT_FOUND`,仍接受 | +| clientTimestampMs | 不能在未来 5 分钟以上 | warning `FUTURE_TIMESTAMP`,仍接受 | +| eventType | 必须为 5 种之一 | status=failed, errorCode=`INVALID_EVENT_TYPE` | + +## 8. 响应 + +### 成功 + +```json +{ + "processed": 10, + "duplicate": 1, + "failed": 0, + "warnings": [] +} +``` + +### 部分失败 + +```json +{ + "processed": 8, + "duplicate": 0, + "failed": 2, + "warnings": [ + { "eventId": "xxx", "code": "INVALID_DELTA", "message": "activeSecondsDelta must be >= 0" }, + { "eventId": "yyy", "code": "DELTA_EXCEEDED", "message": "activeSecondsDelta 350 truncated to 300" } + ] +} +``` + +## 9. 错误码 + +| 码 | 类型 | 含义 | +|----|------|------| +| `DUPLICATE_EVENT` | info | 重复 eventId(幂等) | +| `INVALID_DELTA` | error | activeSecondsDelta 负数 | +| `DELTA_EXCEEDED` | warning | delta > 300,已截断 | +| `MISSING_DELTA` | error | 缺少 activeSecondsDelta | +| `INVALID_EVENT_TYPE` | error | 未知 eventType | +| `INVALID_TARGET_TYPE` | error | 未知 readingTargetType | +| `MISSING_CLIENT_SESSION` | error | 缺少 clientSessionId | +| `MISSING_MATERIAL_ID` | error | 缺少 materialId | +| `MATERIAL_NOT_FOUND` | warning | materialId 对应的资源不存在 | +| `FUTURE_TIMESTAMP` | warning | 时间戳在未来 | + +## 10. 示例 + +### 请求 + +```json +{ + "events": [ + { + "eventId": "550e8400-e29b-41d4-a716-446655440001", + "clientSessionId": "550e8400-e29b-41d4-a716-446655440000", + "materialId": "cuid_mat_001", + "readingTargetType": "knowledge_source", + "eventType": "material_opened", + "activeSecondsDelta": 0, + "clientTimestampMs": 1717800000000, + "sequence": 1, + "platform": "ios", + "appVersion": "1.2.3", + "clientTimezoneOffsetMinutes": -480 + }, + { + "eventId": "550e8400-e29b-41d4-a716-446655440002", + "clientSessionId": "550e8400-e29b-41d4-a716-446655440000", + "materialId": "cuid_mat_001", + "readingTargetType": "knowledge_source", + "eventType": "position_changed", + "position": { "type": "Markdown", "blockId": "intro", "scrollProgress": 0.25 }, + "activeSecondsDelta": 0, + "clientTimestampMs": 1717800005000, + "sequence": 2, + "platform": "ios", + "appVersion": "1.2.3", + "clientTimezoneOffsetMinutes": -480 + }, + { + "eventId": "550e8400-e29b-41d4-a716-446655440003", + "clientSessionId": "550e8400-e29b-41d4-a716-446655440000", + "materialId": "cuid_mat_001", + "readingTargetType": "knowledge_source", + "eventType": "heartbeat", + "position": { "type": "Markdown", "blockId": "ch1", "scrollProgress": 0.5 }, + "activeSecondsDelta": 15, + "clientTimestampMs": 1717800020000, + "sequence": 3, + "platform": "ios", + "appVersion": "1.2.3", + "clientTimezoneOffsetMinutes": -480 + } + ] +} +``` + +### 响应 + +```json +{ + "processed": 3, + "duplicate": 0, + "failed": 0, + "warnings": [] +} +``` diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b2000c3..c048957 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -389,6 +389,13 @@ model LearningSession { durationSeconds Int @default(0) focusMinutes Int? metadata Json? + // ── M8 新增字段 ── + clientSessionId String? + materialId String? + readingTargetType String? @db.VarChar(32) + totalActiveSeconds Int @default(0) + lastPosition Json? + lastEventAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -397,6 +404,8 @@ model LearningSession { @@index([userId]) @@index([knowledgeItemId]) @@index([startedAt]) + @@index([clientSessionId]) + @@index([materialId]) } model LearningRecord { @@ -418,6 +427,86 @@ model LearningRecord { @@index([createdAt]) } +model ReadingEvent { + id String @id @default(cuid()) + userId String + eventId String + clientSessionId String + readingTargetType String @db.VarChar(32) + materialId String + knowledgeBaseId String? + eventType String @db.VarChar(32) + position Json? + activeSecondsDelta Int @default(0) + clientTimestampMs BigInt + clientTimezoneOffsetMinutes Int? + sequence Int + platform String? @db.VarChar(16) + appVersion String? @db.VarChar(32) + status String @default("pending") @db.VarChar(32) + errorCode String? @db.VarChar(32) + warningCodes Json? + serverReceivedAt DateTime @default(now()) + processedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + + @@unique([userId, eventId]) + @@index([userId, clientSessionId]) + @@index([userId, readingTargetType, materialId, clientTimestampMs]) + @@index([status, createdAt]) + @@index([userId, createdAt]) +} + +model MaterialReadingProgress { + id String @id @default(cuid()) + userId String + readingTargetType String @db.VarChar(32) + materialId String + knowledgeBaseId String? + lastClientSessionId String? + lastPosition Json? + lastProgress Float? + totalActiveSeconds Int @default(0) + sessionCount Int @default(0) + status String @default("not_started") @db.VarChar(32) + firstOpenedAt DateTime? + lastOpenedAt DateTime? + lastReadAt DateTime? + isMarkedRead Boolean @default(false) + markedReadAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + + @@unique([userId, materialId]) + @@index([userId]) + @@index([knowledgeBaseId]) + @@index([status]) +} + +model TemporaryReadingMaterial { + id String @id @default(cuid()) + userId String + title String? @db.VarChar(255) + originalFilename String? @db.VarChar(255) + mimeType String? @db.VarChar(100) + sizeBytes BigInt @default(0) + storageKey String? @db.VarChar(500) + sourceStatus String @default("active") @db.VarChar(32) + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + user User @relation(fields: [userId], references: [id]) + + @@index([userId]) + @@index([expiresAt]) +} + model ActiveRecallQuestion { id String @id @default(cuid()) userId String @@ -605,6 +694,10 @@ model DailyLearningActivity { aiAnalysisCount Int @default(0) completedLoopCount Int @default(0) activityLevel Int @default(0) + // ── M8 新增字段 ── + readingSeconds Int @default(0) + materialsReadCount Int @default(0) + markedReadCount Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/modules/learning-activity/learning-activity.controller.ts b/src/modules/learning-activity/learning-activity.controller.ts index 2b5b07c..c9abff1 100644 --- a/src/modules/learning-activity/learning-activity.controller.ts +++ b/src/modules/learning-activity/learning-activity.controller.ts @@ -15,8 +15,12 @@ export class LearningActivityController { @Get('heatmap') @ApiOperation({ summary: '获取学习热力图数据' }) - async getHeatmap(@CurrentUser() user: UserPayload) { - return this.activityService.getHeatmap(String(user?.id || 'anonymous')); + async getHeatmap( + @CurrentUser() user: UserPayload, + @Query('days') days?: string, + ) { + const d = Math.min(Number(days ?? 365) || 365, 365); + return this.activityService.getHeatmap(String(user?.id || 'anonymous'), d); } @Get('summary') diff --git a/src/modules/learning-activity/learning-activity.module.ts b/src/modules/learning-activity/learning-activity.module.ts index 39faaf3..2d6fb92 100644 --- a/src/modules/learning-activity/learning-activity.module.ts +++ b/src/modules/learning-activity/learning-activity.module.ts @@ -9,6 +9,6 @@ import { GrowthService } from './growth.service'; imports: [AiModule], controllers: [LearningActivityController], providers: [LearningActivityService, LearningActivityRepository, GrowthService], - exports: [LearningActivityService, GrowthService], + exports: [LearningActivityService, LearningActivityRepository, GrowthService], }) export class LearningActivityModule {} diff --git a/src/modules/learning-activity/learning-activity.repository.ts b/src/modules/learning-activity/learning-activity.repository.ts index 805fcc9..ea99f58 100644 --- a/src/modules/learning-activity/learning-activity.repository.ts +++ b/src/modules/learning-activity/learning-activity.repository.ts @@ -11,4 +11,50 @@ export class LearningActivityRepository { orderBy: { activityDate: 'asc' }, }); } + + async findByDateRange(userId: string, from: Date, to: Date) { + return this.prisma.dailyLearningActivity.findMany({ + where: { userId, activityDate: { gte: from, lte: to } }, + orderBy: { activityDate: 'asc' }, + }); + } + + /** M8: Upsert daily activity from reading event (within transaction, timezone-aware). */ + async upsertFromReadingEvent(tx: any, data: { + userId: string; + clientTimestampMs: bigint; + clientTimezoneOffsetMinutes: number | null; + activeSecondsDelta: number; + isNewMaterial?: boolean; + isMarkedRead?: boolean; + }) { + const activityDate = this.computeActivityDate(data.clientTimestampMs, data.clientTimezoneOffsetMinutes); + const { userId, activeSecondsDelta, isNewMaterial, isMarkedRead } = data; + + return tx.dailyLearningActivity.upsert({ + where: { userId_activityDate: { userId, activityDate } }, + create: { + userId, + activityDate, + durationSeconds: activeSecondsDelta, + readingSeconds: activeSecondsDelta, + materialsReadCount: isNewMaterial ? 1 : 0, + markedReadCount: isMarkedRead ? 1 : 0, + }, + update: { + durationSeconds: { increment: activeSecondsDelta }, + readingSeconds: { increment: activeSecondsDelta }, + materialsReadCount: isNewMaterial ? { increment: 1 } : undefined, + markedReadCount: isMarkedRead ? { increment: 1 } : undefined, + }, + }); + } + + /** Compute local date from timestamp and timezone offset. */ + private computeActivityDate(timestampMs: bigint, offsetMinutes: number | null): Date { + const offsetMs = (offsetMinutes ?? 0) * 60 * 1000; + const localMs = Number(timestampMs) + offsetMs; + const date = new Date(localMs); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + } } diff --git a/src/modules/learning-activity/learning-activity.service.ts b/src/modules/learning-activity/learning-activity.service.ts index b3c3729..f66ca07 100644 --- a/src/modules/learning-activity/learning-activity.service.ts +++ b/src/modules/learning-activity/learning-activity.service.ts @@ -9,8 +9,10 @@ export class LearningActivityService { private readonly trendWorkflow: LearningTrendWorkflow, ) {} - async getHeatmap(userId: string) { - const activities = await this.repository.findAll(userId); + async getHeatmap(userId: string, days: number = 365) { + const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const to = new Date(); + const activities = await this.repository.findByDateRange(userId, from, to); const heatmap: Record = {}; for (const a of activities) { const dateStr = a.activityDate instanceof Date @@ -126,4 +128,15 @@ export class LearningActivityService { } return series; } + + /** M8: Upsert daily activity from a reading event. */ + async upsertFromReadingEvent(data: { + userId: string; + activityDate: Date; + activeSecondsDelta: number; + isNewMaterial?: boolean; + isMarkedRead?: boolean; + }) { + return this.repository.upsertFromReadingEvent(data); + } } diff --git a/src/modules/learning-record/learning-record.module.ts b/src/modules/learning-record/learning-record.module.ts new file mode 100644 index 0000000..189bc2c --- /dev/null +++ b/src/modules/learning-record/learning-record.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../infrastructure/prisma.module'; +import { LearningRecordService } from './learning-record.service'; + +@Module({ + imports: [PrismaModule], + providers: [LearningRecordService], + exports: [LearningRecordService], +}) +export class LearningRecordModule {} diff --git a/src/modules/learning-record/learning-record.service.ts b/src/modules/learning-record/learning-record.service.ts new file mode 100644 index 0000000..a56250d --- /dev/null +++ b/src/modules/learning-record/learning-record.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/prisma.service'; + +export const LEARNING_RECORD_TYPES = [ + 'reading', + 'read_completed', + 'review', + 'quiz', + 'chat', + 'note', + 'manual', +] as const; + +const VALID_RECORD_TYPES = new Set(LEARNING_RECORD_TYPES); + +@Injectable() +export class LearningRecordService { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { + userId: string; + recordType: string; + title: string; + description?: string; + sessionId?: string; + durationSeconds?: number; + occurredAt?: Date; + metadata?: Record; + }) { + if (!VALID_RECORD_TYPES.has(data.recordType)) { + throw new Error(`Invalid recordType: ${data.recordType}. Must be one of: ${LEARNING_RECORD_TYPES.join(', ')}`); + } + return this.prisma.learningRecord.create({ + data: { + userId: data.userId, + sessionId: data.sessionId ?? null, + recordType: data.recordType, + title: data.title, + description: data.description ?? null, + durationSeconds: data.durationSeconds ?? 0, + occurredAt: data.occurredAt ?? new Date(), + metadata: data.metadata ?? undefined, + }, + }); + } + + async findByUser( + userId: string, + opts: { page?: number; limit?: number; recordType?: string; cursor?: string } = {}, + ) { + const { page = 1, limit = 20, recordType, cursor } = opts; + return this.prisma.learningRecord.findMany({ + where: { + userId, + ...(recordType ? { recordType } : {}), + }, + orderBy: { occurredAt: 'desc' }, + ...(cursor + ? { cursor: { id: cursor }, skip: 1 } + : { skip: (page - 1) * limit }), + take: limit, + }); + } + + async findBySessionId(sessionId: string) { + return this.prisma.learningRecord.findMany({ + where: { sessionId }, + orderBy: { occurredAt: 'asc' }, + }); + } + + async deleteById(id: string, userId: string) { + const record = await this.prisma.learningRecord.findUnique({ where: { id } }); + if (!record) throw new Error('LearningRecord not found'); + if (record.userId !== userId) throw new Error('Access denied'); + return this.prisma.learningRecord.delete({ where: { id } }); + } + + /** M8: Create a reading record from a processed event (within transaction). */ + async createReadingRecordTx(tx: any, data: { + userId: string; + sessionId?: string; + materialId: string; + readingTargetType: string; + knowledgeBaseId?: string | null; + title: string; + totalActiveSeconds?: number; + lastPosition?: any; + occurredAt: Date; + }) { + if (!VALID_RECORD_TYPES.has('reading')) { + throw new Error('Invalid recordType'); + } + const positionSnapshot = data.lastPosition + ? { type: data.lastPosition.type, progress: data.lastPosition.progressValue ?? null } + : null; + + return tx.learningRecord.create({ + data: { + userId: data.userId, + sessionId: data.sessionId ?? null, + recordType: 'reading', + title: data.title, + durationSeconds: data.totalActiveSeconds ?? 0, + occurredAt: data.occurredAt, + metadata: { + materialId: data.materialId, + readingTargetType: data.readingTargetType, + knowledgeBaseId: data.knowledgeBaseId ?? null, + totalActiveSeconds: data.totalActiveSeconds, + lastPosition: positionSnapshot, + }, + }, + }); + } + + /** M8: Create a reading record from a processed event (outside transaction). */ + async createReadingRecord(data: { + userId: string; + sessionId?: string; + materialId: string; + readingTargetType: string; + knowledgeBaseId?: string | null; + title: string; + totalActiveSeconds?: number; + lastPosition?: any; + occurredAt: Date; + }) { + const positionSnapshot = data.lastPosition + ? { type: data.lastPosition.type, progress: data.lastPosition.progressValue ?? null } + : null; + + return this.create({ + userId: data.userId, + recordType: 'reading', + title: data.title, + sessionId: data.sessionId, + durationSeconds: data.totalActiveSeconds ?? 0, + occurredAt: data.occurredAt, + metadata: { + materialId: data.materialId, + readingTargetType: data.readingTargetType, + knowledgeBaseId: data.knowledgeBaseId ?? null, + totalActiveSeconds: data.totalActiveSeconds, + lastPosition: positionSnapshot, + }, + }); + } +} diff --git a/src/modules/learning-session/learning-session.module.ts b/src/modules/learning-session/learning-session.module.ts index c68c1e7..852ae1b 100644 --- a/src/modules/learning-session/learning-session.module.ts +++ b/src/modules/learning-session/learning-session.module.ts @@ -7,6 +7,6 @@ import { LearningSessionRepository } from './learning-session.repository'; @Module({ controllers: [LearningSessionController, AdminLearningController], providers: [LearningSessionService, LearningSessionRepository], - exports: [LearningSessionService], + exports: [LearningSessionService, LearningSessionRepository], }) export class LearningSessionModule {} diff --git a/src/modules/learning-session/learning-session.repository.ts b/src/modules/learning-session/learning-session.repository.ts index fb3ffb7..d0deb47 100644 --- a/src/modules/learning-session/learning-session.repository.ts +++ b/src/modules/learning-session/learning-session.repository.ts @@ -47,7 +47,6 @@ export class LearningSessionRepository { const where: any = { userId }; if (opts?.status) where.status = opts.status; - // sort: startedAt:desc (default) | startedAt:asc | durationSeconds:desc let orderBy: any = { startedAt: 'desc' }; if (opts?.sort) { const [field, dir] = opts.sort.split(':'); @@ -63,4 +62,55 @@ export class LearningSessionRepository { take: limit, }); } + + /** M8: Upsert session from reading event aggregation. */ + async upsertFromReadingEvent(tx: any, data: { + userId: string; + clientSessionId: string; + materialId: string; + readingTargetType: string; + knowledgeBaseId: string | null; + eventType: string; + activeSecondsDelta: number; + position: any | null; + timestampMs: bigint; + }) { + const existing = await tx.learningSession.findFirst({ + where: { clientSessionId: data.clientSessionId }, + }); + + if (existing) { + const update: any = { + lastEventAt: new Date(Number(data.timestampMs)), + }; + if (data.activeSecondsDelta > 0) { + update.totalActiveSeconds = { increment: data.activeSecondsDelta }; + } + if (data.position) { + update.lastPosition = data.position; + } + if (data.eventType === 'material_closed') { + update.status = 'completed'; + update.endedAt = new Date(Number(data.timestampMs)); + } + + return tx.learningSession.update({ where: { id: existing.id }, data: update }); + } + + return tx.learningSession.create({ + data: { + userId: data.userId, + clientSessionId: data.clientSessionId, + materialId: data.materialId, + readingTargetType: data.readingTargetType, + knowledgeBaseId: data.knowledgeBaseId, + mode: 'reading', + status: 'active', + totalActiveSeconds: data.activeSecondsDelta, + lastPosition: data.position ?? undefined, + lastEventAt: new Date(Number(data.timestampMs)), + startedAt: new Date(Number(data.timestampMs)), + }, + }); + } } diff --git a/src/modules/learning-session/learning-session.service.ts b/src/modules/learning-session/learning-session.service.ts index 324e134..e5c6c9e 100644 --- a/src/modules/learning-session/learning-session.service.ts +++ b/src/modules/learning-session/learning-session.service.ts @@ -18,4 +18,20 @@ export class LearningSessionService { async findByUserId(userId: string, opts: { page?: number; limit?: number; status?: string; sort?: string }) { return this.repository.findByUserId(userId, opts); } + + /** Upsert a session from a reading event (M8 aggregation). */ + async upsertFromReadingEvent(data: { + userId: string; + clientSessionId: string; + materialId: string; + readingTargetType: string; + knowledgeBaseId?: string | null; + eventType: string; + activeSecondsDelta: number; + position?: any; + timestampMs: number; + startedAt: Date; + }) { + return this.repository.upsertByClientSession(data); + } } diff --git a/src/modules/material-reading-progress/material-reading-progress.module.ts b/src/modules/material-reading-progress/material-reading-progress.module.ts new file mode 100644 index 0000000..5023f68 --- /dev/null +++ b/src/modules/material-reading-progress/material-reading-progress.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../infrastructure/prisma.module'; +import { MaterialReadingProgressService } from './material-reading-progress.service'; + +@Module({ + imports: [PrismaModule], + providers: [MaterialReadingProgressService], + exports: [MaterialReadingProgressService], +}) +export class MaterialReadingProgressModule {} diff --git a/src/modules/material-reading-progress/material-reading-progress.service.ts b/src/modules/material-reading-progress/material-reading-progress.service.ts new file mode 100644 index 0000000..370902c --- /dev/null +++ b/src/modules/material-reading-progress/material-reading-progress.service.ts @@ -0,0 +1,136 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/prisma.service'; + +@Injectable() +export class MaterialReadingProgressService { + constructor(private readonly prisma: PrismaService) {} + + /** Upsert progress for a user+material pair. */ + async upsertProgress(data: { + userId: string; + readingTargetType: string; + materialId: string; + knowledgeBaseId?: string | null; + lastPosition?: any; + lastProgress?: number; + activeSecondsDelta: number; + status?: string; + }) { + const existing = await this.prisma.materialReadingProgress.findUnique({ + where: { + userId_materialId: { userId: data.userId, materialId: data.materialId }, + }, + }); + + const isNew = !existing || !existing.firstOpenedAt; + + return this.prisma.materialReadingProgress.upsert({ + where: { + userId_materialId: { userId: data.userId, materialId: data.materialId }, + }, + create: { + userId: data.userId, + readingTargetType: data.readingTargetType, + materialId: data.materialId, + knowledgeBaseId: data.knowledgeBaseId ?? null, + lastPosition: data.lastPosition ?? undefined, + lastProgress: data.lastProgress ?? undefined, + totalActiveSeconds: data.activeSecondsDelta, + sessionCount: 1, + status: 'reading', + firstOpenedAt: new Date(), + lastOpenedAt: new Date(), + }, + update: { + totalActiveSeconds: { increment: data.activeSecondsDelta }, + sessionCount: isNew ? { increment: 1 } : undefined, + lastPosition: data.lastPosition ?? undefined, + lastProgress: data.lastProgress ?? undefined, + status: data.status ?? existing?.status ?? 'reading', + lastOpenedAt: new Date(), + lastReadAt: new Date(), + }, + }); + } + + /** M8: Upsert from reading event (within transaction). */ + async upsertFromReadingEvent(tx: any, data: { + userId: string; + readingTargetType: string; + materialId: string; + knowledgeBaseId: string | null; + eventType: string; + activeSecondsDelta: number; + position: any | null; + isNewSession: boolean; + }) { + const existing = await tx.materialReadingProgress.findUnique({ + where: { userId_materialId: { userId: data.userId, materialId: data.materialId } }, + }); + + const isNew = !existing || !existing.firstOpenedAt; + const isMarkedRead = data.eventType === 'marked_as_read'; + const isClosed = data.eventType === 'material_closed'; + + const update: any = {}; + if (data.activeSecondsDelta > 0) { + update.totalActiveSeconds = { increment: data.activeSecondsDelta }; + } + if (data.position) { + update.lastPosition = data.position; + update.lastProgress = this.extractProgress(data.position); + } + // MaterialClosed without position: don't overwrite lastPosition + if (isMarkedRead) { + update.isMarkedRead = true; + update.markedReadAt = new Date(); + update.status = 'read'; + update.lastProgress = 1.0; + } else if (isClosed) { + update.status = 'read'; + } else if (isNew) { + update.status = 'reading'; + } + update.lastReadAt = new Date(); + if (isNew) { + update.firstOpenedAt = new Date(); + } + if (data.isNewSession) { + update.sessionCount = { increment: 1 }; + update.lastOpenedAt = new Date(); + } + + if (isNew) { + return tx.materialReadingProgress.create({ + data: { + userId: data.userId, + readingTargetType: data.readingTargetType, + materialId: data.materialId, + knowledgeBaseId: data.knowledgeBaseId, + ...update, + }, + }); + } + + return tx.materialReadingProgress.update({ + where: { userId_materialId: { userId: data.userId, materialId: data.materialId } }, + data: update, + }); + } + + private extractProgress(position: any): number | null { + if (!position) return null; + // Try common progress field names + if (typeof position.overallProgress === 'number') return position.overallProgress; + if (typeof position.scrollProgress === 'number') return position.scrollProgress; + if (typeof position.chapterProgress === 'number') return position.chapterProgress; + return null; + } + + /** Get progress for a specific material. */ + async getProgress(userId: string, materialId: string) { + return this.prisma.materialReadingProgress.findUnique({ + where: { userId_materialId: { userId, materialId } }, + }); + } +} diff --git a/src/modules/reading-event/reading-event-codes.ts b/src/modules/reading-event/reading-event-codes.ts new file mode 100644 index 0000000..f9cca5e --- /dev/null +++ b/src/modules/reading-event/reading-event-codes.ts @@ -0,0 +1,43 @@ +/** Error codes — event is rejected (status = failed). */ +export enum ReadingEventErrorCode { + /** readingTargetType=knowledge_source but KnowledgeSource not found */ + MATERIAL_NOT_FOUND = 'MATERIAL_NOT_FOUND', + /** readingTargetType=temporary_file but TemporaryReadingMaterial not found */ + TEMPORARY_MATERIAL_NOT_FOUND = 'TEMPORARY_MATERIAL_NOT_FOUND', + /** Material exists but does not belong to userId */ + MATERIAL_ACCESS_DENIED = 'MATERIAL_ACCESS_DENIED', + /** TemporaryReadingMaterial has expired */ + TEMPORARY_MATERIAL_EXPIRED = 'TEMPORARY_MATERIAL_EXPIRED', + /** readingTargetType is not 'knowledge_source' or 'temporary_file' */ + INVALID_TARGET_TYPE = 'INVALID_TARGET_TYPE', + /** eventType is not one of the 5 valid types */ + INVALID_EVENT_TYPE = 'INVALID_EVENT_TYPE', + /** clientTimestampMs is malformed or absent */ + INVALID_TIMESTAMP = 'INVALID_TIMESTAMP', + /** position JSON is malformed */ + INVALID_POSITION = 'INVALID_POSITION', + /** activeSecondsDelta < 0 */ + INVALID_ACTIVE_SECONDS = 'INVALID_ACTIVE_SECONDS', + /** Batch size exceeds limit (default 100) */ + BATCH_LIMIT_EXCEEDED = 'BATCH_LIMIT_EXCEEDED', + /** clientSessionId missing */ + MISSING_CLIENT_SESSION = 'MISSING_CLIENT_SESSION', + /** materialId missing */ + MISSING_MATERIAL_ID = 'MISSING_MATERIAL_ID', +} + +/** Warning codes — event is accepted but flagged. */ +export enum ReadingEventWarningCode { + /** activeSecondsDelta > 300, truncated to 300 */ + ACTIVE_SECONDS_CAPPED = 'ACTIVE_SECONDS_CAPPED', + /** Timestamp skewed vs server time > 5 min */ + CLIENT_TIMESTAMP_SKEWED = 'CLIENT_TIMESTAMP_SKEWED', + /** Position present but invalid for the event type (e.g. MaterialClosed without position — position is ignored, not rejected) */ + POSITION_IGNORED = 'POSITION_IGNORED', + /** eventId already processed (idempotent replay) */ + DUPLICATE_EVENT = 'DUPLICATE_EVENT', + /** Sequence out of order within session (non-fatal) */ + OUT_OF_ORDER_EVENT = 'OUT_OF_ORDER_EVENT', + /** The source material has been deleted */ + SOURCE_DELETED = 'SOURCE_DELETED', +} diff --git a/src/modules/reading-event/reading-event-processor.service.ts b/src/modules/reading-event/reading-event-processor.service.ts new file mode 100644 index 0000000..37d6f26 --- /dev/null +++ b/src/modules/reading-event/reading-event-processor.service.ts @@ -0,0 +1,340 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/prisma.service'; +import { LearningSessionRepository } from '../learning-session/learning-session.repository'; +import { LearningActivityRepository } from '../learning-activity/learning-activity.repository'; +import { LearningRecordService } from '../learning-record/learning-record.service'; +import { MaterialReadingProgressService } from '../material-reading-progress/material-reading-progress.service'; +import { ReadingEventErrorCode, ReadingEventWarningCode } from './reading-event-codes'; + +const VALID_EVENT_TYPES = new Set([ + 'material_opened', 'material_closed', 'position_changed', 'heartbeat', 'marked_as_read', +]); +const VALID_TARGET_TYPES = new Set(['knowledge_source', 'temporary_file']); +const MAX_DELTA = 300; + +interface ProcessResult { + processed: number; + duplicate: number; + failed: number; + warnings: Array<{ eventId?: string; code: string; message: string }>; +} + +interface ValidatedEvent { + eventId: string; + clientSessionId: string; + materialId: string; + readingTargetType: string; + eventType: string; + position: any | null; + activeSecondsDelta: number; + clientTimestampMs: bigint; + clientTimezoneOffsetMinutes: number | null; + sequence: number; + platform: string | null; + appVersion: string | null; +} + +@Injectable() +export class ReadingEventProcessorService { + constructor( + private readonly prisma: PrismaService, + private readonly sessionRepo: LearningSessionRepository, + private readonly progressSvc: MaterialReadingProgressService, + private readonly activityRepo: LearningActivityRepository, + private readonly recordSvc: LearningRecordService, + ) {} + + async processBatch(userId: string, events: Array>): Promise { + // Lazy cleanup: mark interrupted sessions before processing + await this.cleanupInterruptedSessions(userId); + + const result: ProcessResult = { processed: 0, duplicate: 0, failed: 0, warnings: [] }; + + for (const e of events) { + const { outcome, warnings } = await this.processOne(userId, e); + switch (outcome) { + case 'processed': result.processed++; break; + case 'duplicate': result.duplicate++; break; + case 'failed': result.failed++; break; + } + result.warnings.push(...warnings); + } + + return result; + } + + /** Clean up interrupted sessions for a user before processing new events. */ + async cleanupInterruptedSessions(userId: string): Promise { + const cutoff = new Date(Date.now() - 30 * 60 * 1000); // 30 min ago + + const result = await this.prisma.learningSession.updateMany({ + where: { + userId, + status: 'active', + lastEventAt: { lt: cutoff }, + }, + data: { status: 'interrupted', endedAt: new Date() }, + }); + + return result.count; + } + + async processOne( + userId: string, + e: Record, + ): Promise<{ outcome: 'processed' | 'duplicate' | 'failed'; warnings: Array<{ eventId?: string; code: string; message: string }> }> { + // 1. Validate + const validated = this.validateEvent(e); + if (!validated) { + return { outcome: 'failed', warnings: [{ eventId: e.eventId, code: ReadingEventErrorCode.INVALID_EVENT_TYPE, message: 'Validation failed' }] }; + } + + // 2. Dedup + const isDuplicate = await this.checkDuplicate(userId, e.eventId); + if (isDuplicate) { + return { outcome: 'duplicate', warnings: [{ eventId: e.eventId, code: ReadingEventWarningCode.DUPLICATE_EVENT, message: 'Duplicate eventId' }] }; + } + + // 3. Validate access and resolve target + const access = await this.validateReadingAccess(userId, e.readingTargetType, e.materialId); + const eventWarnings = this.collectWarnings(e); + const knowledgeBaseId = access.allowed ? access.knowledgeBaseId : null; + if (!access.allowed) { + eventWarnings.push(access.errorCode); + } + + // 4. Insert event + aggregate + mark processed in one transaction (F6) + await this.prisma.$transaction(async (tx) => { + await this.insertReadingEvent(tx, userId, validated, knowledgeBaseId, eventWarnings); + + // 5a. Aggregate → LearningSession + await this.sessionRepo.upsertFromReadingEvent(tx, { + userId, + clientSessionId: validated.clientSessionId, + materialId: validated.materialId, + readingTargetType: validated.readingTargetType, + knowledgeBaseId: knowledgeBaseId, + eventType: validated.eventType, + activeSecondsDelta: validated.activeSecondsDelta, + position: validated.position, + timestampMs: validated.clientTimestampMs, + }); + + // 5b. Aggregate → MaterialReadingProgress + await this.progressSvc.upsertFromReadingEvent(tx, { + userId, + readingTargetType: validated.readingTargetType, + materialId: validated.materialId, + knowledgeBaseId: knowledgeBaseId, + eventType: validated.eventType, + activeSecondsDelta: validated.activeSecondsDelta, + position: validated.position, + isNewSession: validated.eventType === 'material_opened', + }); + + // 5c. Aggregate → DailyLearningActivity + await this.activityRepo.upsertFromReadingEvent(tx, { + userId, + clientTimestampMs: validated.clientTimestampMs, + clientTimezoneOffsetMinutes: validated.clientTimezoneOffsetMinutes, + activeSecondsDelta: validated.activeSecondsDelta, + isNewMaterial: validated.eventType === 'material_opened', + isMarkedRead: validated.eventType === 'marked_as_read', + }); + + // 5d. Write LearningRecord (first open / closed / marked read) + if (['material_opened', 'material_closed', 'marked_as_read'].includes(validated.eventType)) { + const recordTitle = validated.eventType === 'material_opened' ? 'Reading started' + : validated.eventType === 'material_closed' ? 'Reading ended' + : 'Marked as read'; + await this.recordSvc.createReadingRecordTx(tx, { + userId, + sessionId: validated.clientSessionId, + materialId: validated.materialId, + readingTargetType: validated.readingTargetType, + knowledgeBaseId: knowledgeBaseId, + title: recordTitle, + totalActiveSeconds: validated.activeSecondsDelta, + lastPosition: validated.position, + occurredAt: new Date(Number(validated.clientTimestampMs)), + }); + } + + // 6. Mark processed + await tx.readingEvent.update({ + where: { userId_eventId: { userId, eventId: e.eventId } }, + data: { status: 'processed', processedAt: new Date() }, + }); + }); + + const resultWarnings = eventWarnings.map(code => ({ eventId: e.eventId, code, message: code })); + return { outcome: 'processed', warnings: resultWarnings }; + } + + // ── Step 1: Validate ── + + validateEvent(e: Record): ValidatedEvent | null { + if (!e.eventId || !e.clientSessionId || !e.materialId) return null; + if (!VALID_TARGET_TYPES.has(e.readingTargetType)) return null; + if (!VALID_EVENT_TYPES.has(e.eventType)) return null; + + const delta = Number(e.activeSecondsDelta ?? 0); + if (isNaN(delta) || delta < 0) return null; + + const ts = Number(e.clientTimestampMs ?? 0); + if (isNaN(ts) || ts <= 0) return null; + + let position = e.position ?? null; + if (position && !this.isValidPosition(position)) { + position = null; // save event but don't update progress + } + + return { + eventId: e.eventId, + clientSessionId: e.clientSessionId, + materialId: e.materialId, + readingTargetType: e.readingTargetType, + eventType: e.eventType, + position, + activeSecondsDelta: Math.min(delta, MAX_DELTA), + clientTimestampMs: BigInt(ts), + clientTimezoneOffsetMinutes: e.clientTimezoneOffsetMinutes ?? null, + sequence: Number(e.sequence ?? 0), + platform: e.platform ?? null, + appVersion: e.appVersion ?? null, + }; + } + + // ── Step 2: Dedup ── + + private async checkDuplicate(userId: string, eventId: string): Promise { + const existing = await this.prisma.readingEvent.findUnique({ + where: { userId_eventId: { userId, eventId } }, + select: { id: true, status: true }, + }); + // Only skip if already successfully processed (not failed/pending) + return !!existing && existing.status === 'processed'; + } + + // ── Step 3: Resolve target (with access validation) ── + + /** Validate reading access and return knowledgeBaseId or error code. */ + async validateReadingAccess( + userId: string, + readingTargetType: string, + materialId: string, + ): Promise<{ allowed: true; knowledgeBaseId: string | null } | { allowed: false; errorCode: string }> { + if (readingTargetType === 'knowledge_source') { + const src = await this.prisma.knowledgeSource.findUnique({ + where: { id: materialId }, + select: { userId: true, knowledgeBaseId: true, deletedAt: true }, + }); + if (!src) return { allowed: false, errorCode: ReadingEventErrorCode.MATERIAL_NOT_FOUND }; + if (src.userId !== userId) return { allowed: false, errorCode: ReadingEventErrorCode.MATERIAL_ACCESS_DENIED }; + if (src.deletedAt) return { allowed: false, errorCode: ReadingEventWarningCode.SOURCE_DELETED }; + return { allowed: true, knowledgeBaseId: src.knowledgeBaseId }; + } + + if (readingTargetType === 'temporary_file') { + const mat = await this.prisma.temporaryReadingMaterial.findUnique({ + where: { id: materialId }, + select: { userId: true, deletedAt: true, expiresAt: true, sourceStatus: true }, + }); + if (!mat) return { allowed: false, errorCode: ReadingEventErrorCode.TEMPORARY_MATERIAL_NOT_FOUND }; + if (mat.userId !== userId) return { allowed: false, errorCode: ReadingEventErrorCode.MATERIAL_ACCESS_DENIED }; + if (mat.deletedAt) return { allowed: false, errorCode: ReadingEventWarningCode.SOURCE_DELETED }; + if (mat.expiresAt && mat.expiresAt < new Date()) { + return { allowed: false, errorCode: ReadingEventErrorCode.TEMPORARY_MATERIAL_EXPIRED }; + } + if (mat.sourceStatus === 'expired') { + return { allowed: false, errorCode: ReadingEventErrorCode.TEMPORARY_MATERIAL_EXPIRED }; + } + return { allowed: true, knowledgeBaseId: null }; + } + + return { allowed: false, errorCode: ReadingEventErrorCode.INVALID_TARGET_TYPE }; + } + + async resolveReadingTarget( + userId: string, + readingTargetType: string, + materialId: string, + ): Promise<{ knowledgeBaseId: string | null } | null> { + if (readingTargetType === 'knowledge_source') { + const src = await this.prisma.knowledgeSource.findUnique({ + where: { id: materialId }, + select: { userId: true, knowledgeBaseId: true }, + }); + if (!src || src.userId !== userId) return null; + return { knowledgeBaseId: src.knowledgeBaseId }; + } + + if (readingTargetType === 'temporary_file') { + const mat = await this.prisma.temporaryReadingMaterial.findUnique({ + where: { id: materialId }, + select: { userId: true, deletedAt: true, expiresAt: true, sourceStatus: true }, + }); + if (!mat || mat.userId !== userId) return null; + if (mat.deletedAt) return null; + if (mat.expiresAt && mat.expiresAt < new Date()) return null; + if (mat.sourceStatus === 'expired') return null; + return { knowledgeBaseId: null }; + } + + return null; + } + + // ── Step 4: Insert ── + + private async insertReadingEvent( + tx: any, + userId: string, + e: ValidatedEvent, + knowledgeBaseId: string | null, + warnings: string[], + ) { + await tx.readingEvent.create({ + data: { + userId, + eventId: e.eventId, + clientSessionId: e.clientSessionId, + readingTargetType: e.readingTargetType, + materialId: e.materialId, + knowledgeBaseId, + eventType: e.eventType, + position: e.position ?? undefined, + activeSecondsDelta: e.activeSecondsDelta, + clientTimestampMs: e.clientTimestampMs, + clientTimezoneOffsetMinutes: e.clientTimezoneOffsetMinutes, + sequence: e.sequence, + platform: e.platform, + appVersion: e.appVersion, + status: 'processing', + warningCodes: warnings.length > 0 ? warnings : undefined, + serverReceivedAt: new Date(), + }, + }); + } + + // ── Helpers ── + + private isValidPosition(pos: any): boolean { + if (!pos || typeof pos !== 'object') return false; + return ['Markdown', 'Text', 'Pdf', 'Image', 'Epub', 'Unknown'].includes(pos.type); + } + + private collectWarnings(e: Record): string[] { + const warnings: string[] = []; + const delta = Number(e.activeSecondsDelta ?? 0); + if (delta > MAX_DELTA) warnings.push(ReadingEventWarningCode.ACTIVE_SECONDS_CAPPED); + + const ts = Number(e.clientTimestampMs ?? 0); + if (ts > Date.now() + 5 * 60 * 1000) warnings.push(ReadingEventWarningCode.CLIENT_TIMESTAMP_SKEWED); + + if (e.position && !this.isValidPosition(e.position)) { + warnings.push(ReadingEventWarningCode.POSITION_IGNORED); + } + + return warnings; + } +} diff --git a/src/modules/reading-event/reading-event.controller.ts b/src/modules/reading-event/reading-event.controller.ts new file mode 100644 index 0000000..222b62e --- /dev/null +++ b/src/modules/reading-event/reading-event.controller.ts @@ -0,0 +1,107 @@ +import { Body, Controller, Param, Post, Query, Req, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { PrismaService } from '../../infrastructure/prisma.service'; +import { BatchUploadReadingEventsDto } from './reading-event.dto'; +import { ReadingEventProcessorService } from './reading-event-processor.service'; + +const MAX_BATCH_SIZE = 100; + +@Controller() +@UseGuards(JwtAuthGuard) +export class ReadingEventController { + constructor( + private readonly prisma: PrismaService, + private readonly processor: ReadingEventProcessorService, + ) {} + + @Post('learning/reading-events/batch') + @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + async uploadBatch( + @Req() req: any, + @Body() body: BatchUploadReadingEventsDto, + ) { + const userId = req.user.id; + const { events } = body; + + if (events.length > MAX_BATCH_SIZE) { + return { + processed: 0, + duplicate: 0, + failed: events.length, + warnings: [{ + code: 'BATCH_LIMIT_EXCEEDED', + message: `Batch size ${events.length} exceeds limit of ${MAX_BATCH_SIZE}`, + }], + }; + } + + return this.processor.processBatch(userId, events); + } + + @Post('internal/learning/reading-events/:id/reprocess') + async reprocessOne( + @Req() req: any, + @Param('id') id: string, + @Query('force') force?: string, + ) { + const event = await this.prisma.readingEvent.findUnique({ where: { id } }); + if (!event) return { error: 'Event not found' }; + + // processed events require force=true to re-process + if (event.status === 'processed' && force !== 'true') { + return { error: 'Event already processed. Use ?force=true to re-process.' }; + } + if (!['failed', 'pending', 'processing'].includes(event.status) && force !== 'true') { + return { error: `Event status ${event.status} not eligible for reprocess. Use ?force=true.` }; + } + + const result = await this.processor.processOne(event.userId, { + eventId: event.eventId, + clientSessionId: event.clientSessionId, + materialId: event.materialId, + readingTargetType: event.readingTargetType, + eventType: event.eventType, + position: event.position, + activeSecondsDelta: event.activeSecondsDelta, + clientTimestampMs: Number(event.clientTimestampMs), + clientTimezoneOffsetMinutes: event.clientTimezoneOffsetMinutes, + sequence: event.sequence, + platform: event.platform, + appVersion: event.appVersion, + }); + + return { id, result }; + } + + @Post('internal/learning/reading-events/reprocess-failed') + async reprocessFailed(@Req() req: any, @Query('limit') limitStr?: string) { + const limit = Math.min(Number(limitStr ?? 50) || 50, 200); + + const failed = await this.prisma.readingEvent.findMany({ + where: { status: 'failed' }, + take: limit, + orderBy: { createdAt: 'asc' }, + }); + + const results: Array<{ id: string; outcome: string }> = []; + for (const event of failed) { + const r = await this.processor.processOne(event.userId, { + eventId: event.eventId, + clientSessionId: event.clientSessionId, + materialId: event.materialId, + readingTargetType: event.readingTargetType, + eventType: event.eventType, + position: event.position, + activeSecondsDelta: event.activeSecondsDelta, + clientTimestampMs: Number(event.clientTimestampMs), + clientTimezoneOffsetMinutes: event.clientTimezoneOffsetMinutes, + sequence: event.sequence, + platform: event.platform, + appVersion: event.appVersion, + }); + results.push({ id: event.id, outcome: r.outcome }); + } + + return { reprocessed: results.length, results }; + } +} diff --git a/src/modules/reading-event/reading-event.dto.ts b/src/modules/reading-event/reading-event.dto.ts new file mode 100644 index 0000000..0360eca --- /dev/null +++ b/src/modules/reading-event/reading-event.dto.ts @@ -0,0 +1,51 @@ +import { IsArray, IsInt, IsOptional, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ReadingEventUploadItemDto { + @IsString() + eventId!: string; + + @IsString() + clientSessionId!: string; + + @IsString() + materialId!: string; + + @IsString() + readingTargetType!: string; + + @IsString() + eventType!: string; + + @IsOptional() + position?: any; + + @IsInt() + @Min(0) + @Max(300) + activeSecondsDelta!: number; + + @IsInt() + clientTimestampMs!: number; + + @IsInt() + sequence!: number; + + @IsString() + platform!: string; + + @IsOptional() + @IsString() + appVersion?: string; + + @IsOptional() + @IsInt() + clientTimezoneOffsetMinutes?: number; +} + +export class BatchUploadReadingEventsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReadingEventUploadItemDto) + events!: ReadingEventUploadItemDto[]; +} diff --git a/src/modules/reading-event/reading-event.module.ts b/src/modules/reading-event/reading-event.module.ts new file mode 100644 index 0000000..c2bb972 --- /dev/null +++ b/src/modules/reading-event/reading-event.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../infrastructure/prisma.module'; +import { LearningSessionModule } from '../learning-session/learning-session.module'; +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 { 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], + providers: [ReadingEventService, ReadingEventProcessorService], + exports: [ReadingEventService, ReadingEventProcessorService], +}) +export class ReadingEventModule {} diff --git a/src/modules/reading-event/reading-event.service.ts b/src/modules/reading-event/reading-event.service.ts new file mode 100644 index 0000000..265dd61 --- /dev/null +++ b/src/modules/reading-event/reading-event.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/prisma.service'; + +@Injectable() +export class ReadingEventService { + constructor(private readonly prisma: PrismaService) {} + + async findBySession(clientSessionId: string) { + return this.prisma.readingEvent.findMany({ + where: { clientSessionId }, + orderBy: { clientTimestampMs: 'asc' }, + }); + } +} diff --git a/src/modules/reading/reading.controller.ts b/src/modules/reading/reading.controller.ts new file mode 100644 index 0000000..b1738b7 --- /dev/null +++ b/src/modules/reading/reading.controller.ts @@ -0,0 +1,212 @@ +import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { PrismaService } from '../../infrastructure/prisma.service'; +import { LearningActivityRepository } from '../learning-activity/learning-activity.repository'; +import { LearningRecordService } from '../learning-record/learning-record.service'; +import { MaterialReadingProgressService } from '../material-reading-progress/material-reading-progress.service'; +import { ReadingEventProcessorService } from '../reading-event/reading-event-processor.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class ReadingController { + constructor( + private readonly prisma: PrismaService, + private readonly progressSvc: MaterialReadingProgressService, + private readonly processor: ReadingEventProcessorService, + private readonly activityRepo: LearningActivityRepository, + private readonly recordSvc: LearningRecordService, + ) {} + + @Get('materials/:id/reading-progress') + async getProgress( + @Req() req: any, + @Param('id') materialId: string, + @Query('readingTargetType') targetType?: string, + ) { + const userId = req.user.id; + + if (targetType) { + const access = await this.processor.validateReadingAccess(userId, targetType, materialId); + if (!access.allowed) { + return { status: 'not_started', reason: access.errorCode }; + } + } + + const progress = await this.progressSvc.getProgress(userId, materialId); + if (!progress) { + return { + status: 'not_started', + lastPosition: null, + lastProgress: null, + totalActiveSeconds: 0, + isMarkedRead: false, + }; + } + + return { + status: progress.status, + lastPosition: progress.lastPosition, + lastProgress: progress.lastProgress, + totalActiveSeconds: progress.totalActiveSeconds, + isMarkedRead: progress.isMarkedRead, + firstOpenedAt: progress.firstOpenedAt, + lastReadAt: progress.lastReadAt, + }; + } + + @Get('learning/continue') + async continueLearning(@Req() req: any) { + const userId = req.user.id; + + const latest = await this.prisma.materialReadingProgress.findFirst({ + where: { + userId, + lastReadAt: { not: null }, + status: { in: ['reading', 'read'] }, + }, + orderBy: { lastReadAt: 'desc' }, + }); + + if (!latest) { + return { type: 'none' }; + } + + let title: string | null = null; + let isAccessible = true; + + if (latest.readingTargetType === 'knowledge_source') { + const src = await this.prisma.knowledgeSource.findUnique({ + where: { id: latest.materialId }, + select: { title: true, deletedAt: true }, + }); + if (src && !src.deletedAt) { + title = src.title; + } else { + isAccessible = false; + } + } else if (latest.readingTargetType === 'temporary_file') { + const mat = await this.prisma.temporaryReadingMaterial.findUnique({ + where: { id: latest.materialId }, + select: { title: true, deletedAt: true, expiresAt: true, sourceStatus: true }, + }); + if (mat && !mat.deletedAt && !mat.expiresAt && mat.sourceStatus !== 'expired') { + title = mat.title; + } else { + isAccessible = false; + } + } + + return { + type: isAccessible ? latest.readingTargetType : 'none', + materialId: latest.materialId, + title, + lastPosition: latest.lastPosition, + lastProgress: latest.lastProgress, + totalActiveSeconds: latest.totalActiveSeconds, + lastReadAt: latest.lastReadAt, + }; + } + + @Get('learning/summary') + async getSummary(@Req() req: any) { + const userId = req.user.id; + const activities = await this.activityRepo.findAll(userId); + + if (!activities.length) { + return { todaySeconds: 0, weekSeconds: 0, totalSeconds: 0, activeDays: 0, sessionsCount: 0, materialsReadCount: 0, markedReadCount: 0, dailyAverageSeconds: 0 }; + } + + const now = new Date(); + const todayStr = now.toISOString().split('T')[0]; + const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + + let todaySeconds = 0, weekSeconds = 0, totalSeconds = 0, activeDays = 0; + let sessionsCount = 0, materialsReadCount = 0, markedReadCount = 0; + + for (const a of activities) { + const dateStr = a.activityDate instanceof Date ? a.activityDate.toISOString().split('T')[0] : String(a.activityDate).split('T')[0]; + + totalSeconds += a.readingSeconds; + sessionsCount += a.sessionsCount; + materialsReadCount += a.materialsReadCount; + markedReadCount += a.markedReadCount; + + if (a.readingSeconds > 0) activeDays++; + + if (dateStr === todayStr) todaySeconds += a.readingSeconds; + + const actDate = a.activityDate instanceof Date ? a.activityDate : new Date(a.activityDate); + if (actDate >= weekAgo) weekSeconds += a.readingSeconds; + } + + return { + todaySeconds, + weekSeconds, + totalSeconds, + activeDays, + sessionsCount, + materialsReadCount, + markedReadCount, + dailyAverageSeconds: activeDays > 0 ? Math.round(totalSeconds / activeDays) : 0, + }; + } + + @Get('learning/trend') + async getTrend(@Req() req: any, @Query('days') daysStr?: string) { + const userId = req.user.id; + const days = Math.min(Number(daysStr ?? 7) || 7, 90); + + const activities = await this.activityRepo.findAll(userId); + const dataMap = new Map(); + for (const a of activities) { + const ds = a.activityDate instanceof Date ? a.activityDate.toISOString().split('T')[0] : String(a.activityDate).split('T')[0]; + dataMap.set(ds, (dataMap.get(ds) ?? 0) + a.readingSeconds); + } + + const series: Array<{ date: string; value: number }> = []; + const now = new Date(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); + const ds = d.toISOString().split('T')[0]; + series.push({ date: ds, value: dataMap.get(ds) ?? 0 }); + } + + return { days, series }; + } + + @Get('learning/records') + async getRecords( + @Req() req: any, + @Query('cursor') cursor?: string, + @Query('limit') limitStr?: string, + @Query('type') recordType?: string, + ) { + const userId = req.user.id; + const limit = Math.min(Number(limitStr ?? 20) || 20, 50); + + const records = await this.recordSvc.findByUser(userId, { + limit: limit + 1, + recordType, + cursor: cursor ?? undefined, + }); + + let nextCursor: string | null = null; + if (records.length > limit) { + nextCursor = records[limit]?.id ?? null; + records.length = limit; + } + + const items = records.map(r => ({ + id: r.id, + recordType: r.recordType, + title: r.title, + description: r.description, + durationSeconds: r.durationSeconds, + occurredAt: r.occurredAt, + metadata: r.metadata, + createdAt: r.createdAt, + })); + + return { items, nextCursor }; + } +} diff --git a/src/modules/reading/reading.module.ts b/src/modules/reading/reading.module.ts new file mode 100644 index 0000000..e35ec98 --- /dev/null +++ b/src/modules/reading/reading.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../infrastructure/prisma.module'; +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 { ReadingEventModule } from '../reading-event/reading-event.module'; +import { ReadingController } from './reading.controller'; + +@Module({ + imports: [PrismaModule, LearningActivityModule, LearningRecordModule, MaterialReadingProgressModule, ReadingEventModule], + controllers: [ReadingController], +}) +export class ReadingModule {} diff --git a/src/modules/temporary-reading-material/temporary-reading-material.module.ts b/src/modules/temporary-reading-material/temporary-reading-material.module.ts new file mode 100644 index 0000000..cea8019 --- /dev/null +++ b/src/modules/temporary-reading-material/temporary-reading-material.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../infrastructure/prisma.module'; +import { TemporaryReadingMaterialService } from './temporary-reading-material.service'; + +@Module({ + imports: [PrismaModule], + providers: [TemporaryReadingMaterialService], + exports: [TemporaryReadingMaterialService], +}) +export class TemporaryReadingMaterialModule {} diff --git a/src/modules/temporary-reading-material/temporary-reading-material.service.ts b/src/modules/temporary-reading-material/temporary-reading-material.service.ts new file mode 100644 index 0000000..f0d7ae9 --- /dev/null +++ b/src/modules/temporary-reading-material/temporary-reading-material.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/prisma.service'; + +@Injectable() +export class TemporaryReadingMaterialService { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { + userId: string; + title?: string; + originalFilename?: string; + mimeType?: string; + sizeBytes?: number; + storageKey?: string; + expiresAt?: Date; + }) { + return this.prisma.temporaryReadingMaterial.create({ + data: { + userId: data.userId, + title: data.title ?? null, + originalFilename: data.originalFilename ?? null, + mimeType: data.mimeType ?? null, + sizeBytes: BigInt(data.sizeBytes ?? 0), + storageKey: data.storageKey ?? null, + expiresAt: data.expiresAt ?? null, + }, + }); + } + + /** Validate a material is active and belongs to user. Returns null if invalid. */ + async validateAccess(userId: string, materialId: string) { + const mat = await this.prisma.temporaryReadingMaterial.findUnique({ + where: { id: materialId }, + }); + + if (!mat) return null; + if (mat.userId !== userId) return null; + if (mat.deletedAt) return null; + if (mat.expiresAt && mat.expiresAt < new Date()) { + // Mark source as expired + await this.prisma.temporaryReadingMaterial.update({ + where: { id: materialId }, + data: { sourceStatus: 'expired' }, + }); + return null; + } + if (mat.sourceStatus === 'expired') return null; + + return mat; + } +}