diff --git a/backend/dotnet-tools.json b/backend/dotnet-tools.json new file mode 100644 index 0000000..7dcefc3 --- /dev/null +++ b/backend/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.8", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/backend/src/Health.Application/Diets/DietService.cs b/backend/src/Health.Application/Diets/DietService.cs index f83c728..2249608 100644 --- a/backend/src/Health.Application/Diets/DietService.cs +++ b/backend/src/Health.Application/Diets/DietService.cs @@ -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> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct) @@ -18,6 +20,15 @@ public sealed class DietService(IDietRepository diets) : IDietService public async Task 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(), diff --git a/backend/src/Health.Application/Exercises/ExerciseService.cs b/backend/src/Health.Application/Exercises/ExerciseService.cs index 1ea26b3..943a9f1 100644 --- a/backend/src/Health.Application/Exercises/ExerciseService.cs +++ b/backend/src/Health.Application/Exercises/ExerciseService.cs @@ -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 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 CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList 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> ListAsync(Guid userId, CancellationToken ct) { var plans = await _exercises.ListAsync(userId, 20, ct); diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs b/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs index 6845dbc..5b64f4c 100644 --- a/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs +++ b/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs @@ -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 }; + + /// + /// 合法性校验——拦截物理上不可能的数值(负数、超量程等),不合法即抛 ValidationException。 + /// 注意:这与 CheckAbnormal(医学异常预警)是两回事,前者拦脏数据,后者只是标记偏离正常范围。 + /// + 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} 不合理"); + } } + diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs index 8f2028d..e881a20 100644 --- a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs +++ b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs @@ -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> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct) { @@ -20,6 +26,8 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal public async Task 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> 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().ToList(); + return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt }).Cast().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)); + } } diff --git a/backend/src/Health.Application/Medications/MedicationService.cs b/backend/src/Health.Application/Medications/MedicationService.cs index cf63b0f..4e94b07 100644 --- a/backend/src/Health.Application/Medications/MedicationService.cs +++ b/backend/src/Health.Application/Medications/MedicationService.cs @@ -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 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 NormalizeTimes(IReadOnlyList times) => + times.Distinct().OrderBy(t => t).ToList(); + public async Task DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct) { var med = await _medications.GetOwnedAsync(userId, medicationId, ct); diff --git a/backend/src/Health.Domain/ValidationException.cs b/backend/src/Health.Domain/ValidationException.cs new file mode 100644 index 0000000..3d9a517 --- /dev/null +++ b/backend/src/Health.Domain/ValidationException.cs @@ -0,0 +1,7 @@ +namespace Health.Domain; + +/// +/// 业务输入校验失败异常——由 Application 层规则抛出, +/// 中间件统一映射为 400 / code 40001,message 可安全展示给用户。 +/// +public sealed class ValidationException(string message) : Exception(message); diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs index f9eb8ac..4a0c613 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs @@ -18,13 +18,11 @@ public static class HealthDataAgentHandler public static List Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool]; - public static async Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId, IHealthRecordService? healthRecords = null) + public static async Task 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 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 diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs index 9619cf8..67a8c72 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs @@ -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, diff --git a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs index 12b8101..27c2272 100644 --- a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs +++ b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs @@ -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), diff --git a/backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs b/backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs deleted file mode 100644 index 442d66f..0000000 --- a/backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace Health.Infrastructure.Data; - -public sealed class DatabaseSchemaMigrator(AppDbContext db, ILogger logger) -{ - private readonly AppDbContext _db = db; - private readonly ILogger _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"); - } -} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs new file mode 100644 index 0000000..dded453 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs @@ -0,0 +1,1443 @@ +// +using System; +using System.Collections.Generic; +using Health.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260621114547_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId"); + + b.ToTable("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsultationId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderName") + .HasColumnType("text"); + + b.Property("SenderType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConsultationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("ConsultationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UpdatedAt") + .IsDescending(false, true); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Intent") + .HasColumnType("text"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "CreatedAt"); + + b.ToTable("ConversationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Calories") + .HasColumnType("integer"); + + b.Property("CarbsGrams") + .HasColumnType("numeric"); + + b.Property("DietRecordId") + .HasColumnType("uuid"); + + b.Property("FatGrams") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Portion") + .HasColumnType("text"); + + b.Property("ProteinGrams") + .HasColumnType("numeric"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Warning") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DietRecordId"); + + b.ToTable("DietFoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HealthScore") + .HasColumnType("integer"); + + b.Property("MealType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("date"); + + b.Property("TotalCalories") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("DietRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("Introduction") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfessionalDirection") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Doctors"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Hospital") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ReminderTime") + .HasColumnType("time without time zone"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "StartDate", "EndDate"); + + b.ToTable("ExercisePlans"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationMinutes") + .HasColumnType("integer"); + + b.Property("ExerciseType") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsRestDay") + .HasColumnType("boolean"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("PlanId", "ScheduledDate") + .IsUnique(); + + b.ToTable("ExercisePlanItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Allergies") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection>("ChronicDiseases") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Diagnosis") + .HasColumnType("text"); + + b.PrimitiveCollection>("DietRestrictions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FamilyHistory") + .HasColumnType("text"); + + b.Property("SurgeryDate") + .HasColumnType("date"); + + b.Property("SurgeryType") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("HealthArchives"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HealthArchiveId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("HealthArchiveId", "SortOrder"); + + b.ToTable("HealthArchiveSurgeries", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Diastolic") + .HasColumnType("integer"); + + b.Property("IsAbnormal") + .HasColumnType("boolean"); + + b.Property("MetricType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Systolic") + .HasColumnType("integer"); + + b.Property("Unit") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "MetricType"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.PrimitiveCollection>("TimeOfDay") + .IsRequired() + .HasColumnType("time[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("Medications"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MedicationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("MedicationLogs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AbnormalAlert") + .HasColumnType("boolean"); + + b.Property("DoctorReply") + .HasColumnType("boolean"); + + b.Property("FollowUpReminder") + .HasColumnType("boolean"); + + b.Property("MedicationReminder") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NotificationPreferences"); + }); + + modelBuilder.Entity("Health.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiIndicators") + .HasColumnType("jsonb"); + + b.Property("AiSummary") + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorComment") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("DoctorRecommendation") + .HasColumnType("text"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Gender") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("User"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Health.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionTargetId") + .HasColumnType("text"); + + b.Property("ActionType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceId") + .IsUnique(); + + b.HasIndex("UserId", "IsDeleted", "IsRead", "CreatedAt"); + + b.ToTable("UserNotifications", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Phone", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("VerificationCodes"); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arguments") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status", "ExpiresAt"); + + b.ToTable("AiWriteCommands", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.DietImageAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePaths") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ResultCode") + .HasColumnType("integer"); + + b.Property("ResultData") + .HasColumnType("text"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("DietImageAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.MedicationReminderTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.HasIndex("MedicationId", "ScheduledDate", "ScheduledTime") + .IsUnique(); + + b.ToTable("MedicationReminderTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.NotificationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SourceTaskId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceTaskId") + .IsUnique(); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("NotificationOutbox", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.ReportAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ReportId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ReportId"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("ReportAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany("Consultations") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Consultations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.HasOne("Health.Domain.Entities.Consultation", "Consultation") + .WithMany("Messages") + .HasForeignKey("ConsultationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Conversations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.HasOne("Health.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DeviceTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.HasOne("Health.Domain.Entities.DietRecord", "DietRecord") + .WithMany("FoodItems") + .HasForeignKey("DietRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DietRecord"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DietRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("DoctorProfile") + .HasForeignKey("Health.Domain.Entities.DoctorProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("ExercisePlans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.HasOne("Health.Domain.Entities.ExercisePlan", "Plan") + .WithMany("Items") + .HasForeignKey("PlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Plan"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("FollowUps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("HealthArchive") + .HasForeignKey("Health.Domain.Entities.HealthArchive", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.HasOne("Health.Domain.Entities.HealthArchive", "HealthArchive") + .WithMany("Surgeries") + .HasForeignKey("HealthArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HealthArchive"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("HealthRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Medications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.HasOne("Health.Domain.Entities.Medication", "Medication") + .WithMany("Logs") + .HasForeignKey("MedicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Medication"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("NotificationPreference") + .HasForeignKey("Health.Domain.Entities.NotificationPreference", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Reports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Doctor"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Navigation("FoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Navigation("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Navigation("Surgeries"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Navigation("Consultations"); + + b.Navigation("Conversations"); + + b.Navigation("DeviceTokens"); + + b.Navigation("DietRecords"); + + b.Navigation("DoctorProfile"); + + b.Navigation("ExercisePlans"); + + b.Navigation("FollowUps"); + + b.Navigation("HealthArchive"); + + b.Navigation("HealthRecords"); + + b.Navigation("Medications"); + + b.Navigation("NotificationPreference"); + + b.Navigation("Reports"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.cs new file mode 100644 index 0000000..f3d29e4 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.cs @@ -0,0 +1,946 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AiWriteCommands", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ToolName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Arguments = table.Column(type: "jsonb", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + FilePaths = table.Column(type: "jsonb", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Attempts = table.Column(type: "integer", nullable: false), + AvailableAt = table.Column(type: "timestamp with time zone", nullable: false), + ResultCode = table.Column(type: "integer", nullable: true), + ResultData = table.Column(type: "text", nullable: true), + ResultMessage = table.Column(type: "text", nullable: true), + LastError = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Title = table.Column(type: "text", nullable: true), + Department = table.Column(type: "text", nullable: true), + Phone = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + ProfessionalDirection = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + AvatarUrl = table.Column(type: "text", nullable: true), + Introduction = table.Column(type: "text", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + MedicationId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Dosage = table.Column(type: "text", nullable: true), + ScheduledDate = table.Column(type: "date", nullable: false), + ScheduledTime = table.Column(type: "time without time zone", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Attempts = table.Column(type: "integer", nullable: false), + AvailableAt = table.Column(type: "timestamp with time zone", nullable: false), + LastError = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + SourceTaskId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Payload = table.Column(type: "jsonb", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Attempts = table.Column(type: "integer", nullable: false), + AvailableAt = table.Column(type: "timestamp with time zone", nullable: false), + LastError = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Token = table.Column(type: "text", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + IsRevoked = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + ReportId = table.Column(type: "uuid", nullable: false), + FilePath = table.Column(type: "text", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Attempts = table.Column(type: "integer", nullable: false), + AvailableAt = table.Column(type: "timestamp with time zone", nullable: false), + LastError = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + SourceId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Title = table.Column(type: "text", nullable: false), + Message = table.Column(type: "text", nullable: false), + Severity = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + ActionType = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + ActionTargetId = table.Column(type: "text", nullable: true), + IsRead = table.Column(type: "boolean", nullable: false), + ReadAt = table.Column(type: "timestamp with time zone", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + Phone = table.Column(type: "text", nullable: false), + Code = table.Column(type: "text", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + IsUsed = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + Phone = table.Column(type: "text", nullable: false), + Role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "User"), + DoctorId = table.Column(type: "uuid", nullable: true), + Name = table.Column(type: "text", nullable: true), + Gender = table.Column(type: "text", nullable: true), + BirthDate = table.Column(type: "date", nullable: true), + AvatarUrl = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + DoctorId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "text", nullable: false), + Month = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ClosedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + AgentType = table.Column(type: "text", nullable: false), + Title = table.Column(type: "text", nullable: true), + Summary = table.Column(type: "text", nullable: true), + MessageCount = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Platform = table.Column(type: "text", nullable: false), + PushToken = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + MealType = table.Column(type: "text", nullable: false), + TotalCalories = table.Column(type: "integer", nullable: true), + HealthScore = table.Column(type: "integer", nullable: true), + RecordedAt = table.Column(type: "date", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + DoctorId = table.Column(type: "uuid", nullable: true), + Name = table.Column(type: "text", nullable: true), + Title = table.Column(type: "text", nullable: true), + Department = table.Column(type: "text", nullable: true), + Hospital = table.Column(type: "text", nullable: true), + AvatarUrl = table.Column(type: "text", nullable: true), + IsOnline = table.Column(type: "boolean", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + StartDate = table.Column(type: "date", nullable: false), + EndDate = table.Column(type: "date", nullable: false), + ReminderTime = table.Column(type: "time without time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "text", nullable: false), + DoctorName = table.Column(type: "text", nullable: true), + Department = table.Column(type: "text", nullable: true), + ScheduledAt = table.Column(type: "timestamp with time zone", nullable: false), + Notes = table.Column(type: "text", nullable: true), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Diagnosis = table.Column(type: "text", nullable: true), + SurgeryType = table.Column(type: "text", nullable: true), + SurgeryDate = table.Column(type: "date", nullable: true), + Allergies = table.Column>(type: "text[]", nullable: false), + DietRestrictions = table.Column>(type: "text[]", nullable: false), + ChronicDiseases = table.Column>(type: "text[]", nullable: false), + FamilyHistory = table.Column(type: "text", nullable: true), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + MetricType = table.Column(type: "text", nullable: false), + Systolic = table.Column(type: "integer", nullable: true), + Diastolic = table.Column(type: "integer", nullable: true), + Value = table.Column(type: "numeric", nullable: true), + Unit = table.Column(type: "text", nullable: true), + Source = table.Column(type: "text", nullable: false), + IsAbnormal = table.Column(type: "boolean", nullable: false), + RecordedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Dosage = table.Column(type: "text", nullable: true), + Frequency = table.Column(type: "text", nullable: false), + TimeOfDay = table.Column>(type: "time[]", nullable: false), + StartDate = table.Column(type: "date", nullable: true), + EndDate = table.Column(type: "date", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + Source = table.Column(type: "text", nullable: false), + Notes = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + MedicationReminder = table.Column(type: "boolean", nullable: false), + FollowUpReminder = table.Column(type: "boolean", nullable: false), + DoctorReply = table.Column(type: "boolean", nullable: false), + AbnormalAlert = table.Column(type: "boolean", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + FileUrl = table.Column(type: "text", nullable: false), + FileType = table.Column(type: "text", nullable: false), + Category = table.Column(type: "text", nullable: false), + AiSummary = table.Column(type: "text", nullable: true), + AiIndicators = table.Column(type: "jsonb", nullable: true), + Status = table.Column(type: "text", nullable: false), + Severity = table.Column(type: "text", nullable: true), + DoctorComment = table.Column(type: "text", nullable: true), + DoctorRecommendation = table.Column(type: "text", nullable: true), + DoctorName = table.Column(type: "text", nullable: true), + ReviewedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + ConsultationId = table.Column(type: "uuid", nullable: false), + SenderType = table.Column(type: "text", nullable: false), + SenderName = table.Column(type: "text", nullable: true), + Content = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "text", nullable: false), + Content = table.Column(type: "text", nullable: false), + Intent = table.Column(type: "text", nullable: true), + MetadataJson = table.Column(type: "jsonb", nullable: true), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + DietRecordId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Portion = table.Column(type: "text", nullable: true), + Calories = table.Column(type: "integer", nullable: true), + ProteinGrams = table.Column(type: "numeric", nullable: true), + CarbsGrams = table.Column(type: "numeric", nullable: true), + FatGrams = table.Column(type: "numeric", nullable: true), + Warning = table.Column(type: "text", nullable: true), + SortOrder = table.Column(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(type: "uuid", nullable: false), + PlanId = table.Column(type: "uuid", nullable: false), + ScheduledDate = table.Column(type: "date", nullable: false), + ExerciseType = table.Column(type: "text", nullable: false), + DurationMinutes = table.Column(type: "integer", nullable: false), + IsCompleted = table.Column(type: "boolean", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsRestDay = table.Column(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(type: "uuid", nullable: false), + HealthArchiveId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Date = table.Column(type: "date", nullable: true), + SortOrder = table.Column(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(type: "uuid", nullable: false), + MedicationId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "text", nullable: false), + ScheduledTime = table.Column(type: "time without time zone", nullable: false), + ConfirmedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(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 }); + } + + /// + 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"); + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..a3cf2c2 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,1440 @@ +// +using System; +using System.Collections.Generic; +using Health.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId"); + + b.ToTable("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsultationId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderName") + .HasColumnType("text"); + + b.Property("SenderType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConsultationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("ConsultationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UpdatedAt") + .IsDescending(false, true); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Intent") + .HasColumnType("text"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "CreatedAt"); + + b.ToTable("ConversationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Calories") + .HasColumnType("integer"); + + b.Property("CarbsGrams") + .HasColumnType("numeric"); + + b.Property("DietRecordId") + .HasColumnType("uuid"); + + b.Property("FatGrams") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Portion") + .HasColumnType("text"); + + b.Property("ProteinGrams") + .HasColumnType("numeric"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Warning") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DietRecordId"); + + b.ToTable("DietFoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HealthScore") + .HasColumnType("integer"); + + b.Property("MealType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("date"); + + b.Property("TotalCalories") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("DietRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("Introduction") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfessionalDirection") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Doctors"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Hospital") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ReminderTime") + .HasColumnType("time without time zone"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "StartDate", "EndDate"); + + b.ToTable("ExercisePlans"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationMinutes") + .HasColumnType("integer"); + + b.Property("ExerciseType") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsRestDay") + .HasColumnType("boolean"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("PlanId", "ScheduledDate") + .IsUnique(); + + b.ToTable("ExercisePlanItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Allergies") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection>("ChronicDiseases") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Diagnosis") + .HasColumnType("text"); + + b.PrimitiveCollection>("DietRestrictions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FamilyHistory") + .HasColumnType("text"); + + b.Property("SurgeryDate") + .HasColumnType("date"); + + b.Property("SurgeryType") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("HealthArchives"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HealthArchiveId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("HealthArchiveId", "SortOrder"); + + b.ToTable("HealthArchiveSurgeries", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Diastolic") + .HasColumnType("integer"); + + b.Property("IsAbnormal") + .HasColumnType("boolean"); + + b.Property("MetricType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Systolic") + .HasColumnType("integer"); + + b.Property("Unit") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "MetricType"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.PrimitiveCollection>("TimeOfDay") + .IsRequired() + .HasColumnType("time[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("Medications"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MedicationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("MedicationLogs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AbnormalAlert") + .HasColumnType("boolean"); + + b.Property("DoctorReply") + .HasColumnType("boolean"); + + b.Property("FollowUpReminder") + .HasColumnType("boolean"); + + b.Property("MedicationReminder") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NotificationPreferences"); + }); + + modelBuilder.Entity("Health.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiIndicators") + .HasColumnType("jsonb"); + + b.Property("AiSummary") + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorComment") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("DoctorRecommendation") + .HasColumnType("text"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Gender") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("User"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Health.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionTargetId") + .HasColumnType("text"); + + b.Property("ActionType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceId") + .IsUnique(); + + b.HasIndex("UserId", "IsDeleted", "IsRead", "CreatedAt"); + + b.ToTable("UserNotifications", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Phone", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("VerificationCodes"); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arguments") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status", "ExpiresAt"); + + b.ToTable("AiWriteCommands", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.DietImageAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePaths") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ResultCode") + .HasColumnType("integer"); + + b.Property("ResultData") + .HasColumnType("text"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("DietImageAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.MedicationReminderTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.HasIndex("MedicationId", "ScheduledDate", "ScheduledTime") + .IsUnique(); + + b.ToTable("MedicationReminderTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.NotificationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SourceTaskId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceTaskId") + .IsUnique(); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("NotificationOutbox", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.ReportAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ReportId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ReportId"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("ReportAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany("Consultations") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Consultations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.HasOne("Health.Domain.Entities.Consultation", "Consultation") + .WithMany("Messages") + .HasForeignKey("ConsultationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Conversations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.HasOne("Health.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DeviceTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.HasOne("Health.Domain.Entities.DietRecord", "DietRecord") + .WithMany("FoodItems") + .HasForeignKey("DietRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DietRecord"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DietRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("DoctorProfile") + .HasForeignKey("Health.Domain.Entities.DoctorProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("ExercisePlans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.HasOne("Health.Domain.Entities.ExercisePlan", "Plan") + .WithMany("Items") + .HasForeignKey("PlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Plan"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("FollowUps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("HealthArchive") + .HasForeignKey("Health.Domain.Entities.HealthArchive", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.HasOne("Health.Domain.Entities.HealthArchive", "HealthArchive") + .WithMany("Surgeries") + .HasForeignKey("HealthArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HealthArchive"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("HealthRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Medications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.HasOne("Health.Domain.Entities.Medication", "Medication") + .WithMany("Logs") + .HasForeignKey("MedicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Medication"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("NotificationPreference") + .HasForeignKey("Health.Domain.Entities.NotificationPreference", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Reports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Doctor"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Navigation("FoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Navigation("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Navigation("Surgeries"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Navigation("Consultations"); + + b.Navigation("Conversations"); + + b.Navigation("DeviceTokens"); + + b.Navigation("DietRecords"); + + b.Navigation("DoctorProfile"); + + b.Navigation("ExercisePlans"); + + b.Navigation("FollowUps"); + + b.Navigation("HealthArchive"); + + b.Navigation("HealthRecords"); + + b.Navigation("Medications"); + + b.Navigation("NotificationPreference"); + + b.Navigation("Reports"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/data_seeder.cs b/backend/src/Health.Infrastructure/Data/data_seeder.cs deleted file mode 100644 index 5212481..0000000 --- a/backend/src/Health.Infrastructure/Data/data_seeder.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Health.Infrastructure.Data; - -/// -/// 数据库种子数据 -/// -public static class DataSeeder -{ - public static async Task SeedAsync(AppDbContext db) - { - // 医生不再使用种子数据,通过注册创建 - await Task.CompletedTask; - } -} diff --git a/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs b/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs deleted file mode 100644 index decfaf0..0000000 --- a/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs +++ /dev/null @@ -1,177 +0,0 @@ -using Health.Domain.Entities; -using Health.Domain.Enums; -using Microsoft.Extensions.Configuration; - -namespace Health.Infrastructure.Data; - -/// -/// 开发环境测试数据填充。生产环境不要调用! -/// 开关:DEVDATA_ENABLED=true 才会执行 -/// -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}"); - } -} diff --git a/backend/src/Health.Infrastructure/Diets/DietImageAnalysisQueue.cs b/backend/src/Health.Infrastructure/Diets/DietImageAnalysisQueue.cs index 2605738..228b957 100644 --- a/backend/src/Health.Infrastructure/Diets/DietImageAnalysisQueue.cs +++ b/backend/src/Health.Infrastructure/Diets/DietImageAnalysisQueue.cs @@ -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 TryTakeAsync(CancellationToken ct) diff --git a/backend/src/Health.Infrastructure/Medications/MedicationReminderQueue.cs b/backend/src/Health.Infrastructure/Medications/MedicationReminderQueue.cs index 265eaad..974c037 100644 --- a/backend/src/Health.Infrastructure/Medications/MedicationReminderQueue.cs +++ b/backend/src/Health.Infrastructure/Medications/MedicationReminderQueue.cs @@ -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 TryTakeAsync(CancellationToken ct) diff --git a/backend/src/Health.Infrastructure/Reports/EfReportAnalysisQueue.cs b/backend/src/Health.Infrastructure/Reports/EfReportAnalysisQueue.cs index 16130eb..9b3b6cf 100644 --- a/backend/src/Health.Infrastructure/Reports/EfReportAnalysisQueue.cs +++ b/backend/src/Health.Infrastructure/Reports/EfReportAnalysisQueue.cs @@ -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 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); + } + } } } diff --git a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs index 6b4f049..3685d4e 100644 --- a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs +++ b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs @@ -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 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 _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); } diff --git a/backend/src/Health.WebApi/Data/AppDbContextFactory.cs b/backend/src/Health.WebApi/Data/AppDbContextFactory.cs new file mode 100644 index 0000000..506bdac --- /dev/null +++ b/backend/src/Health.WebApi/Data/AppDbContextFactory.cs @@ -0,0 +1,52 @@ +using Health.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Health.WebApi.Data; + +/// +/// 设计时 DbContext 工厂——仅供 dotnet-ef(migrations / database update)使用。 +/// 不会启动 Web 服务或后台 Worker,只负责构建 AppDbContext 的连接配置。 +/// 连接串优先级:DB_CONNECTION 环境变量 → backend/.env 文件 → 本地默认。 +/// +public sealed class AppDbContextFactory : IDesignTimeDbContextFactory +{ + 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() + .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; + } +} diff --git a/backend/src/Health.WebApi/Health.WebApi.csproj b/backend/src/Health.WebApi/Health.WebApi.csproj index 87dd5a1..dae703e 100644 --- a/backend/src/Health.WebApi/Health.WebApi.csproj +++ b/backend/src/Health.WebApi/Health.WebApi.csproj @@ -10,6 +10,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/backend/src/Health.WebApi/Middleware/exception_middleware.cs b/backend/src/Health.WebApi/Middleware/exception_middleware.cs index e8b5ba9..c7254bc 100644 --- a/backend/src/Health.WebApi/Middleware/exception_middleware.cs +++ b/backend/src/Health.WebApi/Middleware/exception_middleware.cs @@ -16,6 +16,14 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger // ---- 数据库 ---- builder.Services.AddDbContext(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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -144,7 +148,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); // ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)---- builder.Services.AddHttpClient(client => @@ -167,6 +170,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); // ---- 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(); - await db.Database.EnsureCreatedAsync(); - await scope.ServiceProvider.GetRequiredService().ApplyAsync(); - await DataSeeder.SeedAsync(db); - await DevDataSeeder.SeedIfEnabled(db, app.Configuration); + if (app.Configuration.GetValue("AUTO_MIGRATE", true)) + await db.Database.MigrateAsync(); } // ---- 注册 API 端点 ---- diff --git a/backend/tests/Health.Tests/ai_agent_tests.cs b/backend/tests/Health.Tests/ai_agent_tests.cs deleted file mode 100644 index 706dfce..0000000 --- a/backend/tests/Health.Tests/ai_agent_tests.cs +++ /dev/null @@ -1,287 +0,0 @@ -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using Health.Domain.Entities; -using Health.Domain.Enums; -using Health.Infrastructure.AI; -using Health.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; - -namespace Health.Tests; - -/// -/// AI 智能体集成测试 — 模拟真实用户对话,验证 Tool Calling 与数据库写入 -/// 运行前需确保后端已启动: dotnet run --project src/Health.WebApi -/// -public class AiAgentTests -{ - private static readonly HttpClient Http = new() - { - BaseAddress = new Uri("http://localhost:5000"), - Timeout = TimeSpan.FromSeconds(120) - }; - - private static readonly JsonSerializerOptions JsonOpts = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true - }; - - // ==================== PromptManager 单元测试 ==================== - - [Fact] - public void PromptManager_Default_Should_Contain_HeartKeywords() - { - var pm = new PromptManager(); - var prompt = pm.GetSystemPrompt(AgentType.Default); - Assert.Contains("心脏", prompt); - Assert.Contains("健康", prompt); - } - - [Fact] - public void PromptManager_Consultation_Should_Contain_TriageRules() - { - var pm = new PromptManager(); - var prompt = pm.GetSystemPrompt(AgentType.Consultation); - Assert.Contains("剧烈胸痛", prompt); - Assert.Contains("呼吸困难", prompt); - Assert.Contains("160/100", prompt); - } - - [Fact] - public void PromptManager_Health_Should_Contain_NormalRanges() - { - var pm = new PromptManager(); - var prompt = pm.GetSystemPrompt(AgentType.Health); - Assert.Contains("139", prompt); // 收缩压上界 - Assert.Contains("89", prompt); // 舒张压下界 - Assert.Contains("100", prompt); // 心率上界 - } - - [Fact] - public void PromptManager_Diet_Should_Contain_VlmKeywords() - { - var pm = new PromptManager(); - var prompt = pm.GetSystemPrompt(AgentType.Diet); - Assert.Contains("VLM", prompt); - Assert.Contains("能不能吃", prompt); - } - - [Fact] - public void PromptManager_Medication_Should_Contain_ParseRules() - { - var pm = new PromptManager(); - var prompt = pm.GetSystemPrompt(AgentType.Medication); - Assert.Contains("药名", prompt); - Assert.Contains("剂量", prompt); - } - - [Fact] - public void PromptManager_Report_Should_Contain_Disclaimer() - { - var pm = new PromptManager(); - var prompt = pm.GetSystemPrompt(AgentType.Report); - Assert.Contains("AI预解读", prompt); - Assert.Contains("医生确认", prompt); - } - - [Fact] - public void PromptManager_Exercise_Should_Contain_RehabKeywords() - { - var pm = new PromptManager(); - var prompt = pm.GetSystemPrompt(AgentType.Exercise); - Assert.Contains("心脏康复", prompt); - Assert.Contains("循序渐进", prompt); - } - - [Fact] - public void PromptManager_All_Agents_Should_Return_NonEmpty() - { - var pm = new PromptManager(); - foreach (AgentType agent in Enum.GetValues()) - { - var prompt = pm.GetSystemPrompt(agent); - Assert.False(string.IsNullOrWhiteSpace(prompt), $"{agent} 的 SystemPrompt 为空"); - } - } - - // ==================== DeepSeekClient 连通性测试 ==================== - - [Fact] - public void DeepSeekClient_ApiKey_Should_Be_Configured() - { - // 仅验证 API Key 已配置(实际调用通过集成测试验证) - CreateDeepSeekClient(); - var config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); - var apiKey = config["DEEPSEEK_API_KEY"] ?? ""; - Assert.False(string.IsNullOrEmpty(apiKey) || apiKey.StartsWith("sk-your-key"), - "DEEPSEEK_API_KEY 未配置或为占位符,请在 backend/.env 中设置真实 Key"); - } - - // ==================== AI 对话 + Tool Calling 集成测试 ==================== - - [Fact] - public async Task HealthAgent_RecordBloodPressure_Should_SaveToDb() - { - // 先登录获取 token - var token = await LoginAsync("13800000001"); - - // 发送对话消息触发 Tool Calling - var events = await SendChatMessage(token, "health", "我刚刚测了血压,138/86"); - var toolResults = events.Where(e => e.Action == "tool_result").ToList(); - - Assert.NotEmpty(toolResults); - } - - [Fact] - public async Task HealthAgent_RecordHeartRate_Should_SaveToDb() - { - var token = await LoginAsync("13800000001"); - var events = await SendChatMessage(token, "health", "心率72"); - var toolResults = events.Where(e => e.Action == "tool_result").ToList(); - - Assert.NotEmpty(toolResults); - } - - [Fact] - public async Task MedicationAgent_Query_Should_Return_Medications() - { - var token = await LoginAsync("13800000001"); - var events = await SendChatMessage(token, "medication", "我现在在吃什么药?"); - - var answers = events.Where(e => e.Action == "answer") - .Select(e => e.Data?.ToString() ?? ""); - - var fullResponse = string.Join("", answers); - Assert.NotEmpty(fullResponse); - // 应该提到阿司匹林或阿托伐他汀 - Assert.True(fullResponse.Contains("阿司匹林") || fullResponse.Contains("阿托伐他汀") || - fullResponse.Contains("Aspirin")); - } - - [Fact] - public async Task ConsultationAgent_SymptomCheck_Should_AskFollowUp() - { - var token = await LoginAsync("13800000001"); - var events = await SendChatMessage(token, "consultation", "最近胸口有点不舒服"); - - var answers = events.Where(e => e.Action == "answer") - .Select(e => e.Data?.ToString() ?? ""); - - var fullResponse = string.Join("", answers); - Assert.NotEmpty(fullResponse); - } - - [Fact] - public async Task DefaultAgent_GeneralQuestion_Should_Respond() - { - var token = await LoginAsync("13800000001"); - var events = await SendChatMessage(token, "default", "你好,介绍一下你自己"); - - var answers = events.Where(e => e.Action == "answer") - .Select(e => e.Data?.ToString() ?? ""); - - var fullResponse = string.Join("", answers); - Assert.NotEmpty(fullResponse); - Assert.True(fullResponse.Length > 10, "默认 Agent 应返回有效回复"); - } - - // ==================== 辅助方法 ==================== - - /// - /// 发送验证码 + 登录,返回 accessToken - /// - private static async Task LoginAsync(string phone) - { - // 发送验证码 - var smsPayload = JsonSerializer.Serialize(new { phone }, JsonOpts); - var smsResp = await Http.PostAsync("/api/auth/send-sms", - new StringContent(smsPayload, Encoding.UTF8, "application/json")); - var smsJson = JsonDocument.Parse(await smsResp.Content.ReadAsStringAsync()); - var devCode = smsJson.RootElement.GetProperty("data").GetProperty("devCode").GetString()!; - - // 登录 - var loginPayload = JsonSerializer.Serialize(new { phone, smsCode = devCode }, JsonOpts); - var loginResp = await Http.PostAsync("/api/auth/login", - new StringContent(loginPayload, Encoding.UTF8, "application/json")); - var loginJson = JsonDocument.Parse(await loginResp.Content.ReadAsStringAsync()); - - return loginJson.RootElement.GetProperty("data").GetProperty("accessToken").GetString()!; - } - - /// - /// 向指定 Agent 发送消息,返回所有 SSE 事件 - /// - private static async Task> SendChatMessage(string token, string agentType, string message) - { - var url = $"/api/ai/{agentType}/chat?message={Uri.EscapeDataString(message)}&token={Uri.EscapeDataString(token)}"; - - Http.DefaultRequestHeaders.Authorization = null; // token 走 query string - var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); - - var events = new List(); - using var stream = await response.Content.ReadAsStreamAsync(); - using var reader = new StreamReader(stream); - - string? line; - while ((line = await reader.ReadLineAsync()) != null) - { - if (string.IsNullOrWhiteSpace(line)) continue; - if (!line.StartsWith("data: ")) continue; - - var data = line["data: ".Length..]; - if (data == "[DONE]") break; - - try - { - var parsed = JsonSerializer.Deserialize(data, JsonOpts); - if (parsed != null) events.Add(parsed); - } - catch { /* 跳过无法解析的 chunk */ } - } - - return events; - } - - /// - /// 创建 DeepSeekClient(读取 .env 配置) - /// - private static DeepSeekClient CreateDeepSeekClient() - { - // 从测试输出目录向上 5 级找到 backend/.env - // bin/Debug/net10.0 → Health.Tests → tests → backend - var baseDir = AppContext.BaseDirectory; - var envPath = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", ".env")); - - if (File.Exists(envPath)) - { - foreach (var envLine in File.ReadAllLines(envPath)) - { - var trimmed = envLine.Trim(); - if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue; - var eqIdx = trimmed.IndexOf('='); - if (eqIdx <= 0) continue; - var key = trimmed[..eqIdx].Trim(); - var value = trimmed[(eqIdx + 1)..].Trim(); - Environment.SetEnvironmentVariable(key, value); - } - } - - var config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); - var apiKey = config["DEEPSEEK_API_KEY"] ?? ""; - var baseUrl = (config["DEEPSEEK_BASE_URL"] ?? "https://api.deepseek.com/v1").TrimEnd('/') + "/"; - var httpClient = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(120) }; - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); - return new DeepSeekClient(httpClient, config); - } -} - -/// SSE 事件模型 -public class SseEvent -{ - public string? Action { get; set; } - public object? Data { get; set; } - public string? Message { get; set; } -} diff --git a/health_app/lib/pages/medication/medication_checkin_page.dart b/health_app/lib/pages/medication/medication_checkin_page.dart index b67788d..00286f6 100644 --- a/health_app/lib/pages/medication/medication_checkin_page.dart +++ b/health_app/lib/pages/medication/medication_checkin_page.dart @@ -5,6 +5,7 @@ import '../../core/navigation_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/auth_provider.dart'; import '../../providers/data_providers.dart'; +import '../../widgets/app_error_state.dart'; class MedicationCheckInPage extends ConsumerStatefulWidget { final String? medId; // 可选:指定药品,null 显示全部 @@ -43,6 +44,9 @@ class _MedicationCheckInPageState extends ConsumerState { if (snap.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator(color: AppTheme.primary)); } + if (snap.hasError) { + return AppErrorState(title: '用药信息加载失败', onRetry: _load); + } final reminders = snap.data ?? []; if (reminders.isEmpty) { return Center( diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index bb7e2bb..a923b0c 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -5,6 +5,7 @@ import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; import '../../widgets/app_empty_state.dart'; +import '../../widgets/app_future_view.dart'; import '../../widgets/common_widgets.dart'; class MedicationListPage extends ConsumerStatefulWidget { @@ -85,10 +86,11 @@ class _MedicationListPageState extends ConsumerState ), body: Container( decoration: const BoxDecoration(gradient: AppColors.bgGradient), - child: FutureBuilder>>( + child: AppFutureView>>( future: _future, - builder: (ctx, snap) { - final list = snap.data ?? []; + onRetry: _load, + errorTitle: '用药信息加载失败', + onData: (ctx, list) { if (list.isEmpty) { return const AppEmptyState( icon: Icons.medication_outlined, diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 767fadc..25e0e07 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -6,6 +6,8 @@ import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; import '../widgets/common_widgets.dart'; +import '../widgets/app_error_state.dart'; +import '../widgets/app_future_view.dart'; /// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除) class DietRecordListPage extends ConsumerStatefulWidget { @@ -722,10 +724,11 @@ class _ExercisePlanPageState extends ConsumerState { ), body: Container( decoration: const BoxDecoration(gradient: AppColors.bgGradient), - child: FutureBuilder>>( + child: AppFutureView>>( future: _future, - builder: (ctx, snap) { - final plans = snap.data ?? []; + onRetry: _load, + errorTitle: '运动计划加载失败', + onData: (ctx, plans) { if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建'); return ListView.builder( padding: const EdgeInsets.all(12), @@ -1083,6 +1086,9 @@ class _FollowUpListPageState extends ConsumerState { child: CircularProgressIndicator(color: AppTheme.primaryLight), ); } + if (snap.hasError) { + return AppErrorState(title: '随访加载失败', onRetry: _load); + } final list = snap.data ?? []; if (list.isEmpty) { return Center( @@ -1288,10 +1294,18 @@ class _HealthArchivePageState extends ConsumerState { final a = await srv.getHealthArchive(); if (a != null && mounted) { _diagnosisCtrl.text = a['diagnosis'] ?? ''; - final st = a['surgeryType'] as String?; - final sd = a['surgeryDate'] as String?; - if (st != null && st.isNotEmpty) { - _surgeries.add({'type': st, 'date': sd ?? ''}); + final surgeries = a['surgeries'] as List?; + if (surgeries != null && surgeries.isNotEmpty) { + _surgeries.addAll(surgeries.whereType().map((item) => { + 'type': item['type']?.toString() ?? '', + 'date': item['date']?.toString() ?? '', + })); + } else { + final st = a['surgeryType'] as String?; + final sd = a['surgeryDate'] as String?; + if (st != null && st.isNotEmpty) { + _surgeries.add({'type': st, 'date': sd ?? ''}); + } } _allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? ''; _chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? ''; @@ -1333,18 +1347,19 @@ class _HealthArchivePageState extends ConsumerState { gender: _genderCtrl.text, birthDate: _birthDate, ); - final surgeryType = _surgeries - .map((s) => s['type'] ?? '') - .where((s) => s.isNotEmpty) - .join('、'); - final surgeryDate = _surgeries - .map((s) => s['date'] ?? '') - .where((s) => s.isNotEmpty) - .join('、'); + final surgeries = _surgeries + .where((s) => (s['type'] ?? '').trim().isNotEmpty) + .map((s) => { + 'type': s['type']!.trim(), + 'date': (s['date'] ?? '').trim().isEmpty ? null : s['date']!.trim(), + }) + .toList(); + final firstSurgery = surgeries.isEmpty ? null : surgeries.first; await srv.updateHealthArchive({ 'diagnosis': _diagnosisCtrl.text, - 'surgeryType': surgeryType, - 'surgeryDate': surgeryDate, + 'surgeryType': firstSurgery?['type'], + 'surgeryDate': firstSurgery?['date'], + 'surgeries': surgeries, 'allergies': _allergiesCtrl.text .split('、') .where((s) => s.isNotEmpty) @@ -1359,6 +1374,7 @@ class _HealthArchivePageState extends ConsumerState { .toList(), 'familyHistory': _familyCtrl.text, }); + await ref.read(authProvider.notifier).refreshProfile(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/health_app/lib/widgets/app_error_state.dart b/health_app/lib/widgets/app_error_state.dart new file mode 100644 index 0000000..34af0f9 --- /dev/null +++ b/health_app/lib/widgets/app_error_state.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import '../core/app_colors.dart'; + +/// 统一的"加载失败"状态——与 AppEmptyState 风格一致,区别在于明确表达"出错了,可重试", +/// 避免把网络/服务失败误显示成"暂无数据"。 +class AppErrorState extends StatelessWidget { + final String title; + final String? subtitle; + final VoidCallback? onRetry; + + const AppErrorState({ + super.key, + this.title = '加载失败', + this.subtitle = '网络异常或服务暂时不可用,请稍后重试', + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: AppColors.softGlassGradient, + borderRadius: BorderRadius.circular(40), + border: Border.all(color: AppColors.borderLight), + boxShadow: AppColors.cardShadowLight, + ), + child: const Icon( + Icons.cloud_off_outlined, + size: 34, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 18), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + if (subtitle != null) ...[ + const SizedBox(height: 6), + Text( + subtitle!, + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + if (onRetry != null) ...[ + const SizedBox(height: 20), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('重试'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primaryDark, + side: const BorderSide(color: AppColors.borderLight), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/health_app/lib/widgets/app_future_view.dart b/health_app/lib/widgets/app_future_view.dart new file mode 100644 index 0000000..820da01 --- /dev/null +++ b/health_app/lib/widgets/app_future_view.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import '../core/app_colors.dart'; +import 'app_error_state.dart'; + +/// 基于 FutureBuilder 的四态封装:统一处理"加载中 / 失败", +/// "空数据 / 有数据"交由 onData 自行判断(不同页面空态文案不同)。 +/// +/// onData 拿到非空数据后,自行判断 isEmpty 决定显示空态还是列表。 +class AppFutureView extends StatelessWidget { + final Future? future; + final Widget Function(BuildContext context, T data) onData; + final VoidCallback? onRetry; + final Widget? loading; + final String? errorTitle; + + const AppFutureView({ + super.key, + required this.future, + required this.onData, + this.onRetry, + this.loading, + this.errorTitle, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: future, + builder: (ctx, snap) { + // 失败优先于其它判断,避免把错误显示成"暂无数据" + if (snap.hasError) { + return AppErrorState( + title: errorTitle ?? '加载失败', + onRetry: onRetry, + ); + } + // 首次加载、尚无数据 → 转圈 + if (snap.connectionState == ConnectionState.waiting && !snap.hasData) { + return loading ?? + const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ); + } + if (!snap.hasData) { + return loading ?? + const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ); + } + return onData(ctx, snap.data as T); + }, + ); + } +}