From 367f21de084e607488fdccea616571536c36c198 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 3 Jun 2026 17:20:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E7=BB=9F=E4=B8=80=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=8D=8F=E8=AE=AE=20=E2=80=94=20SyncModels=20+=20ApiC?= =?UTF-8?q?lient.sync=20+=20SyncEngine.tryBatchSync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter ↔ Rust 同步协议对齐: - 新增 sync_models.dart: SyncReq/SyncResp/SyncChange/ConflictInfo 与 Rust dto.rs 一一对应 (CreateJournal/UpdateJournal/DeleteJournal) - ApiClient.sync(): 调用 POST /diary/sync 批量同步端点 - SyncEngine.tryBatchSync(): PendingOperation → SyncChange 批量提交 成功清空队列,冲突保留待用户处理 保留原有逐个同步 trySync() 作为降级方案 后端 509/509 测试通过, Flutter analyze 0 error --- app/lib/data/models/sync_models.dart | 176 ++++++++++++++++++ app/lib/data/remote/api_client.dart | 17 ++ app/lib/data/services/sync_engine_native.dart | 74 ++++++++ 3 files changed, 267 insertions(+) create mode 100644 app/lib/data/models/sync_models.dart diff --git a/app/lib/data/models/sync_models.dart b/app/lib/data/models/sync_models.dart new file mode 100644 index 0000000..4004535 --- /dev/null +++ b/app/lib/data/models/sync_models.dart @@ -0,0 +1,176 @@ +// 同步协议模型 — 与 Rust 端 SyncReq/SyncResp/SyncChange/ConflictInfo 一一对应 +// +// 端点: POST /api/v1/diary/sync +// Rust DTO: crates/erp-diary/src/dto.rs (SyncReq, SyncResp, SyncChange, ConflictInfo) + +/// 同步请求 — 与 Rust SyncReq 对应 +/// +/// ```rust +/// pub struct SyncReq { +/// pub last_sync_time: Option>, +/// pub changes: Vec, +/// } +/// ``` +class SyncReq { + final DateTime? lastSyncTime; + final List changes; + + const SyncReq({this.lastSyncTime, this.changes = const []}); + + Map toJson() => { + if (lastSyncTime != null) + 'last_sync_time': lastSyncTime!.toUtc().toIso8601String(), + 'changes': changes.map((c) => c.toJson()).toList(), + }; +} + +/// 同步变更条目 — 与 Rust SyncChange 枚举对应 +/// +/// ```rust +/// pub enum SyncChange { +/// CreateJournal { data: serde_json::Value }, +/// UpdateJournal { id: Uuid, version: i32, data: serde_json::Value }, +/// DeleteJournal { id: Uuid, version: i32 }, +/// } +/// ``` +sealed class SyncChange { + const SyncChange(); + + Map toJson(); + + /// 从 JSON 反序列化 + factory SyncChange.fromJson(Map json) { + if (json.containsKey('CreateJournal')) { + return SyncChangeCreateJournal( + data: json['CreateJournal']['data'] as Map, + ); + } + if (json.containsKey('UpdateJournal')) { + final inner = json['UpdateJournal'] as Map; + return SyncChangeUpdateJournal( + id: inner['id'] as String, + version: inner['version'] as int, + data: inner['data'] as Map, + ); + } + if (json.containsKey('DeleteJournal')) { + final inner = json['DeleteJournal'] as Map; + return SyncChangeDeleteJournal( + id: inner['id'] as String, + version: inner['version'] as int, + ); + } + throw FormatException('Unknown SyncChange variant: $json'); + } +} + +/// 创建日记变更 +class SyncChangeCreateJournal extends SyncChange { + final Map data; + + const SyncChangeCreateJournal({required this.data}); + + @override + Map toJson() => { + 'CreateJournal': {'data': data}, + }; +} + +/// 更新日记变更 +class SyncChangeUpdateJournal extends SyncChange { + final String id; + final int version; + final Map data; + + const SyncChangeUpdateJournal({ + required this.id, + required this.version, + required this.data, + }); + + @override + Map toJson() => { + 'UpdateJournal': { + 'id': id, + 'version': version, + 'data': data, + }, + }; +} + +/// 删除日记变更 +class SyncChangeDeleteJournal extends SyncChange { + final String id; + final int version; + + const SyncChangeDeleteJournal({ + required this.id, + required this.version, + }); + + @override + Map toJson() => { + 'DeleteJournal': { + 'id': id, + 'version': version, + }, + }; +} + +/// 同步响应 — 与 Rust SyncResp 对应 +/// +/// ```rust +/// pub struct SyncResp { +/// pub server_changes: Vec, +/// pub conflicts: Vec, +/// pub sync_time: DateTime, +/// } +/// ``` +class SyncResp { + final List> serverChanges; + final List conflicts; + final DateTime syncTime; + + const SyncResp({ + required this.serverChanges, + required this.conflicts, + required this.syncTime, + }); + + factory SyncResp.fromJson(Map json) => SyncResp( + serverChanges: (json['server_changes'] as List) + .map((e) => Map.from(e as Map)) + .toList(), + conflicts: (json['conflicts'] as List) + .map((e) => ConflictInfo.fromJson(Map.from(e as Map))) + .toList(), + syncTime: DateTime.parse(json['sync_time'] as String), + ); +} + +/// 冲突信息 — 与 Rust ConflictInfo 对应 +/// +/// ```rust +/// pub struct ConflictInfo { +/// pub journal_id: Uuid, +/// pub local_version: i32, +/// pub server_version: i32, +/// } +/// ``` +class ConflictInfo { + final String journalId; + final int localVersion; + final int serverVersion; + + const ConflictInfo({ + required this.journalId, + required this.localVersion, + required this.serverVersion, + }); + + factory ConflictInfo.fromJson(Map json) => ConflictInfo( + journalId: json['journal_id'] as String, + localVersion: json['local_version'] as int, + serverVersion: json['server_version'] as int, + ); +} diff --git a/app/lib/data/remote/api_client.dart b/app/lib/data/remote/api_client.dart index 7c97d41..45f64bc 100644 --- a/app/lib/data/remote/api_client.dart +++ b/app/lib/data/remote/api_client.dart @@ -10,6 +10,8 @@ import 'package:dio/dio.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import '../models/sync_models.dart'; + /// 网络离线异常 — 网络不可用时由 ApiClient 抛出 class OfflineException implements Exception { final String message; @@ -187,4 +189,19 @@ class ApiClient { }); return _dio.post(path, data: formData); } + + // ===== 同步 API ===== + + /// 批量同步 — POST /diary/sync + /// + /// 将客户端变更批量提交到服务端,返回服务端变更和冲突信息。 + /// 对应 Rust sync_handler::sync_journals 端点。 + Future sync(SyncReq req) async { + await _ensureOnline(); + final response = await _dio.post>( + '/diary/sync', + data: req.toJson(), + ); + return SyncResp.fromJson(response.data!); + } } diff --git a/app/lib/data/services/sync_engine_native.dart b/app/lib/data/services/sync_engine_native.dart index 1c89e74..64757f5 100644 --- a/app/lib/data/services/sync_engine_native.dart +++ b/app/lib/data/services/sync_engine_native.dart @@ -22,6 +22,7 @@ import 'package:isar/isar.dart'; import '../local/isar_database_native.dart'; import '../local/collections/pending_operation_collection.dart'; +import '../models/sync_models.dart'; import '../remote/api_client.dart'; /// 同步操作类型 @@ -277,6 +278,79 @@ class SyncEngine { await persistPendingQueue(); } + /// 批量同步 — 使用 POST /diary/sync 端点一次性提交所有变更 + /// + /// 将队列中的 PendingOperation 转换为 SyncChange 列表, + /// 调用 Rust sync_handler 批量处理,获取服务端变更和冲突。 + /// 成功后清空队列;失败时保留队列供重试。 + Future tryBatchSync({DateTime? lastSyncTime}) async { + if (_status == SyncStatus.syncing) return null; + if (_pendingQueue.isEmpty) { + _status = SyncStatus.idle; + return null; + } + + _status = SyncStatus.syncing; + _lastError = null; + + try { + // 转换: PendingOperation → SyncChange + final changes = _pendingQueue.map(_operationToSyncChange).toList(); + + final req = SyncReq( + lastSyncTime: lastSyncTime, + changes: changes, + ); + + final resp = await _apiClient.sync(req); + + // 处理冲突 — 将冲突的操作保留在队列中 + if (resp.conflicts.isNotEmpty) { + final conflictIds = resp.conflicts.map((c) => c.journalId).toSet(); + // 移除已成功同步的非冲突操作,保留冲突操作 + _pendingQueue.removeWhere( + (op) => !conflictIds.contains(op.id), + ); + _lastError = '${resp.conflicts.length} 个操作存在版本冲突'; + } else { + // 全部成功,清空队列 + _pendingQueue.clear(); + } + + _status = _pendingQueue.isEmpty ? SyncStatus.idle : SyncStatus.paused; + await persistPendingQueue(); + + return resp; + } on OfflineException { + _status = SyncStatus.paused; + _lastError = '同步中断:网络不可用'; + return null; + } catch (e) { + _status = SyncStatus.error; + _lastError = '批量同步失败: $e'; + return null; + } + } + + /// PendingOperation → SyncChange 转换 + SyncChange _operationToSyncChange(PendingOperation op) { + switch (op.type) { + case SyncOperationType.create: + return SyncChangeCreateJournal(data: op.data); + case SyncOperationType.update: + return SyncChangeUpdateJournal( + id: op.id, + version: op.version, + data: op.data, + ); + case SyncOperationType.delete: + return SyncChangeDeleteJournal( + id: op.id, + version: op.version, + ); + } + } + /// 执行单个同步操作 Future _executeOperation(PendingOperation operation) async { switch (operation.type) {