666 lines
20 KiB
Dart
666 lines
20 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../config/app_config.dart';
|
|
import '../models/user_model.dart' as user_model;
|
|
import '../models/user_profile.dart';
|
|
import '../models/health_model.dart' as health_model;
|
|
import '../models/message_model.dart' as message_model;
|
|
import '../models/alert_model.dart' as alert_model;
|
|
import '../models/global_health_status.dart';
|
|
import '../models/health_metric.dart';
|
|
import '../models/risk_alert.dart';
|
|
import '../models/ai_health_suggestion.dart';
|
|
import 'auth_service_v2.dart';
|
|
import 'cache_service.dart';
|
|
import 'openapi_endpoints.dart';
|
|
import '../utils/error_handler.dart';
|
|
import '../utils/logger.dart';
|
|
|
|
/// 统一 API 客户端
|
|
/// 使用 Dio + 拦截器实现自动 token 管理 + 缓存策略
|
|
class ApiClient {
|
|
late final Dio _dio;
|
|
final CacheService _cache = CacheService();
|
|
|
|
// Store current user context for API calls (String to avoid precision issues)
|
|
String? _currentUserId;
|
|
String? _currentCustomerId;
|
|
List<String>? _currentOrgIds;
|
|
|
|
// 重试配置
|
|
static const int _maxRetries = 2;
|
|
static const int _retryDelayMs = 500;
|
|
|
|
ApiClient() {
|
|
_dio = Dio(BaseOptions(
|
|
baseUrl: AppConfig.instance.apiBaseUrl,
|
|
connectTimeout: Duration(seconds: 30),
|
|
receiveTimeout: Duration(seconds: 30),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
));
|
|
|
|
// 请求拦截器:自动添加 token + 性能监控
|
|
_dio.interceptors.add(InterceptorsWrapper(
|
|
onRequest: (options, handler) async {
|
|
final token = await AuthServiceV2.getAccessToken();
|
|
if (token != null) {
|
|
options.headers['Authorization'] = 'Bearer $token';
|
|
}
|
|
|
|
// 结构化日志记录
|
|
Logger.i(
|
|
'API Request: ${options.method} ${options.path}',
|
|
tag: 'API',
|
|
data: {
|
|
'method': options.method,
|
|
'path': options.path,
|
|
'query': options.queryParameters,
|
|
'hasBody': options.data != null,
|
|
},
|
|
);
|
|
|
|
// 开始性能追踪(存储在 extra 中)
|
|
options.extra['_api_tracker'] = ApiPerformanceMonitor.startTracking(
|
|
options.path,
|
|
options.method,
|
|
);
|
|
|
|
return handler.next(options);
|
|
},
|
|
onResponse: (response, handler) {
|
|
// 结束性能追踪
|
|
final tracker = response.requestOptions.extra['_api_tracker'] as ApiCallTracker?;
|
|
tracker?.finish(
|
|
success: true,
|
|
statusCode: response.statusCode,
|
|
);
|
|
|
|
Logger.i(
|
|
'API Response: ${response.statusCode}',
|
|
tag: 'API',
|
|
data: {
|
|
'status': response.statusCode,
|
|
'path': response.requestOptions.path,
|
|
'hasData': response.data != null,
|
|
},
|
|
);
|
|
|
|
return handler.next(response);
|
|
},
|
|
onError: (error, handler) async {
|
|
// 结束性能追踪(失败)
|
|
final tracker = error.requestOptions.extra['_api_tracker'] as ApiCallTracker?;
|
|
tracker?.finish(
|
|
success: false,
|
|
statusCode: error.response?.statusCode,
|
|
errorMessage: error.message,
|
|
);
|
|
|
|
// 使用 ErrorHandler 处理错误
|
|
final errorMessage = ErrorHandler.handleError(
|
|
error,
|
|
context: '${error.requestOptions.method} ${error.requestOptions.path}',
|
|
);
|
|
|
|
Logger.e(
|
|
'API Error: $errorMessage',
|
|
tag: 'API',
|
|
error: error,
|
|
data: {
|
|
'status': error.response?.statusCode,
|
|
'path': error.requestOptions.path,
|
|
'type': error.type.toString(),
|
|
},
|
|
);
|
|
|
|
// Token 过期自动刷新
|
|
if (error.response?.statusCode == 401) {
|
|
Logger.w('Token expired, attempting refresh', tag: 'AUTH');
|
|
|
|
final refreshed = await AuthServiceV2.refreshToken();
|
|
if (refreshed) {
|
|
// 重试原请求
|
|
try {
|
|
final options = error.requestOptions;
|
|
final token = await AuthServiceV2.getAccessToken();
|
|
options.headers['Authorization'] = 'Bearer $token';
|
|
|
|
// 创建新的追踪器(重试请求)
|
|
options.extra['_api_tracker'] = ApiPerformanceMonitor.startTracking(
|
|
options.path,
|
|
options.method,
|
|
);
|
|
|
|
final response = await _dio.fetch(options);
|
|
Logger.i('Token refresh successful, retry succeeded', tag: 'AUTH');
|
|
return handler.resolve(response);
|
|
} catch (e) {
|
|
Logger.e('Token refresh retry failed', tag: 'AUTH', error: e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return handler.next(error);
|
|
},
|
|
));
|
|
}
|
|
|
|
/// 网络请求重试包装器(仅重试网络错误)
|
|
Future<T?> _retryOnNetworkError<T>(
|
|
Future<T> Function() operation, {
|
|
String? cacheKey,
|
|
}) async {
|
|
int retries = 0;
|
|
while (retries <= _maxRetries) {
|
|
try {
|
|
return await operation();
|
|
} on DioException catch (e) {
|
|
final isNetworkError = ErrorHandler.isNetworkError(e);
|
|
|
|
if (isNetworkError && retries < _maxRetries) {
|
|
retries++;
|
|
final delayMs = _retryDelayMs * retries; // 指数退避
|
|
Logger.w(
|
|
'Network error, retrying attempt $retries/$_maxRetries after ${delayMs}ms',
|
|
tag: 'RETRY',
|
|
data: {'error': e.type.toString(), 'delay': delayMs},
|
|
);
|
|
await Future.delayed(Duration(milliseconds: delayMs));
|
|
continue;
|
|
}
|
|
|
|
// 非网络错误或已达最大重试次数,返回缓存数据(即使过期)
|
|
if (cacheKey != null) {
|
|
final expired = _cache.get<T>(cacheKey);
|
|
if (expired != null) {
|
|
Logger.w(
|
|
'Using stale cache as fallback',
|
|
tag: 'CACHE',
|
|
data: {'cacheKey': cacheKey},
|
|
);
|
|
return expired;
|
|
}
|
|
}
|
|
|
|
final errorMessage = ErrorHandler.handleError(e, context: 'Retry wrapper');
|
|
Logger.e(
|
|
'Request failed after retries: $errorMessage',
|
|
tag: 'RETRY',
|
|
error: e,
|
|
);
|
|
return null;
|
|
} catch (e) {
|
|
Logger.e('Unexpected error in retry wrapper', tag: 'RETRY', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 获取当前用户个人资料(带缓存)
|
|
Future<UserProfile?> getUserProfile({bool forceRefresh = false}) async {
|
|
const cacheKey = 'user_profile';
|
|
|
|
if (!forceRefresh) {
|
|
final cached = _cache.get<UserProfile>(cacheKey);
|
|
if (cached != null) {
|
|
debugPrint('[Cache] Hit: $cacheKey');
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
try {
|
|
final response = await _dio.get(OpenApiEndpoints.mobileUserInfo);
|
|
|
|
if (response.data['code'] == 200) {
|
|
final data = response.data['data'];
|
|
final profile = UserProfile.fromJson(data);
|
|
_cache.set(cacheKey, profile, ttl: Duration(minutes: 10));
|
|
return profile;
|
|
}
|
|
|
|
debugPrint('Get user profile failed: ${response.data['message']}');
|
|
return null;
|
|
} catch (e) {
|
|
debugPrint('Get user profile error: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 获取用户信息(带缓存)
|
|
Future<user_model.UserInfo?> getUserInfo({bool forceRefresh = false}) async {
|
|
const cacheKey = 'user_info';
|
|
|
|
if (!forceRefresh) {
|
|
final cached = _cache.get<user_model.UserInfo>(cacheKey);
|
|
if (cached != null) {
|
|
debugPrint('[Cache] Hit: $cacheKey');
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
try {
|
|
final response = await _dio.get(OpenApiEndpoints.mobileUserInfo);
|
|
|
|
if (response.data['code'] == 200) {
|
|
final data = response.data['data'];
|
|
|
|
// Extract user context for API calls (String type)
|
|
_currentUserId = data['userId']?.toString();
|
|
_currentCustomerId = data['customerId']?.toString();
|
|
if (data['orgIds'] != null) {
|
|
_currentOrgIds = (data['orgIds'] as List).map((e) => e.toString()).toList();
|
|
}
|
|
|
|
debugPrint('[UserContext] userId=$_currentUserId, customerId=$_currentCustomerId, orgIds=$_currentOrgIds');
|
|
|
|
final userInfo = user_model.UserInfo.fromJson(data);
|
|
_cache.set(cacheKey, userInfo, ttl: Duration(minutes: 10));
|
|
return userInfo;
|
|
}
|
|
|
|
debugPrint('Get user info failed: ${response.data['message']}');
|
|
return null;
|
|
} catch (e) {
|
|
debugPrint('Get user info error: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 获取健康数据
|
|
Future<List<health_model.HealthDataRecord>> getHealthData({
|
|
required String startDate,
|
|
required String endDate,
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
}) async {
|
|
try {
|
|
final queryParams = {
|
|
'startDate': startDate,
|
|
'endDate': endDate,
|
|
'page': page,
|
|
'pageSize': pageSize,
|
|
};
|
|
|
|
// User context (userId, customerId, orgId) is automatically injected by backend
|
|
// No need to pass these parameters manually
|
|
|
|
final response = await _dio.get(OpenApiEndpoints.mobileHealthData, queryParameters: queryParams);
|
|
|
|
if (response.data['code'] == 200) {
|
|
final records = response.data['data']['records'] as List;
|
|
return records
|
|
.map((e) => health_model.HealthDataRecord.fromJson(e))
|
|
.toList();
|
|
}
|
|
|
|
debugPrint('Get health data failed: ${response.data['message']}');
|
|
return [];
|
|
} catch (e) {
|
|
debugPrint('Get health data error: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// 获取今日健康数据(带缓存)
|
|
Future<health_model.HealthData?> getTodayHealthData({bool forceRefresh = false}) async {
|
|
final today = DateTime.now();
|
|
final dateStr = '${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
|
|
final cacheKey = 'today_health_data_$dateStr';
|
|
|
|
if (!forceRefresh) {
|
|
final cached = _cache.get<health_model.HealthData>(cacheKey);
|
|
if (cached != null) {
|
|
debugPrint('[Cache] Hit: $cacheKey');
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
final dataList = await getHealthData(
|
|
startDate: dateStr,
|
|
endDate: dateStr,
|
|
page: 1,
|
|
pageSize: 1,
|
|
);
|
|
|
|
if (dataList.isNotEmpty) {
|
|
// Wrap HealthDataRecord in HealthData object
|
|
final record = dataList.first;
|
|
final healthData = health_model.HealthData(
|
|
departmentStats: {},
|
|
deviceCount: 1,
|
|
healthData: [record],
|
|
orgId: '',
|
|
statistics: health_model.HealthStatistics(
|
|
averageStats: health_model.AverageStats(
|
|
avgBloodOxygen: record.bloodOxygen ?? 0,
|
|
avgCalorie: record.calorie ?? 0,
|
|
avgDistance: record.distance ?? 0,
|
|
avgHeartRate: record.heartRate ?? 0,
|
|
avgStep: record.step?.toDouble() ?? 0,
|
|
avgTemperature: record.temperature ?? 0,
|
|
),
|
|
devicesWithData: 1,
|
|
totalDevices: 1,
|
|
),
|
|
totalRecords: 1,
|
|
userId: '',
|
|
vitals: {
|
|
'heartRate': record.heartRate,
|
|
'bloodOxygen': record.bloodOxygen,
|
|
'temperature': record.temperature,
|
|
'pressureHigh': record.pressureHigh,
|
|
'pressureLow': record.pressureLow,
|
|
'step': record.step,
|
|
'distance': record.distance,
|
|
'calorie': record.calorie,
|
|
'stress': record.stress,
|
|
},
|
|
);
|
|
_cache.set(cacheKey, healthData, ttl: Duration(minutes: 3));
|
|
return healthData;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// 获取消息列表
|
|
Future<message_model.MessageListResponse> getMessages({
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
String? status,
|
|
}) async {
|
|
try {
|
|
// Use new mobile API endpoint
|
|
final response = await _dio.get(OpenApiEndpoints.mobileMessages, queryParameters: {
|
|
'page': page,
|
|
'pageSize': pageSize,
|
|
if (status != null) 'status': status,
|
|
});
|
|
|
|
if (response.data['code'] == 200 || response.statusCode == 200) {
|
|
// Handle both response formats
|
|
if (response.data['data'] is Map && response.data['data']['records'] != null) {
|
|
return message_model.MessageListResponse.fromJson(response.data['data']);
|
|
}
|
|
// Fallback to empty response
|
|
return message_model.MessageListResponse.empty();
|
|
}
|
|
|
|
debugPrint('Get messages failed: ${response.data['message']}');
|
|
return message_model.MessageListResponse.empty();
|
|
} catch (e) {
|
|
debugPrint('Get messages error: $e');
|
|
return message_model.MessageListResponse.empty();
|
|
}
|
|
}
|
|
|
|
/// 获取未读消息数量
|
|
Future<int> getUnreadMessageCount() async {
|
|
try {
|
|
// Return 0 for now as endpoint may not exist
|
|
return 0;
|
|
} catch (e) {
|
|
debugPrint('Get unread message count error: $e');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// 标记消息已读
|
|
Future<bool> markMessageAsRead(String messageId) async {
|
|
try {
|
|
// Not implemented yet
|
|
return false;
|
|
} catch (e) {
|
|
debugPrint('Mark message as read error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 获取告警列表
|
|
Future<alert_model.AlertListResponse> getAlerts({
|
|
int page = 1,
|
|
int pageSize = 20,
|
|
String? status,
|
|
String? level,
|
|
}) async {
|
|
try {
|
|
final response = await _dio.get(OpenApiEndpoints.mobileAlerts, queryParameters: {
|
|
'page': page,
|
|
'pageSize': pageSize,
|
|
if (status != null) 'status': status,
|
|
if (level != null) 'level': level,
|
|
});
|
|
|
|
if (response.data['code'] == 200) {
|
|
debugPrint('✅ [API] Get alerts response: ${response.data['data']}');
|
|
final alertListResponse = alert_model.AlertListResponse.fromJson(response.data['data']);
|
|
debugPrint('✅ [API] Parsed ${alertListResponse.records.length} alerts');
|
|
if (alertListResponse.records.isNotEmpty) {
|
|
final firstAlert = alertListResponse.records.first;
|
|
debugPrint('✅ [API] First alert - alertDesc: "${firstAlert.alertDesc}", alertType: "${firstAlert.alertType}", alertTimestamp: "${firstAlert.alertTimestamp}"');
|
|
}
|
|
return alertListResponse;
|
|
}
|
|
|
|
debugPrint('Get alerts failed: ${response.data['message']}');
|
|
return alert_model.AlertListResponse.empty();
|
|
} catch (e) {
|
|
debugPrint('Get alerts error: $e');
|
|
return alert_model.AlertListResponse.empty();
|
|
}
|
|
}
|
|
|
|
/// 获取未处理告警数量
|
|
Future<int> getUnprocessedAlertCount() async {
|
|
try {
|
|
final response = await _dio.get(OpenApiEndpoints.mobileAlertsUnprocessedCount);
|
|
|
|
if (response.data['code'] == 200) {
|
|
return (response.data['data'] as num?)?.toInt() ?? 0;
|
|
}
|
|
return 0;
|
|
} catch (e) {
|
|
debugPrint('Get unprocessed alert count error: $e');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// 获取全局健康状态(带重试和缓存降级)
|
|
Future<GlobalHealthStatus?> getGlobalHealthStatus({bool forceRefresh = false}) async {
|
|
const cacheKey = 'global_health_status';
|
|
|
|
if (!forceRefresh) {
|
|
final cached = _cache.get<GlobalHealthStatus>(cacheKey);
|
|
if (cached != null) {
|
|
debugPrint('[Cache] Hit: $cacheKey');
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
return await _retryOnNetworkError<GlobalHealthStatus?>(
|
|
() async {
|
|
final response = await _dio.get(OpenApiEndpoints.mobileHealthStatusGlobal);
|
|
|
|
if (response.data['code'] == 200) {
|
|
final status = GlobalHealthStatus.fromJson(response.data['data']);
|
|
_cache.set(cacheKey, status, ttl: Duration(minutes: 2));
|
|
return status;
|
|
}
|
|
|
|
debugPrint('Get global health status failed: ${response.data['message']}');
|
|
return null;
|
|
},
|
|
cacheKey: cacheKey,
|
|
);
|
|
}
|
|
|
|
/// 获取关键健康指标(带重试和缓存降级)
|
|
Future<List<HealthMetric>> getKeyHealthMetrics({bool forceRefresh = false}) async {
|
|
const cacheKey = 'key_health_metrics';
|
|
|
|
if (!forceRefresh) {
|
|
final cached = _cache.get<List<HealthMetric>>(cacheKey);
|
|
if (cached != null) {
|
|
debugPrint('[Cache] Hit: $cacheKey');
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
final result = await _retryOnNetworkError<List<HealthMetric>>(
|
|
() async {
|
|
final response = await _dio.get(OpenApiEndpoints.mobileHealthMetricsKey);
|
|
|
|
if (response.data['code'] == 200) {
|
|
final List<dynamic> data = response.data['data'];
|
|
final metrics = data.map((e) => HealthMetric.fromJson(e)).toList();
|
|
_cache.set(cacheKey, metrics, ttl: Duration(minutes: 2));
|
|
return metrics;
|
|
}
|
|
|
|
debugPrint('Get key health metrics failed: ${response.data['message']}');
|
|
return [];
|
|
},
|
|
cacheKey: cacheKey,
|
|
);
|
|
|
|
return result ?? [];
|
|
}
|
|
|
|
/// 获取风险告警摘要(带重试和缓存降级)
|
|
Future<RiskAlert?> getRiskAlertSummary({int hours = 24, bool forceRefresh = false}) async {
|
|
const cacheKey = 'risk_alert_summary';
|
|
|
|
if (!forceRefresh) {
|
|
final cached = _cache.get<RiskAlert>(cacheKey);
|
|
if (cached != null) {
|
|
debugPrint('[Cache] Hit: $cacheKey');
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
return await _retryOnNetworkError<RiskAlert?>(
|
|
() async {
|
|
final response = await _dio.get(OpenApiEndpoints.mobileHealthAlertsSummary, queryParameters: {
|
|
'hours': hours,
|
|
});
|
|
|
|
if (response.data['code'] == 200) {
|
|
final data = response.data['data'];
|
|
if (data != null) {
|
|
final alert = RiskAlert.fromJson(data);
|
|
_cache.set(cacheKey, alert, ttl: Duration(minutes: 5));
|
|
return alert;
|
|
}
|
|
}
|
|
|
|
debugPrint('Get risk alert summary failed: ${response.data['message']}');
|
|
return null;
|
|
},
|
|
cacheKey: cacheKey,
|
|
);
|
|
}
|
|
|
|
/// 获取 AI 健康建议(带重试和缓存降级)
|
|
Future<AIHealthSuggestion?> getAIHealthSuggestions({bool forceRefresh = false}) async {
|
|
const cacheKey = 'ai_health_suggestions';
|
|
|
|
if (!forceRefresh) {
|
|
final cached = _cache.get<AIHealthSuggestion>(cacheKey);
|
|
if (cached != null) {
|
|
debugPrint('[Cache] Hit: $cacheKey');
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
return await _retryOnNetworkError<AIHealthSuggestion?>(
|
|
() async {
|
|
final response = await _dio.get(OpenApiEndpoints.mobileHealthAiSuggestions);
|
|
|
|
if (response.data['code'] == 200) {
|
|
final data = response.data['data'];
|
|
if (data != null) {
|
|
final suggestion = AIHealthSuggestion.fromJson(data);
|
|
_cache.set(cacheKey, suggestion, ttl: Duration(minutes: 10));
|
|
return suggestion;
|
|
}
|
|
}
|
|
|
|
debugPrint('Get AI health suggestions failed: ${response.data['message']}');
|
|
return null;
|
|
},
|
|
cacheKey: cacheKey,
|
|
);
|
|
}
|
|
|
|
/// 处理告警
|
|
Future<bool> processAlert(String alertId, String status) async {
|
|
try {
|
|
final response = await _dio.post(OpenApiEndpoints.mobileAlertsProcess, data: {
|
|
'alertId': alertId,
|
|
'status': status,
|
|
'processedTime': DateTime.now().toIso8601String(),
|
|
});
|
|
|
|
return response.data['code'] == 200;
|
|
} catch (e) {
|
|
debugPrint('Process alert error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 上传健康数据(保留兼容旧接口)
|
|
Future<bool> uploadHealthData(Map<String, dynamic> healthData) async {
|
|
try {
|
|
final response = await _dio.post(OpenApiEndpoints.uploadHealthData, data: healthData);
|
|
|
|
return response.statusCode == 200;
|
|
} catch (e) {
|
|
debugPrint('Upload health data error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 上传设备信息(保留兼容旧接口)
|
|
Future<bool> uploadDeviceInfo(Map<String, dynamic> deviceInfo) async {
|
|
try {
|
|
final response = await _dio.post(OpenApiEndpoints.uploadDeviceInfo, data: deviceInfo);
|
|
|
|
return response.statusCode == 200;
|
|
} catch (e) {
|
|
debugPrint('Upload device info error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 重置密码
|
|
Future<bool> resetPassword(String oldPassword, String newPassword) async {
|
|
try {
|
|
// Use mobile API endpoint
|
|
final response = await _dio.post(OpenApiEndpoints.mobileResetPassword, data: {
|
|
'oldPassword': oldPassword,
|
|
'newPassword': newPassword,
|
|
});
|
|
|
|
return response.data['code'] == 200 || response.statusCode == 200;
|
|
} catch (e) {
|
|
debugPrint('Reset password error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 更新用户信息
|
|
Future<bool> updateUserInfo(Map<String, dynamic> userInfo) async {
|
|
try {
|
|
final response = await _dio.put(OpenApiEndpoints.authUserInfo, data: userInfo);
|
|
|
|
return response.data['code'] == 200;
|
|
} catch (e) {
|
|
debugPrint('Update user info error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
}
|