# 阅读事件上传协议 ## 1. 概述 本文档定义 iOS 客户端 → API 服务端的阅读事件上传协议。 **核心原则:Rust 事件和 API 上传事件不是同一个结构。** iOS 适配层负责将 Rust `ReadingEventV2` 转换为 API `ReadingEventUploadItem`,补充 `readingTargetType` 等业务字段。 ## 2. 端点 ``` POST /reading/events Content-Type: application/json Authorization: Bearer ``` ## 3. 请求体 ```typescript interface ReadingEventUploadRequest { events: ReadingEventUploadItem[]; } interface ReadingEventUploadItem { // ── 来自 Rust ReadingEventV2 ── eventId: string; // UUID v4,全局唯一,幂等键 clientSessionId: string; // UUID v4,Rust ReadingSessionV2.clientSessionId materialId: string; // Rust ReadingMaterialRef.materialId eventType: ReadingEventType; // 事件类型 position?: ReadingPosition; // 阅读位置(camelCase JSON, clamped 0~1) activeSecondsDelta: number; // 增量活跃秒数(非累计!) clientTimestampMs: number; // 客户端时间戳(毫秒) sequence: number; // session 内递增序号(1-based) // ── iOS 适配层补充 ── readingTargetType: 'knowledge_source' | 'temporary_file'; platform: string; // 'ios' | 'android' appVersion?: string; // App 版本号 clientTimezoneOffsetMinutes?: number; // 客户端时区偏移(分钟) } type ReadingEventType = | 'material_opened' | 'material_closed' | 'position_changed' | 'heartbeat' | 'marked_as_read'; ``` ## 4. 字段映射:Rust → API | Rust ReadingEventV2 | API ReadingEventUploadItem | 说明 | |---------------------|---------------------------|------| | `event_id` | `eventId` | UUID v4,幂等键 | | `client_session_id` | `clientSessionId` | 会话标识 | | `material_id` | `materialId` | 资料 ID | | `event_type` | `eventType` | snake_case(MaterialOpened→material_opened) | | `position` | `position` | camelCase JSON,已 clamp | | `active_seconds_delta` | `activeSecondsDelta` | **增量值**,非累计 | | `timestamp_ms` | `clientTimestampMs` | 客户端时间戳 | | `sequence` | `sequence` | session 内递增 | | — | `readingTargetType` | **iOS 补充** | | — | `platform` | **iOS 补充**(= "ios") | | — | `appVersion` | **iOS 补充** | | — | `clientTimezoneOffsetMinutes` | **iOS 补充** | ## 5. eventType 取值 | Rust 枚举 | API 字符串 | 说明 | |-----------|-----------|------| | `MaterialOpened` | `material_opened` | 打开资料 | | `MaterialClosed` | `material_closed` | 关闭资料 | | `PositionChanged` | `position_changed` | 位置变化 | | `Heartbeat` | `heartbeat` | 心跳(含 delta) | | `MarkedAsRead` | `marked_as_read` | 标记已读 | ## 6. activeSecondsDelta 规则 | 规则 | 处理 | |------|------| | `= 0` | ✅ 合法(MaterialOpened / PositionChanged / MarkedAsRead 的 delta 为 0) | | `> 0 且 <= 300` | ✅ 正常 | | `> 300` | ⚠️ 截断为 300 + warning `DELTA_EXCEEDED` | | `< 0` | ❌ 拒绝:status=failed, errorCode=`INVALID_DELTA` | | 缺失 | ❌ 拒绝:status=failed, errorCode=`MISSING_DELTA` | > **为什么是增量而非累计?** Rust `ActiveTimeTracker` 每次 tick 输出增量 `active_seconds_delta`,不是累计值。API 侧做累加。 ## 7. 校验规则 | 校验项 | 规则 | 失败处理 | |--------|------|----------| | eventId 唯一性 | 全局唯一,重复视为幂等重放 | status=duplicate, 跳过聚合 | | clientSessionId | 必填 | status=failed, errorCode=`MISSING_CLIENT_SESSION` | | materialId | 必填 | status=failed, errorCode=`MISSING_MATERIAL_ID` | | readingTargetType | 必须为 `knowledge_source` 或 `temporary_file` | status=failed, errorCode=`INVALID_TARGET_TYPE` | | knowledge_source 存在性 | KnowledgeSource 存在且属于当前用户 | warning `MATERIAL_NOT_FOUND`,仍接受 | | temporary_file 存在性 | TemporaryReadingMaterial 存在且属于当前用户 | warning `MATERIAL_NOT_FOUND`,仍接受 | | clientTimestampMs | 不能在未来 5 分钟以上 | warning `FUTURE_TIMESTAMP`,仍接受 | | eventType | 必须为 5 种之一 | status=failed, errorCode=`INVALID_EVENT_TYPE` | ## 8. 响应 ### 成功 ```json { "processed": 10, "duplicate": 1, "failed": 0, "warnings": [] } ``` ### 部分失败 ```json { "processed": 8, "duplicate": 0, "failed": 2, "warnings": [ { "eventId": "xxx", "code": "INVALID_DELTA", "message": "activeSecondsDelta must be >= 0" }, { "eventId": "yyy", "code": "DELTA_EXCEEDED", "message": "activeSecondsDelta 350 truncated to 300" } ] } ``` ## 9. 错误码 | 码 | 类型 | 含义 | |----|------|------| | `DUPLICATE_EVENT` | info | 重复 eventId(幂等) | | `INVALID_DELTA` | error | activeSecondsDelta 负数 | | `DELTA_EXCEEDED` | warning | delta > 300,已截断 | | `MISSING_DELTA` | error | 缺少 activeSecondsDelta | | `INVALID_EVENT_TYPE` | error | 未知 eventType | | `INVALID_TARGET_TYPE` | error | 未知 readingTargetType | | `MISSING_CLIENT_SESSION` | error | 缺少 clientSessionId | | `MISSING_MATERIAL_ID` | error | 缺少 materialId | | `MATERIAL_NOT_FOUND` | warning | materialId 对应的资源不存在 | | `FUTURE_TIMESTAMP` | warning | 时间戳在未来 | ## 10. 示例 ### 请求 ```json { "events": [ { "eventId": "550e8400-e29b-41d4-a716-446655440001", "clientSessionId": "550e8400-e29b-41d4-a716-446655440000", "materialId": "cuid_mat_001", "readingTargetType": "knowledge_source", "eventType": "material_opened", "activeSecondsDelta": 0, "clientTimestampMs": 1717800000000, "sequence": 1, "platform": "ios", "appVersion": "1.2.3", "clientTimezoneOffsetMinutes": -480 }, { "eventId": "550e8400-e29b-41d4-a716-446655440002", "clientSessionId": "550e8400-e29b-41d4-a716-446655440000", "materialId": "cuid_mat_001", "readingTargetType": "knowledge_source", "eventType": "position_changed", "position": { "type": "Markdown", "blockId": "intro", "scrollProgress": 0.25 }, "activeSecondsDelta": 0, "clientTimestampMs": 1717800005000, "sequence": 2, "platform": "ios", "appVersion": "1.2.3", "clientTimezoneOffsetMinutes": -480 }, { "eventId": "550e8400-e29b-41d4-a716-446655440003", "clientSessionId": "550e8400-e29b-41d4-a716-446655440000", "materialId": "cuid_mat_001", "readingTargetType": "knowledge_source", "eventType": "heartbeat", "position": { "type": "Markdown", "blockId": "ch1", "scrollProgress": 0.5 }, "activeSecondsDelta": 15, "clientTimestampMs": 1717800020000, "sequence": 3, "platform": "ios", "appVersion": "1.2.3", "clientTimezoneOffsetMinutes": -480 } ] } ``` ### 响应 ```json { "processed": 3, "duplicate": 0, "failed": 0, "warnings": [] } ```