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:
MingNian
2026-06-21 21:04:40 +08:00
parent aa44d6f0f0
commit b57d0d16f4
30 changed files with 4377 additions and 752 deletions

View File

@@ -1,3 +1,4 @@
using Health.Domain;
using Health.Domain.Entities;
using Health.Domain.Enums;
@@ -5,6 +6,7 @@ namespace Health.Application.Diets;
public sealed class DietService(IDietRepository diets) : IDietService
{
private const int MaxCalories = 20000; // 单条记录/单项热量上限kcal
private readonly IDietRepository _diets = diets;
public async Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct)
@@ -18,6 +20,15 @@ public sealed class DietService(IDietRepository diets) : IDietService
public async Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct)
{
ValidateCalories(request.TotalCalories, "总热量");
ValidateScore(request.HealthScore);
foreach (var item in request.FoodItems)
{
if (string.IsNullOrWhiteSpace(item.Name))
throw new ValidationException("食物名称不能为空");
ValidateCalories(item.Calories, $"食物「{item.Name.Trim()}」热量");
}
var record = new DietRecord
{
Id = Guid.NewGuid(),
@@ -34,7 +45,7 @@ public sealed class DietService(IDietRepository diets) : IDietService
record.FoodItems.Add(new DietFoodItem
{
Id = Guid.NewGuid(),
Name = item.Name,
Name = item.Name.Trim(),
Portion = item.Portion,
Calories = item.Calories,
SortOrder = item.SortOrder,
@@ -61,12 +72,27 @@ public sealed class DietService(IDietRepository diets) : IDietService
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
if (record == null) return false;
ValidateCalories(request.TotalCalories, "总热量");
ValidateScore(request.HealthScore);
if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value;
if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value;
await _diets.SaveChangesAsync(ct);
return true;
}
private static void ValidateCalories(int? calories, string field)
{
if (calories.HasValue && (calories.Value < 0 || calories.Value > MaxCalories))
throw new ValidationException($"{field}应在 0~{MaxCalories} kcal 之间,当前值 {calories.Value} 不合理");
}
private static void ValidateScore(int? score)
{
if (score.HasValue && (score.Value < 0 || score.Value > 100))
throw new ValidationException($"健康评分应在 0~100 之间,当前值 {score.Value} 不合理");
}
private static DietRecordDto ToDto(DietRecord record) => new(
record.Id,
record.MealType.ToString(),

View File

@@ -1,9 +1,11 @@
using Health.Domain;
using Health.Domain.Entities;
namespace Health.Application.Exercises;
public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService
{
private const int MaxDurationMinutes = 1440; // 单次运动时长上限 24 小时
private static readonly TimeOnly DefaultReminderTime = new(19, 0);
private readonly IExerciseRepository _exercises = exercises;
@@ -17,6 +19,8 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
public async Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct)
{
ValidateDuration(request.DurationMinutes);
var startDate = request.StartDate;
var endDate = request.EndDate < startDate ? startDate : request.EndDate;
if (endDate.DayNumber - startDate.DayNumber > 365)
@@ -39,6 +43,9 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
{
foreach (var item in items)
ValidateDuration(item.DurationMinutes);
var normalizedEnd = endDate < startDate ? startDate : endDate;
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime);
foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
@@ -55,6 +62,13 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
return plan.Id;
}
// 时长上限校验:下限交由各方法兜底为 30这里只拦截不合理的超大值
private static void ValidateDuration(int durationMinutes)
{
if (durationMinutes > MaxDurationMinutes)
throw new ValidationException($"单次运动时长不能超过 {MaxDurationMinutes} 分钟24 小时),当前值 {durationMinutes} 不合理");
}
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
{
var plans = await _exercises.ListAsync(userId, 20, ct);

View File

@@ -1,3 +1,4 @@
using Health.Domain;
using Health.Domain.Enums;
namespace Health.Application.HealthRecords;
@@ -13,4 +14,48 @@ public static class HealthRecordRules
HealthMetricType.Weight => false,
_ => false
};
/// <summary>
/// 合法性校验——拦截物理上不可能的数值(负数、超量程等),不合法即抛 ValidationException。
/// 注意:这与 CheckAbnormal医学异常预警是两回事前者拦脏数据后者只是标记偏离正常范围。
/// </summary>
public static void Validate(HealthRecordUpsertRequest request)
{
switch (request.Type)
{
case HealthMetricType.BloodPressure:
var sys = Required(request.Systolic, "收缩压");
var dia = Required(request.Diastolic, "舒张压");
InRange(sys, 40, 300, "收缩压", "mmHg");
InRange(dia, 20, 250, "舒张压", "mmHg");
if (sys <= dia)
throw new ValidationException("收缩压必须大于舒张压");
break;
case HealthMetricType.HeartRate:
InRange(Required(request.Value, "心率"), 20m, 300m, "心率", "次/分");
break;
case HealthMetricType.Glucose:
InRange(Required(request.Value, "血糖"), 0.5m, 60m, "血糖", "mmol/L");
break;
case HealthMetricType.SpO2:
InRange(Required(request.Value, "血氧"), 50m, 100m, "血氧", "%");
break;
case HealthMetricType.Weight:
InRange(Required(request.Value, "体重"), 1m, 500m, "体重", "kg");
break;
}
}
private static int Required(int? value, string field) =>
value ?? throw new ValidationException($"{field}不能为空");
private static decimal Required(decimal? value, string field) =>
value ?? throw new ValidationException($"{field}不能为空");
private static void InRange(decimal value, decimal min, decimal max, string field, string unit)
{
if (value < min || value > max)
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
}
}

View File

@@ -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));
}
}

View File

@@ -1,3 +1,4 @@
using Health.Domain;
using Health.Domain.Entities;
using Health.Domain.Enums;
@@ -16,14 +17,19 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
public async Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct)
{
var name = ValidateName(request.Name);
ValidateDateRange(request.StartDate, request.EndDate);
var times = NormalizeTimes(request.TimeOfDay);
if (times.Count == 0) times = [new TimeOnly(8, 0)];
var med = new Medication
{
Id = Guid.NewGuid(),
UserId = userId,
Name = request.Name,
Name = name,
Dosage = request.Dosage,
Frequency = request.Frequency,
TimeOfDay = request.TimeOfDay.Count > 0 ? request.TimeOfDay.ToList() : [new TimeOnly(8, 0)],
TimeOfDay = times,
StartDate = request.StartDate,
EndDate = request.EndDate,
Source = request.Source,
@@ -43,12 +49,13 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (med == null) return false;
if (!string.IsNullOrWhiteSpace(request.Name)) med.Name = request.Name;
if (request.Name != null) med.Name = ValidateName(request.Name);
if (request.Dosage != null) med.Dosage = request.Dosage;
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
if (request.TimeOfDay != null) med.TimeOfDay = request.TimeOfDay.ToList();
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
ValidateDateRange(med.StartDate, med.EndDate);
if (request.Notes != null) med.Notes = request.Notes;
med.UpdatedAt = DateTime.UtcNow;
@@ -56,6 +63,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
return true;
}
// ── 输入校验 ──
private static string ValidateName(string? name)
{
var trimmed = name?.Trim();
if (string.IsNullOrEmpty(trimmed))
throw new ValidationException("药品名称不能为空");
if (trimmed.Length > 200)
throw new ValidationException("药品名称过长(不超过 200 字)");
return trimmed;
}
private static void ValidateDateRange(DateOnly? startDate, DateOnly? endDate)
{
if (startDate.HasValue && endDate.HasValue && endDate.Value < startDate.Value)
throw new ValidationException("结束日期不能早于开始日期");
}
// 同一时间点去重并排序;空集合交由调用方/实体默认处理
private static List<TimeOnly> NormalizeTimes(IReadOnlyList<TimeOnly> times) =>
times.Distinct().OrderBy(t => t).ToList();
public async Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct)
{
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);