feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化

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

View File

@@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../core/api_client.dart';
@@ -79,7 +81,7 @@ class AuthNotifier extends Notifier<AuthState> {
);
}
} catch (e) {
print('[Auth] loadProfile: $e');
log('[Auth] loadProfile: $e');
}
}
@@ -144,7 +146,7 @@ class AuthNotifier extends Notifier<AuthState> {
final db = ref.read(localDbProvider);
final refresh = await db.read('refresh_token');
if (refresh != null) {
try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { print('[Auth] logout: $e'); }
try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { log('[Auth] logout: $e'); }
}
await api.clearTokens();
state = const AuthState(isLoggedIn: false, isLoading: false);

View File

@@ -79,64 +79,60 @@ final chatProvider = NotifierProvider<ChatNotifier, ChatState>(
ChatNotifier.new,
);
ActiveAgent _parseAgent(String? type) {
switch (type?.toLowerCase()) {
case 'consultation':
return ActiveAgent.consultation;
case 'health':
return ActiveAgent.health;
case 'diet':
return ActiveAgent.diet;
case 'medication':
return ActiveAgent.medication;
case 'report':
return ActiveAgent.report;
case 'exercise':
return ActiveAgent.exercise;
default:
return ActiveAgent.default_;
}
}
class ChatNotifier extends Notifier<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription;
Completer<void>? _streamDone;
ActiveAgent? _lastTriggeredAgent;
void markNeedsRebuild() => state = state.copyWith();
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
void confirmMessage(String id) {
Future<String?> confirmMessage(String id) async {
final msgs = state.messages.toList();
final i = msgs.indexWhere((m) => m.id == id);
if (i >= 0) {
msgs[i].confirmed = true;
state = state.copyWith(messages: msgs);
if (i < 0) return '确认卡片不存在';
final rawIds = msgs[i].metadata?['confirmationIds'];
final confirmationIds = rawIds is List
? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList()
: <String>[];
if (confirmationIds.isEmpty) {
return '确认信息已失效,请重新发送需要录入的内容';
}
try {
final api = ref.read(apiClientProvider);
for (final confirmationId in confirmationIds.toList()) {
final response = await api.post('/api/ai/confirm-write/$confirmationId');
final body = response.data;
if (body is! Map || body['code'] != 0) {
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
}
confirmationIds.remove(confirmationId);
msgs[i].metadata?['confirmationIds'] = confirmationIds.toList();
state = state.copyWith(messages: msgs);
}
} catch (e) {
return '录入失败,请检查网络后重试';
}
msgs[i].confirmed = true;
state = state.copyWith(messages: msgs);
ref.invalidate(medicationListProvider);
ref.invalidate(latestHealthProvider);
}
void startEditingField(String msgId, String fieldLabel) {
final msgs = state.messages.toList();
final i = msgs.indexWhere((m) => m.id == msgId);
if (i >= 0) {
msgs[i].metadata?['_editingField'] = fieldLabel;
state = state.copyWith(messages: msgs);
}
}
void finishEditingField(String msgId, String fieldLabel, String value) {
final msgs = state.messages.toList();
final i = msgs.indexWhere((m) => m.id == msgId);
if (i >= 0) {
if (value.isNotEmpty) msgs[i].metadata?[fieldLabel] = value;
msgs[i].metadata?.remove('_editingField');
state = state.copyWith(messages: msgs);
}
return null;
}
@override
ChatState build() {
ref.onDispose(() {
_subscription?.cancel();
_subscription = null;
if (_streamDone != null && !_streamDone!.isCompleted) {
_streamDone!.complete();
}
_streamDone = null;
});
Future.microtask(() {
insertTaskCard();
});
@@ -162,7 +158,7 @@ class ChatNotifier extends Notifier<ChatState> {
void setAgent(ActiveAgent a) {
// 流式回复中忽略胶囊切换,防止状态混乱
if (state.isStreaming) return;
_subscription?.cancel();
_cancelActiveStream();
state = state.copyWith(activeAgent: a);
ref.read(selectedAgentProvider.notifier).select(a);
}
@@ -198,7 +194,7 @@ class ChatNotifier extends Notifier<ChatState> {
}
Future<void> loadConversation(String convId) async {
_subscription?.cancel();
await _cancelActiveStream();
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/ai/conversations/$convId');
@@ -277,11 +273,12 @@ class ChatNotifier extends Notifier<ChatState> {
// 异步上传图片
String? uploadedUrl;
Object? uploadError;
try {
final api = ref.read(apiClientProvider);
uploadedUrl = await api.uploadFile('/api/files/upload', file);
} catch (_) {
// 上传失败:保留本地路径,仍然可以本地显示
} catch (e) {
uploadError = e;
}
// 更新消息元数据(保留本地路径 + 添加远程URL
@@ -300,6 +297,17 @@ class ChatNotifier extends Notifier<ChatState> {
state = state.copyWith(messages: updatedMsgs);
}
if (uploadedUrl == null) {
final errorMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
role: 'assistant',
content: uploadError == null ? '图片上传失败,请稍后重试。' : '图片上传失败,请检查文件大小或网络后重试。',
createdAt: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, errorMsg]);
return;
}
// 将图片 URL 作为消息内容发送给 AI
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
await _sendToAI(msgWithImage);
@@ -352,9 +360,26 @@ class ChatNotifier extends Notifier<ChatState> {
token: token,
);
await for (final event in stream) {
_processEvent(event, aiMsg);
await _cancelActiveStream();
final done = Completer<void>();
_streamDone = done;
_subscription = stream.listen(
(event) => _processEvent(event, aiMsg),
onError: (_) {
_addError(aiMsg, '网络异常,请稍后重试');
if (!done.isCompleted) done.complete();
},
onDone: () {
if (!done.isCompleted) done.complete();
},
cancelOnError: true,
);
await done.future;
if (_streamDone == done) {
_streamDone = null;
_subscription = null;
}
if (state.isStreaming) {
_done(aiMsg);
}
@@ -363,6 +388,15 @@ class ChatNotifier extends Notifier<ChatState> {
}
}
Future<void> _cancelActiveStream() async {
await _subscription?.cancel();
_subscription = null;
if (_streamDone != null && !_streamDone!.isCompleted) {
_streamDone!.complete();
}
_streamDone = null;
}
void _addError(ChatMessage aiMsg, String errorText) {
aiMsg.content = errorText;
aiMsg.type = MessageType.text;

View File

@@ -1,4 +1,5 @@
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;
@@ -197,7 +198,7 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
doctorTitle: doc['title']?.toString() ?? '',
doctorDepartment: doc['department']?.toString() ?? '',
);
} catch (e) { print('[Consultation]请求失败: $e'); }
} catch (e) { log('[Consultation]请求失败: $e'); }
}
Future<void> _loadMessages(String consultationId) async {
@@ -218,7 +219,7 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
if (msgs.isNotEmpty) {
state = state.copyWith(messages: msgs);
}
} catch (e) { print('[Consultation]请求失败: $e'); }
} catch (e) { log('[Consultation]请求失败: $e'); }
}
Future<void> sendMessage(String text) async {
@@ -333,7 +334,7 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
if (newMsgs.isNotEmpty) {
state = state.copyWith(messages: [...state.messages, ...newMsgs]);
}
} catch (e) { print('[Consultation]请求失败: $e'); }
} catch (e) { log('[Consultation]请求失败: $e'); }
}
void stop() {

View File

@@ -62,37 +62,9 @@ final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((r
/// 医生列表 Provider
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(consultationServiceProvider);
try {
return await service.getDoctors().timeout(const Duration(seconds: 8));
} catch (_) {
return _fallbackDoctors;
}
return service.getDoctors().timeout(const Duration(seconds: 8));
});
const _fallbackDoctors = [
{
'id': '468b82e2-d95a-4436-bff6-a50eecf99a66',
'name': '张明',
'title': '主任医师',
'department': '心脏康复科',
'introduction': '擅长冠心病、高血压术后管理20年临床经验',
},
{
'id': 'd4148733-b538-4398-af17-0c7592fc0c2d',
'name': '李芳',
'title': '副主任医师',
'department': '营养科',
'introduction': '擅长糖尿病、甲状腺疾病管理15年临床经验',
},
{
'id': 'ef0953c9-eb63-4d03-b6d7-050a1897d4a3',
'name': '王建国',
'title': '主任医师',
'department': '心血管内科',
'introduction': '擅长术后营养指导、饮食方案制定10年临床经验',
},
];
/// 问诊配额 Provider
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final service = ref.watch(consultationServiceProvider);
@@ -102,23 +74,7 @@ final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) asy
/// 当前运动计划 Provider
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final service = ref.watch(exerciseServiceProvider);
try {
return await service.getCurrentPlan().timeout(const Duration(seconds: 8));
} catch (_) {
final today = DateTime.now();
final monday = today.subtract(Duration(days: today.weekday - 1));
return {
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
'items': List.generate(7, (i) => {
'id': 'local_$i',
'dayOfWeek': i, // matches C# DayOfWeek: 0=Sun, 1=Mon, ..., 6=Sat
'exerciseType': i == 1 || i == 6 ? '休息' : '散步',
'durationMinutes': i == 1 || i == 6 ? 0 : 30,
'isRestDay': i == 1 || i == 6,
'isCompleted': false,
}),
};
}
return service.getCurrentPlan().timeout(const Duration(seconds: 8));
});
/// 拍照/相册直接触发(无需跳转页面)