using Health.Application.Notifications; using Health.Domain.Entities; using Health.Domain.Enums; namespace Health.Infrastructure.Notifications; public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService { private readonly AppDbContext _db = db; public async Task CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct) { var pref = await GetPreferenceAsync(userId, ct); if (!pref.PushEnabled) return new ReminderCatchUpResult(0); var beijingNow = utcNow.AddHours(8); var today = DateOnly.FromDateTime(beijingNow); var todayStartUtc = beijingNow.Date.AddHours(-8); var todayEndUtc = todayStartUtc.AddDays(1); var created = 0; created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct); created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct); created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct); created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct); if (created > 0) await _db.SaveChangesAsync(ct); return new ReminderCatchUpResult(created); } private async Task GetPreferenceAsync(Guid userId, CancellationToken ct) { var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct); if (pref != null) return pref; pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId }; await _db.NotificationPreferences.AddAsync(pref, ct); return pref; } private async Task CreateHealthRecordReminderAsync( Guid userId, NotificationPreference pref, DateTime beijingNow, DateTime todayStartUtc, DateTime todayEndUtc, CancellationToken ct) { if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0; var window = HealthRecordWindow(beijingNow); if (window == null) return 0; var recordedTypes = await _db.HealthRecords.AsNoTracking() .Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc) .Select(r => r.MetricType) .Distinct() .ToListAsync(ct); var missing = EnabledHealthMetrics(pref) .Where(x => !recordedTypes.Contains(x.Type)) .ToList(); if (missing.Count == 0) return 0; var date = DateOnly.FromDateTime(beijingNow); var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}"); return await AddNotificationIfMissingAsync( userId, sourceId, "health_record_reminder", window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标", $"建议录入:{string.Join("、", missing.Select(x => x.Label))}", "info", "health", null, ct); } private async Task CreateMedicationRemindersAsync( Guid userId, NotificationPreference pref, DateTime beijingNow, DateOnly today, DateTime todayStartUtc, DateTime todayEndUtc, CancellationToken ct) { if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0; var now = TimeOnly.FromDateTime(beijingNow); var earliest = now.AddHours(-4); var medications = await _db.Medications.AsNoTracking() .Where(m => m.UserId == userId && m.IsActive) .ToListAsync(ct); var created = 0; foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today))) { foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now))) { var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l => l.UserId == userId && l.MedicationId == medication.Id && l.ScheduledTime == scheduledTime && l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc, ct); if (logged) continue; var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}"); var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") } .Where(x => !string.IsNullOrWhiteSpace(x))); created += await AddNotificationIfMissingAsync( userId, sourceId, "MedicationReminder", "用药时间到了", $"请按计划服用{medication.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。", "info", "medication", medication.Id.ToString(), ct); } } return created; } private async Task CreateExerciseRemindersAsync( Guid userId, NotificationPreference pref, DateTime beijingNow, DateOnly today, CancellationToken ct) { if (IsInDnd(pref, beijingNow)) return 0; var now = TimeOnly.FromDateTime(beijingNow); var dueItems = await _db.ExercisePlanItems.AsNoTracking() .Include(x => x.Plan) .Where(x => x.Plan.UserId == userId && x.ScheduledDate == today && !x.IsCompleted && !x.IsRestDay && x.Plan.ReminderTime <= now) .ToListAsync(ct); var created = 0; foreach (var item in dueItems) { var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}"); created += await AddNotificationIfMissingAsync( userId, sourceId, "ExerciseReminder", "今日运动计划", $"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。", "info", "exercise", item.PlanId.ToString(), ct); } return created; } private async Task CreateFollowUpRemindersAsync( Guid userId, NotificationPreference pref, DateTime beijingNow, CancellationToken ct) { if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0; var nowUtc = beijingNow.AddHours(-8); var upcomingEndUtc = nowUtc.AddHours(24); var followUps = await _db.FollowUps.AsNoTracking() .Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc) .ToListAsync(ct); var created = 0; foreach (var followUp in followUps) { var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h"); created += await AddNotificationIfMissingAsync( userId, sourceId, "follow_up_reminder", "复查提醒", $"{followUp.Title} 即将到期,请提前安排时间。", "info", "followup", followUp.Id.ToString(), ct); } return created; } private async Task AddNotificationIfMissingAsync( Guid userId, Guid sourceId, string type, string title, string message, string severity, string? actionType, string? actionTargetId, CancellationToken ct) { if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct)) return 0; await _db.UserNotifications.AddAsync(new UserNotification { Id = Guid.NewGuid(), UserId = userId, SourceId = sourceId, Type = type, Title = title, Message = message, Severity = severity, ActionType = actionType, ActionTargetId = actionTargetId, CreatedAt = DateTime.UtcNow, }, ct); return 1; } private static string? HealthRecordWindow(DateTime beijingNow) { var minutes = beijingNow.Hour * 60 + beijingNow.Minute; if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning"; if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon"; if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening"; return null; } private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref) { var result = new List<(HealthMetricType, string)>(); if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压")); if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率")); if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖")); if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧")); if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重")); return result; } private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow) { if (!pref.DndEnabled) return false; var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute; return pref.DndStartMinutes <= pref.DndEndMinutes ? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes : minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes; } private static bool IsMedicationActiveOn(Medication medication, DateOnly date) => (medication.StartDate == null || medication.StartDate <= date) && (medication.EndDate == null || medication.EndDate >= date); private static bool ShouldDoseToday(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; } private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) => end >= start ? value >= start && value <= end : value >= start || value <= end; private static Guid StableGuid(string key) { var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key)); return new Guid(bytes); } }