- 新增 ParentalConsentPage: 显示隐私政策要点 + 双重确认复选框 - 角色选择流程: 学生 → 家长同意确认 → 班级码输入 - Authenticated 状态: 添加 needsParentalConsent/parentalConsentAt/selectedRole - ParentalConsentAccepted 事件: 记录同意时间戳 - 路由守卫: 注册 /parental-consent 路径和重定向逻辑 - 非学生角色(老师/家长/独立用户)不需要经过同意流程 审计 ID: S-03
261 lines
7.7 KiB
Dart
261 lines
7.7 KiB
Dart
// 认证 BLoC — 管理用户登录状态和认证流程
|
||
//
|
||
// 状态机: AuthInitial → AuthLoading → Unauthenticated/Authenticated
|
||
// ↕
|
||
// Authenticating → Authenticated/AuthError
|
||
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:logger/logger.dart';
|
||
|
||
import '../../../data/models/auth_token.dart';
|
||
import '../../../data/models/user.dart';
|
||
import '../../../data/remote/api_client.dart';
|
||
import '../../../data/repositories/auth_repository.dart';
|
||
import '../../../data/repositories/class_repository.dart';
|
||
|
||
part 'auth_event.dart';
|
||
part 'auth_state.dart';
|
||
|
||
/// 认证 BLoC — 处理所有认证相关的状态转换
|
||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||
final AuthRepository _authRepository;
|
||
final ClassRepository? _classRepository;
|
||
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
|
||
|
||
AuthBloc({
|
||
required AuthRepository authRepository,
|
||
ClassRepository? classRepository,
|
||
}) : _authRepository = authRepository,
|
||
_classRepository = classRepository,
|
||
super(const AuthInitial()) {
|
||
// 注册事件处理器
|
||
on<AppStarted>(_onAppStarted);
|
||
on<LoginRequested>(_onLoginRequested);
|
||
on<RegisterRequested>(_onRegisterRequested);
|
||
on<RoleSelected>(_onRoleSelected);
|
||
on<ParentalConsentAccepted>(_onParentalConsentAccepted);
|
||
on<ClassCodeSubmitted>(_onClassCodeSubmitted);
|
||
on<LogoutRequested>(_onLogoutRequested);
|
||
on<TokenRefreshed>(_onTokenRefreshed);
|
||
on<AuthExpired>(_onAuthExpired);
|
||
}
|
||
|
||
/// App 启动 — 从本地存储恢复认证状态
|
||
Future<void> _onAppStarted(
|
||
AppStarted event,
|
||
Emitter<AuthState> emit,
|
||
) async {
|
||
emit(const AuthLoading());
|
||
try {
|
||
final user = await _authRepository.restoreAuth();
|
||
if (user != null) {
|
||
emit(Authenticated(
|
||
user: user,
|
||
needsRoleSelection: !user.hasRole,
|
||
));
|
||
} else {
|
||
emit(const Unauthenticated());
|
||
}
|
||
} catch (e) {
|
||
_logger.e('恢复认证状态失败: $e');
|
||
emit(const Unauthenticated());
|
||
}
|
||
}
|
||
|
||
/// 用户登录
|
||
Future<void> _onLoginRequested(
|
||
LoginRequested event,
|
||
Emitter<AuthState> emit,
|
||
) async {
|
||
emit(const Authenticating());
|
||
try {
|
||
final user = await _authRepository.login(
|
||
username: event.username,
|
||
password: event.password,
|
||
);
|
||
emit(Authenticated(
|
||
user: user,
|
||
needsRoleSelection: !user.hasRole,
|
||
));
|
||
} on AuthException catch (e) {
|
||
_logger.w('登录失败: ${e.message}');
|
||
emit(AuthError(e.message));
|
||
} on OfflineException {
|
||
emit(const AuthError('网络不可用,请检查网络连接', retryable: true));
|
||
} catch (e) {
|
||
_logger.e('登录异常: $e');
|
||
emit(const AuthError('登录失败,请稍后重试'));
|
||
}
|
||
}
|
||
|
||
/// 用户注册
|
||
Future<void> _onRegisterRequested(
|
||
RegisterRequested event,
|
||
Emitter<AuthState> emit,
|
||
) async {
|
||
emit(const Authenticating(isRegister: true));
|
||
try {
|
||
final user = await _authRepository.register(
|
||
username: event.username,
|
||
password: event.password,
|
||
displayName: event.displayName,
|
||
);
|
||
// 注册成功后需要选择角色
|
||
emit(Authenticated(
|
||
user: user,
|
||
needsRoleSelection: true,
|
||
));
|
||
} on AuthException catch (e) {
|
||
_logger.w('注册失败: ${e.message}');
|
||
emit(AuthError(e.message));
|
||
} on OfflineException {
|
||
emit(const AuthError('网络不可用,请检查网络连接', retryable: true));
|
||
} catch (e) {
|
||
_logger.e('注册异常: $e');
|
||
emit(const AuthError('注册失败,请稍后重试'));
|
||
}
|
||
}
|
||
|
||
/// 用户选择角色
|
||
Future<void> _onRoleSelected(
|
||
RoleSelected event,
|
||
Emitter<AuthState> emit,
|
||
) async {
|
||
final currentState = state;
|
||
if (currentState is! Authenticated) return;
|
||
|
||
// 学生角色需要先经过家长同意确认(PIPL 第28条)
|
||
final needsParentalConsent = event.role == UserRoleType.student;
|
||
|
||
// 根据角色决定下一步
|
||
final needsClassCode =
|
||
event.role == UserRoleType.student || event.role == UserRoleType.parent;
|
||
|
||
emit(currentState.copyWith(
|
||
needsRoleSelection: false,
|
||
needsParentalConsent: needsParentalConsent,
|
||
needsClassCode: needsClassCode && !needsParentalConsent,
|
||
selectedRole: event.role,
|
||
));
|
||
|
||
_logger.i('角色选择: ${event.role.name}, 需要家长同意: $needsParentalConsent');
|
||
}
|
||
|
||
/// 家长/监护人同意信息收集(PIPL 合规)
|
||
Future<void> _onParentalConsentAccepted(
|
||
ParentalConsentAccepted event,
|
||
Emitter<AuthState> emit,
|
||
) async {
|
||
final currentState = state;
|
||
if (currentState is! Authenticated) return;
|
||
|
||
_logger.i('家长同意已确认: ${event.consentAt}');
|
||
|
||
emit(currentState.copyWith(
|
||
needsParentalConsent: false,
|
||
needsClassCode: true,
|
||
parentalConsentAt: event.consentAt,
|
||
));
|
||
}
|
||
|
||
/// 班级码加入
|
||
Future<void> _onClassCodeSubmitted(
|
||
ClassCodeSubmitted event,
|
||
Emitter<AuthState> emit,
|
||
) async {
|
||
final currentState = state;
|
||
if (currentState is! Authenticated) return;
|
||
|
||
// 如果没有 ClassRepository(离线模式),直接跳过
|
||
final classRepo = _classRepository;
|
||
if (classRepo == null) {
|
||
_logger.w('ClassRepository 不可用,跳过班级码验证');
|
||
emit(currentState.copyWith(needsClassCode: false));
|
||
return;
|
||
}
|
||
|
||
emit(currentState.copyWith(isLoading: true));
|
||
|
||
try {
|
||
// 调用后端 API 验证班级码并加入班级
|
||
await classRepo.joinClass(
|
||
event.classCode,
|
||
nickname: currentState.user.displayName,
|
||
);
|
||
|
||
// 成功 — 清除班级码需求
|
||
emit(currentState.copyWith(
|
||
needsClassCode: false,
|
||
isLoading: false,
|
||
));
|
||
|
||
_logger.i('班级码加入成功: ${event.classCode}');
|
||
} on DioException catch (e) {
|
||
final statusCode = e.response?.statusCode;
|
||
String errorMessage = '加入班级失败,请重试';
|
||
|
||
if (statusCode == 400) {
|
||
// 班级码无效或已过期
|
||
final body = e.response?.data;
|
||
if (body is Map && body['message'] is String) {
|
||
errorMessage = body['message'] as String;
|
||
} else {
|
||
errorMessage = '班级码无效,请检查后重新输入';
|
||
}
|
||
} else if (statusCode == 429) {
|
||
// 尝试次数过多 — 锁定
|
||
errorMessage = '尝试次数过多,请等待 30 分钟后再试';
|
||
}
|
||
|
||
_logger.w('班级码验证失败 ($statusCode): $errorMessage');
|
||
emit(currentState.copyWith(
|
||
isLoading: false,
|
||
classCodeError: errorMessage,
|
||
));
|
||
} on OfflineException {
|
||
emit(currentState.copyWith(
|
||
isLoading: false,
|
||
classCodeError: '网络不可用,请检查网络后重试',
|
||
));
|
||
} catch (e) {
|
||
_logger.e('班级码验证异常: $e');
|
||
emit(currentState.copyWith(
|
||
isLoading: false,
|
||
classCodeError: '加入班级失败,请稍后重试',
|
||
));
|
||
}
|
||
}
|
||
|
||
/// 用户登出
|
||
Future<void> _onLogoutRequested(
|
||
LogoutRequested event,
|
||
Emitter<AuthState> emit,
|
||
) async {
|
||
try {
|
||
await _authRepository.logout();
|
||
} catch (e) {
|
||
_logger.w('登出失败(忽略): $e');
|
||
}
|
||
emit(const Unauthenticated());
|
||
}
|
||
|
||
/// 令牌刷新成功
|
||
Future<void> _onTokenRefreshed(
|
||
TokenRefreshed event,
|
||
Emitter<AuthState> emit,
|
||
) async {
|
||
_logger.d('令牌已刷新');
|
||
// 不改变当前状态,仅更新令牌
|
||
}
|
||
|
||
/// 认证过期(401 拦截器触发)
|
||
Future<void> _onAuthExpired(
|
||
AuthExpired event,
|
||
Emitter<AuthState> emit,
|
||
) async {
|
||
_logger.w('认证过期,需要重新登录');
|
||
emit(const Unauthenticated());
|
||
}
|
||
}
|