From 4d213b5a44ff7c9990079b406774400731c1860c Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Sat, 20 Jun 2026 20:41:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E7=AB=AF=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20=E2=80=94=20Endpoint=E2=86=92Service?= =?UTF-8?q?=E2=86=92Repository=E5=88=86=E5=B1=82=20+=20AI=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=9C=BA=E5=88=B6=20+=20=E5=BC=82=E6=AD=A5=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误 --- .gitignore | 2 + .../AI/AiConversationContracts.cs | 63 +++ .../AI/AiConversationService.cs | 111 ++++ .../AI/AiWriteConfirmationContracts.cs | 15 + .../AI/PatientContextService.cs | 65 +++ .../Admin/AdminContracts.cs | 15 + .../Health.Application/Auth/AuthContracts.cs | 13 + .../Calendars/CalendarContracts.cs | 19 + .../Calendars/CalendarService.cs | 92 ++++ .../Health.Application/Diets/DietContracts.cs | 51 ++ .../Diets/DietImageAnalysisContracts.cs | 27 + .../Health.Application/Diets/DietService.cs | 77 +++ .../Exercises/ExerciseContracts.cs | 74 +++ .../Exercises/ExerciseService.cs | 132 +++++ .../HealthArchives/HealthArchiveContracts.cs | 39 ++ .../HealthArchives/HealthArchiveService.cs | 111 ++++ .../HealthRecords/HealthRecordContracts.cs | 45 ++ .../HealthRecords/HealthRecordRules.cs | 16 + .../HealthRecords/HealthRecordService.cs | 116 ++++ .../Maintenance/MaintenanceContracts.cs | 16 + .../Maintenance/MaintenanceService.cs | 9 + .../Medications/MedicationContracts.cs | 106 ++++ .../Medications/MedicationReminderScanner.cs | 64 +++ .../Medications/MedicationService.cs | 217 ++++++++ .../InAppNotificationContracts.cs | 26 + .../Notifications/InAppNotificationService.cs | 47 ++ .../Reports/ReportContracts.cs | 83 +++ .../Reports/ReportService.cs | 122 +++++ .../Health.Application/Users/UserContracts.cs | 31 ++ .../Health.Application/Users/UserService.cs | 35 ++ .../Health.Domain/Entities/exercise_plan.cs | 6 +- .../src/Health.Domain/Enums/health_enums.cs | 2 + .../AI/AgentHandlers/common_agent_handler.cs | 95 ++-- .../consultation_agent_handler.cs | 8 - .../AI/AgentHandlers/diet_agent_handler.cs | 8 - .../AgentHandlers/exercise_agent_handler.cs | 134 +++-- .../health_data_agent_handler.cs | 80 ++- .../AgentHandlers/medication_agent_handler.cs | 185 +++++-- .../AI/AgentHandlers/report_agent_handler.cs | 13 +- .../AI/AiToolExecutionService.cs | 78 +++ .../AI/EfAiConversationRepository.cs | 45 ++ .../AI/EfAiWriteConfirmationStore.cs | 98 ++++ .../AI/prompt_manager.cs | 72 ++- .../Admin/AdminService.cs | 90 +++ .../Health.Infrastructure/Auth/AuthService.cs | 115 ++++ .../Calendars/EfCalendarRepository.cs | 27 + .../Data/DatabaseSchemaMigrator.cs | 166 ++++++ .../Data/Records/AiWriteCommandRecord.cs | 13 + .../Records/DietImageAnalysisTaskRecord.cs | 16 + .../Records/MedicationReminderTaskRecord.cs | 18 + .../Data/Records/NotificationOutboxRecord.cs | 16 + .../Data/Records/ReportAnalysisTaskRecord.cs | 14 + .../Data/app_db_context.cs | 56 +- .../Data/dev_data_seeder.cs | 16 +- .../Diets/DietImageAnalysisCoordinator.cs | 59 ++ .../Diets/DietImageAnalysisQueue.cs | 113 ++++ .../Diets/DietImageAnalyzer.cs | 42 ++ .../Diets/EfDietRepository.cs | 33 ++ .../Exercises/EfExerciseRepository.cs | 44 ++ .../Exercises/ExerciseReminderProducer.cs | 44 ++ .../Health.Infrastructure.csproj | 2 + .../EfHealthArchiveRepository.cs | 17 + .../HealthRecords/EfHealthRecordRepository.cs | 45 ++ .../Maintenance/EfMaintenanceRepository.cs | 52 ++ .../Medications/EfMedicationRepository.cs | 79 +++ .../Medications/MedicationReminderQueue.cs | 110 ++++ .../OutboxMedicationReminderDispatcher.cs | 47 ++ .../EfInAppNotificationRepository.cs | 26 + .../Reports/EfReportAnalysisQueue.cs | 86 +++ .../Reports/EfReportRepository.cs | 29 + .../Reports/LocalReportFileStorage.cs | 30 + .../Reports/ReportAnalysisService.cs | 165 ++++++ .../Users/EfUserRepository.cs | 51 ++ .../BackgroundServices/cleanup_service.cs | 52 +- .../diet_image_analysis_worker.cs | 73 +++ .../exercise_reminder_service.cs | 32 ++ .../medication_reminder_service.cs | 56 +- .../medication_reminder_worker.cs | 62 +++ .../report_analysis_worker.cs | 60 ++ .../Endpoints/admin_endpoints.cs | 202 +------ .../Endpoints/ai_chat_endpoints.cs | 498 ++++++++++------- .../Health.WebApi/Endpoints/auth_endpoints.cs | 279 +--------- .../Endpoints/calendar_endpoints.cs | 87 +-- .../Endpoints/consultation_endpoints.cs | 7 +- .../Health.WebApi/Endpoints/diet_endpoints.cs | 121 ++-- .../Endpoints/doctor_endpoints.cs | 102 +++- .../Endpoints/exercise_endpoints.cs | 134 ++--- .../Health.WebApi/Endpoints/file_endpoints.cs | 41 +- .../Endpoints/health_endpoints.cs | 148 ++--- .../Endpoints/medication_endpoints.cs | 284 +++------- .../Endpoints/notification_endpoints.cs | 31 ++ .../Endpoints/report_endpoints.cs | 302 ++-------- .../Health.WebApi/Endpoints/user_endpoints.cs | 107 +--- backend/src/Health.WebApi/Program.cs | 91 +++ .../tests/Health.Tests/Health.Tests.csproj | 3 +- .../Health.Tests/application_service_tests.cs | 149 +++++ backend/tests/Health.Tests/entity_tests.cs | 8 +- .../persistence_pipeline_tests.cs | 76 +++ docs/backend_architecture_evolution.md | 283 ++++++++++ docs/project_deep_review.md | 182 +++++- health_app/lib/core/api_client.dart | 22 +- health_app/lib/core/app_router.dart | 5 + health_app/lib/core/local_database.dart | 5 +- .../lib/pages/admin/admin_doctors_page.dart | 2 +- .../lib/pages/admin/admin_patients_page.dart | 9 +- health_app/lib/pages/auth/login_page.dart | 5 +- health_app/lib/pages/chart/trend_page.dart | 11 +- .../lib/pages/device/device_scan_page.dart | 10 +- .../lib/pages/diet/diet_capture_page.dart | 12 +- .../doctor/doctor_consultations_page.dart | 2 +- .../pages/doctor/doctor_dashboard_page.dart | 2 +- .../doctor/doctor_followup_edit_page.dart | 2 +- .../pages/doctor/doctor_followups_page.dart | 5 +- .../doctor/doctor_patient_detail_page.dart | 10 +- .../pages/doctor/doctor_patients_page.dart | 5 +- .../lib/pages/doctor/doctor_profile_page.dart | 8 +- .../doctor/doctor_report_detail_page.dart | 21 +- .../lib/pages/doctor/doctor_reports_page.dart | 2 +- health_app/lib/pages/home/home_page.dart | 61 +++ .../home/widgets/chat_messages_view.dart | 285 +++------- .../medication/medication_edit_page.dart | 13 +- health_app/lib/pages/remaining_pages.dart | 516 +++--------------- .../lib/pages/report/ai_analysis_page.dart | 213 ++++++-- health_app/lib/pages/report/report_pages.dart | 366 +++++++++---- .../settings/notification_prefs_page.dart | 6 +- .../lib/pages/settings/settings_pages.dart | 1 - health_app/lib/providers/auth_provider.dart | 6 +- health_app/lib/providers/chat_provider.dart | 130 +++-- .../lib/providers/consultation_provider.dart | 7 +- health_app/lib/providers/data_providers.dart | 48 +- .../services/in_app_notification_service.dart | 43 ++ .../lib/services/omron_ble_service.dart | 2 +- 132 files changed, 6733 insertions(+), 2856 deletions(-) create mode 100644 backend/src/Health.Application/AI/AiConversationContracts.cs create mode 100644 backend/src/Health.Application/AI/AiConversationService.cs create mode 100644 backend/src/Health.Application/AI/AiWriteConfirmationContracts.cs create mode 100644 backend/src/Health.Application/AI/PatientContextService.cs create mode 100644 backend/src/Health.Application/Admin/AdminContracts.cs create mode 100644 backend/src/Health.Application/Auth/AuthContracts.cs create mode 100644 backend/src/Health.Application/Calendars/CalendarContracts.cs create mode 100644 backend/src/Health.Application/Calendars/CalendarService.cs create mode 100644 backend/src/Health.Application/Diets/DietContracts.cs create mode 100644 backend/src/Health.Application/Diets/DietImageAnalysisContracts.cs create mode 100644 backend/src/Health.Application/Diets/DietService.cs create mode 100644 backend/src/Health.Application/Exercises/ExerciseContracts.cs create mode 100644 backend/src/Health.Application/Exercises/ExerciseService.cs create mode 100644 backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs create mode 100644 backend/src/Health.Application/HealthArchives/HealthArchiveService.cs create mode 100644 backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs create mode 100644 backend/src/Health.Application/HealthRecords/HealthRecordRules.cs create mode 100644 backend/src/Health.Application/HealthRecords/HealthRecordService.cs create mode 100644 backend/src/Health.Application/Maintenance/MaintenanceContracts.cs create mode 100644 backend/src/Health.Application/Maintenance/MaintenanceService.cs create mode 100644 backend/src/Health.Application/Medications/MedicationContracts.cs create mode 100644 backend/src/Health.Application/Medications/MedicationReminderScanner.cs create mode 100644 backend/src/Health.Application/Medications/MedicationService.cs create mode 100644 backend/src/Health.Application/Notifications/InAppNotificationContracts.cs create mode 100644 backend/src/Health.Application/Notifications/InAppNotificationService.cs create mode 100644 backend/src/Health.Application/Reports/ReportContracts.cs create mode 100644 backend/src/Health.Application/Reports/ReportService.cs create mode 100644 backend/src/Health.Application/Users/UserContracts.cs create mode 100644 backend/src/Health.Application/Users/UserService.cs create mode 100644 backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs create mode 100644 backend/src/Health.Infrastructure/AI/EfAiConversationRepository.cs create mode 100644 backend/src/Health.Infrastructure/AI/EfAiWriteConfirmationStore.cs create mode 100644 backend/src/Health.Infrastructure/Admin/AdminService.cs create mode 100644 backend/src/Health.Infrastructure/Auth/AuthService.cs create mode 100644 backend/src/Health.Infrastructure/Calendars/EfCalendarRepository.cs create mode 100644 backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs create mode 100644 backend/src/Health.Infrastructure/Data/Records/AiWriteCommandRecord.cs create mode 100644 backend/src/Health.Infrastructure/Data/Records/DietImageAnalysisTaskRecord.cs create mode 100644 backend/src/Health.Infrastructure/Data/Records/MedicationReminderTaskRecord.cs create mode 100644 backend/src/Health.Infrastructure/Data/Records/NotificationOutboxRecord.cs create mode 100644 backend/src/Health.Infrastructure/Data/Records/ReportAnalysisTaskRecord.cs create mode 100644 backend/src/Health.Infrastructure/Diets/DietImageAnalysisCoordinator.cs create mode 100644 backend/src/Health.Infrastructure/Diets/DietImageAnalysisQueue.cs create mode 100644 backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs create mode 100644 backend/src/Health.Infrastructure/Diets/EfDietRepository.cs create mode 100644 backend/src/Health.Infrastructure/Exercises/EfExerciseRepository.cs create mode 100644 backend/src/Health.Infrastructure/Exercises/ExerciseReminderProducer.cs create mode 100644 backend/src/Health.Infrastructure/HealthArchives/EfHealthArchiveRepository.cs create mode 100644 backend/src/Health.Infrastructure/HealthRecords/EfHealthRecordRepository.cs create mode 100644 backend/src/Health.Infrastructure/Maintenance/EfMaintenanceRepository.cs create mode 100644 backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs create mode 100644 backend/src/Health.Infrastructure/Medications/MedicationReminderQueue.cs create mode 100644 backend/src/Health.Infrastructure/Medications/OutboxMedicationReminderDispatcher.cs create mode 100644 backend/src/Health.Infrastructure/Notifications/EfInAppNotificationRepository.cs create mode 100644 backend/src/Health.Infrastructure/Reports/EfReportAnalysisQueue.cs create mode 100644 backend/src/Health.Infrastructure/Reports/EfReportRepository.cs create mode 100644 backend/src/Health.Infrastructure/Reports/LocalReportFileStorage.cs create mode 100644 backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs create mode 100644 backend/src/Health.Infrastructure/Users/EfUserRepository.cs create mode 100644 backend/src/Health.WebApi/BackgroundServices/diet_image_analysis_worker.cs create mode 100644 backend/src/Health.WebApi/BackgroundServices/exercise_reminder_service.cs create mode 100644 backend/src/Health.WebApi/BackgroundServices/medication_reminder_worker.cs create mode 100644 backend/src/Health.WebApi/BackgroundServices/report_analysis_worker.cs create mode 100644 backend/src/Health.WebApi/Endpoints/notification_endpoints.cs create mode 100644 backend/tests/Health.Tests/application_service_tests.cs create mode 100644 backend/tests/Health.Tests/persistence_pipeline_tests.cs create mode 100644 docs/backend_architecture_evolution.md create mode 100644 health_app/lib/services/in_app_notification_service.dart diff --git a/.gitignore b/.gitignore index c9533c2..9f53382 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ health_app/ios/.symlinks/ # Uploads & logs backend/src/Health.WebApi/uploads/ +backend/src/Health.WebApi/data-protection-keys/ +*.log *.txt *.jpg *.png diff --git a/backend/src/Health.Application/AI/AiConversationContracts.cs b/backend/src/Health.Application/AI/AiConversationContracts.cs new file mode 100644 index 0000000..a43eb24 --- /dev/null +++ b/backend/src/Health.Application/AI/AiConversationContracts.cs @@ -0,0 +1,63 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.AI; + +public sealed record AiConversationDto( + Guid Id, + string AgentType, + string? Title, + string? Summary, + int MessageCount, + DateTime CreatedAt, + DateTime UpdatedAt); + +public sealed record AiConversationMessageDto( + Guid Id, + string Role, + string Content, + string? Intent, + string? MetadataJson, + DateTime CreatedAt); + +public sealed record OpenConversationResult( + bool Found, + bool Created, + Guid ConversationId); + +public interface IAiConversationService +{ + Task OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct); + Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct); + Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct); + Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct); + Task> ListAsync(Guid userId, CancellationToken ct); + Task> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct); + Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct); +} + +public interface IAiConversationRepository +{ + Task GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct); + Task GetAsync(Guid conversationId, CancellationToken ct); + Task> ListAsync(Guid userId, CancellationToken ct); + Task> GetMessagesAsync(Guid conversationId, CancellationToken ct); + Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct); + Task AddConversationAsync(Conversation conversation, CancellationToken ct); + Task AddMessageAsync(ConversationMessage message, CancellationToken ct); + void Delete(Conversation conversation); + Task SaveChangesAsync(CancellationToken ct); +} + +public interface IPatientContextService +{ + Task BuildAsync(Guid userId, CancellationToken ct); +} + +public sealed record AiConfirmedWriteResult(int Code, object? Data, string? Message); + +public interface IAiToolExecutionService +{ + Task ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct); + Task ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct); +} diff --git a/backend/src/Health.Application/AI/AiConversationService.cs b/backend/src/Health.Application/AI/AiConversationService.cs new file mode 100644 index 0000000..72ddbf8 --- /dev/null +++ b/backend/src/Health.Application/AI/AiConversationService.cs @@ -0,0 +1,111 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.AI; + +public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService +{ + private readonly IAiConversationRepository _conversations = conversations; + + public async Task OpenAsync( + Guid userId, + Guid? conversationId, + AgentType agentType, + string firstMessage, + CancellationToken ct) + { + if (conversationId.HasValue) + { + var existing = await _conversations.GetOwnedAsync(userId, conversationId.Value, ct); + return existing == null + ? new OpenConversationResult(false, false, conversationId.Value) + : new OpenConversationResult(true, false, existing.Id); + } + + var conversation = new Conversation + { + Id = Guid.NewGuid(), + UserId = userId, + AgentType = agentType, + Title = firstMessage.Length > 30 ? firstMessage[..30] : firstMessage, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + await _conversations.AddConversationAsync(conversation, ct); + await _conversations.SaveChangesAsync(ct); + return new OpenConversationResult(true, true, conversation.Id); + } + + public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) => + await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct); + + public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) => + await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct); + + public async Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) + { + var messages = await _conversations.GetRecentMessagesAsync(conversationId, limit, ct); + return messages.OrderBy(m => m.CreatedAt).Select(ToMessageDto).ToList(); + } + + public async Task> ListAsync(Guid userId, CancellationToken ct) + { + var conversations = await _conversations.ListAsync(userId, ct); + return conversations.Select(ToDto).ToList(); + } + + public async Task> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct) + { + var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct); + if (conversation == null) return []; + + var messages = await _conversations.GetMessagesAsync(conversationId, ct); + return messages.Select(ToMessageDto).ToList(); + } + + public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct) + { + var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct); + if (conversation == null) return; + + _conversations.Delete(conversation); + await _conversations.SaveChangesAsync(ct); + } + + private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct) + { + var conversation = await _conversations.GetAsync(conversationId, ct) + ?? throw new InvalidOperationException("会话不存在"); + await _conversations.AddMessageAsync(new ConversationMessage + { + Id = Guid.NewGuid(), + ConversationId = conversationId, + Role = role, + Content = content, + CreatedAt = DateTime.UtcNow, + }, ct); + + conversation.MessageCount++; + conversation.UpdatedAt = DateTime.UtcNow; + if (updateSummary) + conversation.Summary = content.Length > 100 ? content[..100] : content; + await _conversations.SaveChangesAsync(ct); + } + + private static AiConversationDto ToDto(Conversation conversation) => new( + conversation.Id, + conversation.AgentType.ToString(), + conversation.Title, + conversation.Summary, + conversation.MessageCount, + conversation.CreatedAt, + conversation.UpdatedAt); + + private static AiConversationMessageDto ToMessageDto(ConversationMessage message) => new( + message.Id, + message.Role.ToString(), + message.Content, + message.Intent, + message.MetadataJson, + message.CreatedAt); +} diff --git a/backend/src/Health.Application/AI/AiWriteConfirmationContracts.cs b/backend/src/Health.Application/AI/AiWriteConfirmationContracts.cs new file mode 100644 index 0000000..c6e32d0 --- /dev/null +++ b/backend/src/Health.Application/AI/AiWriteConfirmationContracts.cs @@ -0,0 +1,15 @@ +namespace Health.Application.AI; + +public sealed record PendingAiWriteCommand( + Guid Id, + Guid UserId, + string ToolName, + string Arguments, + DateTime ExpiresAt); + +public interface IAiWriteConfirmationStore +{ + Task CreateAsync(Guid userId, string toolName, string arguments, TimeSpan lifetime, CancellationToken ct); + Task TakeAsync(Guid commandId, Guid userId, CancellationToken ct); + Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct); +} diff --git a/backend/src/Health.Application/AI/PatientContextService.cs b/backend/src/Health.Application/AI/PatientContextService.cs new file mode 100644 index 0000000..6be790a --- /dev/null +++ b/backend/src/Health.Application/AI/PatientContextService.cs @@ -0,0 +1,65 @@ +using System.Text; +using Health.Application.HealthArchives; +using Health.Application.HealthRecords; +using Health.Application.Medications; + +namespace Health.Application.AI; + +public sealed class PatientContextService( + IHealthArchiveService archives, + IHealthRecordService healthRecords, + IMedicationService medications) : IPatientContextService +{ + private readonly IHealthArchiveService _archives = archives; + private readonly IHealthRecordService _healthRecords = healthRecords; + private readonly IMedicationService _medications = medications; + + public async Task BuildAsync(Guid userId, CancellationToken ct) + { + var archive = await _archives.GetAsync(userId, ct); + var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList(); + var activeMedications = await _medications.ListActiveAsync(userId, ct); + + var sb = new StringBuilder(); + if (archive != null) + { + if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}"); + if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})"); + if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}"); + if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}"); + if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}"); + if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}"); + } + + if (activeMedications.Count > 0) + { + sb.AppendLine("当前用药:"); + foreach (var medication in activeMedications.Take(20)) + { + var times = medication.TimeOfDay.Count > 0 + ? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm"))) + : "未设置时间"; + sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}"); + } + } + + if (recentRecords.Count > 0) + { + sb.AppendLine("近期健康数据:"); + foreach (var record in recentRecords) + sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})"); + } + + return sb.ToString(); + } + + private static string RecordValue(HealthRecordDto record) => record.Type switch + { + "BloodPressure" => $"{record.Systolic}/{record.Diastolic}", + "HeartRate" => $"{record.Value}次/分", + "Glucose" => $"{record.Value}", + "SpO2" => $"{record.Value}%", + "Weight" => $"{record.Value}kg", + _ => "—" + }; +} diff --git a/backend/src/Health.Application/Admin/AdminContracts.cs b/backend/src/Health.Application/Admin/AdminContracts.cs new file mode 100644 index 0000000..a57a9f2 --- /dev/null +++ b/backend/src/Health.Application/Admin/AdminContracts.cs @@ -0,0 +1,15 @@ +namespace Health.Application.Admin; + +public sealed record AdminResult(int Code, object? Data, string? Message = null); +public sealed record AddDoctorCommand(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection); +public sealed record UpdateDoctorCommand(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection); + +public interface IAdminService +{ + Task ListDoctorsAsync(CancellationToken ct); + Task AddDoctorAsync(AddDoctorCommand command, CancellationToken ct); + Task UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct); + Task ToggleDoctorAsync(Guid id, CancellationToken ct); + Task DeleteDoctorAsync(Guid id, CancellationToken ct); + Task ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Auth/AuthContracts.cs b/backend/src/Health.Application/Auth/AuthContracts.cs new file mode 100644 index 0000000..83e76ce --- /dev/null +++ b/backend/src/Health.Application/Auth/AuthContracts.cs @@ -0,0 +1,13 @@ +namespace Health.Application.Auth; + +public sealed record AuthResult(int Code, object? Data, string? Message = null); +public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId); + +public interface IAuthService +{ + Task SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct); + Task RegisterAsync(RegisterCommand command, CancellationToken ct); + Task LoginAsync(string phone, string smsCode, CancellationToken ct); + Task RefreshAsync(string refreshToken, CancellationToken ct); + Task LogoutAsync(string refreshToken, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Calendars/CalendarContracts.cs b/backend/src/Health.Application/Calendars/CalendarContracts.cs new file mode 100644 index 0000000..00bf7aa --- /dev/null +++ b/backend/src/Health.Application/Calendars/CalendarContracts.cs @@ -0,0 +1,19 @@ +using Health.Domain.Entities; + +namespace Health.Application.Calendars; + +public sealed record CalendarDataSnapshot( + IReadOnlyList Medications, + IReadOnlyList ExercisePlans, + IReadOnlyList FollowUps); + +public interface ICalendarService +{ + Task> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct); + Task GetDayAsync(Guid userId, DateOnly date, CancellationToken ct); +} + +public interface ICalendarRepository +{ + Task GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Calendars/CalendarService.cs b/backend/src/Health.Application/Calendars/CalendarService.cs new file mode 100644 index 0000000..28d344c --- /dev/null +++ b/backend/src/Health.Application/Calendars/CalendarService.cs @@ -0,0 +1,92 @@ +namespace Health.Application.Calendars; + +public sealed class CalendarService(ICalendarRepository calendar) : ICalendarService +{ + private readonly ICalendarRepository _calendar = calendar; + + public async Task> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct) + { + var start = new DateOnly(year, month, 1); + var end = start.AddMonths(1); + var snapshot = await _calendar.GetSnapshotAsync(userId, start, end, ct); + var result = new List(); + + for (var date = start; date < end; date = date.AddDays(1)) + { + var entries = BuildEntries(snapshot, date); + if (entries.Count == 0) continue; + + result.Add(new + { + date = date.ToString("yyyy-MM-dd"), + events = entries.Select(entry => entry.Type).Distinct().ToList(), + details = entries.Select(entry => entry.Value).ToList() + }); + } + + return result; + } + + public async Task GetDayAsync(Guid userId, DateOnly date, CancellationToken ct) + { + var snapshot = await _calendar.GetSnapshotAsync(userId, date, date.AddDays(1), ct); + var medications = snapshot.Medications + .Where(m => IsMedicationActiveOn(m, date)) + .Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() }) + .ToList(); + var exercises = snapshot.ExercisePlans + .SelectMany(p => p.Items) + .Where(i => i.ScheduledDate == date && !i.IsRestDay) + .Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate }) + .ToList(); + var followUps = snapshot.FollowUps + .Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date) + .Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() }) + .ToList(); + + return new { medications, exercises, followUps }; + } + + private static List BuildEntries(CalendarDataSnapshot snapshot, DateOnly date) + { + var entries = new List(); + foreach (var medication in snapshot.Medications.Where(m => IsMedicationActiveOn(m, date))) + { + entries.Add(new CalendarEntry("medication", new + { + type = "medication", + name = medication.Name, + dosage = medication.Dosage, + timeOfDay = medication.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() + })); + } + + foreach (var plan in snapshot.ExercisePlans) + { + foreach (var item in plan.Items.Where(i => i.ScheduledDate == date && !i.IsRestDay)) + entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate })); + } + + foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)) + { + entries.Add(new CalendarEntry("followup", new + { + type = "followup", + title = followUp.Title, + doctorName = followUp.DoctorName, + department = followUp.Department, + status = followUp.Status.ToString() + })); + } + + return entries; + } + + private static bool IsMedicationActiveOn(Health.Domain.Entities.Medication medication, DateOnly date) => + medication.IsActive + && medication.TimeOfDay.Count > 0 + && (medication.StartDate == null || medication.StartDate <= date) + && (medication.EndDate == null || medication.EndDate >= date); + + private sealed record CalendarEntry(string Type, object Value); +} diff --git a/backend/src/Health.Application/Diets/DietContracts.cs b/backend/src/Health.Application/Diets/DietContracts.cs new file mode 100644 index 0000000..d31160d --- /dev/null +++ b/backend/src/Health.Application/Diets/DietContracts.cs @@ -0,0 +1,51 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.Diets; + +public sealed record DietFoodItemInput( + string Name, + string? Portion, + int? Calories, + int SortOrder); + +public sealed record DietRecordCreateRequest( + MealType MealType, + int? TotalCalories, + int? HealthScore, + DateOnly RecordedAt, + IReadOnlyList FoodItems); + +public sealed record DietRecordPatchRequest( + int? TotalCalories, + int? HealthScore); + +public sealed record DietFoodItemDto( + string Name, + string? Portion, + int? Calories); + +public sealed record DietRecordDto( + Guid Id, + string MealType, + int? TotalCalories, + int? HealthScore, + DateOnly RecordedAt, + IReadOnlyList FoodItems); + +public interface IDietService +{ + Task> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct); + Task CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct); + Task DeleteAsync(Guid userId, Guid recordId, CancellationToken ct); + Task UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct); +} + +public interface IDietRepository +{ + Task> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct); + Task GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct); + Task AddAsync(DietRecord record, CancellationToken ct); + void Delete(DietRecord record); + Task SaveChangesAsync(CancellationToken ct); +} diff --git a/backend/src/Health.Application/Diets/DietImageAnalysisContracts.cs b/backend/src/Health.Application/Diets/DietImageAnalysisContracts.cs new file mode 100644 index 0000000..2100ccb --- /dev/null +++ b/backend/src/Health.Application/Diets/DietImageAnalysisContracts.cs @@ -0,0 +1,27 @@ +namespace Health.Application.Diets; + +public sealed record DietImageUploadFile(string FileName, long Length, Stream Content); + +public sealed record DietImageAnalysisJob(Guid Id, IReadOnlyList FilePaths); + +public sealed record DietImageAnalysisResult(bool Success, int Code, string? Data, string? Message); + +public interface IDietImageAnalysisCoordinator +{ + Task AnalyzeAsync(IReadOnlyList files, CancellationToken ct); +} + +public interface IDietImageAnalysisQueue +{ + Task EnqueueAsync(IReadOnlyList filePaths, CancellationToken ct); + Task RecoverStaleAsync(CancellationToken ct); + Task TryTakeAsync(CancellationToken ct); + Task WaitForResultAsync(Guid jobId, CancellationToken ct); + Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct); + Task RetryAsync(Guid jobId, string error, CancellationToken ct); +} + +public interface IDietImageAnalyzer +{ + Task AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Diets/DietService.cs b/backend/src/Health.Application/Diets/DietService.cs new file mode 100644 index 0000000..f83c728 --- /dev/null +++ b/backend/src/Health.Application/Diets/DietService.cs @@ -0,0 +1,77 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.Diets; + +public sealed class DietService(IDietRepository diets) : IDietService +{ + private readonly IDietRepository _diets = diets; + + public async Task> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct) + { + var recordedAt = DateOnly.TryParse(date, out var d) ? d : (DateOnly?)null; + var parsedMealType = Enum.TryParse(mealType, ignoreCase: true, out var mt) ? mt : (MealType?)null; + + var records = await _diets.ListAsync(userId, recordedAt, parsedMealType, ct); + return records.Select(ToDto).ToList(); + } + + public async Task CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct) + { + var record = new DietRecord + { + Id = Guid.NewGuid(), + UserId = userId, + MealType = request.MealType, + TotalCalories = request.TotalCalories, + HealthScore = request.HealthScore, + RecordedAt = request.RecordedAt, + CreatedAt = DateTime.UtcNow, + }; + + foreach (var item in request.FoodItems) + { + record.FoodItems.Add(new DietFoodItem + { + Id = Guid.NewGuid(), + Name = item.Name, + Portion = item.Portion, + Calories = item.Calories, + SortOrder = item.SortOrder, + }); + } + + await _diets.AddAsync(record, ct); + await _diets.SaveChangesAsync(ct); + return record.Id; + } + + public async Task DeleteAsync(Guid userId, Guid recordId, CancellationToken ct) + { + var record = await _diets.GetOwnedAsync(userId, recordId, ct); + if (record == null) return false; + + _diets.Delete(record); + await _diets.SaveChangesAsync(ct); + return true; + } + + public async Task UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct) + { + var record = await _diets.GetOwnedAsync(userId, recordId, ct); + if (record == null) return false; + + if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value; + if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value; + await _diets.SaveChangesAsync(ct); + return true; + } + + private static DietRecordDto ToDto(DietRecord record) => new( + record.Id, + record.MealType.ToString(), + record.TotalCalories, + record.HealthScore, + record.RecordedAt, + record.FoodItems.OrderBy(f => f.SortOrder).Select(f => new DietFoodItemDto(f.Name, f.Portion, f.Calories)).ToList()); +} diff --git a/backend/src/Health.Application/Exercises/ExerciseContracts.cs b/backend/src/Health.Application/Exercises/ExerciseContracts.cs new file mode 100644 index 0000000..7788770 --- /dev/null +++ b/backend/src/Health.Application/Exercises/ExerciseContracts.cs @@ -0,0 +1,74 @@ +using Health.Domain.Entities; + +namespace Health.Application.Exercises; + +public sealed record ExercisePlanItemInput( + DateOnly ScheduledDate, + string? ExerciseType, + int DurationMinutes, + bool IsRestDay); + +public sealed record ExercisePlanCreateRequest( + DateOnly StartDate, + DateOnly EndDate, + string? ExerciseType, + int DurationMinutes, + TimeOnly? ReminderTime = null); + +public sealed record ExercisePlanItemDto( + Guid Id, + DateOnly ScheduledDate, + string ExerciseType, + int DurationMinutes, + bool IsCompleted, + DateTime? CompletedAt, + bool IsRestDay); + +public sealed record ExercisePlanDto( + Guid Id, + DateOnly StartDate, + DateOnly EndDate, + TimeOnly ReminderTime, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList Items); + +public sealed record ExercisePlanSummaryDto( + Guid Id, + DateOnly StartDate, + DateOnly EndDate, + TimeOnly ReminderTime, + DateTime CreatedAt, + int TotalDays, + int CompletedDays, + IReadOnlyList Items); + +public interface IExerciseService +{ + Task GetCurrentAsync(Guid userId, CancellationToken ct); + Task CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct); + Task CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList items, CancellationToken ct); + Task> ListAsync(Guid userId, CancellationToken ct); + Task GetByIdAsync(Guid userId, Guid planId, CancellationToken ct); + Task DeleteAsync(Guid userId, Guid planId, CancellationToken ct); + Task ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct); + Task CheckInAsync(Guid userId, Guid itemId, CancellationToken ct); + Task GetLatestAsync(Guid userId, CancellationToken ct); +} + +public interface IExerciseRepository +{ + Task> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct); + Task> ListAsync(Guid userId, int limit, CancellationToken ct); + Task GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct); + Task GetLatestAsync(Guid userId, CancellationToken ct); + Task GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct); + Task AddAsync(ExercisePlan plan, CancellationToken ct); + void Delete(ExercisePlan plan); + Task SaveChangesAsync(CancellationToken ct); +} + +public interface IExerciseReminderProducer +{ + Task ProduceDueAsync(DateTime utcNow, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Exercises/ExerciseService.cs b/backend/src/Health.Application/Exercises/ExerciseService.cs new file mode 100644 index 0000000..1ea26b3 --- /dev/null +++ b/backend/src/Health.Application/Exercises/ExerciseService.cs @@ -0,0 +1,132 @@ +using Health.Domain.Entities; + +namespace Health.Application.Exercises; + +public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService +{ + private static readonly TimeOnly DefaultReminderTime = new(19, 0); + private readonly IExerciseRepository _exercises = exercises; + + public async Task GetCurrentAsync(Guid userId, CancellationToken ct) + { + var today = BeijingToday(); + var plans = await _exercises.ListActiveOnAsync(userId, today, ct); + var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.ScheduledDate == today)); + return plan == null ? null : ToDto(plan); + } + + public async Task CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct) + { + var startDate = request.StartDate; + var endDate = request.EndDate < startDate ? startDate : request.EndDate; + if (endDate.DayNumber - startDate.DayNumber > 365) + endDate = startDate.AddDays(365); + + var exerciseType = string.IsNullOrWhiteSpace(request.ExerciseType) ? "运动" : request.ExerciseType.Trim(); + var duration = request.DurationMinutes > 0 ? request.DurationMinutes : 30; + var plan = NewPlan(userId, startDate, endDate, request.ReminderTime); + for (var date = startDate; date <= endDate; date = date.AddDays(1)) + { + plan.Items.Add(new ExercisePlanItem + { + Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType, + DurationMinutes = duration, IsRestDay = false, + }); + } + await SaveNewAsync(plan, ct); + return plan.Id; + } + + public async Task CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList items, CancellationToken ct) + { + var normalizedEnd = endDate < startDate ? startDate : endDate; + var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime); + foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First())) + { + plan.Items.Add(new ExercisePlanItem + { + Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate, + ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(), + DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30, + IsRestDay = item.IsRestDay, + }); + } + await SaveNewAsync(plan, ct); + return plan.Id; + } + + public async Task> ListAsync(Guid userId, CancellationToken ct) + { + var plans = await _exercises.ListAsync(userId, 20, ct); + return plans.Select(p => new ExercisePlanSummaryDto( + p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt, + p.Items.Count, p.Items.Count(i => i.IsCompleted), + p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList(); + } + + public async Task GetByIdAsync(Guid userId, Guid planId, CancellationToken ct) + { + var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct); + return plan == null ? null : ToDto(plan); + } + + public async Task DeleteAsync(Guid userId, Guid planId, CancellationToken ct) + { + var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct); + if (plan == null) return false; + _exercises.Delete(plan); + await _exercises.SaveChangesAsync(ct); + return true; + } + + public async Task ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct) + { + var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct); + if (item == null) return null; + item.IsCompleted = !item.IsCompleted; + item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null; + item.Plan.UpdatedAt = DateTime.UtcNow; + await _exercises.SaveChangesAsync(ct); + return item.IsCompleted; + } + + public async Task CheckInAsync(Guid userId, Guid itemId, CancellationToken ct) + { + var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct); + if (item == null) return false; + item.IsCompleted = true; + item.CompletedAt = DateTime.UtcNow; + item.Plan.UpdatedAt = DateTime.UtcNow; + await _exercises.SaveChangesAsync(ct); + return true; + } + + public async Task GetLatestAsync(Guid userId, CancellationToken ct) + { + var plan = await _exercises.GetLatestAsync(userId, ct); + return plan == null ? null : ToDto(plan); + } + + private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct) + { + await _exercises.AddAsync(plan, ct); + await _exercises.SaveChangesAsync(ct); + } + + private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new() + { + Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate, + ReminderTime = reminderTime ?? DefaultReminderTime, + CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }; + + private static ExercisePlanDto ToDto(ExercisePlan plan) => new( + plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt, + plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList()); + + private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new( + item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes, + item.IsCompleted, item.CompletedAt, item.IsRestDay); + + private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); +} diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs new file mode 100644 index 0000000..22c788b --- /dev/null +++ b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs @@ -0,0 +1,39 @@ +using Health.Domain.Entities; + +namespace Health.Application.HealthArchives; + +public sealed record HealthArchiveDto( + Guid Id, + Guid UserId, + string? Diagnosis, + string? SurgeryType, + DateOnly? SurgeryDate, + IReadOnlyList Allergies, + IReadOnlyList DietRestrictions, + IReadOnlyList ChronicDiseases, + string? FamilyHistory, + DateTime UpdatedAt); + +public sealed record HealthArchiveUpdateRequest( + string? Diagnosis, + string? SurgeryType, + DateOnly? SurgeryDate, + IReadOnlyList? Allergies, + IReadOnlyList? DietRestrictions, + IReadOnlyList? ChronicDiseases, + string? FamilyHistory); + +public interface IHealthArchiveService +{ + Task GetAsync(Guid userId, CancellationToken ct); + Task GetOrCreateAsync(Guid userId, CancellationToken ct); + Task UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct); + Task ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct); +} + +public interface IHealthArchiveRepository +{ + Task GetByUserIdAsync(Guid userId, CancellationToken ct); + Task AddAsync(HealthArchive archive, CancellationToken ct); + Task SaveChangesAsync(CancellationToken ct); +} diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs new file mode 100644 index 0000000..6c84e6f --- /dev/null +++ b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs @@ -0,0 +1,111 @@ +using Health.Domain.Entities; + +namespace Health.Application.HealthArchives; + +public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService +{ + private readonly IHealthArchiveRepository _archives = archives; + + public async Task GetAsync(Guid userId, CancellationToken ct) + { + var archive = await _archives.GetByUserIdAsync(userId, ct); + return archive == null ? null : ToDto(archive); + } + + public async Task GetOrCreateAsync(Guid userId, CancellationToken ct) + { + var archive = await GetOrCreateEntityAsync(userId, ct); + await _archives.SaveChangesAsync(ct); + return ToDto(archive); + } + + public async Task UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct) + { + var archive = await GetOrCreateEntityAsync(userId, ct); + Apply(archive, request); + archive.UpdatedAt = DateTime.UtcNow; + await _archives.SaveChangesAsync(ct); + return ToDto(archive); + } + + public async Task ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct) + { + if (action == "query") + { + var archive = await GetAsync(userId, ct); + return archive == null ? new { found = false } : ToAiResult(archive); + } + + var current = await GetOrCreateEntityAsync(userId, ct); + var scopedRequest = action switch + { + "update_diagnosis" => request with + { + SurgeryType = null, SurgeryDate = null, Allergies = null, + DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null + }, + "update_surgery" => request with + { + Diagnosis = null, Allergies = null, DietRestrictions = null, + ChronicDiseases = null, FamilyHistory = null + }, + "update_allergies" => new HealthArchiveUpdateRequest(null, null, null, request.Allergies, null, null, null), + "update_chronic_diseases" => new HealthArchiveUpdateRequest(null, null, null, null, null, request.ChronicDiseases, null), + "update_diet_restrictions" => new HealthArchiveUpdateRequest(null, null, null, null, request.DietRestrictions, null, null), + _ => null + }; + + if (scopedRequest == null) + return new { success = false, message = $"未知档案操作: {action}" }; + + Apply(current, scopedRequest); + current.UpdatedAt = DateTime.UtcNow; + await _archives.SaveChangesAsync(ct); + return new { success = true }; + } + + private async Task GetOrCreateEntityAsync(Guid userId, CancellationToken ct) + { + var archive = await _archives.GetByUserIdAsync(userId, ct); + if (archive != null) return archive; + + archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId, UpdatedAt = DateTime.UtcNow }; + await _archives.AddAsync(archive, ct); + return archive; + } + + private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request) + { + if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis; + if (request.SurgeryType != null) archive.SurgeryType = request.SurgeryType; + if (request.SurgeryDate.HasValue) archive.SurgeryDate = request.SurgeryDate.Value; + if (request.Allergies != null) archive.Allergies = request.Allergies.ToList(); + if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList(); + if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList(); + if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory; + } + + private static object ToAiResult(HealthArchiveDto archive) => new + { + found = true, + archive.Diagnosis, + archive.SurgeryType, + SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), + archive.Allergies, + archive.DietRestrictions, + archive.ChronicDiseases, + archive.FamilyHistory, + }; + + private static HealthArchiveDto ToDto(HealthArchive archive) => new( + archive.Id, + archive.UserId, + archive.Diagnosis, + archive.SurgeryType, + archive.SurgeryDate, + archive.Allergies, + archive.DietRestrictions, + archive.ChronicDiseases, + archive.FamilyHistory, + archive.UpdatedAt); +} diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs b/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs new file mode 100644 index 0000000..aac71a9 --- /dev/null +++ b/backend/src/Health.Application/HealthRecords/HealthRecordContracts.cs @@ -0,0 +1,45 @@ +using Health.Domain.Enums; +using Health.Domain.Entities; + +namespace Health.Application.HealthRecords; + +public sealed record HealthRecordUpsertRequest( + HealthMetricType Type, + int? Systolic, + int? Diastolic, + decimal? Value, + string? Unit, + HealthRecordSource Source, + DateTime? RecordedAt); + +public sealed record HealthRecordDto( + Guid Id, + string Type, + int? Systolic, + int? Diastolic, + decimal? Value, + string? Unit, + string Source, + bool IsAbnormal, + DateTime RecordedAt); + +public interface IHealthRecordService +{ + Task> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct); + Task CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct); + Task UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct); + Task DeleteAsync(Guid userId, Guid id, CancellationToken ct); + Task> GetLatestAsync(Guid userId, CancellationToken ct); + Task> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct); +} + +public interface IHealthRecordRepository +{ + Task> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct); + Task GetOwnedAsync(Guid userId, Guid id, CancellationToken ct); + Task GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct); + Task> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct); + Task AddAsync(HealthRecord record, CancellationToken ct); + void Delete(HealthRecord record); + Task SaveChangesAsync(CancellationToken ct); +} diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs b/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs new file mode 100644 index 0000000..6845dbc --- /dev/null +++ b/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs @@ -0,0 +1,16 @@ +using Health.Domain.Enums; + +namespace Health.Application.HealthRecords; + +public static class HealthRecordRules +{ + public static bool CheckAbnormal(HealthRecordUpsertRequest request) => request.Type switch + { + HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59, + HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60, + HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m, + HealthMetricType.SpO2 => request.Value <= 94, + HealthMetricType.Weight => false, + _ => false + }; +} diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs new file mode 100644 index 0000000..8f2028d --- /dev/null +++ b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs @@ -0,0 +1,116 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.HealthRecords; + +public sealed class HealthRecordService(IHealthRecordRepository records) : IHealthRecordService +{ + private readonly IHealthRecordRepository _records = records; + + public async Task> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct) + { + HealthMetricType? metricType = null; + if (!string.IsNullOrEmpty(type) && Enum.TryParse(type, ignoreCase: true, out var parsed)) + metricType = parsed; + + var recordedAfter = days.HasValue ? DateTime.UtcNow.AddDays(-days.Value) : (DateTime?)null; + var result = await _records.ListAsync(userId, metricType, recordedAfter, 100, ct); + return result.Select(ToDto).ToList(); + } + + public async Task CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct) + { + var record = new HealthRecord + { + Id = Guid.NewGuid(), + UserId = userId, + MetricType = request.Type, + Systolic = request.Systolic, + Diastolic = request.Diastolic, + Value = request.Value, + Unit = request.Unit, + Source = request.Source, + RecordedAt = request.RecordedAt ?? DateTime.UtcNow, + CreatedAt = DateTime.UtcNow, + IsAbnormal = HealthRecordRules.CheckAbnormal(request), + }; + + await _records.AddAsync(record, ct); + await _records.SaveChangesAsync(ct); + return record.Id; + } + + public async Task UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct) + { + var record = await _records.GetOwnedAsync(userId, id, ct); + if (record == null) return false; + + record.MetricType = request.Type; + record.Systolic = request.Systolic; + record.Diastolic = request.Diastolic; + record.Value = request.Value; + record.Unit = request.Unit; + record.Source = request.Source; + record.RecordedAt = request.RecordedAt ?? record.RecordedAt; + record.IsAbnormal = HealthRecordRules.CheckAbnormal(request); + + await _records.SaveChangesAsync(ct); + return true; + } + + public async Task DeleteAsync(Guid userId, Guid id, CancellationToken ct) + { + var record = await _records.GetOwnedAsync(userId, id, ct); + if (record == null) return false; + + _records.Delete(record); + await _records.SaveChangesAsync(ct); + return true; + } + + public async Task> GetLatestAsync(Guid userId, CancellationToken ct) + { + var types = new[] + { + HealthMetricType.BloodPressure, + HealthMetricType.HeartRate, + HealthMetricType.Glucose, + HealthMetricType.SpO2, + HealthMetricType.Weight + }; + + var result = new Dictionary(); + foreach (var type in types) + { + var latest = await _records.GetLatestByTypeAsync(userId, type, ct); + result[type.ToString()] = latest == null ? null : new + { + latest.Systolic, + latest.Diastolic, + latest.Value, + latest.Unit, + latest.RecordedAt + }; + } + + return result; + } + + public async Task> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct) + { + var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 }; + var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct); + return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt }).Cast().ToList(); + } + + public static HealthRecordDto ToDto(HealthRecord record) => new( + record.Id, + record.MetricType.ToString(), + record.Systolic, + record.Diastolic, + record.Value, + record.Unit, + record.Source.ToString(), + record.IsAbnormal, + record.RecordedAt); +} diff --git a/backend/src/Health.Application/Maintenance/MaintenanceContracts.cs b/backend/src/Health.Application/Maintenance/MaintenanceContracts.cs new file mode 100644 index 0000000..8d2caaf --- /dev/null +++ b/backend/src/Health.Application/Maintenance/MaintenanceContracts.cs @@ -0,0 +1,16 @@ +namespace Health.Application.Maintenance; + +public sealed record MaintenanceCleanupResult( + int Conversations, + int VerificationCodes, + int BackgroundTasks); + +public interface IMaintenanceService +{ + Task CleanupAsync(DateTime utcNow, CancellationToken ct); +} + +public interface IMaintenanceRepository +{ + Task CleanupAsync(DateTime conversationCutoff, DateTime taskCutoff, DateTime utcNow, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Maintenance/MaintenanceService.cs b/backend/src/Health.Application/Maintenance/MaintenanceService.cs new file mode 100644 index 0000000..2365f07 --- /dev/null +++ b/backend/src/Health.Application/Maintenance/MaintenanceService.cs @@ -0,0 +1,9 @@ +namespace Health.Application.Maintenance; + +public sealed class MaintenanceService(IMaintenanceRepository repository) : IMaintenanceService +{ + private readonly IMaintenanceRepository _repository = repository; + + public Task CleanupAsync(DateTime utcNow, CancellationToken ct) => + _repository.CleanupAsync(utcNow.AddDays(-30), utcNow.AddDays(-30), utcNow, ct); +} diff --git a/backend/src/Health.Application/Medications/MedicationContracts.cs b/backend/src/Health.Application/Medications/MedicationContracts.cs new file mode 100644 index 0000000..d3cd628 --- /dev/null +++ b/backend/src/Health.Application/Medications/MedicationContracts.cs @@ -0,0 +1,106 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.Medications; + +public sealed record MedicationUpsertRequest( + string Name, + string? Dosage, + MedicationFrequency Frequency, + IReadOnlyList TimeOfDay, + DateOnly? StartDate, + DateOnly? EndDate, + MedicationSource Source, + string? Notes); + +public sealed record MedicationPatchRequest( + string? Name, + string? Dosage, + MedicationFrequency? Frequency, + IReadOnlyList? TimeOfDay, + DateOnly? StartDate, + DateOnly? EndDate, + string? Notes); + +public sealed record MedicationDto( + Guid Id, + string Name, + string? Dosage, + string Frequency, + IReadOnlyList TimeOfDay, + DateOnly? StartDate, + DateOnly? EndDate, + bool IsActive, + string? Notes, + string Source, + DateTime CreatedAt, + DateTime UpdatedAt, + bool TodayTaken); + +public sealed record MedicationReminderDto( + Guid Id, + string Name, + string? Dosage, + string ScheduledTime, + string Status); + +public sealed record MedicationReminderTask( + Guid TaskId, + Guid UserId, + Guid MedicationId, + string Name, + string? Dosage, + DateOnly ScheduledDate, + TimeOnly ScheduledTime); + +public interface IMedicationService +{ + Task> ListAsync(Guid userId, string? filter, CancellationToken ct); + Task CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct); + Task UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct); + Task DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct); + Task ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct); + Task> GetRemindersAsync(Guid userId, CancellationToken ct); + Task ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct); + Task CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct); + Task> ListActiveAsync(Guid userId, CancellationToken ct); + Task ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct); +} + +public interface IMedicationRepository +{ + Task> ListAsync(Guid userId, string? filter, CancellationToken ct); + Task> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct); + Task> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct); + Task GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct); + Task ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct); + Task GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct); + Task DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct); + Task GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct); + Task> ListActiveForReminderScanAsync(CancellationToken ct); + Task HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct); + Task AddMedicationAsync(Medication medication, CancellationToken ct); + Task AddLogAsync(MedicationLog log, CancellationToken ct); + void DeleteMedication(Medication medication); + void DeleteLog(MedicationLog log); + Task SaveChangesAsync(CancellationToken ct); +} + +public interface IMedicationReminderScanner +{ + Task> ScanAsync(DateTime utcNow, CancellationToken ct); +} + +public interface IMedicationReminderQueue +{ + Task EnqueueAsync(MedicationReminderTask task, CancellationToken ct); + Task RecoverStaleAsync(CancellationToken ct); + Task TryTakeAsync(CancellationToken ct); + Task CompleteAsync(Guid taskId, CancellationToken ct); + Task RetryAsync(Guid taskId, string error, CancellationToken ct); +} + +public interface IMedicationReminderDispatcher +{ + Task DispatchAsync(MedicationReminderTask task, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Medications/MedicationReminderScanner.cs b/backend/src/Health.Application/Medications/MedicationReminderScanner.cs new file mode 100644 index 0000000..0fd31c0 --- /dev/null +++ b/backend/src/Health.Application/Medications/MedicationReminderScanner.cs @@ -0,0 +1,64 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.Medications; + +public sealed class MedicationReminderScanner(IMedicationRepository medications) : IMedicationReminderScanner +{ + private readonly IMedicationRepository _medications = medications; + + public async Task> ScanAsync(DateTime utcNow, CancellationToken ct) + { + var beijingNow = utcNow.AddHours(8); + var date = DateOnly.FromDateTime(beijingNow); + var time = TimeOnly.FromDateTime(beijingNow); + var windowStart = time.AddMinutes(-5); + var todayStartUtc = beijingNow.Date.AddHours(-8); + var todayEndUtc = todayStartUtc.AddDays(1); + var active = await _medications.ListActiveForReminderScanAsync(ct); + var tasks = new List(); + + foreach (var medication in active) + { + if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue; + if (await _medications.HasTakenInWindowAsync(medication.UserId, medication.Id, todayStartUtc, todayEndUtc, ct)) continue; + + foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time))) + { + tasks.Add(new MedicationReminderTask( + Guid.NewGuid(), + medication.UserId, + medication.Id, + medication.Name, + medication.Dosage, + date, + scheduledTime)); + } + } + + return tasks; + } + + private static bool IsActiveOn(Medication medication, DateOnly date) => + (medication.StartDate == null || medication.StartDate <= date) + && (medication.EndDate == null || medication.EndDate >= date); + + private static bool IsInWindow(TimeOnly value, TimeOnly start, TimeOnly end) => + end >= start ? value >= start && value <= end : value >= start || value <= end; + + private static bool GetDosesForToday(Medication medication, DateOnly today) + { + if (medication.Frequency is MedicationFrequency.Daily + or MedicationFrequency.TwiceDaily + or MedicationFrequency.ThreeTimesDaily + or MedicationFrequency.AsNeeded) + return true; + + var startDate = medication.StartDate ?? today; + if (medication.Frequency == MedicationFrequency.EveryOtherDay) + return (today.DayNumber - startDate.DayNumber) % 2 == 0; + if (medication.Frequency == MedicationFrequency.Weekly) + return today.DayOfWeek == startDate.DayOfWeek; + return true; + } +} diff --git a/backend/src/Health.Application/Medications/MedicationService.cs b/backend/src/Health.Application/Medications/MedicationService.cs new file mode 100644 index 0000000..cf63b0f --- /dev/null +++ b/backend/src/Health.Application/Medications/MedicationService.cs @@ -0,0 +1,217 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.Medications; + +public sealed class MedicationService(IMedicationRepository medications) : IMedicationService +{ + private readonly IMedicationRepository _medications = medications; + + public async Task> ListAsync(Guid userId, string? filter, CancellationToken ct) + { + var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow(); + var meds = await _medications.ListAsync(userId, filter, ct); + return meds.Select(m => ToDto(m, HasTakenToday(m, todayStartUtc, todayEndUtc))).ToList(); + } + + public async Task CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct) + { + var med = new Medication + { + Id = Guid.NewGuid(), + UserId = userId, + Name = request.Name, + Dosage = request.Dosage, + Frequency = request.Frequency, + TimeOfDay = request.TimeOfDay.Count > 0 ? request.TimeOfDay.ToList() : [new TimeOnly(8, 0)], + StartDate = request.StartDate, + EndDate = request.EndDate, + Source = request.Source, + Notes = request.Notes, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + await _medications.AddMedicationAsync(med, ct); + await _medications.SaveChangesAsync(ct); + return med.Id; + } + + public async Task UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct) + { + var med = await _medications.GetOwnedAsync(userId, medicationId, ct); + if (med == null) return false; + + if (!string.IsNullOrWhiteSpace(request.Name)) med.Name = request.Name; + if (request.Dosage != null) med.Dosage = request.Dosage; + if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value; + if (request.TimeOfDay != null) med.TimeOfDay = request.TimeOfDay.ToList(); + if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value; + if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value; + if (request.Notes != null) med.Notes = request.Notes; + med.UpdatedAt = DateTime.UtcNow; + + await _medications.SaveChangesAsync(ct); + return true; + } + + public async Task DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct) + { + var med = await _medications.GetOwnedAsync(userId, medicationId, ct); + if (med == null) return false; + + _medications.DeleteMedication(med); + await _medications.SaveChangesAsync(ct); + return true; + } + + public async Task ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct) + { + if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null; + + var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow(); + var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct); + if (existing != null) + { + _medications.DeleteLog(existing); + await _medications.SaveChangesAsync(ct); + return false; + } + + await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct); + return true; + } + + public async Task> GetRemindersAsync(Guid userId, CancellationToken ct) + { + var beijingNow = DateTime.UtcNow.AddHours(8); + var now = TimeOnly.FromDateTime(beijingNow); + var today = DateOnly.FromDateTime(beijingNow); + var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow(); + + var meds = await _medications.ListActiveForRemindersAsync(userId, today, ct); + var logs = await _medications.ListLogsInWindowAsync(userId, todayStartUtc, todayEndUtc, ct); + + var reminders = new List(); + foreach (var med in meds) + { + if (!GetDosesForToday(med, today)) continue; + + foreach (var scheduledTime in med.TimeOfDay) + { + var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime); + string status; + if (log != null) + status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped"; + else if (scheduledTime <= now.AddMinutes(-30)) + status = "overdue"; + else if (scheduledTime <= now.AddHours(3)) + status = "upcoming"; + else + continue; + + reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status)); + } + } + + return reminders + .OrderBy(r => r.Status == "overdue" ? 0 : r.Status == "upcoming" ? 1 : 2) + .ToList(); + } + + public async Task ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct) + { + if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null; + + var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow(); + var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct); + if (existing) return false; + + await AddLogAsync(userId, medicationId, status, scheduledTime, ct); + return true; + } + + public async Task CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct) + { + var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow(); + var log = await _medications.GetDoseLogAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct); + if (log == null) return false; + + _medications.DeleteLog(log); + await _medications.SaveChangesAsync(ct); + return true; + } + + public Task> ListActiveAsync(Guid userId, CancellationToken ct) => + ListAsync(userId, "active", ct); + + public async Task ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct) + { + var result = await ConfirmDoseAsync( + userId, + medicationId, + TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), + MedicationLogStatus.Taken, + ct); + return result == true; + } + + private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct) + { + await _medications.AddLogAsync(new MedicationLog + { + Id = Guid.NewGuid(), + MedicationId = medicationId, + UserId = userId, + Status = status, + ScheduledTime = scheduledTime, + ConfirmedAt = DateTime.UtcNow, + CreatedAt = DateTime.UtcNow, + }, ct); + await _medications.SaveChangesAsync(ct); + } + + private static MedicationDto ToDto(Medication med, bool todayTaken) => new( + med.Id, + med.Name, + med.Dosage, + med.Frequency.ToString(), + med.TimeOfDay, + med.StartDate, + med.EndDate, + med.IsActive, + med.Notes, + med.Source.ToString(), + med.CreatedAt, + med.UpdatedAt, + todayTaken); + + private static bool HasTakenToday(Medication med, DateTime todayStartUtc, DateTime todayEndUtc) => + med.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken); + + private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow() + { + var beijingToday = DateTime.UtcNow.AddHours(8).Date; + var todayStartUtc = beijingToday.AddHours(-8); + return (todayStartUtc, todayStartUtc.AddDays(1)); + } + + private static bool GetDosesForToday(Medication med, DateOnly today) + { + if (med.Frequency is MedicationFrequency.Daily + or MedicationFrequency.TwiceDaily + or MedicationFrequency.ThreeTimesDaily + or MedicationFrequency.AsNeeded) + return true; + + var startDate = med.StartDate ?? today; + if (med.Frequency == MedicationFrequency.EveryOtherDay) + return (today.DayNumber - startDate.DayNumber) % 2 == 0; + + if (med.Frequency == MedicationFrequency.Weekly) + return today.DayOfWeek == startDate.DayOfWeek; + + return true; + } +} diff --git a/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs b/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs new file mode 100644 index 0000000..afdfa78 --- /dev/null +++ b/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs @@ -0,0 +1,26 @@ +namespace Health.Application.Notifications; + +public sealed record InAppNotificationDto( + Guid Id, + string Type, + string Title, + string Message, + DateTime CreatedAt); + +public interface IInAppNotificationService +{ + Task> GetPendingAsync(Guid userId, CancellationToken ct); + Task AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct); +} + +public interface IInAppNotificationRepository +{ + Task> GetPendingAsync(Guid userId, CancellationToken ct); + Task AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct); +} + +public sealed record InAppNotificationRecord( + Guid Id, + string Type, + string Payload, + DateTime CreatedAt); diff --git a/backend/src/Health.Application/Notifications/InAppNotificationService.cs b/backend/src/Health.Application/Notifications/InAppNotificationService.cs new file mode 100644 index 0000000..f946adb --- /dev/null +++ b/backend/src/Health.Application/Notifications/InAppNotificationService.cs @@ -0,0 +1,47 @@ +using System.Text.Json; + +namespace Health.Application.Notifications; + +public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService +{ + private readonly IInAppNotificationRepository _notifications = notifications; + + public async Task> GetPendingAsync(Guid userId, CancellationToken ct) + { + var records = await _notifications.GetPendingAsync(userId, ct); + return records.Select(ToDto).ToList(); + } + + public Task AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) => + _notifications.AcknowledgeAsync(userId, notificationId, ct); + + private static InAppNotificationDto ToDto(InAppNotificationRecord record) + { + if (record.Type == "MedicationReminder") + { + using var json = JsonDocument.Parse(record.Payload); + var root = json.RootElement; + var name = root.TryGetProperty("Name", out var nameValue) ? nameValue.GetString() : null; + var dosage = root.TryGetProperty("Dosage", out var dosageValue) ? dosageValue.GetString() : null; + var time = root.TryGetProperty("ScheduledTime", out var timeValue) ? timeValue.GetString() : null; + var details = string.Join(" · ", new[] { dosage, time }.Where(x => !string.IsNullOrWhiteSpace(x))); + return new InAppNotificationDto( + record.Id, + record.Type, + "用药提醒", + $"该服用{name ?? "药物"}{(details.Length > 0 ? $"({details})" : "")}了", + record.CreatedAt); + } + + if (record.Type == "ExerciseReminder") + { + using var json = JsonDocument.Parse(record.Payload); + var root = json.RootElement; + var type = root.TryGetProperty("ExerciseType", out var typeValue) ? typeValue.GetString() : "运动"; + var minutes = root.TryGetProperty("DurationMinutes", out var minutesValue) ? minutesValue.GetInt32() : 30; + return new InAppNotificationDto(record.Id, record.Type, "运动提醒", $"今天的{type}计划还未完成,目标 {minutes} 分钟", record.CreatedAt); + } + + return new InAppNotificationDto(record.Id, record.Type, "健康提醒", "您有一条新的健康提醒", record.CreatedAt); + } +} diff --git a/backend/src/Health.Application/Reports/ReportContracts.cs b/backend/src/Health.Application/Reports/ReportContracts.cs new file mode 100644 index 0000000..eacfe5c --- /dev/null +++ b/backend/src/Health.Application/Reports/ReportContracts.cs @@ -0,0 +1,83 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.Reports; + +public sealed record ReportUploadFile( + string FileName, + long Length, + Stream Content); + +public sealed record ReportDto( + Guid Id, + Guid UserId, + string FileUrl, + string FileType, + string Category, + string Status, + string AiStatus, + string ReviewStatus, + string? Severity, + string? AiSummary, + string? AiIndicators, + string? DoctorComment, + string? DoctorRecommendation, + string? DoctorName, + DateTime? ReviewedAt, + DateTime CreatedAt); + +public sealed record ReportUploadResult( + bool Success, + int Code, + string? Message, + ReportDto? Report); + +public sealed record ReportAnalysisJob( + Guid TaskId, + Guid ReportId, + string FilePath); + +public sealed record StoredReportFile( + string FileUrl, + string FilePath); + +public interface IReportService +{ + Task> GetReportsAsync(Guid userId, CancellationToken ct); + Task GetReportAsync(Guid userId, Guid reportId, CancellationToken ct); + Task UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct); + Task DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct); + Task ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct); +} + +public interface IReportAnalysisQueue +{ + Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default); + Task RecoverStaleAsync(CancellationToken ct); + Task TryTakeAsync(CancellationToken ct); + Task CompleteAsync(Guid taskId, CancellationToken ct); + Task RetryAsync(Guid taskId, string error, CancellationToken ct); +} + +public interface IReportAnalysisService +{ + Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct); +} + +public interface IReportRepository +{ + Task> ListAsync(Guid userId, CancellationToken ct); + Task GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct); + Task GetByIdAsync(Guid reportId, CancellationToken ct); + Task AddAsync(Report report, CancellationToken ct); + void Delete(Report report); + Task SaveChangesAsync(CancellationToken ct); +} + +public interface IReportFileStorage +{ + Task SaveAsync(ReportUploadFile file, string extension, CancellationToken ct); + string GetLocalFilePath(string fileUrl); + bool Exists(string filePath); + void Delete(string filePath); +} diff --git a/backend/src/Health.Application/Reports/ReportService.cs b/backend/src/Health.Application/Reports/ReportService.cs new file mode 100644 index 0000000..1bd443b --- /dev/null +++ b/backend/src/Health.Application/Reports/ReportService.cs @@ -0,0 +1,122 @@ +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Application.Reports; + +public sealed class ReportService( + IReportRepository reports, + IReportFileStorage fileStorage, + IReportAnalysisQueue queue) : IReportService +{ + private const long MaxReportImageBytes = 10 * 1024 * 1024; + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".webp" + }; + + private readonly IReportRepository _reports = reports; + private readonly IReportFileStorage _fileStorage = fileStorage; + private readonly IReportAnalysisQueue _queue = queue; + + public async Task> GetReportsAsync(Guid userId, CancellationToken ct) + { + var result = await _reports.ListAsync(userId, ct); + return result.Select(ToDto).ToList(); + } + + public async Task GetReportAsync(Guid userId, Guid reportId, CancellationToken ct) + { + var report = await _reports.GetOwnedAsync(userId, reportId, ct); + return report == null ? null : ToDto(report); + } + + public async Task UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct) + { + if (file.Length <= 0) + return Fail(400, "未上传文件"); + + if (file.Length > MaxReportImageBytes) + return Fail(400, "报告图片不能超过 10MB,请压缩后重新上传"); + + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!AllowedExtensions.Contains(ext)) + return Fail(400, "目前仅支持上传 JPG、PNG、WEBP 格式的报告图片"); + + var storedFile = await _fileStorage.SaveAsync(file, ext, ct); + var report = new Report + { + Id = Guid.NewGuid(), + UserId = userId, + FileUrl = storedFile.FileUrl, + FileType = ReportFileType.Image, + Category = ReportCategory.Other, + Status = ReportStatus.Analyzing, + AiSummary = null, + AiIndicators = null, + CreatedAt = DateTime.UtcNow, + }; + + await _reports.AddAsync(report, ct); + await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, storedFile.FilePath), ct); + + return new ReportUploadResult(true, 0, "报告已上传,AI 正在分析中", ToDto(report)); + } + + public async Task DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct) + { + var report = await _reports.GetOwnedAsync(userId, reportId, ct); + if (report == null) return false; + + var filePath = _fileStorage.GetLocalFilePath(report.FileUrl); + if (_fileStorage.Exists(filePath)) + _fileStorage.Delete(filePath); + + _reports.Delete(report); + await _reports.SaveChangesAsync(ct); + return true; + } + + public async Task ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct) + { + var report = await _reports.GetOwnedAsync(userId, reportId, ct); + if (report == null) return false; + + var filePath = _fileStorage.GetLocalFilePath(report.FileUrl); + if (!_fileStorage.Exists(filePath)) return false; + + report.Status = ReportStatus.Analyzing; + report.AiSummary = null; + report.AiIndicators = null; + await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, filePath), ct); + return true; + } + + public static ReportDto ToDto(Report report) => new( + report.Id, + report.UserId, + report.FileUrl, + report.FileType.ToString(), + report.Category.ToString(), + report.Status.ToString(), + ToAiStatus(report), + report.Status == ReportStatus.DoctorReviewed ? "Reviewed" : "Pending", + report.Severity, + report.AiSummary, + report.AiIndicators, + report.DoctorComment, + report.DoctorRecommendation, + report.DoctorName, + report.ReviewedAt, + report.CreatedAt); + + private static string ToAiStatus(Report report) => report.Status switch + { + ReportStatus.Analyzing => "Analyzing", + ReportStatus.AnalysisFailed => "Failed", + _ when string.IsNullOrWhiteSpace(report.AiSummary) => "Analyzing", + _ => "Succeeded" + }; + + private static ReportUploadResult Fail(int code, string message) => + new(false, code, message, null); +} diff --git a/backend/src/Health.Application/Users/UserContracts.cs b/backend/src/Health.Application/Users/UserContracts.cs new file mode 100644 index 0000000..d5b357d --- /dev/null +++ b/backend/src/Health.Application/Users/UserContracts.cs @@ -0,0 +1,31 @@ +using Health.Domain.Entities; + +namespace Health.Application.Users; + +public sealed record UserProfileDto( + Guid Id, + string Phone, + string Role, + string? Name, + string? Gender, + string? BirthDate, + string? AvatarUrl); + +public sealed record UserProfileUpdateRequest( + string? Name, + string? Gender, + DateOnly? BirthDate); + +public interface IUserService +{ + Task GetProfileAsync(Guid userId, CancellationToken ct); + Task UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct); + Task DeleteAccountAsync(Guid userId, CancellationToken ct); +} + +public interface IUserRepository +{ + Task GetAsync(Guid userId, CancellationToken ct); + Task SaveChangesAsync(CancellationToken ct); + Task DeleteAccountDataAsync(Guid userId, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Users/UserService.cs b/backend/src/Health.Application/Users/UserService.cs new file mode 100644 index 0000000..6b9da04 --- /dev/null +++ b/backend/src/Health.Application/Users/UserService.cs @@ -0,0 +1,35 @@ +namespace Health.Application.Users; + +public sealed class UserService(IUserRepository users) : IUserService +{ + private readonly IUserRepository _users = users; + + public async Task GetProfileAsync(Guid userId, CancellationToken ct) + { + var user = await _users.GetAsync(userId, ct); + return user == null ? null : new UserProfileDto( + user.Id, + user.Phone, + user.Role, + user.Name, + user.Gender, + user.BirthDate?.ToString("yyyy-MM-dd"), + user.AvatarUrl); + } + + public async Task UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct) + { + var user = await _users.GetAsync(userId, ct); + if (user == null) return false; + + if (request.Name != null) user.Name = request.Name; + if (request.Gender != null) user.Gender = request.Gender; + if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value; + user.UpdatedAt = DateTime.UtcNow; + await _users.SaveChangesAsync(ct); + return true; + } + + public Task DeleteAccountAsync(Guid userId, CancellationToken ct) => + _users.DeleteAccountDataAsync(userId, ct); +} diff --git a/backend/src/Health.Domain/Entities/exercise_plan.cs b/backend/src/Health.Domain/Entities/exercise_plan.cs index 3ffe1d0..081e192 100644 --- a/backend/src/Health.Domain/Entities/exercise_plan.cs +++ b/backend/src/Health.Domain/Entities/exercise_plan.cs @@ -7,7 +7,9 @@ public sealed class ExercisePlan { public Guid Id { get; set; } public Guid UserId { get; set; } - public DateOnly WeekStartDate { get; set; } // 起始日期 + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } + public TimeOnly ReminderTime { get; set; } = new(19, 0); public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; @@ -22,7 +24,7 @@ public sealed class ExercisePlanItem { public Guid Id { get; set; } public Guid PlanId { get; set; } - public int DayOfWeek { get; set; } // C# DayOfWeek: 0=周日, 6=周六 + public DateOnly ScheduledDate { get; set; } public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳 public int DurationMinutes { get; set; } public bool IsCompleted { get; set; } diff --git a/backend/src/Health.Domain/Enums/health_enums.cs b/backend/src/Health.Domain/Enums/health_enums.cs index fb3e5ff..88fe115 100644 --- a/backend/src/Health.Domain/Enums/health_enums.cs +++ b/backend/src/Health.Domain/Enums/health_enums.cs @@ -71,6 +71,8 @@ public enum MedicationFrequency /// public enum ReportStatus { + Analyzing, // AI 分析中 + AnalysisFailed, // AI 分析失败 PendingDoctor, // 待医生确认 DoctorReviewed // 医生已确认 } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs index acc19c1..14fe04c 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs @@ -1,3 +1,6 @@ +using Health.Application.HealthArchives; +using Health.Application.HealthRecords; + namespace Health.Infrastructure.AI.AgentHandlers; /// @@ -21,38 +24,33 @@ public static class CommonAgentHandler public static List Tools => [QueryHealthRecordsTool, CheckArchiveTool]; - public static async Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) + public static async Task Execute( + string toolName, + JsonElement args, + Guid userId, + IHealthRecordService healthRecords, + IHealthArchiveService archives, + CancellationToken ct = default) { return toolName switch { - "query_health_records" => await ExecuteQueryHealthRecords(db, userId, args), - "check_archive" => await ExecuteCheckArchive(db, userId), + "query_health_records" => await ExecuteQueryHealthRecords(healthRecords, userId, args, ct), + "check_archive" => await ExecuteCheckArchive(archives, userId, ct), _ => new { success = false, message = $"未知工具: {toolName}" } }; } - private static async Task ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args) + private static async Task ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct) { var type = args.TryGetProperty("type", out var t) ? t.GetString() : null; var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7; - - var query = db.HealthRecords.Where(r => r.UserId == userId); - if (!string.IsNullOrEmpty(type) && Enum.TryParse(type, ignoreCase: true, out var mt)) - query = query.Where(r => r.MetricType == mt); - - query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days)); - - var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new - { - r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt, - }).ToListAsync(); - + var records = await healthRecords.GetRecordsAsync(userId, type, days, ct); return new { count = records.Count, records }; } - private static async Task ExecuteCheckArchive(AppDbContext db, Guid userId) + private static async Task ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct) { - var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId); + var archive = await archives.GetAsync(userId, ct); if (archive == null) return new { found = false }; return new { @@ -85,49 +83,26 @@ public static class CommonAgentHandler } }; - public static async Task ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args) + public static Task ExecuteManageArchive( + IHealthArchiveService archives, + Guid userId, + JsonElement args, + CancellationToken ct = default) { var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; - var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId); - if (archive == null) - { - archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId }; - db.HealthArchives.Add(archive); - } - - switch (action) - { - case "update_diagnosis": - archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis; - break; - case "update_surgery": - archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType; - if (args.TryGetProperty("surgery_date", out var sd)) - archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate; - break; - case "update_allergies": - if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array) - archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)]; - break; - case "update_chronic_diseases": - if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array) - archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)]; - break; - case "update_diet_restrictions": - if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array) - archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)]; - break; - default: // query - return new - { - found = true, archive.Diagnosis, archive.SurgeryType, - SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), - archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, - }; - } - - archive.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(); - return new { success = true }; + var request = new HealthArchiveUpdateRequest( + args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null, + args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null, + args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null, + ReadStringArray(args, "allergies"), + ReadStringArray(args, "diet_restrictions"), + ReadStringArray(args, "chronic_diseases"), + args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null); + return archives.ExecuteAiActionAsync(userId, action, request, ct); } + + private static IReadOnlyList? ReadStringArray(JsonElement args, string propertyName) => + args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast().ToList() + : null; } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/consultation_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/consultation_agent_handler.cs index 2f21b0d..5939667 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/consultation_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/consultation_agent_handler.cs @@ -7,12 +7,4 @@ public static class ConsultationAgentHandler { public static List Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool]; - public static Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) - { - return toolName switch - { - "query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId), - _ => Task.FromResult(new { success = false, message = $"未知工具: {toolName}" }) - }; - } } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/diet_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/diet_agent_handler.cs index b13c6f6..be4203a 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/diet_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/diet_agent_handler.cs @@ -7,12 +7,4 @@ public static class DietAgentHandler { public static List Tools => [CommonAgentHandler.CheckArchiveTool]; - public static Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) - { - return toolName switch - { - "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId), - _ => Task.FromResult(new { success = false, message = $"未知工具: {toolName}" }) - }; - } } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs index 03524d5..d94fa07 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs @@ -1,85 +1,83 @@ +using Health.Application.Exercises; + namespace Health.Infrastructure.AI.AgentHandlers; -/// -/// 运动计划 Agent 工具处理器 -/// public static class ExerciseAgentHandler { public static readonly ToolDefinition ManageExerciseTool = new() { Function = new() { - Name = "manage_exercise", Description = "运动计划管理(创建/查询/打卡)", - Parameters = new { type = "object", properties = new { - action = new { type = "string", description = "create/query/checkin" }, - week_start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, - items = new { type = "array", description = "每天的运动项目", items = new { type = "object", properties = new { - day_of_week = new { type = "integer", description = "0=周日 1=周一...6=周六" }, - exercise_type = new { type = "string", description = "运动类型,如散步/慢跑/游泳/太极" }, - duration_minutes = new { type = "integer", description = "时长(分钟)" }, - is_rest_day = new { type = "boolean", description = "是否休息日" } - }}} - }, required = new[] { "action" } } - } + Name = "manage_exercise", + Description = "运动计划管理(创建/查询/打卡)", + Parameters = new + { + type = "object", + properties = new + { + action = new { type = "string", description = "create/query/checkin" }, + start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" }, + duration_days = new { type = "integer", description = "连续运动天数,如一周为7天" }, + exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" }, + duration_minutes = new { type = "integer", description = "每天运动时长,单位分钟" }, + reminder_time = new { type = "string", description = "每天提醒时间 HH:mm,默认19:00" }, + item_id = new { type = "string", description = "打卡条目 ID" }, + }, + required = new[] { "action" }, + }, + }, }; public static List Tools => [ManageExerciseTool]; - public static async Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) + public static async Task Execute( + string toolName, + JsonElement args, + AppDbContext db, + Guid userId, + IExerciseService? exercises = null, + CancellationToken ct = default) { - return toolName switch + if (toolName != "manage_exercise" || exercises == null) + return new { success = false, message = $"未知工具: {toolName}" }; + + var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query"; + if (action == "create") { - "manage_exercise" => await ExecuteManageExercise(db, userId, args), - _ => new { success = false, message = $"未知工具: {toolName}" } - }; - } - - private static async Task ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args) - { - var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; - switch (action) - { - case "create": - var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); - var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart }; - if (args.TryGetProperty("items", out var items)) - { - foreach (var item in items.EnumerateArray()) - { - plan.Items.Add(new ExercisePlanItem - { - Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(), - ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步", - DurationMinutes = item.GetProperty("duration_minutes").GetInt32(), - IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(), - }); - } - } - db.ExercisePlans.Add(plan); - await db.SaveChangesAsync(); - var firstItem = plan.Items.FirstOrDefault(); - return new { - success = true, plan_id = plan.Id, - exercise_type = firstItem?.ExerciseType ?? "运动", - duration_minutes = firstItem?.DurationMinutes ?? 30, - day_count = plan.Items.Count, - }; - - case "checkin": - var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty; - var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]); - if (exerciseItem == null) return new { success = false, message = "条目不存在" }; - exerciseItem.IsCompleted = true; - exerciseItem.CompletedAt = DateTime.UtcNow; - await db.SaveChangesAsync(); - return new { success = true }; - - default: - var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId) - .OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync(); - if (existingPlan == null) return new { found = false }; - var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync(); - return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) }; + var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart) + ? parsedStart + : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var days = args.TryGetProperty("duration_days", out var daysValue) && daysValue.TryGetInt32(out var parsedDays) + ? Math.Clamp(parsedDays, 1, 366) + : 7; + var exerciseType = args.TryGetProperty("exercise_type", out var typeValue) ? typeValue.GetString() : "散步"; + var minutes = args.TryGetProperty("duration_minutes", out var minutesValue) && minutesValue.TryGetInt32(out var parsedMinutes) ? parsedMinutes : 30; + var reminder = args.TryGetProperty("reminder_time", out var reminderValue) && TimeOnly.TryParse(reminderValue.GetString(), out var parsedReminder) + ? parsedReminder + : new TimeOnly(19, 0); + var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest( + startDate, startDate.AddDays(days - 1), exerciseType, minutes, reminder), ct); + return new { success = true, plan_id = planId, exercise_type = exerciseType, duration_minutes = minutes, day_count = days, start_date = startDate, end_date = startDate.AddDays(days - 1), reminder_time = reminder }; } + + if (action == "checkin") + { + var itemId = args.TryGetProperty("item_id", out var itemValue) && itemValue.TryGetGuid(out var parsedId) ? parsedId : Guid.Empty; + var success = await exercises.CheckInAsync(userId, itemId, ct); + return success ? new { success = true } : new { success = false, message = "条目不存在" }; + } + + var plan = await exercises.GetLatestAsync(userId, ct); + return plan == null + ? new { found = false } + : new + { + found = true, + plan_id = plan.Id, + plan.StartDate, + plan.EndDate, + plan.ReminderTime, + items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }), + }; } } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs index 4a40433..f9eb8ac 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs @@ -1,3 +1,5 @@ +using Health.Application.HealthRecords; + namespace Health.Infrastructure.AI.AgentHandlers; /// @@ -16,16 +18,50 @@ public static class HealthDataAgentHandler public static List Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool]; - public static async Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) + public static async Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId, IHealthRecordService? healthRecords = null) { return toolName switch { - "record_health_data" => await ExecuteRecordHealthData(db, userId, args), - "query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId), + "record_health_data" => healthRecords == null + ? await ExecuteRecordHealthData(db, userId, args) + : await ExecuteRecordHealthData(healthRecords, userId, args), _ => new { success = false, message = $"未知工具: {toolName}" } }; } + private static async Task ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args) + { + var type = args.TryGetProperty("type", out var t) ? t.GetString()! : ""; + var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args); + if (metricType == null) + return new { success = false, message = $"未知指标类型: {type}" }; + + var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) + ? dt + : DateTime.UtcNow; + + var request = new HealthRecordUpsertRequest( + metricType.Value, + systolic, + diastolic, + value, + unit, + HealthRecordSource.AiEntry, + recordedAt); + + var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None); + var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? ""; + return new + { + success = true, + record_id = id, + type = metricType.Value.ToString(), + value = valStr, + unit, + isAbnormal = HealthRecordRules.CheckAbnormal(request) + }; + } + private static async Task ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args) { var type = args.TryGetProperty("type", out var t) ? t.GetString()! : ""; @@ -82,4 +118,42 @@ public static class HealthDataAgentHandler }; return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal }; } + + private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args) + { + return type switch + { + "blood_pressure" => ( + HealthMetricType.BloodPressure, + null, + "mmHg", + args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null, + args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null), + "heart_rate" => ( + HealthMetricType.HeartRate, + args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null, + "次/分", + null, + null), + "glucose" => ( + HealthMetricType.Glucose, + args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null, + "mmol/L", + null, + null), + "spo2" => ( + HealthMetricType.SpO2, + args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null, + "%", + null, + null), + "weight" => ( + HealthMetricType.Weight, + args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null, + "kg", + null, + null), + _ => (null, null, "", null, null) + }; + } } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs index 9c998cf..9619cf8 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs @@ -1,7 +1,9 @@ +using Health.Application.Medications; + namespace Health.Infrastructure.AI.AgentHandlers; /// -/// 药管家 Agent 工具处理器——用药管理 +/// 药管家 Agent 工具处理器。 /// public static class MedicationAgentHandler { @@ -9,31 +11,105 @@ public static class MedicationAgentHandler { Function = new() { - Name = "manage_medication", Description = "用药管理(创建/查询/确认服药)", - Parameters = new { type = "object", properties = new { - action = new { type = "string", description = "create/query/confirm" }, - name = new { type = "string", description = "药品名称" }, - dosage = new { type = "string", description = "剂量,如 100mg" }, - frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" }, - time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" }, - duration_days = new { type = "integer", description = "服用天数" }, - start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, - }, required = new[] { "action" } } + Name = "manage_medication", + Description = "用药管理(创建/查询/确认服药)", + Parameters = new + { + type = "object", + properties = new + { + action = new { type = "string", description = "create/query/confirm" }, + medication_id = new { type = "string", description = "药品 ID,确认服药时使用" }, + name = new { type = "string", description = "药品名称" }, + dosage = new { type = "string", description = "剂量,如 100mg" }, + frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" }, + time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" }, + duration_days = new { type = "integer", description = "服用天数" }, + start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, + }, + required = new[] { "action" } + } } }; public static List Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool]; - public static async Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) + public static async Task Execute( + string toolName, + JsonElement args, + AppDbContext db, + Guid userId, + IMedicationService? medications = null, + CancellationToken ct = default) { return toolName switch { - "manage_medication" => await ExecuteManageMedication(db, userId, args), - "check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId), + "manage_medication" => medications != null + ? await ExecuteManageMedication(medications, userId, args, ct) + : await ExecuteManageMedication(db, userId, args), _ => new { success = false, message = $"未知工具: {toolName}" } }; } + private static async Task ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct) + { + var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; + return action switch + { + "create" => await CreateMedication(medications, userId, args, ct), + "query" => await QueryMedications(medications, userId, ct), + "confirm" => await ConfirmMedication(medications, userId, args, ct), + _ => new { success = false, message = $"未知操作: {action}" } + }; + } + + private static async Task CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct) + { + var name = args.TryGetProperty("name", out var n) ? n.GetString()! : ""; + var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null; + var frequency = ReadFrequency(args); + var times = ReadTimes(args); + var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0; + var startDate = ReadStartDate(args); + var endDate = durationDays > 0 ? startDate.AddDays(durationDays) : (DateOnly?)null; + + var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest( + name, + dosage, + frequency, + times.Count > 0 ? times : [new TimeOnly(8, 0)], + startDate, + endDate, + MedicationSource.AiEntry, + null), ct); + + var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00"; + return new + { + success = true, + medication_id = medicationId, + name, + dosage, + frequency = frequency.ToString(), + time = timeLabels, + start_date = startDate.ToString("yyyy-MM-dd"), + duration_days = durationDays, + }; + } + + private static async Task QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct) + { + var meds = await medications.ListActiveAsync(userId, ct); + return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) }; + } + + private static async Task ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct) + { + var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty; + var success = await medications.ConfirmNowAsync(userId, medId, ct); + return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" }; + } + private static async Task ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args) { var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; @@ -50,38 +126,36 @@ public static class MedicationAgentHandler { var name = args.TryGetProperty("name", out var n) ? n.GetString()! : ""; var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null; - var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily"; - var frequency = Enum.TryParse(frequencyStr, out var fr) ? fr : MedicationFrequency.Daily; - - List times = []; - if (args.TryGetProperty("time_of_day", out var tod) && tod.ValueKind == JsonValueKind.Array) - { - foreach (var t in tod.EnumerateArray()) - { - if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed); - } - } - + var frequency = ReadFrequency(args); + var times = ReadTimes(args); var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0; - var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null; + var startDate = ReadStartDate(args); var med = new Medication { - Id = Guid.NewGuid(), UserId = userId, - Name = name, Dosage = dosage, Frequency = frequency, + Id = Guid.NewGuid(), + UserId = userId, + Name = name, + Dosage = dosage, + Frequency = frequency, TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)], - Source = MedicationSource.AiEntry, IsActive = true, - StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), - EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null, + Source = MedicationSource.AiEntry, + IsActive = true, + StartDate = startDate, + EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null, }; db.Medications.Add(med); await db.SaveChangesAsync(); var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00"; - return new { - success = true, medication_id = med.Id, - name = med.Name, dosage = med.Dosage, - frequency = med.Frequency.ToString(), time = timeLabels, + return new + { + success = true, + medication_id = med.Id, + name = med.Name, + dosage = med.Dosage, + frequency = med.Frequency.ToString(), + time = timeLabels, start_date = med.StartDate?.ToString("yyyy-MM-dd"), duration_days = durationDays, }; @@ -97,12 +171,47 @@ public static class MedicationAgentHandler private static async Task ConfirmMedication(AppDbContext db, Guid userId, JsonElement args) { var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty; + var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId); + if (med == null) return new { success = false, message = "药品不存在" }; + db.MedicationLogs.Add(new MedicationLog { - Id = Guid.NewGuid(), MedicationId = medId, UserId = userId, - Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow, + Id = Guid.NewGuid(), + MedicationId = medId, + UserId = userId, + Status = MedicationLogStatus.Taken, + ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), + ConfirmedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); return new { success = true }; } + + private static MedicationFrequency ReadFrequency(JsonElement args) + { + var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily"; + return Enum.TryParse(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily; + } + + private static DateOnly ReadStartDate(JsonElement args) + { + var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null; + return DateOnly.TryParse(startDateStr, out var parsedDate) + ? parsedDate + : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + } + + private static List ReadTimes(JsonElement args) + { + List times = []; + if (!args.TryGetProperty("time_of_day", out var tod) || tod.ValueKind != JsonValueKind.Array) + return times; + + foreach (var t in tod.EnumerateArray()) + { + if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed); + } + + return times; + } } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs index 1b26d24..5a587f6 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs @@ -12,15 +12,6 @@ public static class ReportAgentHandler public static List Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool]; - public static Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) - { - return toolName switch - { - "query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId), - _ => Task.FromResult(new { success = false, message = $"未知工具: {toolName}" }) - }; - } - public static async Task AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient) { var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : ""; @@ -28,13 +19,13 @@ public static class ReportAgentHandler return new { success = false, message = "缺少报告图片" }; var prompt = """ - 你是一个医学报告解读专家。请分析以下检查报告图片,以JSON格式返回: + 你是患者端医学报告预解读助手。请分析以下检查报告图片,以JSON格式返回: { "reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)", "indicators": [ {"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"} ], - "summary": "初步分析摘要", + "summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生", "needsDoctorReview": true/false } 只返回JSON,不要其他内容。 diff --git a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs new file mode 100644 index 0000000..12b8101 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using Health.Application.AI; +using Health.Application.Exercises; +using Health.Application.HealthArchives; +using Health.Application.HealthRecords; +using Health.Application.Medications; +using Health.Infrastructure.AI.AgentHandlers; + +namespace Health.Infrastructure.AI; + +public sealed class AiToolExecutionService( + AppDbContext db, + VisionClient visionClient, + IHealthArchiveService healthArchives, + IHealthRecordService healthRecords, + IExerciseService exercises, + IMedicationService medications, + IAiWriteConfirmationStore confirmations) : IAiToolExecutionService +{ + private readonly AppDbContext _db = db; + private readonly VisionClient _visionClient = visionClient; + private readonly IHealthArchiveService _healthArchives = healthArchives; + private readonly IHealthRecordService _healthRecords = healthRecords; + private readonly IExerciseService _exercises = exercises; + private readonly IMedicationService _medications = medications; + private readonly IAiWriteConfirmationStore _confirmations = confirmations; + + public async Task ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct) + { + using var json = JsonDocument.Parse(arguments); + var root = json.RootElement; + return await (toolName switch + { + "record_health_data" => HealthDataAgentHandler.Execute(toolName, root, _db, userId, _healthRecords), + "query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), + "check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), + "manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct), + "analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient), + "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct), + "manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct), + _ => Task.FromResult(new { success = false, message = $"未知工具: {toolName}" }), + }); + } + + public async Task ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct) + { + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + var command = await _confirmations.TakeAsync(commandId, userId, ct); + if (command == null) + return new AiConfirmedWriteResult(40004, null, "确认请求不存在、已执行或已过期"); + + try + { + var result = await ExecuteAsync(command.ToolName, command.Arguments, userId, ct); + if (!Succeeded(result, out var error)) + { + await transaction.RollbackAsync(ct); + return new AiConfirmedWriteResult(40001, result, error ?? "写入失败,请检查内容后重试"); + } + await _confirmations.CompleteAsync(command, ct); + await transaction.CommitAsync(ct); + return new AiConfirmedWriteResult(0, result, null); + } + catch (Exception ex) + { + await transaction.RollbackAsync(ct); + return new AiConfirmedWriteResult(50001, null, $"写入失败: {ex.Message}"); + } + } + + private static bool Succeeded(object result, out string? error) + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result)); + var root = json.RootElement; + error = root.TryGetProperty("message", out var message) ? message.GetString() : null; + return !root.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.False; + } +} diff --git a/backend/src/Health.Infrastructure/AI/EfAiConversationRepository.cs b/backend/src/Health.Infrastructure/AI/EfAiConversationRepository.cs new file mode 100644 index 0000000..8fba7a6 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/EfAiConversationRepository.cs @@ -0,0 +1,45 @@ +using Health.Application.AI; + +namespace Health.Infrastructure.AI; + +public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversationRepository +{ + private readonly AppDbContext _db = db; + + public Task GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) => + _db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId, ct); + + public Task GetAsync(Guid conversationId, CancellationToken ct) => + _db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId, ct); + + public async Task> ListAsync(Guid userId, CancellationToken ct) => + await _db.Conversations + .Where(c => c.UserId == userId) + .OrderByDescending(c => c.UpdatedAt) + .ToListAsync(ct); + + public async Task> GetMessagesAsync(Guid conversationId, CancellationToken ct) => + await _db.ConversationMessages + .Where(m => m.ConversationId == conversationId) + .OrderBy(m => m.CreatedAt) + .ToListAsync(ct); + + public async Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => + await _db.ConversationMessages + .Where(m => m.ConversationId == conversationId) + .OrderByDescending(m => m.CreatedAt) + .Take(limit) + .ToListAsync(ct); + + public async Task AddConversationAsync(Conversation conversation, CancellationToken ct) => + await _db.Conversations.AddAsync(conversation, ct); + + public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => + await _db.ConversationMessages.AddAsync(message, ct); + + public void Delete(Conversation conversation) => + _db.Conversations.Remove(conversation); + + public Task SaveChangesAsync(CancellationToken ct) => + _db.SaveChangesAsync(ct); +} diff --git a/backend/src/Health.Infrastructure/AI/EfAiWriteConfirmationStore.cs b/backend/src/Health.Infrastructure/AI/EfAiWriteConfirmationStore.cs new file mode 100644 index 0000000..bb4b9aa --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/EfAiWriteConfirmationStore.cs @@ -0,0 +1,98 @@ +using Health.Application.AI; +using Health.Infrastructure.Data.Records; + +namespace Health.Infrastructure.AI; + +public sealed class EfAiWriteConfirmationStore(AppDbContext db) : IAiWriteConfirmationStore +{ + private readonly AppDbContext _db = db; + + public async Task CreateAsync( + Guid userId, + string toolName, + string arguments, + TimeSpan lifetime, + CancellationToken ct) + { + var now = DateTime.UtcNow; + if (IsInMemory()) + { + var expired = await _db.AiWriteCommands.Where(x => x.Status == "Pending" && x.ExpiresAt <= now).ToListAsync(ct); + foreach (var item in expired) { item.Status = "Expired"; item.UpdatedAt = now; } + } + else + { + await _db.AiWriteCommands + .Where(x => x.Status == "Pending" && x.ExpiresAt <= now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Expired") + .SetProperty(x => x.UpdatedAt, now), ct); + } + + var command = new AiWriteCommandRecord + { + Id = Guid.NewGuid(), + UserId = userId, + ToolName = toolName, + Arguments = arguments, + Status = "Pending", + ExpiresAt = now.Add(lifetime), + CreatedAt = now, + UpdatedAt = now, + }; + await _db.AiWriteCommands.AddAsync(command, ct); + await _db.SaveChangesAsync(ct); + return ToCommand(command); + } + + public async Task TakeAsync(Guid commandId, Guid userId, CancellationToken ct) + { + var now = DateTime.UtcNow; + if (IsInMemory()) + { + var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync( + x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now, ct); + if (tracked == null) return null; + tracked.Status = "Processing"; + tracked.UpdatedAt = now; + await _db.SaveChangesAsync(ct); + } + else + { + var updated = await _db.AiWriteCommands + .Where(x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Processing") + .SetProperty(x => x.UpdatedAt, now), ct); + if (updated == 0) return null; + } + + var record = await _db.AiWriteCommands.AsNoTracking().FirstAsync(x => x.Id == commandId, ct); + return ToCommand(record); + } + + public async Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct) + { + if (IsInMemory()) + { + var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync( + x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing", ct); + if (tracked != null) { tracked.Status = "Completed"; tracked.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); } + return; + } + await _db.AiWriteCommands + .Where(x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing") + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Completed") + .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); + } + + private bool IsInMemory() => _db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory"; + + private static PendingAiWriteCommand ToCommand(AiWriteCommandRecord record) => new( + record.Id, + record.UserId, + record.ToolName, + record.Arguments, + record.ExpiresAt); +} diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index c18cd2c..ce3b931 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -8,18 +8,33 @@ public sealed class PromptManager /// /// 获取指定 Agent 的 System Prompt /// - public string GetSystemPrompt(AgentType agentType) => agentType switch + public string GetSystemPrompt(AgentType agentType) { - AgentType.Default => DefaultPrompt, - AgentType.Consultation => ConsultationPrompt, - AgentType.Health => HealthDataPrompt, - AgentType.Diet => DietPrompt, - AgentType.Medication => MedicationPrompt, - AgentType.Report => ReportPrompt, - AgentType.Exercise => ExercisePrompt, - AgentType.Unified => UnifiedPrompt, - _ => DefaultPrompt - }; + var prompt = agentType switch + { + AgentType.Default => DefaultPrompt, + AgentType.Consultation => ConsultationPrompt, + AgentType.Health => HealthDataPrompt, + AgentType.Diet => DietPrompt, + AgentType.Medication => MedicationPrompt, + AgentType.Report => ReportPrompt, + AgentType.Exercise => ExercisePrompt, + AgentType.Unified => UnifiedPrompt, + _ => DefaultPrompt + }; + + return $"{prompt}\n\n{MedicalBoundaryRules}"; + } + + private const string MedicalBoundaryRules = """ + 医疗边界(必须遵守): + - 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。 + - 可以解释健康数据、报告指标和症状可能方向,但不要给出确定诊断,不要说“你就是/一定是/已经确诊”。 + - 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,必须建议咨询医生或药师。 + - 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。 + - 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。 + - 医疗相关分析末尾用一句自然的话提醒:以上为 AI 预分析,不能替代医生诊断和治疗建议。 + """; private const string DefaultPrompt = """ 你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。 @@ -38,15 +53,15 @@ public sealed class PromptManager """; private const string ConsultationPrompt = """ - 你是一个心血管内科医生助手,负责对心脏术后患者进行多轮问诊。 + 你是一个患者端 AI 预问诊助手,负责帮助心脏术后患者梳理症状和就医前信息。 规则: 1. 每次只问一个问题,不要一次问多个 2. 给出 2-3 个快捷选项让患者点击 3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析 4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸 - 5. 遇到以下情况建议转医生:血压持续>160/100、心率>120或<50 - 6. 所有分析末尾标注"以上为AI分析,具体请咨询医生" + 5. 遇到以下情况建议咨询医生:血压持续>160/100、心率>120或<50 + 6. 只给出初步分析和下一步建议,不作诊断结论 7. 问诊结束给出结构化小结 """; @@ -55,13 +70,13 @@ public sealed class PromptManager 规则: 1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重) - 2. 指标明确+数值明确→直接调用 record_health_data 录入 + 2. 指标明确+数值明确→调用 record_health_data 生成待确认写入命令,不要直接声称已经录入 3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")→多次调用 record_health_data - 4. 用户可以分时段说(如"早上血压120,下午血压130")→分别录入,recorded_at 参数用不同时间 + 4. 用户可以分时段说(如"早上血压120,下午血压130")→分别生成待确认命令,recorded_at 参数用不同时间 5. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?" 6. 时间模糊→取当前时间 7. 数值超出正常范围→附带异常提醒 - 8. 每次调用 record_health_data 后,继续调用下一个,全部录入完成后生成确认卡片格式的回复 + 8. 每次调用 record_health_data 后继续处理下一个;工具返回仅表示等待确认,必须提示用户点击确认卡片,不能说“录入成功” 正常值参考范围: - 收缩压 90-139 mmHg,舒张压 60-89 mmHg @@ -86,24 +101,24 @@ public sealed class PromptManager """; private const string MedicationPrompt = """ - 你是一个用药管理专家。 + 你是一个用药记录与提醒助手。 规则: 1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录 2. 解析用户口中的药品信息(药名/剂量/频次/时间) 3. "早饭后"等模糊时间→追问具体几点 - 4. 解析完成后展示确认卡片 + 4. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存 5. 处方拍照→提取药品信息→生成用药计划→让用户确认 - 6. 回答用药相关疑问 + 6. 可以解释常见用药注意事项,但不要建议用户自行新增、停用、更换药物或调整剂量 """; private const string ReportPrompt = """ - 你是一个医学报告解读专家。 + 你是一个患者端医学报告预解读助手。 规则: 1. 收到报告图片后,提取所有指标及其数值 2. 标注异常指标(偏高/偏低/正常) - 3. 给出初步分析 + 3. 给出初步分析和可能方向,不作诊断结论 4. 所有内容标注"AI预解读,待医生确认" 5. 图像类报告(彩超/CT)注明"需医生人工审阅" """; @@ -120,7 +135,7 @@ public sealed class PromptManager """; private const string UnifiedPrompt = """ - 你是一个全能的AI健康管家,为心脏术后康复患者提供全方位服务。 + 你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。 核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。 @@ -133,20 +148,21 @@ public sealed class PromptManager 6. 历史数据查询 → 调用 query_health_records 运动计划参数格式(manage_exercise): - - action="create", week_start_date="今天日期(YYYY-MM-DD)" - - items: [{"day_of_week":0-6(周日=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...] + - action="create", start_date="开始日期(YYYY-MM-DD,默认今天)" + - duration_days=连续天数,exercise_type="散步",duration_minutes=30,reminder_time="19:00" - 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字 - 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天 - 用户说"坚持X天/月/周"则创建对应天数 - - items 数量必须等于持续天数 + - 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目 用药管理参数格式(manage_medication): - action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7 规则: - 用户可能一句话涉及多个领域,全部处理 - - 数值明确+指标明确→直接录入,不要追问 - - 录入完成后生成确认卡片 + - 数值明确+指标明确→直接生成待确认命令,不要追问 + - 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库 + - 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认 - 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医 - 回复语气温暖、专业 """; diff --git a/backend/src/Health.Infrastructure/Admin/AdminService.cs b/backend/src/Health.Infrastructure/Admin/AdminService.cs new file mode 100644 index 0000000..be4599b --- /dev/null +++ b/backend/src/Health.Infrastructure/Admin/AdminService.cs @@ -0,0 +1,90 @@ +using Health.Application.Admin; + +namespace Health.Infrastructure.Admin; + +public sealed class AdminService(AppDbContext db) : IAdminService +{ + private readonly AppDbContext _db = db; + + public async Task ListDoctorsAsync(CancellationToken ct) + { + var doctors = await _db.Doctors.AsNoTracking().OrderBy(x => x.CreatedAt) + .Select(x => new { x.Id, x.Name, x.Title, x.Department, x.Phone, x.ProfessionalDirection, x.IsActive, x.AvatarUrl, x.Introduction, x.CreatedAt }) + .ToListAsync(ct); + return Ok(doctors); + } + + public async Task AddDoctorAsync(AddDoctorCommand command, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空"); + if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空"); + var doctor = new Doctor + { + Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department, + Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow, + }; + _db.Doctors.Add(doctor); + if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct)) + { + var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.Name, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; + _db.Users.Add(user); + _db.DoctorProfiles.Add(new DoctorProfile + { + Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name, + Title = command.Title, Department = command.Department, IsActive = true, + CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }); + } + await _db.SaveChangesAsync(ct); + return Ok(new { doctor.Id, doctor.Name }); + } + + public async Task UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct) + { + var doctor = await _db.Doctors.FindAsync([id], ct); + if (doctor == null) return Error(404, "医生不存在"); + if (command.Name != null) doctor.Name = command.Name; + if (command.Title != null) doctor.Title = command.Title; + if (command.Department != null) doctor.Department = command.Department; + if (command.Phone != null) doctor.Phone = command.Phone; + if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection; + await _db.SaveChangesAsync(ct); + return Ok(new { doctor.Id }); + } + + public async Task ToggleDoctorAsync(Guid id, CancellationToken ct) + { + var doctor = await _db.Doctors.FindAsync([id], ct); + if (doctor == null) return Error(404, "医生不存在"); + doctor.IsActive = !doctor.IsActive; + await _db.SaveChangesAsync(ct); + return Ok(new { doctor.Id, doctor.IsActive }); + } + + public async Task DeleteDoctorAsync(Guid id, CancellationToken ct) + { + var doctor = await _db.Doctors.FindAsync([id], ct); + if (doctor == null) return Error(404, "医生不存在"); + await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct); + _db.Doctors.Remove(doctor); + await _db.SaveChangesAsync(ct); + return Ok(new { success = true }); + } + + public async Task ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct) + { + page = Math.Max(page, 1); + pageSize = Math.Clamp(pageSize, 1, 100); + var query = _db.Users.AsNoTracking().Where(x => x.Role == "User"); + if (!string.IsNullOrWhiteSpace(search)) + query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search)); + var total = await query.CountAsync(ct); + var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null }) + .ToListAsync(ct); + return Ok(new { patients, total, page, pageSize }); + } + + private static AdminResult Ok(object data) => new(0, data); + private static AdminResult Error(int code, string message) => new(code, null, message); +} diff --git a/backend/src/Health.Infrastructure/Auth/AuthService.cs b/backend/src/Health.Infrastructure/Auth/AuthService.cs new file mode 100644 index 0000000..f8822fe --- /dev/null +++ b/backend/src/Health.Infrastructure/Auth/AuthService.cs @@ -0,0 +1,115 @@ +using Health.Application.Auth; +using Health.Infrastructure.Services; + +namespace Health.Infrastructure.Auth; + +public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService +{ + private const string AdminPhone = "12345678910"; + private const string AdminSmsCode = "000000"; + private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + private readonly AppDbContext _db = db; + private readonly JwtProvider _jwt = jwt; + private readonly SmsService _sms = sms; + + public async Task SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct) + { + var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode(); + await _db.VerificationCodes.AddAsync(new VerificationCode + { + Id = Guid.NewGuid(), Phone = phone, Code = code, + ExpiresAt = DateTime.UtcNow.AddMinutes(5), + }, ct); + await _db.SaveChangesAsync(ct); + if (phone != AdminPhone) await _sms.SendCodeAsync(phone, code); + return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null }); + } + + public async Task RegisterAsync(RegisterCommand command, CancellationToken ct) + { + var code = await TakeCodeAsync(command.Phone, command.SmsCode, ct); + if (code == null) return Error(40001, "验证码错误或已过期"); + if (await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct)) return Error(40002, "该手机号已注册,请直接登录"); + if (string.IsNullOrWhiteSpace(command.Name)) return Error(40003, "请输入姓名"); + if (!await _db.Doctors.AnyAsync(x => x.Id == command.DoctorId && x.IsActive, ct)) return Error(40004, "所选医生不存在或已停诊"); + + code.IsUsed = true; + var user = new User + { + Id = Guid.NewGuid(), Phone = command.Phone, Role = "User", Name = command.Name, + DoctorId = command.DoctorId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }; + _db.Users.Add(user); + _db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id }); + _db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id }); + var tokens = AddTokens(user.Id, user.Phone, user.Role); + await _db.SaveChangesAsync(ct); + return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { user.Id, user.Phone, user.Role, isNew = true } }); + } + + public async Task LoginAsync(string phone, string smsCode, CancellationToken ct) + { + var code = await TakeCodeAsync(phone, smsCode, ct); + if (code == null) return Error(40001, "验证码错误或已过期"); + code.IsUsed = true; + + if (phone == AdminPhone) + { + var tokens = AddTokens(AdminId, AdminPhone, "Admin"); + await _db.SaveChangesAsync(ct); + return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { id = AdminId, phone = AdminPhone, role = "Admin", name = "管理员", isNew = false } }); + } + + var user = await _db.Users.FirstOrDefaultAsync(x => x.Phone == phone, ct); + if (user == null) return Error(40004, "该手机号未注册,请先注册"); + user.UpdatedAt = DateTime.UtcNow; + var userTokens = AddTokens(user.Id, user.Phone, user.Role); + await _db.SaveChangesAsync(ct); + return new AuthResult(0, new + { + userTokens.accessToken, userTokens.refreshToken, + user = new { user.Id, user.Phone, user.Role, user.Name, user.Gender, user.AvatarUrl, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), isNew = false } + }); + } + + public async Task RefreshAsync(string refreshToken, CancellationToken ct) + { + var oldToken = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken && !x.IsRevoked, ct); + if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow) return Error(40002, "登录已过期,请重新登录"); + oldToken.IsRevoked = true; + + if (oldToken.UserId == AdminId) + { + var tokens = AddTokens(AdminId, AdminPhone, "Admin"); + await _db.SaveChangesAsync(ct); + return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } }); + } + + var user = await _db.Users.FindAsync([oldToken.UserId], ct); + if (user == null) return Error(40002, "用户不存在"); + var userTokens = AddTokens(user.Id, user.Phone, user.Role); + await _db.SaveChangesAsync(ct); + return new AuthResult(0, new { userTokens.accessToken, userTokens.refreshToken, user = new { user.Role } }); + } + + public async Task LogoutAsync(string refreshToken, CancellationToken ct) + { + var token = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken, ct); + if (token != null) token.IsRevoked = true; + await _db.SaveChangesAsync(ct); + } + + private Task TakeCodeAsync(string phone, string code, CancellationToken ct) => + _db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed) + .OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct); + + private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role) + { + var access = _jwt.GenerateAccessToken(userId, phone, role); + var refresh = _jwt.GenerateRefreshToken(); + _db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) }); + return (access, refresh); + } + + private static AuthResult Error(int code, string message) => new(code, null, message); +} diff --git a/backend/src/Health.Infrastructure/Calendars/EfCalendarRepository.cs b/backend/src/Health.Infrastructure/Calendars/EfCalendarRepository.cs new file mode 100644 index 0000000..67852ff --- /dev/null +++ b/backend/src/Health.Infrastructure/Calendars/EfCalendarRepository.cs @@ -0,0 +1,27 @@ +using Health.Application.Calendars; + +namespace Health.Infrastructure.Calendars; + +public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository +{ + private readonly AppDbContext _db = db; + + public async Task GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) + { + var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + + var medications = await _db.Medications + .Where(m => m.UserId == userId && m.IsActive) + .ToListAsync(ct); + var exercisePlans = await _db.ExercisePlans + .Include(p => p.Items) + .Where(p => p.UserId == userId && p.StartDate < end && p.EndDate >= start) + .ToListAsync(ct); + var followUps = await _db.FollowUps + .Where(f => f.UserId == userId && f.ScheduledAt >= startUtc && f.ScheduledAt < endUtc) + .ToListAsync(ct); + + return new CalendarDataSnapshot(medications, exercisePlans, followUps); + } +} diff --git a/backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs b/backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs new file mode 100644 index 0000000..442d66f --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs @@ -0,0 +1,166 @@ +using Microsoft.Extensions.Logging; + +namespace Health.Infrastructure.Data; + +public sealed class DatabaseSchemaMigrator(AppDbContext db, ILogger logger) +{ + private readonly AppDbContext _db = db; + private readonly ILogger _logger = logger; + + public async Task ApplyAsync(CancellationToken ct = default) + { + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + await _db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS "__AppSchemaMigrations" ( + "Id" varchar(128) PRIMARY KEY, + "AppliedAt" timestamptz NOT NULL + ); + + CREATE TABLE IF NOT EXISTS "AiWriteCommands" ( + "Id" uuid PRIMARY KEY, + "UserId" uuid NOT NULL, + "ToolName" varchar(64) NOT NULL, + "Arguments" jsonb NOT NULL, + "Status" varchar(32) NOT NULL, + "ExpiresAt" timestamptz NOT NULL, + "CreatedAt" timestamptz NOT NULL, + "UpdatedAt" timestamptz NOT NULL + ); + CREATE INDEX IF NOT EXISTS "IX_AiWriteCommands_UserId_Status_ExpiresAt" + ON "AiWriteCommands" ("UserId", "Status", "ExpiresAt"); + + CREATE TABLE IF NOT EXISTS "ReportAnalysisTasks" ( + "Id" uuid PRIMARY KEY, + "ReportId" uuid NOT NULL, + "FilePath" text NOT NULL, + "Status" varchar(32) NOT NULL, + "Attempts" integer NOT NULL, + "AvailableAt" timestamptz NOT NULL, + "LastError" text NULL, + "CreatedAt" timestamptz NOT NULL, + "UpdatedAt" timestamptz NOT NULL + ); + CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_Status_AvailableAt" + ON "ReportAnalysisTasks" ("Status", "AvailableAt"); + CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_ReportId" + ON "ReportAnalysisTasks" ("ReportId"); + + CREATE TABLE IF NOT EXISTS "DietImageAnalysisTasks" ( + "Id" uuid PRIMARY KEY, + "FilePaths" jsonb NOT NULL, + "Status" varchar(32) NOT NULL, + "Attempts" integer NOT NULL, + "AvailableAt" timestamptz NOT NULL, + "ResultCode" integer NULL, + "ResultData" text NULL, + "ResultMessage" text NULL, + "LastError" text NULL, + "CreatedAt" timestamptz NOT NULL, + "UpdatedAt" timestamptz NOT NULL + ); + CREATE INDEX IF NOT EXISTS "IX_DietImageAnalysisTasks_Status_AvailableAt" + ON "DietImageAnalysisTasks" ("Status", "AvailableAt"); + + CREATE TABLE IF NOT EXISTS "MedicationReminderTasks" ( + "Id" uuid PRIMARY KEY, + "UserId" uuid NOT NULL, + "MedicationId" uuid NOT NULL, + "Name" text NOT NULL, + "Dosage" text NULL, + "ScheduledDate" date NOT NULL, + "ScheduledTime" time without time zone NOT NULL, + "Status" varchar(32) NOT NULL, + "Attempts" integer NOT NULL, + "AvailableAt" timestamptz NOT NULL, + "LastError" text NULL, + "CreatedAt" timestamptz NOT NULL, + "UpdatedAt" timestamptz NOT NULL + ); + CREATE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_Status_AvailableAt" + ON "MedicationReminderTasks" ("Status", "AvailableAt"); + CREATE UNIQUE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_MedicationId_ScheduledDate_ScheduledTime" + ON "MedicationReminderTasks" ("MedicationId", "ScheduledDate", "ScheduledTime"); + + CREATE TABLE IF NOT EXISTS "NotificationOutbox" ( + "Id" uuid PRIMARY KEY, + "UserId" uuid NOT NULL, + "SourceTaskId" uuid NOT NULL, + "Type" varchar(64) NOT NULL, + "Payload" jsonb NOT NULL, + "Status" varchar(32) NOT NULL, + "Attempts" integer NOT NULL, + "AvailableAt" timestamptz NOT NULL, + "LastError" text NULL, + "CreatedAt" timestamptz NOT NULL, + "UpdatedAt" timestamptz NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS "IX_NotificationOutbox_SourceTaskId" + ON "NotificationOutbox" ("SourceTaskId"); + CREATE INDEX IF NOT EXISTS "IX_NotificationOutbox_Status_AvailableAt" + ON "NotificationOutbox" ("Status", "AvailableAt"); + + ALTER TABLE IF EXISTS "ExercisePlans" + ADD COLUMN IF NOT EXISTS "StartDate" date NULL, + ADD COLUMN IF NOT EXISTS "EndDate" date NULL, + ADD COLUMN IF NOT EXISTS "ReminderTime" time without time zone NOT NULL DEFAULT TIME '19:00'; + ALTER TABLE IF EXISTS "ExercisePlanItems" + ADD COLUMN IF NOT EXISTS "ScheduledDate" date NULL; + + DO $exercise_migration$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'ExercisePlans' AND column_name = 'WeekStartDate' + ) THEN + UPDATE "ExercisePlans" + SET "StartDate" = COALESCE("StartDate", "WeekStartDate") + WHERE "StartDate" IS NULL; + + WITH ranked AS ( + SELECT i."Id", p."StartDate", + ROW_NUMBER() OVER (PARTITION BY i."PlanId" ORDER BY i."Id") - 1 AS day_offset + FROM "ExercisePlanItems" i + JOIN "ExercisePlans" p ON p."Id" = i."PlanId" + WHERE i."ScheduledDate" IS NULL + ) + UPDATE "ExercisePlanItems" i + SET "ScheduledDate" = ranked."StartDate" + ranked.day_offset::integer + FROM ranked + WHERE i."Id" = ranked."Id"; + + UPDATE "ExercisePlans" p + SET "EndDate" = COALESCE( + p."EndDate", + (SELECT MAX(i."ScheduledDate") FROM "ExercisePlanItems" i WHERE i."PlanId" = p."Id"), + p."StartDate" + ) + WHERE p."EndDate" IS NULL; + END IF; + END $exercise_migration$; + + ALTER TABLE IF EXISTS "ExercisePlans" + ALTER COLUMN "StartDate" SET NOT NULL, + ALTER COLUMN "EndDate" SET NOT NULL; + ALTER TABLE IF EXISTS "ExercisePlanItems" + ALTER COLUMN "ScheduledDate" SET NOT NULL; + CREATE INDEX IF NOT EXISTS "IX_ExercisePlans_UserId_StartDate_EndDate" + ON "ExercisePlans" ("UserId", "StartDate", "EndDate"); + CREATE UNIQUE INDEX IF NOT EXISTS "IX_ExercisePlanItems_PlanId_ScheduledDate" + ON "ExercisePlanItems" ("PlanId", "ScheduledDate"); + + INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt") + VALUES ('20260619_01_persistent_ai_commands_and_report_tasks', now()) + ON CONFLICT ("Id") DO NOTHING; + + INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt") + VALUES ('20260619_02_persistent_diet_and_medication_tasks', now()) + ON CONFLICT ("Id") DO NOTHING; + + INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt") + VALUES ('20260620_03_date_based_exercise_plans', now()) + ON CONFLICT ("Id") DO NOTHING; + """, ct); + await transaction.CommitAsync(ct); + _logger.LogInformation("数据库结构迁移已检查: 20260620_03"); + } +} diff --git a/backend/src/Health.Infrastructure/Data/Records/AiWriteCommandRecord.cs b/backend/src/Health.Infrastructure/Data/Records/AiWriteCommandRecord.cs new file mode 100644 index 0000000..0610b69 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Records/AiWriteCommandRecord.cs @@ -0,0 +1,13 @@ +namespace Health.Infrastructure.Data.Records; + +public sealed class AiWriteCommandRecord +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string ToolName { get; set; } = string.Empty; + public string Arguments { get; set; } = string.Empty; + public string Status { get; set; } = "Pending"; + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/Health.Infrastructure/Data/Records/DietImageAnalysisTaskRecord.cs b/backend/src/Health.Infrastructure/Data/Records/DietImageAnalysisTaskRecord.cs new file mode 100644 index 0000000..60148ca --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Records/DietImageAnalysisTaskRecord.cs @@ -0,0 +1,16 @@ +namespace Health.Infrastructure.Data.Records; + +public sealed class DietImageAnalysisTaskRecord +{ + public Guid Id { get; set; } + public string FilePaths { get; set; } = "[]"; + public string Status { get; set; } = "Pending"; + public int Attempts { get; set; } + public DateTime AvailableAt { get; set; } = DateTime.UtcNow; + public int? ResultCode { get; set; } + public string? ResultData { get; set; } + public string? ResultMessage { get; set; } + public string? LastError { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/Health.Infrastructure/Data/Records/MedicationReminderTaskRecord.cs b/backend/src/Health.Infrastructure/Data/Records/MedicationReminderTaskRecord.cs new file mode 100644 index 0000000..66fec45 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Records/MedicationReminderTaskRecord.cs @@ -0,0 +1,18 @@ +namespace Health.Infrastructure.Data.Records; + +public sealed class MedicationReminderTaskRecord +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public Guid MedicationId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Dosage { get; set; } + public DateOnly ScheduledDate { get; set; } + public TimeOnly ScheduledTime { get; set; } + public string Status { get; set; } = "Pending"; + public int Attempts { get; set; } + public DateTime AvailableAt { get; set; } = DateTime.UtcNow; + public string? LastError { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/Health.Infrastructure/Data/Records/NotificationOutboxRecord.cs b/backend/src/Health.Infrastructure/Data/Records/NotificationOutboxRecord.cs new file mode 100644 index 0000000..e5fa467 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Records/NotificationOutboxRecord.cs @@ -0,0 +1,16 @@ +namespace Health.Infrastructure.Data.Records; + +public sealed class NotificationOutboxRecord +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public Guid SourceTaskId { get; set; } + public string Type { get; set; } = string.Empty; + public string Payload { get; set; } = "{}"; + public string Status { get; set; } = "AwaitingProvider"; + public int Attempts { get; set; } + public DateTime AvailableAt { get; set; } = DateTime.UtcNow; + public string? LastError { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/Health.Infrastructure/Data/Records/ReportAnalysisTaskRecord.cs b/backend/src/Health.Infrastructure/Data/Records/ReportAnalysisTaskRecord.cs new file mode 100644 index 0000000..ce9b3b5 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Records/ReportAnalysisTaskRecord.cs @@ -0,0 +1,14 @@ +namespace Health.Infrastructure.Data.Records; + +public sealed class ReportAnalysisTaskRecord +{ + public Guid Id { get; set; } + public Guid ReportId { get; set; } + public string FilePath { get; set; } = string.Empty; + public string Status { get; set; } = "Pending"; + public int Attempts { get; set; } + public DateTime AvailableAt { get; set; } = DateTime.UtcNow; + public string? LastError { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/Health.Infrastructure/Data/app_db_context.cs b/backend/src/Health.Infrastructure/Data/app_db_context.cs index 2edf472..310ddb3 100644 --- a/backend/src/Health.Infrastructure/Data/app_db_context.cs +++ b/backend/src/Health.Infrastructure/Data/app_db_context.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Health.Infrastructure.Data.Records; namespace Health.Infrastructure.Data; @@ -33,6 +34,11 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon public DbSet VerificationCodes => Set(); public DbSet NotificationPreferences => Set(); public DbSet DeviceTokens => Set(); + public DbSet AiWriteCommands => Set(); + public DbSet ReportAnalysisTasks => Set(); + public DbSet DietImageAnalysisTasks => Set(); + public DbSet MedicationReminderTasks => Set(); + public DbSet NotificationOutbox => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -95,7 +101,12 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon // ---- ExercisePlan ---- builder.Entity(e => { - e.HasIndex(p => new { p.UserId, p.WeekStartDate }).IsDescending(false, true); + e.HasIndex(p => new { p.UserId, p.StartDate, p.EndDate }); + }); + + builder.Entity(e => + { + e.HasIndex(i => new { i.PlanId, i.ScheduledDate }).IsUnique(); }); // ---- Report ---- @@ -148,5 +159,48 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon { e.HasIndex(a => a.UserId).IsUnique(); }); + + builder.Entity(e => + { + e.ToTable("AiWriteCommands"); + e.HasIndex(x => new { x.UserId, x.Status, x.ExpiresAt }); + e.Property(x => x.ToolName).HasMaxLength(64); + e.Property(x => x.Status).HasMaxLength(32); + e.Property(x => x.Arguments).HasColumnType("jsonb"); + }); + + builder.Entity(e => + { + e.ToTable("ReportAnalysisTasks"); + e.HasIndex(x => new { x.Status, x.AvailableAt }); + e.HasIndex(x => x.ReportId); + e.Property(x => x.Status).HasMaxLength(32); + }); + + builder.Entity(e => + { + e.ToTable("DietImageAnalysisTasks"); + e.HasIndex(x => new { x.Status, x.AvailableAt }); + e.Property(x => x.FilePaths).HasColumnType("jsonb"); + e.Property(x => x.Status).HasMaxLength(32); + }); + + builder.Entity(e => + { + e.ToTable("MedicationReminderTasks"); + e.HasIndex(x => new { x.Status, x.AvailableAt }); + e.HasIndex(x => new { x.MedicationId, x.ScheduledDate, x.ScheduledTime }).IsUnique(); + e.Property(x => x.Status).HasMaxLength(32); + }); + + builder.Entity(e => + { + e.ToTable("NotificationOutbox"); + e.HasIndex(x => x.SourceTaskId).IsUnique(); + e.HasIndex(x => new { x.Status, x.AvailableAt }); + e.Property(x => x.Type).HasMaxLength(64); + e.Property(x => x.Status).HasMaxLength(32); + e.Property(x => x.Payload).HasColumnType("jsonb"); + }); } } diff --git a/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs b/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs index 393e0de..decfaf0 100644 --- a/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs +++ b/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs @@ -137,14 +137,14 @@ public static class DevDataSeeder // ---- 运动计划 ---- var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1)); - var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday }; - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) }); - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 }); - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, ExerciseType = "散步", DurationMinutes = 30 }); - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 3, IsRestDay = true }); - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 4, ExerciseType = "太极", DurationMinutes = 40 }); - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 5, IsRestDay = true }); - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 6, ExerciseType = "散步", DurationMinutes = 30 }); + var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(6), ReminderTime = new TimeOnly(19, 0) }; + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) }); + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 }); + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), ExerciseType = "散步", DurationMinutes = 30 }); + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(3), IsRestDay = true }); + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(4), ExerciseType = "太极", DurationMinutes = 40 }); + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(5), IsRestDay = true }); + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(6), ExerciseType = "散步", DurationMinutes = 30 }); db.ExercisePlans.Add(plan); // ---- 饮食记录 ---- diff --git a/backend/src/Health.Infrastructure/Diets/DietImageAnalysisCoordinator.cs b/backend/src/Health.Infrastructure/Diets/DietImageAnalysisCoordinator.cs new file mode 100644 index 0000000..287777a --- /dev/null +++ b/backend/src/Health.Infrastructure/Diets/DietImageAnalysisCoordinator.cs @@ -0,0 +1,59 @@ +using Health.Application.Diets; + +namespace Health.Infrastructure.Diets; + +public sealed class DietImageAnalysisCoordinator(IDietImageAnalysisQueue queue) : IDietImageAnalysisCoordinator +{ + private const long MaxImageBytes = 20 * 1024 * 1024; + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".heic" + }; + + private readonly IDietImageAnalysisQueue _queue = queue; + + public async Task AnalyzeAsync(IReadOnlyList files, CancellationToken ct) + { + if (files.Count == 0) + return new DietImageAnalysisResult(false, 40001, null, "请上传至少一张图片"); + + var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "diet-analysis"); + Directory.CreateDirectory(uploadsDir); + var paths = new List(); + var handedOff = false; + + try + { + foreach (var file in files) + { + if (file.Length <= 0 || file.Length > MaxImageBytes) + return new DietImageAnalysisResult(false, 40001, null, "文件大小超过 20MB 限制"); + + var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!AllowedExtensions.Contains(extension)) + return new DietImageAnalysisResult(false, 40001, null, "不支持的图片格式,仅支持 JPG/PNG/HEIC"); + + var filePath = Path.Combine(uploadsDir, $"{Guid.NewGuid()}{extension}"); + await using var stream = new FileStream(filePath, FileMode.CreateNew); + await file.Content.CopyToAsync(stream, ct); + paths.Add(filePath); + } + + var jobId = await _queue.EnqueueAsync(paths, ct); + handedOff = true; + return await _queue.WaitForResultAsync(jobId, ct); + } + catch + { + if (!handedOff) + { + foreach (var path in paths) + { + try { if (File.Exists(path)) File.Delete(path); } + catch { } + } + } + throw; + } + } +} diff --git a/backend/src/Health.Infrastructure/Diets/DietImageAnalysisQueue.cs b/backend/src/Health.Infrastructure/Diets/DietImageAnalysisQueue.cs new file mode 100644 index 0000000..2605738 --- /dev/null +++ b/backend/src/Health.Infrastructure/Diets/DietImageAnalysisQueue.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Health.Application.Diets; +using Health.Infrastructure.Data.Records; + +namespace Health.Infrastructure.Diets; + +public sealed class DietImageAnalysisQueue(AppDbContext db) : IDietImageAnalysisQueue +{ + private const int MaxAttempts = 3; + private readonly AppDbContext _db = db; + + public async Task EnqueueAsync(IReadOnlyList filePaths, CancellationToken ct) + { + var now = DateTime.UtcNow; + var task = new DietImageAnalysisTaskRecord + { + Id = Guid.NewGuid(), + FilePaths = JsonSerializer.Serialize(filePaths), + AvailableAt = now, + CreatedAt = now, + UpdatedAt = now, + }; + await _db.DietImageAnalysisTasks.AddAsync(task, ct); + await _db.SaveChangesAsync(ct); + return task.Id; + } + + public Task RecoverStaleAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + return _db.DietImageAnalysisTasks + .Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Pending") + .SetProperty(x => x.AvailableAt, now) + .SetProperty(x => x.UpdatedAt, now), ct); + } + + public async Task TryTakeAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + var candidate = await _db.DietImageAnalysisTasks.AsNoTracking() + .Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts) + .OrderBy(x => x.CreatedAt) + .FirstOrDefaultAsync(ct); + if (candidate == null) return null; + + var claimed = await _db.DietImageAnalysisTasks + .Where(x => x.Id == candidate.Id && x.Status == "Pending") + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Processing") + .SetProperty(x => x.Attempts, x => x.Attempts + 1) + .SetProperty(x => x.UpdatedAt, now), ct); + if (claimed == 0) return null; + + var paths = JsonSerializer.Deserialize>(candidate.FilePaths) ?? []; + return new DietImageAnalysisJob(candidate.Id, paths); + } + + public async Task WaitForResultAsync(Guid jobId, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + var task = await _db.DietImageAnalysisTasks.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == jobId, ct); + if (task == null) + return new DietImageAnalysisResult(false, 40004, null, "饮食识别任务不存在"); + if (task.Status is "Completed" or "Failed") + return new DietImageAnalysisResult( + task.Status == "Completed", + task.ResultCode ?? 50001, + task.ResultData, + task.ResultMessage); + await Task.Delay(TimeSpan.FromMilliseconds(500), ct); + } + + throw new OperationCanceledException(ct); + } + + public Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct) + { + var now = DateTime.UtcNow; + return _db.DietImageAnalysisTasks.Where(x => x.Id == jobId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, result.Success ? "Completed" : "Failed") + .SetProperty(x => x.ResultCode, result.Code) + .SetProperty(x => x.ResultData, result.Data) + .SetProperty(x => x.ResultMessage, result.Message) + .SetProperty(x => x.UpdatedAt, now), ct); + } + + public async Task RetryAsync(Guid jobId, string error, CancellationToken ct) + { + var task = await _db.DietImageAnalysisTasks.FirstOrDefaultAsync(x => x.Id == jobId, ct); + if (task == null) return true; + + task.LastError = error.Length > 2000 ? error[..2000] : error; + task.UpdatedAt = DateTime.UtcNow; + if (task.Attempts >= MaxAttempts) + { + task.Status = "Failed"; + task.ResultCode = 50001; + task.ResultMessage = $"食物识别失败: {error}"; + } + else + { + task.Status = "Pending"; + task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5); + } + await _db.SaveChangesAsync(ct); + return task.Status == "Failed"; + } +} diff --git a/backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs b/backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs new file mode 100644 index 0000000..2cc9583 --- /dev/null +++ b/backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs @@ -0,0 +1,42 @@ +using Health.Application.Diets; +using Health.Infrastructure.AI; + +namespace Health.Infrastructure.Diets; + +public sealed class DietImageAnalyzer(VisionClient vision) : IDietImageAnalyzer +{ + private readonly VisionClient _vision = vision; + + public async Task AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct) + { + var imageUrls = new List(); + foreach (var filePath in job.FilePaths) + { + if (!File.Exists(filePath)) + return new DietImageAnalysisResult(false, 40004, null, "待识别图片不存在"); + + var bytes = await File.ReadAllBytesAsync(filePath, ct); + var base64 = Convert.ToBase64String(bytes); + if (base64.Length > 7 * 1024 * 1024) + return new DietImageAnalysisResult(false, 40001, null, "图片过大,请压缩后重新上传"); + + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + var mime = extension switch + { + ".png" => "image/png", + ".heic" => "image/heic", + _ => "image/jpeg" + }; + imageUrls.Add($"data:{mime};base64,{base64}"); + } + + var prompt = """ + 识别图片中的食物和饮品,返回JSON数组: + [{"name":"名称","portion":"份量","calories":热量}] + 只返回JSON,不要其他内容。 + """; + var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct); + var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]"; + return new DietImageAnalysisResult(true, 0, result, null); + } +} diff --git a/backend/src/Health.Infrastructure/Diets/EfDietRepository.cs b/backend/src/Health.Infrastructure/Diets/EfDietRepository.cs new file mode 100644 index 0000000..b74beba --- /dev/null +++ b/backend/src/Health.Infrastructure/Diets/EfDietRepository.cs @@ -0,0 +1,33 @@ +using Health.Application.Diets; + +namespace Health.Infrastructure.Diets; + +public sealed class EfDietRepository(AppDbContext db) : IDietRepository +{ + private readonly AppDbContext _db = db; + + public async Task> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct) + { + var query = _db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId); + + if (recordedAt.HasValue) + query = query.Where(r => r.RecordedAt == recordedAt.Value); + + if (mealType.HasValue) + query = query.Where(r => r.MealType == mealType.Value); + + return await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct); + } + + public Task GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct) => + _db.DietRecords.FirstOrDefaultAsync(r => r.Id == recordId && r.UserId == userId, ct); + + public async Task AddAsync(DietRecord record, CancellationToken ct) => + await _db.DietRecords.AddAsync(record, ct); + + public void Delete(DietRecord record) => + _db.DietRecords.Remove(record); + + public Task SaveChangesAsync(CancellationToken ct) => + _db.SaveChangesAsync(ct); +} diff --git a/backend/src/Health.Infrastructure/Exercises/EfExerciseRepository.cs b/backend/src/Health.Infrastructure/Exercises/EfExerciseRepository.cs new file mode 100644 index 0000000..2f3d101 --- /dev/null +++ b/backend/src/Health.Infrastructure/Exercises/EfExerciseRepository.cs @@ -0,0 +1,44 @@ +using Health.Application.Exercises; + +namespace Health.Infrastructure.Exercises; + +public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository +{ + private readonly AppDbContext _db = db; + + public async Task> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => + await _db.ExercisePlans.Include(p => p.Items) + .Where(p => p.UserId == userId && p.StartDate <= date && p.EndDate >= date) + .OrderByDescending(p => p.StartDate) + .ToListAsync(ct); + + public async Task> ListAsync(Guid userId, int limit, CancellationToken ct) => + await _db.ExercisePlans.Include(p => p.Items) + .Where(p => p.UserId == userId) + .OrderByDescending(p => p.StartDate) + .Take(limit) + .ToListAsync(ct); + + public Task GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => + _db.ExercisePlans.Include(p => p.Items) + .FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct); + + public Task GetLatestAsync(Guid userId, CancellationToken ct) => + _db.ExercisePlans.Include(p => p.Items) + .Where(p => p.UserId == userId) + .OrderByDescending(p => p.StartDate) + .FirstOrDefaultAsync(ct); + + public Task GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => + _db.ExercisePlanItems.Include(i => i.Plan) + .FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct); + + public async Task AddAsync(ExercisePlan plan, CancellationToken ct) => + await _db.ExercisePlans.AddAsync(plan, ct); + + public void Delete(ExercisePlan plan) => + _db.ExercisePlans.Remove(plan); + + public Task SaveChangesAsync(CancellationToken ct) => + _db.SaveChangesAsync(ct); +} diff --git a/backend/src/Health.Infrastructure/Exercises/ExerciseReminderProducer.cs b/backend/src/Health.Infrastructure/Exercises/ExerciseReminderProducer.cs new file mode 100644 index 0000000..b970cb3 --- /dev/null +++ b/backend/src/Health.Infrastructure/Exercises/ExerciseReminderProducer.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using Health.Application.Exercises; +using Health.Infrastructure.Data.Records; + +namespace Health.Infrastructure.Exercises; + +public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminderProducer +{ + private readonly AppDbContext _db = db; + + public async Task ProduceDueAsync(DateTime utcNow, CancellationToken ct) + { + var beijingNow = utcNow.AddHours(8); + var today = DateOnly.FromDateTime(beijingNow); + var currentTime = TimeOnly.FromDateTime(beijingNow); + var dueItems = await _db.ExercisePlanItems.AsNoTracking() + .Include(x => x.Plan) + .Where(x => x.ScheduledDate == today && !x.IsCompleted && !x.IsRestDay && x.Plan.ReminderTime <= currentTime) + .ToListAsync(ct); + var sourceIds = dueItems.Select(x => x.Id).ToList(); + var existing = await _db.NotificationOutbox.AsNoTracking() + .Where(x => sourceIds.Contains(x.SourceTaskId)) + .Select(x => x.SourceTaskId) + .ToListAsync(ct); + var existingSet = existing.ToHashSet(); + var now = DateTime.UtcNow; + foreach (var item in dueItems.Where(x => !existingSet.Contains(x.Id))) + { + await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord + { + Id = Guid.NewGuid(), UserId = item.Plan.UserId, SourceTaskId = item.Id, + Type = "ExerciseReminder", + Payload = JsonSerializer.Serialize(new + { + item.ExerciseType, item.DurationMinutes, item.ScheduledDate, + ReminderTime = item.Plan.ReminderTime, + }), + Status = "AwaitingProvider", AvailableAt = now, CreatedAt = now, UpdatedAt = now, + }, ct); + } + if (_db.ChangeTracker.HasChanges()) await _db.SaveChangesAsync(ct); + return dueItems.Count - existingSet.Count; + } +} diff --git a/backend/src/Health.Infrastructure/Health.Infrastructure.csproj b/backend/src/Health.Infrastructure/Health.Infrastructure.csproj index 508b7dc..511c462 100644 --- a/backend/src/Health.Infrastructure/Health.Infrastructure.csproj +++ b/backend/src/Health.Infrastructure/Health.Infrastructure.csproj @@ -1,12 +1,14 @@  + + diff --git a/backend/src/Health.Infrastructure/HealthArchives/EfHealthArchiveRepository.cs b/backend/src/Health.Infrastructure/HealthArchives/EfHealthArchiveRepository.cs new file mode 100644 index 0000000..ef6ab29 --- /dev/null +++ b/backend/src/Health.Infrastructure/HealthArchives/EfHealthArchiveRepository.cs @@ -0,0 +1,17 @@ +using Health.Application.HealthArchives; + +namespace Health.Infrastructure.HealthArchives; + +public sealed class EfHealthArchiveRepository(AppDbContext db) : IHealthArchiveRepository +{ + private readonly AppDbContext _db = db; + + public Task GetByUserIdAsync(Guid userId, CancellationToken ct) => + _db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); + + public async Task AddAsync(HealthArchive archive, CancellationToken ct) => + await _db.HealthArchives.AddAsync(archive, ct); + + public Task SaveChangesAsync(CancellationToken ct) => + _db.SaveChangesAsync(ct); +} diff --git a/backend/src/Health.Infrastructure/HealthRecords/EfHealthRecordRepository.cs b/backend/src/Health.Infrastructure/HealthRecords/EfHealthRecordRepository.cs new file mode 100644 index 0000000..e80c4ab --- /dev/null +++ b/backend/src/Health.Infrastructure/HealthRecords/EfHealthRecordRepository.cs @@ -0,0 +1,45 @@ +using Health.Application.HealthRecords; + +namespace Health.Infrastructure.HealthRecords; + +public sealed class EfHealthRecordRepository(AppDbContext db) : IHealthRecordRepository +{ + private readonly AppDbContext _db = db; + + public async Task> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) + { + var query = _db.HealthRecords.Where(r => r.UserId == userId); + + if (type.HasValue) + query = query.Where(r => r.MetricType == type.Value); + + if (recordedAfter.HasValue) + query = query.Where(r => r.RecordedAt >= recordedAfter.Value); + + return await query.OrderByDescending(r => r.RecordedAt).Take(limit).ToListAsync(ct); + } + + public Task GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => + _db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); + + public Task GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct) => + _db.HealthRecords + .Where(r => r.UserId == userId && r.MetricType == type) + .OrderByDescending(r => r.RecordedAt) + .FirstOrDefaultAsync(ct); + + public async Task> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct) => + await _db.HealthRecords + .Where(r => r.UserId == userId && r.MetricType == type && r.RecordedAt >= recordedAfter) + .OrderBy(r => r.RecordedAt) + .ToListAsync(ct); + + public async Task AddAsync(HealthRecord record, CancellationToken ct) => + await _db.HealthRecords.AddAsync(record, ct); + + public void Delete(HealthRecord record) => + _db.HealthRecords.Remove(record); + + public Task SaveChangesAsync(CancellationToken ct) => + _db.SaveChangesAsync(ct); +} diff --git a/backend/src/Health.Infrastructure/Maintenance/EfMaintenanceRepository.cs b/backend/src/Health.Infrastructure/Maintenance/EfMaintenanceRepository.cs new file mode 100644 index 0000000..0f66d08 --- /dev/null +++ b/backend/src/Health.Infrastructure/Maintenance/EfMaintenanceRepository.cs @@ -0,0 +1,52 @@ +using Health.Application.Maintenance; + +namespace Health.Infrastructure.Maintenance; + +public sealed class EfMaintenanceRepository(AppDbContext db) : IMaintenanceRepository +{ + private readonly AppDbContext _db = db; + + public async Task CleanupAsync( + DateTime conversationCutoff, + DateTime taskCutoff, + DateTime utcNow, + CancellationToken ct) + { + var oldConversationIds = await _db.Conversations.AsNoTracking() + .Where(x => x.UpdatedAt < conversationCutoff) + .Select(x => x.Id) + .ToListAsync(ct); + if (oldConversationIds.Count > 0) + { + await _db.ConversationMessages + .Where(x => oldConversationIds.Contains(x.ConversationId)) + .ExecuteDeleteAsync(ct); + await _db.Conversations + .Where(x => oldConversationIds.Contains(x.Id)) + .ExecuteDeleteAsync(ct); + } + + var verificationCodes = await _db.VerificationCodes + .Where(x => x.ExpiresAt < utcNow) + .ExecuteDeleteAsync(ct); + + var backgroundTasks = 0; + backgroundTasks += await _db.ReportAnalysisTasks + .Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed")) + .ExecuteDeleteAsync(ct); + backgroundTasks += await _db.DietImageAnalysisTasks + .Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed")) + .ExecuteDeleteAsync(ct); + backgroundTasks += await _db.MedicationReminderTasks + .Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed")) + .ExecuteDeleteAsync(ct); + backgroundTasks += await _db.NotificationOutbox + .Where(x => x.UpdatedAt < taskCutoff) + .ExecuteDeleteAsync(ct); + backgroundTasks += await _db.AiWriteCommands + .Where(x => x.UpdatedAt < taskCutoff) + .ExecuteDeleteAsync(ct); + + return new MaintenanceCleanupResult(oldConversationIds.Count, verificationCodes, backgroundTasks); + } +} diff --git a/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs b/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs new file mode 100644 index 0000000..f5933d0 --- /dev/null +++ b/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs @@ -0,0 +1,79 @@ +using Health.Application.Medications; + +namespace Health.Infrastructure.Medications; + +public sealed class EfMedicationRepository(AppDbContext db) : IMedicationRepository +{ + private readonly AppDbContext _db = db; + + public async Task> ListAsync(Guid userId, string? filter, CancellationToken ct) + { + var query = _db.Medications.Include(m => m.Logs).Where(m => m.UserId == userId); + if (filter == "active") query = query.Where(m => m.IsActive); + else if (filter == "inactive") query = query.Where(m => !m.IsActive); + + return await query.OrderByDescending(m => m.CreatedAt).ToListAsync(ct); + } + + public async Task> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => + await _db.Medications + .Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0 + && (m.StartDate == null || m.StartDate <= today) + && (m.EndDate == null || m.EndDate >= today)) + .ToListAsync(ct); + + public async Task> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => + await _db.MedicationLogs + .Where(l => l.UserId == userId && l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc) + .ToListAsync(ct); + + public Task GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => + _db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct); + + public Task ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => + _db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct); + + public Task GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => + _db.MedicationLogs.FirstOrDefaultAsync(l => + l.MedicationId == medicationId && l.UserId == userId + && l.CreatedAt >= startUtc && l.CreatedAt < endUtc + && l.Status == MedicationLogStatus.Taken, ct); + + public Task DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => + _db.MedicationLogs.AnyAsync(l => + l.MedicationId == medicationId && l.UserId == userId + && l.ScheduledTime == scheduledTime + && l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc, ct); + + public Task GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => + _db.MedicationLogs.FirstOrDefaultAsync(l => + l.MedicationId == medicationId && l.UserId == userId + && l.ScheduledTime == scheduledTime + && l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc, ct); + + public async Task> ListActiveForReminderScanAsync(CancellationToken ct) => + await _db.Medications + .Where(m => m.IsActive && m.TimeOfDay.Count > 0) + .ToListAsync(ct); + + public Task HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => + _db.MedicationLogs.AnyAsync(l => + l.UserId == userId && l.MedicationId == medicationId + && l.CreatedAt >= startUtc && l.CreatedAt < endUtc + && l.Status == MedicationLogStatus.Taken, ct); + + public async Task AddMedicationAsync(Medication medication, CancellationToken ct) => + await _db.Medications.AddAsync(medication, ct); + + public async Task AddLogAsync(MedicationLog log, CancellationToken ct) => + await _db.MedicationLogs.AddAsync(log, ct); + + public void DeleteMedication(Medication medication) => + _db.Medications.Remove(medication); + + public void DeleteLog(MedicationLog log) => + _db.MedicationLogs.Remove(log); + + public Task SaveChangesAsync(CancellationToken ct) => + _db.SaveChangesAsync(ct); +} diff --git a/backend/src/Health.Infrastructure/Medications/MedicationReminderQueue.cs b/backend/src/Health.Infrastructure/Medications/MedicationReminderQueue.cs new file mode 100644 index 0000000..265eaad --- /dev/null +++ b/backend/src/Health.Infrastructure/Medications/MedicationReminderQueue.cs @@ -0,0 +1,110 @@ +using Health.Application.Medications; +using Health.Infrastructure.Data.Records; +using Npgsql; + +namespace Health.Infrastructure.Medications; + +public sealed class MedicationReminderQueue(AppDbContext db) : IMedicationReminderQueue +{ + private const int MaxAttempts = 5; + private readonly AppDbContext _db = db; + + public async Task EnqueueAsync(MedicationReminderTask task, CancellationToken ct) + { + var exists = await _db.MedicationReminderTasks.AsNoTracking().AnyAsync(x => + x.MedicationId == task.MedicationId && + x.ScheduledDate == task.ScheduledDate && + x.ScheduledTime == task.ScheduledTime, ct); + if (exists) return false; + + var now = DateTime.UtcNow; + await _db.MedicationReminderTasks.AddAsync(new MedicationReminderTaskRecord + { + Id = task.TaskId, + UserId = task.UserId, + MedicationId = task.MedicationId, + Name = task.Name, + Dosage = task.Dosage, + ScheduledDate = task.ScheduledDate, + ScheduledTime = task.ScheduledTime, + AvailableAt = now, + CreatedAt = now, + UpdatedAt = now, + }, ct); + + try + { + await _db.SaveChangesAsync(ct); + return true; + } + catch (DbUpdateException ex) when ( + ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + _db.ChangeTracker.Clear(); + return false; + } + } + + public Task RecoverStaleAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + return _db.MedicationReminderTasks + .Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Pending") + .SetProperty(x => x.AvailableAt, now) + .SetProperty(x => x.UpdatedAt, now), ct); + } + + public async Task TryTakeAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + var candidate = await _db.MedicationReminderTasks.AsNoTracking() + .Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts) + .OrderBy(x => x.CreatedAt) + .FirstOrDefaultAsync(ct); + if (candidate == null) return null; + + var claimed = await _db.MedicationReminderTasks + .Where(x => x.Id == candidate.Id && x.Status == "Pending") + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Processing") + .SetProperty(x => x.Attempts, x => x.Attempts + 1) + .SetProperty(x => x.UpdatedAt, now), ct); + if (claimed == 0) return null; + + return new MedicationReminderTask( + candidate.Id, + candidate.UserId, + candidate.MedicationId, + candidate.Name, + candidate.Dosage, + candidate.ScheduledDate, + candidate.ScheduledTime); + } + + public Task CompleteAsync(Guid taskId, CancellationToken ct) => + _db.MedicationReminderTasks.Where(x => x.Id == taskId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Completed") + .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); + + public async Task RetryAsync(Guid taskId, string error, CancellationToken ct) + { + var task = await _db.MedicationReminderTasks.FirstOrDefaultAsync(x => x.Id == taskId, ct); + if (task == null) return; + + task.LastError = error.Length > 2000 ? error[..2000] : error; + task.UpdatedAt = DateTime.UtcNow; + if (task.Attempts >= MaxAttempts) + { + task.Status = "Failed"; + } + else + { + task.Status = "Pending"; + task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5); + } + await _db.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Health.Infrastructure/Medications/OutboxMedicationReminderDispatcher.cs b/backend/src/Health.Infrastructure/Medications/OutboxMedicationReminderDispatcher.cs new file mode 100644 index 0000000..3a4ceba --- /dev/null +++ b/backend/src/Health.Infrastructure/Medications/OutboxMedicationReminderDispatcher.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using Health.Application.Medications; +using Health.Infrastructure.Data.Records; +using Microsoft.Extensions.Logging; + +namespace Health.Infrastructure.Medications; + +public sealed class OutboxMedicationReminderDispatcher( + AppDbContext db, + ILogger logger) : IMedicationReminderDispatcher +{ + private readonly AppDbContext _db = db; + private readonly ILogger _logger = logger; + + public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct) + { + if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == task.TaskId, ct)) + return; + + var now = DateTime.UtcNow; + await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord + { + Id = Guid.NewGuid(), + UserId = task.UserId, + SourceTaskId = task.TaskId, + Type = "MedicationReminder", + Payload = JsonSerializer.Serialize(new + { + task.MedicationId, + task.Name, + task.Dosage, + task.ScheduledDate, + task.ScheduledTime, + }), + Status = "AwaitingProvider", + AvailableAt = now, + CreatedAt = now, + UpdatedAt = now, + }, ct); + await _db.SaveChangesAsync(ct); + + _logger.LogInformation( + "用药提醒已写入通知 Outbox,等待推送服务: {TaskId} {UserId}", + task.TaskId, + task.UserId); + } +} diff --git a/backend/src/Health.Infrastructure/Notifications/EfInAppNotificationRepository.cs b/backend/src/Health.Infrastructure/Notifications/EfInAppNotificationRepository.cs new file mode 100644 index 0000000..babbb97 --- /dev/null +++ b/backend/src/Health.Infrastructure/Notifications/EfInAppNotificationRepository.cs @@ -0,0 +1,26 @@ +using Health.Application.Notifications; + +namespace Health.Infrastructure.Notifications; + +public sealed class EfInAppNotificationRepository(AppDbContext db) : IInAppNotificationRepository +{ + private readonly AppDbContext _db = db; + + public async Task> GetPendingAsync(Guid userId, CancellationToken ct) => + await _db.NotificationOutbox.AsNoTracking() + .Where(x => x.UserId == userId && x.Status == "AwaitingProvider") + .OrderBy(x => x.CreatedAt) + .Take(10) + .Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Payload, x.CreatedAt)) + .ToListAsync(ct); + + public async Task AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) + { + var updated = await _db.NotificationOutbox + .Where(x => x.Id == notificationId && x.UserId == userId && x.Status == "AwaitingProvider") + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "InAppDelivered") + .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); + return updated > 0; + } +} diff --git a/backend/src/Health.Infrastructure/Reports/EfReportAnalysisQueue.cs b/backend/src/Health.Infrastructure/Reports/EfReportAnalysisQueue.cs new file mode 100644 index 0000000..16130eb --- /dev/null +++ b/backend/src/Health.Infrastructure/Reports/EfReportAnalysisQueue.cs @@ -0,0 +1,86 @@ +using Health.Application.Reports; +using Health.Infrastructure.Data.Records; + +namespace Health.Infrastructure.Reports; + +public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueue +{ + private const int MaxAttempts = 3; + private readonly AppDbContext _db = db; + + public async Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default) + { + var now = DateTime.UtcNow; + await _db.ReportAnalysisTasks.AddAsync(new ReportAnalysisTaskRecord + { + Id = job.TaskId, + ReportId = job.ReportId, + FilePath = job.FilePath, + Status = "Pending", + Attempts = 0, + AvailableAt = now, + CreatedAt = now, + UpdatedAt = now, + }, ct); + await _db.SaveChangesAsync(ct); + } + + public Task RecoverStaleAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + return _db.ReportAnalysisTasks + .Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Pending") + .SetProperty(x => x.AvailableAt, now) + .SetProperty(x => x.UpdatedAt, now), ct); + } + + public async Task TryTakeAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + var candidate = await _db.ReportAnalysisTasks.AsNoTracking() + .Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts) + .OrderBy(x => x.CreatedAt) + .FirstOrDefaultAsync(ct); + if (candidate == null) return null; + + var claimed = await _db.ReportAnalysisTasks + .Where(x => x.Id == candidate.Id && x.Status == "Pending") + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Processing") + .SetProperty(x => x.Attempts, x => x.Attempts + 1) + .SetProperty(x => x.UpdatedAt, now), ct); + return claimed == 0 + ? null + : new ReportAnalysisJob(candidate.Id, candidate.ReportId, candidate.FilePath); + } + + public async Task CompleteAsync(Guid taskId, CancellationToken ct) + { + await _db.ReportAnalysisTasks + .Where(x => x.Id == taskId && x.Status == "Processing") + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Completed") + .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); + } + + public async Task RetryAsync(Guid taskId, string error, CancellationToken ct) + { + var task = await _db.ReportAnalysisTasks.FirstOrDefaultAsync(x => x.Id == taskId, ct); + if (task == null) return; + + task.LastError = error.Length > 2000 ? error[..2000] : error; + task.UpdatedAt = DateTime.UtcNow; + if (task.Attempts >= MaxAttempts) + { + task.Status = "Failed"; + } + else + { + task.Status = "Pending"; + task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5); + } + await _db.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Health.Infrastructure/Reports/EfReportRepository.cs b/backend/src/Health.Infrastructure/Reports/EfReportRepository.cs new file mode 100644 index 0000000..c5cad5d --- /dev/null +++ b/backend/src/Health.Infrastructure/Reports/EfReportRepository.cs @@ -0,0 +1,29 @@ +using Health.Application.Reports; + +namespace Health.Infrastructure.Reports; + +public sealed class EfReportRepository(AppDbContext db) : IReportRepository +{ + private readonly AppDbContext _db = db; + + public async Task> ListAsync(Guid userId, CancellationToken ct) => + await _db.Reports + .Where(r => r.UserId == userId) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(ct); + + public Task GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct) => + _db.Reports.FirstOrDefaultAsync(r => r.Id == reportId && r.UserId == userId, ct); + + public Task GetByIdAsync(Guid reportId, CancellationToken ct) => + _db.Reports.FirstOrDefaultAsync(r => r.Id == reportId, ct); + + public async Task AddAsync(Report report, CancellationToken ct) => + await _db.Reports.AddAsync(report, ct); + + public void Delete(Report report) => + _db.Reports.Remove(report); + + public Task SaveChangesAsync(CancellationToken ct) => + _db.SaveChangesAsync(ct); +} diff --git a/backend/src/Health.Infrastructure/Reports/LocalReportFileStorage.cs b/backend/src/Health.Infrastructure/Reports/LocalReportFileStorage.cs new file mode 100644 index 0000000..6217535 --- /dev/null +++ b/backend/src/Health.Infrastructure/Reports/LocalReportFileStorage.cs @@ -0,0 +1,30 @@ +using Health.Application.Reports; + +namespace Health.Infrastructure.Reports; + +public sealed class LocalReportFileStorage : IReportFileStorage +{ + public async Task SaveAsync(ReportUploadFile file, string extension, CancellationToken ct) + { + var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports"); + Directory.CreateDirectory(uploadsDir); + + var fileName = $"{Guid.NewGuid()}{extension}"; + var filePath = Path.Combine(uploadsDir, fileName); + await using (var stream = new FileStream(filePath, FileMode.CreateNew)) + { + await file.Content.CopyToAsync(stream, ct); + } + + return new StoredReportFile($"/uploads/reports/{fileName}", filePath); + } + + public string GetLocalFilePath(string fileUrl) => + Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/')); + + public bool Exists(string filePath) => + File.Exists(filePath); + + public void Delete(string filePath) => + File.Delete(filePath); +} diff --git a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs new file mode 100644 index 0000000..3c01154 --- /dev/null +++ b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs @@ -0,0 +1,165 @@ +using Health.Application.Reports; +using Health.Infrastructure.AI; +using Microsoft.Extensions.Logging; + +namespace Health.Infrastructure.Reports; + +public sealed class ReportAnalysisService( + IReportRepository reports, + VisionClient vision, + DeepSeekClient llm, + ILogger logger) : IReportAnalysisService +{ + private readonly IReportRepository _reports = reports; + private readonly VisionClient _vision = vision; + private readonly DeepSeekClient _llm = llm; + private readonly ILogger _logger = logger; + + public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct) + { + var report = await _reports.GetByIdAsync(job.ReportId, ct); + if (report == null) return; + + try + { + var vlmJson = await ExtractIndicatorsAsync(job.FilePath, ct); + + var isReport = vlmJson.TryGetProperty("isReport", out var ir) && ir.GetBoolean(); + var reason = vlmJson.TryGetProperty("reason", out var rsn) ? rsn.GetString() : null; + if (!isReport) + { + report.AiSummary = $"AI 暂时无法完成解读:{reason ?? "这可能不是一份医学报告"}。请重新上传清晰的报告图片。"; + report.AiIndicators = "[]"; + report.Status = ReportStatus.AnalysisFailed; + await _reports.SaveChangesAsync(ct); + return; + } + + var reportType = vlmJson.TryGetProperty("reportType", out var rt) ? rt.GetString() ?? "Other" : "Other"; + var indicatorsJson = vlmJson.TryGetProperty("indicators", out var inds) ? inds.GetRawText() : "[]"; + + report.Category = Enum.TryParse(reportType, out var cat) ? cat : ReportCategory.Other; + report.AiIndicators = indicatorsJson; + report.AiSummary = await GenerateSummaryAsync(indicatorsJson, ct); + report.Status = ReportStatus.PendingDoctor; + + await _reports.SaveChangesAsync(ct); + _logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId); + } + catch (Exception ex) + { + _logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId); + report.Status = ReportStatus.AnalysisFailed; + report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。"; + report.AiIndicators = "[]"; + await _reports.SaveChangesAsync(ct); + } + } + + private async Task ExtractIndicatorsAsync(string filePath, CancellationToken ct) + { + var imageUrl = await GetLocalImageUrl(filePath, ct); + if (imageUrl == null) + throw new InvalidOperationException("报告图片文件不存在或无法读取"); + + var prompt = """ + 你是一名医学检验报告分析专家。请仔细识别这份医学报告。 + + 第一步:判断这到底是不是一份医学检验报告。 + 如果不是(比如是自拍、风景照、食物、文件等),直接返回: + {"isReport": false, "reason": "这不是医学报告,原因:..."} + + 如果是医学报告,提取所有检测指标,返回严格JSON格式(不要markdown包裹): + { + "isReport": true, + "reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other", + "indicators": [ + {"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"} + ] + } + + 注意: + - status 判断:数值在参考范围内=normal,超出上限=high,低于下限=low + - 如果参考范围不明确,根据常见医学标准判断 + """; + + var response = await _vision.VisionAsync( + prompt, + [imageUrl], + userText: "请分析这份医学报告", + maxTokens: 2048, + ct: ct); + + var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? ""; + _logger.LogInformation("报告 VLM 返回: {Content}", content[..Math.Min(content.Length, 200)]); + + var json = TryParseJson(content); + return json ?? throw new InvalidOperationException("报告识别结果格式异常"); + } + + private async Task GenerateSummaryAsync(string indicatorsJson, CancellationToken ct) + { + var prompt = $""" + 你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。 + + 检测指标:{indicatorsJson} + + 要求: + 1. 总字数200-300字 + 2. 先总结整体情况 + 3. 指出异常指标及其可能原因,但不要给出确定诊断 + 4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议 + 5. 如指标明显异常或存在急症风险,优先建议及时就医 + 6. 末尾提醒"以上为AI预解读,不能替代医生诊断和治疗建议" + + 只返回解读文本,不要JSON格式。 + """; + + var messages = new List + { + new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" }, + new() { Role = "user", Content = prompt } + }; + + var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct); + var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? ""; + return !string.IsNullOrWhiteSpace(summary) + ? summary + : throw new InvalidOperationException("AI 未返回有效解读内容"); + } + + private static async Task GetLocalImageUrl(string filePath, CancellationToken ct) + { + if (!File.Exists(filePath)) return null; + + var bytes = await File.ReadAllBytesAsync(filePath, ct); + var base64 = Convert.ToBase64String(bytes); + var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.'); + var mime = ext switch + { + "png" => "image/png", + "jpg" or "jpeg" => "image/jpeg", + "webp" => "image/webp", + _ => "image/png" + }; + return $"data:{mime};base64,{base64}"; + } + + private static JsonElement? TryParseJson(string? content) + { + if (string.IsNullOrWhiteSpace(content)) return null; + + content = content.Trim(); + if (content.StartsWith("```")) + { + var end = content.IndexOf('\n'); + if (end > 0) content = content[(end + 1)..]; + if (content.EndsWith("```")) + content = content[..^3]; + } + + content = content.Trim(); + try { return JsonDocument.Parse(content).RootElement; } + catch { return null; } + } +} diff --git a/backend/src/Health.Infrastructure/Users/EfUserRepository.cs b/backend/src/Health.Infrastructure/Users/EfUserRepository.cs new file mode 100644 index 0000000..ad67d0d --- /dev/null +++ b/backend/src/Health.Infrastructure/Users/EfUserRepository.cs @@ -0,0 +1,51 @@ +using Health.Application.Users; + +namespace Health.Infrastructure.Users; + +public sealed class EfUserRepository(AppDbContext db) : IUserRepository +{ + private readonly AppDbContext _db = db; + + public Task GetAsync(Guid userId, CancellationToken ct) => + _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); + + public Task SaveChangesAsync(CancellationToken ct) => + _db.SaveChangesAsync(ct); + + public async Task DeleteAccountDataAsync(Guid userId, CancellationToken ct) + { + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct); + if (profile?.DoctorId != null) + { + await _db.Users.Where(u => u.DoctorId == profile.DoctorId) + .ExecuteUpdateAsync(u => u.SetProperty(x => x.DoctorId, (Guid?)null), ct); + await _db.Doctors.Where(d => d.Id == profile.DoctorId).ExecuteDeleteAsync(ct); + } + + await _db.HealthRecords.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct); + await _db.MedicationLogs.Where(l => l.UserId == userId).ExecuteDeleteAsync(ct); + await _db.Medications.Where(m => m.UserId == userId).ExecuteDeleteAsync(ct); + await _db.DietFoodItems.Where(i => i.DietRecord!.UserId == userId).ExecuteDeleteAsync(ct); + await _db.DietRecords.Where(d => d.UserId == userId).ExecuteDeleteAsync(ct); + await _db.ExercisePlanItems.Where(i => i.Plan!.UserId == userId).ExecuteDeleteAsync(ct); + await _db.ExercisePlans.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct); + await _db.ReportAnalysisTasks.Where(t => _db.Reports.Any(r => r.Id == t.ReportId && r.UserId == userId)).ExecuteDeleteAsync(ct); + await _db.Reports.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct); + await _db.ConversationMessages.Where(m => m.Conversation!.UserId == userId).ExecuteDeleteAsync(ct); + await _db.Conversations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct); + await _db.ConsultationMessages.Where(m => m.Consultation!.UserId == userId).ExecuteDeleteAsync(ct); + await _db.Consultations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct); + await _db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct); + await _db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct); + await _db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct); + await _db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct); + await _db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct); + await _db.AiWriteCommands.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct); + await _db.DoctorProfiles.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct); + await _db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync(ct); + + await transaction.CommitAsync(ct); + } +} diff --git a/backend/src/Health.WebApi/BackgroundServices/cleanup_service.cs b/backend/src/Health.WebApi/BackgroundServices/cleanup_service.cs index ea1512a..421b223 100644 --- a/backend/src/Health.WebApi/BackgroundServices/cleanup_service.cs +++ b/backend/src/Health.WebApi/BackgroundServices/cleanup_service.cs @@ -1,8 +1,7 @@ +using Health.Application.Maintenance; + namespace Health.WebApi.BackgroundServices; -/// -/// 数据清理后台服务(每小时检查一次) -/// public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { private readonly IServiceScopeFactory _scopeFactory = scopeFactory; @@ -15,44 +14,21 @@ public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger(); - - // 清理 30 天前的对话记录(先删消息再删对话,避免 FK 冲突) - var cutoff = DateTime.UtcNow.AddDays(-30); - var oldConvIds = await db.Conversations - .Where(c => c.UpdatedAt < cutoff) - .Select(c => c.Id) - .ToListAsync(stoppingToken); - - if (oldConvIds.Count > 0) + var maintenance = scope.ServiceProvider.GetRequiredService(); + var result = await maintenance.CleanupAsync(DateTime.UtcNow, stoppingToken); + if (result.Conversations + result.VerificationCodes + result.BackgroundTasks > 0) { - // 先批量删消息 - var oldMessages = await db.ConversationMessages - .Where(m => oldConvIds.Contains(m.ConversationId)) - .ToListAsync(stoppingToken); - db.ConversationMessages.RemoveRange(oldMessages); - - // 再删对话 - var oldConversations = await db.Conversations - .Where(c => oldConvIds.Contains(c.Id)) - .ToListAsync(stoppingToken); - db.Conversations.RemoveRange(oldConversations); - - await db.SaveChangesAsync(stoppingToken); - _logger.LogInformation("清理 {Count} 条过期对话", oldConvIds.Count); - } - - // 清理过期验证码 - var expiredCodes = await db.VerificationCodes - .Where(v => v.ExpiresAt < DateTime.UtcNow) - .ToListAsync(stoppingToken); - - if (expiredCodes.Count > 0) - { - db.VerificationCodes.RemoveRange(expiredCodes); - await db.SaveChangesAsync(stoppingToken); + _logger.LogInformation( + "自动清理完成: 会话 {Conversations}, 验证码 {Codes}, 后台任务 {Tasks}", + result.Conversations, + result.VerificationCodes, + result.BackgroundTasks); } } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } catch (Exception ex) { _logger.LogError(ex, "数据清理异常"); diff --git a/backend/src/Health.WebApi/BackgroundServices/diet_image_analysis_worker.cs b/backend/src/Health.WebApi/BackgroundServices/diet_image_analysis_worker.cs new file mode 100644 index 0000000..fcdd035 --- /dev/null +++ b/backend/src/Health.WebApi/BackgroundServices/diet_image_analysis_worker.cs @@ -0,0 +1,73 @@ +using Health.Application.Diets; + +namespace Health.WebApi.BackgroundServices; + +public sealed class DietImageAnalysisWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory = scopeFactory; + private readonly ILogger _logger = logger; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("饮食图片识别持久化任务消费者已启动"); + var idleDelay = TimeSpan.FromSeconds(1); + var nextRecovery = DateTime.MinValue; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var queue = scope.ServiceProvider.GetRequiredService(); + if (DateTime.UtcNow >= nextRecovery) + { + await queue.RecoverStaleAsync(stoppingToken); + nextRecovery = DateTime.UtcNow.AddMinutes(1); + } + + var job = await queue.TryTakeAsync(stoppingToken); + if (job == null) + { + await Task.Delay(idleDelay, stoppingToken); + idleDelay = TimeSpan.FromSeconds(Math.Min(idleDelay.TotalSeconds * 2, 5)); + continue; + } + + idleDelay = TimeSpan.FromSeconds(1); + try + { + var analyzer = scope.ServiceProvider.GetRequiredService(); + var result = await analyzer.AnalyzeAsync(job, stoppingToken); + await queue.CompleteAsync(job.Id, result, stoppingToken); + DeleteFiles(job.FilePaths); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "饮食图片识别任务失败: {JobId}", job.Id); + var terminal = await queue.RetryAsync(job.Id, ex.Message, stoppingToken); + if (terminal) DeleteFiles(job.FilePaths); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "饮食图片识别消费者循环异常"); + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + } + } + } + + private static void DeleteFiles(IEnumerable paths) + { + foreach (var path in paths) + { + try { if (File.Exists(path)) File.Delete(path); } + catch { } + } + } +} diff --git a/backend/src/Health.WebApi/BackgroundServices/exercise_reminder_service.cs b/backend/src/Health.WebApi/BackgroundServices/exercise_reminder_service.cs new file mode 100644 index 0000000..c732145 --- /dev/null +++ b/backend/src/Health.WebApi/BackgroundServices/exercise_reminder_service.cs @@ -0,0 +1,32 @@ +using Health.Application.Exercises; + +namespace Health.WebApi.BackgroundServices; + +public sealed class ExerciseReminderService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory = scopeFactory; + private readonly ILogger _logger = logger; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var producer = scope.ServiceProvider.GetRequiredService(); + var count = await producer.ProduceDueAsync(DateTime.UtcNow, stoppingToken); + if (count > 0) _logger.LogInformation("已生成 {Count} 条 App 内运动提醒", count); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "生成运动提醒失败"); + } + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } +} diff --git a/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs b/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs index e1cd085..e03c4dd 100644 --- a/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs +++ b/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs @@ -1,22 +1,31 @@ +using Health.Application.Medications; + namespace Health.WebApi.BackgroundServices; -/// -/// 用药提醒定时扫描服务(每分钟检查一次) -/// -public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService +public sealed class MedicationReminderService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService { private readonly IServiceScopeFactory _scopeFactory = scopeFactory; private readonly ILogger _logger = logger; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("用药提醒服务已启动"); - + _logger.LogInformation("用药提醒扫描生产者已启动"); while (!stoppingToken.IsCancellationRequested) { try { - await ProcessReminders(stoppingToken); + using var scope = _scopeFactory.CreateScope(); + var scanner = scope.ServiceProvider.GetRequiredService(); + var queue = scope.ServiceProvider.GetRequiredService(); + var tasks = await scanner.ScanAsync(DateTime.UtcNow, stoppingToken); + foreach (var task in tasks) + await queue.EnqueueAsync(task, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; } catch (Exception ex) { @@ -26,37 +35,4 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory, await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); } } - - private async Task ProcessReminders(CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - // 北京时间今天0点 → 转为UTC - var beijingToday = DateTime.UtcNow.AddHours(8).Date; - var todayStartUtc = beijingToday.AddHours(-8); - - // 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内 - var beijingTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); - var windowStart = beijingTime.AddMinutes(-5); - var medications = await db.Medications - .Where(m => m.IsActive) - .Where(m => m.TimeOfDay.Any(t => - beijingTime > windowStart ? (t >= windowStart && t <= beijingTime) : (t >= windowStart || t <= beijingTime))) - .ToListAsync(ct); - - foreach (var med in medications) - { - // 检查今天是否已打卡(用正确的北京时间区间) - var alreadyLogged = await db.MedicationLogs - .AnyAsync(l => l.MedicationId == med.Id - && l.CreatedAt >= todayStartUtc - && l.Status == MedicationLogStatus.Taken, ct); - - if (alreadyLogged) continue; - - // TODO: 调用极光推送发送用药提醒 - _logger.LogInformation("用药提醒: 用户 {UserId} 药品 {Name} {Dosage} 时间 {Time}", - med.UserId, med.Name, med.Dosage, beijingTime); - } - } } diff --git a/backend/src/Health.WebApi/BackgroundServices/medication_reminder_worker.cs b/backend/src/Health.WebApi/BackgroundServices/medication_reminder_worker.cs new file mode 100644 index 0000000..915d12b --- /dev/null +++ b/backend/src/Health.WebApi/BackgroundServices/medication_reminder_worker.cs @@ -0,0 +1,62 @@ +using Health.Application.Medications; + +namespace Health.WebApi.BackgroundServices; + +public sealed class MedicationReminderWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory = scopeFactory; + private readonly ILogger _logger = logger; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("用药提醒持久化任务消费者已启动"); + var idleDelay = TimeSpan.FromSeconds(1); + var nextRecovery = DateTime.MinValue; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var queue = scope.ServiceProvider.GetRequiredService(); + if (DateTime.UtcNow >= nextRecovery) + { + await queue.RecoverStaleAsync(stoppingToken); + nextRecovery = DateTime.UtcNow.AddMinutes(1); + } + + var task = await queue.TryTakeAsync(stoppingToken); + if (task == null) + { + await Task.Delay(idleDelay, stoppingToken); + idleDelay = TimeSpan.FromSeconds(Math.Min(idleDelay.TotalSeconds * 2, 5)); + continue; + } + + idleDelay = TimeSpan.FromSeconds(1); + try + { + var dispatcher = scope.ServiceProvider.GetRequiredService(); + await dispatcher.DispatchAsync(task, stoppingToken); + await queue.CompleteAsync(task.TaskId, stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "用药提醒消费失败: {MedicationId}", task.MedicationId); + await queue.RetryAsync(task.TaskId, ex.Message, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "用药提醒消费者循环异常"); + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + } + } + } +} diff --git a/backend/src/Health.WebApi/BackgroundServices/report_analysis_worker.cs b/backend/src/Health.WebApi/BackgroundServices/report_analysis_worker.cs new file mode 100644 index 0000000..edfbaef --- /dev/null +++ b/backend/src/Health.WebApi/BackgroundServices/report_analysis_worker.cs @@ -0,0 +1,60 @@ +using Health.Application.Reports; + +namespace Health.WebApi.BackgroundServices; + +public sealed class ReportAnalysisWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory = scopeFactory; + private readonly ILogger _logger = logger; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("报告 AI 持久化任务消费者已启动"); + var idleDelay = TimeSpan.FromSeconds(1); + var nextRecovery = DateTime.MinValue; + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var queue = scope.ServiceProvider.GetRequiredService(); + if (DateTime.UtcNow >= nextRecovery) + { + await queue.RecoverStaleAsync(stoppingToken); + nextRecovery = DateTime.UtcNow.AddMinutes(1); + } + var job = await queue.TryTakeAsync(stoppingToken); + if (job == null) + { + await Task.Delay(idleDelay, stoppingToken); + idleDelay = TimeSpan.FromSeconds(Math.Min(idleDelay.TotalSeconds * 2, 5)); + continue; + } + + idleDelay = TimeSpan.FromSeconds(1); + try + { + var analysisService = scope.ServiceProvider.GetRequiredService(); + await analysisService.AnalyzeAsync(job, stoppingToken); + await queue.CompleteAsync(job.TaskId, stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "报告 AI 分析任务执行异常: {ReportId}", job.ReportId); + await queue.RetryAsync(job.TaskId, ex.Message, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "报告任务消费者循环异常"); + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + } + } + } +} diff --git a/backend/src/Health.WebApi/Endpoints/admin_endpoints.cs b/backend/src/Health.WebApi/Endpoints/admin_endpoints.cs index 46b84e6..7a448b6 100644 --- a/backend/src/Health.WebApi/Endpoints/admin_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/admin_endpoints.cs @@ -1,201 +1,33 @@ using System.Security.Claims; -using Health.Domain.Entities; -using Microsoft.EntityFrameworkCore; +using Health.Application.Admin; namespace Health.WebApi.Endpoints; -/// -/// 管理员 API(需JWT鉴权 + Role=Admin) -/// public static class AdminEndpoints { - private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001"); - - private static Guid GetUserId(HttpContext http) => - Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; - - private static string GetUserRole(HttpContext http) => - http.User.FindFirst("Role")?.Value ?? http.User.FindFirst(ClaimTypes.Role)?.Value ?? "User"; - public static void MapAdminEndpoints(this WebApplication app) { var group = app.MapGroup("/api/admin").RequireAuthorization(); - - // 管理员身份校验 group.AddEndpointFilter(async (context, next) => - { - var role = GetUserRole(context.HttpContext); - if (role != "Admin") - return Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403); - return await next(context); - }); + GetRole(context.HttpContext) == "Admin" + ? await next(context) + : Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403)); - // ===== 医生列表 ===== - group.MapGet("/doctors", async (AppDbContext db) => - { - var doctors = await db.Doctors - .OrderBy(d => d.CreatedAt) - .Select(d => new - { - d.Id, d.Name, d.Title, d.Department, - d.Phone, d.ProfessionalDirection, - d.IsActive, d.AvatarUrl, d.Introduction, - d.CreatedAt - }) - .ToListAsync(); - - return Results.Ok(new { code = 0, data = doctors, message = (string?)null }); - }); - - // ===== 新增医生 ===== - group.MapPost("/doctors", async ( - AddDoctorRequest request, - AppDbContext db, - CancellationToken ct) => - { - if (string.IsNullOrWhiteSpace(request.Phone)) - return Results.Ok(new { code = 40001, data = (object?)null, message = "手机号不能为空" }); - if (string.IsNullOrWhiteSpace(request.Name)) - return Results.Ok(new { code = 40002, data = (object?)null, message = "姓名不能为空" }); - - var doctor = new Doctor - { - Id = Guid.NewGuid(), - Name = request.Name, - Title = request.Title, - Department = request.Department, - Phone = request.Phone, - ProfessionalDirection = request.ProfessionalDirection, - IsActive = true, - CreatedAt = DateTime.UtcNow, - }; - db.Doctors.Add(doctor); - - // 同步创建 User + DoctorProfile,医生可用手机号登录 - var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct); - if (existingUser == null) - { - var user = new User - { - Id = Guid.NewGuid(), - Phone = request.Phone, - Role = "Doctor", - Name = request.Name, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - db.Users.Add(user); - db.DoctorProfiles.Add(new DoctorProfile - { - Id = Guid.NewGuid(), - UserId = user.Id, - DoctorId = doctor.Id, - Name = request.Name, - Title = request.Title, - Department = request.Department, - IsActive = true, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }); - } - - await db.SaveChangesAsync(ct); - - return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.Name }, message = (string?)null }); - }); - - // ===== 编辑医生 ===== - group.MapPut("/doctors/{id:guid}", async ( - Guid id, UpdateDoctorRequest request, AppDbContext db, CancellationToken ct) => - { - var doctor = await db.Doctors.FindAsync([id], ct); - if (doctor == null) - return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" }); - - if (request.Name != null) doctor.Name = request.Name; - if (request.Title != null) doctor.Title = request.Title; - if (request.Department != null) doctor.Department = request.Department; - if (request.Phone != null) doctor.Phone = request.Phone; - if (request.ProfessionalDirection != null) doctor.ProfessionalDirection = request.ProfessionalDirection; - - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { doctor.Id }, message = (string?)null }); - }); - - // ===== 启用/停用医生 ===== - group.MapPut("/doctors/{id:guid}/disable", async ( - Guid id, AppDbContext db, CancellationToken ct) => - { - var doctor = await db.Doctors.FindAsync([id], ct); - if (doctor == null) - return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" }); - - doctor.IsActive = !doctor.IsActive; - await db.SaveChangesAsync(ct); - - return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.IsActive }, message = (string?)null }); - }); - - // ===== 删除医生 ===== - group.MapDelete("/doctors/{id:guid}", async ( - Guid id, AppDbContext db, CancellationToken ct) => - { - var doctor = await db.Doctors.FindAsync([id], ct); - if (doctor == null) - return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" }); - - // 清除患者的 DoctorId 关联 - await db.Users.Where(u => u.DoctorId == id) - .ExecuteUpdateAsync(s => s.SetProperty(u => u.DoctorId, (Guid?)null), ct); - - db.Doctors.Remove(doctor); - await db.SaveChangesAsync(ct); - - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); - }); - - // ===== 患者列表 ===== - group.MapGet("/patients", async ( - AppDbContext db, - string? search, - int? page, - int? pageSize) => - { - var p = page ?? 1; - var ps = pageSize ?? 20; - var query = db.Users - .Where(u => u.Role == "User") - .AsQueryable(); - - if (!string.IsNullOrWhiteSpace(search)) - { - query = query.Where(u => u.Name!.Contains(search) || u.Phone.Contains(search)); - } - - var total = await query.CountAsync(); - var patients = await query - .OrderByDescending(u => u.CreatedAt) - .Skip((p - 1) * ps) - .Take(ps) - .Select(u => new - { - u.Id, u.Name, u.Phone, u.Gender, - u.BirthDate, u.CreatedAt, - DoctorName = u.Doctor != null ? u.Doctor.Name : null, - DoctorDepartment = u.Doctor != null ? u.Doctor.Department : null, - }) - .ToListAsync(); - - return Results.Ok(new - { - code = 0, - data = new { patients, total, page = p, pageSize = ps }, - message = (string?)null - }); - }); + group.MapGet("/doctors", async (IAdminService admin, CancellationToken ct) => ToResult(await admin.ListDoctorsAsync(ct))); + group.MapPost("/doctors", async (AddDoctorRequest request, IAdminService admin, CancellationToken ct) => + ToResult(await admin.AddDoctorAsync(new AddDoctorCommand(request.Phone, request.Name, request.Title, request.Department, request.ProfessionalDirection), ct))); + group.MapPut("/doctors/{id:guid}", async (Guid id, UpdateDoctorRequest request, IAdminService admin, CancellationToken ct) => + ToResult(await admin.UpdateDoctorAsync(id, new UpdateDoctorCommand(request.Phone, request.Name, request.Title, request.Department, request.ProfessionalDirection), ct))); + group.MapPut("/doctors/{id:guid}/disable", async (Guid id, IAdminService admin, CancellationToken ct) => ToResult(await admin.ToggleDoctorAsync(id, ct))); + group.MapDelete("/doctors/{id:guid}", async (Guid id, IAdminService admin, CancellationToken ct) => ToResult(await admin.DeleteDoctorAsync(id, ct))); + group.MapGet("/patients", async (IAdminService admin, string? search, int? page, int? pageSize, CancellationToken ct) => + ToResult(await admin.ListPatientsAsync(search, page ?? 1, pageSize ?? 20, ct))); } + + private static string GetRole(HttpContext http) => + http.User.FindFirst("Role")?.Value ?? http.User.FindFirst(ClaimTypes.Role)?.Value ?? "User"; + private static IResult ToResult(AdminResult result) => Results.Ok(new { code = result.Code, data = result.Data, message = result.Message }); } -// ---- DTO ---- public sealed record AddDoctorRequest(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection); public sealed record UpdateDoctorRequest(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection); diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 9c5d485..8e852c4 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -1,6 +1,11 @@ -using System.Drawing; -using System.Drawing.Imaging; using System.Text.Json; +using System.Text.RegularExpressions; +using Health.Application.AI; +using Health.Application.Diets; +using Health.Application.Exercises; +using Health.Application.HealthArchives; +using Health.Application.HealthRecords; +using Health.Application.Medications; using Health.Infrastructure.AI; using Health.Infrastructure.AI.AgentHandlers; @@ -26,10 +31,12 @@ public static class AiChatEndpoints string token, string agentType, HttpContext http, - AppDbContext db, DeepSeekClient llmClient, PromptManager promptManager, - VisionClient visionClient, + IAiToolExecutionService toolExecution, + IAiWriteConfirmationStore confirmations, + IAiConversationService conversations, + IPatientContextService patientContexts, CancellationToken ct) => { // 支持 token 通过 query string(浏览器 EventSource)或 header 传递 @@ -51,38 +58,57 @@ public static class AiChatEndpoints http.Response.Headers.Connection = "keep-alive"; http.Response.Headers["X-Accel-Buffering"] = "no"; - // 创建或获取对话 - Conversation? conversation = null; - if (!string.IsNullOrEmpty(conversationId) && Guid.TryParse(conversationId, out var convId)) - conversation = await db.Conversations.FindAsync([convId], ct); - - if (conversation == null) + // 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。 + Guid? requestedConversationId = null; + if (!string.IsNullOrWhiteSpace(conversationId)) { - conversation = new Conversation + if (!Guid.TryParse(conversationId, out var convId)) { - Id = Guid.NewGuid(), UserId = userId.Value, AgentType = parsedType, - Title = message.Length > 30 ? message[..30] : message, - CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, - }; - db.Conversations.Add(conversation); - await db.SaveChangesAsync(ct); - await SseWriteAsync(http, new { action = "conversation_id", data = conversation.Id.ToString() }, ct); + await SseWriteAsync(http, new { action = "answer", data = "当前会话参数异常,请重新开始一次对话。" }, ct); + await SseWriteAsync(http, new { action = "status", data = "error" }, ct); + await http.Response.WriteAsync("data: [DONE]\n\n", ct); + return; + } + requestedConversationId = convId; } - // 保存用户消息 - var userMsg = new ConversationMessage + var opened = await conversations.OpenAsync(userId.Value, requestedConversationId, parsedType, message, ct); + if (!opened.Found) { - Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.User, - Content = message, CreatedAt = DateTime.UtcNow, - }; - db.ConversationMessages.Add(userMsg); - conversation.MessageCount++; - conversation.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(ct); + await SseWriteAsync(http, new { action = "answer", data = "当前会话不存在或不属于当前账号,请重新开始一次对话。" }, ct); + await SseWriteAsync(http, new { action = "status", data = "error" }, ct); + await http.Response.WriteAsync("data: [DONE]\n\n", ct); + return; + } + var activeConversationId = opened.ConversationId; + if (opened.Created) + await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct); + + await conversations.AddUserMessageAsync(activeConversationId, message, ct); + + var urgentWarning = DetectUrgentRisk(message); + if (!string.IsNullOrEmpty(urgentWarning)) + { + var urgentResponse = $""" + {urgentWarning} + + 这种情况需要把安全放在第一位。请先停止自行判断和等待 AI 分析,尽快联系医生、互联网医院或前往急诊评估;如果症状正在加重,建议立即拨打当地急救电话。若身边有人,请让家人或同伴陪同,不要独自开车就医。 + + 以上为 AI 安全提醒,不能替代医生诊断和治疗建议。 + """; + + await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct); + + await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct); + await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct); + await SseWriteAsync(http, new { action = "status", data = "done" }, ct); + await http.Response.WriteAsync("data: [DONE]\n\n", ct); + return; + } // 加载上下文 var systemPrompt = promptManager.GetSystemPrompt(parsedType); - var patientContext = await BuildPatientContext(db, userId.Value, ct); + var patientContext = await patientContexts.BuildAsync(userId.Value, ct); var messages = new List { @@ -90,17 +116,13 @@ public static class AiChatEndpoints }; // 加载历史对话(最近 10 条) - var history = await db.ConversationMessages - .Where(m => m.ConversationId == conversation.Id) - .OrderByDescending(m => m.CreatedAt) - .Take(12) - .ToListAsync(ct); + var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct); - foreach (var h in history.Reverse()) + foreach (var h in history) { messages.Add(new ChatMessage { - Role = h.Role == MessageRole.User ? "user" : "assistant", + Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant", Content = h.Content, }); } @@ -155,7 +177,9 @@ public static class AiChatEndpoints object toolResult; try { - toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value, visionClient); + toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments) + ? await PreparePendingWriteAsync(confirmations, userId.Value, tc.Function.Name, tc.Function.Arguments, ct) + : await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct); } catch (Exception ex) { @@ -173,71 +197,62 @@ public static class AiChatEndpoints // 保存 AI 回复 if (!string.IsNullOrEmpty(fullResponse)) - { - db.ConversationMessages.Add(new ConversationMessage - { - Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.Assistant, - Content = fullResponse, CreatedAt = DateTime.UtcNow, - }); - conversation.MessageCount++; - conversation.Summary = fullResponse.Length > 100 ? fullResponse[..100] : fullResponse; - conversation.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(ct); - } + await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct); await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct); await http.Response.WriteAsync("data: [DONE]\n\n", ct); }); + app.MapPost("/api/ai/confirm-write/{commandId:guid}", async ( + Guid commandId, + HttpContext http, + IAiToolExecutionService toolExecution, + CancellationToken ct) => + { + var userId = GetUserId(http); + if (userId == null) + return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401); + + var result = await toolExecution.ConfirmAsync(commandId, userId.Value, ct); + return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message }); + }).RequireAuthorization(); + // 获取对话列表 - app.MapGet("/api/ai/conversations", async (HttpContext http, AppDbContext db, CancellationToken ct) => + app.MapGet("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401); - var conversations = await db.Conversations - .Where(c => c.UserId == userId.Value) - .OrderByDescending(c => c.UpdatedAt) - .Select(c => new { c.Id, AgentType = c.AgentType.ToString(), c.Title, c.Summary, c.MessageCount, c.CreatedAt, c.UpdatedAt }) - .ToListAsync(ct); + var result = await conversations.ListAsync(userId.Value, ct); - return Results.Ok(new { code = 0, data = conversations, message = (string?)null }); + return Results.Ok(new { code = 0, data = result, message = (string?)null }); }); // 获取对话历史 - app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); - var messages = await db.ConversationMessages - .Where(m => m.ConversationId == id && m.Conversation.UserId == userId.Value) - .OrderBy(m => m.CreatedAt) - .Select(m => new { m.Id, Role = m.Role.ToString(), m.Content, m.Intent, m.MetadataJson, m.CreatedAt }) - .ToListAsync(ct); + var messages = await conversations.GetMessagesAsync(userId.Value, id, ct); return Results.Ok(new { code = 0, data = messages, message = (string?)null }); }); // 删除对话 - app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); - var conv = await db.Conversations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId.Value, ct); - if (conv != null) - { - db.Conversations.Remove(conv); - await db.SaveChangesAsync(ct); - } + await conversations.DeleteAsync(userId.Value, id, ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); - // VLM 食物识别 app.MapPost("/api/ai/analyze-food-image", async ( - HttpRequest httpRequest, HttpContext http, - VisionClient visionClient, AppDbContext db, + HttpRequest httpRequest, + HttpContext http, + IDietImageAnalysisCoordinator dietAnalysis, CancellationToken ct) => { var userId = GetUserId(http); @@ -245,61 +260,19 @@ public static class AiChatEndpoints var form = await httpRequest.ReadFormAsync(ct); var files = form.Files.GetFiles("images"); - if (files == null || files.Count == 0) - return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" }); - - var imageUrls = new List(); - var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); - Directory.CreateDirectory(uploadsDir); - - foreach (var file in files) + var uploads = files + .Select(file => new DietImageUploadFile(file.FileName, file.Length, file.OpenReadStream())) + .ToList(); + try { - if (file.Length > 20 * 1024 * 1024) - return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小超过 20MB 限制" }); - - var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); - if (ext is not ".jpg" and not ".jpeg" and not ".png" and not ".heic") - return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的图片格式,仅支持 JPG/PNG/HEIC" }); - - var safeName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}"; - var filePath = Path.Combine(uploadsDir, safeName); - using (var stream = new FileStream(filePath, FileMode.Create)) - await file.CopyToAsync(stream, ct); - - // 千问3.7-plus + vl_high_resolution_images=true 支持到 16M 像素,保留原图细节 - // base64 ≥ 7MB(官方限制)时才压缩,否则原图上传 - var fileBytes = await File.ReadAllBytesAsync(filePath, ct); - var base64 = Convert.ToBase64String(fileBytes); - if (base64.Length > 7 * 1024 * 1024) - { - var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}"); - CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 85L); - fileBytes = await File.ReadAllBytesAsync(compressedPath, ct); - base64 = Convert.ToBase64String(fileBytes); - } - imageUrls.Add($"data:image/jpeg;base64,{base64}"); + var result = await dietAnalysis.AnalyzeAsync(uploads, ct); + return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message }); + } + finally + { + foreach (var upload in uploads) + await upload.Content.DisposeAsync(); } - - var prompt = """ - 识别图片中的食物和饮品,返回JSON数组: - [{"name":"名称","portion":"份量","calories":热量}] - 只返回JSON,不要其他内容。 - """; - - try - { - var response = await visionClient.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct); - var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}"; - // 记录VLM原始返回用于排查 - var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); - var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.UtcNow:HHmmss}.txt"); - await File.WriteAllTextAsync(logPath, $"MODEL: {Environment.GetEnvironmentVariable("VLM_MODEL")}\nIMAGE_SIZE: {imageUrls.FirstOrDefault()?.Length ?? 0}\nRESPONSE:\n{result}", ct); - return Results.Ok(new { code = 0, data = result, message = (string?)null }); - } - catch (Exception ex) - { - return Results.Ok(new { code = 50001, data = (object?)null, message = $"食物识别失败:{ex.Message}" }); - } }); } @@ -348,60 +321,176 @@ public static class AiChatEndpoints _ => CommonAgentHandler.Tools, }; - private static Task ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId, VisionClient? visionClient = null) + private static bool IsWriteToolCall(string toolName, string arguments) { - using var jsonDoc = JsonDocument.Parse(arguments); - var root = jsonDoc.RootElement; + if (toolName == "record_health_data") return true; + if (toolName == "manage_archive") return GetToolAction(arguments) != "query"; + if (toolName == "manage_medication") return GetToolAction(arguments) is "create" or "confirm"; + if (toolName == "manage_exercise") return GetToolAction(arguments) is "create" or "checkin"; + return false; + } - return toolName switch + private static string GetToolAction(string arguments) + { + try { - "record_health_data" => HealthDataAgentHandler.Execute(toolName, root, db, userId), - "query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId), - "check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId), - "manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId), - "analyze_report" => visionClient != null - ? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient) - : Task.FromResult(new { success = false, message = "VLM服务未配置" }), - "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId), - "manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root), - _ => Task.FromResult(new { success = false, message = $"未知工具: {toolName}" }) + using var json = JsonDocument.Parse(arguments); + return json.RootElement.TryGetProperty("action", out var action) + ? action.GetString()?.ToLowerInvariant() ?? "" + : ""; + } + catch + { + return ""; + } + } + + private static async Task PreparePendingWriteAsync( + IAiWriteConfirmationStore confirmations, + Guid userId, + string toolName, + string arguments, + CancellationToken ct) + { + var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct); + using var json = JsonDocument.Parse(arguments); + var args = json.RootElement; + + var preview = new Dictionary + { + ["success"] = true, + ["pendingConfirmation"] = true, + ["confirmationId"] = command.Id, + ["message"] = "等待用户确认后写入", }; + + switch (toolName) + { + case "record_health_data": + AddHealthPreview(preview, args); + break; + case "manage_medication": + preview["type"] = "medication"; + preview["name"] = GetString(args, "name") ?? "服药确认"; + preview["dosage"] = GetString(args, "dosage") ?? ""; + preview["frequency"] = GetString(args, "frequency") ?? "Daily"; + preview["time"] = GetStringArray(args, "time_of_day"); + preview["duration_days"] = GetInt(args, "duration_days") ?? 0; + preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd"); + break; + case "manage_exercise": + preview["type"] = "exercise"; + preview["exercise_type"] = GetString(args, "exercise_type") ?? "运动"; + preview["duration_minutes"] = GetInt(args, "duration_minutes") ?? 30; + preview["day_count"] = GetInt(args, "duration_days") ?? 7; + preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd"); + preview["reminder_time"] = GetString(args, "reminder_time") ?? "19:00"; + break; + case "manage_archive": + preview["type"] = "archive"; + preview["value"] = "健康档案更新"; + preview["unit"] = ""; + break; + } + + return preview; } - // ── 患者上下文构建 ── - - private static async Task BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct) + private static void AddHealthPreview(Dictionary preview, JsonElement args) { - var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); - var recentRecords = await db.HealthRecords.Where(r => r.UserId == userId) - .OrderByDescending(r => r.RecordedAt).Take(10).ToListAsync(ct); - - var sb = new System.Text.StringBuilder(); - if (archive != null) + var type = GetString(args, "type") ?? ""; + switch (type) { - if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}"); - if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})"); - if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}"); - if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}"); + case "blood_pressure": + var systolic = GetInt(args, "systolic"); + var diastolic = GetInt(args, "diastolic"); + preview["type"] = HealthMetricType.BloodPressure.ToString(); + preview["value"] = $"{systolic}/{diastolic}"; + preview["unit"] = "mmHg"; + preview["isAbnormal"] = systolic >= 140 || diastolic >= 90 || systolic <= 89 || diastolic <= 59; + break; + case "heart_rate": + AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60); + break; + case "glucose": + AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m); + break; + case "spo2": + AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94); + break; + case "weight": + AddMetricPreview(preview, HealthMetricType.Weight, GetDecimal(args, "weight"), "kg", _ => false); + break; } - if (recentRecords.Count > 0) - { - sb.AppendLine("近期健康数据:"); - foreach (var r in recentRecords) - sb.AppendLine($" {r.MetricType}: {RecordValue(r)} ({r.RecordedAt:MM-dd HH:mm})"); - } - return sb.ToString(); } - private static string RecordValue(HealthRecord r) => r.MetricType switch + private static void AddMetricPreview( + Dictionary preview, + HealthMetricType type, + decimal? value, + string unit, + Func isAbnormal) { - HealthMetricType.BloodPressure => $"{r.Systolic}/{r.Diastolic}", - HealthMetricType.HeartRate => $"{r.Value}次/分", - HealthMetricType.Glucose => $"{r.Value}", - HealthMetricType.SpO2 => $"{r.Value}%", - HealthMetricType.Weight => $"{r.Value}kg", - _ => "—" - }; + preview["type"] = type.ToString(); + preview["value"] = value?.ToString() ?? ""; + preview["unit"] = unit; + preview["isAbnormal"] = value.HasValue && isAbnormal(value.Value); + } + + private static string? GetString(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static int? GetInt(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : null; + + private static decimal? GetDecimal(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.TryGetDecimal(out var result) ? result : null; + + private static string GetStringArray(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Array + ? string.Join(", ", value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x))) + : ""; + + private static string? DetectUrgentRisk(string message) + { + var text = message.Trim(); + if (string.IsNullOrEmpty(text)) return null; + + if (ContainsAny(text, "剧烈胸痛", "胸口剧痛", "胸痛明显", "胸闷憋气", "喘不上气", "呼吸困难", "呼吸很困难", "意识模糊", "昏厥", "晕厥", "说话不清", "口角歪斜", "一侧无力")) + return "你描述的症状可能属于需要紧急评估的危险信号。"; + + var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?\d{2,3})\s*/\s*(?\d{2,3})"); + if (bp.Success && + int.TryParse(bp.Groups["sys"].Value, out var systolic) && + int.TryParse(bp.Groups["dia"].Value, out var diastolic) && + (systolic >= 180 || diastolic >= 120)) + { + return $"你提到的血压 {systolic}/{diastolic} mmHg 已达到明显危险范围。"; + } + + var spo2 = MatchMetricValue(text, "血氧|氧饱和|SpO2|spo2"); + if (spo2.HasValue && spo2.Value <= 90) + return $"你提到的血氧 {spo2.Value:0.#}% 明显偏低。"; + + var glucose = MatchMetricValue(text, "血糖|葡萄糖|glucose"); + if (glucose.HasValue && (glucose.Value >= 16.7m || glucose.Value <= 3.0m)) + return $"你提到的血糖 {glucose.Value:0.#} mmol/L 属于需要尽快处理的异常范围。"; + + var heartRate = MatchMetricValue(text, "心率|脉搏|heart rate|HR|hr"); + if (heartRate.HasValue && (heartRate.Value >= 130 || heartRate.Value <= 45)) + return $"你提到的心率 {heartRate.Value:0.#} 次/分属于明显异常范围。"; + + return null; + } + + private static bool ContainsAny(string text, params string[] keywords) => + keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase)); + + private static decimal? MatchMetricValue(string text, string metricPattern) + { + var match = Regex.Match(text, $@"(?:{metricPattern})[^\d]{{0,8}}(?\d{{1,3}}(?:\.\d+)?)", RegexOptions.IgnoreCase); + return match.Success && decimal.TryParse(match.Groups["value"].Value, out var value) ? value : null; + } // ── JSON 类型安全转换 ── private static int _ToInt(object? v) => v switch @@ -435,39 +524,59 @@ public static class AiChatEndpoints catch { resultDict = new Dictionary(); } } + var isPendingConfirmation = resultDict != null + && resultDict.TryGetValue("pendingConfirmation", out var pendingValue) + && _ToBool(pendingValue); + if (isPendingConfirmation && resultDict!.TryGetValue("confirmationId", out var confirmationIdValue)) + { + var confirmationId = confirmationIdValue?.ToString()?.Trim('"'); + if (!string.IsNullOrWhiteSpace(confirmationId)) + { + if (!metadata.TryGetValue("confirmationIds", out var idsValue) || idsValue is not List ids) + { + ids = []; + metadata["confirmationIds"] = ids; + } + ids.Add(confirmationId); + } + } + switch (toolName) { case "record_health_data": + if (!isPendingConfirmation) break; messageType = "data_confirm"; if (resultDict != null) { - if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type.ToString(); - if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val.ToString(); - if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit.ToString(); - if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = abn is bool b2 ? b2 : false; - if (resultDict.TryGetValue("success", out var success)) metadata["success"] = success is bool b && b; + if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type?.ToString() ?? ""; + if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val?.ToString() ?? ""; + if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit?.ToString() ?? ""; + if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = _ToBool(abn); + if (resultDict.TryGetValue("success", out var success)) metadata["success"] = _ToBool(success); metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); } break; case "manage_medication": + if (!isPendingConfirmation) break; messageType = "medication_confirm"; if (resultDict != null) { if (resultDict.TryGetValue("name", out var name)) - metadata["name"] = name.ToString(); + metadata["name"] = name?.ToString() ?? ""; if (resultDict.TryGetValue("dosage", out var dosage)) - metadata["dosage"] = dosage.ToString(); + metadata["dosage"] = dosage?.ToString() ?? ""; if (resultDict.TryGetValue("time", out var time)) - metadata["time"] = time.ToString(); + metadata["time"] = time?.ToString() ?? ""; if (resultDict.TryGetValue("frequency", out var freq)) - metadata["frequency"] = freq.ToString(); - if (resultDict.TryGetValue("duration_days", out var dd2) && dd2 is int d2) - metadata["duration_days"] = d2; + metadata["frequency"] = freq?.ToString() ?? ""; + if (resultDict.TryGetValue("duration_days", out var dd2)) + metadata["duration_days"] = _ToInt(dd2); if (resultDict.TryGetValue("start_date", out var sd)) - metadata["startDate"] = sd.ToString(); + metadata["startDate"] = sd?.ToString() ?? ""; } break; case "manage_exercise": + if (!isPendingConfirmation) break; messageType = "data_confirm"; if (resultDict != null) { @@ -477,40 +586,27 @@ public static class AiChatEndpoints resultDict.TryGetValue("duration_minutes", out var dm); metadata["unit"] = dm != null ? $"每天{dm}分钟" : ""; resultDict.TryGetValue("day_count", out var dc); - if (dc != null) metadata["durationDays"] = dc.ToString(); + if (dc != null) metadata["durationDays"] = dc.ToString() ?? ""; resultDict.TryGetValue("success", out var ok); - metadata["success"] = ok?.ToString() == "True"; + metadata["success"] = _ToBool(ok); metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); } break; + case "manage_archive": + if (!isPendingConfirmation) break; + messageType = "data_confirm"; + metadata["type"] = "archive"; + metadata["value"] = "健康档案更新"; + metadata["unit"] = ""; + metadata["success"] = true; + metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); + break; case "analyze_report": messageType = "report_analysis"; break; } } - // ── 图片处理 ── - - private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality) - { - using var image = Image.FromFile(inputPath); - var width = image.Width; - var height = image.Height; - if (width > maxWidth) - { - height = (int)((double)height / width * maxWidth); - width = maxWidth; - } - using var bitmap = new Bitmap(width, height); - using var graphics = Graphics.FromImage(bitmap); - graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; - graphics.DrawImage(image, 0, 0, width, height); - - var jpegCodec = ImageCodecInfo.GetImageEncoders().First(c => c.MimeType == "image/jpeg"); - var parameters = new EncoderParameters(1); - parameters.Param[0] = new EncoderParameter(Encoder.Quality, quality); - bitmap.Save(outputPath, jpegCodec, parameters); - } } public sealed record ChatRequest(string Message, string? ConversationId); diff --git a/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs b/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs index c5e77f0..24f2501 100644 --- a/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs @@ -1,281 +1,30 @@ -using Health.Infrastructure.Services; +using Health.Application.Auth; namespace Health.WebApi.Endpoints; -/// -/// 认证相关 API 端点 -/// public static class AuthEndpoints { - private const string AdminPhone = "12345678910"; - private const string AdminSmsCode = "000000"; - public static void MapAuthEndpoints(this WebApplication app) { - // 发送短信验证码 - app.MapPost("/api/auth/send-sms", async ( - SendSmsRequest request, - AppDbContext db, - SmsService sms, - CancellationToken ct) => + app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) => + ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct))); + app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) => + ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct))); + app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) => + ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct))); + app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) => + ToResult(await auth.RefreshAsync(request.RefreshToken, ct))); + app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) => { - var code = request.Phone == AdminPhone ? AdminSmsCode : sms.GenerateCode(); - var vc = new VerificationCode - { - Id = Guid.NewGuid(), - Phone = request.Phone, - Code = code, - ExpiresAt = DateTime.UtcNow.AddMinutes(5), - }; - db.VerificationCodes.Add(vc); - await db.SaveChangesAsync(ct); - - if (request.Phone != AdminPhone) - await sms.SendCodeAsync(request.Phone, code); - - return Results.Ok(new { code = 0, data = new { success = true, devCode = code }, message = (string?)null }); - }); - - // ── 注册(仅限用户注册,需选择医生)── - app.MapPost("/api/auth/register", async ( - RegisterRequest request, - AppDbContext db, - JwtProvider jwt, - CancellationToken ct) => - { - // 验证码 - var validCode = await db.VerificationCodes - .Where(v => v.Phone == request.Phone - && v.Code == request.SmsCode - && v.ExpiresAt > DateTime.UtcNow - && !v.IsUsed) - .OrderByDescending(v => v.CreatedAt) - .FirstOrDefaultAsync(ct); - - if (validCode == null) - return Results.Ok(new { code = 40001, data = (object?)null, message = "验证码错误或已过期" }); - - validCode.IsUsed = true; - - // 检查手机号是否已注册 - var existing = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct); - if (existing != null) - return Results.Ok(new { code = 40002, data = (object?)null, message = "该手机号已注册,请直接登录" }); - - // 校验姓名 - if (string.IsNullOrWhiteSpace(request.Name)) - return Results.Ok(new { code = 40003, data = (object?)null, message = "请输入姓名" }); - - // 校验医生 - var doctor = await db.Doctors.FirstOrDefaultAsync(d => d.Id == request.DoctorId && d.IsActive, ct); - if (doctor == null) - return Results.Ok(new { code = 40004, data = (object?)null, message = "所选医生不存在或已停诊" }); - - var user = new User - { - Id = Guid.NewGuid(), - Phone = request.Phone, - Role = "User", - Name = request.Name, - DoctorId = request.DoctorId, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - db.Users.Add(user); - - db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id }); - db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id }); - - await db.SaveChangesAsync(ct); - - var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, "User"); - var refreshToken = jwt.GenerateRefreshToken(); - - db.RefreshTokens.Add(new RefreshToken - { - Id = Guid.NewGuid(), - UserId = user.Id, - Token = refreshToken, - ExpiresAt = DateTime.UtcNow.AddDays(30), - }); - await db.SaveChangesAsync(ct); - - return Results.Ok(new - { - code = 0, - data = new - { - accessToken, - refreshToken, - user = new { user.Id, user.Phone, user.Role, isNew = true } - }, - message = (string?)null - }); - }); - - // ── 登录(已有账号 / 管理员)── - app.MapPost("/api/auth/login", async ( - LoginRequest request, - AppDbContext db, - JwtProvider jwt, - CancellationToken ct) => - { - var validCode = await db.VerificationCodes - .Where(v => v.Phone == request.Phone - && v.Code == request.SmsCode - && v.ExpiresAt > DateTime.UtcNow - && !v.IsUsed) - .OrderByDescending(v => v.CreatedAt) - .FirstOrDefaultAsync(ct); - - if (validCode == null) - return Results.Ok(new { code = 40001, data = (object?)null, message = "验证码错误或已过期" }); - - validCode.IsUsed = true; - - // 管理员登录 - if (request.Phone == AdminPhone) - { - var adminAccessToken = jwt.GenerateAccessToken( - Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin"); - var adminRefreshToken = jwt.GenerateRefreshToken(); - db.RefreshTokens.Add(new RefreshToken - { - Id = Guid.NewGuid(), - UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"), - Token = adminRefreshToken, - ExpiresAt = DateTime.UtcNow.AddDays(30), - }); - await db.SaveChangesAsync(ct); - - return Results.Ok(new - { - code = 0, - data = new - { - accessToken = adminAccessToken, - refreshToken = adminRefreshToken, - user = new { id = "00000000-0000-0000-0000-000000000001", phone = AdminPhone, role = "Admin", name = "管理员", isNew = false } - }, - message = (string?)null - }); - } - - var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct); - if (user == null) - return Results.Ok(new { code = 40004, data = (object?)null, message = "该手机号未注册,请先登录" }); - - user.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(ct); - - var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role); - var refreshToken = jwt.GenerateRefreshToken(); - - db.RefreshTokens.Add(new RefreshToken - { - Id = Guid.NewGuid(), - UserId = user.Id, - Token = refreshToken, - ExpiresAt = DateTime.UtcNow.AddDays(30), - }); - await db.SaveChangesAsync(ct); - - return Results.Ok(new - { - code = 0, - data = new - { - accessToken, - refreshToken, - user = new { - user.Id, user.Phone, user.Role, user.Name, - user.Gender, user.AvatarUrl, - BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), - isNew = false - } - }, - message = (string?)null - }); - }); - - // 刷新 token - app.MapPost("/api/auth/refresh", async ( - RefreshRequest request, - AppDbContext db, - JwtProvider jwt, - CancellationToken ct) => - { - var oldToken = await db.RefreshTokens - .FirstOrDefaultAsync(t => t.Token == request.RefreshToken && !t.IsRevoked, ct); - - if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow) - return Results.Ok(new { code = 40002, data = (object?)null, message = "登录已过期,请重新登录" }); - - oldToken.IsRevoked = true; - - var user = await db.Users.FindAsync([oldToken.UserId], ct); - - // 管理员刷新:无 User 记录,直接用固定信息 - if (user == null && oldToken.UserId == Guid.Parse("00000000-0000-0000-0000-000000000001")) - { - var adminAccessToken = jwt.GenerateAccessToken( - Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin"); - var adminNewRefresh = jwt.GenerateRefreshToken(); - db.RefreshTokens.Add(new RefreshToken - { - Id = Guid.NewGuid(), - UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"), - Token = adminNewRefresh, - ExpiresAt = DateTime.UtcNow.AddDays(30), - }); - await db.SaveChangesAsync(ct); - return Results.Ok(new - { - code = 0, - data = new { accessToken = adminAccessToken, refreshToken = adminNewRefresh, user = new { role = "Admin" } }, - message = (string?)null - }); - } - - if (user == null) - return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" }); - - var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role); - var newRefreshToken = jwt.GenerateRefreshToken(); - db.RefreshTokens.Add(new RefreshToken - { - Id = Guid.NewGuid(), - UserId = user.Id, - Token = newRefreshToken, - ExpiresAt = DateTime.UtcNow.AddDays(30), - }); - await db.SaveChangesAsync(ct); - - return Results.Ok(new - { - code = 0, - data = new { accessToken, refreshToken = newRefreshToken, user = new { user.Role } }, - message = (string?)null - }); - }); - - // 登出 - app.MapPost("/api/auth/logout", async ( - RefreshRequest request, - AppDbContext db, - CancellationToken ct) => - { - var token = await db.RefreshTokens - .FirstOrDefaultAsync(t => t.Token == request.RefreshToken, ct); - if (token != null) token.IsRevoked = true; - await db.SaveChangesAsync(ct); - + await auth.LogoutAsync(request.RefreshToken, ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); } + + private static IResult ToResult(AuthResult result) => + Results.Ok(new { code = result.Code, data = result.Data, message = result.Message }); } -// ---- 请求 DTO ---- public sealed record SendSmsRequest(string Phone); public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId); public sealed record LoginRequest(string Phone, string SmsCode); diff --git a/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs b/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs index 49d2b53..f07f9b8 100644 --- a/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs @@ -1,3 +1,5 @@ +using Health.Application.Calendars; + namespace Health.WebApi.Endpoints; public static class CalendarEndpoints @@ -6,91 +8,22 @@ public static class CalendarEndpoints { var group = app.MapGroup("/api/calendar").RequireAuthorization(); - group.MapGet("/", async (int year, int month, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/", async (int year, int month, HttpContext http, ICalendarService calendar, CancellationToken ct) => { - var userId = GetUserId(http); - var start = new DateOnly(year, month, 1); - var end = start.AddMonths(1); - var startDt = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); - var endDt = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); - - var medications = await db.Medications - .Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0) - .ToListAsync(ct); - var plans = await db.ExercisePlans - .Where(p => p.UserId == userId).Include(p => p.Items).ToListAsync(ct); - var followups = await db.FollowUps - .Where(f => f.UserId == userId && f.ScheduledAt >= startDt && f.ScheduledAt < endDt) - .ToListAsync(ct); - - var result = new List(); - for (var d = start; d < end; d = d.AddDays(1)) - { - var detailList = new List(); - - // 用药:当前日期在用药有效期内 - var todayMedications = medications - .Where(m => m.StartDate <= d && (m.EndDate == null || m.EndDate >= d)); - foreach (var m in todayMedications) - { - detailList.Add(new { type = "medication", name = m.Name, dosage = m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() }); - } - - // 运动:按计划有效周范围 + 星期几匹配 - foreach (var p in plans) - { - var weekEnd = p.WeekStartDate.AddDays(6); - if (d >= p.WeekStartDate && d <= weekEnd) - { - var dayItems = p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay); - foreach (var i in dayItems) - detailList.Add(new { type = "exercise", name = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted }); - } - } - - // 随访 - var todayFollowups = followups.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == d); - foreach (var f in todayFollowups) - detailList.Add(new { type = "followup", title = f.Title, doctorName = f.DoctorName, department = f.Department, status = f.Status.ToString() }); - - if (detailList.Count > 0) - result.Add(new { date = d.ToString("yyyy-MM-dd"), events = detailList.Select(x => ((dynamic)x).type as string).Distinct().ToList(), details = detailList }); - } + if (year is < 2000 or > 2100 || month is < 1 or > 12) + return Results.Ok(new { code = 400, data = (object?)null, message = "年月格式错误" }); + var result = await calendar.GetMonthAsync(GetUserId(http), year, month, ct); return Results.Ok(new { code = 0, data = result, message = (string?)null }); }); - // 获取某一天的详细安排 - group.MapGet("/day", async (string date, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/day", async (string date, HttpContext http, ICalendarService calendar, CancellationToken ct) => { - var userId = GetUserId(http); - if (!DateOnly.TryParse(date, out var d)) + if (!DateOnly.TryParse(date, out var parsedDate)) return Results.Ok(new { code = 400, data = (object?)null, message = "日期格式错误" }); - var medications = await db.Medications - .Where(m => m.UserId == userId && m.IsActive && m.StartDate <= d && (m.EndDate == null || m.EndDate >= d)) - .Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() }) - .ToListAsync(ct); - - var plans = await db.ExercisePlans - .Where(p => p.UserId == userId) - .Include(p => p.Items).ToListAsync(ct); - var exercises = new List(); - foreach (var p in plans) - { - if (d >= p.WeekStartDate && d <= p.WeekStartDate.AddDays(6)) - { - foreach (var i in p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay)) - exercises.Add(new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted }); - } - } - - var followUps = await db.FollowUps - .Where(f => f.UserId == userId && DateOnly.FromDateTime(f.ScheduledAt) == d) - .Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() }) - .ToListAsync(ct); - - return Results.Ok(new { code = 0, data = new { medications, exercises, followUps }, message = (string?)null }); + var result = await calendar.GetDayAsync(GetUserId(http), parsedDate, ct); + return Results.Ok(new { code = 0, data = result, message = (string?)null }); }); } diff --git a/backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs b/backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs index 2943d55..1d14053 100644 --- a/backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs @@ -49,11 +49,14 @@ public static class ConsultationEndpoints group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, IHubContext hubContext, CancellationToken ct) => { var userId = GetUserId(http); - var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow }; + var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId, ct); + if (consultation == null) + return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" }); + + var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = consultation.Id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow }; db.ConsultationMessages.Add(msg); // 用户发消息后,状态从 AiTalking → WaitingDoctor - var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct); if (consultation != null && consultation.Status == ConsultationStatus.AiTalking) { consultation.Status = ConsultationStatus.WaitingDoctor; diff --git a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs index fdeb19a..4bef77e 100644 --- a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs @@ -1,3 +1,5 @@ +using Health.Application.Diets; + namespace Health.WebApi.Endpoints; public static class DietEndpoints @@ -6,82 +8,91 @@ public static class DietEndpoints { var group = app.MapGroup("/api/diet-records").RequireAuthorization(); - group.MapGet("/", async (string? date, string? mealType, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/", async (string? date, string? mealType, HttpContext http, IDietService diets, CancellationToken ct) => { var userId = GetUserId(http); - var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId); - if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d); - if (Enum.TryParse(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt); - var records = await query.OrderByDescending(r => r.RecordedAt).Select(r => new - { - r.Id, MealType = r.MealType.ToString(), r.TotalCalories, r.HealthScore, r.RecordedAt, - foodItems = r.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories }) - }).ToListAsync(ct); + var records = await diets.ListAsync(userId, date, mealType, ct); return Results.Ok(new { code = 0, data = records, message = (string?)null }); }); - group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPost("/", async (HttpRequest request, HttpContext http, IDietService diets, CancellationToken ct) => { var userId = GetUserId(http); - request.EnableBuffering(); - using var reader = new StreamReader(request.Body); - var body = await reader.ReadToEndAsync(ct); - request.Body.Position = 0; - using var json = JsonDocument.Parse(body); - var root = json.RootElement; - var record = new DietRecord - { - Id = Guid.NewGuid(), UserId = userId, - MealType = Enum.TryParse(root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch", out var meal) ? meal : MealType.Lunch, - TotalCalories = root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null, - HealthScore = root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null, - RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), - }; - if (root.TryGetProperty("foodItems", out var items)) - { - var i = 0; - foreach (var fi in items.EnumerateArray()) - { - record.FoodItems.Add(new DietFoodItem - { - Id = Guid.NewGuid(), Name = fi.TryGetProperty("name", out var n) ? n.GetString()! : "", - Portion = fi.TryGetProperty("portion", out var p) ? p.GetString() : null, - Calories = fi.TryGetProperty("calories", out var c) ? c.GetInt32() : null, - SortOrder = fi.TryGetProperty("sortOrder", out var so) ? so.GetInt32() : i, - }); - i++; - } - } - db.DietRecords.Add(record); - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null }); + using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct)); + var recordId = await diets.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct); + return Results.Ok(new { code = 0, data = new { id = recordId }, message = (string?)null }); }); - group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) => { var userId = GetUserId(http); - var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); - if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); } + await diets.DeleteAsync(userId, id, ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); - group.MapPut("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) => { var userId = GetUserId(http); - var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); - if (record == null) return Results.Ok(new { code = 40004, message = "记录不存在" }); + using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct)); + var updated = await diets.UpdateAsync(userId, id, ReadPatchRequest(json.RootElement), ct); + if (!updated) return Results.Ok(new { code = 40004, message = "记录不存在" }); - using var reader = new StreamReader(http.Request.Body); - var body = await reader.ReadToEndAsync(ct); - var json = System.Text.Json.JsonDocument.Parse(body); - if (json.RootElement.TryGetProperty("totalCalories", out var cal)) record.TotalCalories = (int?)cal.GetInt32(); - if (json.RootElement.TryGetProperty("healthScore", out var hs)) record.HealthScore = (int?)hs.GetInt32(); - await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); } + private static DietRecordCreateRequest ReadCreateRequest(JsonElement root) + { + var mealType = Enum.TryParse( + root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch", + out var meal) + ? meal + : MealType.Lunch; + var recordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) + ? d + : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + + return new DietRecordCreateRequest( + mealType, + root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null, + root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null, + recordedAt, + ReadFoodItems(root)); + } + + private static DietRecordPatchRequest ReadPatchRequest(JsonElement root) => new( + root.TryGetProperty("totalCalories", out var cal) ? cal.GetInt32() : null, + root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null); + + private static List ReadFoodItems(JsonElement root) + { + var result = new List(); + if (!root.TryGetProperty("foodItems", out var items) || items.ValueKind != JsonValueKind.Array) + return result; + + var i = 0; + foreach (var fi in items.EnumerateArray()) + { + result.Add(new DietFoodItemInput( + fi.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "", + fi.TryGetProperty("portion", out var p) ? p.GetString() : null, + fi.TryGetProperty("calories", out var c) ? c.GetInt32() : null, + fi.TryGetProperty("sortOrder", out var so) ? so.GetInt32() : i)); + i++; + } + + return result; + } + + private static async Task ReadBodyAsync(HttpRequest request, CancellationToken ct) + { + request.EnableBuffering(); + using var reader = new StreamReader(request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(ct); + request.Body.Position = 0; + return string.IsNullOrWhiteSpace(body) ? "{}" : body; + } + private static Guid GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } - diff --git a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs index 08fce27..52642e4 100644 --- a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs @@ -49,31 +49,34 @@ public static class DoctorEndpoints var profile = await GetDoctorProfile(http, db); if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在,请先完善个人信息" }); + if (profile.DoctorId == null) + return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); - var totalPatients = await db.Users.CountAsync(u => u.Role == "User"); - var activeConsultations = await db.Consultations.CountAsync(c => c.Status != ConsultationStatus.Closed); - var pendingReports = await db.Reports.CountAsync(r => r.Status == ReportStatus.PendingDoctor); + var doctorId = profile.DoctorId.Value; + var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId); + var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed); + var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor); var todayStart = DateTime.UtcNow.Date; var todayEnd = todayStart.AddDays(1); - var todayFollowUps = await db.FollowUps.CountAsync(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming); + var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming); // 待办列表 var pendingConsultations = await db.Consultations - .Where(c => c.Status == ConsultationStatus.WaitingDoctor) + .Where(c => c.User.DoctorId == doctorId && c.Status == ConsultationStatus.WaitingDoctor) .OrderByDescending(c => c.CreatedAt) .Take(5) .Select(c => new { c.Id, PatientName = c.User.Name ?? c.User.Phone, c.CreatedAt }) .ToListAsync(); var pendingReportList = await db.Reports - .Where(r => r.Status == ReportStatus.PendingDoctor) + .Where(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor) .OrderByDescending(r => r.CreatedAt) .Take(5) .Select(r => new { r.Id, PatientName = r.User.Name ?? r.User.Phone, r.Category, r.CreatedAt }) .ToListAsync(); var todayFollowUpList = await db.FollowUps - .Where(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming) + .Where(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming) .OrderBy(f => f.ScheduledAt) .Select(f => new { f.Id, f.Title, PatientName = f.User.Name ?? f.User.Phone, f.ScheduledAt }) .ToListAsync(); @@ -121,11 +124,15 @@ public static class DoctorEndpoints }); // ===== 患者详情 ===== - group.MapGet("/patients/{id:guid}", async (Guid id, AppDbContext db) => + group.MapGet("/patients/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) => { + var doctorId = await GetDoctorEntityId(http, db); + if (doctorId == null) + return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); + var user = await db.Users .Include(u => u.HealthArchive) - .FirstOrDefaultAsync(u => u.Id == id); + .FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId); if (user == null) return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" }); @@ -153,7 +160,7 @@ public static class DoctorEndpoints .ToListAsync(); var exercisePlans = await db.ExercisePlans - .Where(p => p.UserId == id).OrderByDescending(p => p.WeekStartDate).Take(1).Include(p => p.Items) + .Where(p => p.UserId == id).OrderByDescending(p => p.StartDate).Take(1).Include(p => p.Items) .FirstOrDefaultAsync(); var reports = await db.Reports @@ -177,7 +184,7 @@ public static class DoctorEndpoints trendRecords, medications, dietRecords = dietRecords.Select(d => new { d.Id, MealType = d.MealType.ToString(), d.TotalCalories, d.HealthScore, d.RecordedAt, foods = d.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories, f.Warning }) }), - exercisePlan = exercisePlans == null ? null : new { exercisePlans.WeekStartDate, items = exercisePlans.Items.Select(i => new { i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.IsRestDay, i.CompletedAt }) }, + exercisePlan = exercisePlans == null ? null : new { exercisePlans.StartDate, exercisePlans.EndDate, exercisePlans.ReminderTime, items = exercisePlans.Items.Select(i => new { i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.IsRestDay, i.CompletedAt }) }, reports, followUps }, message = (string?)null @@ -189,8 +196,10 @@ public static class DoctorEndpoints { var profile = await GetDoctorProfile(http, db); if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); var consultations = await db.Consultations + .Where(c => c.User.DoctorId == profile.DoctorId) .OrderByDescending(c => c.CreatedAt) .Select(c => new { c.Id, c.UserId, PatientName = c.User.Name, PatientPhone = c.User.Phone, Status = c.Status.ToString(), c.CreatedAt, c.ClosedAt, LastMessage = c.Messages.OrderByDescending(m => m.CreatedAt).Select(m => new { m.Content, SenderType = m.SenderType.ToString(), m.CreatedAt }).FirstOrDefault() }) .ToListAsync(); @@ -199,15 +208,22 @@ public static class DoctorEndpoints }); // ===== 问诊消息 ===== - group.MapGet("/consultations/{id:guid}/messages", async (Guid id, AppDbContext db) => + group.MapGet("/consultations/{id:guid}/messages", async (Guid id, HttpContext http, AppDbContext db) => { + var doctorId = await GetDoctorEntityId(http, db); + if (doctorId == null) + return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); + + var consultation = await db.Consultations + .FirstOrDefaultAsync(c => c.Id == id && c.User.DoctorId == doctorId); + if (consultation == null) + return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" }); + var messages = await db.ConsultationMessages - .Where(m => m.ConsultationId == id).OrderBy(m => m.CreatedAt) + .Where(m => m.ConsultationId == consultation.Id).OrderBy(m => m.CreatedAt) .Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt }) .ToListAsync(); - var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id); - return Results.Ok(new { code = 0, data = new { status = consultation?.Status.ToString() ?? "Closed", messages }, message = (string?)null }); }); @@ -216,8 +232,9 @@ public static class DoctorEndpoints { var profile = await GetDoctorProfile(http, db); if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); - var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct); + var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id && c.User.DoctorId == profile.DoctorId, ct); if (consultation == null) return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" }); using var reader = new StreamReader(http.Request.Body); @@ -249,9 +266,13 @@ public static class DoctorEndpoints }); // ===== 报告列表 ===== - group.MapGet("/reports", async (string? status, AppDbContext db) => + group.MapGet("/reports", async (string? status, HttpContext http, AppDbContext db) => { - var query = db.Reports.AsQueryable(); + var doctorId = await GetDoctorEntityId(http, db); + if (doctorId == null) + return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); + + var query = db.Reports.Where(r => r.User.DoctorId == doctorId); if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, out var s)) query = query.Where(r => r.Status == s); @@ -262,9 +283,13 @@ public static class DoctorEndpoints }); // ===== 报告详情 ===== - group.MapGet("/reports/{id:guid}", async (Guid id, AppDbContext db) => + group.MapGet("/reports/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) => { - var report = await db.Reports.Where(r => r.Id == id) + var doctorId = await GetDoctorEntityId(http, db); + if (doctorId == null) + return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); + + var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId) .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) .FirstOrDefaultAsync(); if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); @@ -276,8 +301,9 @@ public static class DoctorEndpoints { var profile = await GetDoctorProfile(http, db); if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); - var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id, ct); + var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.User.DoctorId == profile.DoctorId, ct); if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); using var reader = new StreamReader(http.Request.Body); @@ -299,9 +325,13 @@ public static class DoctorEndpoints }); // ===== 随访列表 ===== - group.MapGet("/follow-ups", async (string? status, string? patientId, AppDbContext db) => + group.MapGet("/follow-ups", async (string? status, string? patientId, HttpContext http, AppDbContext db) => { - var query = db.FollowUps.AsQueryable(); + var doctorId = await GetDoctorEntityId(http, db); + if (doctorId == null) + return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); + + var query = db.FollowUps.Where(f => f.User.DoctorId == doctorId); if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, out var s)) query = query.Where(f => f.Status == s); if (Guid.TryParse(patientId, out var pid)) @@ -318,15 +348,19 @@ public static class DoctorEndpoints { var profile = await GetDoctorProfile(http, db); if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); using var reader = new StreamReader(http.Request.Body); var body = await reader.ReadToEndAsync(ct); var json = System.Text.Json.JsonDocument.Parse(body); + var patientId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!); + var patientExists = await db.Users.AnyAsync(u => u.Id == patientId && u.DoctorId == profile.DoctorId, ct); + if (!patientExists) return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" }); var followUp = new FollowUp { Id = Guid.NewGuid(), - UserId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!), + UserId = patientId, Title = json.RootElement.GetProperty("title").GetString() ?? "", DoctorName = profile.Name, Department = profile.Department, @@ -343,7 +377,11 @@ public static class DoctorEndpoints // ===== 更新随访 ===== group.MapPut("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { - var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct); + var doctorId = await GetDoctorEntityId(http, db); + if (doctorId == null) + return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); + + var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id && f.User.DoctorId == doctorId, ct); if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" }); using var reader = new StreamReader(http.Request.Body); @@ -358,9 +396,13 @@ public static class DoctorEndpoints }); // ===== 删除随访 ===== - group.MapDelete("/follow-ups/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) => + group.MapDelete("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { - var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct); + var doctorId = await GetDoctorEntityId(http, db); + if (doctorId == null) + return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); + + var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id && f.User.DoctorId == doctorId, ct); if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" }); db.FollowUps.Remove(followUp); await db.SaveChangesAsync(ct); @@ -371,9 +413,9 @@ public static class DoctorEndpoints group.MapGet("/patients-simple", async (HttpContext http, AppDbContext db) => { var doctorId = await GetDoctorEntityId(http, db); - var query = db.Users.Where(u => u.Role == "User"); - if (doctorId != null) - query = query.Where(u => u.DoctorId == doctorId); + if (doctorId == null) + return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" }); + var query = db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId); var patients = await query.OrderBy(u => u.Name).Select(u => new { u.Id, u.Name, u.Phone }).ToListAsync(); return Results.Ok(new { code = 0, data = patients, message = (string?)null }); }); diff --git a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs index 77b3576..7b33ab6 100644 --- a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs @@ -1,3 +1,5 @@ +using Health.Application.Exercises; + namespace Health.WebApi.Endpoints; public static class ExerciseEndpoints @@ -6,126 +8,78 @@ public static class ExerciseEndpoints { var group = app.MapGroup("/api/exercise-plans").RequireAuthorization(); - group.MapGet("/current", async (HttpContext http, AppDbContext db) => + group.MapGet("/current", async (HttpContext http, IExerciseService exercises, CancellationToken ct) => { var userId = GetUserId(http); - var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); // 北京时间 - // 查找覆盖今天的所有计划(不仅限于周一开始的) - var plans = await db.ExercisePlans.Include(p => p.Items) - .Where(p => p.UserId == userId && p.WeekStartDate <= today) - .OrderByDescending(p => p.WeekStartDate) - .ToListAsync(); - var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.DayOfWeek == (int)today.DayOfWeek)); - if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null }); - return Results.Ok(new - { - code = 0, - data = new - { - plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt, - items = plan.Items.Select(i => new - { - i.Id, i.DayOfWeek, - i.ExerciseType, i.DurationMinutes, - i.IsCompleted, i.CompletedAt, i.IsRestDay - }) - }, - message = (string?)null - }); + var plan = await exercises.GetCurrentAsync(userId, ct); + return Results.Ok(new { code = 0, data = plan, message = (string?)null }); }); - group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPost("/", async (HttpRequest request, HttpContext http, IExerciseService exercises, CancellationToken ct) => { var userId = GetUserId(http); - request.EnableBuffering(); - using var reader = new StreamReader(request.Body); - var body = await reader.ReadToEndAsync(ct); - request.Body.Position = 0; + var body = await ReadBodyAsync(request, ct); using var json = JsonDocument.Parse(body); var root = json.RootElement; - var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动"; + + var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString() : "运动"; var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30; - var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd"))); - var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd"))); - var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate }; - for (var d = startDate; d <= endDate; d = d.AddDays(1)) - { - plan.Items.Add(new ExercisePlanItem - { - Id = Guid.NewGuid(), - DayOfWeek = (int)d.DayOfWeek, - ExerciseType = exerciseType, - DurationMinutes = duration, - IsRestDay = false, - }); - } - db.ExercisePlans.Add(plan); - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null }); + var startDate = DateOnly.Parse(root.TryGetProperty("startDate", out var sd) + ? sd.GetString()! + : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd")); + var endDate = DateOnly.Parse(root.TryGetProperty("endDate", out var ed) + ? ed.GetString()! + : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd")); + var reminderTime = root.TryGetProperty("reminderTime", out var rt) && TimeOnly.TryParse(rt.GetString(), out var parsedTime) + ? parsedTime + : new TimeOnly(19, 0); + + var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(startDate, endDate, exerciseType, duration, reminderTime), ct); + return Results.Ok(new { code = 0, data = new { id = planId }, message = (string?)null }); }); - group.MapGet("/", async (HttpContext http, AppDbContext db) => + group.MapGet("/", async (HttpContext http, IExerciseService exercises, CancellationToken ct) => { var userId = GetUserId(http); - var plans = await db.ExercisePlans.Where(p => p.UserId == userId) - .OrderByDescending(p => p.WeekStartDate).Take(20) - .Select(p => new - { - p.Id, p.WeekStartDate, p.CreatedAt, - totalDays = p.Items.Count, - completedDays = p.Items.Count(i => i.IsCompleted), - items = p.Items.OrderBy(i => i.DayOfWeek).Select(i => new - { - i.Id, i.DayOfWeek, - i.ExerciseType, i.DurationMinutes, - i.IsCompleted, i.IsRestDay - }) - }).ToListAsync(); + var plans = await exercises.ListAsync(userId, ct); return Results.Ok(new { code = 0, data = plans, message = (string?)null }); }); - group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) => + group.MapGet("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) => { var userId = GetUserId(http); - var plan = await db.ExercisePlans.Include(p => p.Items) - .FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId); + var plan = await exercises.GetByIdAsync(userId, id, ct); if (plan == null) return Results.Ok(new { code = 40004, message = "不存在" }); - return Results.Ok(new - { - code = 0, - data = new - { - plan.Id, plan.WeekStartDate, plan.CreatedAt, - items = plan.Items.OrderBy(i => i.DayOfWeek).Select(i => new - { - i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, - i.IsCompleted, i.CompletedAt, i.IsRestDay - }) - }, - message = (string?)null - }); + + return Results.Ok(new { code = 0, data = plan, message = (string?)null }); }); - group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) => { var userId = GetUserId(http); - var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId, ct); - if (plan != null) { db.ExercisePlans.Remove(plan); await db.SaveChangesAsync(ct); } + await exercises.DeleteAsync(userId, id, ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); - group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) => { - var item = await db.ExercisePlanItems.FindAsync([itemId], ct); - if (item == null) return Results.Ok(new { code = 40004, message = "不存在" }); - item.IsCompleted = !item.IsCompleted; - item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null; - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { completed = item.IsCompleted }, message = (string?)null }); + var userId = GetUserId(http); + var completed = await exercises.ToggleCheckInAsync(userId, itemId, ct); + if (completed == null) return Results.Ok(new { code = 40004, message = "不存在" }); + + return Results.Ok(new { code = 0, data = new { completed }, message = (string?)null }); }); } + private static async Task ReadBodyAsync(HttpRequest request, CancellationToken ct) + { + request.EnableBuffering(); + using var reader = new StreamReader(request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(ct); + request.Body.Position = 0; + return string.IsNullOrWhiteSpace(body) ? "{}" : body; + } + private static Guid GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } - diff --git a/backend/src/Health.WebApi/Endpoints/file_endpoints.cs b/backend/src/Health.WebApi/Endpoints/file_endpoints.cs index f94b324..c06890d 100644 --- a/backend/src/Health.WebApi/Endpoints/file_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/file_endpoints.cs @@ -2,26 +2,55 @@ namespace Health.WebApi.Endpoints; public static class FileEndpoints { + private const long MaxFileSize = 20 * 1024 * 1024; + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf" + }; + public static void MapFileEndpoints(this WebApplication app) { var group = app.MapGroup("/api/files").RequireAuthorization(); - group.MapPost("/upload", async (HttpRequest request) => + group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) => { - var form = await request.ReadFormAsync(); + if (!request.HasFormContentType) + return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" }); + + var form = await request.ReadFormAsync(ct); var files = form.Files; + if (files.Count == 0) + return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" }); + var results = new List(); var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); Directory.CreateDirectory(uploadsDir); foreach (var file in files) { + if (file.Length <= 0) + continue; + + if (file.Length > MaxFileSize) + return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小不能超过 20MB" }); + var fileId = Guid.NewGuid().ToString(); - var ext = Path.GetExtension(file.FileName); + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (!AllowedExtensions.Contains(ext)) + return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的文件类型" }); + + var storedName = $"{fileId}{ext}"; var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}"); - using var stream = new FileStream(filePath, FileMode.Create); - await file.CopyToAsync(stream); - results.Add(new { id = fileId, name = file.FileName, size = file.Length }); + await using var stream = new FileStream(filePath, FileMode.Create); + await file.CopyToAsync(stream, ct); + results.Add(new + { + id = fileId, + name = file.FileName, + size = file.Length, + url = $"/uploads/{storedName}", + contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType + }); } return Results.Ok(new { code = 0, data = results, message = (string?)null }); diff --git a/backend/src/Health.WebApi/Endpoints/health_endpoints.cs b/backend/src/Health.WebApi/Endpoints/health_endpoints.cs index 57dfd91..750e82c 100644 --- a/backend/src/Health.WebApi/Endpoints/health_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/health_endpoints.cs @@ -1,3 +1,5 @@ +using Health.Application.HealthRecords; + namespace Health.WebApi.Endpoints; /// @@ -9,127 +11,82 @@ public static class HealthEndpoints { var group = app.MapGroup("/api/health-records").RequireAuthorization(); - // 查询健康记录 group.MapGet("/", async ( - string? type, int? days, - HttpContext http, AppDbContext db, CancellationToken ct) => + string? type, + int? days, + HttpContext http, + IHealthRecordService healthRecords, + CancellationToken ct) => { var userId = GetUserId(http); - var query = db.HealthRecords.Where(r => r.UserId == userId); - - if (!string.IsNullOrEmpty(type) && Enum.TryParse(type, ignoreCase: true, out var mt)) - query = query.Where(r => r.MetricType == mt); - - if (days.HasValue) - query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days.Value)); - - var records = await query.OrderByDescending(r => r.RecordedAt).Take(100) - .Select(r => new - { - r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, - Source = r.Source.ToString(), r.IsAbnormal, r.RecordedAt - }).ToListAsync(ct); - + var records = await healthRecords.GetRecordsAsync(userId, type, days, ct); return Results.Ok(new { code = 0, data = records, message = (string?)null }); }); - // 新增健康记录 - group.MapPost("/", async (CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPost("/", async ( + CreateHealthRecordRequest req, + HttpContext http, + IHealthRecordService healthRecords, + CancellationToken ct) => { var userId = GetUserId(http); - var record = new HealthRecord - { - Id = Guid.NewGuid(), UserId = userId, MetricType = req.Type, - Systolic = req.Systolic, Diastolic = req.Diastolic, Value = req.Value, - Unit = req.Unit, Source = req.Source, RecordedAt = req.RecordedAt ?? DateTime.UtcNow, - IsAbnormal = CheckAbnormal(req), - }; - db.HealthRecords.Add(record); - await db.SaveChangesAsync(ct); - - return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null }); + var id = await healthRecords.CreateAsync(userId, req.ToServiceRequest(), ct); + return Results.Ok(new { code = 0, data = new { id }, message = (string?)null }); }); - // 修改健康记录 - group.MapPut("/{id:guid}", async (Guid id, CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPut("/{id:guid}", async ( + Guid id, + CreateHealthRecordRequest req, + HttpContext http, + IHealthRecordService healthRecords, + CancellationToken ct) => { var userId = GetUserId(http); - var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); - if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" }); - - record.Systolic = req.Systolic; - record.Diastolic = req.Diastolic; - record.Value = req.Value; - record.Unit = req.Unit; - record.RecordedAt = req.RecordedAt ?? record.RecordedAt; - record.IsAbnormal = CheckAbnormal(req); - await db.SaveChangesAsync(ct); - - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + var ok = await healthRecords.UpdateAsync(userId, id, req.ToServiceRequest(), ct); + return ok + ? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }) + : Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" }); }); - // 删除健康记录 - group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapDelete("/{id:guid}", async ( + Guid id, + HttpContext http, + IHealthRecordService healthRecords, + CancellationToken ct) => { var userId = GetUserId(http); - var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); - if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" }); - db.HealthRecords.Remove(record); - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + var ok = await healthRecords.DeleteAsync(userId, id, ct); + return ok + ? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }) + : Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" }); }); - // 获取各指标最新值 - group.MapGet("/latest", async (HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/latest", async ( + HttpContext http, + IHealthRecordService healthRecords, + CancellationToken ct) => { var userId = GetUserId(http); - var types = new[] { HealthMetricType.BloodPressure, HealthMetricType.HeartRate, HealthMetricType.Glucose, HealthMetricType.SpO2, HealthMetricType.Weight }; - var result = new Dictionary(); - - foreach (var t in types) - { - var latest = await db.HealthRecords - .Where(r => r.UserId == userId && r.MetricType == t) - .OrderByDescending(r => r.RecordedAt) - .FirstOrDefaultAsync(ct); - - result[t.ToString()] = latest == null ? null : new - { - latest.Systolic, latest.Diastolic, latest.Value, latest.Unit, latest.RecordedAt - }; - } - - return Results.Ok(new { code = 0, data = result, message = (string?)null }); + var latest = await healthRecords.GetLatestAsync(userId, ct); + return Results.Ok(new { code = 0, data = latest, message = (string?)null }); }); - // 趋势数据 - group.MapGet("/trend", async (string type, int period, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/trend", async ( + string type, + int period, + HttpContext http, + IHealthRecordService healthRecords, + CancellationToken ct) => { var userId = GetUserId(http); - if (!Enum.TryParse(type, ignoreCase: true, out var mt)) + if (!Enum.TryParse(type, ignoreCase: true, out var metricType)) return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的指标类型" }); - var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 }; - var records = await db.HealthRecords - .Where(r => r.UserId == userId && r.MetricType == mt && r.RecordedAt >= DateTime.UtcNow.AddDays(-days)) - .OrderBy(r => r.RecordedAt) - .Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt }) - .ToListAsync(ct); - + var records = await healthRecords.GetTrendAsync(userId, metricType, period, ct); return Results.Ok(new { code = 0, data = records, message = (string?)null }); }); } - private static bool CheckAbnormal(CreateHealthRecordRequest req) => req.Type switch - { - HealthMetricType.BloodPressure => req.Systolic >= 140 || req.Diastolic >= 90 || req.Systolic <= 89 || req.Diastolic <= 59, - HealthMetricType.HeartRate => req.Value > 100 || req.Value < 60, - HealthMetricType.Glucose => req.Value >= 7.0m || req.Value <= 3.8m, - HealthMetricType.SpO2 => req.Value <= 94, - HealthMetricType.Weight => false, // 体重无标准异常范围 - _ => false - }; - private static Guid GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } @@ -143,4 +100,13 @@ public sealed class CreateHealthRecordRequest public string? Unit { get; init; } public HealthRecordSource Source { get; init; } public DateTime? RecordedAt { get; init; } + + public HealthRecordUpsertRequest ToServiceRequest() => new( + Type, + Systolic, + Diastolic, + Value, + Unit, + Source, + RecordedAt); } diff --git a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs index e64f753..1f8e560 100644 --- a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs @@ -1,3 +1,5 @@ +using Health.Application.Medications; + namespace Health.WebApi.Endpoints; public static class MedicationEndpoints @@ -6,255 +8,139 @@ public static class MedicationEndpoints { var group = app.MapGroup("/api/medications").RequireAuthorization(); - group.MapGet("/", async (string? filter, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/", async (string? filter, HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - var query = db.Medications.Where(m => m.UserId == userId); - if (filter == "active") query = query.Where(m => m.IsActive); - else if (filter == "inactive") query = query.Where(m => !m.IsActive); - // 北京时间今天对应的 UTC 区间 - var beijingToday = DateTime.UtcNow.AddHours(8).Date; - var todayStartUtc = beijingToday.AddHours(-8); - var todayEndUtc = todayStartUtc.AddDays(1); - var meds = await query.OrderByDescending(m => m.CreatedAt).Select(m => new - { - m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(), - m.TimeOfDay, m.StartDate, m.EndDate, m.IsActive, m.Notes, - Source = m.Source.ToString(), m.CreatedAt, m.UpdatedAt, - todayTaken = m.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken) - }).ToListAsync(ct); + var meds = await medications.ListAsync(userId, filter, ct); return Results.Ok(new { code = 0, data = meds, message = (string?)null }); }); - group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPost("/", async (HttpRequest request, HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - request.EnableBuffering(); - using var reader = new StreamReader(request.Body); - var body = await reader.ReadToEndAsync(ct); - request.Body.Position = 0; - using var json = JsonDocument.Parse(body); - var root = json.RootElement; - var med = new Medication - { - Id = Guid.NewGuid(), UserId = userId, - Name = root.GetProperty("name").GetString()!, - Dosage = root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null, - Frequency = Enum.TryParse(root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily", out var freq) ? freq : MedicationFrequency.Daily, - Source = Enum.TryParse(root.TryGetProperty("source", out var s) ? s.GetString() : "Manual", out var src) ? src : MedicationSource.Manual, - IsActive = true, - }; - if (root.TryGetProperty("timeOfDay", out var tod)) - med.TimeOfDay = tod.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList(); - if (root.TryGetProperty("startDate", out var sd)) - med.StartDate = DateOnly.TryParse(sd.GetString(), out var d) ? d : null; - if (root.TryGetProperty("endDate", out var ed)) - med.EndDate = DateOnly.TryParse(ed.GetString(), out var d2) ? d2 : null; - if (root.TryGetProperty("notes", out var nt)) med.Notes = nt.GetString(); - db.Medications.Add(med); - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null }); + using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct)); + var medId = await medications.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct); + return Results.Ok(new { code = 0, data = new { id = medId }, message = (string?)null }); }); - group.MapPut("/{id:guid}", async (Guid id, HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPut("/{id:guid}", async (Guid id, HttpRequest request, HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct); - if (med == null) return Results.Ok(new { code = 40004, message = "不存在" }); - request.EnableBuffering(); - using var reader = new StreamReader(request.Body); - var body = await reader.ReadToEndAsync(ct); - request.Body.Position = 0; - using var json = JsonDocument.Parse(body); - var root = json.RootElement; - if (root.TryGetProperty("name", out var n)) med.Name = n.GetString()!; - if (root.TryGetProperty("dosage", out var dg2)) med.Dosage = dg2.GetString(); - if (root.TryGetProperty("frequency", out var f2) && Enum.TryParse(f2.GetString(), out var freq2)) med.Frequency = freq2; - if (root.TryGetProperty("timeOfDay", out var tod2)) med.TimeOfDay = tod2.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList(); - if (root.TryGetProperty("startDate", out var sd2) && DateOnly.TryParse(sd2.GetString(), out var d3)) med.StartDate = d3; - if (root.TryGetProperty("endDate", out var ed2) && DateOnly.TryParse(ed2.GetString(), out var d4)) med.EndDate = d4; - if (root.TryGetProperty("notes", out var nt2)) med.Notes = nt2.GetString(); - med.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(ct); + using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct)); + var updated = await medications.UpdateAsync(userId, id, ReadPatchRequest(json.RootElement), ct); + if (!updated) return Results.Ok(new { code = 40004, message = "不存在" }); + return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); - group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct); - if (med != null) { db.Medications.Remove(med); await db.SaveChangesAsync(ct); } + await medications.DeleteAsync(userId, id, ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); - group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - var beijingToday = DateTime.UtcNow.AddHours(8).Date; - var todayStartUtc = beijingToday.AddHours(-8); - var todayEndUtc = todayStartUtc.AddDays(1); - var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken, ct); - if (existing != null) { - db.MedicationLogs.Remove(existing); - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { taken = false }, message = (string?)null }); - } - var log = new MedicationLog - { - Id = Guid.NewGuid(), MedicationId = id, UserId = userId, - Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow, - }; - db.MedicationLogs.Add(log); - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { taken = true }, message = (string?)null }); + var taken = await medications.ToggleTodayTakenAsync(userId, id, ct); + if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" }); + + return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null }); }); - group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - var beijingNow = DateTime.UtcNow.AddHours(8); - var now = TimeOnly.FromDateTime(beijingNow); - var today = DateOnly.FromDateTime(beijingNow); - var todayUtc = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc); - - // 只查活跃且在有效期内的药 - var meds = await db.Medications - .Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null - && m.StartDate <= today && (m.EndDate == null || m.EndDate >= today)) - .ToListAsync(ct); - - // 查询今天所有服药记录 - var logs = await db.MedicationLogs - .Where(l => l.UserId == userId && l.ConfirmedAt >= todayUtc) - .ToListAsync(ct); - - // 按次生成提醒 - var reminders = new List(); - foreach (var m in meds) - { - // 处理隔天/每周的逻辑 - var dosesToday = GetDosesForToday(m, today); - if (!dosesToday) continue; - - foreach (var t in m.TimeOfDay!) - { - var scheduledTime = t; - var doseTimeBeijing = today.ToDateTime(scheduledTime); - - // 查该药该时间是否已打卡 - var alreadyLogged = logs.Any(l => - l.MedicationId == m.Id && l.ScheduledTime == scheduledTime); - - string status; - if (alreadyLogged) - { - var log = logs.First(l => l.MedicationId == m.Id && l.ScheduledTime == scheduledTime); - status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped"; - } - else if (scheduledTime <= now.AddMinutes(-30)) - { - status = "overdue"; // 过了30分钟还没吃 - } - else if (scheduledTime <= now.AddHours(3)) - { - status = "upcoming"; // 未来3小时内 - } - else - { - continue; // 太远,不显示 - } - - reminders.Add(new - { - m.Id, m.Name, m.Dosage, - scheduledTime = scheduledTime.ToString("HH:mm"), - status, - }); - } - } - - // overdue 在前,upcoming 在后,taken 在最后 - reminders = reminders.OrderBy(r => - ((dynamic)r).status == "overdue" ? 0 : - ((dynamic)r).status == "upcoming" ? 1 : 2 - ).ToList(); - + var reminders = await medications.GetRemindersAsync(userId, ct); return Results.Ok(new { code = 0, data = reminders, message = (string?)null }); }); - // 按次打卡 - group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct); - if (med == null) return Results.Ok(new { code = 404, message = "药品不存在" }); - - using var reader = new System.IO.StreamReader(http.Request.Body); - var body = await reader.ReadToEndAsync(ct); - var json = System.Text.Json.JsonDocument.Parse(body); + using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct)); var timeStr = json.RootElement.GetProperty("scheduledTime").GetString()!; var statusStr = json.RootElement.TryGetProperty("status", out var st) ? st.GetString() : "taken"; var scheduledTime = TimeOnly.Parse(timeStr); var status = statusStr == "taken" ? MedicationLogStatus.Taken : MedicationLogStatus.Skipped; - // 防止重复打卡 - var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc); - var existing = await db.MedicationLogs.AnyAsync(l => - l.MedicationId == id && l.UserId == userId - && l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc, ct); - if (existing) return Results.Ok(new { code = 0, data = new { success = false, message = "已打卡" }, message = (string?)null }); + var result = await medications.ConfirmDoseAsync(userId, id, scheduledTime, status, ct); + if (result == null) return Results.Ok(new { code = 404, message = "药品不存在" }); + if (result == false) return Results.Ok(new { code = 0, data = new { success = false, message = "已打卡" }, message = (string?)null }); - db.MedicationLogs.Add(new MedicationLog - { - Id = Guid.NewGuid(), MedicationId = id, UserId = userId, - Status = status, ScheduledTime = scheduledTime, ConfirmedAt = DateTime.UtcNow, - }); - await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); - // 取消打卡(删掉对应 log) - group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - var scheduledTime = TimeOnly.Parse(time); - var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc); - var log = await db.MedicationLogs - .Where(l => l.MedicationId == id && l.UserId == userId - && l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc) - .FirstOrDefaultAsync(ct); - if (log != null) { db.MedicationLogs.Remove(log); await db.SaveChangesAsync(ct); } + await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); } - /// - /// 判断今天是否该吃药(处理隔天/每周频率) - /// - private static bool GetDosesForToday(Medication m, DateOnly today) + private static MedicationUpsertRequest ReadCreateRequest(JsonElement root) { - if (m.Frequency == MedicationFrequency.Daily) return true; - if (m.Frequency == MedicationFrequency.TwiceDaily) return true; - if (m.Frequency == MedicationFrequency.ThreeTimesDaily) return true; - if (m.Frequency == MedicationFrequency.AsNeeded) return true; + var frequency = Enum.TryParse( + root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily", + out var freq) + ? freq + : MedicationFrequency.Daily; + var source = Enum.TryParse( + root.TryGetProperty("source", out var s) ? s.GetString() : "Manual", + out var src) + ? src + : MedicationSource.Manual; - var startDate = m.StartDate ?? today; - if (m.Frequency == MedicationFrequency.EveryOtherDay) - { - var daysSinceStart = today.DayNumber - startDate.DayNumber; - return daysSinceStart % 2 == 0; - } - if (m.Frequency == MedicationFrequency.Weekly) - { - return today.DayOfWeek == startDate.DayOfWeek; - } - return true; + return new MedicationUpsertRequest( + root.GetProperty("name").GetString()!, + root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null, + frequency, + ReadTimes(root, "timeOfDay"), + root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null, + root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null, + source, + root.TryGetProperty("notes", out var nt) ? nt.GetString() : null); } - private static bool InTimeWindow(TimeOnly t, TimeOnly start, TimeOnly end) => - end > start ? (t >= start && t <= end) : (t >= start || t <= end); // 处理跨午夜 + private static MedicationPatchRequest ReadPatchRequest(JsonElement root) + { + MedicationFrequency? frequency = null; + if (root.TryGetProperty("frequency", out var f) && Enum.TryParse(f.GetString(), out var freq)) + frequency = freq; + + return new MedicationPatchRequest( + root.TryGetProperty("name", out var n) ? n.GetString() : null, + root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null, + frequency, + root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null, + root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null, + root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null, + root.TryGetProperty("notes", out var nt) ? nt.GetString() : null); + } + + private static List ReadTimes(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var tod) || tod.ValueKind != JsonValueKind.Array) + return []; + + return tod.EnumerateArray() + .Select(t => TimeOnly.TryParse(t.GetString(), out var parsed) ? parsed : (TimeOnly?)null) + .Where(t => t.HasValue) + .Select(t => t!.Value) + .ToList(); + } + + private static async Task ReadBodyAsync(HttpRequest request, CancellationToken ct) + { + request.EnableBuffering(); + using var reader = new StreamReader(request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(ct); + request.Body.Position = 0; + return string.IsNullOrWhiteSpace(body) ? "{}" : body; + } private static Guid GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } - diff --git a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs new file mode 100644 index 0000000..f12fbb2 --- /dev/null +++ b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs @@ -0,0 +1,31 @@ +using System.Security.Claims; +using Health.Application.Notifications; + +namespace Health.WebApi.Endpoints; + +public static class NotificationEndpoints +{ + public static void MapNotificationEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/notifications").RequireAuthorization(); + + group.MapGet("/pending", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => + { + var userId = GetUserId(http); + if (userId == Guid.Empty) return Results.Unauthorized(); + var result = await notifications.GetPendingAsync(userId, ct); + return Results.Ok(new { code = 0, data = result, message = (string?)null }); + }); + + group.MapPost("/{id:guid}/acknowledge", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => + { + var userId = GetUserId(http); + if (userId == Guid.Empty) return Results.Unauthorized(); + var acknowledged = await notifications.AcknowledgeAsync(userId, id, ct); + return Results.Ok(new { code = 0, data = new { acknowledged }, message = (string?)null }); + }); + } + + private static Guid GetUserId(HttpContext http) => + Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; +} diff --git a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs index b5b66f8..e919ba4 100644 --- a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs @@ -1,4 +1,4 @@ -using Health.Infrastructure.AI; +using Health.Application.Reports; namespace Health.WebApi.Endpoints; @@ -8,294 +8,62 @@ public static class ReportEndpoints { var group = app.MapGroup("/api/reports").RequireAuthorization(); - // 列表 - group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/", async (HttpContext http, IReportService reports, CancellationToken ct) => { var userId = GetUserId(http); - var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct); - return Results.Ok(new { code = 0, data = reports, message = (string?)null }); + var data = await reports.GetReportsAsync(userId, ct); + return Results.Ok(new { code = 0, data, message = (string?)null }); }); - // 删除 - group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) => { var userId = GetUserId(http); - var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); - if (report == null) return Results.Ok(new { code = 40004, message = "不存在" }); - // 删除本地文件 - if (!string.IsNullOrEmpty(report.FileUrl)) - { - var filePath = Path.Combine(Directory.GetCurrentDirectory(), report.FileUrl.TrimStart('/')); - if (File.Exists(filePath)) File.Delete(filePath); - } - db.Reports.Remove(report); - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, message = "已删除" }); + var report = await reports.GetReportAsync(userId, id, ct); + return report == null + ? Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" }) + : Results.Ok(new { code = 0, data = report, message = (string?)null }); }); - // 详情 - group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => - { - var userId = GetUserId(http); - var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); - return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null }); - }); - - // 上传 + AI 预解读(VLM 提取指标 → LLM 生成摘要) - group.MapPost("/", async (HttpContext http, AppDbContext db, IServiceScopeFactory scopeFactory, CancellationToken ct) => + group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) => { var userId = GetUserId(http); - // 读 multipart form if (!http.Request.HasFormContentType) - return Results.Ok(new { code = 400, message = "需要 multipart/form-data" }); + return Results.Ok(new { code = 400, data = (object?)null, message = "需要 multipart/form-data" }); var form = await http.Request.ReadFormAsync(ct); var file = form.Files.GetFile("file"); - if (file == null || file.Length == 0) - return Results.Ok(new { code = 400, message = "未上传文件" }); + if (file == null) + return Results.Ok(new { code = 400, data = (object?)null, message = "未上传文件" }); - // 保存文件 - var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports"); - Directory.CreateDirectory(uploadsDir); - var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}"; - var filePath = Path.Combine(uploadsDir, fileName); - await using (var stream = new FileStream(filePath, FileMode.Create)) - await file.CopyToAsync(stream, ct); + await using var stream = file.OpenReadStream(); + var result = await reports.UploadReportAsync( + userId, + new ReportUploadFile(file.FileName, file.Length, stream), + ct); - var fileUrl = $"/uploads/reports/{fileName}"; - var isPdf = Path.GetExtension(fileName).ToLowerInvariant() == ".pdf"; + return Results.Ok(new { code = result.Code, data = result.Report, message = result.Message }); + }); - // 创建报告记录 - var report = new Report - { - Id = Guid.NewGuid(), UserId = userId, - FileUrl = fileUrl, - FileType = isPdf ? ReportFileType.Pdf : ReportFileType.Image, - Category = ReportCategory.Other, - Status = ReportStatus.PendingDoctor, - CreatedAt = DateTime.UtcNow, - }; - db.Reports.Add(report); - await db.SaveChangesAsync(ct); + group.MapPost("/{id:guid}/reanalyze", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) => + { + var userId = GetUserId(http); + var ok = await reports.ReanalyzeReportAsync(userId, id, ct); + return ok + ? Results.Ok(new { code = 0, data = new { success = true }, message = "已重新提交 AI 分析" }) + : Results.Ok(new { code = 40004, data = (object?)null, message = "报告不存在或原始文件丢失" }); + }); - // 异步 AI 分析(用独立 scope,不依赖请求生命周期) - var reportId = report.Id; - _ = Task.Run(async () => - { - try - { - using var scope = scopeFactory.CreateScope(); - var taskDb = scope.ServiceProvider.GetRequiredService(); - var taskVision = scope.ServiceProvider.GetRequiredService(); - var taskLlm = scope.ServiceProvider.GetRequiredService(); - await AnalyzeReportWithAI(reportId, filePath, taskVision, taskLlm, taskDb); - } - catch (Exception ex) - { - Console.WriteLine($"[Report] AI 分析失败: {ex.Message}"); - // 回退:写入 fallback 数据 - try - { - using var fbScope = scopeFactory.CreateScope(); - var fbDb = fbScope.ServiceProvider.GetRequiredService(); - var fbReport = await fbDb.Reports.FirstOrDefaultAsync(r => r.Id == reportId); - if (fbReport != null) - { - fbReport.AiSummary = GenerateFallbackSummary(fbReport.Category); - fbReport.AiIndicators = GenerateFallbackIndicators(fbReport.Category); - await fbDb.SaveChangesAsync(); - } - } - catch { } - } - }, CancellationToken.None); - - return Results.Ok(new { code = 0, data = new { report.Id, report.Status, report.FileUrl }, message = "报告已上传,AI 正在分析中" }); + group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) => + { + var userId = GetUserId(http); + var ok = await reports.DeleteReportAsync(userId, id, ct); + return ok + ? Results.Ok(new { code = 0, data = new { success = true }, message = "已删除" }) + : Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" }); }); } - /// - /// VLM 识别 + LLM 解读 - /// - private static async Task AnalyzeReportWithAI( - Guid reportId, string filePath, - VisionClient vision, DeepSeekClient llm, AppDbContext db) - { - var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == reportId); - if (report == null) return; - - try - { - // ===== Step 1: 使用 VLM 识别报告图片,提取指标 ===== - var vlmPrompt = """ - 你是一名医学检验报告分析专家。请仔细识别这份医学报告。 - - 第一步:判断这到底是不是一份医学检验报告。 - 如果不是(比如是自拍、风景照、食物、文件等),直接返回: - {"isReport": false, "reason": "这不是医学报告,原因:..."} - - 如果是医学报告,提取所有检测指标,返回严格JSON格式(不要markdown包裹): - { - "isReport": true, - "reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other", - "indicators": [ - {"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"} - ] - } - - 注意: - - status 判断:数值在参考范围内=normal,超出上限=high,低于下限=low - - 如果参考范围不明确,根据常见医学标准判断 - """; - - var vlmContent = ""; - try - { - var isPdf = Path.GetExtension(filePath).ToLowerInvariant() == ".pdf"; - var imageUrl = isPdf ? null : await GetLocalImageUrl(filePath); - var vlmResponse = await vision.VisionAsync( - vlmPrompt, - imageUrl != null ? [imageUrl] : [], - userText: "请分析这份医学报告", - maxTokens: 2048); - - vlmContent = vlmResponse.Choices?.FirstOrDefault()?.Message?.Content ?? ""; - Console.WriteLine($"[Report] VLM 返回: {vlmContent?[..Math.Min(vlmContent.Length, 200)]}"); - } - catch (Exception ex) - { - Console.WriteLine($"[Report] VLM 调用失败: {ex.Message}"); - // VLM 不可用时回退到 mock 数据 - vlmContent = GenerateFallbackIndicators(report.Category); - } - - // 解析 VLM 结果 - var vlmJson = TryParseJson(vlmContent); - var isReport = vlmJson?.TryGetProperty("isReport", out var ir) == true && ir.GetBoolean(); - var reason = vlmJson?.TryGetProperty("reason", out var rsn) == true ? rsn.GetString() : null; - - if (!isReport && !string.IsNullOrEmpty(reason)) - { - report.AiSummary = $"⚠️ 无法分析:{reason}"; - report.AiIndicators = "[]"; - report.Status = ReportStatus.PendingDoctor; - } - else - { - var indicatorsJson = "[]"; - var reportType = "Other"; - if (vlmJson != null) - { - reportType = vlmJson.Value.TryGetProperty("reportType", out var rt) ? rt.GetString()! : "Other"; - if (vlmJson.Value.TryGetProperty("indicators", out var inds)) - indicatorsJson = inds.GetRawText(); - } - - report.Category = Enum.TryParse(reportType, out var cat) ? cat : ReportCategory.Other; - report.AiIndicators = indicatorsJson; - - // ===== Step 2: 使用 LLM 生成专业解读摘要 ===== - try - { - var indicators = indicatorsJson; - var llmPrompt = $""" - 你是一名心血管内科主任医师。请根据以下检验指标,用通俗易懂的语言写一份报告解读。 - - 检测指标:{indicators} - - 要求: - 1. 总字数200-300字 - 2. 先总结整体情况 - 3. 指出异常指标及其可能原因 - 4. 给出生活建议 - 5. 末尾提醒"以上为AI预解读,具体请以医生诊断为准" - - 只返回解读文本,不要JSON格式。 - """; - - var llmMessages = new List - { - new() { Role = "system", Content = "你是一名心血管内科主任医师,擅长解读检验报告。" }, - new() { Role = "user", Content = llmPrompt } - }; - - var llmResponse = await llm.ChatAsync(llmMessages, maxTokens: 800, temperature: 0.3f); - var summary = llmResponse.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? ""; - - if (!string.IsNullOrEmpty(summary)) - report.AiSummary = summary; - else - report.AiSummary = GenerateFallbackSummary(report.Category); - } - catch (Exception ex) - { - Console.WriteLine($"[Report] LLM 调用失败: {ex.Message}"); - report.AiSummary = GenerateFallbackSummary(report.Category); - } - } - - await db.SaveChangesAsync(); - Console.WriteLine($"[Report] AI 分析完成: {reportId}"); - } - catch (Exception ex) - { - Console.WriteLine($"[Report] 分析异常: {ex.Message}"); - if (report != null) - { - report.AiSummary = GenerateFallbackSummary(report.Category); - report.AiIndicators = GenerateFallbackIndicators(report.Category); - await db.SaveChangesAsync(); - } - } - - } - - private static async Task GetLocalImageUrl(string filePath) - { - // 对于本地文件,VLM 需要可访问的 URL - // 由于 VLM 调用是服务器端,本地路径可以直接作为 base64 或者 file:// URL - // 这里简化:暂不支持 PDF,图片直接用本地路径(千问支持本地路径) - if (!File.Exists(filePath)) return null; - var bytes = await File.ReadAllBytesAsync(filePath); - var base64 = Convert.ToBase64String(bytes); - var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.'); - var mime = ext switch { "png" => "image/png", "jpg" or "jpeg" => "image/jpeg", "webp" => "image/webp", _ => "image/png" }; - return $"data:{mime};base64,{base64}"; - } - - private static JsonElement? TryParseJson(string content) - { - if (string.IsNullOrWhiteSpace(content)) return null; - content = content.Trim(); - // 去掉可能包裹的 markdown ```json ... ``` - if (content.StartsWith("```")) - { - var end = content.IndexOf('\n'); - if (end > 0) content = content[(end + 1)..]; - if (content.EndsWith("```")) - content = content[..^3]; - } - content = content.Trim(); - try { return JsonDocument.Parse(content).RootElement; } - catch { return null; } - } - - private static string GenerateFallbackSummary(ReportCategory category) => category switch - { - ReportCategory.BloodTest => "血常规显示白细胞计数正常,红细胞及血红蛋白略低于参考范围下限,提示可能存在轻度贫血。血小板计数在正常范围内。建议关注铁蛋白及维生素B12水平,适当补充富含铁质的食物。以上为AI预解读,具体请以医生诊断为准。", - ReportCategory.Ecg => "心电图显示窦性心律,心率正常。无明显ST段改变或心律失常。以上为AI预解读,具体请以医生诊断为准。", - _ => "检查指标基本在参考范围内。以上为AI预解读,具体请以医生诊断为准。" - }; - - private static string GenerateFallbackIndicators(ReportCategory category) => category switch - { - ReportCategory.BloodTest => """[{"name":"白细胞计数","value":"7.5","unit":"×10^9/L","referenceRange":"4.0-10.0","status":"normal"},{"name":"红细胞计数","value":"4.1","unit":"×10^12/L","referenceRange":"4.3-5.8","status":"low"},{"name":"血红蛋白","value":"125","unit":"g/L","referenceRange":"130-175","status":"low"},{"name":"血小板计数","value":"195","unit":"×10^9/L","referenceRange":"100-300","status":"normal"}]""", - _ => """[{"name":"检查结果","value":"正常","unit":"","referenceRange":"","status":"normal"}]""" - }; - private static Guid GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } - -public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category); \ No newline at end of file diff --git a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs index 8f76a6c..ac1b647 100644 --- a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs @@ -1,105 +1,58 @@ +using Health.Application.HealthArchives; +using Health.Application.Users; + namespace Health.WebApi.Endpoints; -/// -/// 用户与健康档案 API 端点 -/// public static class UserEndpoints { public static void MapUserEndpoints(this WebApplication app) { var group = app.MapGroup("/api/user").RequireAuthorization(); - // 获取个人信息 - group.MapGet("/profile", async (HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/profile", async (HttpContext http, IUserService users, CancellationToken ct) => { - var userId = GetUserId(http); - var user = await db.Users.Select(u => new - { - u.Id, u.Phone, u.Role, u.Name, u.Gender, BirthDate = u.BirthDate != null ? u.BirthDate.Value.ToString("yyyy-MM-dd") : null, u.AvatarUrl - }).FirstOrDefaultAsync(u => u.Id == userId, ct); - - return Results.Ok(new { code = 0, data = user, message = (string?)null }); + var profile = await users.GetProfileAsync(GetUserId(http), ct); + return Results.Ok(new { code = 0, data = profile, message = (string?)null }); }); - // 修改资料 - group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, IUserService users, CancellationToken ct) => { - var userId = GetUserId(http); - var user = await db.Users.FindAsync([userId], ct); - if (user == null) return Results.Ok(new { code = 40004, message = "用户不存在" }); - - user.Name = req.Name ?? user.Name; - user.Gender = req.Gender ?? user.Gender; - if (DateOnly.TryParse(req.BirthDate, out var bd)) user.BirthDate = bd; - user.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(ct); + var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null; + var updated = await users.UpdateProfileAsync( + GetUserId(http), + new UserProfileUpdateRequest(req.Name, req.Gender, birthDate), + ct); + if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); - // 获取健康档案 - group.MapGet("/health-archive", async (HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) => { - var userId = GetUserId(http); - var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); + var archive = await archives.GetAsync(GetUserId(http), ct); return Results.Ok(new { code = 0, data = archive, message = (string?)null }); }); - // 更新健康档案 - group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) => { - var userId = GetUserId(http); - var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); - if (archive == null) - { - archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId }; - db.HealthArchives.Add(archive); - } - - archive.Diagnosis = req.Diagnosis ?? archive.Diagnosis; - archive.SurgeryType = req.SurgeryType ?? archive.SurgeryType; - if (DateOnly.TryParse(req.SurgeryDate, out var sd)) archive.SurgeryDate = sd; - if (req.Allergies != null) archive.Allergies = req.Allergies; - if (req.DietRestrictions != null) archive.DietRestrictions = req.DietRestrictions; - if (req.ChronicDiseases != null) archive.ChronicDiseases = req.ChronicDiseases; - archive.FamilyHistory = req.FamilyHistory ?? archive.FamilyHistory; - archive.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(ct); - + var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null; + await archives.UpdateAsync( + GetUserId(http), + new HealthArchiveUpdateRequest( + req.Diagnosis, + req.SurgeryType, + surgeryDate, + req.Allergies, + req.DietRestrictions, + req.ChronicDiseases, + req.FamilyHistory), + ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); - // 注销账号 - group.MapDelete("/account", async (HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapDelete("/account", async (HttpContext http, IUserService users, CancellationToken ct) => { - var userId = GetUserId(http); - // 医生:清除Doctor关联 - var profile = await db.DoctorProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct); - if (profile?.DoctorId != null) - { - await db.Users.Where(u => u.DoctorId == profile!.DoctorId).ExecuteUpdateAsync(u => u.SetProperty(x => x.DoctorId, (Guid?)null), ct); - await db.Doctors.Where(d => d.Id == profile.DoctorId).ExecuteDeleteAsync(ct); - } - // 删除所有关联数据 - await db.HealthRecords.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct); - await db.MedicationLogs.Where(l => l.UserId == userId).ExecuteDeleteAsync(ct); - await db.Medications.Where(m => m.UserId == userId).ExecuteDeleteAsync(ct); - await db.DietFoodItems.Where(i => i.DietRecord!.UserId == userId).ExecuteDeleteAsync(ct); - await db.DietRecords.Where(d => d.UserId == userId).ExecuteDeleteAsync(ct); - await db.ExercisePlanItems.Where(i => i.Plan!.UserId == userId).ExecuteDeleteAsync(ct); - await db.ExercisePlans.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct); - await db.Reports.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct); - await db.ConversationMessages.Where(m => m.Conversation!.UserId == userId).ExecuteDeleteAsync(ct); - await db.Conversations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct); - await db.ConsultationMessages.Where(m => m.Consultation!.UserId == userId).ExecuteDeleteAsync(ct); - await db.Consultations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct); - await db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct); - await db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct); - await db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct); - await db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct); - await db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct); - await db.DoctorProfiles.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct); - await db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync(ct); + await users.DeleteAccountAsync(GetUserId(http), ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); } diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index d84d8e2..87a498e 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -1,13 +1,40 @@ using System.Text; +using Health.Application.AI; +using Health.Application.Admin; +using Health.Application.Auth; +using Health.Application.Calendars; +using Health.Application.Diets; +using Health.Application.Exercises; +using Health.Application.HealthArchives; +using Health.Application.HealthRecords; +using Health.Application.Medications; +using Health.Application.Maintenance; +using Health.Application.Notifications; +using Health.Application.Reports; +using Health.Application.Users; using Health.Infrastructure.AI; +using Health.Infrastructure.Admin; +using Health.Infrastructure.Auth; +using Health.Infrastructure.Calendars; using Health.Infrastructure.Data; +using Health.Infrastructure.Diets; +using Health.Infrastructure.Exercises; +using Health.Infrastructure.HealthArchives; +using Health.Infrastructure.HealthRecords; +using Health.Infrastructure.Medications; +using Health.Infrastructure.Maintenance; +using Health.Infrastructure.Notifications; +using Health.Infrastructure.Reports; using Health.Infrastructure.Services; +using Health.Infrastructure.Users; using Health.WebApi.BackgroundServices; using Health.WebApi.Converters; using Health.WebApi.Endpoints; using Health.WebApi.Middleware; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.FileProviders; using Microsoft.IdentityModel.Tokens; // 加载 .env 文件(开发环境) @@ -28,6 +55,18 @@ if (File.Exists(envPath)) var builder = WebApplication.CreateBuilder(args); +if (builder.Environment.IsDevelopment()) +{ + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(); + builder.Logging.AddDebug(); + var dataProtectionPath = Path.Combine(Directory.GetCurrentDirectory(), "data-protection-keys"); + Directory.CreateDirectory(dataProtectionPath); + builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath)) + .SetApplicationName("HealthManager.Development"); +} + // ---- JSON 配置(枚举字符串、camelCase、UTC时间统一带Z)---- builder.Services.ConfigureHttpJsonOptions(options => { @@ -68,6 +107,44 @@ builder.Services.AddAuthorization(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)---- builder.Services.AddHttpClient(client => @@ -85,7 +162,11 @@ builder.Services.AddHttpClient(client => // ---- 后台服务 ---- builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); // ---- OpenAPI ---- builder.Services.AddOpenApi(); @@ -110,6 +191,14 @@ app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); +var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); +Directory.CreateDirectory(uploadsPath); +app.UseStaticFiles(new StaticFileOptions +{ + FileProvider = new PhysicalFileProvider(uploadsPath), + RequestPath = "/uploads" +}); + if (app.Environment.IsDevelopment()) app.MapOpenApi(); @@ -118,6 +207,7 @@ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); + await scope.ServiceProvider.GetRequiredService().ApplyAsync(); await DataSeeder.SeedAsync(db); await DevDataSeeder.SeedIfEnabled(db, app.Configuration); } @@ -134,6 +224,7 @@ app.MapUserEndpoints(); app.MapAiChatEndpoints(); app.MapFileEndpoints(); app.MapCalendarEndpoints(); +app.MapNotificationEndpoints(); app.MapDoctorEndpoints(); app.MapAdminEndpoints(); app.MapFollowUpEndpoints(); diff --git a/backend/tests/Health.Tests/Health.Tests.csproj b/backend/tests/Health.Tests/Health.Tests.csproj index d526567..05a1d91 100644 --- a/backend/tests/Health.Tests/Health.Tests.csproj +++ b/backend/tests/Health.Tests/Health.Tests.csproj @@ -21,9 +21,10 @@ + - \ No newline at end of file + diff --git a/backend/tests/Health.Tests/application_service_tests.cs b/backend/tests/Health.Tests/application_service_tests.cs new file mode 100644 index 0000000..59f798e --- /dev/null +++ b/backend/tests/Health.Tests/application_service_tests.cs @@ -0,0 +1,149 @@ +using Health.Application.AI; +using Health.Application.Calendars; +using Health.Application.HealthArchives; +using Health.Application.Exercises; +using Health.Domain.Entities; +using Health.Domain.Enums; + +namespace Health.Tests; + +public sealed class ApplicationServiceTests +{ + [Fact] + public async Task HealthArchive_AiUpdate_OnlyChangesRequestedSection() + { + var userId = Guid.NewGuid(); + var repository = new FakeHealthArchiveRepository(new HealthArchive + { + Id = Guid.NewGuid(), + UserId = userId, + Diagnosis = "原诊断", + Allergies = ["青霉素"] + }); + var service = new HealthArchiveService(repository); + + var result = await service.ExecuteAiActionAsync( + userId, + "update_diagnosis", + new HealthArchiveUpdateRequest("新诊断", "不应写入的手术", null, null, null, null, null), + CancellationToken.None); + + Assert.True((bool)result.GetType().GetProperty("success")!.GetValue(result)!); + Assert.Equal("新诊断", repository.Archive!.Diagnosis); + Assert.Null(repository.Archive.SurgeryType); + Assert.Equal(["青霉素"], repository.Archive.Allergies); + } + + [Fact] + public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation() + { + var ownerId = Guid.NewGuid(); + var conversation = new Conversation { Id = Guid.NewGuid(), UserId = ownerId, AgentType = AgentType.Health }; + var service = new AiConversationService(new FakeConversationRepository(conversation)); + + var result = await service.OpenAsync(Guid.NewGuid(), conversation.Id, AgentType.Health, "测试", CancellationToken.None); + + Assert.False(result.Found); + Assert.False(result.Created); + } + + [Fact] + public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp() + { + var date = new DateOnly(2026, 6, 19); + var medication = new Medication + { + Id = Guid.NewGuid(), IsActive = true, Name = "阿司匹林", StartDate = date.AddDays(-1), TimeOfDay = [new TimeOnly(8, 0)] + }; + var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) }; + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 }); + var followUp = new FollowUp { Id = Guid.NewGuid(), ScheduledAt = date.ToDateTime(new TimeOnly(9, 0)), Title = "复查" }; + var service = new CalendarService(new FakeCalendarRepository(new CalendarDataSnapshot([medication], [plan], [followUp]))); + + var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None); + + var target = Assert.Single(result, item => + item.GetType().GetProperty("date")!.GetValue(item)?.ToString() == "2026-06-19"); + var events = (IEnumerable)target.GetType().GetProperty("events")!.GetValue(target)!; + Assert.Equal(["medication", "exercise", "followup"], events); + } + + [Fact] + public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates() + { + var repository = new FakeExerciseRepository(); + var service = new ExerciseService(repository); + var start = new DateOnly(2026, 6, 20); + + await service.CreateAsync(Guid.NewGuid(), new ExercisePlanCreateRequest(start, start.AddDays(9), "跑步", 30, new TimeOnly(19, 0)), CancellationToken.None); + + var plan = Assert.IsType(repository.Plan); + Assert.Equal(start.AddDays(9), plan.EndDate); + Assert.Equal(10, plan.Items.Count); + Assert.Equal(10, plan.Items.Select(x => x.ScheduledDate).Distinct().Count()); + Assert.Equal(Enumerable.Range(0, 10).Select(start.AddDays), plan.Items.OrderBy(x => x.ScheduledDate).Select(x => x.ScheduledDate)); + } + + [Fact] + public async Task ExercisePlan_CurrentUsesExactScheduledDate() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var repository = new FakeExerciseRepository(); + var plan = new ExercisePlan + { + Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today, EndDate = today.AddDays(9), ReminderTime = new TimeOnly(19, 0), + }; + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 }); + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 }); + repository.SetPlan(plan); + var service = new ExerciseService(repository); + + var current = await service.GetCurrentAsync(plan.UserId, CancellationToken.None); + + Assert.NotNull(current); + Assert.Equal(today, current!.Items.Single(x => x.ScheduledDate == today).ScheduledDate); + Assert.Equal(2, current.Items.Count); + } + + private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository + { + public HealthArchive? Archive { get; private set; } = archive; + public Task GetByUserIdAsync(Guid userId, CancellationToken ct) => Task.FromResult(Archive?.UserId == userId ? Archive : null); + public Task AddAsync(HealthArchive value, CancellationToken ct) { Archive = value; return Task.CompletedTask; } + public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; + } + + private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository + { + private readonly Conversation _conversation = conversation; + public Task GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) => Task.FromResult(_conversation.Id == conversationId && _conversation.UserId == userId ? _conversation : null); + public Task GetAsync(Guid conversationId, CancellationToken ct) => Task.FromResult(_conversation.Id == conversationId ? _conversation : null); + public Task> ListAsync(Guid userId, CancellationToken ct) => Task.FromResult>([]); + public Task> GetMessagesAsync(Guid conversationId, CancellationToken ct) => Task.FromResult>([]); + public Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult>([]); + public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask; + public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask; + public void Delete(Conversation value) { } + public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; + } + + private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository + { + public Task GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot); + } + + private sealed class FakeExerciseRepository : IExerciseRepository + { + public ExercisePlan? Plan { get; private set; } + public void SetPlan(ExercisePlan plan) => Plan = plan; + public Task> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult>( + Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []); + public Task> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult>([]); + public Task GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan); + public Task GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan); + public Task GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId)); + public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; } + public void Delete(ExercisePlan plan) { Plan = null; } + public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; + } +} diff --git a/backend/tests/Health.Tests/entity_tests.cs b/backend/tests/Health.Tests/entity_tests.cs index baffb7b..f9978a8 100644 --- a/backend/tests/Health.Tests/entity_tests.cs +++ b/backend/tests/Health.Tests/entity_tests.cs @@ -204,10 +204,10 @@ public class EntityTests await db.SaveChangesAsync(); var monday = new DateOnly(2026, 6, 1); - var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday }; - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow }); - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 }); - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, IsRestDay = true }); + var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(2), ReminderTime = new TimeOnly(19, 0) }; + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow }); + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 }); + plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), IsRestDay = true }); db.ExercisePlans.Add(plan); await db.SaveChangesAsync(); diff --git a/backend/tests/Health.Tests/persistence_pipeline_tests.cs b/backend/tests/Health.Tests/persistence_pipeline_tests.cs new file mode 100644 index 0000000..7871e55 --- /dev/null +++ b/backend/tests/Health.Tests/persistence_pipeline_tests.cs @@ -0,0 +1,76 @@ +using Health.Infrastructure.AI; +using Health.Infrastructure.Data; +using Health.Infrastructure.Data.Records; +using Health.Infrastructure.Reports; +using Microsoft.EntityFrameworkCore; + +namespace Health.Tests; + +public sealed class PersistencePipelineTests +{ + [Fact] + public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner() + { + await using var db = CreateDbContext(); + var store = new EfAiWriteConfirmationStore(db); + var ownerId = Guid.NewGuid(); + var command = await store.CreateAsync(ownerId, "record_health_data", "{}", TimeSpan.FromMinutes(10), CancellationToken.None); + + var wrongUser = await store.TakeAsync(command.Id, Guid.NewGuid(), CancellationToken.None); + var firstTake = await store.TakeAsync(command.Id, ownerId, CancellationToken.None); + var secondTake = await store.TakeAsync(command.Id, ownerId, CancellationToken.None); + + Assert.Null(wrongUser); + Assert.NotNull(firstTake); + Assert.Null(secondTake); + } + + [Fact] + public async Task ReportTask_RetryUsesBackoffBeforeMaxAttempts() + { + await using var db = CreateDbContext(); + var task = NewReportTask(attempts: 1); + db.ReportAnalysisTasks.Add(task); + await db.SaveChangesAsync(); + var queue = new EfReportAnalysisQueue(db); + var before = DateTime.UtcNow; + + await queue.RetryAsync(task.Id, "temporary failure", CancellationToken.None); + + Assert.Equal("Pending", task.Status); + Assert.True(task.AvailableAt > before); + Assert.Equal("temporary failure", task.LastError); + } + + [Fact] + public async Task ReportTask_RetryMarksFailedAtMaxAttempts() + { + await using var db = CreateDbContext(); + var task = NewReportTask(attempts: 3); + db.ReportAnalysisTasks.Add(task); + await db.SaveChangesAsync(); + var queue = new EfReportAnalysisQueue(db); + + await queue.RetryAsync(task.Id, new string('x', 2500), CancellationToken.None); + + Assert.Equal("Failed", task.Status); + Assert.Equal(2000, task.LastError!.Length); + } + + private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new() + { + Id = Guid.NewGuid(), + ReportId = Guid.NewGuid(), + FilePath = "report.jpg", + Status = "Processing", + Attempts = attempts, + AvailableAt = DateTime.UtcNow, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + private static AppDbContext CreateDbContext() => new( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); +} diff --git a/docs/backend_architecture_evolution.md b/docs/backend_architecture_evolution.md new file mode 100644 index 0000000..2290daa --- /dev/null +++ b/docs/backend_architecture_evolution.md @@ -0,0 +1,283 @@ +# 后端架构演进方案 + +日期:2026-06-18 + +## 1. 背景 + +当前项目已经具备 `Health.Domain`、`Health.Application`、`Health.Infrastructure`、`Health.WebApi` 的分层目录,但实际业务逻辑主要仍写在 `Health.WebApi/Endpoints` 中。多数接口直接注入 `AppDbContext`,在 Endpoint 内完成查询、权限判断、状态流转和 `SaveChanges`。 + +这种方式适合早期快速验证,但随着患者端健康管理、AI 分析、报告、饮食、用药、运动、医生端等业务增长,会带来几个问题: + +- 业务规则分散在 Endpoint、AI Agent Handler、后台服务中。 +- 同一个业务动作可能被普通接口和 AI 工具重复实现。 +- 权限和资源归属校验容易遗漏。 +- 异步任务目前存在 `Task.Run` 形式,不便于限流、重试和统一管理。 +- Application 层没有真正承接业务用例,后续测试和维护成本会升高。 + +技术目标是按 DDD 思路逐步演进:让 Endpoint 成为接口适配层,业务流程进入 Application Service,外部能力和数据访问由 Infrastructure 支撑,耗时任务通过生产者-消费者管道处理。 + +## 2. 当前业务边界 + +### 2.1 当前核心患者端闭环 + +以下业务都需要继续做扎实: + +1. AI 健康管家聊天 +2. 健康指标记录与趋势:血压、心率、血糖、血氧、体重 +3. 报告上传与 AI 预解读 +4. 饮食拍照分析与保存 +5. 用药管理与打卡 +6. 运动计划 + +### 2.2 医生端当前策略 + +医生端可以做代码整理和权限边界收拢,但不作为当前核心业务闭环: + +- 医患实时聊天:暂时搁置,后续接入互联网医院后再完善。 +- 医生审核报告:暂时不是当前真实流程,患者端报告以 AI 预解读为主;界面可显示“医生审核中/待审核”一类状态。 +- 医生工作台:保留现有页面和基础接口,不主动扩大功能范围,重构时以不影响当前项目运行为目标。 + +### 2.3 AI 写入规则 + +统一业务规则: + +- AI 纯查询、解释、建议可以直接回复。 +- AI 只要要写入用户健康相关数据,必须先让用户确认。 +- 需要确认的写入包括但不限于:健康指标、用药计划、运动计划、健康档案修改。 +- 饮食记录当前在饮食分析结果页由用户主动保存,保留该方式。 + +## 3. 目标架构 + +目标不是创建一个巨大的全能 Service,而是按业务模块拆分 Application Service: + +```text +Health.WebApi + Endpoints + Hubs + BackgroundServices + +Health.Application + Auth + Users + HealthRecords + Medications + Diet + Reports + Exercise + Consultations + Doctors + Ai + Common + +Health.Domain + Entities + Enums + DomainRules + +Health.Infrastructure + Data + AI + Services + Storage +``` + +职责边界: + +- `WebApi`:接收 HTTP/SignalR 请求,解析参数,返回响应。 +- `Application`:承接业务用例、状态流转、权限校验、任务入队。 +- `Domain`:保存核心实体、枚举和稳定业务规则。 +- `Infrastructure`:EF Core、AI 客户端、短信、文件存储、推送、未来互联网医院适配。 + +## 4. 数据库读写收拢方式 + +短期先不强制引入 Repository 抽象,避免过度设计。第一阶段采用: + +```text +Endpoint -> Application Service -> AppDbContext +``` + +也就是说,数据库读写先统一进入对应业务 Service,而不是继续散落在 Endpoint 中。 + +后续如果业务复杂度继续提升,再考虑: + +```text +Application Service -> Repository / UnitOfWork -> AppDbContext +``` + +第一阶段优先收拢这些模块: + +1. `ReportService` +2. `DietService` +3. `MedicationService` +4. `ExerciseService` +5. `HealthRecordService` +6. `ConsultationService` + +## 5. 生产者-消费者管道 + +不是所有业务都需要管道。同步、快速、必须立即返回的操作仍走普通 Service。 + +适合管道的任务: + +- 报告 AI 分析 +- 饮食图片识别 +- 处方图片识别 +- 用药提醒推送 +- 健康周报生成 +- 后期互联网医院数据同步 + +当前阶段采用 .NET 内置 `Channel` + `BackgroundService`: + +```text +业务 Service = 生产者 +Channel = 内存任务队列 +BackgroundService = 消费者 +AI/推送/外部服务 = 实际执行器 +``` + +单服务器和当前用户规模下,内存队列足够作为第一阶段方案。后续如果出现多实例部署、任务不能丢、重试次数和死信队列等要求,再迁移到 Redis Stream、RabbitMQ 或 Hangfire。 + +## 6. 第一阶段落地范围 + +先从报告模块落地,因为它同时具备上传、AI、异步、状态流转、失败重试等典型场景。 + +### 6.1 报告模块目标 + +从当前: + +```text +ReportEndpoints -> AppDbContext + Task.Run + AI 调用 +``` + +演进为: + +```text +ReportEndpoints + -> ReportService + -> 保存文件 + -> 创建报告 + -> 标记状态 + -> 入队 ReportAnalysisJob + +ReportAnalysisWorker + -> 消费 ReportAnalysisQueue + -> 调用 ReportAnalysisService / AI Analyzer + -> 更新报告状态 +``` + +### 6.2 报告模块要保持的业务行为 + +- 上传只支持报告图片。 +- 上传成功后状态为 `Analyzing`。 +- AI 成功后进入 `PendingDoctor`,患者端展示 AI 预解读。 +- AI 失败后进入 `AnalysisFailed`,不生成假摘要、假指标。 +- 患者端可以查看原始报告图片。 +- 医生审核不是当前核心闭环,状态可保留但不扩大功能。 + +## 7. 当前已落地进展 + +截至 2026-06-18,第一轮服务化已经完成以下模块: + +1. `ReportService`:报告上传、图片校验、报告创建、重新分析、删除、AI 分析状态流转进入服务层;报告分析通过 `ReportAnalysisQueue` + `ReportAnalysisWorker` 异步消费。 +2. `HealthRecordService`:健康指标列表、创建、更新、删除、最新值、趋势查询、异常判断进入服务层;AI 记数据工具复用同一套创建逻辑。 +3. `ExerciseService`:运动计划创建、列表、详情、删除、打卡、AI 创建/查询/打卡进入服务层。 +4. `MedicationService`:用药列表、创建、更新、删除、今日服药确认、按剂量打卡、提醒查询、AI 创建/查询/确认进入服务层。 +5. `DietService`:饮食记录列表、保存、删除、热量/评分更新进入服务层。 + +其中以下模块已经继续演进为更严格的 Application Service + Repository 结构: + +```text +Endpoint + -> Health.Application.*Service + -> Health.Application.I*Repository + -> Health.Infrastructure.Ef*Repository + -> AppDbContext +``` + +已完成: + +1. `HealthRecordService` + `IHealthRecordRepository` + `EfHealthRecordRepository` +2. `ExerciseService` + `IExerciseRepository` + `EfExerciseRepository` +3. `DietService` + `IDietRepository` + `EfDietRepository` +4. `MedicationService` + `IMedicationRepository` + `EfMedicationRepository` +5. `ReportService` + `IReportRepository` + `EfReportRepository` + `IReportFileStorage` + `LocalReportFileStorage` + +报告模块中,`ReportAnalysisQueue` 和 `ReportAnalysisService` 仍属于 Infrastructure:前者是内存队列适配,后者需要调用 AI 客户端并通过 `IReportRepository` 更新报告状态,不再直接依赖 `AppDbContext`。 + +AI 写入确认机制已完成第一阶段落地: + +```text +AI 写入工具调用 + -> 创建 10 分钟有效的 PendingAiWriteCommand + -> 前端展示确认卡片,此时不写数据库 + -> 用户点击确认 + -> POST /api/ai/confirm-write/{commandId} + -> 校验当前用户和一次性命令 + -> 执行对应 Application Service 写入 +``` + +- 健康指标、创建/确认用药、创建/打卡运动、AI 修改健康档案均进入待确认流程。 +- 纯查询工具继续直接执行。 +- 命令只能由所属用户执行一次;执行失败时会恢复命令供用户重试,过期或服务重启后自动失效。 +- AI 待确认命令已迁移到数据库表 `AiWriteCommands`,领取命令、业务写入和完成状态在同一事务内执行,避免重复写入。 +- 报告分析任务已迁移到数据库表 `ReportAnalysisTasks`,支持服务重启恢复、原子领取、失败重试和最终失败状态。 +- 项目当前仍使用 `EnsureCreated` 管理原有表;新增 `DatabaseSchemaMigrator` 和 `__AppSchemaMigrations` 版本表,为已有本地数据库安全补充基础设施表。 + +患者端其他业务收拢进展: + +1. `HealthArchiveService` + `IHealthArchiveRepository`:健康档案页面、AI 查询和确认写入统一执行。 +2. `AiConversationService` + `IAiConversationRepository`:会话创建、消息保存、历史列表和删除统一执行。 +3. `PatientContextService`:统一组合健康档案、近期指标和当前用药。 +4. `UserService` + `IUserRepository`:个人资料和账号注销统一执行;账号数据清理使用事务。 +5. `CalendarService` + `ICalendarRepository`:聚合用药、运动、随访日历。 + +生产者/消费者管道现状: + +- 报告分析:数据库持久化任务 + `ReportAnalysisWorker`。 +- 饮食图片识别:任务持久化到 `DietImageAnalysisTasks`,由 `DietImageAnalysisWorker` 原子领取、失败重试和恢复处理中断任务;前端接口协议保持不变。 +- 用药提醒:`MedicationReminderService` 负责扫描生产任务,任务持久化到 `MedicationReminderTasks` 并按药品/日期/时间唯一去重;`MedicationReminderWorker` 消费后写入 `NotificationOutbox`。真正的手机推送需在选定推送服务后消费 Outbox,目前不会把未发送通知标记为已推送。 +- 报告、饮食和用药任务消费者均采用 1 到 5 秒的自适应空闲轮询,过期 Processing 任务每分钟恢复一次,避免每秒重复执行恢复更新。 +- App 内用药提醒通过 `NotificationOutbox` + `/api/notifications/pending` 提供,患者端前台每 30 秒获取并展示,展示后回执去重;暂不接入系统级或厂商推送。 +- `MaintenanceService` 每小时执行一次维护,自动删除超过 30 天的已完成/失败后台任务、通知 Outbox、过期 AI 写入命令和旧 AI 会话,不删除用户健康记录、饮食记录或用药计划。 +- 登录、注册、验证码、Token 刷新和退出已收拢到 `IAuthService`;管理员医生管理和患者列表已收拢到 `IAdminService`,Endpoint 不再直接读写数据库。开发环境 `send-sms` 仍返回 `devCode` 供 Flutter 自动填入,非开发环境不返回验证码。 +- AI 工具查询、确认写入和事务边界已收拢到 `IAiToolExecutionService`,AI Endpoint 不再直接持有 `AppDbContext`。 +- 运动计划已从 `WeekStartDate + DayOfWeek` 周模板改为 `StartDate + EndDate + ReminderTime`,每日条目使用唯一的 `ScheduledDate`。连续 7 天、10 天或更长计划按真实日期生成,首页今日任务、健康日历、医生端只读详情和 AI 创建均使用同一日期模型。 +- App 内运动提醒按每日条目的 `ScheduledDate` 和计划 `ReminderTime` 生成到 `NotificationOutbox`;已打卡和休息日不会提醒,同一每日条目只生成一次。 + +当前仍保留在 Endpoint 或旧 Handler 中的业务,需要后续逐步收拢: + +- `ConsultationService`:医生聊天暂不作为当前核心业务,但基础权限和数据读写仍可继续服务化。 +- `DoctorService` / `AdminDoctorService`:医生信息、患者列表、报告查看等目前不作为真实互联网医院流程,后续按老板和互联网医院接入方案调整。 +- `CalendarService`:健康日历目前聚合运动、用药、饮食等多模块数据,适合在核心模块稳定后单独收拢。 +- `User/ProfileService`:个人信息、健康档案、账号清理等可继续整理,尤其是 AI 修改健康档案前的确认规则。 + +## 8. 后续推广顺序 + +1. 报告:Application Service + 分析队列 + Worker +2. 饮食:图片识别任务队列,用户修正后保存 +3. 用药:提醒扫描和推送任务解耦 +4. 运动:计划创建、打卡规则收拢到 Service +5. 健康指标:记录、异常判断、趋势查询收拢到 Service +6. 问诊:互联网医院接入前只收拢基础接口,不扩大聊天功能 +7. 医生端:保留基础接口,后续根据互联网医院和老板决策再完善审核/聊天工作流 + +## 9. 不做的事 + +当前阶段暂不做: + +- 不一次性重构全项目。 +- 不立即引入 RabbitMQ/Kafka 等重型组件。 +- 不强制引入复杂 Repository 层。 +- 不改变当前患者端主要交互。 +- 不把医生聊天/医生审核作为当前核心业务流。 + +## 10. 验收标准 + +第一阶段完成后应满足: + +- 报告 Endpoint 不再直接承载主要业务流程。 +- 报告 AI 分析不再使用 `Task.Run`。 +- 报告分析任务通过队列进入后台 Worker。 +- 报告状态流转集中在 Application Service。 +- 上传、列表、详情、删除、查看原图、分析失败状态保持可用。 +- 后端编译通过,前端报告页分析通过。 diff --git a/docs/project_deep_review.md b/docs/project_deep_review.md index 366aa81..3c31a87 100644 --- a/docs/project_deep_review.md +++ b/docs/project_deep_review.md @@ -371,7 +371,186 @@ var conversation = await db.Conversations 5. 医生端做成真正可用的工作台:患者筛选、风险排序、随访提醒。 6. 报告、饮食、用药、运动做长期趋势关联。 -## 11. 总体建议 +## 11. 第二轮深挖补充 + +这一轮额外对已有 `docs/BUG_REVIEW.md`、后端端点、AI Agent handler、SignalR、Flutter provider 生命周期、饮食/蓝牙/问诊页面做了交叉检查。需要注意:旧 bug 文档中有一些问题已经被修复或情况已经变化,本节以当前代码为准。 + +### 11.1 已确认仍然存在的高风险问题 + +| 优先级 | 位置 | 当前问题 | 风险说明 | 修复方向 | +| --- | --- | --- | --- | --- | +| P0 | `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | `/api/auth/send-sms` 仍返回 `devCode` | 任何客户端都能拿到验证码,短信验证等同失效 | 只在 Development 返回;生产环境完全移除 | +| P0 | `health_app/lib/pages/auth/login_page.dart` | 前端拿到 `devCode` 后自动填入验证码 | 把后端安全问题固化成产品行为 | 删除自动填充;开发环境可用 debug banner 或日志 | +| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | SSE token 走 query,且 fallback 用 `ReadJwtToken` 解析 | query token 会进日志;`ReadJwtToken` 不验证签名 | SSE 改标准鉴权,或先换一次性 stream ticket | +| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | 创建/续写 AI 会话时 `FindAsync(conversationId)` 未校验 `UserId` | 可能跨用户写入/读取会话 | `FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId)` | +| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | SignalR Hub 没有鉴权,入组只靠 `consultationId` | 任意连接可加入任意问诊房间 | `MapHub().RequireAuthorization()`,Join 前校验用户或医生权限 | +| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | `SendMessage` 按传入 `senderType` 和 `consultationId` 写库 | 客户端可冒充 Doctor/User,向任意会话发消息 | senderType 从 Claims/角色推导,不信任客户端 | +| P0 | `backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs` | 患者发消息只查会话存在,未校验会话属于当前用户 | 用户 A 可向用户 B 的问诊发 HTTP 消息 | 查询加 `c.UserId == userId` | +| P0 | `backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs` | `/items/{itemId}/checkin` 只 `FindAsync(itemId)` | 用户可打卡/取消他人的运动计划项 | Include Plan 后校验 `Plan.UserId` | +| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs` | AI 运动 checkin 只按 itemId 操作 | 通过 AI 工具也可越权打卡 | 查询 item 时联表校验当前 userId | +| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs` | AI 用药 confirm 直接写 `MedicationLog`,不验证药品归属 | 可给他人药品写入服药记录 | 先查 `Medication.Id == medId && UserId == userId` | +| P1 | `backend/src/Health.WebApi/Endpoints/medication_endpoints.cs` | `/medications/{id}/confirm` 查 existing log 未限制 `UserId`,也未先确认药品归属 | 可能误删/写入不属于当前用户药品的日志 | 先查药品归属,再按 `MedicationId + UserId` 查日志 | +| P1 | `backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs` | 医生端已鉴权,但患者详情/报告/随访操作多处只按 id 查询 | 医生可能访问或修改非自己负责患者的数据 | 所有医生端资源都按医生关联患者过滤 | +| P1 | `backend/src/Health.WebApi/Endpoints/file_endpoints.cs` | 上传缺少大小、类型、内容校验,且返回结构与前端不匹配 | 恶意文件/超大文件风险,前端图片上传链路失败 | 加限制、白名单、URL 返回和访问控制 | +| P1 | `health_app/lib/pages/diet/diet_capture_page.dart` | `_fieldCtrls` 缓存 controller,但页面没有 dispose | 多次进入饮食分析页会泄漏 TextEditingController | 在 State `dispose()` 中遍历释放 | +| P1 | `health_app/lib/providers/consultation_provider.dart` | Hub/轮询 stop 需要手动调用,provider 自身没有自动释放 | 离开页面后可能继续 SignalR 或 5 秒轮询 | Notifier build 中注册 `ref.onDispose(stop)` | +| P1 | `health_app/lib/providers/chat_provider.dart` | SSE `_subscription` 和 timer 没看到 provider dispose 释放 | 聊天流中断/页面销毁后可能残留监听 | 注册 `ref.onDispose`,切换会话时取消旧流 | +| P1 | `backend/src/Health.WebApi/Program.cs` | `MapHub("/hubs/consultation")` 未 RequireAuthorization | 即使 API 鉴权,实时通道仍裸露 | Hub 映射处加鉴权并处理 token 传递 | + +### 11.2 旧问题中已经变化或需要修正的判断 + +这些点在旧 `BUG_REVIEW.md` 里出现过,但当前代码已经不是原始状态,后续不要按旧结论机械修: + +- `doctor_endpoints.cs` 不是“零授权”了:当前已有 `.RequireAuthorization()` 和医生角色过滤。真正问题是医生端数据授权粒度不够,部分详情/报告/随访接口没有限制到当前医生负责的患者。 +- `open_ai_compatible_client.cs` 的 Vision content 当前已经是 `Content = contentParts`,不是把多模态数组序列化成字符串。旧的 VLM 序列化 bug 看起来已修复。 +- `diet_agent_handler.cs` 和 `consultation_agent_handler.cs` 当前没有声明未实现工具,而是主动缩减为档案/记录查询。问题应描述为“AI Agent 能力和首页胶囊/欢迎卡片承诺不一致”,不是“声明工具但未实现”。 +- `cleanup_service.cs` 当前已经先删 ConversationMessages 再删 Conversations,旧的 FK 删除顺序问题已修。 +- `device_scan_page.dart` 当前 `dispose()` 已取消 scan/read/connection 订阅,不能继续作为确定泄漏 bug。仍建议检查 `OmronBleService` 的全局 provider 生命周期和断线重连边界。 +- `widget_test.dart` 中 `primary == primaryLight` 的错误断言已经改掉,目前测试更大的问题是覆盖面太浅。 + +### 11.3 后端授权边界需要系统性重查 + +项目里很多接口已经 `.RequireAuthorization()`,但“已登录”不等于“有权操作这个资源”。建议建立统一规则: + +| 资源 | 当前风险 | 应该怎么查 | +| --- | --- | --- | +| AI Conversation | 续写时未绑定 `UserId` | `Conversation.Id == id && Conversation.UserId == currentUserId` | +| Consultation HTTP | POST message 未绑定 `UserId` | `Consultation.Id == id && Consultation.UserId == currentUserId` | +| Consultation Hub | 入组/发消息无服务端权限判断 | Join 和 SendMessage 都查用户或医生是否有权进入该 consultation | +| ExercisePlanItem | checkin 只按 itemId | `ExercisePlanItem.Id == itemId && Item.Plan.UserId == currentUserId` | +| Medication confirm | 部分确认接口未先校验药品归属 | `Medication.Id == id && Medication.UserId == currentUserId` | +| Doctor patient detail | 医生按任意 patient id 查详情 | `User.Id == patientId && User.DoctorId == currentDoctorId` | +| Doctor report review | 医生按任意 report id 审阅 | `Report.User.DoctorId == currentDoctorId` | +| Doctor follow-up update/delete | 医生按任意 followUp id 操作 | `FollowUp.User.DoctorId == currentDoctorId` 或 `DoctorName/DoctorId` 绑定 | + +建议在后端加一层可复用 helper,例如: + +```csharp +static IQueryable ScopePatientsToDoctor(AppDbContext db, Guid doctorId) => + db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId); +``` + +所有医生端接口都从这个 scope 派生,不要每个 endpoint 手写判断。 + +### 11.4 API 语义和错误码问题 + +现在不少接口用 HTTP 200 包业务错误码,比如 401/403/404/400 都包成 `{ code, message }`。这种风格可以保留,但要注意两个问题: + +- 对认证授权失败,HTTP 状态码最好仍返回 401/403,方便客户端、网关、日志系统识别。 +- 业务错误码需要统一枚举,否则前端只能靠字符串判断。 + +建议定义统一错误码: + +- `0` 成功 +- `40001` 参数错误 +- `40002` 登录过期 +- `40003` 无权限 +- `40004` 资源不存在 +- `40005` 业务状态冲突 +- `50000` 服务端异常 + +并让 `ExceptionMiddleware` 只处理意外异常,业务错误由 endpoint 明确返回。 + +### 11.5 数据模型和索引补充 + +当前 `AppDbContext` 已经配置了不少索引和枚举转换,比旧报告里“完全没有 FK/索引”的描述更好。但仍建议补: + +- `RefreshToken(Token)` 唯一或普通索引:刷新 token 查询会频繁发生。 +- `RefreshToken(UserId, IsRevoked, ExpiresAt)`:便于清理和会话管理。 +- `Report(UserId, CreatedAt)`:报告列表按用户和时间查询。 +- `FollowUp(UserId, ScheduledAt)`:随访日历、医生待办会用到。 +- `DeviceToken(UserId)`:推送服务上线后需要。 +- `Consultation(UserId, CreatedAt)` 和 `Consultation(Status, CreatedAt)`:患者历史和医生待办都会用到。 + +另外,核心关系建议显式配置删除行为,尤其是 User 删除时关联 Consultation、Report、Conversation、MedicationLog 的级联或手动删除策略。 + +### 11.6 AI Agent 产品能力不一致 + +现在首页上有多个 agent/胶囊入口,但后端能力不完全一致: + +- 饮食 Agent 当前只保留健康档案查询,真正饮食识别在专门图片接口。 +- 问诊 Agent 当前只保留健康记录和档案查询,转医生走专门问诊流程。 +- 用药/运动 Agent 有创建、查询、确认能力,但确认工具存在所有权校验问题。 +- 通用 Agent 如果聚合多个工具,需要明确“哪些动作会写数据,哪些只是查询”。 + +建议 UI 文案和后端能力统一: + +- 欢迎卡片不要暗示当前 agent 能完成它实际上做不到的写操作。 +- 会写入健康数据、药品、运动计划、档案的 AI 动作,必须有确认卡片。 +- AI 工具调用结果应返回结构化状态,前端不要只靠自然语言判断成功。 + +### 11.7 前端状态和生命周期问题 + +前端现在能跑起来,但长期运行会有状态残留风险: + +- `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`。 +- `ChatNotifier` 的 SSE 订阅、流式响应 timer 需要在 provider 销毁、切换 agent、重新发送时取消。 +- 饮食页 `_fieldCtrls` 需要 `dispose()`,否则每次识别食物后 controller 累积。 +- 多个 `FutureProvider` 没有 `autoDispose`,页面级数据会缓存很久。健康最新值、药品提醒、当前运动计划这类数据建议明确刷新策略。 +- 很多页面删除后只本地 `_load()`,没有 `ref.invalidate(...)`,跨页面缓存可能不同步。 + +### 11.8 UI 深层问题:不是再加渐变,而是建立层级 + +最近 UI 调整集中在侧边栏、欢迎卡片、饮食页、设置页、个人信息页等。颜色已经比最初丰富,但仍需要注意: + +- 颜色角色要固定:蓝色用于主行动/健康状态,橙色用于饮食,紫色用于报告,绿色用于记录/恢复,青色用于设备或运动,浅红用于提醒/风险。 +- 欢迎卡片和胶囊按钮的图标必须同语义、同线宽、同背景形状。用户已经多次指出“不只是颜色一样,图标内容也要一样”,这说明视觉一致性比单个渐变更重要。 +- 健康仪表盘应优先展示数字、单位、状态、更新时间。图标可以弱化,避免抢数字层级。 +- 设置页不应只是按钮列表,应分为账号、安全、通知、设备、隐私、关于。 +- 个人信息页应像正式档案:基础信息、医疗信息、健康偏好、绑定医生、账号安全分区展示。 +- 功能入口两行三列是合理的,但每个入口不要堆摘要,图标 + 名称 + 必要状态即可。 + +### 11.9 功能缺口再细化 + +| 模块 | 当前缺口 | 建议 | +| --- | --- | --- | +| 饮食记录 | 已有识别和保存,但历史记录编辑能力弱 | 支持编辑食物、份量、热量、餐次;支持从历史复制 | +| 报告管理 | 上传后异步 AI 分析,但失败/处理中状态不够细 | 增加 pending/analyzing/failed/retry 状态和轮询刷新 | +| 问诊 | 患者端创建即新会话,历史问诊入口弱 | 增加问诊历史、继续问诊、关闭问诊、评价医生 | +| 用药 | 提醒后台只记录日志,未实际推送 | 接入推送,支持漏服/补服/跳过原因 | +| 运动 | 有计划和打卡,但计划解释和详情不足 | 计划详情页、运动禁忌、完成趋势 | +| 健康日历 | 汇总用药/运动/随访,但和打卡状态联动有限 | 日历上直接展示已完成、未完成、逾期 | +| 医生端 | 已有工作台,但患者范围和流程需加强 | 风险患者排序、未读消息、待审报告、随访待办 | +| 管理员端 | 医生管理已有基础 | 增加操作审计、禁用账号、数据统计 | + +### 11.10 更细的整改顺序 + +第一批必须先修安全: + +1. 移除生产 `devCode` 和前端自动填充。 +2. 修 SSE 认证,不再解析未验证 JWT。 +3. AI conversation 按用户归属查询。 +4. Consultation Hub 加鉴权、入组校验、senderType 服务端判定。 +5. Consultation HTTP 发消息校验当前用户。 +6. Exercise/Medication 的普通接口和 AI 工具都补所有权校验。 +7. 医生端详情、报告、随访接口限制到当前医生负责患者。 + +第二批修接口契约和稳定性: + +1. 文件上传返回 `{ id, name, size, url, contentType }`。 +2. 前端 `uploadFile` 兼容后端 envelope 和 list 返回,失败要提示。 +3. 饮食页 controller dispose。 +4. Chat/Consultation provider 注册 `ref.onDispose`。 +5. 后端补上传大小/类型限制。 +6. 关闭生产 body 日志。 + +第三批做 UI 体系: + +1. 把颜色、渐变、圆角、阴影、图标背景抽成设计 token。 +2. 侧边栏、个人信息、设置、饮食分析页按同一设计语言重做。 +3. 首页欢迎卡片和胶囊入口统一图标语义。 +4. 健康仪表盘增加更新时间、单位、状态文字。 +5. 对中老年用户做字号、对比度、触控面积检查。 + +第四批补测试: + +1. 后端加越权测试:用户 A 不能操作用户 B 的 conversation/consultation/exercise/medication。 +2. 加短信验证码生产环境不返回测试。 +3. 加 SignalR Hub 入组权限测试。 +4. 加文件上传类型/大小测试。 +5. Flutter 加饮食保存、问诊连接释放、上传失败 UI 的 widget/provider 测试。 + +## 12. 总体建议 这个项目最有价值的方向是“围绕患者长期健康数据做 AI 辅助管理”,不是简单堆功能入口。接下来建议把项目重心从“页面多”转为“核心闭环扎实”: @@ -383,4 +562,3 @@ var conversation = await db.Conversations - 安全和隐私经得起真实使用。 UI 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。 - diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 598bde9..8a5fac3 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -1,3 +1,4 @@ +import 'dart:developer'; import 'dart:io'; import 'package:dio/dio.dart'; import 'local_database.dart'; @@ -19,7 +20,7 @@ class ApiClient { headers: {'Content-Type': 'application/json'}, )) { _dio.interceptors.add(_AuthInterceptor(this)); - _dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: false)); + _dio.interceptors.add(LogInterceptor(requestBody: false, responseBody: false)); } Dio get dio => _dio; @@ -33,7 +34,8 @@ class ApiClient { } Future clearTokens() async { - await _db.deleteAll(); + await _db.delete('access_token'); + await _db.delete('refresh_token'); } /// 带 token 的 GET 请求 @@ -64,7 +66,19 @@ class ApiClient { final res = await _dio.post(path, data: formData); final data = res.data; if (data is Map) { - return data['url']?.toString() ?? data['data']?['url']?.toString(); + final topUrl = data['url']?.toString(); + if (topUrl != null && topUrl.isNotEmpty) return topUrl; + + final payload = data['data']; + if (payload is Map) { + final url = payload['url']?.toString(); + return url != null && url.isNotEmpty ? url : null; + } + if (payload is List && payload.isNotEmpty && payload.first is Map) { + final first = payload.first as Map; + final url = first['url']?.toString(); + return url != null && url.isNotEmpty ? url : null; + } } return null; } @@ -104,7 +118,7 @@ class _AuthInterceptor extends Interceptor { final retryResponse = await Dio(BaseOptions(baseUrl: baseUrl)).fetch(opts); return handler.resolve(retryResponse); } - } catch (e) { print('[ApiClient] token刷新失败: $e'); } + } catch (e) { log('[ApiClient] token刷新失败: $e'); } } await _client.clearTokens(); } diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index 0f9b234..ab4678d 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -58,6 +58,11 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { return const ReportListPage(); case 'aiAnalysis': return AiAnalysisPage(id: params['id']!); + case 'reportOriginal': + return ReportOriginalPage( + url: params['url'] ?? '', + title: params['title'] ?? '原始报告', + ); case 'exercisePlan': return const ExercisePlanPage(); case 'exerciseCreate': diff --git a/health_app/lib/core/local_database.dart b/health_app/lib/core/local_database.dart index 3d5fe29..c0b148f 100644 --- a/health_app/lib/core/local_database.dart +++ b/health_app/lib/core/local_database.dart @@ -1,7 +1,10 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; -/// SQLite 本地数据库——存储 token 等关键信息 +/// SQLite 本地键值缓存。 +/// +/// 约定:后端数据库是健康业务数据的唯一事实源。这里仅保存会话、偏好、 +/// 草稿和临时缓存,不保存健康记录、报告、用药、饮食、AI 对话等核心业务事实。 class LocalDatabase { static LocalDatabase? _instance; Database? _db; diff --git a/health_app/lib/pages/admin/admin_doctors_page.dart b/health_app/lib/pages/admin/admin_doctors_page.dart index 020b7fa..54aa04c 100644 --- a/health_app/lib/pages/admin/admin_doctors_page.dart +++ b/health_app/lib/pages/admin/admin_doctors_page.dart @@ -55,8 +55,8 @@ class _AdminDoctorsPageState extends ConsumerState { ), TextButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('删除'), style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('删除'), ), ], ), diff --git a/health_app/lib/pages/admin/admin_patients_page.dart b/health_app/lib/pages/admin/admin_patients_page.dart index e012d27..7ac3955 100644 --- a/health_app/lib/pages/admin/admin_patients_page.dart +++ b/health_app/lib/pages/admin/admin_patients_page.dart @@ -56,11 +56,12 @@ class _AdminPatientsPageState extends ConsumerState { }); } } catch (_) { - if (mounted) + if (mounted) { setState(() { _loading = false; _loadingMore = false; }); + } } } @@ -133,21 +134,23 @@ class _AdminPatientsPageState extends ConsumerState { : NotificationListener( onNotification: (n) { if (n is ScrollEndNotification && - n.metrics.pixels >= n.metrics.maxScrollExtent - 50) + n.metrics.pixels >= n.metrics.maxScrollExtent - 50) { _loadMore(); + } return false; }, child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _patients.length + (_loadingMore ? 1 : 0), itemBuilder: (_, i) { - if (i >= _patients.length) + if (i >= _patients.length) { return const Center( child: Padding( padding: EdgeInsets.all(16), child: CircularProgressIndicator(), ), ); + } final p = _patients[i]; return Card( margin: const EdgeInsets.only(bottom: 8), diff --git a/health_app/lib/pages/auth/login_page.dart b/health_app/lib/pages/auth/login_page.dart index a0578a5..302c776 100644 --- a/health_app/lib/pages/auth/login_page.dart +++ b/health_app/lib/pages/auth/login_page.dart @@ -160,11 +160,12 @@ class _LoginPageState extends ConsumerState { final active = doctors .where((d) => d['isActive'] != false) .toList(); - if (active.isEmpty) + if (active.isEmpty) { return const SizedBox( height: 200, child: Center(child: Text('暂无可用医生')), ); + } return SizedBox( height: 420, child: Column( @@ -228,7 +229,7 @@ class _LoginPageState extends ConsumerState { height: 200, child: Center(child: CircularProgressIndicator()), ), - error: (_, __) => + error: (_, _) => const SizedBox(height: 200, child: Center(child: Text('加载失败'))), ); }, diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 8639ae5..ada2b85 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -218,10 +218,16 @@ class _TrendPageState extends ConsumerState { body['value'] = v1; } await api.post('/api/health-records', data: body); + if (!ctx.mounted || !mounted) { + return; + } Navigator.pop(ctx); ref.invalidate(latestHealthProvider); _loadAll(); } catch (_) { + if (!mounted) { + return; + } ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('录入失败'))); @@ -454,8 +460,9 @@ class _TrendPageState extends ConsumerState { : 1, getTitlesWidget: (v, meta) { final idx = v.toInt(); - if (idx < 0 || idx >= _filtered.length) + if (idx < 0 || idx >= _filtered.length) { return const SizedBox.shrink(); + } final d = _filtered[idx]['date'] as DateTime; return Text( '${d.month}/${d.day}', @@ -482,7 +489,7 @@ class _TrendPageState extends ConsumerState { barWidth: 2.5, dotData: FlDotData( show: spots.length <= 60, - getDotPainter: (spot, _, __, ___) => FlDotCirclePainter( + getDotPainter: (spot, _, _, _) => FlDotCirclePainter( radius: 3, color: _color, strokeWidth: 1, diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index 38c5f91..405300a 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -118,8 +118,8 @@ class _DeviceScanPageState extends ConsumerState try { final ble = ref.read(omronBleServiceProvider); await ble.connect(r.device); - final name = r.advertisementData.localName.isNotEmpty - ? r.advertisementData.localName + final name = r.advertisementData.advName.isNotEmpty + ? r.advertisementData.advName : (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计'); await ref @@ -414,7 +414,7 @@ class _DeviceScanPageState extends ConsumerState const SizedBox(height: 32), AnimatedBuilder( animation: _pulseAnim, - builder: (_, __) => Container( + builder: (_, _) => Container( width: 48, height: 48, decoration: BoxDecoration( @@ -448,8 +448,8 @@ class _DeviceScanPageState extends ConsumerState // ── 设备列表项 ── Widget _buildTile(ScanResult r) { final isConnecting = _connectingId == r.device.remoteId.toString(); - final name = r.advertisementData.localName.isNotEmpty - ? r.advertisementData.localName + final name = r.advertisementData.advName.isNotEmpty + ? r.advertisementData.advName : (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备'); return Container( diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 5d861a3..b1e7b33 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -129,8 +129,9 @@ class DietNotifier extends Notifier { ); String text = ''; await for (final event in stream) { - if (event['action'] == 'answer') + if (event['action'] == 'answer') { text += (event['data'] as String?) ?? ''; + } if (event['action'] == 'status') { if (text.isNotEmpty) state = state.copyWith(commentary: text.trim()); } @@ -323,6 +324,15 @@ class _DietCapturePageState extends ConsumerState { super.initState(); } + @override + void dispose() { + for (final ctrl in _fieldCtrls.values) { + ctrl.dispose(); + } + _fieldCtrls.clear(); + super.dispose(); + } + @override Widget build(BuildContext context) { final state = ref.watch(dietProvider); diff --git a/health_app/lib/pages/doctor/doctor_consultations_page.dart b/health_app/lib/pages/doctor/doctor_consultations_page.dart index a829e63..d256af5 100644 --- a/health_app/lib/pages/doctor/doctor_consultations_page.dart +++ b/health_app/lib/pages/doctor/doctor_consultations_page.dart @@ -19,7 +19,7 @@ class DoctorConsultationsPage extends ConsumerWidget { final list = ref.watch(_consListProvider); return list.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center(child: Text('加载失败')), + error: (_, _) => const Center(child: Text('加载失败')), data: (items) => items.isEmpty ? const Center( child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint)), diff --git a/health_app/lib/pages/doctor/doctor_dashboard_page.dart b/health_app/lib/pages/doctor/doctor_dashboard_page.dart index 668caec..a0c3c02 100644 --- a/health_app/lib/pages/doctor/doctor_dashboard_page.dart +++ b/health_app/lib/pages/doctor/doctor_dashboard_page.dart @@ -63,7 +63,7 @@ class DoctorDashboardPage extends ConsumerWidget { dash.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center(child: Text('加载失败')), + error: (_, _) => const Center(child: Text('加载失败')), data: (data) => _buildContent(context, ref, data), ), ], diff --git a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart index 681d92f..eab0980 100644 --- a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart +++ b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart @@ -82,7 +82,7 @@ class _DoctorFollowUpEditPageState const SizedBox(height: 8), pts.when( loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), + error: (_, _) => const Text('加载失败'), data: (list) => _dropdown(list), ), const SizedBox(height: 16), diff --git a/health_app/lib/pages/doctor/doctor_followups_page.dart b/health_app/lib/pages/doctor/doctor_followups_page.dart index 2aef7a0..891171a 100644 --- a/health_app/lib/pages/doctor/doctor_followups_page.dart +++ b/health_app/lib/pages/doctor/doctor_followups_page.dart @@ -45,10 +45,11 @@ class _DoctorFollowupsPageState extends ConsumerState { @override Widget build(BuildContext context) { var items = ref.watch(_fupList); - if (_filter == 'Upcoming') + if (_filter == 'Upcoming') { items = items.where((f) => f['status'] == 'Upcoming').toList(); - else if (_filter == 'Completed') + } else if (_filter == 'Completed') { items = items.where((f) => f['status'] == 'Completed').toList(); + } return Column( children: [ diff --git a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart index 640e032..88d3359 100644 --- a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart @@ -35,7 +35,7 @@ class DoctorPatientDetailPage extends ConsumerWidget { ), body: detail.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center(child: Text('加载失败')), + error: (_, _) => const Center(child: Text('加载失败')), data: (data) => data == null ? const Center(child: Text('患者不存在')) : _buildBody(data), @@ -50,9 +50,6 @@ class DoctorPatientDetailPage extends ConsumerWidget { final meds = data['medications'] as List? ?? []; final reports = data['reports'] as List? ?? []; final followUps = data['followUps'] as List? ?? []; - final trends = data['trendRecords'] as List? ?? []; - final diet = data['dietRecords'] as List? ?? []; - final exercise = data['exercisePlan'] as Map?; return ListView( padding: const EdgeInsets.all(16), @@ -159,11 +156,12 @@ class DoctorPatientDetailPage extends ConsumerWidget { ...latest.map((r) { final type = r['metricType']?.toString() ?? ''; String val = ''; - if (type == 'BloodPressure') + if (type == 'BloodPressure') { val = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg'; - else + } else { val = '${r['value'] ?? '--'} ${r['unit'] ?? ''}'; + } return Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( diff --git a/health_app/lib/pages/doctor/doctor_patients_page.dart b/health_app/lib/pages/doctor/doctor_patients_page.dart index 0aff1a0..27fbf58 100644 --- a/health_app/lib/pages/doctor/doctor_patients_page.dart +++ b/health_app/lib/pages/doctor/doctor_patients_page.dart @@ -51,10 +51,11 @@ class _DoctorPatientsPageState extends ConsumerState { final items = (data['items'] as List?)?.cast>() ?? []; setState(() { - if (reset) + if (reset) { _patients = items; - else + } else { _patients.addAll(items); + } _total = data['total'] ?? 0; _hasMore = _patients.length < _total; _page++; diff --git a/health_app/lib/pages/doctor/doctor_profile_page.dart b/health_app/lib/pages/doctor/doctor_profile_page.dart index 246fb74..76232ff 100644 --- a/health_app/lib/pages/doctor/doctor_profile_page.dart +++ b/health_app/lib/pages/doctor/doctor_profile_page.dart @@ -53,7 +53,7 @@ class _DoctorProfileEditPageState extends ConsumerState { ), body: prof.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center(child: Text('加载失败')), + error: (_, _) => const Center(child: Text('加载失败')), data: (d) { if (d != null) { _name.text = d['name'] ?? ''; @@ -111,21 +111,23 @@ class _DoctorProfileEditPageState extends ConsumerState { }, ); ref.invalidate(_docProfileProvider); - if (mounted) + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('保存成功'), backgroundColor: AppColors.success, ), ); + } } catch (_) { - if (mounted) + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('保存失败'), backgroundColor: AppColors.error, ), ); + } } finally { if (mounted) setState(() => _saving = false); } diff --git a/health_app/lib/pages/doctor/doctor_report_detail_page.dart b/health_app/lib/pages/doctor/doctor_report_detail_page.dart index 6dfc1d1..763e548 100644 --- a/health_app/lib/pages/doctor/doctor_report_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_report_detail_page.dart @@ -72,18 +72,20 @@ class _DoctorReportDetailPageState ); setState(() => _submitted = true); ref.invalidate(_reportDetailProvider(widget.id)); - if (mounted) + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('审核提交成功'), backgroundColor: AppColors.success, ), ); + } } catch (e) { - if (mounted) + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error), ); + } } finally { if (mounted) setState(() => _submitting = false); } @@ -110,7 +112,7 @@ class _DoctorReportDetailPageState ), body: detail.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center(child: Text('加载失败')), + error: (_, _) => const Center(child: Text('加载失败')), data: (data) => data == null ? const Center(child: Text('报告不存在')) : _buildBody(data), @@ -253,7 +255,7 @@ class _DoctorReportDetailPageState ), ), Text( - '${ind['value'] ?? ''}', + ind['value'] ?? '', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -528,11 +530,11 @@ class _DoctorReportDetailPageState ), const SizedBox(height: 12), if (severity != null) - _InfoRow('严重程度', _severityLabels[severity] ?? severity), + _infoRow('严重程度', _severityLabels[severity] ?? severity), if (doctorRec != null && doctorRec.isNotEmpty) - _InfoRow('建议', doctorRec), + _infoRow('建议', doctorRec), if (doctorComment != null && doctorComment.isNotEmpty) - _InfoRow('评语', doctorComment), + _infoRow('评语', doctorComment), ], ), ), @@ -546,8 +548,9 @@ class _DoctorReportDetailPageState if (raw == null || raw.isEmpty) return []; try { final list = jsonDecode(raw); - if (list is List) + if (list is List) { return list.map((e) => Map.from(e as Map)).toList(); + } } catch (_) {} return []; } @@ -559,7 +562,7 @@ class _DoctorReportDetailPageState _ => AppColors.textHint, }; - Widget _InfoRow(String label, String value) => Padding( + Widget _infoRow(String label, String value) => Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/health_app/lib/pages/doctor/doctor_reports_page.dart b/health_app/lib/pages/doctor/doctor_reports_page.dart index ff574d7..8749275 100644 --- a/health_app/lib/pages/doctor/doctor_reports_page.dart +++ b/health_app/lib/pages/doctor/doctor_reports_page.dart @@ -63,7 +63,7 @@ class _DoctorReportsPageState extends ConsumerState { Expanded( child: reports.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => const Center(child: Text('加载失败')), + error: (_, _) => const Center(child: Text('加载失败')), data: (items) => items.isEmpty ? const Center( child: Text( diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 968fbd6..79b4b01 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; @@ -11,6 +12,7 @@ import '../../providers/auth_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/data_providers.dart'; import '../../widgets/health_drawer.dart'; +import '../../services/in_app_notification_service.dart'; import '../diet/diet_capture_page.dart'; import 'widgets/chat_messages_view.dart'; @@ -26,15 +28,74 @@ class _HomePageState extends ConsumerState { final _focusNode = FocusNode(); String? _pickedImagePath; int _lastMsgCount = 0; + Timer? _notificationTimer; + bool _checkingNotifications = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkNotifications()); + _notificationTimer = Timer.periodic( + const Duration(seconds: 30), + (_) => _checkNotifications(), + ); + } @override void dispose() { + _notificationTimer?.cancel(); _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); } + Future _checkNotifications() async { + if (!mounted || _checkingNotifications) return; + if (!ref.read(authProvider).isLoggedIn) return; + _checkingNotifications = true; + try { + final service = InAppNotificationService(ref.read(apiClientProvider)); + final notifications = await service.getPending(); + for (final notification in notifications) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 5), + content: Row( + children: [ + Icon( + notification.type == 'ExerciseReminder' + ? Icons.directions_run_outlined + : Icons.medication_outlined, + color: Colors.white, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(notification.title, + style: const TextStyle(fontWeight: FontWeight.w700)), + Text(notification.message), + ], + ), + ), + ], + ), + ), + ); + await service.acknowledge(notification.id); + } + } catch (_) { + // App 内提醒失败不阻断主页使用,下次轮询会重试。 + } finally { + _checkingNotifications = false; + } + } + void _sendMessage() { final text = _textCtrl.text.trim(); final imagePath = _pickedImagePath; diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 2d70066..26f826f 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -6,7 +6,6 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../core/app_colors.dart'; import '../../../core/app_theme.dart'; import '../../../core/navigation_provider.dart'; -import '../../../providers/auth_provider.dart'; import '../../../providers/chat_provider.dart'; import '../../../providers/data_providers.dart'; @@ -503,21 +502,21 @@ class ChatMessagesView extends ConsumerWidget { if (time.isNotEmpty) { final timeValue = meta['服药时间'] as String? ?? time; fields.add( - _ConfirmField(label: '服药时间', value: timeValue, editable: true), + _ConfirmField(label: '服药时间', value: timeValue), ); } final frequency = meta['frequency'] as String? ?? ''; if (frequency.isNotEmpty) { final freqValue = meta['频率'] as String? ?? _freqLabel(frequency); fields.add( - _ConfirmField(label: '频率', value: freqValue, editable: true), + _ConfirmField(label: '频率', value: freqValue), ); } final duration = meta['duration_days'] as int?; if (duration != null && duration > 0) { final durationValue = meta['服用天数'] as String? ?? '$duration 天'; fields.add( - _ConfirmField(label: '服用天数', value: durationValue, editable: true), + _ConfirmField(label: '服用天数', value: durationValue), ); } } else if (isExercise) { @@ -549,18 +548,18 @@ class ChatMessagesView extends ConsumerWidget { if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); if (dur.isNotEmpty) { - fields.add(_ConfirmField(label: '运动时长', value: dur, editable: true)); + fields.add(_ConfirmField(label: '运动时长', value: dur)); } if (freq.isNotEmpty) { fields.add(_ConfirmField(label: '频率', value: freq)); } if (exDays > 0) { - fields.add(_ConfirmField(label: '计划天数', value: '${exDays}天')); + fields.add(_ConfirmField(label: '计划天数', value: '$exDays天')); } final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString(); if (intensity.isNotEmpty) { fields.add( - _ConfirmField(label: '运动强度', value: intensity, editable: true), + _ConfirmField(label: '运动强度', value: intensity), ); } final calories = @@ -568,7 +567,7 @@ class ChatMessagesView extends ConsumerWidget { .toString(); if (calories.isNotEmpty) { fields.add( - _ConfirmField(label: '消耗热量', value: '$calories kcal', editable: true), + _ConfirmField(label: '消耗热量', value: '$calories kcal'), ); } } else { @@ -582,13 +581,13 @@ class ChatMessagesView extends ConsumerWidget { if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); final timeValue = meta['记录时间'] as String? ?? recordTime; fields.add( - _ConfirmField(label: '记录时间', value: timeValue, editable: true), + _ConfirmField(label: '记录时间', value: timeValue), ); final note = meta['note'] as String? ?? ''; if (note.isNotEmpty) { final noteValue = meta['备注'] as String? ?? note; fields.add( - _ConfirmField(label: '备注', value: noteValue, editable: true), + _ConfirmField(label: '备注', value: noteValue), ); } } @@ -851,7 +850,7 @@ class ChatMessagesView extends ConsumerWidget { horizontal: 16, ), ), - _buildFieldRow(fields[i], msg, ref), + _buildFieldRow(fields[i]), ], ], ), @@ -907,10 +906,15 @@ class ChatMessagesView extends ConsumerWidget { : Material( color: Colors.transparent, child: InkWell( - onTap: () { - ref + onTap: () async { + final error = await ref .read(chatProvider.notifier) .confirmMessage(msg.id); + if (error != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error)), + ); + } }, borderRadius: BorderRadius.circular(18), child: Container( @@ -958,9 +962,7 @@ class ChatMessagesView extends ConsumerWidget { ); } - Widget _buildFieldRow(_ConfirmField field, ChatMessage msg, WidgetRef ref) { - final isEditing = field.label == msg.metadata?['_editingField']; - + Widget _buildFieldRow(_ConfirmField field) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( @@ -975,67 +977,20 @@ class ChatMessagesView extends ConsumerWidget { ), const SizedBox(width: 12), Expanded( - child: !field.editable - ? Text( - field.value.isNotEmpty ? field.value : '未设置', - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ) - : isEditing - ? _buildEditableTextField(field, msg, ref) - : InkWell( - onTap: () => ref - .read(chatProvider.notifier) - .startEditingField(msg.id, field.label), - child: Row( - children: [ - Expanded( - child: Text( - field.value.isNotEmpty ? field.value : '未设置', - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - ), - Icon( - Icons.edit_outlined, - size: 18, - color: AppColors.border, - ), - ], - ), - ), + child: Text( + field.value.isNotEmpty ? field.value : '未设置', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), ), ], ), ); } - Widget _buildEditableTextField( - _ConfirmField field, - ChatMessage msg, - WidgetRef ref, - ) { - return _EditFieldCell( - key: ValueKey('editing_${msg.id}_${field.label}'), - initialValue: field.value, - onDone: (value) { - ref - .read(chatProvider.notifier) - .finishEditingField(msg.id, field.label, value); - }, - ); - } - - void _editField(_ConfirmField field, ChatMessage msg, WidgetRef ref) { - // 字段可点击修改的入口,后续可接入 showDialog 实现 - } - // ═══════════════════════════════════════════════════════════ // 3. ThinkingBubble — 思考动画 // ═══════════════════════════════════════════════════════════ @@ -1243,57 +1198,6 @@ class ChatMessagesView extends ConsumerWidget { ); } - // ═══════════════════════════════════════════════════════════ - // 公共组件:通用按钮 - // ═══════════════════════════════════════════════════════════ - - Widget _cardFilledBtn(String label, IconData icon, {VoidCallback? onTap}) { - return ElevatedButton( - onPressed: onTap ?? () {}, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primary, - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(vertical: 11), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 19), - const SizedBox(width: 5), - Text( - label, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), - ), - ], - ), - ); - } - - Widget _cardOutlineBtn(String label, IconData icon, {VoidCallback? onTap}) { - return OutlinedButton( - onPressed: onTap ?? () {}, - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.primary, - side: const BorderSide(color: AppTheme.primary, width: 1.2), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(vertical: 11), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 18), - const SizedBox(width: 4), - Text( - label, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), - ), - ], - ), - ); - } - // ═══════════════════════════════════════════════════════════ // 工具方法 // ═══════════════════════════════════════════════════════════ @@ -1428,45 +1332,6 @@ class ChatMessagesView extends ConsumerWidget { } } - static void _medicationCheckIn(WidgetRef ref, BuildContext context) async { - try { - final api = ref.read(apiClientProvider); - final reminders = await ref.read(medicationReminderProvider.future); - if (reminders.isEmpty) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('暂无待服药记录'), - backgroundColor: AppColors.warning, - ), - ); - return; - } - for (final m in reminders) { - final id = m['id']?.toString() ?? ''; - final time = m['scheduledTime']?.toString() ?? ''; - if (id.isEmpty) continue; - await api.post( - '/api/medications/$id/confirm-dose', - data: {'scheduledTime': time, 'status': 'taken'}, - ); - } - ref.invalidate(medicationReminderProvider); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('打卡成功 已记录 ${reminders.length} 项服药'), - backgroundColor: AppTheme.success, - ), - ); - } catch (e) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('打卡失败:$e'), backgroundColor: Colors.red), - ); - } - } - static _AgentColors _agentColors(ActiveAgent agent) { return switch (agent) { ActiveAgent.consultation => _AgentColors( @@ -1637,8 +1502,9 @@ class ChatMessagesView extends ConsumerWidget { } else if (hasAbnormal) { // 有异常时显示异常指标 final abnormalParts = []; - if (bpAbnormal) + if (bpAbnormal) { abnormalParts.add('血压 ${bp['systolic']}/${bp['diastolic']} 偏高'); + } tasks.add( _taskRow( context, @@ -1672,14 +1538,14 @@ class ChatMessagesView extends ConsumerWidget { const exIconBg = Color(0xFFFEF3C7); final exercisePlan = ref.watch(currentExercisePlanProvider); var hasExercise = false; - exercisePlan.whenOrNull( + exercisePlan.when( data: (plan) { if (plan != null) { final items = (plan['items'] as List?)?.cast>() ?? []; - final today = now.weekday % 7; + final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; final todayItem = items.cast?>().firstWhere( - (i) => i?['dayOfWeek'] == today, + (i) => i?['scheduledDate']?.toString() == today, orElse: () => null, ); if (todayItem != null && todayItem['isRestDay'] != true) { @@ -1707,6 +1573,36 @@ class ChatMessagesView extends ConsumerWidget { } } }, + loading: () { + hasExercise = true; + tasks.add( + _taskRow( + context, + Icons.directions_run, + '运动', + trailing: '正在加载运动计划', + status: 'pending', + iconColor: exIconColor, + iconBg: exIconBg, + onTap: () => pushRoute(ref, 'exercisePlan'), + ), + ); + }, + error: (_, _) { + hasExercise = true; + tasks.add( + _taskRow( + context, + Icons.directions_run, + '运动', + trailing: '运动计划加载失败', + status: 'overdue', + iconColor: exIconColor, + iconBg: exIconBg, + onTap: () => pushRoute(ref, 'exercisePlan'), + ), + ); + }, ); if (!hasExercise) { tasks.add( @@ -2057,13 +1953,9 @@ class _AgentAction { class _ConfirmField { final String label; final String value; - final IconData icon; - final bool editable; const _ConfirmField({ required this.label, required this.value, - this.icon = Icons.info_outline, - this.editable = false, }); } @@ -2215,58 +2107,3 @@ class _ExpandableAdviceState extends State<_ExpandableAdvice> { ); } } - -class _EditFieldCell extends StatefulWidget { - final String initialValue; - final ValueChanged onDone; - const _EditFieldCell({ - super.key, - required this.initialValue, - required this.onDone, - }); - - @override - State<_EditFieldCell> createState() => _EditFieldCellState(); -} - -class _EditFieldCellState extends State<_EditFieldCell> { - late final TextEditingController _ctrl; - - @override - void initState() { - super.initState(); - _ctrl = TextEditingController(text: widget.initialValue); - } - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => TextField( - controller: _ctrl, - autofocus: true, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - filled: true, - fillColor: Colors.white, - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: const Color(0xFF6366F1).withAlpha(80)), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFF6366F1), width: 1.5), - ), - ), - onSubmitted: widget.onDone, - onTapOutside: (_) => widget.onDone(_ctrl.text), - ); -} diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index 2d52269..93fab17 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -64,8 +64,9 @@ class _MedicationEditPageState extends ConsumerState { ]; _timesPerDay = tod.length; _times = tod; - if (m['startDate'] != null) + if (m['startDate'] != null) { _start = DateTime.tryParse(m['startDate'].toString()) ?? DateTime.now(); + } if (m['endDate'] != null) _end = DateTime.tryParse(m['endDate'].toString()); setState(() {}); } @@ -110,13 +111,14 @@ class _MedicationEditPageState extends ConsumerState { popRoute(ref); } } catch (_) { - if (mounted) + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('保存失败'), backgroundColor: AppTheme.error, ), ); + } } if (mounted) setState(() => _loading = false); } @@ -124,9 +126,12 @@ class _MedicationEditPageState extends ConsumerState { void _updateTimes(int n) { setState(() { _timesPerDay = n; - while (_times.length < n) + while (_times.length < n) { _times.add(const TimeOfDay(hour: 12, minute: 0)); - while (_times.length > n) _times.removeLast(); + } + while (_times.length > n) { + _times.removeLast(); + } }); } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index ac94145..767fadc 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -5,7 +5,6 @@ import '../core/app_theme.dart'; import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; -import '../providers/omron_device_provider.dart'; import '../widgets/common_widgets.dart'; /// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除) @@ -29,17 +28,19 @@ class _DietRecordListPageState extends ConsumerState { Future _refresh() async { final records = await ref.read(dietServiceProvider).getRecords(); - if (mounted) + if (mounted) { setState(() { _data = records; _loading = false; }); + } } Future _delete(String id) async { await ref.read(dietServiceProvider).deleteRecord(id); - if (mounted) + if (mounted) { setState(() => _data.removeWhere((d) => d['id']?.toString() == id)); + } } String _dateKey(DateTime d) => @@ -67,7 +68,7 @@ class _DietRecordListPageState extends ConsumerState { @override Widget build(BuildContext context) { - if (_loading) + if (_loading) { return GradientScaffold( appBar: AppBar( backgroundColor: Colors.white, @@ -84,6 +85,7 @@ class _DietRecordListPageState extends ConsumerState { ), body: const Center(child: CircularProgressIndicator()), ); + } final todayRecords = _dayRecords(_selectedDate); final totalCal = todayRecords.fold( @@ -441,15 +443,17 @@ class _TrendChart extends StatelessWidget { const _TrendChart(this.data); @override Widget build(BuildContext c) { - if (data.isEmpty) + if (data.isEmpty) { return const Center( child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)), ); + } final m = data.reduce((a, b) => a > b ? a : b); - if (m == 0) + if (m == 0) { return const Center( child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)), ); + } return Row( crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(data.length, (i) { @@ -549,10 +553,11 @@ class DietRecordDetailPage extends ConsumerWidget { body: FutureBuilder>>( future: service.getRecords(), builder: (ctx, snap) { - if (!snap.hasData) + if (!snap.hasData) { return const Center( child: CircularProgressIndicator(color: AppTheme.primary), ); + } final d = snap.data!.firstWhere( (r) => r['id']?.toString() == id, orElse: () => {}, @@ -734,11 +739,12 @@ class _ExercisePlanPageState extends ConsumerState { final done = items .where((it) => it['isCompleted'] == true) .length; - final weekStart = p['weekStartDate']?.toString() ?? ''; - // 用 DayOfWeek 匹配今天(C#: 0=Sun, 6=Sat; Dart weekday%7 对齐) - final todayCsDow = DateTime.now().weekday % 7; + final startDate = p['startDate']?.toString() ?? ''; + final endDate = p['endDate']?.toString() ?? ''; + final now = DateTime.now(); + final todayKey = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; final todayItem = items.cast>().firstWhere( - (it) => it['dayOfWeek'] == todayCsDow, + (it) => it['scheduledDate']?.toString() == todayKey, orElse: () => {}, ); final hasTodayItem = todayItem.isNotEmpty; @@ -786,7 +792,7 @@ class _ExercisePlanPageState extends ConsumerState { ), const SizedBox(height: 2), Text( - '$weekStart 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', + '$startDate 至 $endDate · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle( fontSize: 16, color: AppColors.textSecondary, @@ -855,12 +861,14 @@ class _ExercisePlanCreatePageState extends ConsumerState { final _nameCtrl = TextEditingController(); final _durationCtrl = TextEditingController(text: '30'); + final _daysCtrl = TextEditingController(text: '7'); DateTime _start = DateTime.now(); - DateTime _end = DateTime.now().add(const Duration(days: 6)); + TimeOfDay _reminderTime = const TimeOfDay(hour: 19, minute: 0); @override void dispose() { _nameCtrl.dispose(); _durationCtrl.dispose(); + _daysCtrl.dispose(); super.dispose(); } @@ -873,13 +881,16 @@ class _ExercisePlanCreatePageState return; } final dur = int.tryParse(_durationCtrl.text) ?? 30; + final days = (int.tryParse(_daysCtrl.text) ?? 7).clamp(1, 366); + final end = _start.add(Duration(days: days - 1)); await ref.read(exerciseServiceProvider).createPlanSimple({ 'exerciseType': name, 'durationMinutes': dur, 'startDate': '${_start.year}-${_start.month.toString().padLeft(2, '0')}-${_start.day.toString().padLeft(2, '0')}', 'endDate': - '${_end.year}-${_end.month.toString().padLeft(2, '0')}-${_end.day.toString().padLeft(2, '0')}', + '${end.year}-${end.month.toString().padLeft(2, '0')}-${end.day.toString().padLeft(2, '0')}', + 'reminderTime': '${_reminderTime.hour.toString().padLeft(2, '0')}:${_reminderTime.minute.toString().padLeft(2, '0')}', }); ref.invalidate(currentExercisePlanProvider); if (mounted) { @@ -904,9 +915,11 @@ class _ExercisePlanCreatePageState const SizedBox(height: 16), _field('每天时长(分钟)', _durationCtrl, hint: '30', number: true), const SizedBox(height: 16), + _field('坚持天数', _daysCtrl, hint: '7', number: true), + const SizedBox(height: 16), _dateField('开始日期', _start, (d) => setState(() => _start = d)), const SizedBox(height: 16), - _dateField('结束日期', _end, (d) => setState(() => _end = d)), + _timeField(), const SizedBox(height: 32), SizedBox( width: double.infinity, @@ -1006,6 +1019,29 @@ class _ExercisePlanCreatePageState ], ); } + + Widget _timeField() { + final value = '${_reminderTime.hour.toString().padLeft(2, '0')}:${_reminderTime.minute.toString().padLeft(2, '0')}'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('每日提醒时间', style: TextStyle(fontSize: 17, color: AppColors.textSecondary)), + const SizedBox(height: 6), + GestureDetector( + onTap: () async { + final picked = await showTimePicker(context: context, initialTime: _reminderTime); + if (picked != null && mounted) setState(() => _reminderTime = picked); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + child: Text(value, style: const TextStyle(fontSize: 19)), + ), + ), + ], + ); + } } /// 复查列表(从服务器读取) @@ -1254,8 +1290,9 @@ class _HealthArchivePageState extends ConsumerState { _diagnosisCtrl.text = a['diagnosis'] ?? ''; final st = a['surgeryType'] as String?; final sd = a['surgeryDate'] as String?; - if (st != null && st.isNotEmpty) + if (st != null && st.isNotEmpty) { _surgeries.add({'type': st, 'date': sd ?? ''}); + } _allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? ''; _chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? ''; _dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? ''; @@ -1839,19 +1876,16 @@ class _HealthCalendarPageState extends ConsumerState { '/api/calendar/day', queryParameters: {'date': _dateKey(d)}, ); - if (mounted) + if (mounted) { setState( () => _selectedDayData = res.data['data'] as Map?, ); + } } catch (_) {} } @override Widget build(BuildContext context) { - final today = DateTime.now(); - final isToday = - _selectedDate != null && _dateKey(_selectedDate!) == _dateKey(today); - return GradientScaffold( appBar: AppBar( backgroundColor: Colors.white, @@ -2053,13 +2087,14 @@ class _HealthCalendarPageState extends ConsumerState { }; Widget _buildDayDetail() { - if (_selectedDate == null) + if (_selectedDate == null) { return const Center( child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)), ); + } if (_selectedDayData == null) { final evs = _events[_dateKey(_selectedDate!)] ?? []; - if (evs.isEmpty) + if (evs.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -2077,10 +2112,11 @@ class _HealthCalendarPageState extends ConsumerState { ], ), ); + } } final data = _selectedDayData; - if (data == null) + if (data == null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -2094,6 +2130,7 @@ class _HealthCalendarPageState extends ConsumerState { ], ), ); + } final meds = (data['medications'] as List?)?.cast>() ?? []; @@ -2359,437 +2396,6 @@ class StaticTextPage extends ConsumerWidget { } } -/// 旧版设备管理页,已移至 device/device_management_page.dart -class _DeviceManagementPageLegacy extends ConsumerWidget { - const _DeviceManagementPageLegacy({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final device = ref.watch(omronDeviceProvider); - return GradientScaffold( - appBar: AppBar( - backgroundColor: AppColors.cardBackground, - title: const Text('蓝牙血压计'), - ), - body: device.isBound - ? _buildBoundCard(context, ref, device) - : _buildEmptyState(context, ref), - ); - } - - Widget _buildEmptyState(BuildContext context, WidgetRef ref) => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 88, - height: 88, - decoration: BoxDecoration( - color: AppColors.iconBg, - borderRadius: BorderRadius.circular(28), - ), - child: Icon( - Icons.bluetooth_disabled, - size: 44, - color: AppColors.textHint, - ), - ), - const SizedBox(height: 20), - const Text( - '未绑定设备', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - const Text( - '连接欧姆龙血压计,自动同步测量数据', - style: TextStyle(fontSize: 15, color: AppColors.textHint), - ), - const SizedBox(height: 28), - SizedBox( - width: 220, - height: 52, - child: ElevatedButton.icon( - onPressed: () => pushRoute(ref, 'deviceScan'), - icon: const Icon(Icons.add, size: 22), - label: const Text( - '添加设备', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - ), - ), - ), - ], - ), - ), - ); - - Widget _buildBoundCard( - BuildContext context, - WidgetRef ref, - DeviceBindState device, - ) => SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // 设备信息卡片 - Container( - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(AppTheme.rMd), - boxShadow: AppColors.cardShadow, - ), - child: Column( - children: [ - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - gradient: device.isConnected - ? AppColors.primaryGradient - : const LinearGradient( - colors: [Color(0xFF9CA3AF), Color(0xFF6B7280)], - ), - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - child: Icon( - device.isConnected - ? Icons.bluetooth_connected - : Icons.bluetooth_disabled, - size: 36, - color: Colors.white, - ), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - decoration: BoxDecoration( - color: device.isConnected - ? const Color(0xFFD1FAE5) - : const Color(0xFFFEE2E2), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - device.isConnected ? '已连接' : '未连接', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: device.isConnected - ? const Color(0xFF059669) - : const Color(0xFFDC2626), - ), - ), - ), - const SizedBox(height: 12), - Text( - device.name ?? '血压计', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 6), - Text( - 'MAC: ${device.mac ?? '—'}', - style: const TextStyle(fontSize: 14, color: AppColors.textHint), - ), - if (device.lastSync != null) ...[ - const SizedBox(height: 4), - Text( - '上次同步: ${device.lastSync}', - style: const TextStyle( - fontSize: 13, - color: AppColors.textHint, - ), - ), - ], - const SizedBox(height: 24), - if (device.isConnected) ...[ - // 已连接:显示断开和解绑 - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => _unbind(context, ref), - icon: const Icon( - Icons.delete_outline, - size: 18, - color: AppColors.error, - ), - label: const Text( - '解绑', - style: TextStyle(color: AppColors.error), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: AppColors.error), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - padding: const EdgeInsets.symmetric(vertical: 14), - ), - ), - ), - ], - ), - ] else ...[ - // 未连接:显示重新连接和解绑 - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton.icon( - onPressed: () => pushRoute(ref, 'deviceScan'), - icon: const Icon(Icons.bluetooth_searching, size: 20), - label: const Text( - '重新连接设备', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - ), - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _unbind(context, ref), - icon: const Icon( - Icons.delete_outline, - size: 18, - color: AppColors.error, - ), - label: const Text( - '解绑设备', - style: TextStyle(color: AppColors.error), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: AppColors.error), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - padding: const EdgeInsets.symmetric(vertical: 14), - ), - ), - ), - ], - ], - ), - ), - - // 最后读数卡片 - if (device.lastReading != null) ...[ - const SizedBox(height: 16), - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(AppTheme.rMd), - boxShadow: AppColors.cardShadow, - ), - child: Column( - children: [ - Row( - children: [ - Container( - width: 6, - height: 18, - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(3), - ), - ), - const SizedBox(width: 8), - const Text( - '最近一次测量', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: AppColors.iconBg, - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - child: const Text('🩺', style: TextStyle(fontSize: 28)), - ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - device.lastReading!.display, - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - letterSpacing: -0.5, - ), - ), - Text( - 'mmHg', - style: TextStyle( - fontSize: 14, - color: AppColors.textHint, - ), - ), - ], - ), - const Spacer(), - if (device.lastReading!.pulse != null) - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${device.lastReading!.pulse}', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - color: AppColors.primary, - ), - ), - const Text( - 'bpm', - style: TextStyle( - fontSize: 13, - color: AppColors.textHint, - ), - ), - ], - ), - ], - ), - ], - ), - ), - ], - - // 使用说明 - const SizedBox(height: 16), - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: AppColors.iconBg, - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.lightbulb_outline, - size: 18, - color: AppColors.primary, - ), - const SizedBox(width: 8), - const Text( - '使用说明', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - ], - ), - const SizedBox(height: 10), - _guideItem('1', '血压计装好电池,绑好袖带'), - _guideItem('2', '血压计按开始键开始测量'), - _guideItem('3', '测量完成后数据自动同步到 App'), - _guideItem('4', '打开此页面时保持蓝牙开启'), - ], - ), - ), - ], - ), - ); - - Widget _guideItem(String num, String text) => Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '$num.', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColors.primary, - ), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - text, - style: const TextStyle( - fontSize: 14, - color: AppColors.textSecondary, - ), - ), - ), - ], - ), - ); - - void _unbind(BuildContext context, WidgetRef ref) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('解绑设备'), - content: const Text('解绑后需重新扫描连接,确定吗?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('取消'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('确定解绑', style: TextStyle(color: AppColors.error)), - ), - ], - ), - ); - if (ok == true) { - await ref.read(omronDeviceProvider.notifier).unbind(); - } - } -} - Widget _empty(BuildContext context, String title, String subtitle) => Center( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/health_app/lib/pages/report/ai_analysis_page.dart b/health_app/lib/pages/report/ai_analysis_page.dart index 33f2a23..3917f1c 100644 --- a/health_app/lib/pages/report/ai_analysis_page.dart +++ b/health_app/lib/pages/report/ai_analysis_page.dart @@ -39,7 +39,12 @@ class _AiAnalysisPageState extends ConsumerState { } final displayType = _displayName(analysis.reportType); - final isReviewed = reportItem?.status == 'DoctorReviewed'; + final aiStatus = reportItem?.aiStatus ?? analysis.aiStatus; + final reviewStatus = reportItem?.reviewStatus ?? analysis.reviewStatus; + final fileUrl = reportItem?.fileUrl ?? analysis.fileUrl; + final isReviewed = reviewStatus == 'Reviewed'; + final isAnalyzing = aiStatus == 'Analyzing'; + final isFailed = aiStatus == 'Failed'; return GradientScaffold( appBar: AppBar( @@ -64,8 +69,43 @@ class _AiAnalysisPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (fileUrl != null && fileUrl.isNotEmpty) ...[ + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => pushRoute( + ref, + 'reportOriginal', + params: {'url': fileUrl, 'title': displayType}, + ), + icon: const Icon(Icons.image_outlined, size: 18), + label: const Text('查看原始报告'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primaryLight), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 12), + ], + if (isAnalyzing || isFailed) ...[ + _analysisStateCard( + isFailed + ? (analysis.summary.isNotEmpty ? analysis.summary : 'AI 分析失败,请重新上传或稍后重试') + : 'AI 正在分析报告,请稍后刷新查看。', + isFailed: isFailed, + onRetry: isFailed + ? () => ref.read(reportProvider.notifier).reanalyzeReport(widget.id) + : null, + ), + const SizedBox(height: 20), + ], // ── 1. 指标分析 ── - if (analysis.indicators.isNotEmpty) ...[ + if (!isAnalyzing && !isFailed && analysis.indicators.isNotEmpty) ...[ _sectionTitle('指标分析'), const SizedBox(height: 8), Container( @@ -79,72 +119,76 @@ class _AiAnalysisPageState extends ConsumerState { const SizedBox(height: 20), ], // ── 2. 综合解读 ── - _sectionTitle('综合解读'), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(16), - decoration: _cardDeco(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - analysis.summary.isNotEmpty - ? analysis.summary - : 'AI 正在分析中,请稍后刷新查看', - style: const TextStyle( - fontSize: 16, - color: AppColors.textPrimary, - height: 1.7, - ), - ), - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.cardInner, - borderRadius: BorderRadius.circular(10), - ), - child: const Text( - '以上为AI预解读,具体请以医生诊断为准', - style: TextStyle(fontSize: 13, color: AppColors.textHint), - ), - ), - ], - ), - ), - const SizedBox(height: 20), - // ── 3. 医生审核意见 ── - _sectionTitle('医生审核意见'), - const SizedBox(height: 8), - if (isReviewed) - _doctorReviewCard(reportItem!) - else + if (!isAnalyzing && !isFailed) ...[ + _sectionTitle('综合解读'), + const SizedBox(height: 8), Container( padding: const EdgeInsets.all(16), decoration: _cardDeco(), - child: const Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.hourglass_empty, - size: 18, - color: AppColors.textHint, - ), - SizedBox(width: 8), Text( - '待审核', - style: TextStyle( + analysis.summary.isNotEmpty + ? analysis.summary + : 'AI 正在分析中,请稍后刷新查看', + style: const TextStyle( fontSize: 16, - color: AppColors.textSecondary, + color: AppColors.textPrimary, + height: 1.7, ), ), - Spacer(), - Text( - 'AI 预解读', - style: TextStyle(fontSize: 14, color: AppColors.textHint), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.cardInner, + borderRadius: BorderRadius.circular(10), + ), + child: const Text( + '以上为AI预解读,不能替代医生诊断和治疗建议', + style: TextStyle(fontSize: 13, color: AppColors.textHint), + ), ), ], ), ), + const SizedBox(height: 20), + ], + if (!isAnalyzing && !isFailed) ...[ + // ── 3. 医生审核意见 ── + _sectionTitle('医生审核意见'), + const SizedBox(height: 8), + if (isReviewed) + _doctorReviewCard(reportItem!) + else + Container( + padding: const EdgeInsets.all(16), + decoration: _cardDeco(), + child: const Row( + children: [ + Icon( + Icons.hourglass_empty, + size: 18, + color: AppColors.textHint, + ), + SizedBox(width: 8), + Text( + '待审核', + style: TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + ), + ), + Spacer(), + Text( + 'AI 预解读', + style: TextStyle(fontSize: 14, color: AppColors.textHint), + ), + ], + ), + ), + ], const SizedBox(height: 30), ], ), @@ -159,6 +203,63 @@ class _AiAnalysisPageState extends ConsumerState { boxShadow: AppColors.cardShadowLight, ); + Widget _analysisStateCard( + String message, { + required bool isFailed, + VoidCallback? onRetry, + }) { + final color = isFailed ? AppColors.error : AppColors.primary; + return Container( + padding: const EdgeInsets.all(16), + decoration: _cardDeco(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isFailed ? Icons.error_outline : Icons.hourglass_empty, + color: color, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: TextStyle( + fontSize: 16, + height: 1.6, + color: isFailed ? AppColors.error : AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (onRetry != null) ...[ + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('重新分析'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ], + ), + ); + } + Widget _sectionTitle(String text) => Row( children: [ Container( diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index 9955b11..72fc07d 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -1,12 +1,13 @@ import 'dart:convert'; +import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:file_picker/file_picker.dart'; import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; +import '../../core/api_client.dart' show baseUrl; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; @@ -19,12 +20,14 @@ class ReportState { final String? uploadingImage; final bool isAnalyzing; final ReportAnalysis? currentAnalysis; + final String? uploadError; ReportState({ this.reports = const [], this.uploadingImage, this.isAnalyzing = false, this.currentAnalysis, + this.uploadError, }); ReportState copyWith({ @@ -32,12 +35,21 @@ class ReportState { String? uploadingImage, bool? isAnalyzing, ReportAnalysis? currentAnalysis, + String? uploadError, + bool clearUploadingImage = false, + bool clearCurrentAnalysis = false, + bool clearUploadError = false, }) { return ReportState( reports: reports ?? this.reports, - uploadingImage: uploadingImage ?? this.uploadingImage, + uploadingImage: clearUploadingImage + ? null + : uploadingImage ?? this.uploadingImage, isAnalyzing: isAnalyzing ?? this.isAnalyzing, - currentAnalysis: currentAnalysis ?? this.currentAnalysis, + currentAnalysis: clearCurrentAnalysis + ? null + : currentAnalysis ?? this.currentAnalysis, + uploadError: clearUploadError ? null : uploadError ?? this.uploadError, ); } } @@ -47,9 +59,12 @@ class ReportItem { final String title; final String type; final DateTime uploadedAt; - final String? imagePath; + final String? fileUrl; final bool hasAnalysis; - final String status; // PendingDoctor | DoctorReviewed + final String status; // Legacy combined status from backend. + final String aiStatus; // Analyzing | Succeeded | Failed + final String reviewStatus; // Pending | Reviewed + final String? analysisSummary; final String? severity; // Normal | Abnormal | Severe | Critical final String? doctorComment; final String? doctorRecommendation; @@ -61,9 +76,12 @@ class ReportItem { required this.title, required this.type, required this.uploadedAt, - this.imagePath, + this.fileUrl, this.hasAnalysis = false, this.status = 'PendingDoctor', + this.aiStatus = 'Analyzing', + this.reviewStatus = 'Pending', + this.analysisSummary, this.severity, this.doctorComment, this.doctorRecommendation, @@ -77,12 +95,20 @@ class ReportAnalysis { final String reportType; final List indicators; final String summary; + final String status; + final String aiStatus; + final String reviewStatus; + final String? fileUrl; ReportAnalysis({ required this.reportId, required this.reportType, required this.indicators, required this.summary, + this.status = 'PendingDoctor', + this.aiStatus = 'Analyzing', + this.reviewStatus = 'Pending', + this.fileUrl, }); } @@ -103,8 +129,11 @@ class Indicator { } class ReportNotifier extends Notifier { + Timer? _pollTimer; + @override ReportState build() { + ref.onDispose(() => _pollTimer?.cancel()); Future.microtask(() => loadReports()); return ReportState(); } @@ -129,8 +158,12 @@ class ReportNotifier extends Notifier { uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(), + fileUrl: m['fileUrl']?.toString(), hasAnalysis: m['aiSummary'] != null, status: m['status']?.toString() ?? 'PendingDoctor', + aiStatus: m['aiStatus']?.toString() ?? _deriveAiStatus(m), + reviewStatus: m['reviewStatus']?.toString() ?? _deriveReviewStatus(m), + analysisSummary: m['aiSummary']?.toString(), severity: m['severity']?.toString(), doctorComment: m['doctorComment']?.toString(), doctorRecommendation: m['doctorRecommendation']?.toString(), @@ -139,11 +172,25 @@ class ReportNotifier extends Notifier { ); }).toList(); state = state.copyWith(reports: reports); + _syncAnalysisPolling(reports); } catch (e) { debugPrint('[Report] 加载报告列表失败: $e'); } } + void _syncAnalysisPolling(List reports) { + final hasAnalyzing = reports.any((r) => r.aiStatus == 'Analyzing'); + if (!hasAnalyzing) { + _pollTimer?.cancel(); + _pollTimer = null; + return; + } + _pollTimer ??= Timer.periodic( + const Duration(seconds: 4), + (_) => loadReports(), + ); + } + void fetchReportDetail(String reportId) async { try { final api = ref.read(apiClientProvider); @@ -164,6 +211,10 @@ class ReportNotifier extends Notifier { reportType: displayType, indicators: indicators, summary: m['aiSummary']?.toString() ?? '', + status: m['status']?.toString() ?? 'PendingDoctor', + aiStatus: m['aiStatus']?.toString() ?? _deriveAiStatus(m), + reviewStatus: m['reviewStatus']?.toString() ?? _deriveReviewStatus(m), + fileUrl: m['fileUrl']?.toString(), ); state = state.copyWith(currentAnalysis: analysis); } catch (_) { @@ -176,8 +227,21 @@ class ReportNotifier extends Notifier { reportType: '检查报告', indicators: [], summary: '暂无数据,请下拉刷新重试', + status: 'AnalysisFailed', + aiStatus: 'Failed', + reviewStatus: 'Pending', ); + String _deriveAiStatus(Map m) { + final status = m['status']?.toString(); + if (status == 'Analyzing') return 'Analyzing'; + if (status == 'AnalysisFailed') return 'Failed'; + return m['aiSummary'] != null ? 'Succeeded' : 'Analyzing'; + } + + String _deriveReviewStatus(Map m) => + m['status']?.toString() == 'DoctorReviewed' ? 'Reviewed' : 'Pending'; + List _parseIndicators(String? jsonStr) { if (jsonStr == null || jsonStr.isEmpty) return []; try { @@ -198,7 +262,11 @@ class ReportNotifier extends Notifier { } void uploadImage(String path) async { - state = state.copyWith(uploadingImage: path, isAnalyzing: true); + state = state.copyWith( + uploadingImage: path, + isAnalyzing: true, + clearUploadError: true, + ); try { final api = ref.read(apiClientProvider); final file = File(path); @@ -211,14 +279,28 @@ class ReportNotifier extends Notifier { // 直接上传到报告端点,后端自动触发 VLM+LLM 分析 final createRes = await api.dio.post('/api/reports', data: formData); final data = createRes.data; - final reportId = data is Map - ? (data['data']?['id']?.toString() ?? '') - : ''; - - state = state.copyWith(isAnalyzing: false, uploadingImage: null); + if (data is Map && data['code'] != 0) { + final message = data['message']?.toString() ?? '上传失败,请稍后重试'; + state = state.copyWith( + isAnalyzing: false, + clearUploadingImage: true, + uploadError: message, + ); + return; + } + state = state.copyWith( + isAnalyzing: false, + clearUploadingImage: true, + clearUploadError: true, + ); loadReports(); - } catch (_) { - state = state.copyWith(isAnalyzing: false, uploadingImage: null); + } catch (e) { + debugPrint('[Report] 上传失败: $e'); + state = state.copyWith( + isAnalyzing: false, + clearUploadingImage: true, + uploadError: '上传失败,请检查网络或文件格式后重试', + ); } } @@ -237,8 +319,25 @@ class ReportNotifier extends Notifier { } } + Future reanalyzeReport(String id) async { + try { + final res = await ref.read(apiClientProvider).post('/api/reports/$id/reanalyze'); + final data = res.data; + if (data is Map && data['code'] != 0) { + state = state.copyWith(uploadError: data['message']?.toString() ?? '重新分析失败'); + return; + } + state = state.copyWith(clearCurrentAnalysis: true, clearUploadError: true); + await loadReports(); + fetchReportDetail(id); + } catch (e) { + debugPrint('[Report] 重新分析失败: $e'); + state = state.copyWith(uploadError: '重新分析失败,请稍后重试'); + } + } + void clearAnalysis() { - state = state.copyWith(currentAnalysis: null); + state = state.copyWith(clearCurrentAnalysis: true); } } @@ -253,15 +352,18 @@ class ReportListPage extends ConsumerWidget { if (state.isAnalyzing) { return GradientScaffold( appBar: AppBar(title: const Text('报告管理')), - body: const Center( + body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(color: AppColors.primary), - SizedBox(height: 16), + const CircularProgressIndicator(color: AppColors.primary), + const SizedBox(height: 16), Text( - 'AI 正在分析报告...', - style: TextStyle(fontSize: 16, color: AppColors.textSecondary), + state.uploadingImage == null ? 'AI 正在分析报告...' : '正在上传报告...', + style: const TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + ), ), ], ), @@ -281,14 +383,48 @@ class ReportListPage extends ConsumerWidget { floatingActionButton: _buildUploadButton(context, ref), body: RefreshIndicator( onRefresh: () async => ref.read(reportProvider.notifier).loadReports(), - child: state.reports.isEmpty - ? ListView(children: [_buildEmptyState(context)]) - : ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: state.reports.length, - itemBuilder: (context, index) => - _buildReportCard(context, ref, state.reports[index]), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (state.uploadError != null) ...[ + _buildUploadError(state.uploadError!), + const SizedBox(height: 12), + ], + if (state.reports.isEmpty) + _buildEmptyState(context) + else + ...state.reports.map( + (report) => _buildReportCard(context, ref, report), ), + ], + ), + ), + ); + } + + Widget _buildUploadError(String message) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.error.withValues(alpha: 0.22)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: AppColors.error, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: const TextStyle( + fontSize: 14, + color: AppColors.error, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), ); } @@ -327,8 +463,9 @@ class ReportListPage extends ConsumerWidget { source: ImageSource.camera, imageQuality: 85, ); - if (picked != null) + if (picked != null) { ref.read(reportProvider.notifier).uploadImage(picked.path); + } }, ), ListTile( @@ -344,26 +481,9 @@ class ReportListPage extends ConsumerWidget { source: ImageSource.gallery, imageQuality: 85, ); - if (picked != null) + if (picked != null) { ref.read(reportProvider.notifier).uploadImage(picked.path); - }, - ), - ListTile( - leading: const Icon( - Icons.picture_as_pdf_outlined, - color: AppColors.primary, - ), - title: const Text('上传PDF文件', style: TextStyle(fontSize: 17)), - onTap: () async { - Navigator.pop(ctx); - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['pdf'], - ); - if (result != null && result.files.isNotEmpty) - ref - .read(reportProvider.notifier) - .uploadFile(result.files.first.path!); + } }, ), ], @@ -471,66 +591,24 @@ class ReportListPage extends ConsumerWidget { color: AppColors.textHint, ), ), + if (report.aiStatus == 'Failed') ...[ + const SizedBox(height: 4), + Text( + _failureSummary(report), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + height: 1.35, + color: AppColors.error, + fontWeight: FontWeight.w600, + ), + ), + ], ], ), ), - if (report.status == 'DoctorReviewed') - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.successLight, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - '已审核', - style: TextStyle( - fontSize: 13, - color: AppColors.success, - fontWeight: FontWeight.w500, - ), - ), - ) - else if (!report.hasAnalysis) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.cardInner, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - '分析中', - style: TextStyle( - fontSize: 13, - color: AppColors.textHint, - fontWeight: FontWeight.w500, - ), - ), - ) - else - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.warningLight, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - '待审核', - style: TextStyle( - fontSize: 13, - color: AppColors.warning, - fontWeight: FontWeight.w500, - ), - ), - ), + _buildStatusBadge(report), const SizedBox(width: 4), GestureDetector( onTap: () => _confirmDelete(context, ref, report.id), @@ -574,6 +652,40 @@ class ReportListPage extends ConsumerWidget { ); } + Widget _buildStatusBadge(ReportItem report) { + final (label, bg, fg) = switch (report.status) { + _ when report.reviewStatus == 'Reviewed' => ('已审核', AppColors.successLight, AppColors.success), + _ when report.aiStatus == 'Analyzing' => ('分析中', AppColors.cardInner, AppColors.textHint), + _ when report.aiStatus == 'Failed' => ('分析失败', AppColors.error.withValues(alpha: 0.08), AppColors.error), + _ when report.aiStatus == 'Succeeded' => ('待审核', AppColors.warningLight, AppColors.warning), + _ => ('分析中', AppColors.cardInner, AppColors.textHint), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: fg, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + String _failureSummary(ReportItem report) { + final summary = report.analysisSummary?.trim(); + if (summary != null && summary.isNotEmpty) { + return summary; + } + return 'AI 暂时无法解读,请稍后重试或重新上传清晰图片'; + } + String _formatDate(DateTime date) { final now = DateTime.now(); final diff = now.difference(date); @@ -593,3 +705,49 @@ String _catTitle(String c) => switch (c) { 'Image' => '影像检查报告', _ => '检查报告', }; + +class ReportOriginalPage extends StatelessWidget { + final String url; + final String title; + + const ReportOriginalPage({ + super.key, + required this.url, + this.title = '原始报告', + }); + + @override + Widget build(BuildContext context) { + final imageUrl = _absoluteUrl(url); + return GradientScaffold( + appBar: AppBar(title: Text(title)), + body: Center( + child: InteractiveViewer( + minScale: 0.7, + maxScale: 4, + child: Image.network( + imageUrl, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => const Padding( + padding: EdgeInsets.all(24), + child: Text( + '原始报告图片加载失败', + style: TextStyle(color: AppColors.textSecondary), + ), + ), + ), + ), + ), + ); + } + + String _absoluteUrl(String value) { + if (value.startsWith('http://') || value.startsWith('https://')) { + return value; + } + if (value.startsWith('/')) { + return '$baseUrl$value'; + } + return '$baseUrl/$value'; + } +} diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart index f871a01..c497c2f 100644 --- a/health_app/lib/pages/settings/notification_prefs_page.dart +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -171,10 +171,11 @@ class NotificationPrefsPage extends ConsumerWidget { context: context, initialTime: const TimeOfDay(hour: 22, minute: 0), ); - if (picked != null && context.mounted) + if (picked != null && context.mounted) { ref .read(notificationPrefsProvider.notifier) .setDndStart(picked); + } }, ), ), @@ -197,10 +198,11 @@ class NotificationPrefsPage extends ConsumerWidget { context: context, initialTime: const TimeOfDay(hour: 8, minute: 0), ); - if (picked != null && context.mounted) + if (picked != null && context.mounted) { ref .read(notificationPrefsProvider.notifier) .setDndEnd(picked); + } }, ), ), diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index 34488ec..7a3e08e 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -5,7 +5,6 @@ import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; -import '../../providers/data_providers.dart' show apiClientProvider; class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index 8bcae7c..bed44e4 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -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 { ); } } catch (e) { - print('[Auth] loadProfile: $e'); + log('[Auth] loadProfile: $e'); } } @@ -144,7 +146,7 @@ class AuthNotifier extends Notifier { 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); diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 9527ec6..845c84e 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -79,64 +79,60 @@ final chatProvider = NotifierProvider( 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 { StreamSubscription>? _subscription; + Completer? _streamDone; ActiveAgent? _lastTriggeredAgent; void markNeedsRebuild() => state = state.copyWith(); /// 不可变消息操作方法(供 chat_messages_view 新版代码调用) - void confirmMessage(String id) { + Future 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() + : []; + 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 { 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 { } Future 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 { // 异步上传图片 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 { 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 { token: token, ); - await for (final event in stream) { - _processEvent(event, aiMsg); + await _cancelActiveStream(); + final done = Completer(); + _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 { } } + Future _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; diff --git a/health_app/lib/providers/consultation_provider.dart b/health_app/lib/providers/consultation_provider.dart index f580981..e8e320d 100644 --- a/health_app/lib/providers/consultation_provider.dart +++ b/health_app/lib/providers/consultation_provider.dart @@ -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 { doctorTitle: doc['title']?.toString() ?? '', doctorDepartment: doc['department']?.toString() ?? '', ); - } catch (e) { print('[Consultation]请求失败: $e'); } + } catch (e) { log('[Consultation]请求失败: $e'); } } Future _loadMessages(String consultationId) async { @@ -218,7 +219,7 @@ class ConsultationChatNotifier extends Notifier { if (msgs.isNotEmpty) { state = state.copyWith(messages: msgs); } - } catch (e) { print('[Consultation]请求失败: $e'); } + } catch (e) { log('[Consultation]请求失败: $e'); } } Future sendMessage(String text) async { @@ -333,7 +334,7 @@ class ConsultationChatNotifier extends Notifier { if (newMsgs.isNotEmpty) { state = state.copyWith(messages: [...state.messages, ...newMsgs]); } - } catch (e) { print('[Consultation]请求失败: $e'); } + } catch (e) { log('[Consultation]请求失败: $e'); } } void stop() { diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index 39ab83c..5d4b434 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -62,37 +62,9 @@ final medicationReminderProvider = FutureProvider>>((r /// 医生列表 Provider final doctorListProvider = FutureProvider>>((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>((ref) async { final service = ref.watch(consultationServiceProvider); @@ -102,23 +74,7 @@ final consultationQuotaProvider = FutureProvider>((ref) asy /// 当前运动计划 Provider final currentExercisePlanProvider = FutureProvider?>((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)); }); /// 拍照/相册直接触发(无需跳转页面) diff --git a/health_app/lib/services/in_app_notification_service.dart b/health_app/lib/services/in_app_notification_service.dart new file mode 100644 index 0000000..ea8eaee --- /dev/null +++ b/health_app/lib/services/in_app_notification_service.dart @@ -0,0 +1,43 @@ +import '../core/api_client.dart'; + +class InAppNotification { + final String id; + final String type; + final String title; + final String message; + + const InAppNotification({ + required this.id, + required this.type, + required this.title, + required this.message, + }); + + factory InAppNotification.fromJson(Map json) => + InAppNotification( + id: json['id']?.toString() ?? '', + type: json['type']?.toString() ?? '', + title: json['title']?.toString() ?? '健康提醒', + message: json['message']?.toString() ?? '', + ); +} + +class InAppNotificationService { + final ApiClient _api; + + const InAppNotificationService(this._api); + + Future> getPending() async { + final response = await _api.get('/api/notifications/pending'); + final items = response.data['data'] as List? ?? const []; + return items + .whereType() + .map((item) => InAppNotification.fromJson(Map.from(item))) + .where((item) => item.id.isNotEmpty) + .toList(); + } + + Future acknowledge(String id) async { + await _api.post('/api/notifications/$id/acknowledge'); + } +} diff --git a/health_app/lib/services/omron_ble_service.dart b/health_app/lib/services/omron_ble_service.dart index 9c1f534..96f55cf 100644 --- a/health_app/lib/services/omron_ble_service.dart +++ b/health_app/lib/services/omron_ble_service.dart @@ -49,7 +49,7 @@ class OmronBleService { static bool isBpDevice(ScanResult r) { final name = [ - r.advertisementData.localName, + r.advertisementData.advName, r.device.platformName, ].where((n) => n.isNotEmpty).join(' ').toUpperCase(); return name.contains('OMRON') ||