# 学习信息收集 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 | 来源已删除 |
