api-server/docs/reading-event-api-protocol.md

206 lines
6.8 KiB
Markdown
Raw Normal View History

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