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,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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} 不合理");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
7
backend/src/Health.Domain/ValidationException.cs
Normal file
7
backend/src/Health.Domain/ValidationException.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Health.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// 业务输入校验失败异常——由 Application 层规则抛出,
|
||||
/// 中间件统一映射为 400 / code 40001,message 可安全展示给用户。
|
||||
/// </summary>
|
||||
public sealed class ValidationException(string message) : Exception(message);
|
||||
@@ -18,13 +18,11 @@ public static class HealthDataAgentHandler
|
||||
|
||||
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId, IHealthRecordService? healthRecords = null)
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"record_health_data" => healthRecords == null
|
||||
? await ExecuteRecordHealthData(db, userId, args)
|
||||
: await ExecuteRecordHealthData(healthRecords, userId, args),
|
||||
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
@@ -62,63 +60,6 @@ public static class HealthDataAgentHandler
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
|
||||
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "blood_pressure":
|
||||
record.MetricType = HealthMetricType.BloodPressure;
|
||||
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
|
||||
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
|
||||
record.Unit = "mmHg";
|
||||
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
|
||||
break;
|
||||
case "heart_rate":
|
||||
record.MetricType = HealthMetricType.HeartRate;
|
||||
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
|
||||
record.Unit = "次/分";
|
||||
record.IsAbnormal = record.Value > 100 || record.Value < 60;
|
||||
break;
|
||||
case "glucose":
|
||||
record.MetricType = HealthMetricType.Glucose;
|
||||
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
|
||||
record.Unit = "mmol/L";
|
||||
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
|
||||
break;
|
||||
case "spo2":
|
||||
record.MetricType = HealthMetricType.SpO2;
|
||||
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
|
||||
record.Unit = "%";
|
||||
record.IsAbnormal = record.Value <= 94;
|
||||
break;
|
||||
case "weight":
|
||||
record.MetricType = HealthMetricType.Weight;
|
||||
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
|
||||
record.Unit = "kg";
|
||||
record.IsAbnormal = false;
|
||||
break;
|
||||
default:
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
}
|
||||
|
||||
db.HealthRecords.Add(record);
|
||||
await db.SaveChangesAsync();
|
||||
var valStr = record.MetricType switch
|
||||
{
|
||||
HealthMetricType.BloodPressure => $"{record.Systolic}/{record.Diastolic}",
|
||||
_ => record.Value?.ToString() ?? ""
|
||||
};
|
||||
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
|
||||
}
|
||||
|
||||
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
|
||||
{
|
||||
return type switch
|
||||
|
||||
@@ -71,7 +71,8 @@ public static class MedicationAgentHandler
|
||||
var times = ReadTimes(args);
|
||||
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
||||
var startDate = ReadStartDate(args);
|
||||
var endDate = durationDays > 0 ? startDate.AddDays(durationDays) : (DateOnly?)null;
|
||||
// 结束日为包含式(IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
|
||||
var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null;
|
||||
|
||||
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
|
||||
name,
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class AiToolExecutionService(
|
||||
var root = json.RootElement;
|
||||
return await (toolName switch
|
||||
{
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, _db, userId, _healthRecords),
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct),
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Health.Infrastructure.Data;
|
||||
|
||||
public sealed class DatabaseSchemaMigrator(AppDbContext db, ILogger<DatabaseSchemaMigrator> logger)
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly ILogger<DatabaseSchemaMigrator> _logger = logger;
|
||||
|
||||
public async Task ApplyAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
|
||||
await _db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS "__AppSchemaMigrations" (
|
||||
"Id" varchar(128) PRIMARY KEY,
|
||||
"AppliedAt" timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AiWriteCommands" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"UserId" uuid NOT NULL,
|
||||
"ToolName" varchar(64) NOT NULL,
|
||||
"Arguments" jsonb NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"ExpiresAt" timestamptz NOT NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_AiWriteCommands_UserId_Status_ExpiresAt"
|
||||
ON "AiWriteCommands" ("UserId", "Status", "ExpiresAt");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "ReportAnalysisTasks" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"ReportId" uuid NOT NULL,
|
||||
"FilePath" text NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"Attempts" integer NOT NULL,
|
||||
"AvailableAt" timestamptz NOT NULL,
|
||||
"LastError" text NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_Status_AvailableAt"
|
||||
ON "ReportAnalysisTasks" ("Status", "AvailableAt");
|
||||
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_ReportId"
|
||||
ON "ReportAnalysisTasks" ("ReportId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "DietImageAnalysisTasks" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"FilePaths" jsonb NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"Attempts" integer NOT NULL,
|
||||
"AvailableAt" timestamptz NOT NULL,
|
||||
"ResultCode" integer NULL,
|
||||
"ResultData" text NULL,
|
||||
"ResultMessage" text NULL,
|
||||
"LastError" text NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_DietImageAnalysisTasks_Status_AvailableAt"
|
||||
ON "DietImageAnalysisTasks" ("Status", "AvailableAt");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "MedicationReminderTasks" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"UserId" uuid NOT NULL,
|
||||
"MedicationId" uuid NOT NULL,
|
||||
"Name" text NOT NULL,
|
||||
"Dosage" text NULL,
|
||||
"ScheduledDate" date NOT NULL,
|
||||
"ScheduledTime" time without time zone NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"Attempts" integer NOT NULL,
|
||||
"AvailableAt" timestamptz NOT NULL,
|
||||
"LastError" text NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_Status_AvailableAt"
|
||||
ON "MedicationReminderTasks" ("Status", "AvailableAt");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_MedicationId_ScheduledDate_ScheduledTime"
|
||||
ON "MedicationReminderTasks" ("MedicationId", "ScheduledDate", "ScheduledTime");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "NotificationOutbox" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"UserId" uuid NOT NULL,
|
||||
"SourceTaskId" uuid NOT NULL,
|
||||
"Type" varchar(64) NOT NULL,
|
||||
"Payload" jsonb NOT NULL,
|
||||
"Status" varchar(32) NOT NULL,
|
||||
"Attempts" integer NOT NULL,
|
||||
"AvailableAt" timestamptz NOT NULL,
|
||||
"LastError" text NULL,
|
||||
"CreatedAt" timestamptz NOT NULL,
|
||||
"UpdatedAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_NotificationOutbox_SourceTaskId"
|
||||
ON "NotificationOutbox" ("SourceTaskId");
|
||||
CREATE INDEX IF NOT EXISTS "IX_NotificationOutbox_Status_AvailableAt"
|
||||
ON "NotificationOutbox" ("Status", "AvailableAt");
|
||||
|
||||
ALTER TABLE IF EXISTS "ExercisePlans"
|
||||
ADD COLUMN IF NOT EXISTS "StartDate" date NULL,
|
||||
ADD COLUMN IF NOT EXISTS "EndDate" date NULL,
|
||||
ADD COLUMN IF NOT EXISTS "ReminderTime" time without time zone NOT NULL DEFAULT TIME '19:00';
|
||||
ALTER TABLE IF EXISTS "ExercisePlanItems"
|
||||
ADD COLUMN IF NOT EXISTS "ScheduledDate" date NULL;
|
||||
|
||||
DO $exercise_migration$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'ExercisePlans' AND column_name = 'WeekStartDate'
|
||||
) THEN
|
||||
UPDATE "ExercisePlans"
|
||||
SET "StartDate" = COALESCE("StartDate", "WeekStartDate")
|
||||
WHERE "StartDate" IS NULL;
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT i."Id", p."StartDate",
|
||||
ROW_NUMBER() OVER (PARTITION BY i."PlanId" ORDER BY i."Id") - 1 AS day_offset
|
||||
FROM "ExercisePlanItems" i
|
||||
JOIN "ExercisePlans" p ON p."Id" = i."PlanId"
|
||||
WHERE i."ScheduledDate" IS NULL
|
||||
)
|
||||
UPDATE "ExercisePlanItems" i
|
||||
SET "ScheduledDate" = ranked."StartDate" + ranked.day_offset::integer
|
||||
FROM ranked
|
||||
WHERE i."Id" = ranked."Id";
|
||||
|
||||
UPDATE "ExercisePlans" p
|
||||
SET "EndDate" = COALESCE(
|
||||
p."EndDate",
|
||||
(SELECT MAX(i."ScheduledDate") FROM "ExercisePlanItems" i WHERE i."PlanId" = p."Id"),
|
||||
p."StartDate"
|
||||
)
|
||||
WHERE p."EndDate" IS NULL;
|
||||
END IF;
|
||||
END $exercise_migration$;
|
||||
|
||||
ALTER TABLE IF EXISTS "ExercisePlans"
|
||||
ALTER COLUMN "StartDate" SET NOT NULL,
|
||||
ALTER COLUMN "EndDate" SET NOT NULL;
|
||||
ALTER TABLE IF EXISTS "ExercisePlanItems"
|
||||
ALTER COLUMN "ScheduledDate" SET NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS "IX_ExercisePlans_UserId_StartDate_EndDate"
|
||||
ON "ExercisePlans" ("UserId", "StartDate", "EndDate");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_ExercisePlanItems_PlanId_ScheduledDate"
|
||||
ON "ExercisePlanItems" ("PlanId", "ScheduledDate");
|
||||
|
||||
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
|
||||
VALUES ('20260619_01_persistent_ai_commands_and_report_tasks', now())
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
|
||||
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
|
||||
VALUES ('20260619_02_persistent_diet_and_medication_tasks', now())
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
|
||||
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
|
||||
VALUES ('20260620_03_date_based_exercise_plans', now())
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
""", ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
_logger.LogInformation("数据库结构迁移已检查: 20260620_03");
|
||||
}
|
||||
}
|
||||
1443
backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs
generated
Normal file
1443
backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,946 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AiWriteCommands",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ToolName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Arguments = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AiWriteCommands", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DietImageAnalysisTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FilePaths = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ResultCode = table.Column<int>(type: "integer", nullable: true),
|
||||
ResultData = table.Column<string>(type: "text", nullable: true),
|
||||
ResultMessage = table.Column<string>(type: "text", nullable: true),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DietImageAnalysisTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Doctors",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Department = table.Column<string>(type: "text", nullable: true),
|
||||
Phone = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||
ProfessionalDirection = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: true),
|
||||
Introduction = table.Column<string>(type: "text", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Doctors", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MedicationReminderTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Dosage = table.Column<string>(type: "text", nullable: true),
|
||||
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MedicationReminderTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationOutbox",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SourceTaskId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Payload = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationOutbox", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RefreshTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "text", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsRevoked = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReportAnalysisTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ReportId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FilePath = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ReportAnalysisTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserNotifications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SourceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
Message = table.Column<string>(type: "text", nullable: false),
|
||||
Severity = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
ActionType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
ActionTargetId = table.Column<string>(type: "text", nullable: true),
|
||||
IsRead = table.Column<bool>(type: "boolean", nullable: false),
|
||||
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserNotifications", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VerificationCodes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Phone = table.Column<string>(type: "text", nullable: false),
|
||||
Code = table.Column<string>(type: "text", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsUsed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VerificationCodes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Phone = table.Column<string>(type: "text", nullable: false),
|
||||
Role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "User"),
|
||||
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Name = table.Column<string>(type: "text", nullable: true),
|
||||
Gender = table.Column<string>(type: "text", nullable: true),
|
||||
BirthDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Users_Doctors_DoctorId",
|
||||
column: x => x.DoctorId,
|
||||
principalTable: "Doctors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Consultations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DoctorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
Month = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Consultations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Consultations_Doctors_DoctorId",
|
||||
column: x => x.DoctorId,
|
||||
principalTable: "Doctors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Consultations_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Conversations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AgentType = table.Column<string>(type: "text", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Summary = table.Column<string>(type: "text", nullable: true),
|
||||
MessageCount = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Conversations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Conversations_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DeviceTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Platform = table.Column<string>(type: "text", nullable: false),
|
||||
PushToken = table.Column<string>(type: "text", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DeviceTokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DeviceTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DietRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MealType = table.Column<string>(type: "text", nullable: false),
|
||||
TotalCalories = table.Column<int>(type: "integer", nullable: true),
|
||||
HealthScore = table.Column<int>(type: "integer", nullable: true),
|
||||
RecordedAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DietRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DietRecords_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DoctorProfiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Name = table.Column<string>(type: "text", nullable: true),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Department = table.Column<string>(type: "text", nullable: true),
|
||||
Hospital = table.Column<string>(type: "text", nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: true),
|
||||
IsOnline = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DoctorProfiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DoctorProfiles_Doctors_DoctorId",
|
||||
column: x => x.DoctorId,
|
||||
principalTable: "Doctors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_DoctorProfiles_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExercisePlans",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ReminderTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExercisePlans", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExercisePlans_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FollowUps",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
DoctorName = table.Column<string>(type: "text", nullable: true),
|
||||
Department = table.Column<string>(type: "text", nullable: true),
|
||||
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Notes = table.Column<string>(type: "text", nullable: true),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FollowUps", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FollowUps_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthArchives",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Diagnosis = table.Column<string>(type: "text", nullable: true),
|
||||
SurgeryType = table.Column<string>(type: "text", nullable: true),
|
||||
SurgeryDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
Allergies = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
DietRestrictions = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
ChronicDiseases = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
FamilyHistory = table.Column<string>(type: "text", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthArchives", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthArchives_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MetricType = table.Column<string>(type: "text", nullable: false),
|
||||
Systolic = table.Column<int>(type: "integer", nullable: true),
|
||||
Diastolic = table.Column<int>(type: "integer", nullable: true),
|
||||
Value = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
Unit = table.Column<string>(type: "text", nullable: true),
|
||||
Source = table.Column<string>(type: "text", nullable: false),
|
||||
IsAbnormal = table.Column<bool>(type: "boolean", nullable: false),
|
||||
RecordedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthRecords_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Medications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Dosage = table.Column<string>(type: "text", nullable: true),
|
||||
Frequency = table.Column<string>(type: "text", nullable: false),
|
||||
TimeOfDay = table.Column<List<TimeOnly>>(type: "time[]", nullable: false),
|
||||
StartDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
EndDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Source = table.Column<string>(type: "text", nullable: false),
|
||||
Notes = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Medications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Medications_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationPreferences",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MedicationReminder = table.Column<bool>(type: "boolean", nullable: false),
|
||||
FollowUpReminder = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DoctorReply = table.Column<bool>(type: "boolean", nullable: false),
|
||||
AbnormalAlert = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationPreferences", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationPreferences_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Reports",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileUrl = table.Column<string>(type: "text", nullable: false),
|
||||
FileType = table.Column<string>(type: "text", nullable: false),
|
||||
Category = table.Column<string>(type: "text", nullable: false),
|
||||
AiSummary = table.Column<string>(type: "text", nullable: true),
|
||||
AiIndicators = table.Column<string>(type: "jsonb", nullable: true),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
Severity = table.Column<string>(type: "text", nullable: true),
|
||||
DoctorComment = table.Column<string>(type: "text", nullable: true),
|
||||
DoctorRecommendation = table.Column<string>(type: "text", nullable: true),
|
||||
DoctorName = table.Column<string>(type: "text", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Reports", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Reports_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConsultationMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ConsultationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SenderType = table.Column<string>(type: "text", nullable: false),
|
||||
SenderName = table.Column<string>(type: "text", nullable: true),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConsultationMessages", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConsultationMessages_Consultations_ConsultationId",
|
||||
column: x => x.ConsultationId,
|
||||
principalTable: "Consultations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConversationMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Role = table.Column<string>(type: "text", nullable: false),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
Intent = table.Column<string>(type: "text", nullable: true),
|
||||
MetadataJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConversationMessages", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConversationMessages_Conversations_ConversationId",
|
||||
column: x => x.ConversationId,
|
||||
principalTable: "Conversations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DietFoodItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DietRecordId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Portion = table.Column<string>(type: "text", nullable: true),
|
||||
Calories = table.Column<int>(type: "integer", nullable: true),
|
||||
ProteinGrams = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
CarbsGrams = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
FatGrams = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
Warning = table.Column<string>(type: "text", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DietFoodItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DietFoodItems_DietRecords_DietRecordId",
|
||||
column: x => x.DietRecordId,
|
||||
principalTable: "DietRecords",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExercisePlanItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PlanId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ExerciseType = table.Column<string>(type: "text", nullable: false),
|
||||
DurationMinutes = table.Column<int>(type: "integer", nullable: false),
|
||||
IsCompleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsRestDay = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExercisePlanItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExercisePlanItems_ExercisePlans_PlanId",
|
||||
column: x => x.PlanId,
|
||||
principalTable: "ExercisePlans",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthArchiveSurgeries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
HealthArchiveId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Date = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthArchiveSurgeries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthArchiveSurgeries_HealthArchives_HealthArchiveId",
|
||||
column: x => x.HealthArchiveId,
|
||||
principalTable: "HealthArchives",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MedicationLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MedicationLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MedicationLogs_Medications_MedicationId",
|
||||
column: x => x.MedicationId,
|
||||
principalTable: "Medications",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AiWriteCommands_UserId_Status_ExpiresAt",
|
||||
table: "AiWriteCommands",
|
||||
columns: new[] { "UserId", "Status", "ExpiresAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConsultationMessages_ConsultationId_CreatedAt",
|
||||
table: "ConsultationMessages",
|
||||
columns: new[] { "ConsultationId", "CreatedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Consultations_DoctorId",
|
||||
table: "Consultations",
|
||||
column: "DoctorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Consultations_UserId",
|
||||
table: "Consultations",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConversationMessages_ConversationId_CreatedAt",
|
||||
table: "ConversationMessages",
|
||||
columns: new[] { "ConversationId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Conversations_UserId_UpdatedAt",
|
||||
table: "Conversations",
|
||||
columns: new[] { "UserId", "UpdatedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeviceTokens_UserId",
|
||||
table: "DeviceTokens",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DietFoodItems_DietRecordId",
|
||||
table: "DietFoodItems",
|
||||
column: "DietRecordId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DietImageAnalysisTasks_Status_AvailableAt",
|
||||
table: "DietImageAnalysisTasks",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DietRecords_UserId_RecordedAt",
|
||||
table: "DietRecords",
|
||||
columns: new[] { "UserId", "RecordedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DoctorProfiles_DoctorId",
|
||||
table: "DoctorProfiles",
|
||||
column: "DoctorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DoctorProfiles_UserId",
|
||||
table: "DoctorProfiles",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExercisePlanItems_PlanId_ScheduledDate",
|
||||
table: "ExercisePlanItems",
|
||||
columns: new[] { "PlanId", "ScheduledDate" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExercisePlans_UserId_StartDate_EndDate",
|
||||
table: "ExercisePlans",
|
||||
columns: new[] { "UserId", "StartDate", "EndDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FollowUps_UserId",
|
||||
table: "FollowUps",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthArchives_UserId",
|
||||
table: "HealthArchives",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthArchiveSurgeries_HealthArchiveId_SortOrder",
|
||||
table: "HealthArchiveSurgeries",
|
||||
columns: new[] { "HealthArchiveId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthRecords_UserId_MetricType",
|
||||
table: "HealthRecords",
|
||||
columns: new[] { "UserId", "MetricType" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthRecords_UserId_RecordedAt",
|
||||
table: "HealthRecords",
|
||||
columns: new[] { "UserId", "RecordedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MedicationLogs_MedicationId_CreatedAt",
|
||||
table: "MedicationLogs",
|
||||
columns: new[] { "MedicationId", "CreatedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MedicationReminderTasks_MedicationId_ScheduledDate_Schedule~",
|
||||
table: "MedicationReminderTasks",
|
||||
columns: new[] { "MedicationId", "ScheduledDate", "ScheduledTime" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MedicationReminderTasks_Status_AvailableAt",
|
||||
table: "MedicationReminderTasks",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Medications_UserId_IsActive",
|
||||
table: "Medications",
|
||||
columns: new[] { "UserId", "IsActive" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationOutbox_SourceTaskId",
|
||||
table: "NotificationOutbox",
|
||||
column: "SourceTaskId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationOutbox_Status_AvailableAt",
|
||||
table: "NotificationOutbox",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationPreferences_UserId",
|
||||
table: "NotificationPreferences",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReportAnalysisTasks_ReportId",
|
||||
table: "ReportAnalysisTasks",
|
||||
column: "ReportId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReportAnalysisTasks_Status_AvailableAt",
|
||||
table: "ReportAnalysisTasks",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reports_UserId",
|
||||
table: "Reports",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserNotifications_SourceId",
|
||||
table: "UserNotifications",
|
||||
column: "SourceId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserNotifications_UserId_IsDeleted_IsRead_CreatedAt",
|
||||
table: "UserNotifications",
|
||||
columns: new[] { "UserId", "IsDeleted", "IsRead", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_DoctorId",
|
||||
table: "Users",
|
||||
column: "DoctorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_Phone",
|
||||
table: "Users",
|
||||
column: "Phone",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VerificationCodes_Phone_CreatedAt",
|
||||
table: "VerificationCodes",
|
||||
columns: new[] { "Phone", "CreatedAt" },
|
||||
descending: new[] { false, true });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AiWriteCommands");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConsultationMessages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConversationMessages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DeviceTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DietFoodItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DietImageAnalysisTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DoctorProfiles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExercisePlanItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FollowUps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthArchiveSurgeries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MedicationLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MedicationReminderTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationOutbox");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RefreshTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReportAnalysisTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Reports");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserNotifications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VerificationCodes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Consultations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Conversations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DietRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExercisePlans");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthArchives");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Medications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Doctors");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
namespace Health.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库种子数据
|
||||
/// </summary>
|
||||
public static class DataSeeder
|
||||
{
|
||||
public static async Task SeedAsync(AppDbContext db)
|
||||
{
|
||||
// 医生不再使用种子数据,通过注册创建
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Health.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 开发环境测试数据填充。生产环境不要调用!
|
||||
/// 开关:DEVDATA_ENABLED=true 才会执行
|
||||
/// </summary>
|
||||
public static class DevDataSeeder
|
||||
{
|
||||
public static async Task SeedIfEnabled(AppDbContext db, IConfiguration config)
|
||||
{
|
||||
// 通过环境变量控制:DEVDATA_ENABLED=true 才填充测试数据
|
||||
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
|
||||
if (enabled != "true") return;
|
||||
|
||||
// 已有任何真实用户时跳过(避免污染真实数据)
|
||||
if (db.Users.Any(u => u.Phone != "13800000001")) return;
|
||||
|
||||
// 检查是否已有测试用户(避免重复填充)
|
||||
if (db.Users.Any(u => u.Phone == "13800000001")) return;
|
||||
|
||||
// ---- 种子医生数据 ----
|
||||
if (!db.Doctors.Any())
|
||||
{
|
||||
var doctor1 = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = "张明", Title = "主任医师",
|
||||
Department = "心脏康复科", Phone = "13800000002",
|
||||
ProfessionalDirection = "冠心病康复、术后管理",
|
||||
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var doctor2 = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = "李芳", Title = "副主任医师",
|
||||
Department = "营养科", Phone = "13800000003",
|
||||
ProfessionalDirection = "糖尿病管理、饮食指导",
|
||||
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var doctor3 = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = "王建国", Title = "主任医师",
|
||||
Department = "心血管内科", Phone = "13800000004",
|
||||
ProfessionalDirection = "高血压管理、PCI术后随访",
|
||||
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Doctors.AddRange(doctor1, doctor2, doctor3);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- 创建测试患者 ----
|
||||
var doctorWang = db.Doctors.FirstOrDefault(d => d.Name == "王建国");
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(), Phone = "13800000001", Name = "张三",
|
||||
Gender = "男", BirthDate = new DateOnly(1970, 3, 15),
|
||||
DoctorId = doctorWang?.Id,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// ---- 健康档案 ----
|
||||
db.HealthArchives.Add(new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id,
|
||||
Diagnosis = "冠心病", SurgeryType = "PCI支架植入术",
|
||||
SurgeryDate = new DateOnly(2026, 3, 15),
|
||||
Allergies = ["青霉素"], DietRestrictions = ["低盐", "低脂"],
|
||||
ChronicDiseases = ["高血压", "高血脂"],
|
||||
FamilyHistory = "父亲冠心病",
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
// ---- 健康数据(过去 7 天)----
|
||||
var random = new Random(42);
|
||||
for (int i = 7; i >= 0; i--)
|
||||
{
|
||||
var date = DateTime.UtcNow.AddDays(-i);
|
||||
// 血压
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
|
||||
Systolic = 120 + random.Next(-5, 15), Diastolic = 75 + random.Next(-5, 10),
|
||||
Unit = "mmHg", Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
// 心率
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.HeartRate,
|
||||
Value = 68 + random.Next(-5, 10), Unit = "次/分",
|
||||
Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
// 血糖
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.Glucose,
|
||||
Value = 5.0m + (decimal)(random.NextDouble() * 1.5),
|
||||
Unit = "mmol/L", Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
// 血氧
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.SpO2,
|
||||
Value = 96 + random.Next(0, 3), Unit = "%",
|
||||
Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
}
|
||||
// 一条异常血压
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
|
||||
Systolic = 148, Diastolic = 92, Unit = "mmHg",
|
||||
Source = HealthRecordSource.AiEntry, IsAbnormal = true,
|
||||
RecordedAt = DateTime.UtcNow.AddDays(-1),
|
||||
});
|
||||
|
||||
// ---- 用药计划 ----
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿司匹林", Dosage = "100mg",
|
||||
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)],
|
||||
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
|
||||
});
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿托伐他汀", Dosage = "20mg",
|
||||
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(20, 0)],
|
||||
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
|
||||
});
|
||||
|
||||
// ---- 运动计划 ----
|
||||
var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1));
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(6), ReminderTime = new TimeOnly(19, 0) };
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), ExerciseType = "散步", DurationMinutes = 30 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(3), IsRestDay = true });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(4), ExerciseType = "太极", DurationMinutes = 40 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(5), IsRestDay = true });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(6), ExerciseType = "散步", DurationMinutes = 30 });
|
||||
db.ExercisePlans.Add(plan);
|
||||
|
||||
// ---- 饮食记录 ----
|
||||
var lunch = new DietRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
|
||||
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
};
|
||||
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 });
|
||||
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 });
|
||||
db.DietRecords.Add(lunch);
|
||||
|
||||
// ---- 复查计划 ----
|
||||
db.FollowUps.Add(new FollowUp
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Title = "心内科复查",
|
||||
DoctorName = "王建国", Department = "心血管内科",
|
||||
ScheduledAt = DateTime.UtcNow.AddDays(3), Status = FollowUpStatus.Upcoming,
|
||||
});
|
||||
db.FollowUps.Add(new FollowUp
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Title = "术后3周复查",
|
||||
DoctorName = "王建国", Department = "心血管内科",
|
||||
ScheduledAt = DateTime.UtcNow.AddDays(-14), Status = FollowUpStatus.Completed,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
Console.WriteLine($"[DEV] 测试数据已填充:用户 {user.Phone}");
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,28 @@ public sealed class DietImageAnalysisQueue(AppDbContext db) : IDietImageAnalysis
|
||||
return task.Id;
|
||||
}
|
||||
|
||||
public Task RecoverStaleAsync(CancellationToken ct)
|
||||
public async Task RecoverStaleAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return _db.DietImageAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
|
||||
var staleCutoff = now.AddMinutes(-5);
|
||||
|
||||
// 超时但仍有重试余量 → 重新排队
|
||||
await _db.DietImageAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Pending")
|
||||
.SetProperty(x => x.AvailableAt, now)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
|
||||
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing(也让 WaitForResultAsync 能尽快返回失败)
|
||||
await _db.DietImageAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.ResultCode, 50001)
|
||||
.SetProperty(x => x.ResultMessage, "食物识别超时且已达最大重试次数")
|
||||
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
public async Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct)
|
||||
|
||||
@@ -45,15 +45,26 @@ public sealed class MedicationReminderQueue(AppDbContext db) : IMedicationRemind
|
||||
}
|
||||
}
|
||||
|
||||
public Task RecoverStaleAsync(CancellationToken ct)
|
||||
public async Task RecoverStaleAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return _db.MedicationReminderTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
|
||||
var staleCutoff = now.AddMinutes(-5);
|
||||
|
||||
// 超时但仍有重试余量 → 重新排队
|
||||
await _db.MedicationReminderTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Pending")
|
||||
.SetProperty(x => x.AvailableAt, now)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
|
||||
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
|
||||
await _db.MedicationReminderTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
public async Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
|
||||
namespace Health.Infrastructure.Reports;
|
||||
|
||||
public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueue
|
||||
public sealed class EfReportAnalysisQueue(
|
||||
AppDbContext db,
|
||||
IUserNotificationProducer? notifications = null) : IReportAnalysisQueue
|
||||
{
|
||||
private const int MaxAttempts = 3;
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly IUserNotificationProducer? _notifications = notifications;
|
||||
|
||||
public async Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default)
|
||||
{
|
||||
@@ -25,15 +29,26 @@ public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueu
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public Task RecoverStaleAsync(CancellationToken ct)
|
||||
public async Task RecoverStaleAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return _db.ReportAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
|
||||
var staleCutoff = now.AddMinutes(-5);
|
||||
|
||||
// 超时但仍有重试余量 → 重新排队
|
||||
await _db.ReportAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Pending")
|
||||
.SetProperty(x => x.AvailableAt, now)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
|
||||
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
|
||||
await _db.ReportAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
public async Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct)
|
||||
@@ -82,5 +97,27 @@ public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueu
|
||||
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
if (task.Status == "Failed" && _notifications != null)
|
||||
{
|
||||
var report = await _db.Reports.AsNoTracking()
|
||||
.Where(x => x.Id == task.ReportId)
|
||||
.Select(x => new { x.Id, x.UserId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (report != null)
|
||||
{
|
||||
await _notifications.EnqueueAsync(
|
||||
report.UserId,
|
||||
task.Id,
|
||||
new NotificationMessage(
|
||||
"ReportAnalysisFailed",
|
||||
"报告分析未完成",
|
||||
"这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
|
||||
"warning",
|
||||
"report",
|
||||
report.Id.ToString()),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Infrastructure.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -8,11 +9,13 @@ public sealed class ReportAnalysisService(
|
||||
IReportRepository reports,
|
||||
VisionClient vision,
|
||||
DeepSeekClient llm,
|
||||
IUserNotificationProducer notifications,
|
||||
ILogger<ReportAnalysisService> logger) : IReportAnalysisService
|
||||
{
|
||||
private readonly IReportRepository _reports = reports;
|
||||
private readonly VisionClient _vision = vision;
|
||||
private readonly DeepSeekClient _llm = llm;
|
||||
private readonly IUserNotificationProducer _notifications = notifications;
|
||||
private readonly ILogger<ReportAnalysisService> _logger = logger;
|
||||
|
||||
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
|
||||
@@ -32,6 +35,7 @@ public sealed class ReportAnalysisService(
|
||||
report.AiIndicators = "[]";
|
||||
report.Status = ReportStatus.AnalysisFailed;
|
||||
await _reports.SaveChangesAsync(ct);
|
||||
await NotifyAsync(report, job.TaskId, false, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,8 +48,14 @@ public sealed class ReportAnalysisService(
|
||||
report.Status = ReportStatus.PendingDoctor;
|
||||
|
||||
await _reports.SaveChangesAsync(ct);
|
||||
await NotifyAsync(report, job.TaskId, true, ct);
|
||||
_logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// 进程停机导致的取消:不算失败,不消耗重试,交还给恢复机制
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId);
|
||||
@@ -53,6 +63,10 @@ public sealed class ReportAnalysisService(
|
||||
report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。";
|
||||
report.AiIndicators = "[]";
|
||||
await _reports.SaveChangesAsync(ct);
|
||||
|
||||
// 重新抛出,使 Worker 进入 RetryAsync(指数退避重试,超限后任务置 Failed);
|
||||
// 若为可恢复的瞬时故障,重试成功会把状态改回 PendingDoctor。
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +105,8 @@ public sealed class ReportAnalysisService(
|
||||
ct: ct);
|
||||
|
||||
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
|
||||
_logger.LogInformation("报告 VLM 返回: {Content}", content[..Math.Min(content.Length, 200)]);
|
||||
// 不记录 VLM 返回正文:可能包含检验指标等敏感医疗信息,生产日志只记录长度
|
||||
_logger.LogInformation("报告 VLM 返回长度: {Length}", content.Length);
|
||||
|
||||
var json = TryParseJson(content);
|
||||
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
|
||||
@@ -162,4 +177,17 @@ public sealed class ReportAnalysisService(
|
||||
try { return JsonDocument.Parse(content).RootElement; }
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
private Task NotifyAsync(Report report, Guid sourceId, bool succeeded, CancellationToken ct) =>
|
||||
_notifications.EnqueueAsync(
|
||||
report.UserId,
|
||||
sourceId,
|
||||
new NotificationMessage(
|
||||
succeeded ? "ReportAnalysisCompleted" : "ReportAnalysisFailed",
|
||||
succeeded ? "报告分析完成" : "报告分析未完成",
|
||||
succeeded ? "您的报告已完成 AI 预解读,点击查看分析结果。" : "这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
|
||||
succeeded ? "success" : "warning",
|
||||
"report",
|
||||
report.Id.ToString()),
|
||||
ct);
|
||||
}
|
||||
|
||||
52
backend/src/Health.WebApi/Data/AppDbContextFactory.cs
Normal file
52
backend/src/Health.WebApi/Data/AppDbContextFactory.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Health.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Health.WebApi.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 设计时 DbContext 工厂——仅供 dotnet-ef(migrations / database update)使用。
|
||||
/// 不会启动 Web 服务或后台 Worker,只负责构建 AppDbContext 的连接配置。
|
||||
/// 连接串优先级:DB_CONNECTION 环境变量 → backend/.env 文件 → 本地默认。
|
||||
/// </summary>
|
||||
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
private const string FallbackConnection =
|
||||
"Host=localhost;Database=health_manager;Username=postgres;Password=postgres123";
|
||||
|
||||
public AppDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connection = Environment.GetEnvironmentVariable("DB_CONNECTION")
|
||||
?? ReadConnectionFromEnvFile()
|
||||
?? FallbackConnection;
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseNpgsql(connection)
|
||||
.Options;
|
||||
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
// 从 backend 目录的 .env 读取 DB_CONNECTION(设计时不走 Program.cs 的加载逻辑)
|
||||
private static string? ReadConnectionFromEnvFile()
|
||||
{
|
||||
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
|
||||
for (var i = 0; i < 6 && dir != null; i++, dir = dir.Parent)
|
||||
{
|
||||
var envPath = Path.Combine(dir.FullName, ".env");
|
||||
if (!File.Exists(envPath)) continue;
|
||||
|
||||
foreach (var line in File.ReadAllLines(envPath))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Length == 0 || trimmed.StartsWith('#')) continue;
|
||||
var eq = trimmed.IndexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
if (trimmed[..eq].Trim() != "DB_CONNECTION") continue;
|
||||
var value = trimmed[(eq + 1)..].Trim();
|
||||
return value.Length > 0 ? value : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (Health.Domain.ValidationException vex)
|
||||
{
|
||||
// 业务输入校验失败:返回 400,message 为我们自己产生的提示,可安全展示
|
||||
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
||||
context.Response.ContentType = "application/json";
|
||||
var result = new { code = 40001, data = (object?)null, message = vex.Message };
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(result));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 生产环境不暴露内部异常详情
|
||||
|
||||
@@ -79,7 +79,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
|
||||
// ---- 数据库 ----
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("Default") ?? builder.Configuration["DB_CONNECTION"] ?? "Host=localhost;Database=health_manager;Username=postgres;Password=postgres"));
|
||||
options.UseNpgsql(
|
||||
builder.Configuration.GetConnectionString("Default") ?? builder.Configuration["DB_CONNECTION"] ?? "Host=localhost;Database=health_manager;Username=postgres;Password=postgres",
|
||||
npgsql => npgsql.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
|
||||
|
||||
// ---- JWT 认证 ----
|
||||
var jwtSecret = builder.Configuration["JWT_SECRET"];
|
||||
@@ -137,6 +139,8 @@ builder.Services.AddScoped<IMaintenanceService, MaintenanceService>();
|
||||
builder.Services.AddScoped<IMaintenanceRepository, EfMaintenanceRepository>();
|
||||
builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>();
|
||||
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
||||
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
||||
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
||||
builder.Services.AddScoped<IReportService, ReportService>();
|
||||
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
||||
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
||||
@@ -144,7 +148,6 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||
builder.Services.AddScoped<DatabaseSchemaMigrator>();
|
||||
|
||||
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
||||
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||
@@ -167,6 +170,7 @@ builder.Services.AddHostedService<MedicationReminderWorker>();
|
||||
builder.Services.AddHostedService<CleanupService>();
|
||||
builder.Services.AddHostedService<ReportAnalysisWorker>();
|
||||
builder.Services.AddHostedService<DietImageAnalysisWorker>();
|
||||
builder.Services.AddHostedService<NotificationOutboxWorker>();
|
||||
|
||||
// ---- OpenAPI ----
|
||||
builder.Services.AddOpenApi();
|
||||
@@ -202,14 +206,12 @@ app.UseStaticFiles(new StaticFileOptions
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
|
||||
// ---- 初始化数据库:创建空库,并用版本化补丁更新已有本地结构 ----
|
||||
// ---- 数据库迁移(EF Core Migrations,可用 AUTO_MIGRATE=false 禁用自动迁移)----
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await scope.ServiceProvider.GetRequiredService<DatabaseSchemaMigrator>().ApplyAsync();
|
||||
await DataSeeder.SeedAsync(db);
|
||||
await DevDataSeeder.SeedIfEnabled(db, app.Configuration);
|
||||
if (app.Configuration.GetValue<bool>("AUTO_MIGRATE", true))
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// ---- 注册 API 端点 ----
|
||||
|
||||
Reference in New Issue
Block a user