Files
AI-Health/health_app/lib/providers/consultation_provider.dart
MingNian 4d213b5a44 feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
2026-06-20 20:41:42 +08:00

347 lines
12 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: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() => 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()) ?? 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() ?? '') ?? 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() ?? '') ?? 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;
}
}