feat: M8 学习信息收集系统完整实现
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 11s
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 11s
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 <noreply@anthropic.com>
This commit is contained in:
parent
55e25f347e
commit
38a8629e42
263
docs/learning-info-api.md
Normal file
263
docs/learning-info-api.md
Normal file
@ -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 | 来源已删除 |
|
||||||
311
docs/learning-info-design.md
Normal file
311
docs/learning-info-design.md
Normal file
@ -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] 同步聚合策略
|
||||||
205
docs/reading-event-api-protocol.md
Normal file
205
docs/reading-event-api-protocol.md
Normal file
@ -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 <jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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": []
|
||||||
|
}
|
||||||
|
```
|
||||||
@ -389,6 +389,13 @@ model LearningSession {
|
|||||||
durationSeconds Int @default(0)
|
durationSeconds Int @default(0)
|
||||||
focusMinutes Int?
|
focusMinutes Int?
|
||||||
metadata Json?
|
metadata Json?
|
||||||
|
// ── M8 新增字段 ──
|
||||||
|
clientSessionId String?
|
||||||
|
materialId String?
|
||||||
|
readingTargetType String? @db.VarChar(32)
|
||||||
|
totalActiveSeconds Int @default(0)
|
||||||
|
lastPosition Json?
|
||||||
|
lastEventAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@ -397,6 +404,8 @@ model LearningSession {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([knowledgeItemId])
|
@@index([knowledgeItemId])
|
||||||
@@index([startedAt])
|
@@index([startedAt])
|
||||||
|
@@index([clientSessionId])
|
||||||
|
@@index([materialId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model LearningRecord {
|
model LearningRecord {
|
||||||
@ -418,6 +427,86 @@ model LearningRecord {
|
|||||||
@@index([createdAt])
|
@@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 {
|
model ActiveRecallQuestion {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
@ -605,6 +694,10 @@ model DailyLearningActivity {
|
|||||||
aiAnalysisCount Int @default(0)
|
aiAnalysisCount Int @default(0)
|
||||||
completedLoopCount Int @default(0)
|
completedLoopCount Int @default(0)
|
||||||
activityLevel Int @default(0)
|
activityLevel Int @default(0)
|
||||||
|
// ── M8 新增字段 ──
|
||||||
|
readingSeconds Int @default(0)
|
||||||
|
materialsReadCount Int @default(0)
|
||||||
|
markedReadCount Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,12 @@ export class LearningActivityController {
|
|||||||
|
|
||||||
@Get('heatmap')
|
@Get('heatmap')
|
||||||
@ApiOperation({ summary: '获取学习热力图数据' })
|
@ApiOperation({ summary: '获取学习热力图数据' })
|
||||||
async getHeatmap(@CurrentUser() user: UserPayload) {
|
async getHeatmap(
|
||||||
return this.activityService.getHeatmap(String(user?.id || 'anonymous'));
|
@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')
|
@Get('summary')
|
||||||
|
|||||||
@ -9,6 +9,6 @@ import { GrowthService } from './growth.service';
|
|||||||
imports: [AiModule],
|
imports: [AiModule],
|
||||||
controllers: [LearningActivityController],
|
controllers: [LearningActivityController],
|
||||||
providers: [LearningActivityService, LearningActivityRepository, GrowthService],
|
providers: [LearningActivityService, LearningActivityRepository, GrowthService],
|
||||||
exports: [LearningActivityService, GrowthService],
|
exports: [LearningActivityService, LearningActivityRepository, GrowthService],
|
||||||
})
|
})
|
||||||
export class LearningActivityModule {}
|
export class LearningActivityModule {}
|
||||||
|
|||||||
@ -11,4 +11,50 @@ export class LearningActivityRepository {
|
|||||||
orderBy: { activityDate: 'asc' },
|
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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,8 +9,10 @@ export class LearningActivityService {
|
|||||||
private readonly trendWorkflow: LearningTrendWorkflow,
|
private readonly trendWorkflow: LearningTrendWorkflow,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getHeatmap(userId: string) {
|
async getHeatmap(userId: string, days: number = 365) {
|
||||||
const activities = await this.repository.findAll(userId);
|
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<string, number> = {};
|
const heatmap: Record<string, number> = {};
|
||||||
for (const a of activities) {
|
for (const a of activities) {
|
||||||
const dateStr = a.activityDate instanceof Date
|
const dateStr = a.activityDate instanceof Date
|
||||||
@ -126,4 +128,15 @@ export class LearningActivityService {
|
|||||||
}
|
}
|
||||||
return series;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/modules/learning-record/learning-record.module.ts
Normal file
10
src/modules/learning-record/learning-record.module.ts
Normal file
@ -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 {}
|
||||||
149
src/modules/learning-record/learning-record.service.ts
Normal file
149
src/modules/learning-record/learning-record.service.ts
Normal file
@ -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<string>(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<string, any>;
|
||||||
|
}) {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,6 @@ import { LearningSessionRepository } from './learning-session.repository';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [LearningSessionController, AdminLearningController],
|
controllers: [LearningSessionController, AdminLearningController],
|
||||||
providers: [LearningSessionService, LearningSessionRepository],
|
providers: [LearningSessionService, LearningSessionRepository],
|
||||||
exports: [LearningSessionService],
|
exports: [LearningSessionService, LearningSessionRepository],
|
||||||
})
|
})
|
||||||
export class LearningSessionModule {}
|
export class LearningSessionModule {}
|
||||||
|
|||||||
@ -47,7 +47,6 @@ export class LearningSessionRepository {
|
|||||||
const where: any = { userId };
|
const where: any = { userId };
|
||||||
if (opts?.status) where.status = opts.status;
|
if (opts?.status) where.status = opts.status;
|
||||||
|
|
||||||
// sort: startedAt:desc (default) | startedAt:asc | durationSeconds:desc
|
|
||||||
let orderBy: any = { startedAt: 'desc' };
|
let orderBy: any = { startedAt: 'desc' };
|
||||||
if (opts?.sort) {
|
if (opts?.sort) {
|
||||||
const [field, dir] = opts.sort.split(':');
|
const [field, dir] = opts.sort.split(':');
|
||||||
@ -63,4 +62,55 @@ export class LearningSessionRepository {
|
|||||||
take: limit,
|
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)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,4 +18,20 @@ export class LearningSessionService {
|
|||||||
async findByUserId(userId: string, opts: { page?: number; limit?: number; status?: string; sort?: string }) {
|
async findByUserId(userId: string, opts: { page?: number; limit?: number; status?: string; sort?: string }) {
|
||||||
return this.repository.findByUserId(userId, opts);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
@ -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 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/modules/reading-event/reading-event-codes.ts
Normal file
43
src/modules/reading-event/reading-event-codes.ts
Normal file
@ -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',
|
||||||
|
}
|
||||||
340
src/modules/reading-event/reading-event-processor.service.ts
Normal file
340
src/modules/reading-event/reading-event-processor.service.ts
Normal file
@ -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<Record<string, any>>): Promise<ProcessResult> {
|
||||||
|
// 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<number> {
|
||||||
|
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<string, any>,
|
||||||
|
): 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<string, any>): 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<boolean> {
|
||||||
|
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, any>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/modules/reading-event/reading-event.controller.ts
Normal file
107
src/modules/reading-event/reading-event.controller.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/modules/reading-event/reading-event.dto.ts
Normal file
51
src/modules/reading-event/reading-event.dto.ts
Normal file
@ -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[];
|
||||||
|
}
|
||||||
17
src/modules/reading-event/reading-event.module.ts
Normal file
17
src/modules/reading-event/reading-event.module.ts
Normal file
@ -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 {}
|
||||||
14
src/modules/reading-event/reading-event.service.ts
Normal file
14
src/modules/reading-event/reading-event.service.ts
Normal file
@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/modules/reading/reading.controller.ts
Normal file
212
src/modules/reading/reading.controller.ts
Normal file
@ -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<string, number>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/reading/reading.module.ts
Normal file
13
src/modules/reading/reading.module.ts
Normal file
@ -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 {}
|
||||||
@ -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 {}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user