Files
jizhi/frontend/lib/shared/services/sync_service.dart
T
2026-07-24 23:11:20 +08:00

400 lines
12 KiB
Dart

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<int>(0);
final _dio = ApiClient.instance.dio;
Future<void>? _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<void> 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<void> _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<void> _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<String, dynamic>.from(data['server'] as Map)
: <String, dynamic>{};
database.addConflict(
item['entityType'] as String,
item['entityId'] as int,
item['operation'] as String,
item['payload'] as Map<String, dynamic>,
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<void> _process(Map<String, dynamic> item) async {
final entityType = item['entityType'] as String;
final entityId = item['entityId'] as int;
final operation = item['operation'] as String;
final payload = Map<String, dynamic>.from(
item['payload'] as Map<String, dynamic>,
);
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<void> _syncLedger(
int entityId,
String operation,
Map<String, dynamic> payload,
) async {
if (operation == 'create') {
final response = await _dio.post('/api/ledgers', data: payload);
LocalDatabase.instance.remapLedger(
entityId,
Map<String, dynamic>.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<void> _syncCategory(
int entityId,
String operation,
Map<String, dynamic> payload,
) async {
if (operation == 'create') {
final response = await _dio.post('/api/categories', data: payload);
LocalDatabase.instance.remapCategory(
entityId,
Map<String, dynamic>.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<void> _syncTransaction(
int entityId,
String operation,
Map<String, dynamic> payload,
) async {
final data = Map<String, dynamic>.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<String, dynamic>.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<String, dynamic>.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<String, dynamic>.from(response.data as Map),
);
}
}
Future<void> _syncBudget(
String operation,
Map<String, dynamic> 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<String, dynamic>.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<void> resolveConflict(
Map<String, dynamic> conflict, {
required bool keepLocal,
}) async {
final database = LocalDatabase.instance;
final remote = conflict['remote'] as Map<String, dynamic>;
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<String, dynamic>.from(
conflict['local'] as Map<String, dynamic>,
)..remove('baseUpdatedAt');
if (operation == 'update') {
final response = await _dio.put(
'/api/transactions/$remoteId',
data: payload,
);
database.cacheTransaction(
Map<String, dynamic>.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<String, dynamic>.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();
}