Files
AI-Health/backend/src/Health.Infrastructure/Notifications/EfReminderCatchUpService.cs
MingNian fade61ac21 feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
2026-07-15 23:22:52 +08:00

285 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ReminderCatchUpResult> 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<NotificationPreference> 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<int> 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<int> 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<int> 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<int> 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<int> 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);
}
}