Files
nj/app/lib/data/repositories/auth_repository.dart
iven 0fe3bc705c feat(app): 实现认证模块 — F2 Auth BLoC + 登录/注册/角色选择/班级码加入
新增文件 (10):
- data/models/user.dart — 用户+角色模型 (匹配后端 UserResp/RoleResp)
- data/models/auth_token.dart — 认证令牌模型 (匹配后端 LoginResp)
- data/repositories/auth_repository.dart — 认证仓库 (JWT 安全持久化 + PIPL 合规)
- features/auth/bloc/auth_bloc.dart — 认证 BLoC (8 种事件, 6 种状态)
- features/auth/bloc/auth_event.dart — 认证事件 (sealed class 穷尽匹配)
- features/auth/bloc/auth_state.dart — 认证状态 (Authenticated 含角色/班级码流程)
- features/auth/views/login_page.dart — 登录/注册页面 (重写占位页面)
- features/auth/views/role_selection_page.dart — 角色选择页 (4 种角色卡片)
- features/auth/views/class_code_join_page.dart — 班级码加入页 (6 位输入)

修改文件 (5):
- pubspec.yaml — 添加 flutter_secure_storage 依赖
- app.dart — 注入 AuthBloc + RepositoryProvider
- main.dart — 简化入口 (认证恢复在 BLoC 中处理)
- core/routing/app_router.dart — 添加认证路由守卫 + 2 新路由
- erp-diary/service/class_service.rs — 移除未使用的 PaginatorTrait import

验证: flutter analyze (0 error) + cargo check 通过
2026-06-01 01:22:53 +08:00

265 lines
7.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 认证仓库 — 登录/注册/令牌管理的统一入口
//
// 职责:
// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出)
// - 通过 flutter_secure_storage 安全持久化 JWT 令牌PIPL 合规)
// - 为 AuthBloc 提供干净的认证数据访问接口
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:logger/logger.dart';
import '../models/auth_token.dart';
import '../models/user.dart';
import '../remote/api_client.dart';
/// 安全存储键名
const _keyAccessToken = 'auth_access_token';
const _keyRefreshToken = 'auth_refresh_token';
const _keyExpiresAt = 'auth_expires_at';
const _keyUserJson = 'auth_user_json';
/// 认证异常 — 认证流程中出现的错误
class AuthException implements Exception {
final String message;
final int? statusCode;
const AuthException(this.message, {this.statusCode});
@override
String toString() => 'AuthException: $message';
}
/// 认证仓库 — 管理用户登录状态和令牌
///
/// 使用 [ApiClient] 与后端通信,使用 [FlutterSecureStorage] 持久化令牌。
/// 所有令牌操作都是加密存储,满足儿童数据 PIPL 合规要求。
class AuthRepository {
final ApiClient _apiClient;
final FlutterSecureStorage _secureStorage;
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
AuthToken? _currentToken;
User? _currentUser;
AuthRepository({
required ApiClient apiClient,
FlutterSecureStorage? secureStorage,
}) : _apiClient = apiClient,
_secureStorage = secureStorage ??
const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
/// 当前用户(可能为 null
User? get currentUser => _currentUser;
/// 当前令牌(可能为 null
AuthToken? get currentToken => _currentToken;
/// 是否已登录
bool get isAuthenticated => _currentToken != null && !_currentToken!.isExpired;
// ===== 登录 =====
/// 用户名密码登录
///
/// 调用后端 `POST /auth/login`,成功后保存令牌和用户信息。
Future<User> login({
required String username,
required String password,
}) async {
_logger.i('登录请求: $username');
final response = await _apiClient.post('/auth/login', data: {
'username': username,
'password': password,
'client_type': 'mobile',
});
final data = _extractData(response.data);
final token = AuthToken.fromJson(data);
final user = User.fromJson(data['user'] as Map<String, dynamic>);
await _saveAuth(token, user);
_apiClient.setToken(token.accessToken);
_logger.i('登录成功: ${user.displayLabel}');
return user;
}
// ===== 注册 =====
/// 注册新用户
///
/// 调用后端 `POST /users`,成功后自动登录。
Future<User> register({
required String username,
required String password,
String? displayName,
}) async {
_logger.i('注册请求: $username');
await _apiClient.post('/users', data: {
'username': username,
'password': password,
if (displayName != null) 'display_name': displayName,
});
// 注册成功后自动登录
return await login(username: username, password: password);
}
// ===== 刷新令牌 =====
/// 刷新访问令牌
///
/// 调用后端 `POST /auth/refresh`,使用 refresh_token 获取新的 access_token。
Future<AuthToken> refreshToken() async {
if (_currentToken == null) {
throw const AuthException('无可用令牌,请重新登录');
}
_logger.d('刷新令牌');
final response = await _apiClient.post('/auth/refresh', data: {
'refresh_token': _currentToken!.refreshToken,
});
final data = _extractData(response.data);
final token = AuthToken.fromJson(data);
await _saveToken(token);
_apiClient.setToken(token.accessToken);
return token;
}
// ===== 登出 =====
/// 登出
///
/// 调用后端 `POST /auth/logout` 并清除本地存储。
Future<void> logout() async {
_logger.i('登出');
try {
if (isAuthenticated) {
await _apiClient.post('/auth/logout');
}
} catch (e) {
_logger.w('登出 API 调用失败(忽略): $e');
}
await _clearAuth();
_apiClient.clearToken();
}
// ===== 本地恢复 =====
/// 从安全存储恢复认证状态
///
/// App 启动时调用,检查是否有有效的持久化令牌。
/// 如果令牌即将过期,自动刷新。
Future<User?> restoreAuth() async {
_logger.d('恢复认证状态');
try {
final accessToken = await _secureStorage.read(key: _keyAccessToken);
final refreshTokenStr = await _secureStorage.read(key: _keyRefreshToken);
final expiresAtStr = await _secureStorage.read(key: _keyExpiresAt);
final userJsonStr = await _secureStorage.read(key: _keyUserJson);
if (accessToken == null || refreshTokenStr == null || userJsonStr == null) {
_logger.d('无存储的认证信息');
return null;
}
final expiresAt = DateTime.parse(expiresAtStr ?? DateTime.now().subtract(const Duration(hours: 1)).toIso8601String());
_currentToken = AuthToken(
accessToken: accessToken,
refreshToken: refreshTokenStr,
expiresIn: 0,
expiresAt: expiresAt,
);
_currentUser = User.fromJson(
jsonDecode(userJsonStr) as Map<String, dynamic>,
);
// 令牌已过期 → 尝试刷新
if (_currentToken!.isExpired) {
_logger.d('令牌已过期,尝试刷新');
try {
await refreshToken();
} catch (e) {
_logger.w('令牌刷新失败,需要重新登录: $e');
await _clearAuth();
return null;
}
} else if (_currentToken!.isExpiringSoon) {
// 即将过期 → 后台刷新(不阻塞)
_logger.d('令牌即将过期,后台刷新');
refreshToken().catchError((e) {
_logger.w('后台刷新失败: $e');
return _currentToken!; // 返回当前令牌作为 fallback
});
}
_apiClient.setToken(_currentToken!.accessToken);
_logger.i('认证恢复成功: ${_currentUser!.displayLabel}');
return _currentUser;
} catch (e) {
_logger.e('认证恢复失败: $e');
await _clearAuth();
return null;
}
}
// ===== 私有方法 =====
/// 从 API 响应中提取 data 字段
Map<String, dynamic> _extractData(dynamic responseData) {
if (responseData is Map<String, dynamic>) {
// 后端 ApiResponse 格式: { success: bool, data: T, message: String? }
if (responseData.containsKey('data')) {
return responseData['data'] as Map<String, dynamic>;
}
return responseData;
}
throw const AuthException('服务器响应格式异常');
}
/// 保存令牌和用户到安全存储
Future<void> _saveAuth(AuthToken token, User user) async {
_currentToken = token;
_currentUser = user;
await _saveToken(token);
await _secureStorage.write(
key: _keyUserJson,
value: jsonEncode(user.toJson()),
);
}
/// 仅保存令牌到安全存储
Future<void> _saveToken(AuthToken token) async {
_currentToken = token;
await _secureStorage.write(key: _keyAccessToken, value: token.accessToken);
await _secureStorage.write(key: _keyRefreshToken, value: token.refreshToken);
await _secureStorage.write(key: _keyExpiresAt, value: token.expiresAt.toIso8601String());
}
/// 清除所有认证数据
Future<void> _clearAuth() async {
_currentToken = null;
_currentUser = null;
await _secureStorage.delete(key: _keyAccessToken);
await _secureStorage.delete(key: _keyRefreshToken);
await _secureStorage.delete(key: _keyExpiresAt);
await _secureStorage.delete(key: _keyUserJson);
}
}