import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/services/local_database.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; enum CloudSyncPhase { idle, syncing, synced, pending, offline, disabled, reauthenticate, failed, } class SyncService extends ChangeNotifier { SyncService._(); static final instance = SyncService._(); static final conflictRevision = ValueNotifier(0); final _dio = ApiClient.instance.dio; Future? _running; CloudSyncPhase _phase = CloudSyncPhase.idle; DateTime? _lastSyncedAt; int _pendingCount = 0; bool _connectivityInterrupted = false; bool _hadFailures = false; int get conflictCount => LocalDatabase.instance.conflicts().length; CloudSyncPhase get phase => _phase; DateTime? get lastSyncedAt => _lastSyncedAt; int get pendingCount => _pendingCount; String get statusLabel => switch (_phase) { CloudSyncPhase.syncing => '正在同步', CloudSyncPhase.synced => '已同步', CloudSyncPhase.pending => '$_pendingCount 项待同步', CloudSyncPhase.offline => '网络不可用,$_pendingCount 项待同步', CloudSyncPhase.disabled => '云同步已关闭', CloudSyncPhase.reauthenticate => '登录已过期,等待重新登录', CloudSyncPhase.failed => '同步失败,点击重试', CloudSyncPhase.idle => _pendingCount > 0 ? '$_pendingCount 项待同步' : '等待同步', }; void refreshLocalStatus() { final session = SessionStore.instance; if (!session.isAccount || !session.cloudSyncEnabled) { _setPhase(CloudSyncPhase.disabled, 0); return; } if (session.needsReauth) { _setPhase(CloudSyncPhase.reauthenticate, _safePendingCount()); return; } final pending = _safePendingCount(); _setPhase( pending > 0 ? CloudSyncPhase.pending : CloudSyncPhase.idle, pending, ); } Future run() { final session = SessionStore.instance; if (!session.isAccount || !session.cloudSyncEnabled) { _setPhase(CloudSyncPhase.disabled, 0); return Future.value(); } if (session.needsReauth) { _setPhase(CloudSyncPhase.reauthenticate, _safePendingCount()); return Future.value(); } final active = _running; if (active != null) return active; _connectivityInterrupted = false; _hadFailures = false; _setPhase(CloudSyncPhase.syncing, _safePendingCount()); return _running = _execute().whenComplete(() => _running = null); } Future _execute() async { try { await _runCore(); if (SessionStore.instance.needsReauth) { _setPhase(CloudSyncPhase.reauthenticate, _safePendingCount()); return; } final pending = _safePendingCount(); if (_connectivityInterrupted) { _setPhase(CloudSyncPhase.offline, pending); } else if (_hadFailures) { _setPhase(CloudSyncPhase.failed, pending); } else if (pending > 0) { _setPhase(CloudSyncPhase.pending, pending); } else { _lastSyncedAt = DateTime.now(); _setPhase(CloudSyncPhase.synced, 0); } } catch (error) { final pending = _safePendingCount(); _setPhase( isConnectivityError(error) ? CloudSyncPhase.offline : CloudSyncPhase.failed, pending, ); } } int _safePendingCount() { try { return LocalDatabase.instance.pendingSync().length; } catch (_) { return 0; } } void _setPhase(CloudSyncPhase phase, int pending) { if (_phase == phase && _pendingCount == pending) return; _phase = phase; _pendingCount = pending; notifyListeners(); } Future _runCore() async { final database = LocalDatabase.instance; for (final item in database.pendingSync()) { final queueId = item['id'] as int; try { await _process(item); database.completeSync(queueId); } on _DependencyPending { continue; } on DioException catch (error) { if (error.response?.statusCode == 409) { final data = error.response?.data; final remote = data is Map && data['server'] is Map ? Map.from(data['server'] as Map) : {}; database.addConflict( item['entityType'] as String, item['entityId'] as int, item['operation'] as String, item['payload'] as Map, remote, ); database.completeSync(queueId); conflictRevision.value++; continue; } if (error.response?.statusCode == 401) { await SessionStore.instance.markNeedsReauth(); return; } if (isConnectivityError(error)) { _connectivityInterrupted = true; return; } database.failSync(queueId); _hadFailures = true; } catch (_) { database.failSync(queueId); _hadFailures = true; } } await CurrentLedgerStore.instance.ensureLoaded(force: true); } Future _process(Map item) async { final entityType = item['entityType'] as String; final entityId = item['entityId'] as int; final operation = item['operation'] as String; final payload = Map.from( item['payload'] as Map, ); switch (entityType) { case 'ledger': await _syncLedger(entityId, operation, payload); return; case 'category': await _syncCategory(entityId, operation, payload); return; case 'transaction': await _syncTransaction(entityId, operation, payload); return; case 'budget': await _syncBudget(operation, payload); return; } } Future _syncLedger( int entityId, String operation, Map payload, ) async { if (operation == 'create') { final response = await _dio.post('/api/ledgers', data: payload); LocalDatabase.instance.remapLedger( entityId, Map.from(response.data as Map), ); return; } final remoteId = _resolvedId('ledger', entityId); if (operation == 'update') { await _dio.put('/api/ledgers/$remoteId', data: payload); } else if (operation == 'delete') { await _dio.delete('/api/ledgers/$remoteId'); } else if (operation == 'select') { await _dio.put('/api/ledgers/$remoteId/default'); } } Future _syncCategory( int entityId, String operation, Map payload, ) async { if (operation == 'create') { final response = await _dio.post('/api/categories', data: payload); LocalDatabase.instance.remapCategory( entityId, Map.from(response.data as Map), ); return; } if (operation == 'reorder') { final ids = (payload['categoryIds'] as List) .map((value) => _resolvedId('category', (value as num).toInt())) .toList(); await _dio.put( '/api/categories/reorder', data: {'type': payload['type'], 'categoryIds': ids}, ); return; } final remoteId = _resolvedId('category', entityId); if (operation == 'update') { await _dio.put('/api/categories/$remoteId', data: payload); } else if (operation == 'delete') { await _dio.delete('/api/categories/$remoteId'); } } Future _syncTransaction( int entityId, String operation, Map payload, ) async { final data = Map.from(payload); if (data['ledgerId'] is num) { data['ledgerId'] = _resolvedId( 'ledger', (data['ledgerId'] as num).toInt(), ); } if (data['categoryId'] is num) { data['categoryId'] = _resolvedId( 'category', (data['categoryId'] as num).toInt(), ); } if (operation == 'create') { final response = await _dio.post('/api/transactions', data: data); LocalDatabase.instance.replaceLocalTransaction( entityId, Map.from(response.data as Map), ); return; } final remoteId = _resolvedId('transaction', entityId); if (operation == 'update') { final response = await _dio.put( '/api/transactions/$remoteId', data: data, ); LocalDatabase.instance.cacheTransaction( Map.from(response.data as Map), ); } else if (operation == 'delete') { await _dio.delete( '/api/transactions/$remoteId', queryParameters: { if (data['baseUpdatedAt'] != null) 'baseUpdatedAt': data['baseUpdatedAt'], }, ); } else if (operation == 'restore') { final response = await _dio.post( '/api/transactions/$remoteId/restore', queryParameters: { if (data['baseUpdatedAt'] != null) 'baseUpdatedAt': data['baseUpdatedAt'], }, ); LocalDatabase.instance.cacheTransaction( Map.from(response.data as Map), ); } } Future _syncBudget( String operation, Map payload, ) async { final ledgerId = _resolvedId( 'ledger', (payload['ledgerId'] as num).toInt(), ); if (operation == 'batch') { final items = (payload['items'] as List).map((value) { final item = Map.from(value as Map); if (item['categoryId'] is num) { item['categoryId'] = _resolvedId( 'category', (item['categoryId'] as num).toInt(), ); } return item; }).toList(); await _dio.put( '/api/budgets/batch', queryParameters: { 'year': payload['year'], 'month': payload['month'], 'ledgerId': ledgerId, }, data: {'recurring': payload['recurring'], 'items': items}, ); return; } final categoryId = payload['categoryId'] is num ? _resolvedId('category', (payload['categoryId'] as num).toInt()) : null; await _dio.put( '/api/budgets', queryParameters: { 'year': payload['year'], 'month': payload['month'], 'ledgerId': ledgerId, }, data: { 'categoryId': categoryId, 'amount': payload['amount'], 'recurring': payload['recurring'], }, ); } int _resolvedId(String entityType, int value) { final resolved = LocalDatabase.instance.remoteId(entityType, value); if (resolved == null) throw const _DependencyPending(); return resolved; } Future resolveConflict( Map conflict, { required bool keepLocal, }) async { final database = LocalDatabase.instance; final remote = conflict['remote'] as Map; if (keepLocal && conflict['entityType'] == 'transaction') { final entityId = conflict['entityId'] as int; final remoteId = _resolvedId('transaction', entityId); final operation = conflict['operation'] as String; final payload = Map.from( conflict['local'] as Map, )..remove('baseUpdatedAt'); if (operation == 'update') { final response = await _dio.put( '/api/transactions/$remoteId', data: payload, ); database.cacheTransaction( Map.from(response.data as Map), ); } else if (operation == 'delete') { await _dio.delete('/api/transactions/$remoteId'); } else if (operation == 'restore') { final response = await _dio.post('/api/transactions/$remoteId/restore'); database.cacheTransaction( Map.from(response.data as Map), ); } } else if (!keepLocal && remote.isNotEmpty) { database.cacheTransaction(remote); } database.resolveConflict(conflict['id'] as int); conflictRevision.value++; } } class _DependencyPending implements Exception { const _DependencyPending(); }