## 后端安全加固 - 新增 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: 更新
390 lines
12 KiB
Dart
390 lines
12 KiB
Dart
import 'dart:async';
|
||
import 'dart:developer';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:signalr_netcore/signalr_client.dart';
|
||
import '../core/api_client.dart' show baseUrl;
|
||
import 'auth_provider.dart';
|
||
|
||
class ConsultationMsg {
|
||
final String id;
|
||
final String senderType; // 'User' | 'Ai' | 'Doctor'
|
||
final String? senderName;
|
||
final String content;
|
||
final DateTime createdAt;
|
||
|
||
ConsultationMsg({
|
||
required this.id,
|
||
required this.senderType,
|
||
this.senderName,
|
||
required this.content,
|
||
required this.createdAt,
|
||
});
|
||
}
|
||
|
||
class ConsultationChatState {
|
||
final String? consultationId;
|
||
final String doctorId;
|
||
final String doctorName;
|
||
final String doctorTitle;
|
||
final String doctorDepartment;
|
||
final String status; // AiTalking | WaitingDoctor | DoctorReplied | Closed
|
||
final List<ConsultationMsg> messages;
|
||
final bool isLoading;
|
||
final bool isSending;
|
||
final bool isConnected;
|
||
final int quotaRemaining;
|
||
final int quotaTotal;
|
||
|
||
const ConsultationChatState({
|
||
this.consultationId,
|
||
this.doctorId = '',
|
||
this.doctorName = '',
|
||
this.doctorTitle = '',
|
||
this.doctorDepartment = '',
|
||
this.status = 'AiTalking',
|
||
this.messages = const [],
|
||
this.isLoading = true,
|
||
this.isSending = false,
|
||
this.isConnected = false,
|
||
this.quotaRemaining = 3,
|
||
this.quotaTotal = 3,
|
||
});
|
||
|
||
ConsultationChatState copyWith({
|
||
String? consultationId,
|
||
String? doctorId,
|
||
String? doctorName,
|
||
String? doctorTitle,
|
||
String? doctorDepartment,
|
||
String? status,
|
||
List<ConsultationMsg>? messages,
|
||
bool? isLoading,
|
||
bool? isSending,
|
||
bool? isConnected,
|
||
int? quotaRemaining,
|
||
int? quotaTotal,
|
||
}) => ConsultationChatState(
|
||
consultationId: consultationId ?? this.consultationId,
|
||
doctorId: doctorId ?? this.doctorId,
|
||
doctorName: doctorName ?? this.doctorName,
|
||
doctorTitle: doctorTitle ?? this.doctorTitle,
|
||
doctorDepartment: doctorDepartment ?? this.doctorDepartment,
|
||
status: status ?? this.status,
|
||
messages: messages ?? this.messages,
|
||
isLoading: isLoading ?? this.isLoading,
|
||
isSending: isSending ?? this.isSending,
|
||
isConnected: isConnected ?? this.isConnected,
|
||
quotaRemaining: quotaRemaining ?? this.quotaRemaining,
|
||
quotaTotal: quotaTotal ?? this.quotaTotal,
|
||
);
|
||
}
|
||
|
||
final consultationChatProvider =
|
||
NotifierProvider<ConsultationChatNotifier, ConsultationChatState>(
|
||
ConsultationChatNotifier.new,
|
||
);
|
||
|
||
class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||
HubConnection? _hub;
|
||
String get _hubUrl => '$baseUrl/hubs/consultation';
|
||
|
||
@override
|
||
ConsultationChatState build() {
|
||
ref.watch(userSessionIdentityProvider);
|
||
ref.onDispose(() async {
|
||
await _hub?.stop();
|
||
_hub = null;
|
||
});
|
||
return const ConsultationChatState();
|
||
}
|
||
|
||
Future<void> init(String doctorId) async {
|
||
state = state.copyWith(doctorId: doctorId, isLoading: true);
|
||
final api = ref.read(apiClientProvider);
|
||
|
||
try {
|
||
// 加载医生信息
|
||
await _loadDoctorInfo(doctorId);
|
||
|
||
// 加载配额
|
||
final quotaRes = await api.get('/api/user/consultation-quota');
|
||
final quota = quotaRes.data['data'];
|
||
state = state.copyWith(
|
||
quotaRemaining: quota?['remaining'] ?? 3,
|
||
quotaTotal: quota?['total'] ?? 3,
|
||
);
|
||
|
||
// 创建问诊会话
|
||
final createRes = await api.post(
|
||
'/api/consultations',
|
||
data: {'doctorId': doctorId},
|
||
);
|
||
final consultationId = createRes.data['data']?['id']?.toString() ?? '';
|
||
|
||
// 加载历史消息
|
||
await _loadMessages(consultationId);
|
||
|
||
// 新会话:插入AI开场问候
|
||
if (state.messages.isEmpty) {
|
||
final greeting = ConsultationMsg(
|
||
id: 'greeting_${DateTime.now().millisecondsSinceEpoch}',
|
||
senderType: 'Ai',
|
||
senderName: 'AI分身 · ${state.doctorName}',
|
||
content:
|
||
'您好,我是${state.doctorName}的AI分身。请问您最近有什么身体不适?可以描述一下您的症状,我会先帮您做初步分析。\n\n如果情况需要,我会帮您转接${state.doctorName}医生。',
|
||
createdAt: DateTime.now(),
|
||
);
|
||
state = state.copyWith(
|
||
consultationId: consultationId,
|
||
messages: [greeting],
|
||
isLoading: false,
|
||
);
|
||
} else {
|
||
state = state.copyWith(
|
||
consultationId: consultationId,
|
||
isLoading: false,
|
||
);
|
||
}
|
||
|
||
// 建立 SignalR 连接
|
||
await _connectHub(consultationId);
|
||
} catch (_) {
|
||
state = state.copyWith(isLoading: false);
|
||
}
|
||
}
|
||
|
||
Future<void> _connectHub(String consultationId) async {
|
||
try {
|
||
_hub?.stop();
|
||
_hub = HubConnectionBuilder()
|
||
.withUrl(_hubUrl)
|
||
.withAutomaticReconnect()
|
||
.build();
|
||
|
||
// 注册消息接收
|
||
_hub!.on('ReceiveMessage', (args) {
|
||
if (args == null || args.isEmpty) return;
|
||
final data = args[0] as Map<String, dynamic>;
|
||
final msgConsultationId = data['consultationId']?.toString() ?? '';
|
||
if (msgConsultationId != consultationId) return;
|
||
|
||
final msg = ConsultationMsg(
|
||
id:
|
||
data['id']?.toString() ??
|
||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||
senderType: data['senderType']?.toString() ?? 'Ai',
|
||
senderName: data['senderName']?.toString(),
|
||
content: data['content']?.toString() ?? '',
|
||
createdAt: data['createdAt'] != null
|
||
? DateTime.tryParse(data['createdAt'].toString())?.toLocal() ??
|
||
DateTime.now()
|
||
: DateTime.now(),
|
||
);
|
||
|
||
// 去重:按 ID + 内容+时间窗口(防止本地消息和服务器消息重复)
|
||
final isDuplicate = state.messages.any(
|
||
(m) =>
|
||
m.id == msg.id ||
|
||
(m.senderType == msg.senderType &&
|
||
m.content == msg.content &&
|
||
msg.createdAt.difference(m.createdAt).inSeconds.abs() < 3),
|
||
);
|
||
if (!isDuplicate) {
|
||
state = state.copyWith(messages: [...state.messages, msg]);
|
||
}
|
||
});
|
||
|
||
await _hub!.start();
|
||
await _hub!.invoke('JoinConsultation', args: [consultationId]);
|
||
state = state.copyWith(isConnected: true);
|
||
} catch (_) {
|
||
// SignalR 连接失败,回退到轮询
|
||
_startPolling();
|
||
}
|
||
}
|
||
|
||
Future<void> _loadDoctorInfo(String doctorId) async {
|
||
try {
|
||
final api = ref.read(apiClientProvider);
|
||
final res = await api.get('/api/doctors');
|
||
final list = (res.data['data'] as List?) ?? [];
|
||
final doc = list.cast<Map<String, dynamic>>().firstWhere(
|
||
(d) => d['id']?.toString() == doctorId,
|
||
orElse: () => <String, dynamic>{},
|
||
);
|
||
state = state.copyWith(
|
||
doctorName: doc['name']?.toString() ?? '',
|
||
doctorTitle: doc['title']?.toString() ?? '',
|
||
doctorDepartment: doc['department']?.toString() ?? '',
|
||
);
|
||
} catch (e) {
|
||
log('[Consultation]请求失败: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _loadMessages(String consultationId) async {
|
||
try {
|
||
final api = ref.read(apiClientProvider);
|
||
final res = await api.get('/api/consultations/$consultationId/messages');
|
||
final list = (res.data['data'] as List?) ?? [];
|
||
final msgs = list.map((m) {
|
||
final map = m as Map<String, dynamic>;
|
||
return ConsultationMsg(
|
||
id: map['id']?.toString() ?? '',
|
||
senderType: map['senderType']?.toString() ?? 'User',
|
||
senderName: map['senderName']?.toString(),
|
||
content: map['content']?.toString() ?? '',
|
||
createdAt:
|
||
DateTime.tryParse(
|
||
map['createdAt']?.toString() ?? '',
|
||
)?.toLocal() ??
|
||
DateTime.now(),
|
||
);
|
||
}).toList();
|
||
if (msgs.isNotEmpty) {
|
||
state = state.copyWith(messages: msgs);
|
||
}
|
||
} catch (e) {
|
||
log('[Consultation]请求失败: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> sendMessage(String text) async {
|
||
if (text.trim().isEmpty ||
|
||
state.isSending ||
|
||
state.consultationId == null) {
|
||
return;
|
||
}
|
||
|
||
final localId = '${DateTime.now().millisecondsSinceEpoch}';
|
||
final userMsg = ConsultationMsg(
|
||
id: localId,
|
||
senderType: 'User',
|
||
content: text,
|
||
createdAt: DateTime.now(),
|
||
);
|
||
state = state.copyWith(
|
||
messages: [...state.messages, userMsg],
|
||
isSending: true,
|
||
);
|
||
|
||
try {
|
||
// 优先通过 SignalR 发送
|
||
if (_hub != null && _hub!.state == HubConnectionState.Connected) {
|
||
final result = await _hub!.invoke(
|
||
'SendMessage',
|
||
args: <Object>[state.consultationId!, 'User', '', text],
|
||
);
|
||
// 用服务器返回的真实 ID 更新本地消息,防止后续轮询重复
|
||
if (result != null) {
|
||
final serverId = (result as Map<String, dynamic>)['id']?.toString();
|
||
if (serverId != null && serverId != localId) {
|
||
final updated = state.messages.map((m) {
|
||
if (m.id == localId) {
|
||
return ConsultationMsg(
|
||
id: serverId,
|
||
senderType: m.senderType,
|
||
senderName: m.senderName,
|
||
content: m.content,
|
||
createdAt: m.createdAt,
|
||
);
|
||
}
|
||
return m;
|
||
}).toList();
|
||
state = state.copyWith(messages: updated);
|
||
}
|
||
}
|
||
} else {
|
||
// 回退到 HTTP
|
||
final api = ref.read(apiClientProvider);
|
||
final res = await api.post(
|
||
'/api/consultations/${state.consultationId}/messages',
|
||
data: {'content': text},
|
||
);
|
||
// 用服务器返回的真实 ID 更新本地消息
|
||
final dataMap = res.data['data'] as Map<String, dynamic>?;
|
||
final serverId = dataMap?['id']?.toString();
|
||
if (serverId != null && serverId != localId) {
|
||
final updated = state.messages.map((m) {
|
||
if (m.id == localId) {
|
||
return ConsultationMsg(
|
||
id: serverId,
|
||
senderType: m.senderType,
|
||
senderName: m.senderName,
|
||
content: m.content,
|
||
createdAt: m.createdAt,
|
||
);
|
||
}
|
||
return m;
|
||
}).toList();
|
||
state = state.copyWith(messages: updated);
|
||
}
|
||
}
|
||
} catch (_) {
|
||
// 静默失败,消息已显示在本地
|
||
}
|
||
state = state.copyWith(isSending: false);
|
||
}
|
||
|
||
// ---- 轮询回退(SignalR 不可用时)----
|
||
Timer? _pollTimer;
|
||
|
||
void _startPolling() {
|
||
_pollTimer?.cancel();
|
||
_pollTimer = Timer.periodic(
|
||
const Duration(seconds: 5),
|
||
(_) => _pollMessages(),
|
||
);
|
||
}
|
||
|
||
Future<void> _pollMessages() async {
|
||
if (state.consultationId == null || state.isConnected) return;
|
||
try {
|
||
final api = ref.read(apiClientProvider);
|
||
final lastId = state.messages.isNotEmpty ? state.messages.last.id : null;
|
||
final params = <String, dynamic>{};
|
||
if (lastId != null && !lastId.startsWith('greeting_')) {
|
||
params['after'] = lastId;
|
||
}
|
||
final res = await api.get(
|
||
'/api/consultations/${state.consultationId}/messages',
|
||
queryParameters: params.isNotEmpty ? params : null,
|
||
);
|
||
final list = (res.data['data'] as List?) ?? [];
|
||
if (list.isEmpty) return;
|
||
|
||
final existingIds = state.messages.map((m) => m.id).toSet();
|
||
final newMsgs = list
|
||
.map((m) {
|
||
final map = m as Map<String, dynamic>;
|
||
return ConsultationMsg(
|
||
id: map['id']?.toString() ?? '',
|
||
senderType: map['senderType']?.toString() ?? 'User',
|
||
senderName: map['senderName']?.toString(),
|
||
content: map['content']?.toString() ?? '',
|
||
createdAt:
|
||
DateTime.tryParse(
|
||
map['createdAt']?.toString() ?? '',
|
||
)?.toLocal() ??
|
||
DateTime.now(),
|
||
);
|
||
})
|
||
.where((m) => !existingIds.contains(m.id))
|
||
.toList();
|
||
|
||
if (newMsgs.isNotEmpty) {
|
||
state = state.copyWith(messages: [...state.messages, ...newMsgs]);
|
||
}
|
||
} catch (e) {
|
||
log('[Consultation]请求失败: $e');
|
||
}
|
||
}
|
||
|
||
void stop() {
|
||
_hub?.stop();
|
||
_hub = null;
|
||
_pollTimer?.cancel();
|
||
_pollTimer = null;
|
||
}
|
||
}
|