206 lines
6.8 KiB
Markdown
206 lines
6.8 KiB
Markdown
|
|
# 阅读事件上传协议
|
|||
|
|
|
|||
|
|
## 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": []
|
|||
|
|
}
|
|||
|
|
```
|