diff --git a/lib/core/network/auth_redirect.dart b/lib/core/network/auth_redirect.dart index 5fea17a..71ac8e4 100644 --- a/lib/core/network/auth_redirect.dart +++ b/lib/core/network/auth_redirect.dart @@ -29,6 +29,11 @@ Future forceLogout({String reason = '登录已过期,请重新登录'}) void _goLoginPage() { final nav = navigatorKey.currentState; if (nav == null) return; + // ⚠️ 必须用 pushAndRemoveUntil 把 MainShell 连同所有 push 出来的模块页一起 + // 卸掉。App 里那批 autoDispose 的 provider(账单列表、统计、首页各卡片等) + // 就是靠「页面卸载」才被回收的 —— 换成 pushReplacement 之类的写法, + // 它们会带着上一个用户的数据继续活着。 + // (不带 autoDispose 的那批另有保险:见 lib/providers/session_provider.dart) nav.pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const LoginPage()), (route) => false, diff --git a/lib/core/storage/local_storage.dart b/lib/core/storage/local_storage.dart index 6ba316d..fd27354 100644 --- a/lib/core/storage/local_storage.dart +++ b/lib/core/storage/local_storage.dart @@ -36,6 +36,32 @@ class LocalStorage { await prefs.remove(_keyUserInfo); } + /// 清掉「属于上一个用户」的全部本地数据。**登出时调**(`AuthNotifier.logout`)。 + /// + /// 为什么不和 [clearAuth] 合并成一个方法:`clearAuth` 还有另一个调用点 + /// —— `AuthNotifier.load()` 在启动时发现 token 过期也会调它。那种情况 + /// 重新登录的通常还是同一个人,把他的水印配置和记住的账号一并抹掉是净损失。 + /// 登出才是明确的「换人」信号。 + /// + /// ⚠️ **以后新增「用户级」的存储 key,记得加到这里。** + /// 判断标准:这条数据属于某个账号(尤其是它存的是数据库行 id)就该加; + /// 纯粹跟设备走的(比如界面缩放、主题)不用加。 + /// + /// 内存里那批 provider 不归这里管 —— 它们靠 `sessionUserProvider` + /// 触发重建(见 lib/providers/session_provider.dart)。 + Future clearUserScopedData() async { + final prefs = await _getPrefs(); + // AI 选择:存的是服务端数据行的 id,跨用户会指到别人的配置上 + await prefs.remove(_keyAiConfigId); + await prefs.remove(_keyAiPersonaId); + // 水印:印的是自己的品牌信息;常用地址是用户常去的地点 + await prefs.remove(_keyWatermarkSettings); + await prefs.remove(_keyWatermarkAddresses); + // 记住的账号密码:不清的话登录页会把上一个人的账号连明文密码一起回填 + await prefs.remove(_keySavedUsername); + await prefs.remove(_keySavedPassword); + } + /// 只更新用户信息,不动 token。 /// /// 改头像/昵称后用它 —— 那些操作不发新 token,不该走 [setAuth] diff --git a/lib/pages/mine/mine_page.dart b/lib/pages/mine/mine_page.dart index f1fca2c..0f9ca04 100644 --- a/lib/pages/mine/mine_page.dart +++ b/lib/pages/mine/mine_page.dart @@ -27,7 +27,15 @@ class MinePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final user = ref.watch(authProvider).user; + final auth = ref.watch(authProvider); + + // 登出瞬间这里还会重建一帧(这个页在 MainShell 的 IndexedStack 里常驻, + // 而 provider 的重建走 Timer 调度、会先于页面卸载发生)。此时 account / + // budget 已经是空态了,照常渲染会闪一下「余额 ¥0.00 / 未设置预算」, + // 然后登录页才盖上来。直接不渲染,过渡帧就没有内容可闪。 + if (!auth.isLoggedIn) return const SizedBox.shrink(); + + final user = auth.user; final accountAsync = ref.watch(accountProvider); final budgetAsync = ref.watch(budgetProvider); diff --git a/lib/providers/account_provider.dart b/lib/providers/account_provider.dart index d9f4ee9..948e1b2 100644 --- a/lib/providers/account_provider.dart +++ b/lib/providers/account_provider.dart @@ -1,13 +1,27 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/account_api.dart'; import '../core/network/api_exception.dart'; import '../models/account.dart'; +import 'session_provider.dart'; /// 账户信息(余额 / 总收入 / 总支出) class AccountNotifier extends AsyncNotifier { @override - Future build() => ref.watch(accountApiProvider).getAccount(); + FutureOr build() { + // 换人(含登出)就重建,绝不把上一个人的余额留给下一个。 + // + // 未登录时直接给空态、**不发请求**:登出瞬间 MinePage 还挂在 + // IndexedStack 上(页面卸载在下一帧,而 provider 重建走 Timer 调度, + // 会先发生),这时 token 已经被清掉了 —— 发出去必然 403, + // 会被 ErrorInterceptor 当成「登录已过期」,弹一个假的红条。 + // + // 同步返回的好处:走 AsyncData 而不是 AsyncLoading,不闪加载圈。 + if (ref.watch(sessionUserProvider) == null) return AccountInfo.empty; + return ref.watch(accountApiProvider).getAccount(); + } /// 把账户余额改成 [targetBalance]。 /// diff --git a/lib/providers/add_bill_provider.dart b/lib/providers/add_bill_provider.dart index 4f27b6a..2ab1354 100644 --- a/lib/providers/add_bill_provider.dart +++ b/lib/providers/add_bill_provider.dart @@ -6,6 +6,7 @@ import '../models/bill_request.dart'; import '../models/ocr_result.dart'; import '../utils/date_util.dart'; import 'bill_refresh.dart'; +import 'session_provider.dart'; /// 手动记账表单状态 class AddBillFormState { @@ -52,7 +53,11 @@ class AddBillFormState { class AddBillFormNotifier extends Notifier { @override - AddBillFormState build() => AddBillFormState(billDate: formatDate(DateTime.now())); + AddBillFormState build() { + // 换人(含登出)就重建 —— 上一个人没提交完的草稿不该留在新用户的表单里。 + ref.watch(sessionUserProvider); + return AddBillFormState(billDate: formatDate(DateTime.now())); + } void setType(int type) { if (type == state.type) return; diff --git a/lib/providers/ai_config_provider.dart b/lib/providers/ai_config_provider.dart index 09afc8e..56839a3 100644 --- a/lib/providers/ai_config_provider.dart +++ b/lib/providers/ai_config_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/ai_api.dart'; import '../core/storage/local_storage.dart'; import '../models/ai_config.dart'; +import 'session_provider.dart'; /// 可选的 AI 配置列表(含虚拟的「默认」项) final aiConfigsProvider = FutureProvider.autoDispose>((ref) { @@ -17,6 +18,11 @@ final aiConfigsProvider = FutureProvider.autoDispose>((ref) { class SelectedAiConfigNotifier extends Notifier { @override int? build() { + // 换人(含登出)就重建。**必须重读一遍**:存的是配置文件的行 id, + // 属于上一个人的数据行,登出时本地那份也被清掉了(LocalStorage + // .clearUserScopedData),重读才会落到 null。 + ref.watch(sessionUserProvider); + // 存的就是「真实 id」,null = 用默认。 // 默认项在列表里的 id 也是 null,两边语义天然一致。 return LocalStorage.instance.getAiConfigId(); diff --git a/lib/providers/ai_persona_provider.dart b/lib/providers/ai_persona_provider.dart index b5876c1..aacc594 100644 --- a/lib/providers/ai_persona_provider.dart +++ b/lib/providers/ai_persona_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/ai_api.dart'; import '../core/storage/local_storage.dart'; import '../models/ai_persona.dart'; +import 'session_provider.dart'; /// 可选的人设列表(含虚拟的「默认」项) /// @@ -28,6 +29,11 @@ final aiPersonasProvider = FutureProvider.autoDispose>((ref) class PreferredPersonaNotifier extends Notifier { @override int? build() { + // 换人(含登出)就重建。**必须重读一遍**:存的是人设的行 id, + // 属于上一个人的数据行,登出时本地那份也被清掉了(LocalStorage + // .clearUserScopedData),重读才会落到 null。 + ref.watch(sessionUserProvider); + // 存的是「真实 id」,null = 内置默认。 // 默认项在列表里的 id 也是 null,两边语义天然一致。 return LocalStorage.instance.getAiPersonaId(); diff --git a/lib/providers/ai_provider.dart b/lib/providers/ai_provider.dart index 511b651..4933134 100644 --- a/lib/providers/ai_provider.dart +++ b/lib/providers/ai_provider.dart @@ -6,6 +6,7 @@ import '../models/ai.dart'; import 'ai_config_provider.dart'; import 'bill_refresh.dart'; import 'note_provider.dart'; +import 'session_provider.dart'; /// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句) final aiStatusProvider = FutureProvider.autoDispose((ref) { @@ -95,7 +96,17 @@ class AssistantNotifier extends Notifier { bool _autoRestored = false; @override - AssistantState build() => const AssistantState(); + AssistantState build() { + // 换人(含登出)就重建 —— 聊天记录属于上一个人,不能留给下一个。 + // + // _autoRestored 必须手动复位:Riverpod 重建时不会新建 Notifier 实例 + // (它把实例缓存在 classListenable.result 里),字段不会自己清。 + // 不复位的话新用户进助手页时 restoreLastSessionIfNeeded() 会直接 + // 早退 —— 明明 _autoRestored 是上一个人留下的,却再也不恢复会话了。 + ref.watch(sessionUserProvider); + _autoRestored = false; + return const AssistantState(); + } /// 打开助手页时调用:界面还是空的就把最近一次对话展开。 /// diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 7189557..73f997e 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -62,7 +62,13 @@ class AuthNotifier extends Notifier { } Future logout() async { + // 顺序要紧:先把本地清干净,最后才改 state。 + // + // 改 state 会触发所有 watch `sessionUserProvider` 的 provider 重建, + // 而它们 rebuild 时会重读本地存储(AI 选中的行 id、水印设置)。 + // 反过来先改 state 的话,那批 provider 会读到还没清掉的脏数据。 await LocalStorage.instance.clearAuth(); + await LocalStorage.instance.clearUserScopedData(); state = const AuthState.initial(); } } diff --git a/lib/providers/bill_provider.dart b/lib/providers/bill_provider.dart index 396a0dc..c0ec5ca 100644 --- a/lib/providers/bill_provider.dart +++ b/lib/providers/bill_provider.dart @@ -5,12 +5,18 @@ import '../api/statistics_api.dart'; import '../models/bill.dart'; import '../models/statistics_response.dart'; import '../utils/date_util.dart'; +import 'session_provider.dart'; /// 首页选中的月份锚点(该月 1 日 00:00)。 /// 切换月份只改这一个状态,依赖它的列表与汇总会自动换 key 重新取数。 class MonthAnchorNotifier extends Notifier { @override - DateTime build() => monthStart(DateTime.now()); + DateTime build() { + // 换人(含登出)就回到当月 —— 不然新用户打开账单页会落在 + // 上一个人翻到的那个月份上,看着像是他的数据还在。 + ref.watch(sessionUserProvider); + return monthStart(DateTime.now()); + } bool get canGoNext => state.isBefore(monthStart(DateTime.now())); diff --git a/lib/providers/budget_provider.dart b/lib/providers/budget_provider.dart index b1c5b08..01a4409 100644 --- a/lib/providers/budget_provider.dart +++ b/lib/providers/budget_provider.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/budget_api.dart'; import '../models/budget.dart'; +import 'session_provider.dart'; /// 当月预算(后端只提供当月数据)。 /// @@ -9,7 +12,13 @@ import '../models/budget.dart'; /// [BudgetNotifier.setMonthBudget] 修改预算并拿到后端返回的最新值。 class BudgetNotifier extends AsyncNotifier { @override - Future build() => ref.watch(budgetApiProvider).getBudget(); + FutureOr build() { + // 换人(含登出)就重建,绝不把上一个人的预算留给下一个。 + // 未登录直接给空态、不发请求 —— 理由同 account_provider.dart 的说明 + // (登出瞬间页面还没卸载,发出去必然 403,会弹假的「登录已过期」)。 + if (ref.watch(sessionUserProvider) == null) return BudgetInfo.empty; + return ref.watch(budgetApiProvider).getBudget(); + } /// 设置/修改当月预算(立即生效,可随时修改) Future setMonthBudget(double amount) async { diff --git a/lib/providers/note_provider.dart b/lib/providers/note_provider.dart index 724e3b1..203ab6d 100644 --- a/lib/providers/note_provider.dart +++ b/lib/providers/note_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/note_api.dart'; import '../core/network/api_exception.dart'; import '../models/note.dart'; +import 'session_provider.dart'; /// 笔记列表页状态 class NoteListState { @@ -85,14 +86,51 @@ class NoteListNotifier extends Notifier { /// 阈值的作用是避免频繁进出页面时反复打请求。 static const _staleAfter = Duration(seconds: 30); + /// 「回到前台对账」的节流窗口。切一次应用就刷三个接口太吵,窗口内只对一次。 + static const _resumeThrottle = Duration(seconds: 30); + Timer? _searchDebounce; /// 上次成功拉到数据的时间;null = 还没加载过 DateTime? _lastLoadedAt; + /// 上次「回到前台对账」的时间;null = 还没对过 + /// + /// 故意做成实例字段而不是 `note_refresh.dart` 里的文件级变量: + /// 实例字段会随 [build] 复位,换人后节流窗口自动作废。放文件级的话, + /// 新用户第一次切回前台会被上一个用户留下的时间戳挡掉, + /// 看不到自己刚在 Web 上改的笔记。 + DateTime? _lastResumeAt; + + /// 是否该在「回到前台」这个时机对一次账。返回 true 时顺手记下时间。 + bool claimResumeCheck() { + final now = DateTime.now(); + final last = _lastResumeAt; + if (last != null && now.difference(last) < _resumeThrottle) return false; + _lastResumeAt = now; + return true; + } + @override NoteListState build() { ref.onDispose(() => _searchDebounce?.cancel()); + + // 换人(含登出)就重建 —— 笔记、搜索词、标签筛选都属于上一个人。 + // + // 下面几个实例字段要**手动**复位:Riverpod 重建时不会新建 Notifier 实例 + // (它用 `classListenable.result ??=` 把实例缓存住了),字段不会自己清。 + // _lastLoadedAt 尤其要紧 —— 不清的话 refreshIfStale() 会以为「刚加载过」, + // 对新用户直接 no-op,笔记列表就永远停在上一个人的数据。 + _lastLoadedAt = null; + _lastResumeAt = null; + _searchDebounce?.cancel(); + _searchDebounce = null; + + // 未登录不发请求:登出瞬间列表页还挂在 IndexedStack 上,这里会立刻重建, + // 而 token 已经被清掉了 —— 发出去必然 403,会被 ErrorInterceptor 当成 + // 「登录已过期」,弹一个假的红条。界面反正马上就没了,直接给空态。 + if (ref.watch(sessionUserProvider) == null) return const NoteListState(); + // build() 里不能同步读写 state,所以丢到微任务里再发起首次加载 Future.microtask(() => _fetchFirstPage( keyword: '', @@ -103,6 +141,12 @@ class NoteListNotifier extends Notifier { return const NoteListState(loading: true); } + /// 未登录时一律不发请求(理由见 [build])。 + /// + /// 每个会改 loading 标志的入口都要自己挡一下 —— 光靠 + /// [_fetchFirstPage] 早退的话,`loading: true` 会留在 state 里出不去。 + bool get _loggedIn => ref.read(sessionUserProvider) != null; + /// 取第一页。参数由调用方传入而不是读 state, /// 是为了能在 build() 阶段安全调用。 Future _fetchFirstPage({ @@ -111,6 +155,8 @@ class NoteListNotifier extends Notifier { required String sort, required NoteLocation location, }) async { + // 所有请求都从这里出去,兜底挡一道 + if (!_loggedIn) return; try { final result = await ref.read(noteApiProvider).list( page: 1, @@ -142,6 +188,7 @@ class NoteListNotifier extends Notifier { /// 下拉刷新 / 详情页改动后刷新。保留当前筛选条件。 Future refresh() async { + if (!_loggedIn) return; state = state.copyWith(loading: true, error: null); await _fetchFirstPage( keyword: state.keyword, @@ -196,6 +243,7 @@ class NoteListNotifier extends Notifier { /// 上拉加载下一页 Future loadMore() async { + if (!_loggedIn) return; if (state.loadingMore || !state.hasMore || state.loading) return; state = state.copyWith(loadingMore: true); diff --git a/lib/providers/note_refresh.dart b/lib/providers/note_refresh.dart index 202c4a2..b1b01c2 100644 --- a/lib/providers/note_refresh.dart +++ b/lib/providers/note_refresh.dart @@ -2,13 +2,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'note_provider.dart'; -/// 上次「回到前台对账」的时间,用来节流。 -/// -/// 切一次应用就刷三个接口太吵,30 秒内只对一次账。 -DateTime? _lastResumeCheck; - -const _resumeThrottle = Duration(seconds: 30); - /// App 重新回到前台时,对一次笔记数据的账。 /// /// 存在的理由:**在 Web 后台或另一台设备上改过笔记时,App 收不到任何通知** @@ -22,13 +15,15 @@ const _resumeThrottle = Duration(seconds: 30); /// 不主动对账的话,它们会一直显示旧数据。表现很容易被误解成「缓存没刷」, /// 但其实是数据真的没重取过。 /// +/// 节流时间戳存在 [NoteListNotifier] 上,不放这里 —— 放文件级变量的话, +/// 它会跨用户残留:新用户第一次切回前台,会被上一个用户留下的时间戳挡掉 +/// (进程没死,这个变量就一直在)。挂在 Notifier 实例上则会随换人重建而复位。 +/// /// [force] 为 true 时跳过节流(重新进入页面用)。 void refreshNoteDataOnResume(WidgetRef ref, {bool force = false}) { if (!force) { - final now = DateTime.now(); - final last = _lastResumeCheck; - if (last != null && now.difference(last) < _resumeThrottle) return; - _lastResumeCheck = now; + final notifier = ref.read(noteListProvider.notifier); + if (!notifier.claimResumeCheck()) return; } // 列表走 refreshIfStale 而不是 invalidate: diff --git a/lib/providers/ocr_provider.dart b/lib/providers/ocr_provider.dart index 50b0537..ddb3eac 100644 --- a/lib/providers/ocr_provider.dart +++ b/lib/providers/ocr_provider.dart @@ -8,6 +8,7 @@ import '../models/bill_request.dart'; import '../models/ocr_result.dart'; import '../utils/date_util.dart'; import 'bill_refresh.dart'; +import 'session_provider.dart'; /// OCR 识别流程状态 class OcrState { @@ -50,7 +51,12 @@ class OcrState { class OcrNotifier extends Notifier { @override - OcrState build() => const OcrState(); + OcrState build() { + // 换人(含登出)就重建 —— 上一个人拍的那张图和识别出来的条目都归他, + // 不该出现在新用户的界面上。 + ref.watch(sessionUserProvider); + return const OcrState(); + } void setImagePath(String path) { state = OcrState(imagePath: path); @@ -73,16 +79,21 @@ class OcrNotifier extends Notifier { state = state.copyWith(recognizing: true, error: null); try { final results = await ref.read(ocrApiProvider).recognize(path); + // await 之后身份可能已经变了(换人/登出会让这个 Notifier 重建, + // 或者整个 provider 被回收)。这时候再写 state,上一个人的识别结果 + // 就落到新会话里了 —— 而且对已销毁的 ref 写 state 会直接抛。 + if (!ref.mounted) return; state = state.copyWith( results: results, // 只有一条时自动展开 expandedIndexes: results.length == 1 ? {0} : {}, ); } on ApiException catch (e) { + if (!ref.mounted) return; state = state.copyWith(error: e.message); rethrow; } finally { - state = state.copyWith(recognizing: false); + if (ref.mounted) state = state.copyWith(recognizing: false); } } @@ -113,14 +124,17 @@ class OcrNotifier extends Notifier { )); } await ref.read(billApiProvider).createBillsBatch(bills); + // 同 recognize():await 之后可能已经换人了,不能再碰 state/ref + if (!ref.mounted) return; // 让首页列表与统计页重新取数 invalidateBillData(ref); state = const OcrState(); } on ApiException catch (e) { + if (!ref.mounted) return; state = state.copyWith(error: e.message); rethrow; } finally { - state = state.copyWith(saving: false); + if (ref.mounted) state = state.copyWith(saving: false); } } } diff --git a/lib/providers/session_provider.dart b/lib/providers/session_provider.dart new file mode 100644 index 0000000..c2a07c8 --- /dev/null +++ b/lib/providers/session_provider.dart @@ -0,0 +1,37 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'auth_provider.dart'; + +/// 当前登录用户的身份。`null` = 未登录;非 null(可能是空串)= 已登录。 +/// +/// ## 它是干什么的 +/// +/// **所有「属于某个用户」的 provider 都应该 watch 它。** 换了人(含登出) +/// 它们就会自动重建,上一个人的余额、预算、聊天记录不会留给下一个。 +/// +/// 为什么不能只靠 `autoDispose`:`ProviderContainer` 在 main.dart 里创建一次、 +/// 进程存活期内不重建,所以**不带 autoDispose 的 provider 会活到 App 结束**, +/// 登出根本不会清它们。新用户登录后读到的还是缓存值,连请求都不发 —— +/// 表现就是「换了账号还看到上一个人的余额」。 +/// +/// ## 两个刻意的选择 +/// +/// **不直接 watch(authProvider),而是 select 出用户名。** +/// 改头像、改昵称也会换掉 `AuthState`。而依赖 `sessionUserProvider` 的 +/// 那一批 provider 一旦被标记失效就会重建 —— 换个头像就把聊天记录、 +/// 笔记列表、记账草稿全清一遍,明显不对。select 到用户名后,这些变化 +/// 根本不会传导出去。 +/// +/// (细究的话 Riverpod 对 `==` 相等的值不会通知下游,所以「watch 整个 +/// AuthState 但返回值仍是用户名」其实也不会误清。但那是靠值相等兜底, +/// 语义上等于承认「改头像也算换人」,不该赌这个 —— 直接 select 更明确。) +/// +/// **用 isLoggedIn 判空,而不是只看 `user?.username`。** +/// `AuthNotifier.load()` 在「本地有 token 但拿不到 userInfo」时会得到 +/// `AuthState(token: t, user: null)`。只看 username 会把这种情况误判成 +/// 未登录,于是各处的空态护栏把整个 App 打回空数据。 +final sessionUserProvider = Provider((ref) { + return ref.watch(authProvider.select( + (auth) => auth.isLoggedIn ? (auth.user?.username ?? '') : null, + )); +}); diff --git a/lib/providers/statistics_provider.dart b/lib/providers/statistics_provider.dart index 8eb8b21..0d6c977 100644 --- a/lib/providers/statistics_provider.dart +++ b/lib/providers/statistics_provider.dart @@ -3,13 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/statistics_api.dart'; import '../models/statistics_response.dart'; import '../utils/date_util.dart'; +import 'session_provider.dart'; /// 统计页的时间粒度 enum StatisticsTab { daily, weekly, monthly } class StatisticsTabNotifier extends Notifier { @override - StatisticsTab build() => StatisticsTab.monthly; // 对齐 uniApp 默认按月 + StatisticsTab build() { + // 这条是纯 UI 偏好、不含任何隐私数据,复位只是为了「换人=全新开始」的一致感: + // 不然新用户打开统计页会莫名其妙停在上一个人选的粒度上。 + ref.watch(sessionUserProvider); + return StatisticsTab.monthly; // 对齐 uniApp 默认按月 + } void set(StatisticsTab tab) => state = tab; } diff --git a/lib/providers/watermark_provider.dart b/lib/providers/watermark_provider.dart index 6930ad7..669cdbb 100644 --- a/lib/providers/watermark_provider.dart +++ b/lib/providers/watermark_provider.dart @@ -7,16 +7,24 @@ import '../api/photo_api.dart'; import '../core/storage/local_storage.dart'; import '../models/photo.dart'; import '../models/watermark.dart'; +import 'session_provider.dart'; -/// 水印设置(全局共享,改动即落盘) +/// 水印设置(改动即落盘) /// -/// 跟项目里其他 provider 不同的一点:这个**不涉及网络**,纯粹是设备本地的偏好, +/// 跟项目里其他 provider 不同的一点:这个**不涉及网络**,纯粹是本地偏好, /// 所以直接读写 [LocalStorage],没有 api / 没有 refresh 失效那一套。 /// +/// 但它是**按用户隔离**的:水印上印的是自己的品牌信息,登出时连同 +/// 常用地址一起清掉(`LocalStorage.clearUserScopedData`),别串给下一个账号。 +/// /// 需要 `LocalStorage.instance.init()` 已经跑过(`main.dart` 启动时做了)。 class WatermarkSettingsNotifier extends Notifier { @override WatermarkSettings build() { + // 换人(含登出)就重建,重读一遍 —— 登出时本地那份已经被清掉了, + // 于是一并退回默认值。select 出来的是用户名,只换人会触发重建。 + ref.watch(sessionUserProvider); + final raw = LocalStorage.instance.getWatermarkSettings(); if (raw == null) return WatermarkSettings.defaults; // 存坏了就当没存过,用默认值兜住 —— 不能让一段脏 JSON 把页面打不开 diff --git a/test/note_refresh_test.dart b/test/note_refresh_test.dart index 82c48b3..2e5db93 100644 --- a/test/note_refresh_test.dart +++ b/test/note_refresh_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:qiangbao_accounting/api/note_api.dart'; import 'package:qiangbao_accounting/models/note.dart'; import 'package:qiangbao_accounting/providers/note_provider.dart'; +import 'package:qiangbao_accounting/providers/session_provider.dart'; /// 记录每次 list() 的调用,用来断言「有没有真的重新请求」。 class FakeNoteApi extends NoteApi { @@ -69,7 +70,16 @@ void main() { (ProviderContainer, FakeNoteApi) boot(List pages) { final api = FakeNoteApi(pages); final container = ProviderContainer( - overrides: [noteApiProvider.overrideWithValue(api)], + overrides: [ + noteApiProvider.overrideWithValue(api), + // 补一个登录态。noteListProvider 现在会 watch 它,未登录时走空态 + // 护栏、不发请求(那是为了登出瞬间不打无 token 的请求)。 + // + // 这里直接 override 而不是走真实 authProvider:本文件测的是 + // 「什么时候该对账」,和登录流程无关,override 不用碰 + // SharedPreferences,也不给这 5 条用例引入无关依赖。 + sessionUserProvider.overrideWithValue('u'), + ], ); addTearDown(container.dispose); return (container, api); diff --git a/test/user_isolation_test.dart b/test/user_isolation_test.dart new file mode 100644 index 0000000..fddaa5a --- /dev/null +++ b/test/user_isolation_test.dart @@ -0,0 +1,404 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:qiangbao_accounting/api/account_api.dart'; +import 'package:qiangbao_accounting/api/ai_api.dart'; +import 'package:qiangbao_accounting/api/budget_api.dart'; +import 'package:qiangbao_accounting/api/note_api.dart'; +import 'package:qiangbao_accounting/core/storage/local_storage.dart'; +import 'package:qiangbao_accounting/models/account.dart'; +import 'package:qiangbao_accounting/models/ai.dart'; +import 'package:qiangbao_accounting/models/auth_response.dart'; +import 'package:qiangbao_accounting/models/budget.dart'; +import 'package:qiangbao_accounting/models/note.dart'; +import 'package:qiangbao_accounting/providers/account_provider.dart'; +import 'package:qiangbao_accounting/providers/ai_provider.dart'; +import 'package:qiangbao_accounting/providers/auth_provider.dart'; +import 'package:qiangbao_accounting/providers/budget_provider.dart'; +import 'package:qiangbao_accounting/providers/note_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 回归测试:**换用户登录后,不能看到上一个用户的数据**。 +/// +/// ## 背景(真实踩过的 bug) +/// +/// `ProviderContainer` 在 main.dart 里创建一次、进程存活期内不重建,所以 +/// **不带 `autoDispose` 的 provider 会活到 App 结束 —— 登出根本不会清它们**。 +/// 新用户登录后读到的还是缓存值、连请求都不发,于是余额 / 本月预算 / 聊天记录 +/// 全显示上一个人的。 +/// +/// 修法是让这批 provider 都 watch `sessionUserProvider`,换人就重建。 +/// +/// ## 为什么这些用例能守住它 +/// +/// **刻意走真实的 `authProvider` 链路,不 override `sessionUserProvider`。** +/// 一旦 override 掉,`authProvider → sessionUser → 业务 provider` 这条链就断了, +/// 把修复改回旧行为测试照样过 —— 那就成了永远绿的假测试。 +/// +/// 每条用例都验证过「改坏代码会让它失败」,不是凑数的。 + +// ---------------------------------------------------------------- 测试替身 + +/// 余额可切换的假账户接口,顺带记调用次数。 +/// +/// [next] 第一次是「u1 的余额」,第二次换成「u2 的余额」—— 用它区分 +/// 界面上显示的到底是重新取回来的、还是上一个用户留下的缓存。 +class _FakeAccountApi extends AccountApi { + _FakeAccountApi(this.next) : super(Dio()); + + AccountInfo next; + int calls = 0; + + @override + Future getAccount() async { + calls++; + return next; + } + + @override + Future updateBalance(double initialBalance) async { + calls++; + return next; + } +} + +class _FakeBudgetApi extends BudgetApi { + _FakeBudgetApi(this.next) : super(Dio()); + + BudgetInfo next; + int calls = 0; + + @override + Future getBudget() async { + calls++; + return next; + } + + @override + Future setCurrentBudget(double amount) async { + calls++; + return next; + } +} + +class _FakeNoteApi extends NoteApi { + _FakeNoteApi() : super(Dio()); + + int listCalls = 0; + + @override + Future list({ + int page = 1, + int size = 20, + String scope = 'all', + int? folderId, + String sort = 'updated', + String? keyword, + String? tag, + }) async { + listCalls++; + return NotePage( + items: const [ + NoteListItem( + id: 1, + folderId: null, + title: 'u1 的笔记', + summary: '', + tags: [], + pinned: false, + updateTime: '2026-09-24 10:00', + ), + NoteListItem( + id: 2, + folderId: null, + title: 'u1 的另一篇', + summary: '', + tags: [], + pinned: false, + updateTime: '2026-09-24 09:00', + ), + ], + total: 2, + hasMore: false, + ); + } + + @override + Future> tags() async => const []; + + @override + Future folderTree() async => FolderTree.empty; +} + +class _FakeAiApi extends AiApi { + _FakeAiApi() : super(Dio()); + + int sessionsCalls = 0; + + @override + Future> sessions() async { + sessionsCalls++; + return const [ + ChatSessionItem(id: 9, title: '上一次的对话', updateTime: '2026-09-24 10:00'), + ]; + } + + @override + Future> messages(int sessionId) async => const [ + ChatMessageItem(id: 1, role: 'user', content: '你好'), + ChatMessageItem(id: 2, role: 'assistant', content: '你好呀'), + ]; +} + +// ---------------------------------------------------------------- 辅助 + +const _u1Money = 100.0; +const _u2Money = 777.0; + +AccountInfo _accountWith(double balance) => AccountInfo( + initialBalance: balance, + balance: balance, + totalIncome: 0, + totalExpense: 0, + ); + +BudgetInfo _budgetWith(double amount) => BudgetInfo( + amount: amount, + usedAmount: 0, + remainingAmount: amount, + remainingDaily: 0, + ); + +/// 真登录一次(写本地存储 + 改 auth 状态),不走界面。 +Future _login(ProviderContainer c, String username) { + return c.read(authProvider.notifier).loginSuccess( + AuthResponse(token: 'token-$username', username: username, nickname: username), + ); +} + +/// 等 Riverpod 把重建跑完。 +/// +/// 重建**不是同步的** —— `ref.watch` 的依赖变化只是把 provider 标记为失效, +/// 真正的重跑走 `container.scheduler` 的 `Timer(Duration.zero)` +/// (见 riverpod 的 scheduler.dart)。所以必须让事件循环转几圈。 +Future _settle() async { + for (var i = 0; i < 3; i++) { + await Future.delayed(Duration.zero); + } +} + +void main() { + // LocalStorage 是单例,会真的写字。测试里给它一个空实现。 + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + // ------------------------------------------------------------ 账户 / 余额 + + test('★ 登出后不再展示上一用户的余额,也不再发请求', () async { + final api = _FakeAccountApi(_accountWith(_u1Money)); + final c = ProviderContainer( + overrides: [accountApiProvider.overrideWithValue(api)], + ); + addTearDown(c.dispose); + + // 模拟 MinePage 挂在 IndexedStack 上常驻监听。 + // 没有监听者的话 Riverpod 不会调度重建,这条测试就测不到东西了。 + final sub = c.listen(accountProvider, (_, _) {}); + addTearDown(sub.close); + + await _login(c, 'u1'); + await _settle(); + expect(c.read(accountProvider).value?.balance, _u1Money); + + final callsWhileLoggedIn = api.calls; + + await c.read(authProvider.notifier).logout(); + await _settle(); + + expect(c.read(accountProvider).value?.balance, 0, + reason: '登出后不能还挂着上一用户的余额'); + expect(api.calls, callsWhileLoggedIn, + reason: '未登录不得再发请求 —— 那时候 token 已经清了,' + '请求必然 403,会被 ErrorInterceptor 当成「登录已过期」弹假红条'); + }); + + test('★ 换账号后余额是新账号的,不是上一个留下的缓存', () async { + final api = _FakeAccountApi(_accountWith(_u1Money)); + final c = ProviderContainer( + overrides: [accountApiProvider.overrideWithValue(api)], + ); + addTearDown(c.dispose); + final sub = c.listen(accountProvider, (_, _) {}); + addTearDown(sub.close); + + await _login(c, 'u1'); + await _settle(); + expect(c.read(accountProvider).value?.balance, _u1Money); + + // 服务端那边 u2 的余额是另一个数 + api.next = _accountWith(_u2Money); + + await c.read(authProvider.notifier).logout(); + await _settle(); + await _login(c, 'u2'); + await _settle(); + + expect(c.read(accountProvider).value?.balance, _u2Money, + reason: '换人必须重新取数,不能拿缓存顶上'); + }); + + // ------------------------------------------------------------ 助手 / 聊天记录 + + test('★ 登出后聊天记录清空,且新用户还能自动恢复自己的会话', () async { + final api = _FakeAiApi(); + final c = ProviderContainer(overrides: [aiApiProvider.overrideWithValue(api)]); + addTearDown(c.dispose); + final sub = c.listen(assistantProvider, (_, _) {}); + addTearDown(sub.close); + + await _login(c, 'u1'); + await _settle(); + + // u1 开了个新对话(会顺手把 _autoRestored 置 true) + c.read(assistantProvider.notifier).newChat(personaId: 7); + expect(c.read(assistantProvider).personaId, 7); + + await c.read(authProvider.notifier).logout(); + await _settle(); + + final afterLogout = c.read(assistantProvider); + expect(afterLogout.personaId, isNull, reason: '会话锁定的人设属于上一个人'); + expect(afterLogout.sessionId, isNull); + expect(afterLogout.bubbles, isEmpty); + + // ★ 这条才是「实例字段必须手动复位」的哨兵。 + // Riverpod 重建时**不会新建 Notifier 实例**,所以 _autoRestored 不会自己清。 + // 忘了在 build() 里复位的话,下面这次恢复会被上一轮留下的 true 挡掉, + // 新用户进助手页永远看不到自己的历史对话。 + await _login(c, 'u2'); + await _settle(); + await c.read(assistantProvider.notifier).restoreLastSessionIfNeeded(); + await _settle(); + + expect(c.read(assistantProvider).sessionId, 9, + reason: '换了人之后第一次进助手页,应该能恢复新用户自己的上一次会话'); + }); + + // ------------------------------------------------------------ 笔记列表 + + test('★ 登出后笔记列表清空,且不再发请求', () async { + final api = _FakeNoteApi(); + final c = ProviderContainer( + overrides: [noteApiProvider.overrideWithValue(api)], + ); + addTearDown(c.dispose); + final sub = c.listen(noteListProvider, (_, _) {}); + addTearDown(sub.close); + + await _login(c, 'u1'); + await _settle(); + expect(api.listCalls, 1); + expect(c.read(noteListProvider).items.length, 2); + + await c.read(authProvider.notifier).logout(); + await _settle(); + + final afterLogout = c.read(noteListProvider); + expect(afterLogout.items, isEmpty, reason: '笔记属于上一个人,不能留在列表里'); + expect(afterLogout.loading, isFalse, + reason: '登出后的空态不该是「加载中」—— 界面会一直转圈。' + '卡住的原因是 build() 里返回了 loading:true 却没发请求,' + '没人再把它改回 false'); + expect(api.listCalls, 1, reason: '未登录不该再发请求'); + }); + + // ------------------------------------------------------------ 预算 + + test('★ 换账号后本月预算是新账号的', () async { + final api = _FakeBudgetApi(_budgetWith(3000)); + final c = ProviderContainer( + overrides: [budgetApiProvider.overrideWithValue(api)], + ); + addTearDown(c.dispose); + final sub = c.listen(budgetProvider, (_, _) {}); + addTearDown(sub.close); + + await _login(c, 'u1'); + await _settle(); + expect(c.read(budgetProvider).value?.amount, 3000); + + api.next = _budgetWith(500); + + await c.read(authProvider.notifier).logout(); + await _settle(); + await _login(c, 'u2'); + await _settle(); + + expect(c.read(budgetProvider).value?.amount, 500, + reason: '预算卡必须重新取数,不能显示上一个人的预算'); + }); + + // ------------------------------------------------------------ 一个容易被打回的设计决定 + + test('★ 改头像/昵称不该把数据清掉(只认用户名,不认整个 AuthState)', () async { + // sessionUserProvider 是 select 到「用户名」的,不是 watch 整个 authProvider。 + // 因为改头像、改昵称也会换掉 AuthState —— 直接 watch 会把聊天记录、 + // 笔记列表、记账草稿全无谓清空。哪天有人图省事把它改成 watch(authProvider), + // 这条会拦下来。 + final noteApi = _FakeNoteApi(); + final c = ProviderContainer( + overrides: [noteApiProvider.overrideWithValue(noteApi)], + ); + addTearDown(c.dispose); + final noteSub = c.listen(noteListProvider, (_, _) {}); + addTearDown(noteSub.close); + final assistantSub = c.listen(assistantProvider, (_, _) {}); + addTearDown(assistantSub.close); + + await _login(c, 'u1'); + await _settle(); + c.read(assistantProvider.notifier).newChat(personaId: 7); + expect(c.read(noteListProvider).items.length, 2); + + // 用户在「我的」页面换了头像 —— username 没变,只是头像变了 + await c.read(authProvider.notifier).updateUserFromResponse( + const AuthResponse(token: '', username: 'u1', nickname: 'u1', avatar: 'me.jpg'), + ); + await _settle(); + + expect(c.read(assistantProvider).personaId, 7, + reason: '换头像不是换人,聊天记录不该被清'); + expect(c.read(noteListProvider).items.length, 2, + reason: '换头像不是换人,笔记列表不该被清空重取'); + expect(noteApi.listCalls, 1, reason: '不该因为改头像就多打一次请求'); + }); + + // ------------------------------------------------------------ 本地存储 + + test('★ 登出清掉本地存着的用户级数据', () async { + final c = ProviderContainer(); + addTearDown(c.dispose); + + await _login(c, 'u1'); + + // 这些 key 存的是「属于某个账号」的东西: + // 前两个是服务端数据行的 id,后三个是个人资料/偏好 + await LocalStorage.instance.saveAiConfigId(42); + await LocalStorage.instance.saveAiPersonaId(99); + await LocalStorage.instance.saveWatermarkSettings({'posX': 0.5}); + await LocalStorage.instance.saveWatermarkAddresses(['昆明市公安局']); + await LocalStorage.instance.saveRememberedPassword('u1', 'encrypted'); + + await c.read(authProvider.notifier).logout(); + + expect(LocalStorage.instance.getAiConfigId(), isNull, + reason: 'AI 配置存的是行 id,留着会让新用户指到别人的配置上'); + expect(LocalStorage.instance.getAiPersonaId(), isNull, reason: '人设同理'); + expect(LocalStorage.instance.getWatermarkSettings(), isNull, + reason: '水印上印的是自己的品牌信息'); + expect(LocalStorage.instance.getWatermarkAddresses(), isEmpty, + reason: '常用地址是用户常去的地点'); + expect(LocalStorage.instance.getRememberedAccount(), isNull, + reason: '不清的话登录页会把上一账号连明文密码一起回填'); + }); +}