From 882293343e6a42a166d89c2550fca1777600d5bb Mon Sep 17 00:00:00 2001 From: nanxun Date: Sun, 26 Jul 2026 21:36:46 +0800 Subject: [PATCH] fix: improve transfer recognition and local data safety --- frontend/android/app/build.gradle.kts | 13 +- .../internal/res/raw/sakura_frp_test_ca.pem | 20 --- .../xml/network_security_config_internal.xml | 9 +- .../kotlin/com/nx/miaoji/LocalPaymentOcr.kt | 8 +- .../miaoji/ScreenshotAccessibilityService.kt | 10 +- .../kotlin/com/nx/miaoji/PaymentParserTest.kt | 9 ++ .../features/auth/local_data_recovery.dart | 70 +++++++++++ .../lib/features/auth/pages/login_page.dart | 32 +---- .../features/auth/pages/register_page.dart | 26 +++- frontend/lib/shared/api/api_client.dart | 56 +++------ frontend/lib/shared/api/auth_api.dart | 29 ++++- frontend/lib/shared/api/backend_identity.dart | 40 ++++++ .../shared/services/guest_merge_service.dart | 70 +++++++++-- .../lib/shared/services/local_database.dart | 21 +++- .../lib/shared/services/session_store.dart | 118 +++++++++++++++++- frontend/pubspec.yaml | 2 +- frontend/test/api_models_test.dart | 24 ++++ .../test/local_transfer_database_test.dart | 25 ++++ frontend/test/visual_contract_test.dart | 15 +++ release-notes/JiZhi-1.2.5-internal-134.md | 14 +++ release-notes/JiZhi-1.2.5-internal-135.md | 22 ++++ 21 files changed, 505 insertions(+), 128 deletions(-) delete mode 100644 frontend/android/app/src/internal/res/raw/sakura_frp_test_ca.pem create mode 100644 frontend/lib/features/auth/local_data_recovery.dart create mode 100644 frontend/lib/shared/api/backend_identity.dart create mode 100644 release-notes/JiZhi-1.2.5-internal-134.md create mode 100644 release-notes/JiZhi-1.2.5-internal-135.md diff --git a/frontend/android/app/build.gradle.kts b/frontend/android/app/build.gradle.kts index 8d47be9..44fe6ff 100644 --- a/frontend/android/app/build.gradle.kts +++ b/frontend/android/app/build.gradle.kts @@ -1,4 +1,5 @@ import java.io.FileInputStream +import java.net.URI import java.util.Base64 import java.util.Properties @@ -120,15 +121,21 @@ val internalReleaseRequested = gradle.startParameter.taskNames.any { requestedTa taskName.contains("InternalRelease", ignoreCase = true) } if (internalReleaseRequested) { - val expectedApiUrl = "https://lt.frp-say.com:38012" if (dartDefines["INTERNAL_BUILD"] != "true") { throw GradleException( "Internal release requires --dart-define=INTERNAL_BUILD=true", ) } - if (dartDefines["API_BASE_URL"] != expectedApiUrl) { + val apiBaseUrl = dartDefines["API_BASE_URL"]?.trim().orEmpty() + val validApiBaseUrl = runCatching { + val uri = URI(apiBaseUrl) + uri.scheme in setOf("http", "https") && + !uri.host.isNullOrBlank() && + uri.userInfo == null + }.getOrDefault(false) + if (!validApiBaseUrl) { throw GradleException( - "Internal release requires --dart-define=API_BASE_URL=$expectedApiUrl", + "Internal release requires an explicit HTTP(S) --dart-define=API_BASE_URL=", ) } if (dartDefines["APP_VERSION"].isNullOrBlank()) { diff --git a/frontend/android/app/src/internal/res/raw/sakura_frp_test_ca.pem b/frontend/android/app/src/internal/res/raw/sakura_frp_test_ca.pem deleted file mode 100644 index 5597592..0000000 --- a/frontend/android/app/src/internal/res/raw/sakura_frp_test_ca.pem +++ /dev/null @@ -1,20 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDOzCCAiOgAwIBAgIELZVPvzANBgkqhkiG9w0BAQsFADBQMQswCQYDVQQGEwJD -TjEtMCsGA1UEAxMkU2FrdXJhRnJwIEF1dG9tYXRpYyBUTFMgc24uNzY0NzU5OTk5 -MRIwEAYDVQQFEwk3NjQ3NTk5OTkwHhcNMjYwNzIwMDQ0NDM4WhcNMjcwNzIwMDQ0 -NDM4WjBQMQswCQYDVQQGEwJDTjEtMCsGA1UEAxMkU2FrdXJhRnJwIEF1dG9tYXRp -YyBUTFMgc24uNzY0NzU5OTk5MRIwEAYDVQQFEwk3NjQ3NTk5OTkwggEiMA0GCSqG -SIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0poNmZC+UWeDWGRjHot86lnA3J5Ueujml -GvapcAWUoQBNs1p+tlVedz1Dwo/Hq1u7tHdp3WBDax5naKLFKIz0kQIbCWxDrDTH -YNVQ9O7MHcf8dcDeayvo6q9z7PzhVXH/CJTlWx2634RGYbaU5jBjWX4fPFHyXJe0 -57zQqSIYrxZFozEd9NewELavkjCydI8atSFQNEDtlHziiXXLKlvUk7Sk6Drc2AxU -ZYk0/mz6GFbBIeIKLUeDlvoocHQvzC3kK+pn1Ggq+ky2DViGPkzekh8HYiOo+/Wv -VRXP9LHp17AsmvsQQM4y2NzSkX/la4R1pgq5OwHw6pHHCeWwvBWTAgMBAAGjHTAb -MBkGA1UdEQQSMBCCDmx0LmZycC1zYXkuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBg -I4grSvEqI2RRXlwbRjKlBnlBWgiw51sEuM7Sjq6P8t2IoaGJ5/F3PeT0XWwyTopg -hV5hNPU+wOKtVilyNqepljrPQ5XAm3uWp68aIHBuCxh3XOfjetPBPXoisY67AUHH -9gilTg24GjZ7koJGfiS0iHmfLtf1rEDUgCl27pX9e2NzMRr9aVAsRkdp6D3esXZL -e2aUTgBWRg45PU+26dd/JN738F85nqwdRc16MeTMDDbqyIVmcUnZZVPeGXAmTotk -b9pHfmaT3h3YqPDv/rWa9w+AMdc2mBIckPQ3ZV4H95xHEOR56uFQwkfiPQABlBKO -2oBDbJB1yhK++6XkhmLj ------END CERTIFICATE----- diff --git a/frontend/android/app/src/internal/res/xml/network_security_config_internal.xml b/frontend/android/app/src/internal/res/xml/network_security_config_internal.xml index f0fbbfd..45ea31b 100644 --- a/frontend/android/app/src/internal/res/xml/network_security_config_internal.xml +++ b/frontend/android/app/src/internal/res/xml/network_security_config_internal.xml @@ -5,11 +5,4 @@ - - lt.frp-say.com - - - - - - \ No newline at end of file + diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/LocalPaymentOcr.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/LocalPaymentOcr.kt index cd36f4b..278a331 100644 --- a/frontend/android/app/src/main/kotlin/com/nx/miaoji/LocalPaymentOcr.kt +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/LocalPaymentOcr.kt @@ -374,7 +374,7 @@ object LocalPaymentOcr { packageName = packageName, channel = "local_ocr", amountCents = selectedCents, - type = direction, + type = if (kind == "transfer") "transfer" else direction, merchant = merchant, orderId = orderId, occurredAtEpochMs = capturedAt, @@ -395,6 +395,10 @@ object LocalPaymentOcr { orderId, PaymentParser.sha256(allText), ), + transferDirection = direction.takeIf { kind == "transfer" }?.let { + if (it == "income") "in" else "out" + }, + counterparty = merchant.takeIf { kind == "transfer" }, ) val reason = when { highConfidence && amountSource == "expected" -> "expected_amount_fallback" @@ -480,4 +484,4 @@ object LocalPaymentOcr { private const val OCR_IDLE_CLOSE_SECONDS = 60L private const val MAX_SCREENSHOT_AGE_MS = 3_000L private const val MAX_AMOUNT = 100_000_000.0 -} \ No newline at end of file +} diff --git a/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt b/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt index b0784c8..d9af38d 100644 --- a/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt +++ b/frontend/android/app/src/main/kotlin/com/nx/miaoji/ScreenshotAccessibilityService.kt @@ -812,7 +812,7 @@ class ScreenshotAccessibilityService : AccessibilityService() { ) return } - val retryable = outcome.reason in setOf("no_text", "missing_amount") + val retryable = isRetryableOcrOutcome(outcome.reason) if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) { flow.retryCount += 1 scheduleVisualRecognition( @@ -1205,6 +1205,14 @@ class ScreenshotAccessibilityService : AccessibilityService() { internal fun shouldSuppressCompletedResult(resultSurfaceExited: Boolean): Boolean = !resultSurfaceExited + internal fun isRetryableOcrOutcome(reason: String): Boolean = reason in setOf( + "no_text", + "missing_amount", + "expected_amount_missing", + "no_success_status", + "payment_input_page", + ) + internal fun completedResultStartReason( hasObservedResult: Boolean, resultFingerprintChanged: Boolean, diff --git a/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt b/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt index 10734c8..ab1db68 100644 --- a/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt +++ b/frontend/android/app/src/test/kotlin/com/nx/miaoji/PaymentParserTest.kt @@ -307,6 +307,15 @@ class PaymentParserTest { assertFalse(ScreenshotAccessibilityService.shouldSuppressCompletedResult(true)) } + @Test + fun transientWechatOcrSurfacesAreRetriedInsteadOfFinallyRejected() { + assertTrue(ScreenshotAccessibilityService.isRetryableOcrOutcome("no_success_status")) + assertTrue(ScreenshotAccessibilityService.isRetryableOcrOutcome("payment_input_page")) + assertTrue(ScreenshotAccessibilityService.isRetryableOcrOutcome("missing_amount")) + assertFalse(ScreenshotAccessibilityService.isRetryableOcrOutcome("blocked_status")) + assertFalse(ScreenshotAccessibilityService.isRetryableOcrOutcome("history_page")) + } + @Test fun consecutiveIdenticalTransfersKeepDistinctFlowIdentities() { val first = paymentSignal( diff --git a/frontend/lib/features/auth/local_data_recovery.dart b/frontend/lib/features/auth/local_data_recovery.dart new file mode 100644 index 0000000..44a4ed1 --- /dev/null +++ b/frontend/lib/features/auth/local_data_recovery.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:miaoji_zhang/shared/api/api_client.dart'; +import 'package:miaoji_zhang/shared/services/guest_merge_service.dart'; +import 'package:miaoji_zhang/shared/services/session_store.dart'; +import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; + +Future offerLocalDataRecovery( + BuildContext context, { + Map? guestSnapshot, +}) async { + final session = SessionStore.instance; + Map? accountSnapshot; + try { + accountSnapshot = await session.pendingAccountSnapshot(); + } catch (error) { + if (context.mounted) { + _showMessage(context, '本机历史数据读取失败:${apiErrorMessage(error)}'); + } + } + if (accountSnapshot != null && context.mounted) { + final shouldMerge = await showJzConfirmSheet( + context, + title: '发现本机历史数据', + message: + '检测到旧账号或旧后端留下的本地账单。为防止不同服务器的数据互相覆盖,这些数据已单独保留。是否导入当前账号的“本机历史数据”账本?', + confirmLabel: '导入数据', + ); + if (shouldMerge) { + try { + final result = await GuestMergeService.merge( + accountSnapshot, + ledgerName: '本机历史数据', + ); + await session.clearPendingAccountSnapshot(); + if (context.mounted) { + _showMessage(context, '已导入 ${result.transactionCount} 笔本机历史账单'); + } + } catch (error) { + if (context.mounted) { + _showMessage(context, '本机历史数据暂未导入:${apiErrorMessage(error)}'); + } + } + } + } + + if (guestSnapshot?['hasData'] == true && context.mounted) { + final shouldMerge = await showJzConfirmSheet( + context, + title: '发现游客数据', + message: '是否将本机游客账单、分类和预算导入当前账号?数据会放入独立的“游客数据”账本,原游客数据仍保留在本机。', + confirmLabel: '导入数据', + ); + if (shouldMerge) { + try { + final result = await GuestMergeService.merge(guestSnapshot!); + if (context.mounted) { + _showMessage(context, '已导入 ${result.transactionCount} 笔游客账单'); + } + } catch (error) { + if (context.mounted) { + _showMessage(context, '游客数据暂未导入:${apiErrorMessage(error)}'); + } + } + } + } +} + +void _showMessage(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} diff --git a/frontend/lib/features/auth/pages/login_page.dart b/frontend/lib/features/auth/pages/login_page.dart index 2d12f92..04150ec 100644 --- a/frontend/lib/features/auth/pages/login_page.dart +++ b/frontend/lib/features/auth/pages/login_page.dart @@ -3,8 +3,8 @@ import 'package:go_router/go_router.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/auth_api.dart'; import 'package:miaoji_zhang/shared/api/config_api.dart'; +import 'package:miaoji_zhang/features/auth/local_data_recovery.dart'; import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; -import 'package:miaoji_zhang/shared/services/guest_merge_service.dart'; import 'package:miaoji_zhang/shared/services/local_database.dart'; import 'package:miaoji_zhang/shared/services/session_store.dart'; import 'package:miaoji_zhang/shared/services/push_service.dart'; @@ -59,36 +59,14 @@ class _LoginPageState extends State { : null; setState(() => _loading = true); try { - final closureCancelled = await AuthApi.login( - _user.text.trim(), - _pass.text, - ); - final profile = await AuthApi.me(); + final result = await AuthApi.login(_user.text.trim(), _pass.text); + final profile = result.profile; await PushService.instance.refresh(); await CurrentLedgerStore.instance.ensureLoaded(force: true); if (!mounted) return; - if (guestSnapshot?['hasData'] == true) { - final shouldMerge = await showJzConfirmSheet( - context, - title: '发现游客数据', - message: '是否将本机游客账单、分类和预算导入当前账号?数据会放入独立的“游客数据”账本,原游客数据仍保留在本机。', - confirmLabel: '导入数据', - ); - if (shouldMerge) { - try { - final result = await GuestMergeService.merge(guestSnapshot!); - if (mounted) { - _showMessage('已导入 ${result.transactionCount} 笔游客账单'); - } - } catch (error) { - if (mounted) { - _showMessage('游客数据暂未导入:${apiErrorMessage(error)}'); - } - } - } - } + await offerLocalDataRecovery(context, guestSnapshot: guestSnapshot); if (!mounted) return; - if (closureCancelled) _showMessage('已取消账号注销,欢迎回来'); + if (result.accountClosureCancelled) _showMessage('已取消账号注销,欢迎回来'); final destination = !profile.onboardingDone ? '/onboarding' : profile.appMode == 'ai' diff --git a/frontend/lib/features/auth/pages/register_page.dart b/frontend/lib/features/auth/pages/register_page.dart index cbf43c1..cac1794 100644 --- a/frontend/lib/features/auth/pages/register_page.dart +++ b/frontend/lib/features/auth/pages/register_page.dart @@ -2,6 +2,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/api/auth_api.dart'; +import 'package:miaoji_zhang/features/auth/local_data_recovery.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/push_service.dart'; +import 'package:miaoji_zhang/shared/services/session_store.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; class RegisterPage extends StatefulWidget { @@ -40,9 +45,26 @@ class _RegisterPageState extends State { } setState(() => _loading = true); try { - await AuthApi.register(username, _pass.text, agreedToTerms: _agreed); + final guestSnapshot = SessionStore.instance.isGuest + ? LocalDatabase.instance.guestSnapshot() + : null; + final profile = await AuthApi.register( + username, + _pass.text, + agreedToTerms: _agreed, + ); + await PushService.instance.refresh(); + await CurrentLedgerStore.instance.ensureLoaded(force: true); if (!mounted) return; - context.go('/onboarding'); + await offerLocalDataRecovery(context, guestSnapshot: guestSnapshot); + if (!mounted) return; + context.go( + !profile.onboardingDone + ? '/onboarding' + : profile.appMode == 'ai' + ? '/ai-mode' + : '/home', + ); } catch (error) { if (!mounted) return; _message(apiErrorMessage(error)); diff --git a/frontend/lib/shared/api/api_client.dart b/frontend/lib/shared/api/api_client.dart index d489605..e03ed03 100644 --- a/frontend/lib/shared/api/api_client.dart +++ b/frontend/lib/shared/api/api_client.dart @@ -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(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 saveToken(String token) => - _storage.write(key: _tokenKey, value: token); + Future saveToken(String token) async { + await _storage.write(key: _tokenKey, value: token); + await _storage.delete(key: _legacyTokenKey); + } + Future readToken() => _storage.read(key: _tokenKey); - Future clearToken() => _storage.delete(key: _tokenKey); + Future clearToken() async { + await _storage.delete(key: _tokenKey); + await _storage.delete(key: _legacyTokenKey); + } } bool isConnectivityError(Object error) => diff --git a/frontend/lib/shared/api/auth_api.dart b/frontend/lib/shared/api/auth_api.dart index a215217..f012355 100644 --- a/frontend/lib/shared/api/auth_api.dart +++ b/frontend/lib/shared/api/auth_api.dart @@ -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 register( + static Future 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 login(String username, String password) async { + static Future 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 me() async { + static Future 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 { diff --git a/frontend/lib/shared/api/backend_identity.dart b/frontend/lib/shared/api/backend_identity.dart new file mode 100644 index 0000000..28e9eae --- /dev/null +++ b/frontend/lib/shared/api/backend_identity.dart @@ -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(); + } +} diff --git a/frontend/lib/shared/services/guest_merge_service.dart b/frontend/lib/shared/services/guest_merge_service.dart index b0550e3..d1ece1a 100644 --- a/frontend/lib/shared/services/guest_merge_service.dart +++ b/frontend/lib/shared/services/guest_merge_service.dart @@ -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 merge(Map snapshot) async { - final ledgerResponse = await _dio.post( - '/api/ledgers', - data: {'name': '游客数据', 'iconKey': 'wallet'}, - ); - final ledgerId = (ledgerResponse.data['id'] as num).toInt(); + static Future merge( + Map snapshot, { + String ledgerName = '游客数据', + }) async { + final ledgersResponse = await _dio.get('/api/ledgers'); + final existingLedger = (ledgersResponse.data as List) + .cast>() + .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 = {}; final available = >[]; for (final type in ['expense', 'income']) { @@ -37,6 +51,7 @@ class GuestMergeService { for (final value in snapshot['categories'] as List? ?? const []) { final category = value as Map; + if (category['isDeleted'] == true) continue; final existing = available.where( (item) => item['type'] == category['type'] && @@ -45,6 +60,8 @@ class GuestMergeService { Map 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? ?? const []) { final transaction = value as Map; + 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? ?? const []) .cast>() - .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 transaction) { + if (transaction['type'] != 'transfer') { + return transaction['type'] as String; + } + return transaction['transferDirection'] == 'in' ? 'income' : 'expense'; + } + + static String _importRequestId(Map 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)}'; + } } diff --git a/frontend/lib/shared/services/local_database.dart b/frontend/lib/shared/services/local_database.dart index 5300620..ccea0cf 100644 --- a/frontend/lib/shared/services/local_database.dart +++ b/frontend/lib/shared/services/local_database.dart @@ -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 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 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( diff --git a/frontend/lib/shared/services/session_store.dart b/frontend/lib/shared/services/session_store.dart index 4abe5fb..178db90 100644 --- a/frontend/lib/shared/services/session_store.dart +++ b/frontend/lib/shared/services/session_store.dart @@ -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? _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 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?> 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 clearPendingAccountSnapshot() async { + _pendingAccountSnapshot = null; + _pendingAccountNamespace = null; + final preferences = _preferences ??= await SharedPreferences.getInstance(); + await preferences.remove('pending_account_archive_namespace'); + } + + static bool _snapshotHasUserData(Map snapshot) { + final transactions = snapshot['transactions'] as List? ?? const []; + final budgets = snapshot['budgets'] as List? ?? const []; + final ledgers = snapshot['ledgers'] as List? ?? const []; + final categories = snapshot['categories'] as List? ?? const []; + return transactions.any( + (value) => (value as Map)['isDeleted'] != true, + ) || + budgets.isNotEmpty || + ledgers.length > 1 || + categories.any( + (value) => (value as Map)['isCustom'] == true, + ); + } } diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index e3cc9d0..592a8a1 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.5+133 +version: 1.2.5+135 environment: sdk: ^3.11.0 diff --git a/frontend/test/api_models_test.dart b/frontend/test/api_models_test.dart index b6779be..61d3315 100644 --- a/frontend/test/api_models_test.dart +++ b/frontend/test/api_models_test.dart @@ -1,12 +1,36 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:miaoji_zhang/shared/api/backend_identity.dart'; import 'package:miaoji_zhang/shared/api/business_api.dart'; import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.dart'; import 'package:miaoji_zhang/shared/services/current_ledger_store.dart'; import 'package:miaoji_zhang/shared/widgets/app_icons.dart'; void main() { + test('本地账号命名空间同时隔离后端和用户', () { + final first = BackendIdentity.accountNamespaceFor( + 'https://api-a.example.com/', + 1, + ); + final normalized = BackendIdentity.accountNamespaceFor( + 'https://API-A.example.com', + 1, + ); + final otherBackend = BackendIdentity.accountNamespaceFor( + 'https://api-b.example.com', + 1, + ); + final otherUser = BackendIdentity.accountNamespaceFor( + 'https://api-a.example.com', + 2, + ); + + expect(first, normalized); + expect(first, isNot(otherBackend)); + expect(first, isNot(otherUser)); + }); + test( 'SSE framing preserves split UTF-8 characters and trailing events', () async { diff --git a/frontend/test/local_transfer_database_test.dart b/frontend/test/local_transfer_database_test.dart index e10eda2..b11c0e8 100644 --- a/frontend/test/local_transfer_database_test.dart +++ b/frontend/test/local_transfer_database_test.dart @@ -82,4 +82,29 @@ void main() { throwsStateError, ); }); + + test('云端回写失败时保留待同步的本地账单', () { + final database = LocalDatabase.inMemoryForTesting(); + addTearDown(database.close); + final local = database.createTransaction({ + 'ledgerId': 1, + 'categoryId': 1, + 'type': 'expense', + 'amount': 28, + 'occurredAt': '2026-07-25T03:00:00.000Z', + 'source': 'manual', + }); + final localId = local['id'] as int; + + expect( + () => database.replaceLocalTransaction(localId, { + ...local, + 'id': 42, + 'occurredAt': 'invalid-time', + }), + throwsFormatException, + ); + expect(database.transaction(localId), isNotNull); + expect(database.transaction(42), isNull); + }); } diff --git a/frontend/test/visual_contract_test.dart b/frontend/test/visual_contract_test.dart index 8a4a2fc..a03df91 100644 --- a/frontend/test/visual_contract_test.dart +++ b/frontend/test/visual_contract_test.dart @@ -3,6 +3,21 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('登录强制刷新远端资料并按后端隔离本地账号数据', () { + final auth = File('lib/shared/api/auth_api.dart').readAsStringSync(); + final session = File( + 'lib/shared/services/session_store.dart', + ).readAsStringSync(); + + expect(auth, contains('me(forceRemote: true)')); + expect(auth, contains('if (!forceRemote &&')); + expect(session, contains('BackendIdentity.accountNamespace(userId)')); + expect(session, contains('pending_account_archive_namespace')); + expect(session, contains('last_account_namespace')); + final client = File('lib/shared/api/api_client.dart').readAsStringSync(); + expect(client, contains("'auth_token_\${BackendIdentity.scope}'")); + }); + test('启动页只读取本地缓存,不等待远程接口', () { final source = File( 'lib/features/auth/pages/splash_page.dart', diff --git a/release-notes/JiZhi-1.2.5-internal-134.md b/release-notes/JiZhi-1.2.5-internal-134.md new file mode 100644 index 0000000..666d8fc --- /dev/null +++ b/release-notes/JiZhi-1.2.5-internal-134.md @@ -0,0 +1,14 @@ +# 记之 1.2.5-internal (134) + +## 测试环境 + +- API 地址:`http://192.168.5.8:5000` +- API 数据库已执行到 `TransferAndAdminSecurity` 迁移。 +- 内测构建必须显式提供合法的 HTTP/HTTPS API 地址,不再绑定旧 FRP 地址。 + +## 构建信息 + +- 包名:`com.nx.miaoji.internal` +- 显示版本:`1.2.5-internal` +- 构建号:`134` +- APK SHA-256:`5916997D050AB5209AEAB8C86267FAF9727EEC71AD8C28E79FACBC92D860B7E2` diff --git a/release-notes/JiZhi-1.2.5-internal-135.md b/release-notes/JiZhi-1.2.5-internal-135.md new file mode 100644 index 0000000..9c9663a --- /dev/null +++ b/release-notes/JiZhi-1.2.5-internal-135.md @@ -0,0 +1,22 @@ +# 记之 1.2.5-internal (135) + +## 本版更新 + +- 修复微信好友转账 OCR 在识别树不完整时过早拒绝的问题;金额缺失、成功状态暂缺及付款输入页会继续等待后续页面。 +- 转账 OCR 结果按转账类型、收支方向和对方信息入账,同时仍要求明确的支付成功证据,降低误记风险。 +- 修复切换后端后相同用户编号复用本地数据库的问题;本地数据库和登录令牌现按后端地址隔离。 +- 保留旧本地数据库并提供“本机历史数据”显式导入,导入过程支持幂等与事务保护。 +- 修复游客从 AI 页面登录后仍停留在游客模式、未触发应用模式选择的问题。 +- 移除旧 FRP 测试后端回退和证书适配;未显式配置 API 时不再连接任何历史后端。 + +## 测试环境 + +- API 地址:`http://192.168.5.8:5000` +- 内测构建必须显式提供合法的 HTTP/HTTPS API 地址。 + +## 构建信息 + +- 包名:`com.nx.miaoji.internal` +- 显示版本:`1.2.5-internal` +- 构建号:`135` +- APK SHA-256:`E57867A7541EF857B1A9BD0F68916429B8EA27C1336F285BF9A944470333DD29`