架构治理: - Feature Flag 落地: Cargo.toml [features] default=["diary"] + main.rs cfg 条件编译 - 环境配置统一: AppConfig 类 + --dart-define 注入 + SSE 端口 8080→3000 修复 搜索替代方案 (无 FTS): - SearchBloc + 标签/心情筛选接入后端 API - JournalRepository 扩展 mood/tag 筛选参数 - 搜索页 UI 接入实际数据(替换占位文本) 家长中心最小集 (PIPL 合规): - 后端: parent_service (绑定/查看/导出/删除/解绑) + parent_handler (6 个 API 端点) - 前端: ParentBloc + ParentPage 功能完整实现 - 绑定孩子、只读查看日记、导出数据、删除数据、解绑 Docker 部署: - verify.sh 健康检查脚本 (Axum/PG/Redis/OpenAPI 四项检查) 测试修复: - home_bloc_test / calendar_bloc_test 适配 JournalRepository 新参数 验证: flutter test 84/84 pass, cargo test 76/76 pass, cargo check pass
346 lines
10 KiB
Dart
346 lines
10 KiB
Dart
// Isar 本地日记仓库 — 本地优先数据存储
|
||
//
|
||
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
|
||
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。
|
||
//
|
||
// 转换层:
|
||
// - JournalEntry ↔ JournalEntryCollection(通过 toCollection/fromCollection)
|
||
// - JournalElement ↔ JournalElementCollection(通过 toCollection/fromCollection)
|
||
|
||
import 'dart:convert';
|
||
|
||
import 'package:isar/isar.dart';
|
||
|
||
import '../local/isar_database.dart';
|
||
import '../local/collections/journal_entry_collection.dart';
|
||
import '../local/collections/journal_element_collection.dart';
|
||
import '../models/journal_entry.dart';
|
||
import '../models/journal_element.dart';
|
||
import 'journal_repository.dart';
|
||
|
||
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
|
||
class IsarJournalRepository implements JournalRepository {
|
||
Isar get _isar => IsarDatabase.instance!;
|
||
|
||
// ============================================================
|
||
// 日记 CRUD
|
||
// ============================================================
|
||
|
||
@override
|
||
Future<List<JournalEntry>> getJournals({
|
||
DateTime? dateFrom,
|
||
DateTime? dateTo,
|
||
int? page,
|
||
int? pageSize,
|
||
String? mood,
|
||
String? tag,
|
||
}) async {
|
||
var query = _isar.journalEntryCollections
|
||
.where()
|
||
.filter()
|
||
.isDeletedEqualTo(false);
|
||
|
||
// 日期范围过滤
|
||
if (dateFrom != null) {
|
||
query = query.and().dateEpochGreaterThan(dateFrom.millisecondsSinceEpoch);
|
||
}
|
||
if (dateTo != null) {
|
||
query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch);
|
||
}
|
||
|
||
// 心情过滤
|
||
if (mood != null) {
|
||
query = query.and().moodEqualTo(mood);
|
||
}
|
||
|
||
// 标签过滤:Isar tagsJson 字段存储 JSON 数组,用 contains 匹配
|
||
if (tag != null) {
|
||
query = query.and().tagsJsonContains(tag);
|
||
}
|
||
|
||
// 按日期降序排列
|
||
var results = await query
|
||
.sortByDateEpochDesc()
|
||
.findAll();
|
||
|
||
// 分页
|
||
if (page != null && pageSize != null) {
|
||
final start = (page - 1) * pageSize;
|
||
if (start >= results.length) return [];
|
||
final end = (start + pageSize).clamp(0, results.length);
|
||
results = results.sublist(start, end);
|
||
}
|
||
|
||
return results.map(_fromCollection).toList();
|
||
}
|
||
|
||
@override
|
||
Future<JournalEntry?> getJournal(String id) async {
|
||
final col = await _isar.journalEntryCollections
|
||
.where()
|
||
.filter()
|
||
.idEqualTo(id)
|
||
.and()
|
||
.isDeletedEqualTo(false)
|
||
.findFirst();
|
||
if (col == null) return null;
|
||
return _fromCollection(col);
|
||
}
|
||
|
||
@override
|
||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||
final col = _toEntryCollection(entry);
|
||
await _isar.writeTxn(() async {
|
||
await _isar.journalEntryCollections.put(col);
|
||
});
|
||
return entry;
|
||
}
|
||
|
||
@override
|
||
Future<JournalEntry> updateJournal(JournalEntry entry) async {
|
||
final existing = await _isar.journalEntryCollections
|
||
.where()
|
||
.filter()
|
||
.idEqualTo(entry.id)
|
||
.and()
|
||
.isDeletedEqualTo(false)
|
||
.findFirst();
|
||
|
||
if (existing == null) {
|
||
throw StateError('日记不存在: ${entry.id}');
|
||
}
|
||
|
||
// 乐观锁冲突检测
|
||
if (existing.version != entry.version) {
|
||
throw StateError(
|
||
'版本冲突: 本地版本 ${entry.version}, 存储版本 ${existing.version}',
|
||
);
|
||
}
|
||
|
||
final updated = entry.copyWith(
|
||
version: entry.version + 1,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
|
||
final col = _toEntryCollection(updated);
|
||
col.isarId = existing.isarId; // 保留 Isar 主键
|
||
|
||
await _isar.writeTxn(() async {
|
||
await _isar.journalEntryCollections.put(col);
|
||
});
|
||
|
||
return updated;
|
||
}
|
||
|
||
@override
|
||
Future<void> deleteJournal(String id) async {
|
||
final existing = await _isar.journalEntryCollections
|
||
.where()
|
||
.filter()
|
||
.idEqualTo(id)
|
||
.findFirst();
|
||
if (existing == null) return;
|
||
|
||
// 软删除日记
|
||
existing.isDeleted = true;
|
||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||
|
||
// 软删除关联元素
|
||
final elements = await _isar.journalElementCollections
|
||
.where()
|
||
.filter()
|
||
.journalIdEqualTo(id)
|
||
.and()
|
||
.isDeletedEqualTo(false)
|
||
.findAll();
|
||
|
||
await _isar.writeTxn(() async {
|
||
await _isar.journalEntryCollections.put(existing);
|
||
for (final el in elements) {
|
||
el.isDeleted = true;
|
||
el.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||
await _isar.journalElementCollections.put(el);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 元素 CRUD
|
||
// ============================================================
|
||
|
||
@override
|
||
Future<List<JournalElement>> getElements(String journalId) async {
|
||
final results = await _isar.journalElementCollections
|
||
.where()
|
||
.filter()
|
||
.journalIdEqualTo(journalId)
|
||
.and()
|
||
.isDeletedEqualTo(false)
|
||
.sortByZIndex()
|
||
.findAll();
|
||
return results.map(_fromElementCollection).toList();
|
||
}
|
||
|
||
@override
|
||
Future<JournalElement> addElement(JournalElement element) async {
|
||
final col = _toElementCollection(element);
|
||
await _isar.writeTxn(() async {
|
||
await _isar.journalElementCollections.put(col);
|
||
});
|
||
return element;
|
||
}
|
||
|
||
@override
|
||
Future<JournalElement> updateElement(JournalElement element) async {
|
||
final existing = await _isar.journalElementCollections
|
||
.where()
|
||
.filter()
|
||
.idEqualTo(element.id)
|
||
.and()
|
||
.isDeletedEqualTo(false)
|
||
.findFirst();
|
||
|
||
if (existing == null) {
|
||
throw StateError('元素不存在: ${element.id}');
|
||
}
|
||
|
||
// 乐观锁冲突检测
|
||
if (existing.version != element.version) {
|
||
throw StateError(
|
||
'版本冲突: 本地版本 ${element.version}, 存储版本 ${existing.version}',
|
||
);
|
||
}
|
||
|
||
final updated = element.copyWith(
|
||
version: element.version + 1,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
|
||
final col = _toElementCollection(updated);
|
||
col.isarId = existing.isarId;
|
||
|
||
await _isar.writeTxn(() async {
|
||
await _isar.journalElementCollections.put(col);
|
||
});
|
||
|
||
return updated;
|
||
}
|
||
|
||
@override
|
||
Future<void> removeElement(String elementId) async {
|
||
final existing = await _isar.journalElementCollections
|
||
.where()
|
||
.filter()
|
||
.idEqualTo(elementId)
|
||
.findFirst();
|
||
if (existing == null) return;
|
||
|
||
// 软删除
|
||
existing.isDeleted = true;
|
||
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
|
||
|
||
await _isar.writeTxn(() async {
|
||
await _isar.journalElementCollections.put(existing);
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 转换函数:JournalEntry ↔ JournalEntryCollection
|
||
// ============================================================
|
||
|
||
/// JournalEntry → JournalEntryCollection
|
||
JournalEntryCollection _toEntryCollection(JournalEntry entry) {
|
||
return JournalEntryCollection()
|
||
..id = entry.id
|
||
..authorId = entry.authorId
|
||
..classId = entry.classId
|
||
..title = entry.title
|
||
..dateEpoch = entry.date.millisecondsSinceEpoch
|
||
..mood = entry.mood.value
|
||
..weather = entry.weather.value
|
||
..tagsJson = jsonEncode(entry.tags)
|
||
..isPrivate = entry.isPrivate
|
||
..sharedToClass = entry.sharedToClass
|
||
..assignedTopicId = entry.assignedTopicId
|
||
..version = entry.version
|
||
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
|
||
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
|
||
..isDeleted = false;
|
||
}
|
||
|
||
/// JournalEntryCollection → JournalEntry
|
||
JournalEntry _fromCollection(JournalEntryCollection col) {
|
||
return JournalEntry(
|
||
id: col.id,
|
||
authorId: col.authorId,
|
||
classId: col.classId,
|
||
title: col.title,
|
||
date: DateTime.fromMillisecondsSinceEpoch(col.dateEpoch),
|
||
mood: Mood.values.firstWhere(
|
||
(m) => m.value == col.mood,
|
||
orElse: () => Mood.calm,
|
||
),
|
||
weather: Weather.values.firstWhere(
|
||
(w) => w.value == col.weather,
|
||
orElse: () => Weather.sunny,
|
||
),
|
||
tags: List<String>.from(
|
||
jsonDecode(col.tagsJson) as List? ?? [],
|
||
),
|
||
isPrivate: col.isPrivate,
|
||
sharedToClass: col.sharedToClass,
|
||
assignedTopicId: col.assignedTopicId,
|
||
version: col.version,
|
||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 转换函数:JournalElement ↔ JournalElementCollection
|
||
// ============================================================
|
||
|
||
/// JournalElement → JournalElementCollection
|
||
JournalElementCollection _toElementCollection(JournalElement element) {
|
||
return JournalElementCollection()
|
||
..id = element.id
|
||
..journalId = element.journalId
|
||
..elementType = element.elementType.value
|
||
..positionX = element.positionX
|
||
..positionY = element.positionY
|
||
..width = element.width
|
||
..height = element.height
|
||
..rotation = element.rotation
|
||
..zIndex = element.zIndex
|
||
..contentJson = jsonEncode(element.content)
|
||
..version = element.version
|
||
..createdAtEpoch = element.createdAt.millisecondsSinceEpoch
|
||
..updatedAtEpoch = element.updatedAt.millisecondsSinceEpoch
|
||
..isDeleted = false;
|
||
}
|
||
|
||
/// JournalElementCollection → JournalElement
|
||
JournalElement _fromElementCollection(JournalElementCollection col) {
|
||
return JournalElement(
|
||
id: col.id,
|
||
journalId: col.journalId,
|
||
elementType: ElementType.values.firstWhere(
|
||
(e) => e.value == col.elementType,
|
||
orElse: () => ElementType.text,
|
||
),
|
||
positionX: col.positionX,
|
||
positionY: col.positionY,
|
||
width: col.width,
|
||
height: col.height,
|
||
rotation: col.rotation,
|
||
zIndex: col.zIndex,
|
||
content: Map<String, dynamic>.from(
|
||
jsonDecode(col.contentJson) as Map? ?? {},
|
||
),
|
||
version: col.version,
|
||
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
|
||
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
|
||
);
|
||
}
|
||
}
|