Files
AI-Health/health_app/lib/core/api_client.dart
MingNian e654c1e0cc feat: 通知推送/免打扰偏好 + 饮食记录侧滑删除修复 + 聊天交互优化
- 通知: 新增推送开关/免打扰时段偏好持久化 + EF 迁移; 通知管线支持免打扰过滤
- 饮食: 侧滑删除 confirmDismiss 按方向放行(修复删不掉); 新增 diet_nutrition_widgets; 饮食录入页重构
- 聊天: 智能体胶囊点击锁防误触; 流式状态 select 优化; 卡片入场动画
- 主页: 消息列表提取 _HomeMessages + 侧滑手势打开抽屉
- 其他: api_client IP 适配; 通知/用药/饮食端点微调; 测试更新
2026-07-13 10:39:34 +08:00

230 lines
6.6 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.

import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'local_database.dart';
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://192.168.1.34:5000',
);
class ApiException implements Exception {
final int? code;
final String message;
const ApiException(this.message, {this.code});
@override
String toString() => message;
}
typedef AuthExpiredListener = void Function();
class AuthExpiredNotifier {
final List<AuthExpiredListener> _listeners = [];
AuthExpiredListener addListener(AuthExpiredListener listener) {
_listeners.add(listener);
return () => _listeners.remove(listener);
}
void notify() {
for (final listener in List<AuthExpiredListener>.from(_listeners)) {
listener();
}
}
}
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
class ApiClient {
final Dio _dio;
final LocalDatabase _db;
final AuthExpiredNotifier? _authExpiredNotifier;
ApiClient({
required LocalDatabase db,
AuthExpiredNotifier? authExpiredNotifier,
}) : _db = db,
_authExpiredNotifier = authExpiredNotifier,
_dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 60),
headers: {'Content-Type': 'application/json'},
),
) {
_dio.interceptors.add(_AuthInterceptor(this));
_dio.interceptors.add(
LogInterceptor(requestBody: false, responseBody: false),
);
}
Dio get dio => _dio;
Future<String?> get accessToken => _db.read('access_token');
Future<String?> get refreshToken => _db.read('refresh_token');
Future<void> saveTokens(String access, String refresh) async {
await _db.write('access_token', access);
await _db.write('refresh_token', refresh);
}
Future<void> clearTokens() async {
await _db.delete('access_token');
await _db.delete('refresh_token');
}
void notifyAuthExpired() {
_authExpiredNotifier?.notify();
}
/// 带 token 的 GET 请求
Future<Response> get(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
return _request(_dio.get(path, queryParameters: queryParameters));
}
/// 带 token 的 POST 请求
Future<Response> post(String path, {dynamic data}) async {
return _request(_dio.post(path, data: data));
}
/// 带 token 的 PUT 请求
Future<Response> put(String path, {dynamic data}) async {
return _request(_dio.put(path, data: data));
}
/// 带 token 的 DELETE 请求
Future<Response> delete(String path) async {
return _request(_dio.delete(path));
}
/// 上传文件multipart返回文件 URL
Future<String?> uploadFile(
String path,
File file, {
String fieldName = 'file',
}) async {
final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(
file.path,
filename: file.path.split('/').last,
),
});
final res = await _request(_dio.post(path, data: formData));
final data = res.data;
if (data is Map) {
final topUrl = data['url']?.toString();
if (topUrl != null && topUrl.isNotEmpty) return topUrl;
final payload = data['data'];
if (payload is Map) {
final url = payload['url']?.toString();
return url != null && url.isNotEmpty ? url : null;
}
if (payload is List && payload.isNotEmpty && payload.first is Map) {
final first = payload.first as Map;
final url = first['url']?.toString();
return url != null && url.isNotEmpty ? url : null;
}
}
return null;
}
Response _ensureBusinessSuccess(Response response) {
final body = response.data;
if (body is Map && body.containsKey('code')) {
final rawCode = body['code'];
final code = rawCode is int ? rawCode : int.tryParse('$rawCode');
if (code != null && code != 0) {
throw ApiException(
body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: '请求失败',
code: code,
);
}
}
return response;
}
Future<Response> _request(Future<Response> request) async {
try {
return _ensureBusinessSuccess(await request);
} on DioException catch (error) {
final body = error.response?.data;
final message =
body is Map && body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout
? '请求超时,请稍后重试'
: error.type == DioExceptionType.connectionError
? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试';
final rawCode = body is Map ? body['code'] : null;
throw ApiException(
message,
code: rawCode is int
? rawCode
: int.tryParse('$rawCode') ?? error.response?.statusCode,
);
}
}
}
/// 认证拦截器:自动注入 token + 401 刷新
class _AuthInterceptor extends Interceptor {
final ApiClient _client;
_AuthInterceptor(this._client);
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (!options.path.contains('/auth/')) {
final token = await _client.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
final refresh = await _client.refreshToken;
if (refresh != null) {
try {
final response = await Dio(
BaseOptions(baseUrl: baseUrl),
).post('/api/auth/refresh', data: {'refreshToken': refresh});
final data = response.data['data'];
if (data != null) {
await _client.saveTokens(data['accessToken'], data['refreshToken']);
final opts = err.requestOptions;
final token = data['accessToken'];
opts.headers['Authorization'] = 'Bearer $token';
final retryResponse = await Dio(
BaseOptions(baseUrl: baseUrl),
).fetch(opts);
return handler.resolve(retryResponse);
}
} catch (e) {
log('[ApiClient] token刷新失败: $e');
}
}
await _client.clearTokens();
_client.notifyAuthExpired();
}
handler.next(err);
}
}