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>
6.8 KiB
6.8 KiB
阅读事件上传协议
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 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. 响应
成功
{
"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": []
}