From 13714d9ed8abf1a4977d4d3f9cf6bda0e9ac0939 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Sun, 21 Jun 2026 21:06:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BA=94=E7=94=A8=E5=86=85=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E7=B3=BB=E7=BB=9F=20+=20=E7=BB=93=E6=9E=84=E5=8C=96?= =?UTF-8?q?=E6=89=8B=E6=9C=AF=E5=8F=B2/=E7=94=A8=E8=8D=AF=E7=AD=89?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker - 通知中心页面及前端通知服务接入 - 健康指标异常、用药/运动提醒等事件统一产出站内通知 - 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整 - AppDbContext 注册通知相关实体 --- .../AI/PatientContextService.cs | 5 +- .../HealthArchives/HealthArchiveContracts.cs | 13 +- .../HealthArchives/HealthArchiveService.cs | 73 ++- .../Medications/MedicationContracts.cs | 1 - .../Medications/MedicationReminderScanner.cs | 10 +- .../InAppNotificationContracts.cs | 36 +- .../Notifications/InAppNotificationService.cs | 42 +- .../Entities/support_entities.cs | 29 + .../AI/AgentHandlers/common_agent_handler.cs | 2 +- .../Data/app_db_context.cs | 23 + .../Exercises/ExerciseReminderProducer.cs | 41 +- .../EfHealthArchiveRepository.cs | 4 +- .../Medications/EfMedicationRepository.cs | 6 - .../OutboxMedicationReminderDispatcher.cs | 44 +- .../EfInAppNotificationRepository.cs | 40 +- .../EfUserNotificationPipeline.cs | 139 ++++ .../Users/EfUserRepository.cs | 2 + .../notification_outbox_worker.cs | 37 ++ .../Endpoints/doctor_endpoints.cs | 14 +- .../Endpoints/notification_endpoints.cs | 32 + .../Health.WebApi/Endpoints/user_endpoints.cs | 11 +- .../Health.Tests/application_service_tests.cs | 79 +++ .../persistence_pipeline_tests.cs | 111 ++++ docs/project_deep_review.md | 6 + health_app/lib/core/api_client.dart | 67 +- health_app/lib/core/app_router.dart | 3 + .../doctor/doctor_patient_detail_page.dart | 12 +- health_app/lib/pages/home/home_page.dart | 113 ++-- .../home/widgets/chat_messages_view.dart | 12 +- .../notification_center_page.dart | 593 ++++++++++++++++++ health_app/lib/providers/auth_provider.dart | 2 + health_app/lib/providers/chat_provider.dart | 2 + health_app/lib/providers/data_providers.dart | 69 +- .../services/in_app_notification_service.dart | 68 +- 34 files changed, 1541 insertions(+), 200 deletions(-) create mode 100644 backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs create mode 100644 backend/src/Health.WebApi/BackgroundServices/notification_outbox_worker.cs create mode 100644 health_app/lib/pages/notifications/notification_center_page.dart diff --git a/backend/src/Health.Application/AI/PatientContextService.cs b/backend/src/Health.Application/AI/PatientContextService.cs index 6be790a..ee7de85 100644 --- a/backend/src/Health.Application/AI/PatientContextService.cs +++ b/backend/src/Health.Application/AI/PatientContextService.cs @@ -24,7 +24,10 @@ public sealed class PatientContextService( 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.Surgeries.Count > 0) + sb.AppendLine($"手术: {string.Join(";", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}"); + else 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)}"); diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs index 22c788b..2fb65f0 100644 --- a/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs +++ b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs @@ -2,12 +2,22 @@ using Health.Domain.Entities; namespace Health.Application.HealthArchives; +public sealed record HealthArchiveSurgeryDto( + Guid Id, + string Type, + DateOnly? Date); + +public sealed record HealthArchiveSurgeryInput( + string Type, + DateOnly? Date); + public sealed record HealthArchiveDto( Guid Id, Guid UserId, string? Diagnosis, string? SurgeryType, DateOnly? SurgeryDate, + IReadOnlyList Surgeries, IReadOnlyList Allergies, IReadOnlyList DietRestrictions, IReadOnlyList ChronicDiseases, @@ -21,7 +31,8 @@ public sealed record HealthArchiveUpdateRequest( IReadOnlyList? Allergies, IReadOnlyList? DietRestrictions, IReadOnlyList? ChronicDiseases, - string? FamilyHistory); + string? FamilyHistory, + IReadOnlyList? Surgeries = null); public interface IHealthArchiveService { diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs index 6c84e6f..1af1daf 100644 --- a/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs +++ b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs @@ -42,7 +42,8 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH "update_diagnosis" => request with { SurgeryType = null, SurgeryDate = null, Allergies = null, - DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null + DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null, + Surgeries = null }, "update_surgery" => request with { @@ -77,8 +78,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH 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.Surgeries != null) + { + archive.Surgeries.Clear(); + var items = request.Surgeries + .Where(s => !string.IsNullOrWhiteSpace(s.Type)) + .Select((s, index) => new HealthArchiveSurgery + { + Id = Guid.NewGuid(), + HealthArchiveId = archive.Id, + Type = s.Type.Trim(), + Date = s.Date, + SortOrder = index, + }) + .ToList(); + foreach (var item in items) archive.Surgeries.Add(item); + SyncLegacySurgeryFields(archive); + } + else if (request.SurgeryType != null) + { + SeedLegacySurgeryIfNeeded(archive); + var type = request.SurgeryType.Trim(); + if (type.Length > 0 && !archive.Surgeries.Any(s => + s.Type == type && s.Date == request.SurgeryDate)) + { + archive.Surgeries.Add(new HealthArchiveSurgery + { + Id = Guid.NewGuid(), + HealthArchiveId = archive.Id, + Type = type, + Date = request.SurgeryDate, + SortOrder = archive.Surgeries.Count, + }); + } + SyncLegacySurgeryFields(archive); + } 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(); @@ -91,6 +125,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH archive.Diagnosis, archive.SurgeryType, SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), + surgeries = archive.Surgeries.Select(s => new { s.Type, date = s.Date?.ToString("yyyy-MM-dd") }), archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, @@ -103,9 +138,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH archive.Diagnosis, archive.SurgeryType, archive.SurgeryDate, + GetSurgeries(archive), archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, archive.UpdatedAt); + + private static IReadOnlyList GetSurgeries(HealthArchive archive) + { + var surgeries = archive.Surgeries + .OrderBy(s => s.SortOrder) + .Select(s => new HealthArchiveSurgeryDto(s.Id, s.Type, s.Date)) + .ToList(); + if (surgeries.Count == 0 && !string.IsNullOrWhiteSpace(archive.SurgeryType)) + surgeries.Add(new HealthArchiveSurgeryDto(Guid.Empty, archive.SurgeryType, archive.SurgeryDate)); + return surgeries; + } + + private static void SyncLegacySurgeryFields(HealthArchive archive) + { + var first = archive.Surgeries.OrderBy(s => s.SortOrder).FirstOrDefault(); + archive.SurgeryType = first?.Type; + archive.SurgeryDate = first?.Date; + } + + private static void SeedLegacySurgeryIfNeeded(HealthArchive archive) + { + if (archive.Surgeries.Count > 0 || string.IsNullOrWhiteSpace(archive.SurgeryType)) return; + archive.Surgeries.Add(new HealthArchiveSurgery + { + Id = Guid.NewGuid(), + HealthArchiveId = archive.Id, + Type = archive.SurgeryType, + Date = archive.SurgeryDate, + SortOrder = 0, + }); + } } diff --git a/backend/src/Health.Application/Medications/MedicationContracts.cs b/backend/src/Health.Application/Medications/MedicationContracts.cs index d3cd628..b1944bf 100644 --- a/backend/src/Health.Application/Medications/MedicationContracts.cs +++ b/backend/src/Health.Application/Medications/MedicationContracts.cs @@ -78,7 +78,6 @@ public interface IMedicationRepository 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); diff --git a/backend/src/Health.Application/Medications/MedicationReminderScanner.cs b/backend/src/Health.Application/Medications/MedicationReminderScanner.cs index 0fd31c0..0723c0d 100644 --- a/backend/src/Health.Application/Medications/MedicationReminderScanner.cs +++ b/backend/src/Health.Application/Medications/MedicationReminderScanner.cs @@ -21,10 +21,18 @@ public sealed class MedicationReminderScanner(IMedicationRepository medications) 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))) { + if (await _medications.DoseLogExistsAsync( + medication.UserId, + medication.Id, + scheduledTime, + todayStartUtc, + todayEndUtc, + ct)) + continue; + tasks.Add(new MedicationReminderTask( Guid.NewGuid(), medication.UserId, diff --git a/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs b/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs index afdfa78..2cb4114 100644 --- a/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs +++ b/backend/src/Health.Application/Notifications/InAppNotificationContracts.cs @@ -5,22 +5,56 @@ public sealed record InAppNotificationDto( string Type, string Title, string Message, + string Severity, + string? ActionType, + string? ActionTargetId, + bool IsRead, DateTime CreatedAt); +public sealed record NotificationMessage( + string Type, + string Title, + string Message, + string Severity, + string? ActionType, + string? ActionTargetId); + public interface IInAppNotificationService { Task> GetPendingAsync(Guid userId, CancellationToken ct); + Task> GetHistoryAsync(Guid userId, CancellationToken ct); Task AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct); + Task MarkAllReadAsync(Guid userId, CancellationToken ct); + Task DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct); } public interface IInAppNotificationRepository { Task> GetPendingAsync(Guid userId, CancellationToken ct); + Task> GetHistoryAsync(Guid userId, CancellationToken ct); Task AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct); + Task MarkAllReadAsync(Guid userId, CancellationToken ct); + Task DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct); } public sealed record InAppNotificationRecord( Guid Id, string Type, - string Payload, + string Title, + string Message, + string Severity, + string? ActionType, + string? ActionTargetId, + bool IsRead, DateTime CreatedAt); + +public interface IUserNotificationProducer +{ + Task EnqueueAsync(Guid userId, Guid sourceId, NotificationMessage message, CancellationToken ct); +} + +public interface INotificationOutboxProcessor +{ + Task RecoverStaleAsync(CancellationToken ct); + Task ProcessNextAsync(CancellationToken ct); +} diff --git a/backend/src/Health.Application/Notifications/InAppNotificationService.cs b/backend/src/Health.Application/Notifications/InAppNotificationService.cs index f946adb..d7365c7 100644 --- a/backend/src/Health.Application/Notifications/InAppNotificationService.cs +++ b/backend/src/Health.Application/Notifications/InAppNotificationService.cs @@ -1,5 +1,3 @@ -using System.Text.Json; - namespace Health.Application.Notifications; public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService @@ -12,36 +10,22 @@ public sealed class InAppNotificationService(IInAppNotificationRepository notifi return records.Select(ToDto).ToList(); } + public async Task> GetHistoryAsync(Guid userId, CancellationToken ct) + { + var records = await _notifications.GetHistoryAsync(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); - } + public Task MarkAllReadAsync(Guid userId, CancellationToken ct) => + _notifications.MarkAllReadAsync(userId, ct); - 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); - } + public Task DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) => + _notifications.DeleteAsync(userId, notificationId, ct); - return new InAppNotificationDto(record.Id, record.Type, "健康提醒", "您有一条新的健康提醒", record.CreatedAt); - } + private static InAppNotificationDto ToDto(InAppNotificationRecord record) => new( + record.Id, record.Type, record.Title, record.Message, record.Severity, + record.ActionType, record.ActionTargetId, record.IsRead, record.CreatedAt); } diff --git a/backend/src/Health.Domain/Entities/support_entities.cs b/backend/src/Health.Domain/Entities/support_entities.cs index e0328f3..3c32d25 100644 --- a/backend/src/Health.Domain/Entities/support_entities.cs +++ b/backend/src/Health.Domain/Entities/support_entities.cs @@ -38,6 +38,18 @@ public sealed class HealthArchive public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public User User { get; set; } = null!; + public ICollection Surgeries { get; set; } = []; +} + +public sealed class HealthArchiveSurgery +{ + public Guid Id { get; set; } + public Guid HealthArchiveId { get; set; } + public string Type { get; set; } = string.Empty; + public DateOnly? Date { get; set; } + public int SortOrder { get; set; } + + public HealthArchive HealthArchive { get; set; } = null!; } /// @@ -97,3 +109,20 @@ public sealed class DeviceToken public User User { get; set; } = null!; } + +public sealed class UserNotification +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public Guid SourceId { get; set; } + public string Type { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Severity { get; set; } = "info"; + public string? ActionType { get; set; } + public string? ActionTargetId { get; set; } + public bool IsRead { get; set; } + public DateTime? ReadAt { get; set; } + public bool IsDeleted { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} 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 14fe04c..3ed5565 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs @@ -55,7 +55,7 @@ public static class CommonAgentHandler return new { found = true, archive.Diagnosis, archive.SurgeryType, - SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), + SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries, archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, }; } diff --git a/backend/src/Health.Infrastructure/Data/app_db_context.cs b/backend/src/Health.Infrastructure/Data/app_db_context.cs index 310ddb3..37bc5fa 100644 --- a/backend/src/Health.Infrastructure/Data/app_db_context.cs +++ b/backend/src/Health.Infrastructure/Data/app_db_context.cs @@ -28,11 +28,13 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon public DbSet DoctorProfiles => Set(); public DbSet FollowUps => Set(); public DbSet HealthArchives => Set(); + public DbSet HealthArchiveSurgeries => Set(); // 支撑表 public DbSet RefreshTokens => Set(); public DbSet VerificationCodes => Set(); public DbSet NotificationPreferences => Set(); + public DbSet UserNotifications => Set(); public DbSet DeviceTokens => Set(); public DbSet AiWriteCommands => Set(); public DbSet ReportAnalysisTasks => Set(); @@ -154,12 +156,33 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon e.HasIndex(n => n.UserId).IsUnique(); }); + builder.Entity(e => + { + e.ToTable("UserNotifications"); + e.HasIndex(n => n.SourceId).IsUnique(); + e.HasIndex(n => new { n.UserId, n.IsDeleted, n.IsRead, n.CreatedAt }); + e.Property(n => n.Type).HasMaxLength(64); + e.Property(n => n.Severity).HasMaxLength(16); + e.Property(n => n.ActionType).HasMaxLength(64); + }); + // ---- HealthArchive ---- builder.Entity(e => { e.HasIndex(a => a.UserId).IsUnique(); }); + builder.Entity(e => + { + e.ToTable("HealthArchiveSurgeries"); + e.HasIndex(s => new { s.HealthArchiveId, s.SortOrder }); + e.Property(s => s.Type).HasMaxLength(256); + e.HasOne(s => s.HealthArchive) + .WithMany(a => a.Surgeries) + .HasForeignKey(s => s.HealthArchiveId) + .OnDelete(DeleteBehavior.Cascade); + }); + builder.Entity(e => { e.ToTable("AiWriteCommands"); diff --git a/backend/src/Health.Infrastructure/Exercises/ExerciseReminderProducer.cs b/backend/src/Health.Infrastructure/Exercises/ExerciseReminderProducer.cs index b970cb3..2b3b713 100644 --- a/backend/src/Health.Infrastructure/Exercises/ExerciseReminderProducer.cs +++ b/backend/src/Health.Infrastructure/Exercises/ExerciseReminderProducer.cs @@ -1,12 +1,13 @@ using System.Text.Json; using Health.Application.Exercises; -using Health.Infrastructure.Data.Records; +using Health.Application.Notifications; namespace Health.Infrastructure.Exercises; -public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminderProducer +public sealed class ExerciseReminderProducer(AppDbContext db, IUserNotificationProducer notifications) : IExerciseReminderProducer { private readonly AppDbContext _db = db; + private readonly IUserNotificationProducer _notifications = notifications; public async Task ProduceDueAsync(DateTime utcNow, CancellationToken ct) { @@ -17,28 +18,22 @@ public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminde .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))) + var created = 0; + foreach (var item in dueItems) { - 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 (await _notifications.EnqueueAsync( + item.Plan.UserId, + item.Id, + new NotificationMessage( + "ExerciseReminder", + "今日运动计划", + $"今天安排了 {item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。", + "info", + "exercise", + item.PlanId.ToString()), + ct)) + created++; } - if (_db.ChangeTracker.HasChanges()) await _db.SaveChangesAsync(ct); - return dueItems.Count - existingSet.Count; + return created; } } diff --git a/backend/src/Health.Infrastructure/HealthArchives/EfHealthArchiveRepository.cs b/backend/src/Health.Infrastructure/HealthArchives/EfHealthArchiveRepository.cs index ef6ab29..a3e8a29 100644 --- a/backend/src/Health.Infrastructure/HealthArchives/EfHealthArchiveRepository.cs +++ b/backend/src/Health.Infrastructure/HealthArchives/EfHealthArchiveRepository.cs @@ -7,7 +7,9 @@ public sealed class EfHealthArchiveRepository(AppDbContext db) : IHealthArchiveR private readonly AppDbContext _db = db; public Task GetByUserIdAsync(Guid userId, CancellationToken ct) => - _db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); + _db.HealthArchives + .Include(a => a.Surgeries) + .FirstOrDefaultAsync(a => a.UserId == userId, ct); public async Task AddAsync(HealthArchive archive, CancellationToken ct) => await _db.HealthArchives.AddAsync(archive, ct); diff --git a/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs b/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs index f5933d0..7bb5dcf 100644 --- a/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs +++ b/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs @@ -56,12 +56,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit .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); diff --git a/backend/src/Health.Infrastructure/Medications/OutboxMedicationReminderDispatcher.cs b/backend/src/Health.Infrastructure/Medications/OutboxMedicationReminderDispatcher.cs index 3a4ceba..5a481f6 100644 --- a/backend/src/Health.Infrastructure/Medications/OutboxMedicationReminderDispatcher.cs +++ b/backend/src/Health.Infrastructure/Medications/OutboxMedicationReminderDispatcher.cs @@ -1,43 +1,35 @@ using System.Text.Json; using Health.Application.Medications; -using Health.Infrastructure.Data.Records; +using Health.Application.Notifications; using Microsoft.Extensions.Logging; namespace Health.Infrastructure.Medications; public sealed class OutboxMedicationReminderDispatcher( - AppDbContext db, + IUserNotificationProducer notifications, ILogger logger) : IMedicationReminderDispatcher { - private readonly AppDbContext _db = db; + private readonly IUserNotificationProducer _notifications = notifications; 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 + var details = string.Join(" · ", new[] { - 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); + task.Dosage, + task.ScheduledTime.ToString("HH:mm") + }.Where(x => !string.IsNullOrWhiteSpace(x))); + await _notifications.EnqueueAsync( + task.UserId, + task.TaskId, + new NotificationMessage( + "MedicationReminder", + "用药时间到了", + $"请按计划服用{task.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。", + "info", + "medication", + task.MedicationId.ToString()), + ct); _logger.LogInformation( "用药提醒已写入通知 Outbox,等待推送服务: {TaskId} {UserId}", diff --git a/backend/src/Health.Infrastructure/Notifications/EfInAppNotificationRepository.cs b/backend/src/Health.Infrastructure/Notifications/EfInAppNotificationRepository.cs index babbb97..155d7f6 100644 --- a/backend/src/Health.Infrastructure/Notifications/EfInAppNotificationRepository.cs +++ b/backend/src/Health.Infrastructure/Notifications/EfInAppNotificationRepository.cs @@ -7,20 +7,46 @@ public sealed class EfInAppNotificationRepository(AppDbContext db) : IInAppNotif 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") + await _db.UserNotifications.AsNoTracking() + .Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted) .OrderBy(x => x.CreatedAt) .Take(10) - .Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Payload, x.CreatedAt)) + .Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message, + x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, x.CreatedAt)) + .ToListAsync(ct); + + public async Task> GetHistoryAsync(Guid userId, CancellationToken ct) => + await _db.UserNotifications.AsNoTracking() + .Where(x => x.UserId == userId && !x.IsDeleted) + .OrderByDescending(x => x.CreatedAt) + .Take(100) + .Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message, + x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, 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") + var updated = await _db.UserNotifications + .Where(x => x.Id == notificationId && x.UserId == userId && !x.IsRead && !x.IsDeleted) .ExecuteUpdateAsync(setters => setters - .SetProperty(x => x.Status, "InAppDelivered") - .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); + .SetProperty(x => x.IsRead, true) + .SetProperty(x => x.ReadAt, DateTime.UtcNow), ct); return updated > 0; } + + public Task MarkAllReadAsync(Guid userId, CancellationToken ct) => + _db.UserNotifications + .Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.IsRead, true) + .SetProperty(x => x.ReadAt, DateTime.UtcNow), ct); + + public async Task DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) + { + var deleted = await _db.UserNotifications + .Where(x => x.Id == notificationId && x.UserId == userId && !x.IsDeleted) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.IsDeleted, true), ct); + return deleted > 0; + } } diff --git a/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs b/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs new file mode 100644 index 0000000..eddeb0d --- /dev/null +++ b/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using Health.Application.Notifications; +using Health.Infrastructure.Data.Records; +using Npgsql; + +namespace Health.Infrastructure.Notifications; + +public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificationProducer +{ + private readonly AppDbContext _db = db; + + public async Task EnqueueAsync( + Guid userId, + Guid sourceId, + NotificationMessage message, + CancellationToken ct) + { + if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct)) + return false; + + var now = DateTime.UtcNow; + await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord + { + Id = Guid.NewGuid(), + UserId = userId, + SourceTaskId = sourceId, + Type = message.Type, + Payload = JsonSerializer.Serialize(message), + Status = "Pending", + 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 sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificationOutboxProcessor +{ + private const int MaxAttempts = 5; + private readonly AppDbContext _db = db; + + public async Task RecoverStaleAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + await _db.NotificationOutbox + .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); + + await _db.NotificationOutbox + .Where(x => x.Status == "Processing" && + x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Failed") + .SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数") + .SetProperty(x => x.UpdatedAt, now), ct); + } + + public async Task ProcessNextAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + var candidate = await _db.NotificationOutbox.AsNoTracking() + .Where(x => x.Status == "Pending" && + x.AvailableAt <= now && x.Attempts < MaxAttempts) + .OrderBy(x => x.CreatedAt) + .FirstOrDefaultAsync(ct); + if (candidate == null) return false; + + var claimed = await _db.NotificationOutbox + .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 true; + + try + { + var message = JsonSerializer.Deserialize(candidate.Payload) + ?? throw new InvalidOperationException("通知消息内容为空"); + + if (!await _db.UserNotifications.AsNoTracking() + .AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct)) + { + await _db.UserNotifications.AddAsync(new UserNotification + { + Id = Guid.NewGuid(), + UserId = candidate.UserId, + SourceId = candidate.SourceTaskId, + Type = message.Type, + Title = message.Title, + Message = message.Message, + Severity = message.Severity, + ActionType = message.ActionType, + ActionTargetId = message.ActionTargetId, + CreatedAt = candidate.CreatedAt, + }, ct); + // Persist the inbox item first. If completion fails, stale-task + // recovery can safely retry because SourceId is idempotent. + await _db.SaveChangesAsync(ct); + } + + await _db.NotificationOutbox.Where(x => x.Id == candidate.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Completed") + .SetProperty(x => x.LastError, (string?)null) + .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); + } + catch (Exception ex) + { + var attempts = candidate.Attempts + 1; + await _db.NotificationOutbox.Where(x => x.Id == candidate.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending") + .SetProperty(x => x.AvailableAt, + DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5)) + .SetProperty(x => x.LastError, + ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message) + .SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); + } + + return true; + } +} diff --git a/backend/src/Health.Infrastructure/Users/EfUserRepository.cs b/backend/src/Health.Infrastructure/Users/EfUserRepository.cs index ad67d0d..50119f8 100644 --- a/backend/src/Health.Infrastructure/Users/EfUserRepository.cs +++ b/backend/src/Health.Infrastructure/Users/EfUserRepository.cs @@ -40,6 +40,8 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository 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.UserNotifications.Where(n => n.UserId == userId).ExecuteDeleteAsync(ct); + await _db.NotificationOutbox.Where(n => n.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); diff --git a/backend/src/Health.WebApi/BackgroundServices/notification_outbox_worker.cs b/backend/src/Health.WebApi/BackgroundServices/notification_outbox_worker.cs new file mode 100644 index 0000000..5e198a0 --- /dev/null +++ b/backend/src/Health.WebApi/BackgroundServices/notification_outbox_worker.cs @@ -0,0 +1,37 @@ +using Health.Application.Notifications; + +namespace Health.WebApi.BackgroundServices; + +public sealed class NotificationOutboxWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var nextRecovery = DateTime.MinValue; + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService(); + if (DateTime.UtcNow >= nextRecovery) + { + await processor.RecoverStaleAsync(stoppingToken); + nextRecovery = DateTime.UtcNow.AddMinutes(1); + } + if (!await processor.ProcessNextAsync(stoppingToken)) + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "通知 Outbox 消费失败"); + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + } + } + } +} diff --git a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs index 52642e4..6cc82cd 100644 --- a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs @@ -132,6 +132,7 @@ public static class DoctorEndpoints var user = await db.Users .Include(u => u.HealthArchive) + .ThenInclude(a => a!.Surgeries) .FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId); if (user == null) return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" }); @@ -179,7 +180,18 @@ public static class DoctorEndpoints data = new { profile = new { user.Id, user.Phone, user.Name, user.Gender, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), user.AvatarUrl }, - archive = user.HealthArchive == null ? null : new { user.HealthArchive.Diagnosis, user.HealthArchive.SurgeryType, SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"), user.HealthArchive.Allergies, user.HealthArchive.DietRestrictions, user.HealthArchive.ChronicDiseases, user.HealthArchive.FamilyHistory }, + archive = user.HealthArchive == null ? null : new + { + user.HealthArchive.Diagnosis, + user.HealthArchive.SurgeryType, + SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"), + surgeries = user.HealthArchive.Surgeries.OrderBy(s => s.SortOrder) + .Select(s => new { s.Type, Date = s.Date.HasValue ? s.Date.Value.ToString("yyyy-MM-dd") : null }), + user.HealthArchive.Allergies, + user.HealthArchive.DietRestrictions, + user.HealthArchive.ChronicDiseases, + user.HealthArchive.FamilyHistory + }, latestRecords, trendRecords, medications, diff --git a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs index f12fbb2..124a8c5 100644 --- a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs @@ -17,6 +17,19 @@ public static class NotificationEndpoints return Results.Ok(new { code = 0, data = result, message = (string?)null }); }); + group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => + { + var userId = GetUserId(http); + if (userId == Guid.Empty) return Results.Unauthorized(); + var result = await notifications.GetHistoryAsync(userId, ct); + return Results.Ok(new + { + code = 0, + data = new { unreadCount = result.Count(x => !x.IsRead), items = result }, + message = (string?)null + }); + }); + group.MapPost("/{id:guid}/acknowledge", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => { var userId = GetUserId(http); @@ -24,6 +37,25 @@ public static class NotificationEndpoints var acknowledged = await notifications.AcknowledgeAsync(userId, id, ct); return Results.Ok(new { code = 0, data = new { acknowledged }, message = (string?)null }); }); + + group.MapPost("/read-all", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => + { + var userId = GetUserId(http); + if (userId == Guid.Empty) return Results.Unauthorized(); + var count = await notifications.MarkAllReadAsync(userId, ct); + return Results.Ok(new { code = 0, data = new { count }, message = (string?)null }); + }); + + + group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => + { + var userId = GetUserId(http); + if (userId == Guid.Empty) return Results.Unauthorized(); + var deleted = await notifications.DeleteAsync(userId, id, ct); + return deleted + ? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null }) + : Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" }); + }); } private static Guid GetUserId(HttpContext http) => diff --git a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs index ac1b647..081f24a 100644 --- a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs @@ -36,6 +36,9 @@ public static class UserEndpoints group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) => { var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null; + var surgeries = req.Surgeries?.Select(s => new HealthArchiveSurgeryInput( + s.Type, + DateOnly.TryParse(s.Date, out var date) ? date : null)).ToList(); await archives.UpdateAsync( GetUserId(http), new HealthArchiveUpdateRequest( @@ -45,7 +48,8 @@ public static class UserEndpoints req.Allergies, req.DietRestrictions, req.ChronicDiseases, - req.FamilyHistory), + req.FamilyHistory, + surgeries), ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); @@ -65,4 +69,7 @@ public sealed record UpdateProfileRequest(string? Name, string? Gender, string? public sealed record UpdateArchiveRequest( string? Diagnosis, string? SurgeryType, string? SurgeryDate, List? Allergies, List? DietRestrictions, - List? ChronicDiseases, string? FamilyHistory); + List? ChronicDiseases, string? FamilyHistory, + List? Surgeries); + +public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date); diff --git a/backend/tests/Health.Tests/application_service_tests.cs b/backend/tests/Health.Tests/application_service_tests.cs index 59f798e..6c802fd 100644 --- a/backend/tests/Health.Tests/application_service_tests.cs +++ b/backend/tests/Health.Tests/application_service_tests.cs @@ -1,6 +1,7 @@ using Health.Application.AI; using Health.Application.Calendars; using Health.Application.HealthArchives; +using Health.Application.HealthRecords; using Health.Application.Exercises; using Health.Domain.Entities; using Health.Domain.Enums; @@ -34,6 +35,67 @@ public sealed class ApplicationServiceTests Assert.Equal(["青霉素"], repository.Archive.Allergies); } + [Fact] + public async Task HealthArchive_ManualUpdateStoresMultipleStructuredSurgeries() + { + var userId = Guid.NewGuid(); + var repository = new FakeHealthArchiveRepository(new HealthArchive + { + Id = Guid.NewGuid(), UserId = userId, + }); + var service = new HealthArchiveService(repository); + + var result = await service.UpdateAsync( + userId, + new HealthArchiveUpdateRequest( + null, null, null, null, null, null, null, + [ + new HealthArchiveSurgeryInput("PCI", new DateOnly(2025, 3, 1)), + new HealthArchiveSurgeryInput("Appendectomy", new DateOnly(2010, 6, 2)), + ]), + CancellationToken.None); + + Assert.Equal(2, result.Surgeries.Count); + Assert.Equal("PCI", result.SurgeryType); + Assert.Equal(new DateOnly(2025, 3, 1), result.SurgeryDate); + Assert.Equal([0, 1], repository.Archive!.Surgeries.OrderBy(s => s.SortOrder).Select(s => s.SortOrder)); + } + + [Fact] + public async Task HealthTrend_365DaysUsesFullYearWindow() + { + var repository = new CapturingHealthRecordRepository(); + var service = new HealthRecordService(repository); + var before = DateTime.UtcNow; + + await service.GetTrendAsync(Guid.NewGuid(), HealthMetricType.Weight, 365, CancellationToken.None); + + Assert.NotNull(repository.RecordedAfter); + Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01); + } + + [Fact] + public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery() + { + var userId = Guid.NewGuid(); + var repository = new FakeHealthArchiveRepository(new HealthArchive + { + Id = Guid.NewGuid(), UserId = userId, + SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2), + }); + var service = new HealthArchiveService(repository); + + await service.ExecuteAiActionAsync( + userId, + "update_surgery", + new HealthArchiveUpdateRequest(null, "New surgery", new DateOnly(2026, 6, 20), null, null, null, null), + CancellationToken.None); + + Assert.Equal(2, repository.Archive!.Surgeries.Count); + Assert.Contains(repository.Archive.Surgeries, s => s.Type == "Legacy surgery"); + Assert.Contains(repository.Archive.Surgeries, s => s.Type == "New surgery"); + } + [Fact] public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation() { @@ -113,6 +175,23 @@ public sealed class ApplicationServiceTests public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; } + private sealed class CapturingHealthRecordRepository : IHealthRecordRepository + { + public DateTime? RecordedAfter { get; private set; } + public Task> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) => + Task.FromResult>([]); + public Task GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult(null); + public Task GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct) => Task.FromResult(null); + public Task> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct) + { + RecordedAfter = recordedAfter; + return Task.FromResult>([]); + } + public Task AddAsync(HealthRecord record, CancellationToken ct) => Task.CompletedTask; + public void Delete(HealthRecord record) { } + public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; + } + private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository { private readonly Conversation _conversation = conversation; diff --git a/backend/tests/Health.Tests/persistence_pipeline_tests.cs b/backend/tests/Health.Tests/persistence_pipeline_tests.cs index 7871e55..2852378 100644 --- a/backend/tests/Health.Tests/persistence_pipeline_tests.cs +++ b/backend/tests/Health.Tests/persistence_pipeline_tests.cs @@ -1,6 +1,11 @@ +using Health.Application.Medications; +using Health.Domain.Entities; +using Health.Domain.Enums; using Health.Infrastructure.AI; using Health.Infrastructure.Data; using Health.Infrastructure.Data.Records; +using Health.Infrastructure.Medications; +using Health.Infrastructure.Notifications; using Health.Infrastructure.Reports; using Microsoft.EntityFrameworkCore; @@ -57,6 +62,112 @@ public sealed class PersistencePipelineTests Assert.Equal(2000, task.LastError!.Length); } + [Fact] + public async Task ReportTask_OnlyEnqueuesFailureNotificationAtFinalAttempt() + { + await using var db = CreateDbContext(); + var userId = Guid.NewGuid(); + var report = new Report + { + Id = Guid.NewGuid(), + UserId = userId, + FileUrl = "report.jpg", + Status = ReportStatus.Analyzing, + }; + var task = NewReportTask(attempts: 2); + task.ReportId = report.Id; + db.Reports.Add(report); + db.ReportAnalysisTasks.Add(task); + await db.SaveChangesAsync(); + var queue = new EfReportAnalysisQueue(db, new EfUserNotificationProducer(db)); + + await queue.RetryAsync(task.Id, "temporary failure", CancellationToken.None); + Assert.Empty(db.NotificationOutbox); + + task.Status = "Processing"; + task.Attempts = 3; + await db.SaveChangesAsync(); + await queue.RetryAsync(task.Id, "final failure", CancellationToken.None); + + var notification = Assert.Single(db.NotificationOutbox); + Assert.Equal(task.Id, notification.SourceTaskId); + Assert.Equal("ReportAnalysisFailed", notification.Type); + Assert.Equal(userId, notification.UserId); + } + + [Fact] + public async Task MedicationReminder_MorningDoseDoesNotSuppressEveningDose() + { + await using var db = CreateDbContext(); + var userId = Guid.NewGuid(); + var medicationId = Guid.NewGuid(); + var scanTimeUtc = new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc); + db.Medications.Add(new Medication + { + Id = medicationId, + UserId = userId, + Name = "测试药物", + Frequency = MedicationFrequency.TwiceDaily, + TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)], + StartDate = new DateOnly(2026, 6, 20), + IsActive = true, + }); + db.MedicationLogs.Add(new MedicationLog + { + Id = Guid.NewGuid(), + UserId = userId, + MedicationId = medicationId, + Status = MedicationLogStatus.Taken, + ScheduledTime = new TimeOnly(8, 0), + ConfirmedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc), + CreatedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc), + }); + await db.SaveChangesAsync(); + var scanner = new MedicationReminderScanner(new EfMedicationRepository(db)); + + var tasks = await scanner.ScanAsync(scanTimeUtc, CancellationToken.None); + + var reminder = Assert.Single(tasks); + Assert.Equal(new TimeOnly(20, 0), reminder.ScheduledTime); + } + + [Fact] + public async Task MedicationReminder_ConfirmedDoseIsNotRemindedAgain() + { + await using var db = CreateDbContext(); + var userId = Guid.NewGuid(); + var medicationId = Guid.NewGuid(); + var confirmedAt = new DateTime(2026, 6, 20, 12, 0, 0, DateTimeKind.Utc); + db.Medications.Add(new Medication + { + Id = medicationId, + UserId = userId, + Name = "测试药物", + Frequency = MedicationFrequency.Daily, + TimeOfDay = [new TimeOnly(20, 0)], + StartDate = new DateOnly(2026, 6, 20), + IsActive = true, + }); + db.MedicationLogs.Add(new MedicationLog + { + Id = Guid.NewGuid(), + UserId = userId, + MedicationId = medicationId, + Status = MedicationLogStatus.Taken, + ScheduledTime = new TimeOnly(20, 0), + ConfirmedAt = confirmedAt, + CreatedAt = confirmedAt, + }); + await db.SaveChangesAsync(); + var scanner = new MedicationReminderScanner(new EfMedicationRepository(db)); + + var tasks = await scanner.ScanAsync( + new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc), + CancellationToken.None); + + Assert.Empty(tasks); + } + private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new() { Id = Guid.NewGuid(), diff --git a/docs/project_deep_review.md b/docs/project_deep_review.md index 3c31a87..52224ce 100644 --- a/docs/project_deep_review.md +++ b/docs/project_deep_review.md @@ -113,6 +113,8 @@ AI 问诊、饮食分析、报告解读是项目亮点,但要注意: ### 5.1 API 配置不适合真实移动端 +> 2026-06-20 更新:已支持 `--dart-define=API_BASE_URL=...`,并保留 `http://localhost:5000` 作为 USB `adb reverse` 本地开发默认值。本节的环境配置问题已处理。 + `health_app/lib/core/api_client.dart` 中 `baseUrl` 默认是 `http://localhost:5000`。这在 Android/iOS 真机上通常不可用,也不适合测试/生产环境切换。 建议: @@ -174,6 +176,8 @@ AI 问诊、饮食分析、报告解读是项目亮点,但要注意: ### 5.6 错误处理过于安静 +> 2026-06-20 更新:`ApiClient` 已统一识别 `{ code, message }` 业务错误及 HTTP/Dio 网络错误,并转换为可直接展示的 `ApiException`。后端历史 endpoint 的 HTTP 状态码仍需按模块逐步规范,避免一次性破坏现有前端协议。 + 一些 provider 会 catch 异常后返回 fallback 数据,例如医生列表、运动计划。这个方式能让页面不崩,但会掩盖真实后端错误。 建议: @@ -481,6 +485,8 @@ static IQueryable ScopePatientsToDoctor(AppDbContext db, Guid doctorId) => ### 11.7 前端状态和生命周期问题 +> 2026-06-20 更新:健康最新值、用药列表、用药提醒、当前运动计划已改为 `autoDispose`;AI 确认写入后会同时刷新这些核心数据。报告和饮食使用各自 Notifier/页面加载流程,尚未强行合并成一个全局缓存。 + 前端现在能跑起来,但长期运行会有状态残留风险: - `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`。 diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 8a5fac3..b0729f8 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -3,8 +3,21 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'local_database.dart'; -/// API 基础地址 -const String baseUrl = 'http://localhost:5000'; // adb reverse 或同WiFi直连时改为PC的IP +/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 +const String baseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://localhost:5000', +); + +class ApiException implements Exception { + final int? code; + final String message; + + const ApiException(this.message, {this.code}); + + @override + String toString() => message; +} /// Dio HTTP 客户端封装——带 token 注入、401 自动刷新 class ApiClient { @@ -40,22 +53,22 @@ class ApiClient { /// 带 token 的 GET 请求 Future get(String path, {Map? queryParameters}) async { - return _dio.get(path, queryParameters: queryParameters); + return _request(_dio.get(path, queryParameters: queryParameters)); } /// 带 token 的 POST 请求 Future post(String path, {dynamic data}) async { - return _dio.post(path, data: data); + return _request(_dio.post(path, data: data)); } /// 带 token 的 PUT 请求 Future put(String path, {dynamic data}) async { - return _dio.put(path, data: data); + return _request(_dio.put(path, data: data)); } /// 带 token 的 DELETE 请求 Future delete(String path) async { - return _dio.delete(path); + return _request(_dio.delete(path)); } /// 上传文件(multipart),返回文件 URL @@ -63,7 +76,7 @@ class ApiClient { final formData = FormData.fromMap({ fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last), }); - final res = await _dio.post(path, data: formData); + final res = await _request(_dio.post(path, data: formData)); final data = res.data; if (data is Map) { final topUrl = data['url']?.toString(); @@ -82,6 +95,46 @@ class ApiClient { } return null; } + + Response _ensureBusinessSuccess(Response response) { + final body = response.data; + if (body is Map && body.containsKey('code')) { + final rawCode = body['code']; + final code = rawCode is int ? rawCode : int.tryParse('$rawCode'); + if (code != null && code != 0) { + throw ApiException( + body['message']?.toString().trim().isNotEmpty == true + ? body['message'].toString() + : '请求失败', + code: code, + ); + } + } + return response; + } + + Future _request(Future request) async { + try { + return _ensureBusinessSuccess(await request); + } on DioException catch (error) { + final body = error.response?.data; + final message = body is Map && body['message']?.toString().trim().isNotEmpty == true + ? body['message'].toString() + : error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout + ? '请求超时,请稍后重试' + : error.type == DioExceptionType.connectionError + ? '无法连接服务器,请检查网络或后端地址' + : '请求失败,请稍后重试'; + final rawCode = body is Map ? body['code'] : null; + throw ApiException( + message, + code: rawCode is int + ? rawCode + : int.tryParse('$rawCode') ?? error.response?.statusCode, + ); + } + } } /// 认证拦截器:自动注入 token + 401 刷新 diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index ab4678d..688bd50 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -12,6 +12,7 @@ import '../pages/report/ai_analysis_page.dart'; import '../pages/consultation/consultation_pages.dart'; import '../pages/settings/settings_pages.dart'; import '../pages/settings/notification_prefs_page.dart'; +import '../pages/notifications/notification_center_page.dart'; import '../pages/profile/profile_page.dart'; import '../pages/diet/diet_capture_page.dart'; import '../pages/device/device_scan_page.dart'; @@ -97,6 +98,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { return const SettingsPage(); case 'notificationPrefs': return const NotificationPrefsPage(); + case 'notifications': + return const NotificationCenterPage(); case 'staticText': return StaticTextPage(type: params['type']!); case 'dietDetail': 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 88d3359..566c743 100644 --- a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart @@ -121,7 +121,17 @@ class DoctorPatientDetailPage extends ConsumerWidget { const SizedBox(height: 8), if (archive['diagnosis'] != null) _row('诊断', archive['diagnosis']), - if (archive['surgeryType'] != null) + if (archive['surgeries'] is List && + (archive['surgeries'] as List).isNotEmpty) + _row( + '手术史', + (archive['surgeries'] as List).map((item) { + final surgery = item as Map; + final date = surgery['date']?.toString() ?? ''; + return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}'; + }).join(';'), + ) + else if (archive['surgeryType'] != null) _row( '手术史', '${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}', diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 79b4b01..4846bc6 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -12,7 +12,6 @@ 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'; @@ -29,15 +28,16 @@ class _HomePageState extends ConsumerState { String? _pickedImagePath; int _lastMsgCount = 0; Timer? _notificationTimer; - bool _checkingNotifications = false; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _checkNotifications()); + WidgetsBinding.instance.addPostFrameCallback( + (_) => ref.invalidate(notificationUnreadCountProvider), + ); _notificationTimer = Timer.periodic( const Duration(seconds: 30), - (_) => _checkNotifications(), + (_) => ref.invalidate(notificationUnreadCountProvider), ); } @@ -50,52 +50,6 @@ class _HomePageState extends ConsumerState { 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; @@ -169,6 +123,7 @@ class _HomePageState extends ConsumerState { final name = (user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'; + final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0; return Container( padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), decoration: BoxDecoration( @@ -221,7 +176,8 @@ class _HomePageState extends ConsumerState { ), _HeaderIconButton( icon: LucideIcons.bell, - onTap: () => pushRoute(ref, 'notificationPrefs'), + badgeCount: unreadCount, + onTap: () => pushRoute(ref, 'notifications'), ), ], ), @@ -565,22 +521,55 @@ class _HomePageState extends ConsumerState { class _HeaderIconButton extends StatelessWidget { final IconData icon; final VoidCallback onTap; - const _HeaderIconButton({required this.icon, required this.onTap}); + final int badgeCount; + const _HeaderIconButton({ + required this.icon, + required this.onTap, + this.badgeCount = 0, + }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, - child: Container( - width: 38, - height: 38, - decoration: BoxDecoration( - gradient: AppColors.surfaceGradient, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: Icon(icon, size: 20, color: AppColors.textPrimary), + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + gradient: AppColors.surfaceGradient, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.borderLight), + boxShadow: AppColors.cardShadowLight, + ), + child: Icon(icon, size: 20, color: AppColors.textPrimary), + ), + if (badgeCount > 0) + Positioned( + right: -5, + top: -5, + child: Container( + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: AppColors.error, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white, width: 2), + ), + alignment: Alignment.center, + child: Text( + badgeCount > 99 ? '99+' : '$badgeCount', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], ), ); } 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 26f826f..73324f9 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -1464,13 +1464,13 @@ class ChatMessagesView extends ConsumerWidget { ? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}' : null; final hr = healthData['HeartRate']; - final hrText = hr is int ? '$hr' : null; - final bs = healthData['BloodSugar']; - final bsText = bs is num ? '$bs' : null; - final bo = healthData['BloodOxygen']; - final boText = bo is num ? '$bo' : null; + final hrText = hr is Map && hr['value'] is num ? '${hr['value']}' : null; + final bs = healthData['Glucose']; + final bsText = bs is Map && bs['value'] is num ? '${bs['value']}' : null; + final bo = healthData['SpO2']; + final boText = bo is Map && bo['value'] is num ? '${bo['value']}' : null; final wt = healthData['Weight']; - final wtText = wt is num ? '$wt' : null; + final wtText = wt is Map && wt['value'] is num ? '${wt['value']}' : null; final allNull = bpText == null && hrText == null && diff --git a/health_app/lib/pages/notifications/notification_center_page.dart b/health_app/lib/pages/notifications/notification_center_page.dart new file mode 100644 index 0000000..e4fd9f7 --- /dev/null +++ b/health_app/lib/pages/notifications/notification_center_page.dart @@ -0,0 +1,593 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../core/app_colors.dart'; +import '../../core/app_theme.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/data_providers.dart'; +import '../../services/in_app_notification_service.dart'; + +class NotificationCenterPage extends ConsumerStatefulWidget { + const NotificationCenterPage({super.key}); + + @override + ConsumerState createState() => + _NotificationCenterPageState(); +} + +class _NotificationCenterPageState + extends ConsumerState { + InAppNotificationHistory? _history; + bool _loading = true; + bool _markingAll = false; + String? _error; + + InAppNotificationService get _service => + ref.read(inAppNotificationServiceProvider); + + @override + void initState() { + super.initState(); + Future.microtask(_load); + } + + Future _load() async { + if (mounted) { + setState(() { + _loading = true; + _error = null; + }); + } + try { + final history = await _service.getHistory(); + if (!mounted) return; + setState(() { + _history = history; + _loading = false; + }); + ref.invalidate(notificationUnreadCountProvider); + } catch (error) { + if (mounted) { + setState(() { + _error = '$error'; + _loading = false; + }); + } + } + } + + Future _open(InAppNotification item) async { + if (!item.isRead) { + await _service.acknowledge(item.id); + if (!mounted) return; + setState(() { + final current = _history; + if (current == null) return; + _history = InAppNotificationHistory( + unreadCount: (current.unreadCount - 1).clamp(0, current.unreadCount), + items: current.items + .map( + (value) => + value.id == item.id ? value.copyWith(isRead: true) : value, + ) + .toList(), + ); + }); + ref.invalidate(notificationUnreadCountProvider); + } + + if (!mounted || item.actionType == null) return; + switch (item.actionType) { + case 'medication': + pushRoute(ref, 'medCheckIn', params: {'id': item.actionTargetId ?? ''}); + return; + case 'exercise': + pushRoute(ref, 'exercisePlan'); + return; + case 'health': + pushRoute(ref, 'trend', params: {'type': item.actionTargetId ?? ''}); + return; + case 'report': + final reportId = item.actionTargetId; + if (reportId != null && reportId.isNotEmpty) { + pushRoute(ref, 'aiAnalysis', params: {'id': reportId}); + } + return; + } + } + + Future _markAllRead() async { + if (_markingAll || (_history?.unreadCount ?? 0) == 0) return; + setState(() => _markingAll = true); + try { + await _service.markAllRead(); + if (!mounted) return; + final current = _history; + if (current != null) { + setState( + () => _history = InAppNotificationHistory( + unreadCount: 0, + items: current.items + .map((item) => item.copyWith(isRead: true)) + .toList(), + ), + ); + } + ref.invalidate(notificationUnreadCountProvider); + } finally { + if (mounted) setState(() => _markingAll = false); + } + } + + Future _delete(InAppNotification item) async { + try { + await _service.delete(item.id); + if (!mounted) return; + final current = _history; + if (current != null) { + setState( + () => _history = InAppNotificationHistory( + unreadCount: item.isRead + ? current.unreadCount + : (current.unreadCount - 1).clamp(0, current.unreadCount), + items: current.items.where((value) => value.id != item.id).toList(), + ), + ); + } + ref.invalidate(notificationUnreadCountProvider); + } catch (_) { + if (mounted) await _load(); + } + } + + @override + Widget build(BuildContext context) { + final items = _history?.items ?? const []; + return Scaffold( + body: AppBackground( + safeArea: true, + child: Column( + children: [ + _Header( + unreadCount: _history?.unreadCount ?? 0, + markingAll: _markingAll, + onBack: () => popRoute(ref), + onMarkAllRead: _markAllRead, + ), + Expanded( + child: RefreshIndicator( + onRefresh: _load, + color: const Color(0xFF438CE1), + child: _buildBody(items), + ), + ), + ], + ), + ), + ); + } + + Widget _buildBody(List items) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return _MessageState( + icon: LucideIcons.wifiOff, + title: '通知加载失败', + message: '请检查网络连接后重试', + buttonText: '重新加载', + onPressed: _load, + ); + } + if (items.isEmpty) { + return const _MessageState( + icon: LucideIcons.bellOff, + title: '暂时没有通知', + message: '用药、运动、健康指标和报告消息会显示在这里', + ); + } + + final today = []; + final earlier = []; + final now = DateTime.now(); + for (final item in items) { + final local = item.createdAt.toLocal(); + final isToday = + local.year == now.year && + local.month == now.month && + local.day == now.day; + (isToday ? today : earlier).add(item); + } + + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 6, 16, 32), + children: [ + if (today.isNotEmpty) ...[ + const _SectionTitle('今天'), + ...today.map(_buildDismissible), + ], + if (earlier.isNotEmpty) ...[ + const SizedBox(height: 10), + const _SectionTitle('更早'), + ...earlier.map(_buildDismissible), + ], + ], + ); + } + + Widget _buildDismissible(InAppNotification item) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Dismissible( + key: ValueKey(item.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + decoration: BoxDecoration( + color: AppColors.error, + borderRadius: BorderRadius.circular(18), + ), + child: const Icon(LucideIcons.trash2, color: Colors.white), + ), + onDismissed: (_) => _delete(item), + child: _NotificationCard(item: item, onTap: () => _open(item)), + ), + ); +} + +class _Header extends StatelessWidget { + final int unreadCount; + final bool markingAll; + final VoidCallback onBack; + final VoidCallback onMarkAllRead; + + const _Header({ + required this.unreadCount, + required this.markingAll, + required this.onBack, + required this.onMarkAllRead, + }); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 12, 10), + child: Row( + children: [ + IconButton( + tooltip: '返回', + onPressed: onBack, + icon: const Icon(LucideIcons.chevronLeft), + ), + const SizedBox(width: 2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '通知中心', + style: TextStyle( + fontSize: 21, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + unreadCount == 0 ? '所有消息均已读' : '$unreadCount 条未读消息', + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (unreadCount > 0) + TextButton.icon( + onPressed: markingAll ? null : onMarkAllRead, + icon: markingAll + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(LucideIcons.checkCheck, size: 17), + label: const Text('全部已读'), + ), + ], + ), + ); +} + +class _SectionTitle extends StatelessWidget { + final String text; + const _SectionTitle(this.text); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(4, 6, 4, 10), + child: Text( + text, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ); +} + +class _NotificationCard extends StatelessWidget { + final InAppNotification item; + final VoidCallback onTap; + + const _NotificationCard({required this.item, required this.onTap}); + + @override + Widget build(BuildContext context) { + final visual = _NotificationVisual.of(item); + return Material( + color: item.isRead + ? Colors.white.withValues(alpha: 0.72) + : Colors.white.withValues(alpha: 0.96), + borderRadius: BorderRadius.circular(18), + elevation: item.isRead ? 0 : 1, + shadowColor: visual.color.withValues(alpha: 0.12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: item.isRead + ? const Color(0xFFE9EEF5) + : visual.color.withValues(alpha: 0.18), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [visual.lightColor, Colors.white], + ), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(visual.icon, size: 22, color: visual.color), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + item.title, + style: TextStyle( + fontSize: 15, + fontWeight: item.isRead + ? FontWeight.w600 + : FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ), + if (!item.isRead) + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: visual.color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: visual.color.withValues(alpha: 0.3), + blurRadius: 5, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 5), + Text( + item.message, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 9), + Row( + children: [ + Text( + _formatTime(item.createdAt), + style: const TextStyle( + fontSize: 11, + color: AppColors.textHint, + ), + ), + const Spacer(), + if (item.actionType != null) + Row( + children: [ + Text( + '查看详情', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: visual.color, + ), + ), + const SizedBox(width: 2), + Icon( + LucideIcons.chevronRight, + size: 14, + color: visual.color, + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + static String _formatTime(DateTime value) { + final local = value.toLocal(); + final now = DateTime.now(); + final time = + '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; + if (local.year == now.year && + local.month == now.month && + local.day == now.day) { + return time; + } + if (local.year == now.year) return '${local.month}月${local.day}日 $time'; + return '${local.year}年${local.month}月${local.day}日 $time'; + } +} + +class _NotificationVisual { + final IconData icon; + final Color color; + final Color lightColor; + + const _NotificationVisual(this.icon, this.color, this.lightColor); + + factory _NotificationVisual.of(InAppNotification item) { + switch (item.actionType) { + case 'exercise': + return const _NotificationVisual( + LucideIcons.activity, + Color(0xFF2C9A70), + Color(0xFFDDF7EC), + ); + case 'health': + if (item.severity == 'critical') { + return const _NotificationVisual( + LucideIcons.triangleAlert, + Color(0xFFE55353), + Color(0xFFFFE7E7), + ); + } + return const _NotificationVisual( + LucideIcons.heartPulse, + Color(0xFFE8863A), + Color(0xFFFFEEDC), + ); + case 'report': + if (item.severity == 'error') { + return const _NotificationVisual( + LucideIcons.fileWarning, + Color(0xFFE55353), + Color(0xFFFFE7E7), + ); + } + return const _NotificationVisual( + LucideIcons.fileCheck2, + Color(0xFF7B68CE), + Color(0xFFEDE9FF), + ); + default: + return const _NotificationVisual( + LucideIcons.pill, + Color(0xFF468AD8), + Color(0xFFE4F0FF), + ); + } + } +} + +class _MessageState extends StatelessWidget { + final IconData icon; + final String title; + final String message; + final String? buttonText; + final VoidCallback? onPressed; + + const _MessageState({ + required this.icon, + required this.title, + required this.message, + this.buttonText, + this.onPressed, + }); + + @override + Widget build(BuildContext context) => ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(28, 120, 28, 32), + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 34), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.white.withValues(alpha: 0.95), + const Color(0xFFF1F7FF).withValues(alpha: 0.9), + ], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFFE5EDF7)), + ), + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFFE5F0FF), + borderRadius: BorderRadius.circular(20), + ), + child: Icon(icon, size: 29, color: const Color(0xFF5D91CF)), + ), + const SizedBox(height: 18), + Text( + title, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 7), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, + height: 1.5, + color: AppColors.textSecondary, + ), + ), + if (buttonText != null) ...[ + const SizedBox(height: 20), + FilledButton(onPressed: onPressed, child: Text(buttonText!)), + ], + ], + ), + ), + ], + ); +} diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index bed44e4..ca2ae17 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -85,6 +85,8 @@ class AuthNotifier extends Notifier { } } + Future refreshProfile() => _loadProfile(); + /// 发送验证码 Future<({String? error, String? devCode})> sendSms(String phone) async { try { diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 845c84e..2d46fb9 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -119,7 +119,9 @@ class ChatNotifier extends Notifier { msgs[i].confirmed = true; state = state.copyWith(messages: msgs); ref.invalidate(medicationListProvider); + ref.invalidate(medicationReminderProvider); ref.invalidate(latestHealthProvider); + ref.invalidate(currentExercisePlanProvider); return null; } diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index 5d4b434..8050851 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'auth_provider.dart'; import '../services/health_service.dart'; import '../services/admin_service.dart'; +import '../services/in_app_notification_service.dart'; final exerciseServiceProvider = Provider((ref) { return ExerciseService(ref.watch(apiClientProvider)); @@ -36,8 +37,25 @@ final adminServiceProvider = Provider((ref) { return AdminService(ref.watch(apiClientProvider)); }); +final inAppNotificationServiceProvider = Provider(( + ref, +) { + return InAppNotificationService(ref.watch(apiClientProvider)); +}); + +final notificationUnreadCountProvider = FutureProvider.autoDispose(( + ref, +) async { + final history = await ref + .watch(inAppNotificationServiceProvider) + .getHistory(); + return history.unreadCount; +}); + /// 最新健康数据 Provider -final latestHealthProvider = FutureProvider>((ref) async { +final latestHealthProvider = FutureProvider.autoDispose>(( + ref, +) async { final service = ref.watch(healthServiceProvider); return service.getLatest(); }); @@ -49,48 +67,61 @@ void refreshHealthData(WidgetRef ref) { } /// 用药列表 Provider -final medicationListProvider = FutureProvider>>((ref) async { - final service = ref.watch(medicationServiceProvider); - return service.getList(); -}); +final medicationListProvider = + FutureProvider.autoDispose>>((ref) async { + final service = ref.watch(medicationServiceProvider); + return service.getList(); + }); -final medicationReminderProvider = FutureProvider>>((ref) async { - final service = ref.watch(medicationServiceProvider); - return service.getReminders(); -}); +final medicationReminderProvider = + FutureProvider.autoDispose>>((ref) async { + final service = ref.watch(medicationServiceProvider); + return service.getReminders(); + }); /// 医生列表 Provider -final doctorListProvider = FutureProvider>>((ref) async { +final doctorListProvider = FutureProvider>>(( + ref, +) async { final service = ref.watch(consultationServiceProvider); return service.getDoctors().timeout(const Duration(seconds: 8)); }); /// 问诊配额 Provider -final consultationQuotaProvider = FutureProvider>((ref) async { +final consultationQuotaProvider = FutureProvider>(( + ref, +) async { final service = ref.watch(consultationServiceProvider); return service.getQuota(); }); /// 当前运动计划 Provider -final currentExercisePlanProvider = FutureProvider?>((ref) async { - final service = ref.watch(exerciseServiceProvider); - return service.getCurrentPlan().timeout(const Duration(seconds: 8)); -}); +final currentExercisePlanProvider = + FutureProvider.autoDispose?>((ref) async { + final service = ref.watch(exerciseServiceProvider); + return service.getCurrentPlan().timeout(const Duration(seconds: 8)); + }); /// 拍照/相册直接触发(无需跳转页面) -final cameraActionProvider = NotifierProvider(CameraActionNotifier.new); +final cameraActionProvider = NotifierProvider( + CameraActionNotifier.new, +); class CameraActionNotifier extends Notifier { - @override String? build() => null; + @override + String? build() => null; void trigger(String action) => state = action; void clear() => state = null; } /// 拍饮食动作触发(不用跳多余页面) -final dietActionProvider = NotifierProvider(DietActionNotifier.new); +final dietActionProvider = NotifierProvider( + DietActionNotifier.new, +); class DietActionNotifier extends Notifier { - @override String? build() => null; + @override + String? build() => null; void trigger(String action) => state = action; void clear() => state = null; } diff --git a/health_app/lib/services/in_app_notification_service.dart b/health_app/lib/services/in_app_notification_service.dart index ea8eaee..0210435 100644 --- a/health_app/lib/services/in_app_notification_service.dart +++ b/health_app/lib/services/in_app_notification_service.dart @@ -5,12 +5,22 @@ class InAppNotification { final String type; final String title; final String message; + final String severity; + final String? actionType; + final String? actionTargetId; + final bool isRead; + final DateTime createdAt; const InAppNotification({ required this.id, required this.type, required this.title, required this.message, + required this.severity, + this.actionType, + this.actionTargetId, + required this.isRead, + required this.createdAt, }); factory InAppNotification.fromJson(Map json) => @@ -19,7 +29,36 @@ class InAppNotification { type: json['type']?.toString() ?? '', title: json['title']?.toString() ?? '健康提醒', message: json['message']?.toString() ?? '', + severity: json['severity']?.toString() ?? 'info', + actionType: json['actionType']?.toString(), + actionTargetId: json['actionTargetId']?.toString(), + isRead: json['isRead'] == true, + createdAt: + DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? + DateTime.now(), ); + + InAppNotification copyWith({bool? isRead}) => InAppNotification( + id: id, + type: type, + title: title, + message: message, + severity: severity, + actionType: actionType, + actionTargetId: actionTargetId, + isRead: isRead ?? this.isRead, + createdAt: createdAt, + ); +} + +class InAppNotificationHistory { + final int unreadCount; + final List items; + + const InAppNotificationHistory({ + required this.unreadCount, + required this.items, + }); } class InAppNotificationService { @@ -32,7 +71,9 @@ class InAppNotificationService { final items = response.data['data'] as List? ?? const []; return items .whereType() - .map((item) => InAppNotification.fromJson(Map.from(item))) + .map( + (item) => InAppNotification.fromJson(Map.from(item)), + ) .where((item) => item.id.isNotEmpty) .toList(); } @@ -40,4 +81,29 @@ class InAppNotificationService { Future acknowledge(String id) async { await _api.post('/api/notifications/$id/acknowledge'); } + + Future markAllRead() async { + await _api.post('/api/notifications/read-all'); + } + + Future getHistory() async { + final response = await _api.get('/api/notifications'); + final data = response.data['data'] as Map? ?? const {}; + final rawItems = data['items'] as List? ?? const []; + return InAppNotificationHistory( + unreadCount: data['unreadCount'] as int? ?? 0, + items: rawItems + .whereType() + .map( + (item) => + InAppNotification.fromJson(Map.from(item)), + ) + .where((item) => item.id.isNotEmpty) + .toList(), + ); + } + + Future delete(String id) async { + await _api.delete('/api/notifications/$id'); + } }