feat: 引入 EF Core Migrations + 后台任务/校验修复 + 前端四态
后端 - 改用 EF Core Migrations(InitialCreate),Program.cs 用 MigrateAsync + AUTO_MIGRATE 开关 + 显式 MigrationsAssembly;移除 EnsureCreated、删除手写 DatabaseSchemaMigrator - 修复后台任务永久卡 Processing:三个队列对超时且达上限的任务原子标记 Failed - 修复报告重试失效:基础设施异常向上抛激活 RetryAsync,停机取消不计失败 - 修复 AI 用药天数 off-by-one(结束日为包含式) - AI 报告日志不再记录 VLM 健康正文 - 新增统一输入校验(ValidationException + 中间件映射 400):血压/心率/血糖/血氧/体重范围、药名/日期/服药时间去重、饮食热量与评分、运动时长上限 - 删除健康数据 AI 录入的影子直写路径,统一走 Service 校验 前端 - 新增 AppErrorState / AppFutureView,用药列表、服药打卡、运动计划、随访列表改为 加载/失败/空/数据 四态,失败可重试 瘦身 - 删除过时的 DataSeeder / DevDataSeeder 及调用 - 删除依赖实时服务的 ai_agent_tests 集成测试
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Application.Notifications;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Health.Application.HealthRecords;
|
||||
|
||||
public sealed class HealthRecordService(IHealthRecordRepository records) : IHealthRecordService
|
||||
public sealed class HealthRecordService(
|
||||
IHealthRecordRepository records,
|
||||
IUserNotificationProducer? notifications = null) : IHealthRecordService
|
||||
{
|
||||
private readonly IHealthRecordRepository _records = records;
|
||||
private readonly IUserNotificationProducer? _notifications = notifications;
|
||||
|
||||
public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct)
|
||||
{
|
||||
@@ -20,6 +26,8 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
HealthRecordRules.Validate(request);
|
||||
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -37,6 +45,7 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
|
||||
await _records.AddAsync(record, ct);
|
||||
await _records.SaveChangesAsync(ct);
|
||||
await EnqueueAbnormalNotificationAsync(record, ct);
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
@@ -45,6 +54,8 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
HealthRecordRules.Validate(request);
|
||||
|
||||
record.MetricType = request.Type;
|
||||
record.Systolic = request.Systolic;
|
||||
record.Diastolic = request.Diastolic;
|
||||
@@ -55,6 +66,7 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
|
||||
|
||||
await _records.SaveChangesAsync(ct);
|
||||
await EnqueueAbnormalNotificationAsync(record, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -98,9 +110,9 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
|
||||
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
|
||||
{
|
||||
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 };
|
||||
var days = period switch { 7 => 7, 30 => 30, 90 => 90, 365 => 365, _ => 7 };
|
||||
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
|
||||
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
|
||||
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
public static HealthRecordDto ToDto(HealthRecord record) => new(
|
||||
@@ -113,4 +125,42 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
|
||||
record.Source.ToString(),
|
||||
record.IsAbnormal,
|
||||
record.RecordedAt);
|
||||
|
||||
private async Task EnqueueAbnormalNotificationAsync(HealthRecord record, CancellationToken ct)
|
||||
{
|
||||
if (!record.IsAbnormal || _notifications == null) return;
|
||||
|
||||
var (title, message, severity, target) = record.MetricType switch
|
||||
{
|
||||
HealthMetricType.BloodPressure when record.Systolic >= 140 || record.Diastolic >= 90 =>
|
||||
("血压偏高提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg,高于参考范围。建议休息后复测;如伴明显不适,请及时就医。", "warning", "blood_pressure"),
|
||||
HealthMetricType.BloodPressure =>
|
||||
("血压偏低提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg,低于参考范围。请留意头晕、乏力等不适,必要时及时就医。", "warning", "blood_pressure"),
|
||||
HealthMetricType.HeartRate when record.Value > 100 =>
|
||||
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
||||
HealthMetricType.HeartRate =>
|
||||
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
||||
HealthMetricType.Glucose when record.Value >= 7.0m =>
|
||||
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
||||
HealthMetricType.Glucose =>
|
||||
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
||||
HealthMetricType.SpO2 =>
|
||||
("血氧偏低提醒", $"本次血氧为 {record.Value:0.#}%,低于参考范围。建议立即复测;如持续偏低或伴呼吸不适,请及时就医。", "critical", "spo2"),
|
||||
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
|
||||
};
|
||||
|
||||
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
|
||||
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
|
||||
await _notifications.EnqueueAsync(
|
||||
record.UserId,
|
||||
sourceId,
|
||||
new NotificationMessage("HealthMetricAlert", title, message, severity, "health", target),
|
||||
ct);
|
||||
}
|
||||
|
||||
private static Guid DeterministicGuid(string value)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user