fix: improve transfer recognition and local data safety

This commit is contained in:
2026-07-26 21:36:46 +08:00
parent 7df25edd96
commit 882293343e
21 changed files with 505 additions and 128 deletions
+115 -3
View File
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
import 'package:miaoji_zhang/shared/services/local_database.dart';
class OnlineFeatureRequiredException implements Exception {
@@ -25,6 +26,11 @@ class SessionStore extends ChangeNotifier {
bool _cloudSyncEnabled = true;
bool _needsReauth = false;
bool _aiEnabled = false;
String? _backendScope;
String? _accountNamespace;
Map<String, dynamic>? _pendingAccountSnapshot;
String? _pendingAccountNamespace;
String? _lastAccountNamespace;
bool get isGuest => _mode == 'guest';
bool get isAccount => _mode == 'account' && _userId != null;
@@ -38,7 +44,9 @@ class SessionStore extends ChangeNotifier {
String? get nickname => _nickname;
String get appMode => _appMode;
bool get onboardingDone => _onboardingDone;
String get namespace => isGuest ? 'guest' : 'user_${_userId ?? 'none'}';
String? get backendScope => _backendScope;
String get namespace =>
isGuest ? 'guest' : _accountNamespace ?? 'user_${_userId ?? 'none'}';
Future<void> initialize() async {
_preferences ??= await SharedPreferences.getInstance();
@@ -51,9 +59,25 @@ class SessionStore extends ChangeNotifier {
_onboardingDone = preferences.getBool('session_onboarding_done') ?? true;
_cloudSyncEnabled = preferences.getBool('session_cloud_sync') ?? true;
_needsReauth = preferences.getBool('session_needs_reauth') ?? false;
_backendScope = preferences.getString('session_backend_scope');
_accountNamespace = preferences.getString('session_account_namespace');
_pendingAccountNamespace = preferences.getString(
'pending_account_archive_namespace',
);
_lastAccountNamespace = preferences.getString('last_account_namespace');
_aiEnabled =
preferences.getBool('session_ai_enabled') ?? (_mode == 'account');
if (hasSession) {
if (isAccount) {
_accountNamespace ??= 'user_$_userId';
if (_backendScope != BackendIdentity.scope) {
// Legacy builds did not bind local data to a backend. Keep that
// database available locally, but require an explicit login before
// any sync can run against the currently configured server.
_needsReauth = true;
await preferences.setBool('session_needs_reauth', true);
}
}
await LocalDatabase.instance.openNamespace(namespace);
} else if (_mode != 'none') {
_mode = 'none';
@@ -72,6 +96,9 @@ class SessionStore extends ChangeNotifier {
_cloudSyncEnabled = false;
_needsReauth = false;
_aiEnabled = false;
_backendScope = null;
_accountNamespace = null;
_pendingAccountSnapshot = null;
await preferences.setString('session_mode', _mode);
await preferences.remove('session_user_id');
await preferences.setString('session_username', _username!);
@@ -80,6 +107,8 @@ class SessionStore extends ChangeNotifier {
await preferences.setBool('session_onboarding_done', true);
await preferences.setBool('session_cloud_sync', false);
await preferences.setBool('session_needs_reauth', false);
await preferences.remove('session_backend_scope');
await preferences.remove('session_account_namespace');
await LocalDatabase.instance.openNamespace(namespace);
notifyListeners();
}
@@ -93,6 +122,28 @@ class SessionStore extends ChangeNotifier {
required bool aiEnabled,
}) async {
final preferences = _preferences ??= await SharedPreferences.getInstance();
final targetNamespace = BackendIdentity.accountNamespace(userId);
final sourceNamespace =
isAccount && LocalDatabase.instance.namespace == namespace
? namespace
: _lastAccountNamespace;
if (sourceNamespace != null &&
sourceNamespace != targetNamespace &&
(LocalDatabase.instance.namespace == sourceNamespace ||
await LocalDatabase.instance.namespaceExists(sourceNamespace))) {
if (LocalDatabase.instance.namespace != sourceNamespace) {
await LocalDatabase.instance.openNamespace(sourceNamespace);
}
final snapshot = LocalDatabase.instance.exportSnapshot();
if (_snapshotHasUserData(snapshot)) {
_pendingAccountSnapshot = snapshot;
_pendingAccountNamespace = sourceNamespace;
await preferences.setString(
'pending_account_archive_namespace',
sourceNamespace,
);
}
}
_mode = 'account';
_userId = userId;
_username = username;
@@ -100,7 +151,12 @@ class SessionStore extends ChangeNotifier {
_appMode = appMode;
_onboardingDone = onboardingDone;
_aiEnabled = aiEnabled;
_cloudSyncEnabled = preferences.getBool('cloud_sync_user_$userId') ?? true;
_backendScope = BackendIdentity.scope;
_accountNamespace = targetNamespace;
_lastAccountNamespace = targetNamespace;
_cloudSyncEnabled =
preferences.getBool('cloud_sync_${BackendIdentity.scope}_$userId') ??
true;
_needsReauth = false;
await preferences.setString('session_mode', _mode);
await preferences.setInt('session_user_id', userId);
@@ -115,6 +171,9 @@ class SessionStore extends ChangeNotifier {
await preferences.setBool('session_ai_enabled', aiEnabled);
await preferences.setBool('session_cloud_sync', _cloudSyncEnabled);
await preferences.setBool('session_needs_reauth', false);
await preferences.setString('session_backend_scope', _backendScope!);
await preferences.setString('session_account_namespace', targetNamespace);
await preferences.setString('last_account_namespace', targetNamespace);
await LocalDatabase.instance.openNamespace(namespace);
notifyListeners();
}
@@ -165,7 +224,10 @@ class SessionStore extends ChangeNotifier {
_cloudSyncEnabled = enabled;
final preferences = _preferences ??= await SharedPreferences.getInstance();
await preferences.setBool('session_cloud_sync', enabled);
await preferences.setBool('cloud_sync_user_$_userId', enabled);
await preferences.setBool(
'cloud_sync_${_backendScope ?? BackendIdentity.scope}_$_userId',
enabled,
);
notifyListeners();
}
@@ -188,11 +250,16 @@ class SessionStore extends ChangeNotifier {
_cloudSyncEnabled = true;
_needsReauth = false;
_aiEnabled = false;
_backendScope = null;
_accountNamespace = null;
_pendingAccountSnapshot = null;
await preferences.setString('session_mode', 'none');
await preferences.remove('session_user_id');
await preferences.remove('session_username');
await preferences.remove('session_nickname');
await preferences.remove('session_needs_reauth');
await preferences.remove('session_backend_scope');
await preferences.remove('session_account_namespace');
LocalDatabase.instance.close();
notifyListeners();
}
@@ -202,4 +269,49 @@ class SessionStore extends ChangeNotifier {
throw OnlineFeatureRequiredException(message ?? '此功能需要登录并连接网络后使用');
}
}
Future<Map<String, dynamic>?> pendingAccountSnapshot() async {
final cached = _pendingAccountSnapshot;
if (cached != null) return cached;
final sourceNamespace = _pendingAccountNamespace;
final activeNamespace = LocalDatabase.instance.namespace;
if (sourceNamespace == null ||
activeNamespace == null ||
sourceNamespace == activeNamespace) {
return null;
}
try {
await LocalDatabase.instance.openNamespace(sourceNamespace);
final snapshot = LocalDatabase.instance.exportSnapshot();
if (_snapshotHasUserData(snapshot)) {
_pendingAccountSnapshot = snapshot;
}
} finally {
await LocalDatabase.instance.openNamespace(activeNamespace);
}
return _pendingAccountSnapshot;
}
Future<void> clearPendingAccountSnapshot() async {
_pendingAccountSnapshot = null;
_pendingAccountNamespace = null;
final preferences = _preferences ??= await SharedPreferences.getInstance();
await preferences.remove('pending_account_archive_namespace');
}
static bool _snapshotHasUserData(Map<String, dynamic> snapshot) {
final transactions = snapshot['transactions'] as List<dynamic>? ?? const [];
final budgets = snapshot['budgets'] as List<dynamic>? ?? const [];
final ledgers = snapshot['ledgers'] as List<dynamic>? ?? const [];
final categories = snapshot['categories'] as List<dynamic>? ?? const [];
return transactions.any(
(value) => (value as Map<String, dynamic>)['isDeleted'] != true,
) ||
budgets.isNotEmpty ||
ledgers.length > 1 ||
categories.any(
(value) => (value as Map<String, dynamic>)['isCustom'] == true,
);
}
}