Files
AI-Health/health_app/lib/core/api_client.dart
MingNian 9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越
- LocalReportFileStorage: 文件存储路径安全加固
- local_account_file_cleanup: 账号删除时文件清理逻辑增强
- AuthService: 认证逻辑增强
- file_endpoints / report_endpoints: 文件访问接口安全加固
- ai_chat_endpoints / doctor_endpoints: 接口安全调整
- Program.cs: 服务注册调整

## 前端认证与媒体
- 新增 authenticated_network_image.dart: 带认证的图片加载组件
- auth_provider: 认证状态管理大幅增强(+173)
- api_client: 网络客户端增强(+124)
- chat_provider: 聊天 provider 重构(+76)
- omron_device_provider: 蓝牙设备 provider 增强(+53)
- sse_handler: SSE 处理增强(+35)
- consultation_provider / data_providers / conversation_history_provider: 调整

## 页面调整
- remaining_pages: 健康档案/饮食记录等页面增强(+115)
- home_page / chat_messages_view: 主页微调
- doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports)
- report_pages / settings_pages / notification_prefs_page: 微调
- device_scan_page / diet_capture_page / admin_home_page: 微调

## 测试
- 新增 file_path_security_tests: 文件路径安全测试
- 新增 protected_media_url_test: 媒体URL保护测试
- 新增 user_session_identity_test: 用户会话身份测试
- account_deletion_tests / application_service_tests / auth_tests: 更新
2026-07-20 10:19:01 +08:00

311 lines
9.5 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 'package:flutter/foundation.dart' show kReleaseMode;
import 'local_database.dart';
/// API 基础地址(生产模式)。本地开发可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: kReleaseMode
? 'https://erpapi.datalumina.cn/xiaomai'
: '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;
Future<_TokenRefreshResult>? _refreshInFlight;
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');
}
Future<void> clearTokensIfRefreshMatches(String failedRefreshToken) async {
if (await refreshToken != failedRefreshToken) return;
await clearTokens();
}
Future<_TokenRefreshResult> _refreshTokens() {
final active = _refreshInFlight;
if (active != null) return active;
final future = _performTokenRefresh();
_refreshInFlight = future;
return future.whenComplete(() {
if (identical(_refreshInFlight, future)) _refreshInFlight = null;
});
}
Future<_TokenRefreshResult> _performTokenRefresh() async {
final refresh = await refreshToken;
if (refresh == null) return const _TokenRefreshResult.invalid();
try {
final response = await Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
),
).post('/api/auth/refresh', data: {'refreshToken': refresh});
final body = response.data;
final data = body is Map ? body['data'] : null;
final rawCode = body is Map ? body['code'] : null;
final code = rawCode is int ? rawCode : int.tryParse('$rawCode');
if (code != null && code != 0) {
return _TokenRefreshResult.invalid(refreshToken: refresh);
}
if (data is Map &&
data['accessToken'] is String &&
data['refreshToken'] is String) {
final accessToken = data['accessToken'] as String;
final newRefreshToken = data['refreshToken'] as String;
// 刷新期间用户可能已经退出或切换账号,旧响应不得覆盖新会话。
if (await refreshToken != refresh) {
return const _TokenRefreshResult.transientFailure();
}
await saveTokens(accessToken, newRefreshToken);
return _TokenRefreshResult.success(accessToken);
}
return const _TokenRefreshResult.transientFailure();
} on DioException catch (error) {
if (error.response?.statusCode == 400 ||
error.response?.statusCode == 401) {
return _TokenRefreshResult.invalid(refreshToken: refresh);
}
return const _TokenRefreshResult.transientFailure();
} catch (_) {
return const _TokenRefreshResult.transientFailure();
}
}
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 ||
err.requestOptions.extra['authRetried'] == true) {
return handler.next(err);
}
final result = await _client._refreshTokens();
if (result.accessToken != null) {
try {
final opts = err.requestOptions;
opts.extra['authRetried'] = true;
opts.headers['Authorization'] = 'Bearer ${result.accessToken}';
final retryResponse = await _client.dio.fetch(opts);
return handler.resolve(retryResponse);
} catch (error) {
log('[ApiClient] 请求重试失败: $error');
}
} else if (result.isInvalid) {
final failedRefresh = result.failedRefreshToken;
if (failedRefresh != null) {
await _client.clearTokensIfRefreshMatches(failedRefresh);
} else {
await _client.clearTokens();
}
_client.notifyAuthExpired();
}
handler.next(err);
}
}
class _TokenRefreshResult {
final String? accessToken;
final bool isInvalid;
final String? failedRefreshToken;
const _TokenRefreshResult._({
this.accessToken,
this.isInvalid = false,
this.failedRefreshToken,
});
const _TokenRefreshResult.success(String accessToken)
: this._(accessToken: accessToken);
const _TokenRefreshResult.invalid({String? refreshToken})
: this._(isInvalid: true, failedRefreshToken: refreshToken);
const _TokenRefreshResult.transientFailure() : this._();
}