Files
nj/wiki/data-layer.md

129 lines
4.9 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.
---
title: 数据层
updated: 2026-06-07
status: active
tags: [isar, offline-first, sync, repository-pattern]
---
# 数据层 — Isar 本地存储 + SyncEngine 离线同步
> 从 [[index]] 导航。关联: [[handwriting-engine]] [[frontend]] [[erp-diary]]
## 1. 设计决策
### Q: 为什么离线优先?
小学生使用场景中网络不稳定(校园 WiFi 信号差、家庭网络波动)。所有功能必须离线可用,联网后自动同步。
### Q: 为什么选 Isar 不用 SQLite
Isar 提供 FTS 全文搜索、内置加密、零配置 Flutter 原生支持、类型安全的查询 API。对 Flutter 项目比 SQLite+drift 更轻量。
### Q: 为什么用版本号冲突检测?
离线编辑 → 联网同步场景下同一条数据可能在多端修改。version 字段做乐观锁Phase 1 使用"本地优先"策略本地版本覆盖远端Phase 2 提供 UI 手动解决。
### Q: 为什么 Repository 抽象接口?
`JournalRepository` 抽象接口 → `IsarJournalRepository`(本地)+ `RemoteJournalRepository`远程。BLoC 只依赖抽象,切换实现不影响业务逻辑。
## 2. 关键文件 + 数据流
### 核心文件
| 文件 | 行数 | 职责 |
|------|------|------|
| `data/local/isar_database.dart` | 72 | 单例管理3 Schema 注册 |
| `data/repositories/isar_journal_repository.dart` | 333 | CRUD + 乐观锁 + 软删除 |
| `data/repositories/remote_journal_repository.dart` | 114 | Dio HTTP API 代理 |
| `data/repositories/journal_repository.dart` | 184 | 抽象接口 + InMemory 测试实现 |
| `data/services/sync_engine.dart` | 338 | WiFi 增量同步 + Isar 队列持久化 |
| `data/local/collections/` | 3+3 文件 | Isar Collection 定义 + 生成 Schema |
### 数据流
```
UI 操作
EditorBloc.onSave (2s debounce)
├─→ IsarJournalRepository.createJournal() [首次]
├─→ IsarJournalRepository.updateJournal() [后续]
│ └─ 乐观锁: version 匹配 → +1 → 写入
│ └─ 不匹配 → throw StateError('版本冲突')
└─→ SyncEngine.enqueue() [待同步操作入队]
│ WiFi 可用时
trySync() → RemoteJournalRepository → API → PostgreSQL
│ 成功
persistPendingQueue() [更新 Isar 队列]
```
### 集成契约
| 方向 | 组件 | 接口 | 触发时机 |
|------|------|------|---------|
| 被调用 ← | EditorBloc | `onSave(EditorState)` | 笔画/元素变更 2s 后 |
| 调用 → | IsarJournalRepository | `createJournal/updateJournal` | 首次/后续保存 |
| 调用 → | SyncEngine | `enqueue/trySync` | 数据变更/网络恢复 |
| 启动时 → | SyncEngine | `restorePendingQueue()` | app.dart 创建时 |
## 3. 代码逻辑
### 不变量
**Isar 扩展方法需显式 import**`import 'package:isar/isar.dart'` 不会随传递 import 传播,必须在使用 `findAll()`/`findFirst()` 的文件中显式导入
**笔画序列化固定 ID**`${journalId}_strokes` 保证每次覆盖而非重复创建
**SyncEngine 双写** — 操作同时存在于内存 Queue 和 Isar PendingOperationCollection
**乐观锁** — updateJournal 比对 version不匹配抛 StateError
**软删除级联** — 删除日记时关联元素的 isDeleted 也设为 true
### 3 个 Isar Collection
| Collection | Isar 主键 | 业务 ID | 用途 |
|------------|----------|---------|------|
| JournalEntryCollection | autoIncrement | id (indexed) | 日记条目 |
| JournalElementCollection | autoIncrement | id (indexed) | 日记元素 |
| PendingOperationCollection | autoIncrement | id (indexed) | 同步操作队列 |
### 初始化链
```dart
// main.dart
WidgetsFlutterBinding.ensureInitialized();
await IsarDatabase.init(); // 注册 3 个 Schema
// app.dart
final syncEngine = SyncEngine(apiClient: apiClient);
syncEngine.restorePendingQueue(); // fire-and-forget 恢复队列
```
## 4. 活跃问题 + 陷阱
| 问题 | 级别 | 状态 | 说明 |
|------|------|------|------|
| authorId 硬编码 'local' | HIGH | 待修 | EditorPage 未接入 AuthBloc 获取真实用户 |
| SyncEngine 仅 WiFi | MEDIUM | Phase 2 | 蜂窝数据同步未实现 |
| 版本冲突静默覆盖 | MEDIUM | Phase 2 | "本地优先"策略,需 UI 手动解决 |
| ~~编辑器未加载已有数据~~ | ~~MEDIUM~~ | ✅ 已修复 | _loadExistingJournal 读取日记 + 元素 + 笔画 |
### 历史教训
- Isar `findAll()` 编译报"未定义" — 原因是扩展方法不随 import 传播,需显式 `import 'package:isar/isar.dart'`
- Isar 3.x 查询链必须通过 `.filter()` 过渡才能调用 `.findAll()`
## 5. 变更记录
| 日期 | 变更 |
|------|------|
| 2026-06-07 | 编辑器加载已有数据已修复、打开已有日记默认查看模式 |
| 2026-06-01 | Isar 集成完成3 Collection + Repository + SyncEngine 持久化 (2481c8f) |
| 2026-06-01 | 初始创建 — 数据层架构、Isar 踩坑记录 |