Files
CloudHome-Ops/lib/services/api_client.dart

668 lines
21 KiB
Dart
Raw Permalink 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.

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<void> onResponse(
Response response,
ResponseInterceptorHandler handler,
) async {
final body = response.data;
// 非信封格式(如二进制流)直接放行。
if (body is! Map<String, dynamic> || !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<void> 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<bool> _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<dynamic>(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<bool>? _refreshing;
/// 用 refreshToken 换新 token(带并发锁)。
Future<bool> _tryRefreshToken() {
return _refreshing ??= _doRefreshToken().whenComplete(() {
_refreshing = null;
});
}
/// 实际刷新逻辑。使用独立 Dio 实例,避免进入拦截器循环。
Future<bool> _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<Map<String, dynamic>>(
_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<String, dynamic> && 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<String, dynamic> _ok(Object? data) {
return {'code': 0, 'msg': 'success', 'data': data};
}
Map<String, dynamic> _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<Map<String, dynamic>> _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<Map<String, dynamic>> _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,
},
];
}