import 'package:dio/dio.dart'; import '../utils/constants.dart'; import '../utils/credential_storage.dart'; import '../config/app_config.dart'; /// 内存态令牌存储。 /// /// token / refreshToken 仅保存在内存中(不引入 shared_preferences, /// 避免鸿蒙端插件兼容问题),应用重启后需重新登录。 class TokenStore { TokenStore._(); /// 全局单例。 static final TokenStore instance = TokenStore._(); /// 访问令牌。 String? token; /// 刷新令牌。 String? refreshToken; /// 保存登录 / 刷新后的令牌对。 void save({required String token, String? refreshToken}) { this.token = token; if (refreshToken != null && refreshToken.isNotEmpty) { this.refreshToken = refreshToken; } } /// 清空令牌(登出或会话失效时调用)。 void clear() { token = null; refreshToken = null; } /// 是否持有访问令牌。 bool get hasToken => token != null && token!.isNotEmpty; } /// 业务异常:后端统一响应 `{code, msg, data}` 中 code 非成功值时抛出。 class ApiException implements Exception { const ApiException(this.code, this.message); /// 业务错误码。 final int code; /// 错误信息。 final String message; @override String toString() => message; } /// 把 DioException / 业务异常转成用户可读的简短文本。 /// /// 优先展示后端返回的 msg,其次网络状态,最后兜底提示。 String formatApiError(Object? error, {String fallback = '请求失败,请稍后重试'}) { if (error is ApiException && error.message.isNotEmpty) { return error.message; } if (error is DioException) { final inner = error.error; if (inner is ApiException && inner.message.isNotEmpty) { return inner.message; } final response = error.response; if (response != null && response.statusCode != null) { return '服务器响应异常:HTTP ${response.statusCode}'; } switch (error.type) { case DioExceptionType.connectionTimeout: case DioExceptionType.receiveTimeout: case DioExceptionType.sendTimeout: return '连接超时,请检查网络'; case DioExceptionType.connectionError: return '网络连接失败,请检查网络'; case DioExceptionType.cancel: return '请求已取消'; case DioExceptionType.badCertificate: return '服务器证书校验失败'; default: break; } } final text = error?.toString(); if (text != null && text.isNotEmpty && text != 'null') { return text; } return fallback; } /// 判断异常是否表示登录态失效(token 过期 / 401)。 /// /// 后端常以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」而非 HTTP 401, /// 拦截器已统一把这类场景包装为 [ApiException](401, '登录已过期,请重新登录')。 /// /// UI 层捕获到此类异常时,应跳过普通错误提示、改由路由重定向引导重新登录, /// 避免把「登录已过期」之类的底层文案直接暴露给用户(如「查询失败:登录已过期」)。 bool isUnauthorizedError(Object? error) { ApiException? apiEx; if (error is ApiException) { apiEx = error; } else if (error is DioException && error.error is ApiException) { apiEx = error.error as ApiException; } if (apiEx == null) return false; return apiEx.code == 401 || apiEx.message.contains('登录已过期'); } /// HTTP 客户端(Dio 单例)。 /// /// - [AppConstants.useMock] = false:挂载 [ApiInterceptor],对接真实「爱视后台」, /// 自动附加 Bearer 令牌与防缓存时间戳、解包 `{code,msg,data}`、 /// 401 时用 refreshToken 刷新并重试一次; /// - [AppConstants.useMock] = true:挂载 [MockInterceptor] 本地返回示例数据。 class ApiClient { ApiClient._() { _dio = _buildDio(); } /// 构建 Dio 实例(拦截器配置集中于此,[reconfigure] 复用)。 /// /// baseUrl 取自 [AppConfig],支持运行时切换环境后端地址。 Dio _buildDio() { final dio = Dio( BaseOptions( baseUrl: AppConfig.apiFullBaseUrl, connectTimeout: AppConstants.apiTimeout, receiveTimeout: AppConstants.apiTimeout, headers: {'Content-Type': 'application/json; charset=utf-8'}, ), ); if (AppConstants.useMock) { // Mock 在前:直接本地 resolve,后续 ApiInterceptor 仍负责解包信封。 dio.interceptors.add(MockInterceptor()); } dio.interceptors.add(ApiInterceptor(dio)); return dio; } /// 运行时切换后端地址后重建 Dio 单例(保留拦截器配置)。 /// /// 允许重复调用,用于环境切换、语言切换后刷新网络层等场景。 /// 返回重建结果与可能的错误信息,避免直接抛异常导致 UI 崩溃。 ({bool success, String? error}) reconfigure() { try { _dio = _buildDio(); return (success: true, error: null); } catch (e) { return (success: false, error: e.toString()); } } /// 全局单例。 static final ApiClient instance = ApiClient._(); /// Dio 实例。使用 [late](非 [late final])以支持 [reconfigure] 重复重建。 late Dio _dio; /// 会话失效回调(401 刷新失败后触发),由认证层注册以清空登录态。 void Function()? onUnauthorized; /// 获取 Dio 实例。 Dio get dio => _dio; } /// 真实接口拦截器:鉴权头、防缓存时间戳、信封解包、401 刷新重试。 class ApiInterceptor extends Interceptor { ApiInterceptor(this._dio); final Dio _dio; /// 刷新接口路径(避免 401 处理时对自身重试)。 static const String _refreshPath = '/sys/refresh'; /// 标记已重试过的请求,防止循环刷新。 static const String _retriedFlag = '__retried_after_refresh'; @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { // 鉴权:Bearer token(不使用 Cookie)。 final token = TokenStore.instance.token; if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } // 防缓存:仅 GET 在 query 上附加毫秒时间戳 t。 // 注意:POST/PUT 写入请求不缓存,且绝不能把 t 注入业务 body—— // 后端(如 CodeIgniter)按全字段 insert 时会因未知列 `t` 报 500, // 导致「列表能读、保存失败」。故写入请求保持 body 纯净。 if (options.method == 'GET') { options.queryParameters['t'] = DateTime.now().millisecondsSinceEpoch.toString(); } handler.next(options); } @override Future onResponse( Response response, ResponseInterceptorHandler handler, ) async { final body = response.data; // 非信封格式(如二进制流)直接放行。 if (body is! Map || !body.containsKey('code')) { handler.next(response); return; } final code = _asInt(body['code']); final msg = body['msg']?.toString() ?? '请求失败'; // 成功码:0 / 200,解包 data。 if (code == 0 || code == 200) { response.data = body['data']; handler.next(response); return; } // 业务 401 或后端以普通业务码返回 token 失效:尝试刷新令牌并重试一次。 final isTokenExpired = _isSessionExpiredMessage(msg); if ((code == 401 || isTokenExpired) && response.requestOptions.path != _refreshPath && response.requestOptions.extra[_retriedFlag] != true) { final retried = await _refreshAndRetry(response.requestOptions, handler); if (retried) { return; } // 刷新失败:已触发 _notifyUnauthorized() 清空登录态并跳转登录页, // 将错误信息统一为更友好的文案,避免把原始 DioException 抛给 UI。 handler.reject( DioException( requestOptions: response.requestOptions, response: response, error: ApiException(401, '登录已过期,请重新登录'), ), ); return; } handler.reject( DioException( requestOptions: response.requestOptions, response: response, error: ApiException(code, msg), ), ); } @override Future onError( DioException err, ErrorInterceptorHandler handler, ) async { // HTTP 401:同样尝试刷新令牌并重试一次。 if (err.response?.statusCode == 401 && err.requestOptions.path != _refreshPath && err.requestOptions.extra[_retriedFlag] != true) { final retried = await _refreshAndRetry(err.requestOptions, handler); if (retried) { return; } // _refreshAndRetry 失败路径内部已调用 _notifyUnauthorized,此处不重复调用。 handler.next(err); return; } // 已解包出的业务异常直接透传,其余包装网络错误信息。 if (err.error is ApiException) { handler.next(err); return; } handler.next(err); } /// 刷新令牌并重试原请求;成功返回 true。 Future _refreshAndRetry( RequestOptions options, dynamic handler, ) async { final refreshed = await _tryRefreshToken(); if (!refreshed) { _notifyUnauthorized(); return false; } try { options.extra[_retriedFlag] = true; // 重新附加新 token。 final token = TokenStore.instance.token; if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } final retryResponse = await _dio.fetch(options); if (handler is ResponseInterceptorHandler) { handler.resolve(retryResponse); } else if (handler is ErrorInterceptorHandler) { handler.resolve(retryResponse); } return true; } catch (_) { _notifyUnauthorized(); return false; } } /// 进行中的刷新请求:并发 401 只发起一次 /sys/refresh,其余共享结果。 Future? _refreshing; /// 用 refreshToken 换新 token(带并发锁)。 Future _tryRefreshToken() { return _refreshing ??= _doRefreshToken().whenComplete(() { _refreshing = null; }); } /// 实际刷新逻辑。使用独立 Dio 实例,避免进入拦截器循环。 Future _doRefreshToken() async { final refreshToken = TokenStore.instance.refreshToken; if (refreshToken == null || refreshToken.isEmpty) { return false; } try { final rawDio = Dio( BaseOptions( baseUrl: AppConfig.apiFullBaseUrl, connectTimeout: AppConstants.apiTimeout, receiveTimeout: AppConstants.apiTimeout, headers: {'Content-Type': 'application/json; charset=utf-8'}, ), ); final response = await rawDio.post>( _refreshPath, data: { 'refreshToken': refreshToken, 't': DateTime.now().millisecondsSinceEpoch.toString(), }, ); final body = response.data; if (body == null) { return false; } final code = _asInt(body['code']); if (code != 0 && code != 200) { return false; } final data = body['data']; if (data is Map && data['token'] != null) { final newToken = data['token'].toString(); final newRefreshToken = data['refreshToken']?.toString(); TokenStore.instance.save( token: newToken, refreshToken: newRefreshToken, ); // 刷新后的新令牌必须同步持久化,否则切换语言/进程重启后 // 会恢复到旧令牌,导致「token 失效」报错。 await CredentialStorage.instance.saveSession( token: newToken, refreshToken: newRefreshToken, ); return true; } return false; } catch (_) { return false; } } /// 会话失效:清空令牌并通知认证层(跳转登录页)。 void _notifyUnauthorized() { TokenStore.instance.clear(); ApiClient.instance.onUnauthorized?.call(); } /// 判断后端返回的 msg 是否为 token/会话失效类提示。 /// /// 爱视后台有时以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」, /// 而非 HTTP 401;此处统一识别并走刷新/登出流程。 static bool _isSessionExpiredMessage(String msg) { final lower = msg.toLowerCase(); if (!lower.contains('token') && !lower.contains('登录')) return false; return lower.contains('失效') || lower.contains('过期') || lower.contains('无效') || lower.contains('请重新登录') || lower.contains('未登录'); } static int _asInt(dynamic value) { if (value is int) { return value; } return int.tryParse(value?.toString() ?? '') ?? -1; } } /// Mock 拦截器(仅 [AppConstants.useMock] = true 时挂载)。 /// /// 返回与真实后端一致的 `{code, msg, data}` 信封,由 [ApiInterceptor] 统一解包。 class MockInterceptor extends Interceptor { @override void onRequest( RequestOptions options, RequestInterceptorHandler handler, ) { // 模拟网络延迟。 Future.delayed(const Duration(milliseconds: 300), () { handler.resolve( Response( requestOptions: options, statusCode: 200, data: _mockResponse(options), ), ); }); } Map _ok(Object? data) { return {'code': 0, 'msg': 'success', 'data': data}; } Map _mockResponse(RequestOptions options) { final path = options.path; // 登录:POST /sys/login if (path == '/sys/login') { final body = options.data; if (body is Map && body['username'] == AppConstants.mockAccount && body['password'] == AppConstants.mockPassword) { return _ok({ 'token': 'mock-token-20240520', 'expire': 7200, 'refreshToken': 'mock-refresh-token', 'refreshExpire': 604800, 'orgId': 1, 'orgType': 1, 'superAdmin': true, 'roleNames': ['运维管理员'], }); } return {'code': 500, 'msg': '账号或密码错误', 'data': null}; } // 登出:POST /sys/logout if (path == '/sys/logout') { return _ok(null); } // 验证码图片:GET /captcha.jpg?uuid=xxx // mock 模式下返回一个 1x1 透明 PNG 占位图。 if (path == '/captcha.jpg') { return {'code': 0, 'msg': 'success', 'data': _transparentPng}; } // 当前用户:GET /sys/user/info if (path == '/sys/user/info') { return _ok({ 'username': AppConstants.mockAccount, 'realName': '运维管理员', 'roleNames': ['运维管理员'], }); } // 设备列表:GET /device/info/list if (path == '/device/info/list') { return _ok({'list': _mockDevices, 'totalCount': _mockDevices.length}); } // 设备详情:GET /device/info/info/{id} final deviceDetailMatch = RegExp(r'^/device/info/info/([A-Za-z0-9\-]+)$').firstMatch(path); if (deviceDetailMatch != null) { final id = deviceDetailMatch.group(1); for (final device in _mockDevices) { if (device['id'] == id) { return _ok(device); } } return {'code': 404, 'msg': '未找到设备:$id', 'data': null}; } // 设备指令:POST /device/command/sendByMac | sendByOrg if (path == '/device/command/sendByMac' || path == '/device/command/sendByOrg') { return _ok(null); } // 告警列表(仅 mock 模式使用;真实模式由设备列表推导):GET /alerts if (path == '/alerts') { return _ok(_mockAlerts); } return {'code': 404, 'msg': '接口不存在:$path', 'data': null}; } /// mock 验证码占位图:1x1 透明 PNG(Base64)。 static const String _transparentPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; /// 示例设备数据(真实 DeviceInfo 结构 + 演示用扩展字段: /// ip / location / os / uptimeSeconds / metrics)。 static final List> _mockDevices = [ { 'id': 'dev-001', 'mac': 'AA:BB:CC:00:00:11', 'name': '核心交换机-SW01', 'deviceSn': 'SN-SW01-2024', 'deviceUid': 'UID-0001', 'status': 'online', 'groupId': 'g-core', 'lastSyncTime': '2024-05-20 09:30:00', 'createTime': '2024-01-15 10:00:00', 'ip': '10.0.1.11', 'location': '华南机房-A区', 'os': 'Huawei VRP V8', 'uptimeSeconds': 1861272, 'metrics': { 'cpuUsage': 0.32, 'memoryUsage': 0.41, 'diskUsage': 0.55, 'networkRxKbps': 1280.5, 'networkTxKbps': 986.2, }, }, { 'id': 'dev-002', 'mac': 'AA:BB:CC:00:00:21', 'name': '应用服务器-APP01', 'deviceSn': 'SN-APP01-2024', 'deviceUid': 'UID-0002', 'status': 'warning', 'groupId': 'g-app', 'lastSyncTime': '2024-05-20 09:28:40', 'createTime': '2024-01-16 14:20:00', 'ip': '10.0.2.21', 'location': '华南机房-B区', 'os': 'CentOS Stream 9', 'uptimeSeconds': 952133, 'metrics': { 'cpuUsage': 0.87, 'memoryUsage': 0.92, 'diskUsage': 0.63, 'networkRxKbps': 4520.8, 'networkTxKbps': 3890.1, }, }, { 'id': 'dev-003', 'mac': 'AA:BB:CC:00:00:31', 'name': '数据库服务器-DB01', 'deviceSn': 'SN-DB01-2024', 'deviceUid': 'UID-0003', 'status': 'online', 'groupId': 'g-db', 'lastSyncTime': '2024-05-20 09:29:55', 'createTime': '2024-01-18 08:00:00', 'ip': '10.0.2.31', 'location': '华南机房-B区', 'os': 'openEuler 22.03 LTS', 'uptimeSeconds': 3654820, 'metrics': { 'cpuUsage': 0.45, 'memoryUsage': 0.68, 'diskUsage': 0.78, 'networkRxKbps': 2310.4, 'networkTxKbps': 1980.6, }, }, { 'id': 'dev-004', 'mac': 'AA:BB:CC:00:00:42', 'name': '存储节点-NAS02', 'deviceSn': 'SN-NAS02-2024', 'deviceUid': 'UID-0004', 'status': 'offline', 'groupId': 'g-store', 'lastSyncTime': '2024-05-20 07:58:00', 'createTime': '2024-02-01 09:00:00', 'ip': '10.0.3.42', 'location': '华北机房-C区', 'os': 'TrueNAS SCALE 23.10', 'uptimeSeconds': 0, 'metrics': { 'cpuUsage': 0.0, 'memoryUsage': 0.0, 'diskUsage': 0.0, 'networkRxKbps': 0.0, 'networkTxKbps': 0.0, }, }, { 'id': 'dev-005', 'mac': 'AA:BB:CC:00:00:107', 'name': '边缘网关-EDGE07', 'deviceSn': 'SN-EDGE07-2024', 'deviceUid': 'UID-0005', 'status': 'online', 'groupId': 'g-edge', 'lastSyncTime': '2024-05-20 09:31:10', 'createTime': '2024-03-05 16:40:00', 'ip': '172.16.8.107', 'location': '华东边缘节点', 'os': 'OpenHarmony 4.1', 'uptimeSeconds': 612345, 'metrics': { 'cpuUsage': 0.21, 'memoryUsage': 0.35, 'diskUsage': 0.48, 'networkRxKbps': 320.7, 'networkTxKbps': 288.3, }, }, ]; /// 示例告警数据(仅 mock 模式使用)。 static final List> _mockAlerts = [ { 'id': 'alert-001', 'deviceId': 'dev-002', 'deviceName': '应用服务器-APP01', 'title': '内存使用率过高', 'message': '内存使用率达到 92%,超过阈值 85%,请尽快扩容或排查内存泄漏。', 'level': 'critical', 'occurredAt': '2024-05-20T09:12:30.000', 'acknowledged': false, }, { 'id': 'alert-002', 'deviceId': 'dev-002', 'deviceName': '应用服务器-APP01', 'title': 'CPU 使用率过高', 'message': 'CPU 使用率达到 87%,超过阈值 80%。', 'level': 'warning', 'occurredAt': '2024-05-20T08:45:10.000', 'acknowledged': true, }, { 'id': 'alert-003', 'deviceId': 'dev-004', 'deviceName': '存储节点-NAS02', 'title': '设备离线', 'message': '存储节点连续 10 分钟无心跳,请检查电源与网络连接。', 'level': 'critical', 'occurredAt': '2024-05-20T07:58:00.000', 'acknowledged': false, }, { 'id': 'alert-004', 'deviceId': 'dev-003', 'deviceName': '数据库服务器-DB01', 'title': '磁盘空间不足', 'message': '磁盘使用率达到 78%,接近阈值 80%,建议清理归档日志。', 'level': 'warning', 'occurredAt': '2024-05-19T22:30:45.000', 'acknowledged': true, }, { 'id': 'alert-005', 'deviceId': 'dev-001', 'deviceName': '核心交换机-SW01', 'title': '端口流量波动', 'message': '上联端口 GE0/0/1 出现短时流量尖峰,已自动恢复。', 'level': 'info', 'occurredAt': '2024-05-19T16:05:22.000', 'acknowledged': true, }, ]; }