用户数据隔离优化

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 把页面打不开