## 后端 - 健康档案: 新增手术状态字段 + 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 目录
285 lines
11 KiB
C#
285 lines
11 KiB
C#
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);
|
||
}
|
||
}
|