fix: improve transfer recognition and local data safety
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
import java.net.URI
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
@@ -120,15 +121,21 @@ val internalReleaseRequested = gradle.startParameter.taskNames.any { requestedTa
|
|||||||
taskName.contains("InternalRelease", ignoreCase = true)
|
taskName.contains("InternalRelease", ignoreCase = true)
|
||||||
}
|
}
|
||||||
if (internalReleaseRequested) {
|
if (internalReleaseRequested) {
|
||||||
val expectedApiUrl = "https://lt.frp-say.com:38012"
|
|
||||||
if (dartDefines["INTERNAL_BUILD"] != "true") {
|
if (dartDefines["INTERNAL_BUILD"] != "true") {
|
||||||
throw GradleException(
|
throw GradleException(
|
||||||
"Internal release requires --dart-define=INTERNAL_BUILD=true",
|
"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(
|
throw GradleException(
|
||||||
"Internal release requires --dart-define=API_BASE_URL=$expectedApiUrl",
|
"Internal release requires an explicit HTTP(S) --dart-define=API_BASE_URL=<url>",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (dartDefines["APP_VERSION"].isNullOrBlank()) {
|
if (dartDefines["APP_VERSION"].isNullOrBlank()) {
|
||||||
|
|||||||
@@ -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-----
|
|
||||||
@@ -5,11 +5,4 @@
|
|||||||
<certificates src="system"/>
|
<certificates src="system"/>
|
||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
<domain-config cleartextTrafficPermitted="false">
|
</network-security-config>
|
||||||
<domain includeSubdomains="false">lt.frp-say.com</domain>
|
|
||||||
<trust-anchors>
|
|
||||||
<certificates src="system"/>
|
|
||||||
<certificates src="@raw/sakura_frp_test_ca"/>
|
|
||||||
</trust-anchors>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ object LocalPaymentOcr {
|
|||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
channel = "local_ocr",
|
channel = "local_ocr",
|
||||||
amountCents = selectedCents,
|
amountCents = selectedCents,
|
||||||
type = direction,
|
type = if (kind == "transfer") "transfer" else direction,
|
||||||
merchant = merchant,
|
merchant = merchant,
|
||||||
orderId = orderId,
|
orderId = orderId,
|
||||||
occurredAtEpochMs = capturedAt,
|
occurredAtEpochMs = capturedAt,
|
||||||
@@ -395,6 +395,10 @@ object LocalPaymentOcr {
|
|||||||
orderId,
|
orderId,
|
||||||
PaymentParser.sha256(allText),
|
PaymentParser.sha256(allText),
|
||||||
),
|
),
|
||||||
|
transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
||||||
|
if (it == "income") "in" else "out"
|
||||||
|
},
|
||||||
|
counterparty = merchant.takeIf { kind == "transfer" },
|
||||||
)
|
)
|
||||||
val reason = when {
|
val reason = when {
|
||||||
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
|
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
|
||||||
@@ -480,4 +484,4 @@ object LocalPaymentOcr {
|
|||||||
private const val OCR_IDLE_CLOSE_SECONDS = 60L
|
private const val OCR_IDLE_CLOSE_SECONDS = 60L
|
||||||
private const val MAX_SCREENSHOT_AGE_MS = 3_000L
|
private const val MAX_SCREENSHOT_AGE_MS = 3_000L
|
||||||
private const val MAX_AMOUNT = 100_000_000.0
|
private const val MAX_AMOUNT = 100_000_000.0
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -812,7 +812,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val retryable = outcome.reason in setOf("no_text", "missing_amount")
|
val retryable = isRetryableOcrOutcome(outcome.reason)
|
||||||
if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) {
|
if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) {
|
||||||
flow.retryCount += 1
|
flow.retryCount += 1
|
||||||
scheduleVisualRecognition(
|
scheduleVisualRecognition(
|
||||||
@@ -1205,6 +1205,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
|||||||
internal fun shouldSuppressCompletedResult(resultSurfaceExited: Boolean): Boolean =
|
internal fun shouldSuppressCompletedResult(resultSurfaceExited: Boolean): Boolean =
|
||||||
!resultSurfaceExited
|
!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(
|
internal fun completedResultStartReason(
|
||||||
hasObservedResult: Boolean,
|
hasObservedResult: Boolean,
|
||||||
resultFingerprintChanged: Boolean,
|
resultFingerprintChanged: Boolean,
|
||||||
|
|||||||
@@ -307,6 +307,15 @@ class PaymentParserTest {
|
|||||||
assertFalse(ScreenshotAccessibilityService.shouldSuppressCompletedResult(true))
|
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
|
@Test
|
||||||
fun consecutiveIdenticalTransfersKeepDistinctFlowIdentities() {
|
fun consecutiveIdenticalTransfersKeepDistinctFlowIdentities() {
|
||||||
val first = paymentSignal(
|
val first = paymentSignal(
|
||||||
|
|||||||
@@ -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<void> offerLocalDataRecovery(
|
||||||
|
BuildContext context, {
|
||||||
|
Map<String, dynamic>? guestSnapshot,
|
||||||
|
}) async {
|
||||||
|
final session = SessionStore.instance;
|
||||||
|
Map<String, dynamic>? 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)));
|
||||||
|
}
|
||||||
@@ -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/api_client.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/auth_api.dart';
|
import 'package:miaoji_zhang/shared/api/auth_api.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/config_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/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/local_database.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/push_service.dart';
|
import 'package:miaoji_zhang/shared/services/push_service.dart';
|
||||||
@@ -59,36 +59,14 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
: null;
|
: null;
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
final closureCancelled = await AuthApi.login(
|
final result = await AuthApi.login(_user.text.trim(), _pass.text);
|
||||||
_user.text.trim(),
|
final profile = result.profile;
|
||||||
_pass.text,
|
|
||||||
);
|
|
||||||
final profile = await AuthApi.me();
|
|
||||||
await PushService.instance.refresh();
|
await PushService.instance.refresh();
|
||||||
await CurrentLedgerStore.instance.ensureLoaded(force: true);
|
await CurrentLedgerStore.instance.ensureLoaded(force: true);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (guestSnapshot?['hasData'] == true) {
|
await offerLocalDataRecovery(context, guestSnapshot: guestSnapshot);
|
||||||
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)}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (closureCancelled) _showMessage('已取消账号注销,欢迎回来');
|
if (result.accountClosureCancelled) _showMessage('已取消账号注销,欢迎回来');
|
||||||
final destination = !profile.onboardingDone
|
final destination = !profile.onboardingDone
|
||||||
? '/onboarding'
|
? '/onboarding'
|
||||||
: profile.appMode == 'ai'
|
: profile.appMode == 'ai'
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/api_client.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/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';
|
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||||
|
|
||||||
class RegisterPage extends StatefulWidget {
|
class RegisterPage extends StatefulWidget {
|
||||||
@@ -40,9 +45,26 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
}
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
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;
|
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) {
|
} catch (error) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_message(apiErrorMessage(error));
|
_message(apiErrorMessage(error));
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:dio/io.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.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';
|
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -11,24 +9,14 @@ class ApiClient {
|
|||||||
static final ApiClient instance = ApiClient._();
|
static final ApiClient instance = ApiClient._();
|
||||||
|
|
||||||
static const _storage = FlutterSecureStorage();
|
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 final sessionExpired = ValueNotifier<int>(0);
|
||||||
static bool _handlingUnauthorized = false;
|
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 != ''
|
static const String baseUrl = BackendIdentity.baseUrl;
|
||||||
? _configuredBaseUrl
|
|
||||||
: _internalBuild
|
|
||||||
? _internalTestBaseUrl
|
|
||||||
: 'https://api.invalid';
|
|
||||||
|
|
||||||
static bool get isInternalBuild => _internalBuild;
|
static bool get isInternalBuild => BackendIdentity.internalBuild;
|
||||||
|
|
||||||
late final Dio dio = _createDio();
|
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;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveToken(String token) =>
|
Future<void> saveToken(String token) async {
|
||||||
_storage.write(key: _tokenKey, value: token);
|
await _storage.write(key: _tokenKey, value: token);
|
||||||
|
await _storage.delete(key: _legacyTokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
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) =>
|
bool isConnectivityError(Object error) =>
|
||||||
|
|||||||
@@ -53,10 +53,20 @@ class UserProfile {
|
|||||||
}) : aiCompanion = null;
|
}) : aiCompanion = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AuthLoginResult {
|
||||||
|
final bool accountClosureCancelled;
|
||||||
|
final UserProfile profile;
|
||||||
|
|
||||||
|
const AuthLoginResult({
|
||||||
|
required this.accountClosureCancelled,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class AuthApi {
|
class AuthApi {
|
||||||
static final _dio = ApiClient.instance.dio;
|
static final _dio = ApiClient.instance.dio;
|
||||||
|
|
||||||
static Future<void> register(
|
static Future<UserProfile> register(
|
||||||
String username,
|
String username,
|
||||||
String password, {
|
String password, {
|
||||||
required bool agreedToTerms,
|
required bool agreedToTerms,
|
||||||
@@ -70,21 +80,28 @@ class AuthApi {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await ApiClient.instance.saveToken(response.data['token'] as String);
|
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(
|
final response = await _dio.post(
|
||||||
'/api/auth/login',
|
'/api/auth/login',
|
||||||
data: {'username': username, 'password': password},
|
data: {'username': username, 'password': password},
|
||||||
);
|
);
|
||||||
await ApiClient.instance.saveToken(response.data['token'] as String);
|
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;
|
final session = SessionStore.instance;
|
||||||
if (session.isGuest || (session.isAccount && session.shouldUseLocalOnly)) {
|
if (!forceRemote &&
|
||||||
|
(session.isGuest ||
|
||||||
|
(session.isAccount && session.shouldUseLocalOnly))) {
|
||||||
return _localProfile();
|
return _localProfile();
|
||||||
}
|
}
|
||||||
try {
|
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/api/api_client.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||||
@@ -17,12 +20,23 @@ class GuestMergeService {
|
|||||||
|
|
||||||
static final _dio = ApiClient.instance.dio;
|
static final _dio = ApiClient.instance.dio;
|
||||||
|
|
||||||
static Future<GuestMergeResult> merge(Map<String, dynamic> snapshot) async {
|
static Future<GuestMergeResult> merge(
|
||||||
final ledgerResponse = await _dio.post(
|
Map<String, dynamic> snapshot, {
|
||||||
'/api/ledgers',
|
String ledgerName = '游客数据',
|
||||||
data: {'name': '游客数据', 'iconKey': 'wallet'},
|
}) async {
|
||||||
);
|
final ledgersResponse = await _dio.get('/api/ledgers');
|
||||||
final ledgerId = (ledgerResponse.data['id'] as num).toInt();
|
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 categoryMap = <int, int>{};
|
||||||
final available = <Map<String, dynamic>>[];
|
final available = <Map<String, dynamic>>[];
|
||||||
for (final type in ['expense', 'income']) {
|
for (final type in ['expense', 'income']) {
|
||||||
@@ -37,6 +51,7 @@ class GuestMergeService {
|
|||||||
|
|
||||||
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
|
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
|
||||||
final category = value as Map<String, dynamic>;
|
final category = value as Map<String, dynamic>;
|
||||||
|
if (category['isDeleted'] == true) continue;
|
||||||
final existing = available.where(
|
final existing = available.where(
|
||||||
(item) =>
|
(item) =>
|
||||||
item['type'] == category['type'] &&
|
item['type'] == category['type'] &&
|
||||||
@@ -45,6 +60,8 @@ class GuestMergeService {
|
|||||||
Map<String, dynamic> remote;
|
Map<String, dynamic> remote;
|
||||||
if (existing.isNotEmpty) {
|
if (existing.isNotEmpty) {
|
||||||
remote = existing.first;
|
remote = existing.first;
|
||||||
|
} else if (category['isCustom'] == false) {
|
||||||
|
continue;
|
||||||
} else {
|
} else {
|
||||||
final response = await _dio.post(
|
final response = await _dio.post(
|
||||||
'/api/categories',
|
'/api/categories',
|
||||||
@@ -66,14 +83,15 @@ class GuestMergeService {
|
|||||||
final oldId = (transaction['categoryId'] as num).toInt();
|
final oldId = (transaction['categoryId'] as num).toInt();
|
||||||
final custom = categoryMap[oldId];
|
final custom = categoryMap[oldId];
|
||||||
if (custom != null) return custom;
|
if (custom != null) return custom;
|
||||||
|
final categoryType = _transactionCategoryType(transaction);
|
||||||
final exact = available.where(
|
final exact = available.where(
|
||||||
(item) =>
|
(item) =>
|
||||||
item['type'] == transaction['type'] &&
|
item['type'] == categoryType &&
|
||||||
item['name'] == transaction['categoryName'],
|
item['name'] == transaction['categoryName'],
|
||||||
);
|
);
|
||||||
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
|
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
|
||||||
final fallback = available.firstWhere(
|
final fallback = available.firstWhere(
|
||||||
(item) => item['type'] == transaction['type'] && item['name'] == '其他',
|
(item) => item['type'] == categoryType && item['name'] == '其他',
|
||||||
);
|
);
|
||||||
return (fallback['id'] as num).toInt();
|
return (fallback['id'] as num).toInt();
|
||||||
}
|
}
|
||||||
@@ -82,6 +100,7 @@ class GuestMergeService {
|
|||||||
for (final value
|
for (final value
|
||||||
in snapshot['transactions'] as List<dynamic>? ?? const []) {
|
in snapshot['transactions'] as List<dynamic>? ?? const []) {
|
||||||
final transaction = value as Map<String, dynamic>;
|
final transaction = value as Map<String, dynamic>;
|
||||||
|
if (transaction['isDeleted'] == true) continue;
|
||||||
await _dio.post(
|
await _dio.post(
|
||||||
'/api/transactions',
|
'/api/transactions',
|
||||||
data: {
|
data: {
|
||||||
@@ -91,9 +110,12 @@ class GuestMergeService {
|
|||||||
'amount': transaction['amount'],
|
'amount': transaction['amount'],
|
||||||
'note': transaction['note'],
|
'note': transaction['note'],
|
||||||
'paymentMethod': transaction['paymentMethod'],
|
'paymentMethod': transaction['paymentMethod'],
|
||||||
|
'transferDirection': transaction['transferDirection'],
|
||||||
|
'counterparty': transaction['counterparty'],
|
||||||
'occurredAt': transaction['occurredAt'],
|
'occurredAt': transaction['occurredAt'],
|
||||||
'source': 'manual',
|
'source': 'manual',
|
||||||
'sourceText': null,
|
'sourceText': null,
|
||||||
|
'clientRequestId': _importRequestId(transaction),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
transactionCount++;
|
transactionCount++;
|
||||||
@@ -136,14 +158,42 @@ class GuestMergeService {
|
|||||||
) {
|
) {
|
||||||
final transaction = (snapshot['transactions'] as List<dynamic>? ?? const [])
|
final transaction = (snapshot['transactions'] as List<dynamic>? ?? const [])
|
||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
.where((item) => item['categoryId'] == oldCategoryId)
|
.where(
|
||||||
|
(item) =>
|
||||||
|
item['categoryId'] == oldCategoryId && item['isDeleted'] != true,
|
||||||
|
)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
if (transaction == null) return null;
|
if (transaction == null) return null;
|
||||||
final match = available.where(
|
final match = available.where(
|
||||||
(item) =>
|
(item) =>
|
||||||
item['type'] == transaction['type'] &&
|
item['type'] == _transactionCategoryType(transaction) &&
|
||||||
item['name'] == transaction['categoryName'],
|
item['name'] == transaction['categoryName'],
|
||||||
);
|
);
|
||||||
return match.isEmpty ? null : (match.first['id'] as num).toInt();
|
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:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -28,6 +29,13 @@ class LocalDatabase {
|
|||||||
String? _namespace;
|
String? _namespace;
|
||||||
|
|
||||||
String? get namespace => _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 {
|
Database get _db {
|
||||||
final database = _database;
|
final database = _database;
|
||||||
if (database == null) throw StateError('本地数据库尚未初始化');
|
if (database == null) throw StateError('本地数据库尚未初始化');
|
||||||
@@ -766,9 +774,16 @@ class LocalDatabase {
|
|||||||
|
|
||||||
void replaceLocalTransaction(int localId, Map<String, dynamic> remote) {
|
void replaceLocalTransaction(int localId, Map<String, dynamic> remote) {
|
||||||
final remoteId = (remote['id'] as num).toInt();
|
final remoteId = (remote['id'] as num).toInt();
|
||||||
_db.execute('DELETE FROM transactions WHERE id = ?', [localId]);
|
_db.execute('BEGIN');
|
||||||
_saveIdMap('transaction', localId, remoteId);
|
try {
|
||||||
cacheTransaction(remote);
|
cacheTransaction(remote);
|
||||||
|
_saveIdMap('transaction', localId, remoteId);
|
||||||
|
_db.execute('DELETE FROM transactions WHERE id = ?', [localId]);
|
||||||
|
_db.execute('COMMIT');
|
||||||
|
} catch (_) {
|
||||||
|
_db.execute('ROLLBACK');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void enqueueSync(
|
void enqueueSync(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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';
|
import 'package:miaoji_zhang/shared/services/local_database.dart';
|
||||||
|
|
||||||
class OnlineFeatureRequiredException implements Exception {
|
class OnlineFeatureRequiredException implements Exception {
|
||||||
@@ -25,6 +26,11 @@ class SessionStore extends ChangeNotifier {
|
|||||||
bool _cloudSyncEnabled = true;
|
bool _cloudSyncEnabled = true;
|
||||||
bool _needsReauth = false;
|
bool _needsReauth = false;
|
||||||
bool _aiEnabled = false;
|
bool _aiEnabled = false;
|
||||||
|
String? _backendScope;
|
||||||
|
String? _accountNamespace;
|
||||||
|
Map<String, dynamic>? _pendingAccountSnapshot;
|
||||||
|
String? _pendingAccountNamespace;
|
||||||
|
String? _lastAccountNamespace;
|
||||||
|
|
||||||
bool get isGuest => _mode == 'guest';
|
bool get isGuest => _mode == 'guest';
|
||||||
bool get isAccount => _mode == 'account' && _userId != null;
|
bool get isAccount => _mode == 'account' && _userId != null;
|
||||||
@@ -38,7 +44,9 @@ class SessionStore extends ChangeNotifier {
|
|||||||
String? get nickname => _nickname;
|
String? get nickname => _nickname;
|
||||||
String get appMode => _appMode;
|
String get appMode => _appMode;
|
||||||
bool get onboardingDone => _onboardingDone;
|
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 {
|
Future<void> initialize() async {
|
||||||
_preferences ??= await SharedPreferences.getInstance();
|
_preferences ??= await SharedPreferences.getInstance();
|
||||||
@@ -51,9 +59,25 @@ class SessionStore extends ChangeNotifier {
|
|||||||
_onboardingDone = preferences.getBool('session_onboarding_done') ?? true;
|
_onboardingDone = preferences.getBool('session_onboarding_done') ?? true;
|
||||||
_cloudSyncEnabled = preferences.getBool('session_cloud_sync') ?? true;
|
_cloudSyncEnabled = preferences.getBool('session_cloud_sync') ?? true;
|
||||||
_needsReauth = preferences.getBool('session_needs_reauth') ?? false;
|
_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 =
|
_aiEnabled =
|
||||||
preferences.getBool('session_ai_enabled') ?? (_mode == 'account');
|
preferences.getBool('session_ai_enabled') ?? (_mode == 'account');
|
||||||
if (hasSession) {
|
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);
|
await LocalDatabase.instance.openNamespace(namespace);
|
||||||
} else if (_mode != 'none') {
|
} else if (_mode != 'none') {
|
||||||
_mode = 'none';
|
_mode = 'none';
|
||||||
@@ -72,6 +96,9 @@ class SessionStore extends ChangeNotifier {
|
|||||||
_cloudSyncEnabled = false;
|
_cloudSyncEnabled = false;
|
||||||
_needsReauth = false;
|
_needsReauth = false;
|
||||||
_aiEnabled = false;
|
_aiEnabled = false;
|
||||||
|
_backendScope = null;
|
||||||
|
_accountNamespace = null;
|
||||||
|
_pendingAccountSnapshot = null;
|
||||||
await preferences.setString('session_mode', _mode);
|
await preferences.setString('session_mode', _mode);
|
||||||
await preferences.remove('session_user_id');
|
await preferences.remove('session_user_id');
|
||||||
await preferences.setString('session_username', _username!);
|
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_onboarding_done', true);
|
||||||
await preferences.setBool('session_cloud_sync', false);
|
await preferences.setBool('session_cloud_sync', false);
|
||||||
await preferences.setBool('session_needs_reauth', 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);
|
await LocalDatabase.instance.openNamespace(namespace);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -93,6 +122,28 @@ class SessionStore extends ChangeNotifier {
|
|||||||
required bool aiEnabled,
|
required bool aiEnabled,
|
||||||
}) async {
|
}) async {
|
||||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
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';
|
_mode = 'account';
|
||||||
_userId = userId;
|
_userId = userId;
|
||||||
_username = username;
|
_username = username;
|
||||||
@@ -100,7 +151,12 @@ class SessionStore extends ChangeNotifier {
|
|||||||
_appMode = appMode;
|
_appMode = appMode;
|
||||||
_onboardingDone = onboardingDone;
|
_onboardingDone = onboardingDone;
|
||||||
_aiEnabled = aiEnabled;
|
_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;
|
_needsReauth = false;
|
||||||
await preferences.setString('session_mode', _mode);
|
await preferences.setString('session_mode', _mode);
|
||||||
await preferences.setInt('session_user_id', userId);
|
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_ai_enabled', aiEnabled);
|
||||||
await preferences.setBool('session_cloud_sync', _cloudSyncEnabled);
|
await preferences.setBool('session_cloud_sync', _cloudSyncEnabled);
|
||||||
await preferences.setBool('session_needs_reauth', false);
|
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);
|
await LocalDatabase.instance.openNamespace(namespace);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -165,7 +224,10 @@ class SessionStore extends ChangeNotifier {
|
|||||||
_cloudSyncEnabled = enabled;
|
_cloudSyncEnabled = enabled;
|
||||||
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
final preferences = _preferences ??= await SharedPreferences.getInstance();
|
||||||
await preferences.setBool('session_cloud_sync', enabled);
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,11 +250,16 @@ class SessionStore extends ChangeNotifier {
|
|||||||
_cloudSyncEnabled = true;
|
_cloudSyncEnabled = true;
|
||||||
_needsReauth = false;
|
_needsReauth = false;
|
||||||
_aiEnabled = false;
|
_aiEnabled = false;
|
||||||
|
_backendScope = null;
|
||||||
|
_accountNamespace = null;
|
||||||
|
_pendingAccountSnapshot = null;
|
||||||
await preferences.setString('session_mode', 'none');
|
await preferences.setString('session_mode', 'none');
|
||||||
await preferences.remove('session_user_id');
|
await preferences.remove('session_user_id');
|
||||||
await preferences.remove('session_username');
|
await preferences.remove('session_username');
|
||||||
await preferences.remove('session_nickname');
|
await preferences.remove('session_nickname');
|
||||||
await preferences.remove('session_needs_reauth');
|
await preferences.remove('session_needs_reauth');
|
||||||
|
await preferences.remove('session_backend_scope');
|
||||||
|
await preferences.remove('session_account_namespace');
|
||||||
LocalDatabase.instance.close();
|
LocalDatabase.instance.close();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -202,4 +269,49 @@ class SessionStore extends ChangeNotifier {
|
|||||||
throw OnlineFeatureRequiredException(message ?? '此功能需要登录并连接网络后使用');
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.11.0
|
sdk: ^3.11.0
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
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/business_api.dart';
|
||||||
import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.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/services/current_ledger_store.dart';
|
||||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||||
|
|
||||||
void main() {
|
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(
|
test(
|
||||||
'SSE framing preserves split UTF-8 characters and trailing events',
|
'SSE framing preserves split UTF-8 characters and trailing events',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -82,4 +82,29 @@ void main() {
|
|||||||
throwsStateError,
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ import 'dart:io';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
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('启动页只读取本地缓存,不等待远程接口', () {
|
test('启动页只读取本地缓存,不等待远程接口', () {
|
||||||
final source = File(
|
final source = File(
|
||||||
'lib/features/auth/pages/splash_page.dart',
|
'lib/features/auth/pages/splash_page.dart',
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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`
|
||||||
Reference in New Issue
Block a user