Files
AI-Health/health_app/lib/utils/sse_handler.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

117 lines
3.3 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:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import '../core/api_client.dart';
/// 跨平台 SSE 流处理(基于 Dio 流式响应,支持 Android/iOS/Web
class SseHandler {
/// 连接 SSE 端点,返回事件流
static Stream<Map<String, dynamic>> connect({
required String agentType,
required String message,
String? conversationId,
String? imageUrl,
String? pdfUrl,
required String token,
}) {
final params = <String, String>{'message': message};
if (conversationId != null) {
params['conversationId'] = conversationId;
}
if (imageUrl != null && imageUrl.isNotEmpty) {
params['imageUrl'] = imageUrl;
}
if (pdfUrl != null && pdfUrl.isNotEmpty) {
params['pdfUrl'] = pdfUrl;
}
final query = params.entries
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
.join('&');
final url = '$baseUrl/api/ai/$agentType/chat?$query';
final cancelToken = CancelToken();
late final StreamController<Map<String, dynamic>> controller;
controller = StreamController<Map<String, dynamic>>(
onListen: () {
_connect(controller, url, token, cancelToken);
},
onCancel: () {
if (!cancelToken.isCancelled) {
cancelToken.cancel('用户停止生成');
}
},
);
return controller.stream;
}
static Future<void> _connect(
StreamController<Map<String, dynamic>> controller,
String url,
String token,
CancelToken cancelToken,
) async {
try {
final dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(minutes: 5),
),
);
final response = await dio.get(
url,
cancelToken: cancelToken,
options: Options(
responseType: ResponseType.stream,
headers: {'Authorization': 'Bearer $token'},
),
);
final stream = response.data.stream as Stream<List<int>>;
var buffer = '';
await for (final chunk in stream) {
if (controller.isClosed) break;
final text = utf8.decode(chunk, allowMalformed: true);
buffer += text;
// 按行解析 SSE 数据
while (buffer.contains('\n')) {
final newlineIdx = buffer.indexOf('\n');
var line = buffer.substring(0, newlineIdx).trim();
buffer = buffer.substring(newlineIdx + 1);
if (line.isEmpty || !line.startsWith('data: ')) continue;
final data = line.substring(6);
if (data == '[DONE]') {
controller.close();
return;
}
try {
controller.add(jsonDecode(data) as Map<String, dynamic>);
} catch (_) {
// 跳过无法解析的行
}
}
}
controller.close();
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
if (!controller.isClosed) await controller.close();
return;
}
if (!controller.isClosed) {
controller.add({'action': 'error', 'message': e.toString()});
await controller.close();
}
} catch (e) {
if (!controller.isClosed) {
controller.add({'action': 'error', 'message': e.toString()});
await controller.close();
}
}
}
}