import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../config/app_config.dart'; import '../config/app_constants.dart'; const _kAccessToken = 'access_token'; const _kRefreshToken = 'refresh_token'; const _kUserId = 'user_id'; const _kUsername = 'username'; const _kRealName = 'real_name'; const _kShopNo = 'shop_no'; const _kShopId = 'shop_id'; const _kRole = 'role'; class AuthUser { final String accessToken; final String refreshToken; final int id; final String username; final String realName; final String shopNo; final int shopId; final String role; const AuthUser({ required this.accessToken, required this.refreshToken, this.id = 0, required this.username, required this.realName, required this.shopNo, required this.shopId, this.role = 'operator', }); // Kept for ApiClient compatibility String get token => accessToken; } class AuthState { final AuthUser? user; final bool initialized; const AuthState({this.user, this.initialized = false}); bool get isLoggedIn => user != null; } class AuthNotifier extends StateNotifier { AuthNotifier() : super(const AuthState()); /// 会话代号:每次 login/logout 自增,使「上一会话」遗留的异步持久化写入失效。 /// 续期写回(updateAccessToken)是 fire-and-forget,若在并发登出清空 token 之后 /// 才落盘,会把已失效的 token 重新写回;持有此代号即可在写入前丢弃过期操作。 int _sessionGen = 0; /// 串行化 SharedPreferences 写入:保证 login / updateAccessToken / logout 的持久化 /// 按调用顺序落盘,杜绝并发交错(如续期写入与登出 remove 交叉)。 Future _storageQueue = Future.value(); Future _runStorage(Future Function() op) { final next = _storageQueue.then((_) => op()); // 吞掉单次写入异常,避免队列被卡死。 _storageQueue = next.catchError((_) {}); return next; } /// Called at app startup to restore persisted session. Future restore() async { try { final prefs = await SharedPreferences.getInstance(); final accessToken = prefs.getString(_kAccessToken); final refreshToken = prefs.getString(_kRefreshToken); final username = prefs.getString(_kUsername); final realName = prefs.getString(_kRealName); final shopNo = prefs.getString(_kShopNo); final shopIdStr = prefs.getString(_kShopId); final role = prefs.getString(_kRole); final userId = prefs.getInt(_kUserId) ?? 0; if (accessToken != null && refreshToken != null && username != null) { state = AuthState( initialized: true, user: AuthUser( accessToken: accessToken, refreshToken: refreshToken, id: userId, username: username, realName: realName ?? username, shopNo: shopNo ?? '', shopId: int.tryParse(shopIdStr ?? '') ?? 0, role: role ?? 'operator', ), ); return; } } catch (_) { // Storage unavailable — fall through to logged-out state } state = const AuthState(initialized: true); } Future login(AuthUser user) async { debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}'); final gen = ++_sessionGen; await _runStorage(() async { if (gen != _sessionGen) return; // 期间又发生 login/logout → 放弃这次写盘 final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kAccessToken, user.accessToken); await prefs.setString(_kRefreshToken, user.refreshToken); await prefs.setInt(_kUserId, user.id); await prefs.setString(_kUsername, user.username); await prefs.setString(_kRealName, user.realName); await prefs.setString(_kShopNo, user.shopNo); await prefs.setString(_kShopId, user.shopId.toString()); await prefs.setString(_kRole, user.role); }); state = AuthState(initialized: true, user: user); debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}'); } /// 续期后写回令牌。后端会**轮换 refresh token**(jti 轮换 + 盗用检测), /// 因此必须连同新的 refresh token 一起持久化;若仍沿用旧 refresh token, /// 下次续期会重放已被取代的 jti,触发盗用检测吊销整条会话,导致被强制登出。 /// [newRefreshToken] 省略(如旧调用方)时保留原 refresh token。 void updateAccessToken(String newToken, [String? newRefreshToken]) { if (state.user == null) return; final gen = _sessionGen; final refreshToken = newRefreshToken ?? state.user!.refreshToken; // fire-and-forget,但经队列串行 + 代号守卫:若落盘前发生过登出/重登,丢弃此次写入, // 避免把已失效的 token 写回 SharedPreferences。 _runStorage(() async { if (gen != _sessionGen) return; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kAccessToken, newToken); await prefs.setString(_kRefreshToken, refreshToken); }); state = AuthState( initialized: true, user: AuthUser( accessToken: newToken, refreshToken: refreshToken, id: state.user!.id, username: state.user!.username, realName: state.user!.realName, shopNo: state.user!.shopNo, shopId: state.user!.shopId, role: state.user!.role, ), ); } Future logout() async { debugPrint('[Auth] logout() called! stack: ${StackTrace.current}'); final token = state.user?.accessToken; // 先作废在途的令牌持久化并立即本地登出,使任何并发的续期写入在落盘前被丢弃。 _sessionGen++; state = const AuthState(initialized: true); // 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出) if (token != null && token.isNotEmpty) { try { await Dio(BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: AppConstants.authProbeTimeout, receiveTimeout: AppConstants.authProbeTimeout, headers: {'Authorization': 'Bearer $token'}, )).post('/auth/logout'); } catch (_) { // ignore: 后端不可达或会话已失效都无所谓 } } // 经队列串行清空 token,排在任何先前入队的续期写入之后,确保最终为已登出状态。 await _runStorage(() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_kAccessToken); await prefs.remove(_kRefreshToken); await prefs.remove(_kUserId); await prefs.remove(_kUsername); await prefs.remove(_kRealName); await prefs.remove(_kShopNo); await prefs.remove(_kShopId); await prefs.remove(_kRole); }); } } final authStateProvider = StateNotifierProvider( (ref) => AuthNotifier(), ); /// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。 final sessionEndedMessageProvider = StateProvider((ref) => null); /// 全局轻提示(如写请求被后端 403 拒绝时的原因说明)。 /// app_shell 监听后 showSnackBar 并清空,避免各业务层各自处理。 final apiMessageProvider = StateProvider((ref) => null); /// 当前登录用户是否为只读角色(role == 'readonly')。 /// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮, /// 后端亦有 middleware.ReadOnly() 兜底返回 403。 final isReadonlyProvider = Provider((ref) { final role = ref.watch(authStateProvider.select((s) => s.user?.role)); return role == 'readonly'; }); /// 当前登录用户是否具备管理员权限(admin 或 superadmin)。 /// 与后端 middleware.AdminOnly() 一致(两者皆放行),用于「撤回审核中单据」等操作。 final isAdminProvider = Provider((ref) { final role = ref.watch(authStateProvider.select((s) => s.user?.role)); return role == 'admin' || role == 'superadmin'; }); /// 当前登录用户 id(未登录为 0)。用于「撤回本人单据」等需要判断单据归属的场景。 final currentUserIdProvider = Provider((ref) { return ref.watch(authStateProvider.select((s) => s.user?.id)) ?? 0; });