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 通过
This commit is contained in:
iven
2026-06-01 01:22:53 +08:00
parent 232a53dbed
commit 0fe3bc705c
15 changed files with 1805 additions and 113 deletions

View File

@@ -0,0 +1,264 @@
// 认证仓库 — 登录/注册/令牌管理的统一入口
//
// 职责:
// - 封装后端认证 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);
}
}