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