Models: ReadingMaterialContext, ReadingEventUploadItem, AppContext Runtime: ReadingRuntimeAdapter, SessionManager, RecoveryService Reader: MaterialReaderView lifecycle, Heartbeat Timer, Position Adapter Export: EventMapper, UploadQueue, export→queue→ack flow Tests: 16 test files covering models, runtime, lifecycle, upload Docs: ios-learning-info-architecture.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
336 lines
13 KiB
Markdown
336 lines
13 KiB
Markdown
# iOS 学习信息采集架构设计
|
||
|
||
> IOS-INFO-000 | v1.0 | 2026-06-12
|
||
>
|
||
> 本文档定义 iOS 学习信息采集系统的架构决策、模块职责边界与核心链路。
|
||
> 具体类型定义、API 签名、代码示例见 `ios-learning-info-design.md`。
|
||
|
||
## 1. 三方职责边界
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────┐
|
||
│ iOS App │
|
||
│ │
|
||
│ ① 生命周期触发 ② ReadingMaterialContext 管理 │
|
||
│ ③ readingTargetType 补充 ④ 本地上传队列 │
|
||
│ ⑤ ack/reload ⑥ API reading-progress 查询 │
|
||
│ ⑦ 继续学习 UI ⑧ 分析页数据展示 │
|
||
│ │ │
|
||
│ ReadingRuntimeAdapter (FFI) │
|
||
│ ▼ │
|
||
│ ┌────────────────────────────────────────┐ │
|
||
│ │ Rust Document Runtime (zhixi-document-runtime) │ │
|
||
│ │ │ │
|
||
│ │ ① 事件生成 (UUID/sequence/delta) │ │
|
||
│ │ ② position normalize │ │
|
||
│ │ ③ session 管理 + buffer 状态机 │ │
|
||
│ │ ④ 只存 materialId,不存 readingTargetType/userId │ │
|
||
│ └────────────────────────────────────────┘ │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
│
|
||
POST /learning/reading-events/batch
|
||
GET /materials/:id/reading-progress
|
||
GET /learning/continue
|
||
GET /learning/summary | /learning/trend | /activity/heatmap
|
||
│
|
||
▼
|
||
┌───────────────────────────────┐
|
||
│ API Server │
|
||
│ ① 事件落库 + 二次校验 │
|
||
│ ② reading-progress 权威数据源 │
|
||
│ ③ 继续学习 / 分析 / 历史 │
|
||
│ ④ 幂等处理 (duplicate event) │
|
||
└───────────────────────────────┘
|
||
```
|
||
|
||
| 层 | 负责 | 不负责 |
|
||
|----|------|--------|
|
||
| **Rust** | 事件生成、session、buffer 状态机、position normalize | readingTargetType/userId、上传 API、UI |
|
||
| **iOS** | 生命周期触发、ReadingMaterialContext、上传队列、ack/reload、UI 联动 | 事件生成、聚合分析、直接写业务主表 |
|
||
| **API** | 事件落库、reading-progress 权威数据、继续学习、分析聚合 | 事件生成、本地 buffer 管理 |
|
||
|
||
## 2. 核心链路:export → queue → ack
|
||
|
||
```
|
||
MaterialReader Lifecycle
|
||
│
|
||
▼
|
||
ReadingRuntimeSessionManager
|
||
│
|
||
▼
|
||
ReadingRuntimeAdapter (FFI → Rust)
|
||
│
|
||
▼
|
||
Rust document runtime
|
||
① push*.V2 → buffer
|
||
② exportPendingEventsV2 → [ReadingEventV2]
|
||
│
|
||
▼
|
||
iOS export + supplement
|
||
③ 映射 ReadingEventV2 → ReadingEventUploadItem
|
||
④ 补充 readingTargetType, platform, appVersion, timezone
|
||
│
|
||
▼
|
||
ReadingEventUploadQueue
|
||
⑤ enqueue (写入本地持久化)
|
||
│
|
||
▼
|
||
ackEventsV2 (告诉 Rust 删除已导出的事件)
|
||
⚠️ ack 在写入本地队列成功后立即执行,不等 API 上传
|
||
│
|
||
▼
|
||
UploadScheduler (定时或前台/网络恢复触发)
|
||
⑥ POST /learning/reading-events/batch
|
||
│
|
||
┌────┴────┐
|
||
▼ ▼
|
||
HTTP 200 HTTP !200 / 网络错误
|
||
│ │
|
||
▼ ▼
|
||
从队列移除 markFailed → keep → 下次 retry
|
||
```
|
||
|
||
**关键时序约束:**
|
||
|
||
1. Rust export → iOS 写入本地队列 → Rust ack
|
||
2. ack 不等待 API 上传(ack 在 export 后立即执行)
|
||
3. API 上传失败不影响下一轮 export
|
||
|
||
## 3. 九条核心原则
|
||
|
||
### 原则 1:Rust 只接 materialId
|
||
Rust document runtime 的 ReadingMaterialRef V2 仅包含 `materialId`。
|
||
`readingTargetType`、`userId`、`platform` 等由 iOS 在 UploadItem 层补充。
|
||
|
||
### 原则 2:iOS 负责 ReadingMaterialContext
|
||
`ReadingMaterialContext` 由 iOS 构造,包含 `materialId`、`readingTargetType`、`title`、`knowledgeBaseId`、Rust 层的 `MaterialType` 和 `PreviewMode`。
|
||
|
||
### 原则 3:iOS 上传时补充 iOS 专属字段
|
||
`ReadingEventUploadItem` 中补充 `readingTargetType`、`platform: "ios"`、`appVersion`、`timezoneOffsetMinutes`。
|
||
|
||
### 原则 4:ack 不等 API 上传
|
||
Rust `exportPendingEventsV2` → iOS 写入 `ReadingEventUploadQueue` 成功 → Rust `ackEventsV2`。
|
||
ack 在 API 上传之前完成。
|
||
|
||
### 原则 5:ReadingPositionStore 只做兜底
|
||
位置恢复优先 API `GET /materials/:id/reading-progress`。
|
||
`ReadingPositionStore`(UserDefaults)仅在 API 不可用或返回 `not_started` 时使用。
|
||
|
||
### 原则 6:API reading-progress 是主数据源
|
||
跨设备同步、首次阅读、重新阅读等场景以 API 的 reading-progress 为准。
|
||
|
||
### 原则 7:首页继续学习替换硬编码
|
||
替换当前硬编码的"推荐继续阅读",替换为 `GET /learning/continue` 动态数据。
|
||
API 返回 `type=none` 时不显示。
|
||
|
||
### 原则 8:iOS 不直接判断聚合结果
|
||
iOS 只负责采集、上传、展示 API 结果。
|
||
分析判断(薄弱点、下一步建议等)由 API / AI Runtime 产出,iOS 只展示。
|
||
|
||
### 原则 9:debug 面板仅开发模式
|
||
Debug 面板(日志、buffer 状态、上传队列状态)仅在 `#if DEBUG` 下编译。
|
||
|
||
## 4. 模块结构
|
||
|
||
```
|
||
Core/
|
||
Reading/
|
||
ReadingMaterialContext.swift // 上下文类型
|
||
ReadingRuntimeAdapter.swift // FFI 适配器协议
|
||
ReadingRuntimeSessionManager.swift // 生命周期 + 心跳管理
|
||
ReadingPositionStore.swift // 本地位置缓存(兜底)
|
||
MaterialReaderView.swift // 阅读页生命周期
|
||
HeartbeatTimer.swift // 心跳定时器
|
||
|
||
Upload/
|
||
ReadingEventUploadItem.swift // 上传条目 DTO
|
||
ReadingEventUploadQueue.swift // 本地上传队列 + 持久化
|
||
UploadScheduler.swift // 定时 + 前后台触发 flush
|
||
|
||
Networking/
|
||
LearningInfoAPIClient.swift // API 客户端
|
||
|
||
Recovery/
|
||
ReadingPositionRestoreService.swift // 位置恢复(API 优先 + 本地兜底)
|
||
|
||
Home/
|
||
ContinueLearningService.swift // 继续学习查询
|
||
|
||
Analysis/
|
||
AnalysisSummaryService.swift // 分析页数据接入
|
||
LearningHistoryService.swift // 学习历史
|
||
|
||
Debug/
|
||
ReadingEventDebugPanel.swift // Debug 面板(#if DEBUG)
|
||
|
||
Tests/
|
||
ReadingInfoUploadTests/ // 单元测试 + 集成测试
|
||
```
|
||
|
||
## 5. ReadingMaterialContext
|
||
|
||
```
|
||
ReadingMaterialContext
|
||
├── materialId: String
|
||
├── readingTargetType: ReadingTargetType (.knowledgeSource | .temporaryFile)
|
||
├── title: String?
|
||
├── knowledgeBaseId: String?
|
||
├── materialType: MaterialType // Rust 侧类型
|
||
└── previewMode: PreviewMode // Rust 侧类型
|
||
```
|
||
|
||
iOS 在 `openMaterial(ctx)` 时构造并向 Rust 传递 `materialId`,其余字段在 upload 时补充到 `ReadingEventUploadItem`。
|
||
|
||
## 6. ReadingRuntimeAdapter 协议
|
||
|
||
```
|
||
protocol ReadingRuntimeAdapter {
|
||
// Session
|
||
func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String
|
||
func closeSession(_ sessionId: String) throws
|
||
func pauseSession(_ sessionId: String) throws
|
||
func resumeSession(_ sessionId: String) throws
|
||
|
||
// Events
|
||
func pushOpened(...) throws -> ReadingEventV2
|
||
func pushClosed(...) throws -> ReadingEventV2
|
||
func pushPositionChanged(...) throws -> ReadingEventV2
|
||
func pushHeartbeat(...) throws -> ReadingEventV2
|
||
func pushMarkedAsRead(...) throws -> ReadingEventV2
|
||
|
||
// Buffer
|
||
func exportEvents(limit: UInt32, timestampMs: Int64) -> [ReadingEventV2]
|
||
func ackEvents(_ eventIds: [String]) -> UInt32
|
||
func markFailed(_ eventIds: [String]) -> UInt32
|
||
func reloadStale() -> UInt32
|
||
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32
|
||
}
|
||
```
|
||
|
||
## 7. SessionManager 生命周期
|
||
|
||
```
|
||
MaterialReader.onAppear
|
||
→ openMaterial(ctx)
|
||
→ startSession(material)
|
||
→ pushOpened(sessionId, materialId, ts)
|
||
→ startHeartbeat(interval: 15s)
|
||
|
||
Heartbeat Timer (every 15s)
|
||
→ pushHeartbeat(sessionId, materialId, delta, position, ts)
|
||
|
||
Scroll/Page Change (debounced 500ms)
|
||
→ updatePosition(pos)
|
||
→ pushPositionChanged(sessionId, materialId, position, ts)
|
||
|
||
Tap "Mark as Read"
|
||
→ markAsRead()
|
||
→ pushMarkedAsRead(sessionId, materialId, ts)
|
||
|
||
MaterialReader.onDisappear
|
||
→ stopHeartbeat()
|
||
→ pushClosed(sessionId, materialId, delta, ts)
|
||
→ closeSession(sessionId)
|
||
|
||
App → Background
|
||
→ exportEvents → enqueue → ackEvents
|
||
→ stopHeartbeat()
|
||
→ UploadScheduler.flush()
|
||
|
||
App → Foreground
|
||
→ reloadStaleEventsV2
|
||
→ cleanupStaleSessions
|
||
→ UploadScheduler.flush()
|
||
→ startHeartbeat() if reader is active
|
||
```
|
||
|
||
## 8. 上传队列
|
||
|
||
### ReadingEventUploadQueue
|
||
|
||
```
|
||
final class ReadingEventUploadQueue {
|
||
private var items: [ReadingEventUploadItem] // 内存
|
||
private let persistenceURL: URL // 磁盘持久化
|
||
|
||
func enqueue(_ items: [ReadingEventUploadItem]) // 写入内存 + 磁盘
|
||
func flush() async -> FlushResult // POST batch → 成功则移除
|
||
func retryFailed() async -> FlushResult // 网络恢复重试
|
||
func reloadOnLaunch() // 启动时加载磁盘缓存 + reloadStale
|
||
}
|
||
```
|
||
|
||
### UploadScheduler 触发时机
|
||
|
||
| 时机 | 动作 |
|
||
|------|------|
|
||
| App → Background | `flush()` |
|
||
| App → Foreground | `flush()` |
|
||
| Network recovery (NWPathMonitor) | `retryFailed()` |
|
||
| Timer (every 30s) | `flush()` |
|
||
| App launch | `reloadOnLaunch()` |
|
||
|
||
## 9. 阅读位置恢复:API 优先 + 本地兜底
|
||
|
||
```
|
||
ReadingPositionRestoreService.restore(materialId, readingTargetType)
|
||
│
|
||
├── ① GET /materials/:id/reading-progress
|
||
│ ├── 200 + lastPosition != nil → 使用 API 位置
|
||
│ └── 其他 → ②
|
||
│
|
||
└── ② ReadingPositionStore.shared.get(materialId)
|
||
└── 本地缓存兜底
|
||
```
|
||
|
||
## 10. API 端点
|
||
|
||
| 端点 | iOS 用途 |
|
||
|------|---------|
|
||
| `POST /learning/reading-events/batch` | 批量上传事件 |
|
||
| `GET /materials/:id/reading-progress` | 位置恢复(主数据源) |
|
||
| `GET /learning/continue` | 首页继续学习 |
|
||
| `GET /learning/summary` | 分析页摘要 |
|
||
| `GET /learning/trend?days=7` | 趋势曲线 |
|
||
| `GET /activity/heatmap?days=365` | 热力图 |
|
||
| `GET /learning/history` | 学习历史 |
|
||
|
||
## 11. 错误处理
|
||
|
||
| 错误类型 | 动作 |
|
||
|----------|------|
|
||
| `MATERIAL_ACCESS_DENIED` | 从队列移除,不重试,toast |
|
||
| `TEMPORARY_MATERIAL_EXPIRED` | 从队列移除 |
|
||
| `DUPLICATE_EVENT` | 直接 ack 移除 |
|
||
| `SOURCE_DELETED` | toast,从队列移除 |
|
||
| `INVALID_*` | 从队列移除,记录日志 |
|
||
| 网络错误 | 保留队列,等 NWPathMonitor 通知后 retry |
|
||
| HTTP 5xx | 指数退避重试 (1s/2s/4s/8s, max 3 retry) |
|
||
|
||
## 12. 离线与登录状态
|
||
|
||
- 离线:事件正常写入 Rust buffer + 本地队列,export 正常执行,upload 跳过
|
||
- 网络恢复:`reloadStaleEventsV2()` + `flush()`
|
||
- 登录态变化(登出/切换账号):丢弃本地队列(不属于当前用户),`cleanupStaleSessions`
|
||
- 删除资料:API 返回 `SOURCE_DELETED` → 从队列移除关联事件
|
||
|
||
## 13. 测试范围
|
||
|
||
| 层级 | 内容 |
|
||
|------|------|
|
||
| 单元测试 | ReadingMaterialContext 构造、UploadItem 映射、PositionRestore 优先级 |
|
||
| 集成测试 | Adapter FFI mock → SessionManager → UploadQueue → API mock |
|
||
| UI 测试 | MaterialReader 生命周期触发、Debug 面板 |
|
||
|
||
## 14. 实现指导
|
||
|
||
本文档中的架构决策和模块边界直接指导以下 Issue 的实现:
|
||
|
||
- **IOS-INFO-001**:ReadingMaterialContext 实现
|
||
- **IOS-INFO-003**:ReadingRuntimeAdapter 协议定义
|
||
- **IOS-INFO-006**:ReadingRuntimeSessionManager 实现
|
||
- **IOS-INFO-011**:ReadingEventUploadQueue 实现
|
||
- **IOS-INFO-016**:API Client 实现
|
||
|
||
详细类型定义和代码示例参见 `ios-learning-info-design.md`。
|