ios-projects/AIStudyApp/docs/ios-learning-info-architecture.md
wangdl 4ba747af46 feat: iOS learning info P0 complete (IOS-INFO-000~017, 038~039)
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>
2026-06-15 21:53:36 +08:00

336 lines
13 KiB
Markdown
Raw Permalink 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.

# 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. 九条核心原则
### 原则 1Rust 只接 materialId
Rust document runtime 的 ReadingMaterialRef V2 仅包含 `materialId`
`readingTargetType``userId``platform` 等由 iOS 在 UploadItem 层补充。
### 原则 2iOS 负责 ReadingMaterialContext
`ReadingMaterialContext` 由 iOS 构造,包含 `materialId``readingTargetType``title``knowledgeBaseId`、Rust 层的 `MaterialType``PreviewMode`
### 原则 3iOS 上传时补充 iOS 专属字段
`ReadingEventUploadItem` 中补充 `readingTargetType``platform: "ios"``appVersion``timezoneOffsetMinutes`
### 原则 4ack 不等 API 上传
Rust `exportPendingEventsV2` → iOS 写入 `ReadingEventUploadQueue` 成功 → Rust `ackEventsV2`
ack 在 API 上传之前完成。
### 原则 5ReadingPositionStore 只做兜底
位置恢复优先 API `GET /materials/:id/reading-progress`
`ReadingPositionStore`UserDefaults仅在 API 不可用或返回 `not_started` 时使用。
### 原则 6API reading-progress 是主数据源
跨设备同步、首次阅读、重新阅读等场景以 API 的 reading-progress 为准。
### 原则 7首页继续学习替换硬编码
替换当前硬编码的"推荐继续阅读",替换为 `GET /learning/continue` 动态数据。
API 返回 `type=none` 时不显示。
### 原则 8iOS 不直接判断聚合结果
iOS 只负责采集、上传、展示 API 结果。
分析判断(薄弱点、下一步建议等)由 API / AI Runtime 产出iOS 只展示。
### 原则 9debug 面板仅开发模式
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`