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

13 KiB
Raw Blame History

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 仅包含 materialIdreadingTargetTypeuserIdplatform 等由 iOS 在 UploadItem 层补充。

原则 2iOS 负责 ReadingMaterialContext

ReadingMaterialContext 由 iOS 构造,包含 materialIdreadingTargetTypetitleknowledgeBaseId、Rust 层的 MaterialTypePreviewMode

原则 3iOS 上传时补充 iOS 专属字段

ReadingEventUploadItem 中补充 readingTargetTypeplatform: "ios"appVersiontimezoneOffsetMinutes

原则 4ack 不等 API 上传

Rust exportPendingEventsV2 → iOS 写入 ReadingEventUploadQueue 成功 → Rust ackEventsV2。 ack 在 API 上传之前完成。

原则 5ReadingPositionStore 只做兜底

位置恢复优先 API GET /materials/:id/reading-progressReadingPositionStoreUserDefaults仅在 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-001ReadingMaterialContext 实现
  • IOS-INFO-003ReadingRuntimeAdapter 协议定义
  • IOS-INFO-006ReadingRuntimeSessionManager 实现
  • IOS-INFO-011ReadingEventUploadQueue 实现
  • IOS-INFO-016API Client 实现

详细类型定义和代码示例参见 ios-learning-info-design.md