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 目录
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user