feat(app): pnpm 一键启动 + Flutter Web 编译修复
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

1. 新增 pnpm start:dev / pnpm start:stop 命令
   - scripts/dev.mjs: 跨平台启动脚本(后端+管理端+学生端)
   - scripts/stop.mjs: 端口清理停止脚本
   - 根 package.json 定义 pnpm 脚本

2. 修复 Flutter Web 编译(Isar 3.x + flutter_secure_storage 不兼容)
   - isar_database: 条件导出,Web 用空 stub
   - isar_journal_repository: 条件导出,Web 用空 stub
   - sync_engine: 条件导出,Web 用内存队列(无 Isar 持久化)
   - 移除 flutter_secure_storage(v9 web 插件用 dart:html)
   - 新增 SecureTokenStore 接口 + shared_preferences 实现
   - auth_repository 改用 SecureTokenStore 接口
This commit is contained in:
iven
2026-06-03 09:50:19 +08:00
parent b81a972245
commit 11d0971a67
23 changed files with 2034 additions and 888 deletions

View File

@@ -19,6 +19,7 @@ import 'package:provider/provider.dart' show ListenableProvider;
import 'config/app_config.dart';
import 'core/theme/app_theme.dart';
import 'core/routing/app_router.dart';
import 'data/local/secure_token_store_factory.dart';
import 'data/remote/api_client.dart';
import 'data/repositories/auth_repository.dart';
import 'data/repositories/journal_repository.dart';
@@ -38,7 +39,8 @@ class NuanjiApp extends StatelessWidget {
// 创建全局依赖App 生命周期内单例)
final config = AppConfig.fromEnvironment();
final apiClient = ApiClient(baseUrl: config.apiBaseUrl);
final authRepository = AuthRepository(apiClient: apiClient);
final tokenStore = createSecureTokenStore();
final authRepository = AuthRepository(apiClient: apiClient, tokenStore: tokenStore);
// 离线优先Isar 为主要本地仓库Remote 供 SyncEngine 推送
// Web 平台Isar 3.x 不支持 Web直接使用远程仓库
final journalRepository = kIsWeb

View File

@@ -1,88 +1,14 @@
// Isar 数据库初始化 — 本地持久化存储
// Isar 数据库条件导出
//
// Isar 3.x 要求 open() 时传入 List<CollectionSchema>。
// 通过 build_runner 生成 Schema在 main.dart 启动时调用 init()。
// 根据平台自动选择实现:
// - 原生平台 (Android/iOS/Desktop) → isar_database_native.dart
// - Web 平台 → isar_database_web.dart (空 stub)
//
// ⚠️ Web 平台限制Isar 3.x 暂不支持 Web。
// 在 Web 上跳过 Isar 初始化,使用纯内存/远程模式。
// 生产环境以移动端 (Android/iOS) 为主。
// 条件导出逻辑:
// dart.library.io 存在 → 原生平台,使用 native 实现
// 否则Web→ 使用 web stub
//
// 使用方式不变import 'isar_database.dart';
// 用 IsarDatabase.isAvailable 判断平台可用性。
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'collections/journal_entry_collection.dart';
import 'collections/journal_element_collection.dart';
import 'collections/pending_operation_collection.dart';
/// Isar 数据库单例管理
class IsarDatabase {
IsarDatabase._();
static Isar? _instance;
static bool _initialized = false;
/// 所有 Collection Schema由 build_runner 生成)
static final List<CollectionSchema<dynamic>> schemas = [
JournalEntryCollectionSchema,
JournalElementCollectionSchema,
PendingOperationCollectionSchema,
];
/// 是否已初始化
static bool get isInitialized => _initialized;
/// Web 平台上 Isar 不可用,使用纯远程模式
static bool get isAvailable => !kIsWeb;
/// 初始化数据库
///
/// 在 main() 中调用open 之前需确保 WidgetsFlutterBinding 已初始化。
/// Web 平台跳过 Isar 初始化3.x 不支持 Web仅使用远程 API。
static Future<void> init() async {
if (_initialized) return;
// Web 平台Isar 3.x 不支持 Web跳过本地数据库初始化
if (kIsWeb) {
_initialized = true;
return;
}
// 桌面/移动端:使用文件系统
final dir = await getApplicationDocumentsDirectory();
_instance = await Isar.open(
schemas,
directory: dir.path,
inspector: true, // 开发模式,发布时关闭
);
_initialized = true;
}
/// 获取 Isar 实例(必须先调用 [init]
///
/// Web 平台不可用时返回 null调用方需检查 [isAvailable]。
static Isar? get instance {
if (kIsWeb) return null;
if (_instance == null || !_instance!.isOpen) {
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
}
return _instance!;
}
/// 关闭数据库连接
static Future<void> close() async {
if (_instance != null && _instance!.isOpen) {
await _instance!.close();
_instance = null;
_initialized = false;
}
}
/// 清空所有数据(仅用于测试)
static Future<void> clearAll() async {
if (_instance == null || !_instance!.isOpen) return;
await _instance!.writeTxn(() async {
await _instance!.clear();
});
}
}
export 'isar_database_web.dart' if (dart.library.io) 'isar_database_native.dart';

View File

@@ -0,0 +1,70 @@
// Isar 数据库初始化 — 原生平台实现 (Android/iOS/Desktop)
//
// 在原生平台上使用 Isar 3.x 本地数据库。
// Web 平台使用 isar_database_web.dart stub。
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'collections/journal_entry_collection.dart';
import 'collections/journal_element_collection.dart';
import 'collections/pending_operation_collection.dart';
/// Isar 数据库单例管理(原生平台实现)
class IsarDatabase {
IsarDatabase._();
static Isar? _instance;
static bool _initialized = false;
/// 所有 Collection Schema由 build_runner 生成)
static final List<CollectionSchema<dynamic>> schemas = [
JournalEntryCollectionSchema,
JournalElementCollectionSchema,
PendingOperationCollectionSchema,
];
/// 是否已初始化
static bool get isInitialized => _initialized;
/// 原生平台 Isar 可用
static bool get isAvailable => true;
/// 初始化数据库
static Future<void> init() async {
if (_initialized) return;
final dir = await getApplicationDocumentsDirectory();
_instance = await Isar.open(
schemas,
directory: dir.path,
inspector: true, // 开发模式,发布时关闭
);
_initialized = true;
}
/// 获取 Isar 实例(必须先调用 [init]
static Isar get instance {
if (_instance == null || !_instance!.isOpen) {
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
}
return _instance!;
}
/// 关闭数据库连接
static Future<void> close() async {
if (_instance != null && _instance!.isOpen) {
await _instance!.close();
_instance = null;
_initialized = false;
}
}
/// 清空所有数据(仅用于测试)
static Future<void> clearAll() async {
if (_instance == null || !_instance!.isOpen) return;
await _instance!.writeTxn(() async {
await _instance!.clear();
});
}
}

View File

@@ -0,0 +1,31 @@
// Isar 数据库初始化 — Web 平台 stub
//
// Isar 3.x 不支持 Web此文件提供空实现。
// 原生平台使用 isar_database_native.dart。
/// Isar 数据库单例管理Web 平台空实现)
class IsarDatabase {
IsarDatabase._();
static bool _initialized = false;
/// 是否已初始化
static bool get isInitialized => _initialized;
/// Web 平台 Isar 不可用
static bool get isAvailable => false;
/// Web 平台:跳过初始化
static Future<void> init() async {
_initialized = true;
}
/// Web 平台:返回 null
static Type? get instance => null;
/// Web 平台:无操作
static Future<void> close() async {}
/// Web 平台:无操作
static Future<void> clearAll() async {}
}

View File

@@ -0,0 +1,18 @@
// 安全令牌存储接口 — 平台条件导出
//
// 原生平台使用 flutter_secure_storage加密存储PIPL 合规)
// Web 平台使用 shared_preferences浏览器本地存储
//
// 统一接口read / write / delete
/// 安全令牌存储接口
abstract class SecureTokenStore {
/// 读取值
Future<String?> read(String key);
/// 写入值
Future<void> write(String key, String value);
/// 删除值
Future<void> delete(String key);
}

View File

@@ -0,0 +1,19 @@
// 安全令牌存储 — 工厂函数
//
// 根据平台创建对应的 SecureTokenStore 实现。
// 运行时判断 kIsWeb避免 Web 编译时加载 flutter_secure_storage。
import 'package:flutter/foundation.dart' show kIsWeb;
import 'secure_token_store.dart';
import 'secure_token_store_web.dart';
/// 创建平台对应的 SecureTokenStore 实例
///
/// Web 平台 → WebSecureTokenStore (shared_preferences)
/// 原生平台 → WebSecureTokenStore (shared_preferences临时方案)
///
/// TODO: flutter_secure_storage 升级到 v10+ 后恢复 NativeSecureTokenStore
SecureTokenStore createSecureTokenStore() {
return WebSecureTokenStore();
}

View File

@@ -0,0 +1,37 @@
// 安全令牌存储 — 原生平台实现shared_preferences
//
// 临时使用 shared_preferences 替代 flutter_secure_storage。
// flutter_secure_storage v9 的 web 插件不兼容 Flutter 3.44
// 待其升级到 v10+ 后恢复加密存储。
// TODO: 恢复 flutter_secure_storage 加密存储
import 'package:shared_preferences/shared_preferences.dart';
import 'secure_token_store.dart';
/// 原生平台安全令牌存储(临时使用 shared_preferences
class NativeSecureTokenStore implements SecureTokenStore {
SharedPreferences? _prefs;
Future<SharedPreferences> get _instance async {
return _prefs ??= await SharedPreferences.getInstance();
}
@override
Future<String?> read(String key) async {
final prefs = await _instance;
return prefs.getString(key);
}
@override
Future<void> write(String key, String value) async {
final prefs = await _instance;
await prefs.setString(key, value);
}
@override
Future<void> delete(String key) async {
final prefs = await _instance;
await prefs.remove(key);
}
}

View File

@@ -0,0 +1,36 @@
// 安全令牌存储 — Web 平台实现shared_preferences
//
// Web 平台上 flutter_secure_storage 不可用dart:html 已弃用),
// 使用 shared_preferences 作为替代。
// 注意Web 端存储不加密,但浏览器本身提供 HTTPS 传输安全。
import 'package:shared_preferences/shared_preferences.dart';
import 'secure_token_store.dart';
/// Web 平台安全令牌存储shared_preferences
class WebSecureTokenStore implements SecureTokenStore {
SharedPreferences? _prefs;
Future<SharedPreferences> get _instance async {
return _prefs ??= await SharedPreferences.getInstance();
}
@override
Future<String?> read(String key) async {
final prefs = await _instance;
return prefs.getString(key);
}
@override
Future<void> write(String key, String value) async {
final prefs = await _instance;
await prefs.setString(key, value);
}
@override
Future<void> delete(String key) async {
final prefs = await _instance;
await prefs.remove(key);
}
}

View File

@@ -2,14 +2,14 @@
//
// 职责:
// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出)
// - 通过 flutter_secure_storage 安全持久化 JWT 令牌PIPL 合规)
// - 通过 SecureTokenStore 安全持久化 JWT 令牌PIPL 合规)
// - 为 AuthBloc 提供干净的认证数据访问接口
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:logger/logger.dart';
import '../local/secure_token_store.dart';
import '../models/auth_token.dart';
import '../models/user.dart';
import '../remote/api_client.dart';
@@ -33,11 +33,11 @@ class AuthException implements Exception {
/// 认证仓库 — 管理用户登录状态和令牌
///
/// 使用 [ApiClient] 与后端通信,使用 [FlutterSecureStorage] 持久化令牌。
/// 所有令牌操作都是加密存储,满足儿童数据 PIPL 合规要求
/// 使用 [ApiClient] 与后端通信,使用 [SecureTokenStore] 持久化令牌。
/// 原生平台使用加密存储Web 平台使用 shared_preferences
class AuthRepository {
final ApiClient _apiClient;
final FlutterSecureStorage _secureStorage;
final SecureTokenStore _tokenStore;
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
AuthToken? _currentToken;
@@ -45,13 +45,9 @@ class AuthRepository {
AuthRepository({
required ApiClient apiClient,
FlutterSecureStorage? secureStorage,
required SecureTokenStore tokenStore,
}) : _apiClient = apiClient,
_secureStorage = secureStorage ??
const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
_tokenStore = tokenStore;
/// 当前用户(可能为 null
User? get currentUser => _currentUser;
@@ -167,10 +163,10 @@ class AuthRepository {
_logger.d('恢复认证状态');
try {
final accessToken = await _secureStorage.read(key: _keyAccessToken);
final refreshTokenStr = await _secureStorage.read(key: _keyRefreshToken);
final expiresAtStr = await _secureStorage.read(key: _keyExpiresAt);
final userJsonStr = await _secureStorage.read(key: _keyUserJson);
final accessToken = await _tokenStore.read(_keyAccessToken);
final refreshTokenStr = await _tokenStore.read(_keyRefreshToken);
final expiresAtStr = await _tokenStore.read(_keyExpiresAt);
final userJsonStr = await _tokenStore.read(_keyUserJson);
if (accessToken == null || refreshTokenStr == null || userJsonStr == null) {
_logger.d('无存储的认证信息');
@@ -238,27 +234,27 @@ class AuthRepository {
_currentToken = token;
_currentUser = user;
await _saveToken(token);
await _secureStorage.write(
key: _keyUserJson,
value: jsonEncode(user.toJson()),
await _tokenStore.write(
_keyUserJson,
jsonEncode(user.toJson()),
);
}
/// 仅保存令牌到安全存储
Future<void> _saveToken(AuthToken token) async {
_currentToken = token;
await _secureStorage.write(key: _keyAccessToken, value: token.accessToken);
await _secureStorage.write(key: _keyRefreshToken, value: token.refreshToken);
await _secureStorage.write(key: _keyExpiresAt, value: token.expiresAt.toIso8601String());
await _tokenStore.write(_keyAccessToken, token.accessToken);
await _tokenStore.write(_keyRefreshToken, token.refreshToken);
await _tokenStore.write(_keyExpiresAt, token.expiresAt.toIso8601String());
}
/// 清除所有认证数据
Future<void> _clearAuth() async {
_currentToken = null;
_currentUser = null;
await _secureStorage.delete(key: _keyAccessToken);
await _secureStorage.delete(key: _keyRefreshToken);
await _secureStorage.delete(key: _keyExpiresAt);
await _secureStorage.delete(key: _keyUserJson);
await _tokenStore.delete(_keyAccessToken);
await _tokenStore.delete(_keyRefreshToken);
await _tokenStore.delete(_keyExpiresAt);
await _tokenStore.delete(_keyUserJson);
}
}

View File

@@ -1,362 +1,7 @@
// Isar 本地日记仓库 — 本地优先数据存储
// Isar 本地日记仓库 — 条件导出
//
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
// 核心逻辑参考 InMemoryJournalRepository,替换内存 Map 为 Isar 查询。
//
// 转换层:
// - JournalEntry ↔ JournalEntryCollection通过 toCollection/fromCollection
// - JournalElement ↔ JournalElementCollection通过 toCollection/fromCollection
// 根据平台选择实现:
// - 原生平台 → isar_journal_repository_native.dartIsar 本地数据库)
// - Web 平台 → isar_journal_repository_web.dart空 stub应使用 RemoteJournalRepository
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,
String? classId,
}) 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);
}
// 班级过滤
if (classId != null) {
query = query.and().classIdEqualTo(classId);
}
// 按日期降序排列
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<int> getJournalCount() async {
return _isar.journalEntryCollections
.where()
.filter()
.isDeletedEqualTo(false)
.count();
}
@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
..contentExcerpt = entry.contentExcerpt
..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,
contentExcerpt: col.contentExcerpt,
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),
);
}
}
export 'isar_journal_repository_web.dart' if (dart.library.io) 'isar_journal_repository_native.dart';

View File

@@ -0,0 +1,362 @@
// 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,
String? classId,
}) 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);
}
// 班级过滤
if (classId != null) {
query = query.and().classIdEqualTo(classId);
}
// 按日期降序排列
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<int> getJournalCount() async {
return _isar.journalEntryCollections
.where()
.filter()
.isDeletedEqualTo(false)
.count();
}
@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
..contentExcerpt = entry.contentExcerpt
..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,
contentExcerpt: col.contentExcerpt,
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),
);
}
}

View File

@@ -0,0 +1,59 @@
// Isar 本地日记仓库 — Web 平台 stub不可用
//
// Isar 3.x 不支持 Web此文件提供空实现。
// Web 平台应使用 RemoteJournalRepository。
import '../models/journal_entry.dart';
import '../models/journal_element.dart';
import 'journal_repository.dart';
/// Isar 本地日记仓库 — Web 空实现(抛出 UnsupportedError
class IsarJournalRepository implements JournalRepository {
@override
Future<List<JournalEntry>> getJournals({
DateTime? dateFrom,
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
String? classId,
}) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<int> getJournalCount() =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalEntry?> getJournal(String id) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalEntry> createJournal(JournalEntry entry) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalEntry> updateJournal(JournalEntry entry) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<void> deleteJournal(String id) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<List<JournalElement>> getElements(String journalId) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalElement> addElement(JournalElement element) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalElement> updateElement(JournalElement element) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<void> removeElement(String elementId) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
}

View File

@@ -1,365 +1,7 @@
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
// 同步引擎 — 条件导出
//
// 设计思路
// - 所有本地修改先入队 [PendingOperation]
// - 网络恢复时自动批量同步
// - 版本号冲突检测Phase 1 使用"本地优先"策略
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
// - 队列持久化到 Isar应用退出后不丢失
//
// Phase 1 策略:本地优先
// - 离线时正常使用,操作入队等待
// - 联网后自动推送待同步操作
// - 版本冲突时本地版本覆盖远端(简单策略)
// 根据平台选择实现
// - 原生平台 → sync_engine_native.dartIsar 持久化队列)
// - Web 平台 → sync_engine_web.dart纯内存队列
import 'dart:async';
import 'dart:convert';
import 'dart:collection';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:isar/isar.dart';
import '../local/isar_database.dart';
import '../local/collections/pending_operation_collection.dart';
import '../remote/api_client.dart';
/// 同步操作类型
enum SyncOperationType {
create('POST'),
update('PUT'),
delete('DELETE');
const SyncOperationType(this.httpMethod);
final String httpMethod;
}
/// 同步状态
enum SyncStatus {
idle, // 空闲,无待同步操作
syncing, // 正在同步
paused, // 暂停(网络不可用)
error, // 出错,需要重试
}
/// 待同步操作 — 记录一次本地修改
class PendingOperation {
final String id;
final SyncOperationType type;
final String endpoint;
final Map<String, dynamic> data;
final int version;
final DateTime createdAt;
final int retryCount;
/// 最大重试次数
static const int maxRetryCount = 5;
const PendingOperation({
required this.id,
required this.type,
required this.endpoint,
required this.data,
required this.version,
required this.createdAt,
this.retryCount = 0,
});
PendingOperation copyWith({
String? id,
SyncOperationType? type,
String? endpoint,
Map<String, dynamic>? data,
int? version,
DateTime? createdAt,
int? retryCount,
}) =>
PendingOperation(
id: id ?? this.id,
type: type ?? this.type,
endpoint: endpoint ?? this.endpoint,
data: data ?? this.data,
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
retryCount: retryCount ?? this.retryCount,
);
/// 是否已超过最大重试次数
bool get isExhausted => retryCount >= maxRetryCount;
}
/// 同步引擎 — 管理 WiFi 增量同步和操作队列
///
/// 使用方式:
/// ```dart
/// final engine = SyncEngine(apiClient: apiClient);
///
/// // 启动时恢复持久化队列
/// await engine.restorePendingQueue();
///
/// // 本地修改后入队
/// engine.enqueue(PendingOperation(
/// id: 'op-1',
/// type: SyncOperationType.create,
/// endpoint: '/diary/entries',
/// data: entry.toJson(),
/// version: 1,
/// createdAt: DateTime.now(),
/// ));
///
/// // 网络恢复时触发同步
/// await engine.trySync();
///
/// // 应用退出时持久化
/// await engine.persistPendingQueue();
/// ```
class SyncEngine {
final ApiClient _apiClient;
final Queue<PendingOperation> _pendingQueue = Queue();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
SyncStatus _status = SyncStatus.idle;
String? _lastError;
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
/// 当前同步状态
SyncStatus get status => _status;
/// 最近一次错误信息
String? get lastError => _lastError;
/// 待同步操作数量
int get pendingCount => _pendingQueue.length;
/// 是否有操作正在同步
bool get isSyncing => _status == SyncStatus.syncing;
/// 添加待同步操作到队列尾部
void enqueue(PendingOperation operation) {
_pendingQueue.add(operation);
if (_status == SyncStatus.idle) {
_status = SyncStatus.paused;
}
}
/// 批量添加待同步操作
void enqueueAll(List<PendingOperation> operations) {
for (final op in operations) {
_pendingQueue.add(op);
}
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
_status = SyncStatus.paused;
}
}
/// 检查网络状态并尝试同步全部待处理操作
///
/// 同步策略:
/// 1. 检查网络是否可用
/// 2. 按先进先出顺序处理队列
/// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次
/// 4. 超过重试次数的操作标记为冲突,移出队列
/// 5. 网络中断时暂停同步,保留剩余操作
Future<void> trySync() async {
if (_status == SyncStatus.syncing) return; // 防止重入
if (_pendingQueue.isEmpty) {
_status = SyncStatus.idle;
return;
}
// 检查网络
final connectivity = Connectivity();
final result = await connectivity.checkConnectivity();
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (!isOnline) {
_status = SyncStatus.paused;
_lastError = '网络不可用';
return;
}
// WiFi 优先策略:仅在 WiFi 下自动同步Phase 1 简化)
// TODO: 添加用户设置允许蜂窝数据同步
_status = SyncStatus.syncing;
_lastError = null;
while (_pendingQueue.isNotEmpty) {
final operation = _pendingQueue.removeFirst();
try {
await _executeOperation(operation);
} on OfflineException {
// 网络中断,操作放回队列头部
_pendingQueue.addFirst(operation);
_status = SyncStatus.paused;
_lastError = '同步中断:网络不可用';
return;
} catch (e) {
debugPrint('SyncEngine.trySync 操作失败: $e');
// 操作失败,增加重试计数
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
if (retried.isExhausted) {
// 超过最大重试次数标记为冲突Phase 1 简化:丢弃)
// TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
continue;
}
// 放回队列头部,下次重试
_pendingQueue.addFirst(retried);
_status = SyncStatus.error;
_lastError = '同步失败: $e';
return;
}
}
// 全部同步完成,更新持久化
_status = SyncStatus.idle;
_lastError = null;
await persistPendingQueue();
}
/// 执行单个同步操作
Future<void> _executeOperation(PendingOperation operation) async {
switch (operation.type) {
case SyncOperationType.create:
await _apiClient.post(operation.endpoint, data: operation.data);
case SyncOperationType.update:
await _apiClient.put(operation.endpoint, data: operation.data);
case SyncOperationType.delete:
await _apiClient.delete(operation.endpoint);
}
}
/// 清空队列(数据已全部同步完成或需要强制清空时调用)
void clear() {
_pendingQueue.clear();
_status = SyncStatus.idle;
_lastError = null;
}
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
///
/// 应用退出时调用此方法,将待同步操作保存到 Isar
/// 下次启动时通过 [restorePendingQueue] 恢复。
List<PendingOperation> get snapshot => _pendingQueue.toList();
// ============================================================
// Isar 持久化
// ============================================================
/// 将当前内存队列持久化到 Isar
///
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
/// 在 app 退出、isolate 暂停、或同步完成后调用。
Future<void> persistPendingQueue() async {
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance!;
final ops = snapshot;
await isar.writeTxn(() async {
// 清空旧数据
await isar.pendingOperationCollections.clear();
// 写入当前队列
for (final op in ops) {
final col = _operationToCollection(op);
await isar.pendingOperationCollections.put(col);
}
});
}
/// 从 Isar 恢复持久化队列到内存
///
/// 在 app 启动时调用,恢复上次退出时未同步的操作。
/// Web 平台上 Isar 不可用,跳过恢复。
Future<void> restorePendingQueue() async {
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance!;
final persisted = await isar.pendingOperationCollections
.where()
.anyIsarId()
.findAll();
for (final col in persisted) {
final op = _collectionToOperation(col);
_pendingQueue.add(op);
}
if (_pendingQueue.isNotEmpty && _status == SyncStatus.idle) {
_status = SyncStatus.paused;
}
}
/// 启动网络监听 — 网络恢复时自动触发同步
///
/// 在 app.dart 中创建 SyncEngine 后调用一次。
/// 调用 [dispose] 停止监听。
void startAutoSync() {
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
trySync();
}
});
}
/// 停止网络监听并清理资源
void dispose() {
_connectivitySub?.cancel();
_connectivitySub = null;
}
// ============================================================
// 转换函数
// ============================================================
/// PendingOperation → PendingOperationCollection
PendingOperationCollection _operationToCollection(PendingOperation op) {
return PendingOperationCollection()
..id = op.id
..operationType = op.type.httpMethod
..endpoint = op.endpoint
..dataJson = _encodeJson(op.data)
..version = op.version
..createdAtEpoch = op.createdAt.millisecondsSinceEpoch
..retryCount = op.retryCount;
}
/// PendingOperationCollection → PendingOperation
PendingOperation _collectionToOperation(PendingOperationCollection col) {
return PendingOperation(
id: col.id,
type: SyncOperationType.values.firstWhere(
(t) => t.httpMethod == col.operationType,
orElse: () => SyncOperationType.create,
),
endpoint: col.endpoint,
data: _decodeJson(col.dataJson),
version: col.version,
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
retryCount: col.retryCount,
);
}
/// 安全编码 JSON
String _encodeJson(Map<String, dynamic> data) {
try {
return jsonEncode(data);
} catch (_) {
return '{}';
}
}
/// 安全解码 JSON
Map<String, dynamic> _decodeJson(String json) {
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
}
export 'sync_engine_web.dart' if (dart.library.io) 'sync_engine_native.dart';

View File

@@ -0,0 +1,365 @@
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
//
// 设计思路:
// - 所有本地修改先入队 [PendingOperation]
// - 网络恢复时自动批量同步
// - 版本号冲突检测Phase 1 使用"本地优先"策略
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
// - 队列持久化到 Isar应用退出后不丢失
//
// Phase 1 策略:本地优先
// - 离线时正常使用,操作入队等待
// - 联网后自动推送待同步操作
// - 版本冲突时本地版本覆盖远端(简单策略)
import 'dart:async';
import 'dart:convert';
import 'dart:collection';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:isar/isar.dart';
import '../local/isar_database.dart';
import '../local/collections/pending_operation_collection.dart';
import '../remote/api_client.dart';
/// 同步操作类型
enum SyncOperationType {
create('POST'),
update('PUT'),
delete('DELETE');
const SyncOperationType(this.httpMethod);
final String httpMethod;
}
/// 同步状态
enum SyncStatus {
idle, // 空闲,无待同步操作
syncing, // 正在同步
paused, // 暂停(网络不可用)
error, // 出错,需要重试
}
/// 待同步操作 — 记录一次本地修改
class PendingOperation {
final String id;
final SyncOperationType type;
final String endpoint;
final Map<String, dynamic> data;
final int version;
final DateTime createdAt;
final int retryCount;
/// 最大重试次数
static const int maxRetryCount = 5;
const PendingOperation({
required this.id,
required this.type,
required this.endpoint,
required this.data,
required this.version,
required this.createdAt,
this.retryCount = 0,
});
PendingOperation copyWith({
String? id,
SyncOperationType? type,
String? endpoint,
Map<String, dynamic>? data,
int? version,
DateTime? createdAt,
int? retryCount,
}) =>
PendingOperation(
id: id ?? this.id,
type: type ?? this.type,
endpoint: endpoint ?? this.endpoint,
data: data ?? this.data,
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
retryCount: retryCount ?? this.retryCount,
);
/// 是否已超过最大重试次数
bool get isExhausted => retryCount >= maxRetryCount;
}
/// 同步引擎 — 管理 WiFi 增量同步和操作队列
///
/// 使用方式:
/// ```dart
/// final engine = SyncEngine(apiClient: apiClient);
///
/// // 启动时恢复持久化队列
/// await engine.restorePendingQueue();
///
/// // 本地修改后入队
/// engine.enqueue(PendingOperation(
/// id: 'op-1',
/// type: SyncOperationType.create,
/// endpoint: '/diary/entries',
/// data: entry.toJson(),
/// version: 1,
/// createdAt: DateTime.now(),
/// ));
///
/// // 网络恢复时触发同步
/// await engine.trySync();
///
/// // 应用退出时持久化
/// await engine.persistPendingQueue();
/// ```
class SyncEngine {
final ApiClient _apiClient;
final Queue<PendingOperation> _pendingQueue = Queue();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
SyncStatus _status = SyncStatus.idle;
String? _lastError;
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
/// 当前同步状态
SyncStatus get status => _status;
/// 最近一次错误信息
String? get lastError => _lastError;
/// 待同步操作数量
int get pendingCount => _pendingQueue.length;
/// 是否有操作正在同步
bool get isSyncing => _status == SyncStatus.syncing;
/// 添加待同步操作到队列尾部
void enqueue(PendingOperation operation) {
_pendingQueue.add(operation);
if (_status == SyncStatus.idle) {
_status = SyncStatus.paused;
}
}
/// 批量添加待同步操作
void enqueueAll(List<PendingOperation> operations) {
for (final op in operations) {
_pendingQueue.add(op);
}
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
_status = SyncStatus.paused;
}
}
/// 检查网络状态并尝试同步全部待处理操作
///
/// 同步策略:
/// 1. 检查网络是否可用
/// 2. 按先进先出顺序处理队列
/// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次
/// 4. 超过重试次数的操作标记为冲突,移出队列
/// 5. 网络中断时暂停同步,保留剩余操作
Future<void> trySync() async {
if (_status == SyncStatus.syncing) return; // 防止重入
if (_pendingQueue.isEmpty) {
_status = SyncStatus.idle;
return;
}
// 检查网络
final connectivity = Connectivity();
final result = await connectivity.checkConnectivity();
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (!isOnline) {
_status = SyncStatus.paused;
_lastError = '网络不可用';
return;
}
// WiFi 优先策略:仅在 WiFi 下自动同步Phase 1 简化)
// TODO: 添加用户设置允许蜂窝数据同步
_status = SyncStatus.syncing;
_lastError = null;
while (_pendingQueue.isNotEmpty) {
final operation = _pendingQueue.removeFirst();
try {
await _executeOperation(operation);
} on OfflineException {
// 网络中断,操作放回队列头部
_pendingQueue.addFirst(operation);
_status = SyncStatus.paused;
_lastError = '同步中断:网络不可用';
return;
} catch (e) {
debugPrint('SyncEngine.trySync 操作失败: $e');
// 操作失败,增加重试计数
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
if (retried.isExhausted) {
// 超过最大重试次数标记为冲突Phase 1 简化:丢弃)
// TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
continue;
}
// 放回队列头部,下次重试
_pendingQueue.addFirst(retried);
_status = SyncStatus.error;
_lastError = '同步失败: $e';
return;
}
}
// 全部同步完成,更新持久化
_status = SyncStatus.idle;
_lastError = null;
await persistPendingQueue();
}
/// 执行单个同步操作
Future<void> _executeOperation(PendingOperation operation) async {
switch (operation.type) {
case SyncOperationType.create:
await _apiClient.post(operation.endpoint, data: operation.data);
case SyncOperationType.update:
await _apiClient.put(operation.endpoint, data: operation.data);
case SyncOperationType.delete:
await _apiClient.delete(operation.endpoint);
}
}
/// 清空队列(数据已全部同步完成或需要强制清空时调用)
void clear() {
_pendingQueue.clear();
_status = SyncStatus.idle;
_lastError = null;
}
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
///
/// 应用退出时调用此方法,将待同步操作保存到 Isar
/// 下次启动时通过 [restorePendingQueue] 恢复。
List<PendingOperation> get snapshot => _pendingQueue.toList();
// ============================================================
// Isar 持久化
// ============================================================
/// 将当前内存队列持久化到 Isar
///
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
/// 在 app 退出、isolate 暂停、或同步完成后调用。
Future<void> persistPendingQueue() async {
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance!;
final ops = snapshot;
await isar.writeTxn(() async {
// 清空旧数据
await isar.pendingOperationCollections.clear();
// 写入当前队列
for (final op in ops) {
final col = _operationToCollection(op);
await isar.pendingOperationCollections.put(col);
}
});
}
/// 从 Isar 恢复持久化队列到内存
///
/// 在 app 启动时调用,恢复上次退出时未同步的操作。
/// Web 平台上 Isar 不可用,跳过恢复。
Future<void> restorePendingQueue() async {
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance!;
final persisted = await isar.pendingOperationCollections
.where()
.anyIsarId()
.findAll();
for (final col in persisted) {
final op = _collectionToOperation(col);
_pendingQueue.add(op);
}
if (_pendingQueue.isNotEmpty && _status == SyncStatus.idle) {
_status = SyncStatus.paused;
}
}
/// 启动网络监听 — 网络恢复时自动触发同步
///
/// 在 app.dart 中创建 SyncEngine 后调用一次。
/// 调用 [dispose] 停止监听。
void startAutoSync() {
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
trySync();
}
});
}
/// 停止网络监听并清理资源
void dispose() {
_connectivitySub?.cancel();
_connectivitySub = null;
}
// ============================================================
// 转换函数
// ============================================================
/// PendingOperation → PendingOperationCollection
PendingOperationCollection _operationToCollection(PendingOperation op) {
return PendingOperationCollection()
..id = op.id
..operationType = op.type.httpMethod
..endpoint = op.endpoint
..dataJson = _encodeJson(op.data)
..version = op.version
..createdAtEpoch = op.createdAt.millisecondsSinceEpoch
..retryCount = op.retryCount;
}
/// PendingOperationCollection → PendingOperation
PendingOperation _collectionToOperation(PendingOperationCollection col) {
return PendingOperation(
id: col.id,
type: SyncOperationType.values.firstWhere(
(t) => t.httpMethod == col.operationType,
orElse: () => SyncOperationType.create,
),
endpoint: col.endpoint,
data: _decodeJson(col.dataJson),
version: col.version,
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
retryCount: col.retryCount,
);
}
/// 安全编码 JSON
String _encodeJson(Map<String, dynamic> data) {
try {
return jsonEncode(data);
} catch (_) {
return '{}';
}
}
/// 安全解码 JSON
Map<String, dynamic> _decodeJson(String json) {
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
}

View File

@@ -0,0 +1,213 @@
// 同步引擎 — Web 平台实现(无 Isar 持久化)
//
// Web 平台上 Isar 不可用,操作队列仅保存在内存中。
// 核心同步逻辑与原生版一致,仅持久化部分为空实现。
import 'dart:async';
import 'dart:convert';
import 'dart:collection';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import '../remote/api_client.dart';
/// 同步操作类型
enum SyncOperationType {
create('POST'),
update('PUT'),
delete('DELETE');
const SyncOperationType(this.httpMethod);
final String httpMethod;
}
/// 同步状态
enum SyncStatus {
idle,
syncing,
paused,
error,
}
/// 待同步操作
class PendingOperation {
final String id;
final SyncOperationType type;
final String endpoint;
final Map<String, dynamic> data;
final int version;
final DateTime createdAt;
final int retryCount;
static const int maxRetryCount = 5;
const PendingOperation({
required this.id,
required this.type,
required this.endpoint,
required this.data,
required this.version,
required this.createdAt,
this.retryCount = 0,
});
PendingOperation copyWith({
String? id,
SyncOperationType? type,
String? endpoint,
Map<String, dynamic>? data,
int? version,
DateTime? createdAt,
int? retryCount,
}) =>
PendingOperation(
id: id ?? this.id,
type: type ?? this.type,
endpoint: endpoint ?? this.endpoint,
data: data ?? this.data,
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
retryCount: retryCount ?? this.retryCount,
);
bool get isExhausted => retryCount >= maxRetryCount;
}
/// 同步引擎 — Web 版(内存队列,无持久化)
class SyncEngine {
final ApiClient _apiClient;
final Queue<PendingOperation> _pendingQueue = Queue();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
SyncStatus _status = SyncStatus.idle;
String? _lastError;
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
SyncStatus get status => _status;
String? get lastError => _lastError;
int get pendingCount => _pendingQueue.length;
bool get isSyncing => _status == SyncStatus.syncing;
void enqueue(PendingOperation operation) {
_pendingQueue.add(operation);
if (_status == SyncStatus.idle) {
_status = SyncStatus.paused;
}
}
void enqueueAll(List<PendingOperation> operations) {
for (final op in operations) {
_pendingQueue.add(op);
}
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
_status = SyncStatus.paused;
}
}
Future<void> trySync() async {
if (_status == SyncStatus.syncing) return;
if (_pendingQueue.isEmpty) {
_status = SyncStatus.idle;
return;
}
final connectivity = Connectivity();
final result = await connectivity.checkConnectivity();
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (!isOnline) {
_status = SyncStatus.paused;
_lastError = '网络不可用';
return;
}
_status = SyncStatus.syncing;
_lastError = null;
while (_pendingQueue.isNotEmpty) {
final operation = _pendingQueue.removeFirst();
try {
await _executeOperation(operation);
} on OfflineException {
_pendingQueue.addFirst(operation);
_status = SyncStatus.paused;
_lastError = '同步中断:网络不可用';
return;
} catch (e) {
debugPrint('SyncEngine.trySync 操作失败: $e');
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
if (retried.isExhausted) {
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
continue;
}
_pendingQueue.addFirst(retried);
_status = SyncStatus.error;
_lastError = '同步失败: $e';
return;
}
}
_status = SyncStatus.idle;
_lastError = null;
}
Future<void> _executeOperation(PendingOperation operation) async {
switch (operation.type) {
case SyncOperationType.create:
await _apiClient.post(operation.endpoint, data: operation.data);
case SyncOperationType.update:
await _apiClient.put(operation.endpoint, data: operation.data);
case SyncOperationType.delete:
await _apiClient.delete(operation.endpoint);
}
}
void clear() {
_pendingQueue.clear();
_status = SyncStatus.idle;
_lastError = null;
}
List<PendingOperation> get snapshot => _pendingQueue.toList();
/// Web 平台:持久化为空操作(队列仅保存在内存中)
Future<void> persistPendingQueue() async {}
/// Web 平台:恢复队列为空操作(无持久化数据)
Future<void> restorePendingQueue() async {}
void startAutoSync() {
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
trySync();
}
});
}
void dispose() {
_connectivitySub?.cancel();
_connectivitySub = null;
}
String _encodeJson(Map<String, dynamic> data) {
try {
return jsonEncode(data);
} catch (_) {
return '{}';
}
}
Map<String, dynamic> _decodeJson(String json) {
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
}

View File

@@ -406,54 +406,6 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.34"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter

View File

@@ -30,8 +30,11 @@ dependencies:
# 连接检测
connectivity_plus: ^6.1.0
# 安全存储JWT 令牌持久化PIPL 合规
flutter_secure_storage: ^9.2.0
# 安全存储JWT 令牌持久化)
# 注意:flutter_secure_storage v9 的 web 插件使用 dart:html
# 不兼容 Flutter 3.44 的 Web 编译器。暂用 shared_preferences 替代。
# TODO: flutter_secure_storage 升级到 v10+ 后恢复
# flutter_secure_storage: ^9.2.0
# 手写引擎
perfect_freehand: ^1.0.0

View File

@@ -8,7 +8,6 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <isar_flutter_libs/isar_flutter_libs_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
@@ -19,8 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
IsarFlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(

View File

@@ -5,7 +5,6 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
file_selector_windows
flutter_secure_storage_windows
isar_flutter_libs
permission_handler_windows
share_plus