用户数据隔离优化

This commit is contained in:
2026-09-30 09:52:41 +08:00
parent 900d48697e
commit b5bcc1749a
19 changed files with 648 additions and 24 deletions
+5
View File
@@ -29,6 +29,11 @@ Future<void> 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,
+26
View File
@@ -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<void> 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]
+9 -1
View File
@@ -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);
+15 -1
View File
@@ -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<AccountInfo> {
@override
Future<AccountInfo> build() => ref.watch(accountApiProvider).getAccount();
FutureOr<AccountInfo> build() {
// 换人(含登出)就重建,绝不把上一个人的余额留给下一个。
//
// 未登录时直接给空态、**不发请求**:登出瞬间 MinePage 还挂在
// IndexedStack 上(页面卸载在下一帧,而 provider 重建走 Timer 调度,
// 会先发生),这时 token 已经被清掉了 —— 发出去必然 403,
// 会被 ErrorInterceptor 当成「登录已过期」,弹一个假的红条。
//
// 同步返回的好处:走 AsyncData 而不是 AsyncLoading,不闪加载圈。
if (ref.watch(sessionUserProvider) == null) return AccountInfo.empty;
return ref.watch(accountApiProvider).getAccount();
}
/// 把账户余额改成 [targetBalance]。
///
+6 -1
View File
@@ -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<AddBillFormState> {
@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;
+6
View File
@@ -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<List<AiConfigItem>>((ref) {
@@ -17,6 +18,11 @@ final aiConfigsProvider = FutureProvider.autoDispose<List<AiConfigItem>>((ref) {
class SelectedAiConfigNotifier extends Notifier<int?> {
@override
int? build() {
// 换人(含登出)就重建。**必须重读一遍**:存的是配置文件的行 id,
// 属于上一个人的数据行,登出时本地那份也被清掉了(LocalStorage
// .clearUserScopedData),重读才会落到 null。
ref.watch(sessionUserProvider);
// 存的就是「真实 id」,null = 用默认。
// 默认项在列表里的 id 也是 null,两边语义天然一致。
return LocalStorage.instance.getAiConfigId();
+6
View File
@@ -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<List<AiPersonaItem>>((ref)
class PreferredPersonaNotifier extends Notifier<int?> {
@override
int? build() {
// 换人(含登出)就重建。**必须重读一遍**:存的是人设的行 id,
// 属于上一个人的数据行,登出时本地那份也被清掉了(LocalStorage
// .clearUserScopedData),重读才会落到 null。
ref.watch(sessionUserProvider);
// 存的是「真实 id」,null = 内置默认。
// 默认项在列表里的 id 也是 null,两边语义天然一致。
return LocalStorage.instance.getAiPersonaId();
+12 -1
View File
@@ -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<bool>((ref) {
@@ -95,7 +96,17 @@ class AssistantNotifier extends Notifier<AssistantState> {
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();
}
/// 打开助手页时调用:界面还是空的就把最近一次对话展开。
///
+6
View File
@@ -62,7 +62,13 @@ class AuthNotifier extends Notifier<AuthState> {
}
Future<void> logout() async {
// 顺序要紧:先把本地清干净,最后才改 state。
//
// 改 state 会触发所有 watch `sessionUserProvider` 的 provider 重建,
// 而它们 rebuild 时会重读本地存储(AI 选中的行 id、水印设置)。
// 反过来先改 state 的话,那批 provider 会读到还没清掉的脏数据。
await LocalStorage.instance.clearAuth();
await LocalStorage.instance.clearUserScopedData();
state = const AuthState.initial();
}
}
+7 -1
View File
@@ -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<DateTime> {
@override
DateTime build() => monthStart(DateTime.now());
DateTime build() {
// 换人(含登出)就回到当月 —— 不然新用户打开账单页会落在
// 上一个人翻到的那个月份上,看着像是他的数据还在。
ref.watch(sessionUserProvider);
return monthStart(DateTime.now());
}
bool get canGoNext => state.isBefore(monthStart(DateTime.now()));
+10 -1
View File
@@ -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<BudgetInfo> {
@override
Future<BudgetInfo> build() => ref.watch(budgetApiProvider).getBudget();
FutureOr<BudgetInfo> build() {
// 换人(含登出)就重建,绝不把上一个人的预算留给下一个。
// 未登录直接给空态、不发请求 —— 理由同 account_provider.dart 的说明
// (登出瞬间页面还没卸载,发出去必然 403,会弹假的「登录已过期」)。
if (ref.watch(sessionUserProvider) == null) return BudgetInfo.empty;
return ref.watch(budgetApiProvider).getBudget();
}
/// 设置/修改当月预算(立即生效,可随时修改)
Future<void> setMonthBudget(double amount) async {
+48
View File
@@ -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<NoteListState> {
/// 阈值的作用是避免频繁进出页面时反复打请求。
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<NoteListState> {
return const NoteListState(loading: true);
}
/// 未登录时一律不发请求(理由见 [build])。
///
/// 每个会改 loading 标志的入口都要自己挡一下 —— 光靠
/// [_fetchFirstPage] 早退的话,`loading: true` 会留在 state 里出不去。
bool get _loggedIn => ref.read(sessionUserProvider) != null;
/// 取第一页。参数由调用方传入而不是读 state,
/// 是为了能在 build() 阶段安全调用。
Future<void> _fetchFirstPage({
@@ -111,6 +155,8 @@ class NoteListNotifier extends Notifier<NoteListState> {
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<NoteListState> {
/// 下拉刷新 / 详情页改动后刷新。保留当前筛选条件。
Future<void> 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<NoteListState> {
/// 上拉加载下一页
Future<void> loadMore() async {
if (!_loggedIn) return;
if (state.loadingMore || !state.hasMore || state.loading) return;
state = state.copyWith(loadingMore: true);
+6 -11
View File
@@ -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:
+17 -3
View File
@@ -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<OcrState> {
@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<OcrState> {
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} : <int>{},
);
} 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<OcrState> {
));
}
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);
}
}
}
+37
View File
@@ -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<String?>((ref) {
return ref.watch(authProvider.select(
(auth) => auth.isLoggedIn ? (auth.user?.username ?? '') : null,
));
});
+7 -1
View File
@@ -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<StatisticsTab> {
@override
StatisticsTab build() => StatisticsTab.monthly; // 对齐 uniApp 默认按月
StatisticsTab build() {
// 这条是纯 UI 偏好、不含任何隐私数据,复位只是为了「换人=全新开始」的一致感:
// 不然新用户打开统计页会莫名其妙停在上一个人选的粒度上。
ref.watch(sessionUserProvider);
return StatisticsTab.monthly; // 对齐 uniApp 默认按月
}
void set(StatisticsTab tab) => state = tab;
}
+10 -2
View File
@@ -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<WatermarkSettings> {
@override
WatermarkSettings build() {
// 换人(含登出)就重建,重读一遍 —— 登出时本地那份已经被清掉了,
// 于是一并退回默认值。select 出来的是用户名,只换人会触发重建。
ref.watch(sessionUserProvider);
final raw = LocalStorage.instance.getWatermarkSettings();
if (raw == null) return WatermarkSettings.defaults;
// 存坏了就当没存过,用默认值兜住 —— 不能让一段脏 JSON 把页面打不开
+11 -1
View File
@@ -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<NotePage> 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);
+404
View File
@@ -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<AccountInfo> getAccount() async {
calls++;
return next;
}
@override
Future<AccountInfo> updateBalance(double initialBalance) async {
calls++;
return next;
}
}
class _FakeBudgetApi extends BudgetApi {
_FakeBudgetApi(this.next) : super(Dio());
BudgetInfo next;
int calls = 0;
@override
Future<BudgetInfo> getBudget() async {
calls++;
return next;
}
@override
Future<BudgetInfo> setCurrentBudget(double amount) async {
calls++;
return next;
}
}
class _FakeNoteApi extends NoteApi {
_FakeNoteApi() : super(Dio());
int listCalls = 0;
@override
Future<NotePage> 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<List<String>> tags() async => const [];
@override
Future<FolderTree> folderTree() async => FolderTree.empty;
}
class _FakeAiApi extends AiApi {
_FakeAiApi() : super(Dio());
int sessionsCalls = 0;
@override
Future<List<ChatSessionItem>> sessions() async {
sessionsCalls++;
return const [
ChatSessionItem(id: 9, title: '上一次的对话', updateTime: '2026-09-24 10:00'),
];
}
@override
Future<List<ChatMessageItem>> 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<void> _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<void> _settle() async {
for (var i = 0; i < 3; i++) {
await Future<void>.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: '不清的话登录页会把上一账号连明文密码一起回填');
});
}