api-server/docs/reading-event-api-protocol.md
wangdl 38a8629e42
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 11s
feat: M8 学习信息收集系统完整实现
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>
2026-06-08 21:09:13 +08:00

6.8 KiB
Raw Permalink Blame History

阅读事件上传协议

1. 概述

本文档定义 iOS 客户端 → API 服务端的阅读事件上传协议。

核心原则Rust 事件和 API 上传事件不是同一个结构。 iOS 适配层负责将 Rust ReadingEventV2 转换为 API ReadingEventUploadItem,补充 readingTargetType 等业务字段。

2. 端点

POST /reading/events
Content-Type: application/json
Authorization: Bearer <jwt>

3. 请求体

interface ReadingEventUploadRequest {
  events: ReadingEventUploadItem[];
}

interface ReadingEventUploadItem {
  // ── 来自 Rust ReadingEventV2 ──
  eventId: string;              // UUID v4全局唯一幂等键
  clientSessionId: string;      // UUID v4Rust 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_caseMaterialOpened→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_sourcetemporary_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. 响应

成功

{
  "processed": 10,
  "duplicate": 1,
  "failed": 0,
  "warnings": []
}

部分失败

{
  "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. 示例

请求

{
  "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
    }
  ]
}

响应

{
  "processed": 3,
  "duplicate": 0,
  "failed": 0,
  "warnings": []
}