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
+14 -42
View File
@@ -1,9 +1,7 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
class ApiClient {
@@ -11,24 +9,14 @@ class ApiClient {
static final ApiClient instance = ApiClient._();
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
static const _legacyTokenKey = 'auth_token';
static final _tokenKey = 'auth_token_${BackendIdentity.scope}';
static final sessionExpired = ValueNotifier<int>(0);
static bool _handlingUnauthorized = false;
static const _internalBuild = bool.fromEnvironment('INTERNAL_BUILD');
static const _internalTestBaseUrl = 'https://lt.frp-say.com:38012';
static const _configuredBaseUrl = String.fromEnvironment('API_BASE_URL');
static const _temporaryFrpHost = 'lt.frp-say.com';
static const _temporaryFrpPort = 38012;
static const _temporaryFrpCertificateSha1 =
'509C3210161E72FB9DA5183D3D2152F63871C31F';
static const String baseUrl = _configuredBaseUrl != ''
? _configuredBaseUrl
: _internalBuild
? _internalTestBaseUrl
: 'https://api.invalid';
static const String baseUrl = BackendIdentity.baseUrl;
static bool get isInternalBuild => _internalBuild;
static bool get isInternalBuild => BackendIdentity.internalBuild;
late final Dio dio = _createDio();
@@ -77,35 +65,19 @@ class ApiClient {
),
);
final apiUri = Uri.tryParse(baseUrl);
if (_internalBuild &&
apiUri?.scheme == 'https' &&
apiUri?.host == _temporaryFrpHost &&
apiUri?.port == _temporaryFrpPort) {
client.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final httpClient = HttpClient();
httpClient.badCertificateCallback = (certificate, host, port) {
final certificateSha1 = certificate.sha1
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
return host == _temporaryFrpHost &&
port == _temporaryFrpPort &&
certificateSha1 == _temporaryFrpCertificateSha1;
};
return httpClient;
},
);
}
return client;
}
Future<void> saveToken(String token) =>
_storage.write(key: _tokenKey, value: token);
Future<void> saveToken(String token) async {
await _storage.write(key: _tokenKey, value: token);
await _storage.delete(key: _legacyTokenKey);
}
Future<String?> readToken() => _storage.read(key: _tokenKey);
Future<void> clearToken() => _storage.delete(key: _tokenKey);
Future<void> clearToken() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _legacyTokenKey);
}
}
bool isConnectivityError(Object error) =>
+23 -6
View File
@@ -53,10 +53,20 @@ class UserProfile {
}) : aiCompanion = null;
}
class AuthLoginResult {
final bool accountClosureCancelled;
final UserProfile profile;
const AuthLoginResult({
required this.accountClosureCancelled,
required this.profile,
});
}
class AuthApi {
static final _dio = ApiClient.instance.dio;
static Future<void> register(
static Future<UserProfile> register(
String username,
String password, {
required bool agreedToTerms,
@@ -70,21 +80,28 @@ class AuthApi {
},
);
await ApiClient.instance.saveToken(response.data['token'] as String);
await me();
return me(forceRemote: true);
}
static Future<bool> login(String username, String password) async {
static Future<AuthLoginResult> login(String username, String password) async {
final response = await _dio.post(
'/api/auth/login',
data: {'username': username, 'password': password},
);
await ApiClient.instance.saveToken(response.data['token'] as String);
return response.data['accountClosureCancelled'] as bool? ?? false;
final profile = await me(forceRemote: true);
return AuthLoginResult(
accountClosureCancelled:
response.data['accountClosureCancelled'] as bool? ?? false,
profile: profile,
);
}
static Future<UserProfile> me() async {
static Future<UserProfile> me({bool forceRemote = false}) async {
final session = SessionStore.instance;
if (session.isGuest || (session.isAccount && session.shouldUseLocalOnly)) {
if (!forceRemote &&
(session.isGuest ||
(session.isAccount && session.shouldUseLocalOnly))) {
return _localProfile();
}
try {
@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
class BackendIdentity {
BackendIdentity._();
static const internalBuild = bool.fromEnvironment('INTERNAL_BUILD');
static const configuredBaseUrl = String.fromEnvironment('API_BASE_URL');
static const String baseUrl = configuredBaseUrl != ''
? configuredBaseUrl
: 'https://api.invalid';
static final String scope = scopeForBaseUrl(baseUrl);
static String accountNamespace(int userId) => 'account_${scope}_$userId';
static String scopeForBaseUrl(String value) => sha256
.convert(utf8.encode(_normalizedBaseUrl(value)))
.toString()
.substring(0, 16);
static String accountNamespaceFor(String baseUrl, int userId) =>
'account_${scopeForBaseUrl(baseUrl)}_$userId';
static String _normalizedBaseUrl(String value) {
final uri = Uri.tryParse(value.trim());
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return value.trim().toLowerCase();
}
final path = uri.path == '/'
? ''
: uri.path.replaceFirst(RegExp(r'/+$'), '');
return uri
.replace(path: path, query: null, fragment: null)
.toString()
.toLowerCase();
}
}
@@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:crypto/crypto.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/shanghai_time.dart';
@@ -17,12 +20,23 @@ class GuestMergeService {
static final _dio = ApiClient.instance.dio;
static Future<GuestMergeResult> merge(Map<String, dynamic> snapshot) async {
final ledgerResponse = await _dio.post(
'/api/ledgers',
data: {'name': '游客数据', 'iconKey': 'wallet'},
);
final ledgerId = (ledgerResponse.data['id'] as num).toInt();
static Future<GuestMergeResult> merge(
Map<String, dynamic> snapshot, {
String ledgerName = '游客数据',
}) async {
final ledgersResponse = await _dio.get('/api/ledgers');
final existingLedger = (ledgersResponse.data as List<dynamic>)
.cast<Map<String, dynamic>>()
.where((item) => item['name'] == ledgerName)
.firstOrNull;
final ledgerId = existingLedger == null
? ((await _dio.post(
'/api/ledgers',
data: {'name': ledgerName, 'iconKey': 'wallet'},
)).data['id']
as num)
.toInt()
: (existingLedger['id'] as num).toInt();
final categoryMap = <int, int>{};
final available = <Map<String, dynamic>>[];
for (final type in ['expense', 'income']) {
@@ -37,6 +51,7 @@ class GuestMergeService {
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
final category = value as Map<String, dynamic>;
if (category['isDeleted'] == true) continue;
final existing = available.where(
(item) =>
item['type'] == category['type'] &&
@@ -45,6 +60,8 @@ class GuestMergeService {
Map<String, dynamic> remote;
if (existing.isNotEmpty) {
remote = existing.first;
} else if (category['isCustom'] == false) {
continue;
} else {
final response = await _dio.post(
'/api/categories',
@@ -66,14 +83,15 @@ class GuestMergeService {
final oldId = (transaction['categoryId'] as num).toInt();
final custom = categoryMap[oldId];
if (custom != null) return custom;
final categoryType = _transactionCategoryType(transaction);
final exact = available.where(
(item) =>
item['type'] == transaction['type'] &&
item['type'] == categoryType &&
item['name'] == transaction['categoryName'],
);
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
final fallback = available.firstWhere(
(item) => item['type'] == transaction['type'] && item['name'] == '其他',
(item) => item['type'] == categoryType && item['name'] == '其他',
);
return (fallback['id'] as num).toInt();
}
@@ -82,6 +100,7 @@ class GuestMergeService {
for (final value
in snapshot['transactions'] as List<dynamic>? ?? const []) {
final transaction = value as Map<String, dynamic>;
if (transaction['isDeleted'] == true) continue;
await _dio.post(
'/api/transactions',
data: {
@@ -91,9 +110,12 @@ class GuestMergeService {
'amount': transaction['amount'],
'note': transaction['note'],
'paymentMethod': transaction['paymentMethod'],
'transferDirection': transaction['transferDirection'],
'counterparty': transaction['counterparty'],
'occurredAt': transaction['occurredAt'],
'source': 'manual',
'sourceText': null,
'clientRequestId': _importRequestId(transaction),
},
);
transactionCount++;
@@ -136,14 +158,42 @@ class GuestMergeService {
) {
final transaction = (snapshot['transactions'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>()
.where((item) => item['categoryId'] == oldCategoryId)
.where(
(item) =>
item['categoryId'] == oldCategoryId && item['isDeleted'] != true,
)
.firstOrNull;
if (transaction == null) return null;
final match = available.where(
(item) =>
item['type'] == transaction['type'] &&
item['type'] == _transactionCategoryType(transaction) &&
item['name'] == transaction['categoryName'],
);
return match.isEmpty ? null : (match.first['id'] as num).toInt();
}
static String _transactionCategoryType(Map<String, dynamic> transaction) {
if (transaction['type'] != 'transfer') {
return transaction['type'] as String;
}
return transaction['transferDirection'] == 'in' ? 'income' : 'expense';
}
static String _importRequestId(Map<String, dynamic> transaction) {
final existing = transaction['clientRequestId']?.toString().trim();
if (existing != null && existing.isNotEmpty && existing.length <= 64) {
return existing;
}
final identity = jsonEncode([
transaction['id'],
transaction['ledgerId'],
transaction['categoryId'],
transaction['type'],
transaction['transferDirection'],
transaction['amount'],
transaction['occurredAt'],
transaction['note'],
]);
return 'local-import-${sha256.convert(utf8.encode(identity)).toString().substring(0, 48)}';
}
}
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
@@ -28,6 +29,13 @@ class LocalDatabase {
String? _namespace;
String? get namespace => _namespace;
Future<bool> namespaceExists(String namespace) async {
final safeNamespace = namespace.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_');
final directory = await getApplicationSupportDirectory();
return File(p.join(directory.path, 'jizhi_$safeNamespace.db')).exists();
}
Database get _db {
final database = _database;
if (database == null) throw StateError('本地数据库尚未初始化');
@@ -766,9 +774,16 @@ class LocalDatabase {
void replaceLocalTransaction(int localId, Map<String, dynamic> remote) {
final remoteId = (remote['id'] as num).toInt();
_db.execute('DELETE FROM transactions WHERE id = ?', [localId]);
_saveIdMap('transaction', localId, remoteId);
cacheTransaction(remote);
_db.execute('BEGIN');
try {
cacheTransaction(remote);
_saveIdMap('transaction', localId, remoteId);
_db.execute('DELETE FROM transactions WHERE id = ?', [localId]);
_db.execute('COMMIT');
} catch (_) {
_db.execute('ROLLBACK');
rethrow;
}
}
void enqueueSync(
+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,
);
}
}