2.优化修改了tcp重连机制,具体操作:断开重连时候不再建立新链接 而是将旧链接重置,并重新发送数据。 3.大面积的给项目中的各个页面 手动接入了多语言的支持。同时也调整了相应带来的问题英文长度较大影响原有的样式的问题,已解决了对应的样式不和谐问题。保证了外观的统一性。 4.给项目中的主要操作加了几个核心的操作比如远程遥控和路径规划添加了双层的保护,设置里安全模式,即在此期间对服务器推送的重复登录操作后的自动退出登录做一个拦截 保证操作的安全性。
441 lines
17 KiB
Dart
441 lines
17 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:get_it/get_it.dart';
|
||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
|
||
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
|
||
|
||
import '../../../../core/app/app_user_cubit.dart';
|
||
import '../../../../core/infrastructure/logging/sentry_logger_impl.dart';
|
||
import '../../../../core/logging/i_logger_service.dart';
|
||
import '../../../../core/network/tcp/tcp_client.dart';
|
||
import '../../../../core/storage/user_storage.dart';
|
||
import '../../../devices/domain/usecases/get_user_device_usecase.dart';
|
||
import '../../../devices/domain/usecases/switch_device_usecase.dart';
|
||
import '../../../devices/presentation/bloc/devices_cubit.dart';
|
||
import '../../../devices/presentation/bloc/device_status_bloc.dart';
|
||
import '../../../devices/presentation/bloc/device_status_event.dart';
|
||
import '../../../devices/presentation/bloc/devices_state.dart';
|
||
import '../../../../features/v2/site/presentation/cubit/site_cubit.dart';
|
||
import '../../data/datasources/auth_tcp_datasource.dart';
|
||
import '../../data/datasources/impl/auth_tcp_datasource_impl.dart';
|
||
import 'auth_state.dart';
|
||
import '../../../../main.dart'; // 🔥 导入 main.dart 以使用 navigatorKey
|
||
|
||
/// emit做的事情:
|
||
/// 1.修改状态标志:通过改变类的类型(从 Initial 变成 Authenticated)
|
||
/// 2.携带数据:把 user 对象塞进了状态里,让外部能拿到
|
||
/// 3.发送通知:自动触发监听(如 GoRouter 的刷新)。
|
||
class AuthCubit extends Cubit<AuthState> {
|
||
final UserStorage storage;
|
||
final TcpClient tcp;
|
||
final AppUserCubit appCubit;
|
||
final NetMessageDispatcher dispatcher;
|
||
final AuthTcpDatasource _authTcpDatasource;
|
||
final ILoggerService _logger = GetIt.I<ILoggerService>();
|
||
|
||
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
|
||
|
||
// 🔥 登录验证 Completer:用于等待登录阶段的 have_logged_in 推送
|
||
Completer<bool>? _loginVerificationCompleter;
|
||
|
||
// 🔥 安全模式标志位:保护关键操作阶段不被异地登录踢下线
|
||
bool _isSafeMode = false;
|
||
Timer? _safeModeExitTimer;
|
||
|
||
AuthCubit(
|
||
this.storage,
|
||
this.tcp,
|
||
this.appCubit,
|
||
this.dispatcher,
|
||
this._authTcpDatasource,
|
||
) : super(AuthInitial()) {
|
||
// Cubit 一启动就开始监听 TCP 的“自动逻辑”
|
||
_listenToAuthResponse();
|
||
}
|
||
|
||
/// App 启动时检查本地缓存
|
||
Future<void> appStarted() async {
|
||
final logger = GetIt.I<ILoggerService>() as SentryLoggerImpl;
|
||
try {
|
||
final user = await storage.getUser();
|
||
logger.logWithLevel(
|
||
'启动时检查本地缓存',
|
||
level: 'INFO',
|
||
data: {'user': user != null ? '找到用户: ${user.username}' : '未找到用户'},
|
||
);
|
||
|
||
if (user != null) {
|
||
// 🔥 冷启动时不创建TCP连接,改为用户选择设备时再连接
|
||
// 避免发送 0x03 触发服务端残留 session 的 have_logged_in
|
||
|
||
// 2. 同步全局 App 状态
|
||
appCubit.setAuth(user);
|
||
// 3. 进入已登录状态
|
||
emit(AuthAuthenticated(user));
|
||
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
|
||
} else {
|
||
logger.logWithLevel('⚠️ [AUTH] 应用启动 - 无本地缓存,进入未登录状态', level: 'INFO');
|
||
emit(AuthUnauthenticated());
|
||
}
|
||
} catch (e) {
|
||
logger.logWithLevel('❌ [AUTH] 应用启动检查失败: $e', level: 'ERROR');
|
||
emit(AuthUnauthenticated());
|
||
}
|
||
}
|
||
|
||
/// 当 HTTP 登录/注册成功后调用
|
||
Future<void> loginSuccess(UserEntity user) async {
|
||
await storage.saveUser(user);
|
||
|
||
// 🔥 登录后立即创建TCP连接并验证
|
||
try {
|
||
debugPrint('>>> [AUTH] 登录成功,开始建立TCP连接...');
|
||
_logger.logWithLevel('>>> [AUTH] 登录成功,开始建立TCP连接...', shouldLog: true);
|
||
|
||
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
|
||
tcp.startHeartbeat(interval: const Duration(seconds: 4));
|
||
|
||
// 🔥 关键:等待 2.5 秒,看是否收到 have_logged_in
|
||
bool isKicked = await _waitForLoginVerification();
|
||
|
||
if (isKicked) {
|
||
// 🔥 不能进入 APP,必须清除所有登录信息
|
||
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息...');
|
||
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息', shouldLog: true);
|
||
|
||
// 1. 删除已保存的用户信息
|
||
await storage.deleteUser();
|
||
debugPrint('✅ [AUTH] 已删除用户信息');
|
||
|
||
// 2. 断开 TCP 连接
|
||
tcp.forceDisconnect();
|
||
debugPrint('✅ [AUTH] 已断开 TCP 连接');
|
||
|
||
// 3. 显示 Toast
|
||
_showLoginFailedToast("账号已在其他设备登录");
|
||
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页');
|
||
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页', shouldLog: true);
|
||
return; // 停留在登录页
|
||
}
|
||
|
||
debugPrint('✅ [AUTH] TCP连接成功,验证通过');
|
||
_logger.logWithLevel('✅ [AUTH] TCP连接成功,验证通过', shouldLog: true);
|
||
} catch (e) {
|
||
debugPrint('❌ [AUTH] TCP连接失败:$e');
|
||
_logger.logWithLevel('❌ [AUTH] TCP连接失败:$e', level: 'ERROR');
|
||
// TCP 连接失败不影响登录流程,继续执行
|
||
}
|
||
|
||
appCubit.setAuth(user);
|
||
emit(AuthAuthenticated(user));
|
||
}
|
||
|
||
/// 退出登录 (主动或被动)
|
||
Future<void> logout() async {
|
||
print('>>> [AUTH] 🚨🚨🚨 logout() 被调用!时间: ${DateTime.now()}');
|
||
print('>>> [AUTH] 调用栈:\n${StackTrace.current}');
|
||
|
||
// 🔥 关键修复:在断开TCP前,先清空所有业务状态,避免重连触发旧数据
|
||
_clearAllBusinessState();
|
||
|
||
await storage.deleteUser();
|
||
tcp.forceDisconnect(); // 🔥 修复:彻底断开连接,禁止自动重连
|
||
appCubit.clearAuth();
|
||
emit(AuthUnauthenticated());
|
||
}
|
||
|
||
/// 🔥 新增:显示登录失败 Toast(黑色背景,和登录错误提示一致)
|
||
void _showLoginFailedToast(String message) {
|
||
try {
|
||
final context = navigatorKey.currentContext;
|
||
if (context != null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(message),
|
||
backgroundColor: Colors.black,
|
||
duration: const Duration(seconds: 2),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('>>> [AUTH] ❌ 显示Toast失败:$e');
|
||
}
|
||
}
|
||
|
||
/// 🔥 新增:显示异地登录提示弹窗
|
||
void _showKickOutDialog() {
|
||
try {
|
||
debugPrint('>>> [AUTH] 📢 准备显示异地登录提示弹窗');
|
||
_logger.logWithLevel('>>> [AUTH] 📢 准备显示异地登录提示弹窗', shouldLog: true);
|
||
|
||
// 使用全局 navigatorKey 显示弹窗
|
||
final context = navigatorKey.currentContext;
|
||
if (context != null) {
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false, // 不允许点击背景关闭
|
||
builder: (BuildContext dialogContext) {
|
||
return AlertDialog(
|
||
title: const Text('账号被顶下线'),
|
||
content: const Text('您的账号在其他设备登录,即将退出当前设备...'),
|
||
actions: [],
|
||
);
|
||
},
|
||
);
|
||
} else {
|
||
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出');
|
||
_logger.logWithLevel('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出', shouldLog: true);
|
||
}
|
||
|
||
// 🔥 延迟 2 秒后执行退出,给用户时间看到提示
|
||
Future.delayed(const Duration(seconds: 2), () {
|
||
debugPrint('>>> [AUTH] ⏰ 延迟结束,开始执行退出登录');
|
||
_logger.logWithLevel('>>> [AUTH] ⏰ 延迟结束,开始执行退出登录', shouldLog: true);
|
||
logout();
|
||
});
|
||
} catch (e) {
|
||
debugPrint('>>> [AUTH] ❌ 显示弹窗失败:$e');
|
||
_logger.logWithLevel('>>> [AUTH] ❌ 显示弹窗失败:$e', level: 'ERROR');
|
||
// 即使显示弹窗失败,也要执行退出
|
||
logout();
|
||
}
|
||
}
|
||
|
||
/// 🔥 新增:清空所有业务状态,防止数据泄露到新账户
|
||
void _clearAllBusinessState() {
|
||
try {
|
||
final devicesCubit = GetIt.I<DevicesCubit>();
|
||
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
|
||
final siteCubit = GetIt.I<SiteCubit>();
|
||
|
||
// 1. 清空设备列表和选中设备
|
||
devicesCubit.emit(const DevicesState());
|
||
debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态');
|
||
_logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态');
|
||
|
||
// 2. 清空设备实时状态
|
||
deviceStatusBloc.add(DeviceStatusReset());
|
||
debugPrint('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
|
||
_logger.logWithLevel('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
|
||
|
||
// 3. 清空全局选中的场站
|
||
siteCubit.clearSelectedSite();
|
||
debugPrint('✅ [AUTH] 已清空 SiteCubit 选中状态');
|
||
_logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 选中状态');
|
||
|
||
debugPrint('✅ [AUTH] 所有业务状态已清空');
|
||
_logger.logWithLevel('✅ [AUTH] 所有业务状态已清空');
|
||
} catch (e) {
|
||
debugPrint('❌ [AUTH] 清空业务状态失败: $e');
|
||
_logger.logWithLevel('❌ [AUTH] 清空业务状态失败: $e', level: 'ERROR');
|
||
}
|
||
}
|
||
|
||
/// 🔥 等待登录验证结果(2.5 秒内看是否收到 have_logged_in)
|
||
Future<bool> _waitForLoginVerification() async {
|
||
_loginVerificationCompleter = Completer<bool>();
|
||
|
||
// 等待 2.5 秒
|
||
await Future.delayed(const Duration(milliseconds: 2500));
|
||
|
||
// 如果 completer 还没完成,说明没收到 have_logged_in,返回 false(可以进入)
|
||
if (!_loginVerificationCompleter!.isCompleted) {
|
||
_loginVerificationCompleter!.complete(false);
|
||
}
|
||
|
||
return _loginVerificationCompleter!.future;
|
||
}
|
||
|
||
/// TCP 指令监听
|
||
void _listenToAuthResponse() {
|
||
debugPrint('>>> [AUTH] _listenToAuthResponse() 被调用,开始监听 0x12 指令');
|
||
_kickOutSub?.cancel(); // 防止重复监听
|
||
|
||
// 直接监听原始数据包,自己处理 JSON 解析(去掉 CRC 字节)
|
||
_kickOutSub = dispatcher.onCommand(0x12).listen((packet) {
|
||
debugPrint(
|
||
'>>> [AUTH] 收到 0x12 原始包,payload 长度=${packet.payload.length}, 内容=${packet.payload}',
|
||
);
|
||
try {
|
||
// 🔥 关键:手动去掉最后 2 个 CRC 字节
|
||
String jsonString;
|
||
if (packet.payload.length > 2) {
|
||
jsonString = utf8.decode(
|
||
packet.payload.sublist(0, packet.payload.length - 2),
|
||
);
|
||
} else {
|
||
jsonString = utf8.decode(packet.payload);
|
||
}
|
||
debugPrint('>>> [AUTH] 获取 JSON: "$jsonString"');
|
||
|
||
final jsonMap = jsonDecode(jsonString);
|
||
debugPrint('>>> [AUTH] 获取 JSON Map: $jsonMap');
|
||
|
||
final respond = jsonMap['respond'] ?? '';
|
||
debugPrint('>>> [AUTH] respond 字段值: "$respond"');
|
||
_logger.logWithLevel(
|
||
'>>> [AUTH] respond 字段值: "$respond"',
|
||
shouldLog: true,
|
||
);
|
||
debugPrint('>>> [AUTH] respond 类型: ${respond.runtimeType}');
|
||
debugPrint(
|
||
'>>> [AUTH] respond == "have_logged_in": ${respond == "have_logged_in"}',
|
||
);
|
||
|
||
if (respond == 'have_logged_in') {
|
||
// 🔥 登录阶段策略:HTTP 已成功,TCP 认证阶段的推送视为服务端状态同步,直接放行
|
||
if (_loginVerificationCompleter != null && !_loginVerificationCompleter!.isCompleted) {
|
||
debugPrint('>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP');
|
||
_logger.logWithLevel(
|
||
'🛡️ [AUTH] 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入',
|
||
shouldLog: true,
|
||
);
|
||
_loginVerificationCompleter!.complete(false); // 标记为可以进入
|
||
return;
|
||
}
|
||
|
||
// 🔥 已登录阶段策略:检查是否处于安全模式
|
||
if (_isSafeMode) {
|
||
print('>>> [AUTH] 🛡️ 安全模式下拦截 have_logged_in 推送,防止控制中断');
|
||
_logger.logWithLevel(
|
||
'🛡️ [AUTH] 安全模式下拦截异地登录推送',
|
||
level: 'WARN',
|
||
);
|
||
return; // 拦截退出逻辑
|
||
}
|
||
|
||
print('>>> [AUTH] ⚠️ 已登录状态下收到 TCP 0x12 指令 respond=have_logged_in');
|
||
debugPrint('>>> [AUTH] ℹ️ 暂时忽略该推送,观察是否影响业务操作...');
|
||
_logger.logWithLevel(
|
||
'⚠️ [AUTH] 已登录状态收到 have_logged_in,暂不处理,观察业务影响',
|
||
level: 'WARN',
|
||
);
|
||
|
||
// 如果你希望依然保持严格的安全策略,可以取消下面注释恢复退出逻辑:
|
||
// _showKickOutDialog();
|
||
} else {
|
||
debugPrint('>>> [AUTH] ℹ️ 收到 0x12 消息,respond="$respond",不处理');
|
||
}
|
||
} catch (e) {
|
||
debugPrint('>>> [AUTH] ❌ JSON 解析失败:$e');
|
||
}
|
||
});
|
||
_logger.logWithLevel('[AUTH] 监听 TCP 0x12 指令...', level: 'INFO');
|
||
}
|
||
|
||
/// 🔥 安全模式管理:进入关键页面时调用
|
||
void enterSafeMode() {
|
||
_isSafeMode = true;
|
||
_safeModeExitTimer?.cancel(); // 取消之前的退出倒计时
|
||
debugPrint('>>> [AUTH] 🛡️ 已进入安全模式,异地登录推送将被拦截');
|
||
_logger.logWithLevel('>>> [AUTH] 🛡️ 已进入安全模式', shouldLog: true);
|
||
_showSafeModeEnteredToast(); // 🔥 显示进入提示
|
||
}
|
||
|
||
/// 🔥 安全模式管理:离开关键页面时调用
|
||
void exitSafeMode() {
|
||
// 启动 90 秒倒计时
|
||
_safeModeExitTimer?.cancel();
|
||
_safeModeExitTimer = Timer(const Duration(seconds: 90), () {
|
||
if (_isSafeMode) {
|
||
_isSafeMode = false;
|
||
debugPrint('>>> [AUTH] ⏰ 90秒无操作,已自动关闭安全模式');
|
||
_logger.logWithLevel('>>> [AUTH] ⏰ 90秒无操作,已自动关闭安全模式', shouldLog: true);
|
||
_showSafeModeClosedToast();
|
||
}
|
||
});
|
||
debugPrint('>>> [AUTH] 🕒 已离开安全页面,90秒后若无操作将关闭安全模式');
|
||
_showSafeModeCountdownToast();
|
||
}
|
||
|
||
/// 🔥 显示“已进入安全模式”提示
|
||
void _showSafeModeEnteredToast() {
|
||
try {
|
||
final context = navigatorKey.currentContext;
|
||
if (context != null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: const Text('已进入安全模式,防止异地登录干扰'),
|
||
backgroundColor: Colors.green,
|
||
duration: const Duration(seconds: 3),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('>>> [AUTH] ❌ 显示安全模式提示失败:$e');
|
||
}
|
||
}
|
||
|
||
/// 🔥 显示“90秒后关闭”提示
|
||
void _showSafeModeCountdownToast() {
|
||
try {
|
||
final context = navigatorKey.currentContext;
|
||
if (context != null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: const Text('90秒后无操作将关闭安全模式'),
|
||
backgroundColor: Colors.orange,
|
||
duration: const Duration(seconds: 3),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('>>> [AUTH] ❌ 显示倒计时提示失败:$e');
|
||
}
|
||
}
|
||
|
||
/// 🔥 显示“安全模式已关闭”提示
|
||
void _showSafeModeClosedToast() {
|
||
try {
|
||
final context = navigatorKey.currentContext;
|
||
if (context != null) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: const Text('安全模式已关闭,恢复异地登录检测'),
|
||
backgroundColor: Colors.grey,
|
||
duration: const Duration(seconds: 2),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('>>> [AUTH] ❌ 显示关闭提示失败:$e');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<void> close() {
|
||
_kickOutSub?.cancel(); // 销毁 Cubit 时关闭监听
|
||
_safeModeExitTimer?.cancel(); // 清理倒计时
|
||
return super.close();
|
||
}
|
||
|
||
/// 🔥 息屏/后台后恢复到前台时的重连方法
|
||
Future<void> reconnectAfterResume() async {
|
||
_logger.logWithLevel('[AUTH] 检测到应用恢复到前台,检查 TCP 连接状态...', level: 'INFO');
|
||
// 如果 TCP 未连接,则执行重连
|
||
if (!tcp.isConnected) {
|
||
_logger.logWithLevel('[AUTH] TCP 未连接,开始重连...', level: 'INFO');
|
||
try {
|
||
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
|
||
tcp.startHeartbeat(interval: const Duration(seconds: 4));
|
||
_logger.logWithLevel('[AUTH] TCP 重连成功!', level: 'INFO');
|
||
} catch (e) {
|
||
_logger.logWithLevel('[AUTH] TCP 重连失败:$e', level: 'ERROR');
|
||
}
|
||
} else {
|
||
_logger.logWithLevel('[AUTH] TCP 已连接,无需重连', level: 'INFO');
|
||
// 可选:发送一个心跳包确认连接有效
|
||
tcp.sendHeartbeat();
|
||
}
|
||
}
|
||
}
|