# 阅读事件上传协议

## 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": []
}
```
