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

206 lines
6.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 阅读事件上传协议
## 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 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_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": []
}
```