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: 更新
This commit is contained in:
MingNian
2026-07-20 10:19:01 +08:00
parent 0d4fd88ce7
commit 9cea41705e
48 changed files with 1181 additions and 212 deletions

View File

@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: kReleaseMode
? 'https://erpapi.datalumina.cn/xiaomai'
: 'http://10.4.165.54:5000',
: 'http://192.168.1.34:5000',
);
class ApiException implements Exception {
@@ -44,6 +44,7 @@ class ApiClient {
final Dio _dio;
final LocalDatabase _db;
final AuthExpiredNotifier? _authExpiredNotifier;
Future<_TokenRefreshResult>? _refreshInFlight;
ApiClient({
required LocalDatabase db,
@@ -79,6 +80,63 @@ class ApiClient {
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();
}
@@ -202,31 +260,51 @@ class _AuthInterceptor extends Interceptor {
@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');
}
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();
}
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._();
}