feat: 健康录入提醒 + 多指标异常 + 用药漏服 + UI 优化
后端 - NotificationPreference 增加健康录入提醒总开关和 5 子项(血压/心率/血糖/血氧/体重) - EF 迁移 AddHealthRecordReminder - 新增 GET/PUT /api/notification-prefs - 新增 HealthRecordReminderService 每天 9 点/12 点扫描未录入用户推送提醒 前端 - 通知偏好对接真实后端 API - 设置页新增「健康录入提醒」总开关+5 子项 - 通知中心右上角加齿轮跳设置页 - 今日健康卡片:4 项异常检测+多项合并;用药只显漏服并按时间窗口区分文案 - 侧边栏头像改圆形白底灰图标,去掉紫色品牌渐变 - 整体背景统一浅灰白 #F6F8FC,去掉浅紫渐变 - 设置页简化(去掉分组/彩色图标方块) - 健康档案保存按钮移到右上角,TextButton 全局默认黑色 - API baseUrl 跟随网络环境
This commit is contained in:
@@ -89,6 +89,13 @@ public sealed class NotificationPreference
|
||||
public bool FollowUpReminder { get; set; } = true;
|
||||
public bool DoctorReply { get; set; } = true;
|
||||
public bool AbnormalAlert { get; set; } = true;
|
||||
// 健康录入提醒——总开关 + 5 子项
|
||||
public bool HealthRecordReminder { get; set; } = true;
|
||||
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
||||
public bool HealthRecordReminderHeartRate { get; set; } = true;
|
||||
public bool HealthRecordReminderGlucose { get; set; } = true;
|
||||
public bool HealthRecordReminderSpO2 { get; set; } = true;
|
||||
public bool HealthRecordReminderWeight { get; set; } = true;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
1461
backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs
generated
Normal file
1461
backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHealthRecordReminder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminder",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderBloodPressure",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderGlucose",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderHeartRate",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderSpO2",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderWeight",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminder",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderBloodPressure",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderGlucose",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderHeartRate",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderSpO2",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderWeight",
|
||||
table: "NotificationPreferences");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -679,6 +679,24 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.Property<bool>("FollowUpReminder")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HealthRecordReminder")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HealthRecordReminderBloodPressure")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HealthRecordReminderGlucose")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HealthRecordReminderHeartRate")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HealthRecordReminderSpO2")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HealthRecordReminderWeight")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("MedicationReminder")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using Health.Application.Notifications;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
||||
/// 对未录入指标且开关开启的用户推送提醒。
|
||||
/// 中国时区 UTC+8。
|
||||
/// </summary>
|
||||
public sealed class HealthRecordReminderService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
||||
|
||||
// 北京时间 9:00 和 12:00 触发
|
||||
private static readonly int[] ReminderHoursCst = [9, 12];
|
||||
|
||||
private DateTime _lastFired = DateTime.MinValue;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("健康录入提醒服务已启动");
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||
// 当前小时是否在触发列表,且今天该小时还没触发过
|
||||
if (ReminderHoursCst.Contains(nowCst.Hour)
|
||||
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||
{
|
||||
await ScanAndPushAsync(nowCst, stoppingToken);
|
||||
_lastFired = nowCst;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
||||
|
||||
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
|
||||
// 取所有启用了健康录入提醒的用户偏好
|
||||
var prefs = await db.NotificationPreferences
|
||||
.Where(p => p.HealthRecordReminder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var pref in prefs)
|
||||
{
|
||||
// 该用户当天已录入的指标
|
||||
var recordedTypes = await db.HealthRecords
|
||||
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
|
||||
.Select(r => r.MetricType)
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 哪些指标启用开关但当天没录入
|
||||
var missing = new List<(HealthMetricType type, bool enabled, string label)>
|
||||
{
|
||||
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
|
||||
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
|
||||
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
|
||||
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
|
||||
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
|
||||
};
|
||||
|
||||
var missingLabels = missing
|
||||
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
|
||||
.Select(m => m.label)
|
||||
.ToList();
|
||||
|
||||
if (missingLabels.Count == 0) continue;
|
||||
|
||||
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
|
||||
var sourceId = MakeSourceId(pref.UserId, nowCst);
|
||||
var summary = string.Join("、", missingLabels);
|
||||
|
||||
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
|
||||
Type: "health_record_reminder",
|
||||
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
|
||||
Message: $"建议录入:{summary}",
|
||||
Severity: "info",
|
||||
ActionType: "health",
|
||||
ActionTargetId: null), ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
||||
{
|
||||
// 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复
|
||||
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
|
||||
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,77 @@ public static class NotificationEndpoints
|
||||
? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null })
|
||||
: Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" });
|
||||
});
|
||||
|
||||
// ── 通知偏好设置 ──
|
||||
var prefsGroup = app.MapGroup("/api/notification-prefs").RequireAuthorization();
|
||||
|
||||
prefsGroup.MapGet("", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||
var pref = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||
if (pref == null)
|
||||
{
|
||||
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||
db.NotificationPreferences.Add(pref);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null });
|
||||
});
|
||||
|
||||
prefsGroup.MapPut("", async (NotificationPrefsUpdateDto body, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||
var pref = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||
if (pref == null)
|
||||
{
|
||||
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||
db.NotificationPreferences.Add(pref);
|
||||
}
|
||||
if (body.MedicationReminder.HasValue) pref.MedicationReminder = body.MedicationReminder.Value;
|
||||
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
||||
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
||||
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
||||
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
||||
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
||||
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
||||
if (body.HealthRecordReminderGlucose.HasValue) pref.HealthRecordReminderGlucose = body.HealthRecordReminderGlucose.Value;
|
||||
if (body.HealthRecordReminderSpO2.HasValue) pref.HealthRecordReminderSpO2 = body.HealthRecordReminderSpO2.Value;
|
||||
if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value;
|
||||
pref.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
|
||||
private static object ToDto(NotificationPreference pref) => new
|
||||
{
|
||||
medicationReminder = pref.MedicationReminder,
|
||||
followUpReminder = pref.FollowUpReminder,
|
||||
doctorReply = pref.DoctorReply,
|
||||
abnormalAlert = pref.AbnormalAlert,
|
||||
healthRecordReminder = pref.HealthRecordReminder,
|
||||
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
||||
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
||||
healthRecordReminderGlucose = pref.HealthRecordReminderGlucose,
|
||||
healthRecordReminderSpO2 = pref.HealthRecordReminderSpO2,
|
||||
healthRecordReminderWeight = pref.HealthRecordReminderWeight,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record NotificationPrefsUpdateDto(
|
||||
bool? MedicationReminder,
|
||||
bool? FollowUpReminder,
|
||||
bool? DoctorReply,
|
||||
bool? AbnormalAlert,
|
||||
bool? HealthRecordReminder,
|
||||
bool? HealthRecordReminderBloodPressure,
|
||||
bool? HealthRecordReminderHeartRate,
|
||||
bool? HealthRecordReminderGlucose,
|
||||
bool? HealthRecordReminderSpO2,
|
||||
bool? HealthRecordReminderWeight
|
||||
);
|
||||
|
||||
@@ -172,6 +172,8 @@ builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
|
||||
// ---- 后台服务 ----
|
||||
builder.Services.AddHostedService<MedicationReminderService>();
|
||||
builder.Services.AddHostedService<ExerciseReminderService>();
|
||||
builder.Services.AddHostedService<HealthRecordReminderService>();
|
||||
builder.Services.AddHostedService<HealthRecordReminderService>();
|
||||
builder.Services.AddHostedService<MedicationReminderWorker>();
|
||||
builder.Services.AddHostedService<CleanupService>();
|
||||
builder.Services.AddHostedService<ReportAnalysisWorker>();
|
||||
|
||||
Reference in New Issue
Block a user