Files
nj/app/lib/features/auth/bloc/auth_bloc.dart
iven 99db8e5cb0
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 家长同意验证流程 — PIPL 第28条合规
- 新增 ParentalConsentPage: 显示隐私政策要点 + 双重确认复选框
- 角色选择流程: 学生 → 家长同意确认 → 班级码输入
- Authenticated 状态: 添加 needsParentalConsent/parentalConsentAt/selectedRole
- ParentalConsentAccepted 事件: 记录同意时间戳
- 路由守卫: 注册 /parental-consent 路径和重定向逻辑
- 非学生角色(老师/家长/独立用户)不需要经过同意流程

审计 ID: S-03
2026-06-03 10:25:23 +08:00

261 lines
7.7 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.
// 认证 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());
}
}