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:
54
app/lib/data/models/auth_token.dart
Normal file
54
app/lib/data/models/auth_token.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
// 认证令牌模型 — 匹配后端 LoginResp
|
||||
//
|
||||
// 管理访问令牌和刷新令牌,支持自动计算过期时间。
|
||||
// 令牌通过 flutter_secure_storage 安全持久化(PIPL 合规要求)。
|
||||
|
||||
/// 认证令牌 — 包含访问令牌、刷新令牌和过期信息
|
||||
class AuthToken {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const AuthToken({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresIn,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
/// 令牌是否已过期
|
||||
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||
|
||||
/// 令牌是否即将过期(5 分钟内)
|
||||
bool get isExpiringSoon =>
|
||||
DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 5)));
|
||||
|
||||
/// 从后端 LoginResp JSON 创建
|
||||
factory AuthToken.fromJson(Map<String, dynamic> json) {
|
||||
final expiresIn = (json['expires_in'] as int?) ?? 3600;
|
||||
return AuthToken(
|
||||
accessToken: json['access_token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
expiresIn: expiresIn,
|
||||
expiresAt: DateTime.now().add(Duration(seconds: expiresIn)),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken,
|
||||
'expires_in': expiresIn,
|
||||
'expires_at': expiresAt.toIso8601String(),
|
||||
};
|
||||
|
||||
/// 从持久化存储恢复(使用保存的过期时间)
|
||||
factory AuthToken.fromStorage(Map<String, dynamic> json) => AuthToken(
|
||||
accessToken: json['access_token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
expiresIn: (json['expires_in'] as int?) ?? 3600,
|
||||
expiresAt: json['expires_at'] != null
|
||||
? DateTime.parse(json['expires_at'] as String)
|
||||
: DateTime.now().add(const Duration(hours: 1)),
|
||||
);
|
||||
}
|
||||
153
app/lib/data/models/user.dart
Normal file
153
app/lib/data/models/user.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
// 用户数据模型 — 匹配后端 UserResp / RoleResp
|
||||
//
|
||||
// 暖记用户包含四种角色:老师、学生、家长、独立用户。
|
||||
// 角色决定用户可访问的功能模块和页面。
|
||||
|
||||
/// 用户角色枚举 — 对应后端 role code
|
||||
enum UserRoleType {
|
||||
teacher('teacher'),
|
||||
student('student'),
|
||||
parent('parent'),
|
||||
independent('independent');
|
||||
|
||||
const UserRoleType(this.code);
|
||||
final String code;
|
||||
|
||||
/// 从后端角色代码解析,未知代码默认为独立用户
|
||||
static UserRoleType fromCode(String code) => UserRoleType.values.firstWhere(
|
||||
(r) => r.code == code,
|
||||
orElse: () => UserRoleType.independent,
|
||||
);
|
||||
}
|
||||
|
||||
/// 角色信息 — 匹配后端 RoleResp
|
||||
class UserRole {
|
||||
final String id;
|
||||
final String name;
|
||||
final String code;
|
||||
final String? description;
|
||||
final bool isSystem;
|
||||
final int version;
|
||||
|
||||
const UserRole({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.code,
|
||||
this.description,
|
||||
this.isSystem = false,
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
/// 获取标准化的角色类型
|
||||
UserRoleType get type => UserRoleType.fromCode(code);
|
||||
|
||||
factory UserRole.fromJson(Map<String, dynamic> json) => UserRole(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
code: json['code'] as String,
|
||||
description: json['description'] as String?,
|
||||
isSystem: (json['is_system'] as bool?) ?? false,
|
||||
version: (json['version'] as int?) ?? 1,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'code': code,
|
||||
'description': description,
|
||||
'is_system': isSystem,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
/// 用户信息 — 匹配后端 UserResp
|
||||
///
|
||||
/// 包含用户基本信息和角色列表。
|
||||
/// 角色由后端 RBAC 系统管理,前端据此控制页面可见性和功能访问。
|
||||
class User {
|
||||
final String id;
|
||||
final String username;
|
||||
final String? email;
|
||||
final String? phone;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
final String status;
|
||||
final List<UserRole> roles;
|
||||
final int version;
|
||||
|
||||
const User({
|
||||
required this.id,
|
||||
required this.username,
|
||||
this.email,
|
||||
this.phone,
|
||||
this.displayName,
|
||||
this.avatarUrl,
|
||||
this.status = 'active',
|
||||
this.roles = const [],
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
User copyWith({
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
List<UserRole>? roles,
|
||||
int? version,
|
||||
}) =>
|
||||
User(
|
||||
id: id,
|
||||
username: username,
|
||||
email: email,
|
||||
phone: phone,
|
||||
displayName: displayName ?? this.displayName,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
status: status,
|
||||
roles: roles ?? this.roles,
|
||||
version: version ?? this.version,
|
||||
);
|
||||
|
||||
/// 获取用户的主角色类型(第一个角色的类型)
|
||||
UserRoleType get primaryRoleType =>
|
||||
roles.isNotEmpty ? roles.first.type : UserRoleType.independent;
|
||||
|
||||
/// 用户是否为老师
|
||||
bool get isTeacher => roles.any((r) => r.type == UserRoleType.teacher);
|
||||
|
||||
/// 用户是否为学生
|
||||
bool get isStudent => roles.any((r) => r.type == UserRoleType.student);
|
||||
|
||||
/// 用户是否为家长
|
||||
bool get isParent => roles.any((r) => r.type == UserRoleType.parent);
|
||||
|
||||
/// 用户是否已完成角色选择
|
||||
bool get hasRole => roles.isNotEmpty;
|
||||
|
||||
/// 显示名称:优先使用 displayName,回退到 username
|
||||
String get displayLabel => displayName ?? username;
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) => User(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
displayName: json['display_name'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
status: (json['status'] as String?) ?? 'active',
|
||||
roles: (json['roles'] as List?)
|
||||
?.map((r) => UserRole.fromJson(r as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
version: (json['version'] as int?) ?? 1,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'username': username,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'display_name': displayName,
|
||||
'avatar_url': avatarUrl,
|
||||
'status': status,
|
||||
'roles': roles.map((r) => r.toJson()).toList(),
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
264
app/lib/data/repositories/auth_repository.dart
Normal file
264
app/lib/data/repositories/auth_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user