feat: 引入 EF Core Migrations + 后台任务/校验修复 + 前端四态

后端
- 改用 EF Core Migrations(InitialCreate),Program.cs 用 MigrateAsync + AUTO_MIGRATE 开关 + 显式 MigrationsAssembly;移除 EnsureCreated、删除手写 DatabaseSchemaMigrator
- 修复后台任务永久卡 Processing:三个队列对超时且达上限的任务原子标记 Failed
- 修复报告重试失效:基础设施异常向上抛激活 RetryAsync,停机取消不计失败
- 修复 AI 用药天数 off-by-one(结束日为包含式)
- AI 报告日志不再记录 VLM 健康正文
- 新增统一输入校验(ValidationException + 中间件映射 400):血压/心率/血糖/血氧/体重范围、药名/日期/服药时间去重、饮食热量与评分、运动时长上限
- 删除健康数据 AI 录入的影子直写路径,统一走 Service 校验

前端
- 新增 AppErrorState / AppFutureView,用药列表、服药打卡、运动计划、随访列表改为 加载/失败/空/数据 四态,失败可重试

瘦身
- 删除过时的 DataSeeder / DevDataSeeder 及调用
- 删除依赖实时服务的 ai_agent_tests 集成测试
This commit is contained in:
MingNian
2026-06-21 21:04:40 +08:00
parent aa44d6f0f0
commit b57d0d16f4
30 changed files with 4377 additions and 752 deletions

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,17 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Application.Notifications;
using System.Security.Cryptography;
using System.Text;
namespace Health.Application.HealthRecords;
public sealed class HealthRecordService(IHealthRecordRepository records) : IHealthRecordService
public sealed class HealthRecordService(
IHealthRecordRepository records,
IUserNotificationProducer? notifications = null) : IHealthRecordService
{
private readonly IHealthRecordRepository _records = records;
private readonly IUserNotificationProducer? _notifications = notifications;
public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct)
{
@@ -20,6 +26,8 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
{
HealthRecordRules.Validate(request);
var record = new HealthRecord
{
Id = Guid.NewGuid(),
@@ -37,6 +45,7 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
await _records.AddAsync(record, ct);
await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
return record.Id;
}
@@ -45,6 +54,8 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
var record = await _records.GetOwnedAsync(userId, id, ct);
if (record == null) return false;
HealthRecordRules.Validate(request);
record.MetricType = request.Type;
record.Systolic = request.Systolic;
record.Diastolic = request.Diastolic;
@@ -55,6 +66,7 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
return true;
}
@@ -98,9 +110,9 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
{
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 };
var days = period switch { 7 => 7, 30 => 30, 90 => 90, 365 => 365, _ => 7 };
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
}
public static HealthRecordDto ToDto(HealthRecord record) => new(
@@ -113,4 +125,42 @@ public sealed class HealthRecordService(IHealthRecordRepository records) : IHeal
record.Source.ToString(),
record.IsAbnormal,
record.RecordedAt);
private async Task EnqueueAbnormalNotificationAsync(HealthRecord record, CancellationToken ct)
{
if (!record.IsAbnormal || _notifications == null) return;
var (title, message, severity, target) = record.MetricType switch
{
HealthMetricType.BloodPressure when record.Systolic >= 140 || record.Diastolic >= 90 =>
("血压偏高提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg高于参考范围。建议休息后复测如伴明显不适请及时就医。", "warning", "blood_pressure"),
HealthMetricType.BloodPressure =>
("血压偏低提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg低于参考范围。请留意头晕、乏力等不适必要时及时就医。", "warning", "blood_pressure"),
HealthMetricType.HeartRate when record.Value > 100 =>
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
HealthMetricType.HeartRate =>
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
HealthMetricType.Glucose when record.Value >= 7.0m =>
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
HealthMetricType.Glucose =>
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L低于参考范围。请及时关注身体状况。", "critical", "glucose"),
HealthMetricType.SpO2 =>
("血氧偏低提醒", $"本次血氧为 {record.Value:0.#}%,低于参考范围。建议立即复测;如持续偏低或伴呼吸不适,请及时就医。", "critical", "spo2"),
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
};
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
await _notifications.EnqueueAsync(
record.UserId,
sourceId,
new NotificationMessage("HealthMetricAlert", title, message, severity, "health", target),
ct);
}
private static Guid DeterministicGuid(string value)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return new Guid(hash.AsSpan(0, 16));
}
}

View File

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

View File

@@ -0,0 +1,7 @@
namespace Health.Domain;
/// <summary>
/// 业务输入校验失败异常——由 Application 层规则抛出,
/// 中间件统一映射为 400 / code 40001message 可安全展示给用户。
/// </summary>
public sealed class ValidationException(string message) : Exception(message);

View File

@@ -18,13 +18,11 @@ public static class HealthDataAgentHandler
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId, IHealthRecordService? healthRecords = null)
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
{
return toolName switch
{
"record_health_data" => healthRecords == null
? await ExecuteRecordHealthData(db, userId, args)
: await ExecuteRecordHealthData(healthRecords, userId, args),
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
@@ -62,63 +60,6 @@ public static class HealthDataAgentHandler
};
}
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var record = new HealthRecord
{
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
};
switch (type)
{
case "blood_pressure":
record.MetricType = HealthMetricType.BloodPressure;
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
record.Unit = "mmHg";
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
break;
case "heart_rate":
record.MetricType = HealthMetricType.HeartRate;
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
record.Unit = "次/分";
record.IsAbnormal = record.Value > 100 || record.Value < 60;
break;
case "glucose":
record.MetricType = HealthMetricType.Glucose;
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
record.Unit = "mmol/L";
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
break;
case "spo2":
record.MetricType = HealthMetricType.SpO2;
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
record.Unit = "%";
record.IsAbnormal = record.Value <= 94;
break;
case "weight":
record.MetricType = HealthMetricType.Weight;
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
record.Unit = "kg";
record.IsAbnormal = false;
break;
default:
return new { success = false, message = $"未知指标类型: {type}" };
}
db.HealthRecords.Add(record);
await db.SaveChangesAsync();
var valStr = record.MetricType switch
{
HealthMetricType.BloodPressure => $"{record.Systolic}/{record.Diastolic}",
_ => record.Value?.ToString() ?? ""
};
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
}
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{
return type switch

View File

@@ -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,

View File

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

View File

@@ -1,166 +0,0 @@
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Data;
public sealed class DatabaseSchemaMigrator(AppDbContext db, ILogger<DatabaseSchemaMigrator> logger)
{
private readonly AppDbContext _db = db;
private readonly ILogger<DatabaseSchemaMigrator> _logger = logger;
public async Task ApplyAsync(CancellationToken ct = default)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
await _db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "__AppSchemaMigrations" (
"Id" varchar(128) PRIMARY KEY,
"AppliedAt" timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS "AiWriteCommands" (
"Id" uuid PRIMARY KEY,
"UserId" uuid NOT NULL,
"ToolName" varchar(64) NOT NULL,
"Arguments" jsonb NOT NULL,
"Status" varchar(32) NOT NULL,
"ExpiresAt" timestamptz NOT NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_AiWriteCommands_UserId_Status_ExpiresAt"
ON "AiWriteCommands" ("UserId", "Status", "ExpiresAt");
CREATE TABLE IF NOT EXISTS "ReportAnalysisTasks" (
"Id" uuid PRIMARY KEY,
"ReportId" uuid NOT NULL,
"FilePath" text NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_Status_AvailableAt"
ON "ReportAnalysisTasks" ("Status", "AvailableAt");
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_ReportId"
ON "ReportAnalysisTasks" ("ReportId");
CREATE TABLE IF NOT EXISTS "DietImageAnalysisTasks" (
"Id" uuid PRIMARY KEY,
"FilePaths" jsonb NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"ResultCode" integer NULL,
"ResultData" text NULL,
"ResultMessage" text NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_DietImageAnalysisTasks_Status_AvailableAt"
ON "DietImageAnalysisTasks" ("Status", "AvailableAt");
CREATE TABLE IF NOT EXISTS "MedicationReminderTasks" (
"Id" uuid PRIMARY KEY,
"UserId" uuid NOT NULL,
"MedicationId" uuid NOT NULL,
"Name" text NOT NULL,
"Dosage" text NULL,
"ScheduledDate" date NOT NULL,
"ScheduledTime" time without time zone NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_Status_AvailableAt"
ON "MedicationReminderTasks" ("Status", "AvailableAt");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_MedicationId_ScheduledDate_ScheduledTime"
ON "MedicationReminderTasks" ("MedicationId", "ScheduledDate", "ScheduledTime");
CREATE TABLE IF NOT EXISTS "NotificationOutbox" (
"Id" uuid PRIMARY KEY,
"UserId" uuid NOT NULL,
"SourceTaskId" uuid NOT NULL,
"Type" varchar(64) NOT NULL,
"Payload" jsonb NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_NotificationOutbox_SourceTaskId"
ON "NotificationOutbox" ("SourceTaskId");
CREATE INDEX IF NOT EXISTS "IX_NotificationOutbox_Status_AvailableAt"
ON "NotificationOutbox" ("Status", "AvailableAt");
ALTER TABLE IF EXISTS "ExercisePlans"
ADD COLUMN IF NOT EXISTS "StartDate" date NULL,
ADD COLUMN IF NOT EXISTS "EndDate" date NULL,
ADD COLUMN IF NOT EXISTS "ReminderTime" time without time zone NOT NULL DEFAULT TIME '19:00';
ALTER TABLE IF EXISTS "ExercisePlanItems"
ADD COLUMN IF NOT EXISTS "ScheduledDate" date NULL;
DO $exercise_migration$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'ExercisePlans' AND column_name = 'WeekStartDate'
) THEN
UPDATE "ExercisePlans"
SET "StartDate" = COALESCE("StartDate", "WeekStartDate")
WHERE "StartDate" IS NULL;
WITH ranked AS (
SELECT i."Id", p."StartDate",
ROW_NUMBER() OVER (PARTITION BY i."PlanId" ORDER BY i."Id") - 1 AS day_offset
FROM "ExercisePlanItems" i
JOIN "ExercisePlans" p ON p."Id" = i."PlanId"
WHERE i."ScheduledDate" IS NULL
)
UPDATE "ExercisePlanItems" i
SET "ScheduledDate" = ranked."StartDate" + ranked.day_offset::integer
FROM ranked
WHERE i."Id" = ranked."Id";
UPDATE "ExercisePlans" p
SET "EndDate" = COALESCE(
p."EndDate",
(SELECT MAX(i."ScheduledDate") FROM "ExercisePlanItems" i WHERE i."PlanId" = p."Id"),
p."StartDate"
)
WHERE p."EndDate" IS NULL;
END IF;
END $exercise_migration$;
ALTER TABLE IF EXISTS "ExercisePlans"
ALTER COLUMN "StartDate" SET NOT NULL,
ALTER COLUMN "EndDate" SET NOT NULL;
ALTER TABLE IF EXISTS "ExercisePlanItems"
ALTER COLUMN "ScheduledDate" SET NOT NULL;
CREATE INDEX IF NOT EXISTS "IX_ExercisePlans_UserId_StartDate_EndDate"
ON "ExercisePlans" ("UserId", "StartDate", "EndDate");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_ExercisePlanItems_PlanId_ScheduledDate"
ON "ExercisePlanItems" ("PlanId", "ScheduledDate");
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
VALUES ('20260619_01_persistent_ai_commands_and_report_tasks', now())
ON CONFLICT ("Id") DO NOTHING;
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
VALUES ('20260619_02_persistent_diet_and_medication_tasks', now())
ON CONFLICT ("Id") DO NOTHING;
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
VALUES ('20260620_03_date_based_exercise_plans', now())
ON CONFLICT ("Id") DO NOTHING;
""", ct);
await transaction.CommitAsync(ct);
_logger.LogInformation("数据库结构迁移已检查: 20260620_03");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,946 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AiWriteCommands",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ToolName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Arguments = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AiWriteCommands", x => x.Id);
});
migrationBuilder.CreateTable(
name: "DietImageAnalysisTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FilePaths = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ResultCode = table.Column<int>(type: "integer", nullable: true),
ResultData = table.Column<string>(type: "text", nullable: true),
ResultMessage = table.Column<string>(type: "text", nullable: true),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DietImageAnalysisTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Doctors",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: true),
Department = table.Column<string>(type: "text", nullable: true),
Phone = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
ProfessionalDirection = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: true),
Introduction = table.Column<string>(type: "text", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Doctors", x => x.Id);
});
migrationBuilder.CreateTable(
name: "MedicationReminderTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Dosage = table.Column<string>(type: "text", nullable: true),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MedicationReminderTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NotificationOutbox",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
SourceTaskId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Payload = table.Column<string>(type: "jsonb", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationOutbox", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Token = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsRevoked = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ReportAnalysisTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ReportId = table.Column<Guid>(type: "uuid", nullable: false),
FilePath = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Attempts = table.Column<int>(type: "integer", nullable: false),
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ReportAnalysisTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserNotifications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
SourceId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
Severity = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
ActionType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ActionTargetId = table.Column<string>(type: "text", nullable: true),
IsRead = table.Column<bool>(type: "boolean", nullable: false),
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserNotifications", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VerificationCodes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Phone = table.Column<string>(type: "text", nullable: false),
Code = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsUsed = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VerificationCodes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Phone = table.Column<string>(type: "text", nullable: false),
Role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "User"),
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "text", nullable: true),
Gender = table.Column<string>(type: "text", nullable: true),
BirthDate = table.Column<DateOnly>(type: "date", nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
table.ForeignKey(
name: "FK_Users_Doctors_DoctorId",
column: x => x.DoctorId,
principalTable: "Doctors",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "Consultations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
DoctorId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
Month = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Consultations", x => x.Id);
table.ForeignKey(
name: "FK_Consultations_Doctors_DoctorId",
column: x => x.DoctorId,
principalTable: "Doctors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Consultations_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Conversations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
AgentType = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: true),
Summary = table.Column<string>(type: "text", nullable: true),
MessageCount = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Conversations", x => x.Id);
table.ForeignKey(
name: "FK_Conversations_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DeviceTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Platform = table.Column<string>(type: "text", nullable: false),
PushToken = table.Column<string>(type: "text", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeviceTokens", x => x.Id);
table.ForeignKey(
name: "FK_DeviceTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DietRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MealType = table.Column<string>(type: "text", nullable: false),
TotalCalories = table.Column<int>(type: "integer", nullable: true),
HealthScore = table.Column<int>(type: "integer", nullable: true),
RecordedAt = table.Column<DateOnly>(type: "date", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DietRecords", x => x.Id);
table.ForeignKey(
name: "FK_DietRecords_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DoctorProfiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "text", nullable: true),
Title = table.Column<string>(type: "text", nullable: true),
Department = table.Column<string>(type: "text", nullable: true),
Hospital = table.Column<string>(type: "text", nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: true),
IsOnline = table.Column<bool>(type: "boolean", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DoctorProfiles", x => x.Id);
table.ForeignKey(
name: "FK_DoctorProfiles_Doctors_DoctorId",
column: x => x.DoctorId,
principalTable: "Doctors",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_DoctorProfiles_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExercisePlans",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
ReminderTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExercisePlans", x => x.Id);
table.ForeignKey(
name: "FK_ExercisePlans_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FollowUps",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
DoctorName = table.Column<string>(type: "text", nullable: true),
Department = table.Column<string>(type: "text", nullable: true),
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FollowUps", x => x.Id);
table.ForeignKey(
name: "FK_FollowUps_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HealthArchives",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Diagnosis = table.Column<string>(type: "text", nullable: true),
SurgeryType = table.Column<string>(type: "text", nullable: true),
SurgeryDate = table.Column<DateOnly>(type: "date", nullable: true),
Allergies = table.Column<List<string>>(type: "text[]", nullable: false),
DietRestrictions = table.Column<List<string>>(type: "text[]", nullable: false),
ChronicDiseases = table.Column<List<string>>(type: "text[]", nullable: false),
FamilyHistory = table.Column<string>(type: "text", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthArchives", x => x.Id);
table.ForeignKey(
name: "FK_HealthArchives_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HealthRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MetricType = table.Column<string>(type: "text", nullable: false),
Systolic = table.Column<int>(type: "integer", nullable: true),
Diastolic = table.Column<int>(type: "integer", nullable: true),
Value = table.Column<decimal>(type: "numeric", nullable: true),
Unit = table.Column<string>(type: "text", nullable: true),
Source = table.Column<string>(type: "text", nullable: false),
IsAbnormal = table.Column<bool>(type: "boolean", nullable: false),
RecordedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthRecords", x => x.Id);
table.ForeignKey(
name: "FK_HealthRecords_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Medications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Dosage = table.Column<string>(type: "text", nullable: true),
Frequency = table.Column<string>(type: "text", nullable: false),
TimeOfDay = table.Column<List<TimeOnly>>(type: "time[]", nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: true),
EndDate = table.Column<DateOnly>(type: "date", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
Source = table.Column<string>(type: "text", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Medications", x => x.Id);
table.ForeignKey(
name: "FK_Medications_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "NotificationPreferences",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MedicationReminder = table.Column<bool>(type: "boolean", nullable: false),
FollowUpReminder = table.Column<bool>(type: "boolean", nullable: false),
DoctorReply = table.Column<bool>(type: "boolean", nullable: false),
AbnormalAlert = table.Column<bool>(type: "boolean", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationPreferences", x => x.Id);
table.ForeignKey(
name: "FK_NotificationPreferences_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Reports",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
FileUrl = table.Column<string>(type: "text", nullable: false),
FileType = table.Column<string>(type: "text", nullable: false),
Category = table.Column<string>(type: "text", nullable: false),
AiSummary = table.Column<string>(type: "text", nullable: true),
AiIndicators = table.Column<string>(type: "jsonb", nullable: true),
Status = table.Column<string>(type: "text", nullable: false),
Severity = table.Column<string>(type: "text", nullable: true),
DoctorComment = table.Column<string>(type: "text", nullable: true),
DoctorRecommendation = table.Column<string>(type: "text", nullable: true),
DoctorName = table.Column<string>(type: "text", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reports", x => x.Id);
table.ForeignKey(
name: "FK_Reports_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ConsultationMessages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ConsultationId = table.Column<Guid>(type: "uuid", nullable: false),
SenderType = table.Column<string>(type: "text", nullable: false),
SenderName = table.Column<string>(type: "text", nullable: true),
Content = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConsultationMessages", x => x.Id);
table.ForeignKey(
name: "FK_ConsultationMessages_Consultations_ConsultationId",
column: x => x.ConsultationId,
principalTable: "Consultations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ConversationMessages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "text", nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
Intent = table.Column<string>(type: "text", nullable: true),
MetadataJson = table.Column<string>(type: "jsonb", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConversationMessages", x => x.Id);
table.ForeignKey(
name: "FK_ConversationMessages_Conversations_ConversationId",
column: x => x.ConversationId,
principalTable: "Conversations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DietFoodItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DietRecordId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Portion = table.Column<string>(type: "text", nullable: true),
Calories = table.Column<int>(type: "integer", nullable: true),
ProteinGrams = table.Column<decimal>(type: "numeric", nullable: true),
CarbsGrams = table.Column<decimal>(type: "numeric", nullable: true),
FatGrams = table.Column<decimal>(type: "numeric", nullable: true),
Warning = table.Column<string>(type: "text", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DietFoodItems", x => x.Id);
table.ForeignKey(
name: "FK_DietFoodItems_DietRecords_DietRecordId",
column: x => x.DietRecordId,
principalTable: "DietRecords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ExercisePlanItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
PlanId = table.Column<Guid>(type: "uuid", nullable: false),
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
ExerciseType = table.Column<string>(type: "text", nullable: false),
DurationMinutes = table.Column<int>(type: "integer", nullable: false),
IsCompleted = table.Column<bool>(type: "boolean", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsRestDay = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExercisePlanItems", x => x.Id);
table.ForeignKey(
name: "FK_ExercisePlanItems_ExercisePlans_PlanId",
column: x => x.PlanId,
principalTable: "ExercisePlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "HealthArchiveSurgeries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
HealthArchiveId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Date = table.Column<DateOnly>(type: "date", nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HealthArchiveSurgeries", x => x.Id);
table.ForeignKey(
name: "FK_HealthArchiveSurgeries_HealthArchives_HealthArchiveId",
column: x => x.HealthArchiveId,
principalTable: "HealthArchives",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MedicationLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MedicationLogs", x => x.Id);
table.ForeignKey(
name: "FK_MedicationLogs_Medications_MedicationId",
column: x => x.MedicationId,
principalTable: "Medications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AiWriteCommands_UserId_Status_ExpiresAt",
table: "AiWriteCommands",
columns: new[] { "UserId", "Status", "ExpiresAt" });
migrationBuilder.CreateIndex(
name: "IX_ConsultationMessages_ConsultationId_CreatedAt",
table: "ConsultationMessages",
columns: new[] { "ConsultationId", "CreatedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_Consultations_DoctorId",
table: "Consultations",
column: "DoctorId");
migrationBuilder.CreateIndex(
name: "IX_Consultations_UserId",
table: "Consultations",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_ConversationMessages_ConversationId_CreatedAt",
table: "ConversationMessages",
columns: new[] { "ConversationId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Conversations_UserId_UpdatedAt",
table: "Conversations",
columns: new[] { "UserId", "UpdatedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_DeviceTokens_UserId",
table: "DeviceTokens",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_DietFoodItems_DietRecordId",
table: "DietFoodItems",
column: "DietRecordId");
migrationBuilder.CreateIndex(
name: "IX_DietImageAnalysisTasks_Status_AvailableAt",
table: "DietImageAnalysisTasks",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_DietRecords_UserId_RecordedAt",
table: "DietRecords",
columns: new[] { "UserId", "RecordedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_DoctorProfiles_DoctorId",
table: "DoctorProfiles",
column: "DoctorId");
migrationBuilder.CreateIndex(
name: "IX_DoctorProfiles_UserId",
table: "DoctorProfiles",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExercisePlanItems_PlanId_ScheduledDate",
table: "ExercisePlanItems",
columns: new[] { "PlanId", "ScheduledDate" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExercisePlans_UserId_StartDate_EndDate",
table: "ExercisePlans",
columns: new[] { "UserId", "StartDate", "EndDate" });
migrationBuilder.CreateIndex(
name: "IX_FollowUps_UserId",
table: "FollowUps",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_HealthArchives_UserId",
table: "HealthArchives",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_HealthArchiveSurgeries_HealthArchiveId_SortOrder",
table: "HealthArchiveSurgeries",
columns: new[] { "HealthArchiveId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_HealthRecords_UserId_MetricType",
table: "HealthRecords",
columns: new[] { "UserId", "MetricType" });
migrationBuilder.CreateIndex(
name: "IX_HealthRecords_UserId_RecordedAt",
table: "HealthRecords",
columns: new[] { "UserId", "RecordedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_MedicationLogs_MedicationId_CreatedAt",
table: "MedicationLogs",
columns: new[] { "MedicationId", "CreatedAt" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_MedicationReminderTasks_MedicationId_ScheduledDate_Schedule~",
table: "MedicationReminderTasks",
columns: new[] { "MedicationId", "ScheduledDate", "ScheduledTime" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MedicationReminderTasks_Status_AvailableAt",
table: "MedicationReminderTasks",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_Medications_UserId_IsActive",
table: "Medications",
columns: new[] { "UserId", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_NotificationOutbox_SourceTaskId",
table: "NotificationOutbox",
column: "SourceTaskId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NotificationOutbox_Status_AvailableAt",
table: "NotificationOutbox",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_NotificationPreferences_UserId",
table: "NotificationPreferences",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ReportAnalysisTasks_ReportId",
table: "ReportAnalysisTasks",
column: "ReportId");
migrationBuilder.CreateIndex(
name: "IX_ReportAnalysisTasks_Status_AvailableAt",
table: "ReportAnalysisTasks",
columns: new[] { "Status", "AvailableAt" });
migrationBuilder.CreateIndex(
name: "IX_Reports_UserId",
table: "Reports",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserNotifications_SourceId",
table: "UserNotifications",
column: "SourceId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserNotifications_UserId_IsDeleted_IsRead_CreatedAt",
table: "UserNotifications",
columns: new[] { "UserId", "IsDeleted", "IsRead", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Users_DoctorId",
table: "Users",
column: "DoctorId");
migrationBuilder.CreateIndex(
name: "IX_Users_Phone",
table: "Users",
column: "Phone",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VerificationCodes_Phone_CreatedAt",
table: "VerificationCodes",
columns: new[] { "Phone", "CreatedAt" },
descending: new[] { false, true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AiWriteCommands");
migrationBuilder.DropTable(
name: "ConsultationMessages");
migrationBuilder.DropTable(
name: "ConversationMessages");
migrationBuilder.DropTable(
name: "DeviceTokens");
migrationBuilder.DropTable(
name: "DietFoodItems");
migrationBuilder.DropTable(
name: "DietImageAnalysisTasks");
migrationBuilder.DropTable(
name: "DoctorProfiles");
migrationBuilder.DropTable(
name: "ExercisePlanItems");
migrationBuilder.DropTable(
name: "FollowUps");
migrationBuilder.DropTable(
name: "HealthArchiveSurgeries");
migrationBuilder.DropTable(
name: "HealthRecords");
migrationBuilder.DropTable(
name: "MedicationLogs");
migrationBuilder.DropTable(
name: "MedicationReminderTasks");
migrationBuilder.DropTable(
name: "NotificationOutbox");
migrationBuilder.DropTable(
name: "NotificationPreferences");
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(
name: "ReportAnalysisTasks");
migrationBuilder.DropTable(
name: "Reports");
migrationBuilder.DropTable(
name: "UserNotifications");
migrationBuilder.DropTable(
name: "VerificationCodes");
migrationBuilder.DropTable(
name: "Consultations");
migrationBuilder.DropTable(
name: "Conversations");
migrationBuilder.DropTable(
name: "DietRecords");
migrationBuilder.DropTable(
name: "ExercisePlans");
migrationBuilder.DropTable(
name: "HealthArchives");
migrationBuilder.DropTable(
name: "Medications");
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropTable(
name: "Doctors");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
namespace Health.Infrastructure.Data;
/// <summary>
/// 数据库种子数据
/// </summary>
public static class DataSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
// 医生不再使用种子数据,通过注册创建
await Task.CompletedTask;
}
}

View File

@@ -1,177 +0,0 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
using Microsoft.Extensions.Configuration;
namespace Health.Infrastructure.Data;
/// <summary>
/// 开发环境测试数据填充。生产环境不要调用!
/// 开关DEVDATA_ENABLED=true 才会执行
/// </summary>
public static class DevDataSeeder
{
public static async Task SeedIfEnabled(AppDbContext db, IConfiguration config)
{
// 通过环境变量控制DEVDATA_ENABLED=true 才填充测试数据
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
if (enabled != "true") return;
// 已有任何真实用户时跳过(避免污染真实数据)
if (db.Users.Any(u => u.Phone != "13800000001")) return;
// 检查是否已有测试用户(避免重复填充)
if (db.Users.Any(u => u.Phone == "13800000001")) return;
// ---- 种子医生数据 ----
if (!db.Doctors.Any())
{
var doctor1 = new Doctor
{
Id = Guid.NewGuid(), Name = "张明", Title = "主任医师",
Department = "心脏康复科", Phone = "13800000002",
ProfessionalDirection = "冠心病康复、术后管理",
IsActive = true, CreatedAt = DateTime.UtcNow,
};
var doctor2 = new Doctor
{
Id = Guid.NewGuid(), Name = "李芳", Title = "副主任医师",
Department = "营养科", Phone = "13800000003",
ProfessionalDirection = "糖尿病管理、饮食指导",
IsActive = true, CreatedAt = DateTime.UtcNow,
};
var doctor3 = new Doctor
{
Id = Guid.NewGuid(), Name = "王建国", Title = "主任医师",
Department = "心血管内科", Phone = "13800000004",
ProfessionalDirection = "高血压管理、PCI术后随访",
IsActive = true, CreatedAt = DateTime.UtcNow,
};
db.Doctors.AddRange(doctor1, doctor2, doctor3);
await db.SaveChangesAsync();
}
// ---- 创建测试患者 ----
var doctorWang = db.Doctors.FirstOrDefault(d => d.Name == "王建国");
var user = new User
{
Id = Guid.NewGuid(), Phone = "13800000001", Name = "张三",
Gender = "男", BirthDate = new DateOnly(1970, 3, 15),
DoctorId = doctorWang?.Id,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
db.Users.Add(user);
await db.SaveChangesAsync();
// ---- 健康档案 ----
db.HealthArchives.Add(new HealthArchive
{
Id = Guid.NewGuid(), UserId = user.Id,
Diagnosis = "冠心病", SurgeryType = "PCI支架植入术",
SurgeryDate = new DateOnly(2026, 3, 15),
Allergies = ["青霉素"], DietRestrictions = ["低盐", "低脂"],
ChronicDiseases = ["高血压", "高血脂"],
FamilyHistory = "父亲冠心病",
UpdatedAt = DateTime.UtcNow,
});
// ---- 健康数据(过去 7 天)----
var random = new Random(42);
for (int i = 7; i >= 0; i--)
{
var date = DateTime.UtcNow.AddDays(-i);
// 血压
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
Systolic = 120 + random.Next(-5, 15), Diastolic = 75 + random.Next(-5, 10),
Unit = "mmHg", Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
// 心率
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.HeartRate,
Value = 68 + random.Next(-5, 10), Unit = "次/分",
Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
// 血糖
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.Glucose,
Value = 5.0m + (decimal)(random.NextDouble() * 1.5),
Unit = "mmol/L", Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
// 血氧
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.SpO2,
Value = 96 + random.Next(0, 3), Unit = "%",
Source = HealthRecordSource.AiEntry,
IsAbnormal = false, RecordedAt = date,
});
}
// 一条异常血压
db.HealthRecords.Add(new HealthRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
Systolic = 148, Diastolic = 92, Unit = "mmHg",
Source = HealthRecordSource.AiEntry, IsAbnormal = true,
RecordedAt = DateTime.UtcNow.AddDays(-1),
});
// ---- 用药计划 ----
db.Medications.Add(new Medication
{
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿司匹林", Dosage = "100mg",
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)],
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
});
db.Medications.Add(new Medication
{
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿托伐他汀", Dosage = "20mg",
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(20, 0)],
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
});
// ---- 运动计划 ----
var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(6), ReminderTime = new TimeOnly(19, 0) };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), ExerciseType = "散步", DurationMinutes = 30 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(3), IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(4), ExerciseType = "太极", DurationMinutes = 40 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(5), IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(6), ExerciseType = "散步", DurationMinutes = 30 });
db.ExercisePlans.Add(plan);
// ---- 饮食记录 ----
var lunch = new DietRecord
{
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
};
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 });
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 });
db.DietRecords.Add(lunch);
// ---- 复查计划 ----
db.FollowUps.Add(new FollowUp
{
Id = Guid.NewGuid(), UserId = user.Id, Title = "心内科复查",
DoctorName = "王建国", Department = "心血管内科",
ScheduledAt = DateTime.UtcNow.AddDays(3), Status = FollowUpStatus.Upcoming,
});
db.FollowUps.Add(new FollowUp
{
Id = Guid.NewGuid(), UserId = user.Id, Title = "术后3周复查",
DoctorName = "王建国", Department = "心血管内科",
ScheduledAt = DateTime.UtcNow.AddDays(-14), Status = FollowUpStatus.Completed,
});
await db.SaveChangesAsync();
Console.WriteLine($"[DEV] 测试数据已填充:用户 {user.Phone}");
}
}

View File

@@ -25,15 +25,28 @@ public sealed class DietImageAnalysisQueue(AppDbContext db) : IDietImageAnalysis
return task.Id;
}
public Task RecoverStaleAsync(CancellationToken ct)
public async Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.DietImageAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
var staleCutoff = now.AddMinutes(-5);
// 超时但仍有重试余量 → 重新排队
await _db.DietImageAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing也让 WaitForResultAsync 能尽快返回失败)
await _db.DietImageAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.ResultCode, 50001)
.SetProperty(x => x.ResultMessage, "食物识别超时且已达最大重试次数")
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct)

View File

@@ -45,15 +45,26 @@ public sealed class MedicationReminderQueue(AppDbContext db) : IMedicationRemind
}
}
public Task RecoverStaleAsync(CancellationToken ct)
public async Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.MedicationReminderTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
var staleCutoff = now.AddMinutes(-5);
// 超时但仍有重试余量 → 重新排队
await _db.MedicationReminderTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
await _db.MedicationReminderTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct)

View File

@@ -1,12 +1,16 @@
using Health.Application.Reports;
using Health.Application.Notifications;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Reports;
public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueue
public sealed class EfReportAnalysisQueue(
AppDbContext db,
IUserNotificationProducer? notifications = null) : IReportAnalysisQueue
{
private const int MaxAttempts = 3;
private readonly AppDbContext _db = db;
private readonly IUserNotificationProducer? _notifications = notifications;
public async Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default)
{
@@ -25,15 +29,26 @@ public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueu
await _db.SaveChangesAsync(ct);
}
public Task RecoverStaleAsync(CancellationToken ct)
public async Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
var staleCutoff = now.AddMinutes(-5);
// 超时但仍有重试余量 → 重新排队
await _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
await _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Failed")
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct)
@@ -82,5 +97,27 @@ public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueu
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
if (task.Status == "Failed" && _notifications != null)
{
var report = await _db.Reports.AsNoTracking()
.Where(x => x.Id == task.ReportId)
.Select(x => new { x.Id, x.UserId })
.FirstOrDefaultAsync(ct);
if (report != null)
{
await _notifications.EnqueueAsync(
report.UserId,
task.Id,
new NotificationMessage(
"ReportAnalysisFailed",
"报告分析未完成",
"这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
"warning",
"report",
report.Id.ToString()),
ct);
}
}
}
}

View File

@@ -1,4 +1,5 @@
using Health.Application.Reports;
using Health.Application.Notifications;
using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging;
@@ -8,11 +9,13 @@ public sealed class ReportAnalysisService(
IReportRepository reports,
VisionClient vision,
DeepSeekClient llm,
IUserNotificationProducer notifications,
ILogger<ReportAnalysisService> logger) : IReportAnalysisService
{
private readonly IReportRepository _reports = reports;
private readonly VisionClient _vision = vision;
private readonly DeepSeekClient _llm = llm;
private readonly IUserNotificationProducer _notifications = notifications;
private readonly ILogger<ReportAnalysisService> _logger = logger;
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
@@ -32,6 +35,7 @@ public sealed class ReportAnalysisService(
report.AiIndicators = "[]";
report.Status = ReportStatus.AnalysisFailed;
await _reports.SaveChangesAsync(ct);
await NotifyAsync(report, job.TaskId, false, ct);
return;
}
@@ -44,8 +48,14 @@ public sealed class ReportAnalysisService(
report.Status = ReportStatus.PendingDoctor;
await _reports.SaveChangesAsync(ct);
await NotifyAsync(report, job.TaskId, true, ct);
_logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// 进程停机导致的取消:不算失败,不消耗重试,交还给恢复机制
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId);
@@ -53,6 +63,10 @@ public sealed class ReportAnalysisService(
report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。";
report.AiIndicators = "[]";
await _reports.SaveChangesAsync(ct);
// 重新抛出,使 Worker 进入 RetryAsync指数退避重试超限后任务置 Failed
// 若为可恢复的瞬时故障,重试成功会把状态改回 PendingDoctor。
throw;
}
}
@@ -91,7 +105,8 @@ public sealed class ReportAnalysisService(
ct: ct);
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
_logger.LogInformation("报告 VLM 返回: {Content}", content[..Math.Min(content.Length, 200)]);
// 不记录 VLM 返回正文:可能包含检验指标等敏感医疗信息,生产日志只记录长度
_logger.LogInformation("报告 VLM 返回长度: {Length}", content.Length);
var json = TryParseJson(content);
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
@@ -162,4 +177,17 @@ public sealed class ReportAnalysisService(
try { return JsonDocument.Parse(content).RootElement; }
catch (JsonException) { return null; }
}
private Task NotifyAsync(Report report, Guid sourceId, bool succeeded, CancellationToken ct) =>
_notifications.EnqueueAsync(
report.UserId,
sourceId,
new NotificationMessage(
succeeded ? "ReportAnalysisCompleted" : "ReportAnalysisFailed",
succeeded ? "报告分析完成" : "报告分析未完成",
succeeded ? "您的报告已完成 AI 预解读,点击查看分析结果。" : "这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
succeeded ? "success" : "warning",
"report",
report.Id.ToString()),
ct);
}

View File

@@ -0,0 +1,52 @@
using Health.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Health.WebApi.Data;
/// <summary>
/// 设计时 DbContext 工厂——仅供 dotnet-efmigrations / database update使用。
/// 不会启动 Web 服务或后台 Worker只负责构建 AppDbContext 的连接配置。
/// 连接串优先级DB_CONNECTION 环境变量 → backend/.env 文件 → 本地默认。
/// </summary>
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
private const string FallbackConnection =
"Host=localhost;Database=health_manager;Username=postgres;Password=postgres123";
public AppDbContext CreateDbContext(string[] args)
{
var connection = Environment.GetEnvironmentVariable("DB_CONNECTION")
?? ReadConnectionFromEnvFile()
?? FallbackConnection;
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(connection)
.Options;
return new AppDbContext(options);
}
// 从 backend 目录的 .env 读取 DB_CONNECTION设计时不走 Program.cs 的加载逻辑)
private static string? ReadConnectionFromEnvFile()
{
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
for (var i = 0; i < 6 && dir != null; i++, dir = dir.Parent)
{
var envPath = Path.Combine(dir.FullName, ".env");
if (!File.Exists(envPath)) continue;
foreach (var line in File.ReadAllLines(envPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0 || trimmed.StartsWith('#')) continue;
var eq = trimmed.IndexOf('=');
if (eq <= 0) continue;
if (trimmed[..eq].Trim() != "DB_CONNECTION") continue;
var value = trimmed[(eq + 1)..].Trim();
return value.Length > 0 ? value : null;
}
}
return null;
}
}

View File

@@ -10,6 +10,10 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Drawing.Common" Version="10.0.8" />
</ItemGroup>

View File

@@ -16,6 +16,14 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{
await _next(context);
}
catch (Health.Domain.ValidationException vex)
{
// 业务输入校验失败:返回 400message 为我们自己产生的提示,可安全展示
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
context.Response.ContentType = "application/json";
var result = new { code = 40001, data = (object?)null, message = vex.Message };
await context.Response.WriteAsync(JsonSerializer.Serialize(result));
}
catch (Exception ex)
{
// 生产环境不暴露内部异常详情

View File

@@ -79,7 +79,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
// ---- 数据库 ----
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default") ?? builder.Configuration["DB_CONNECTION"] ?? "Host=localhost;Database=health_manager;Username=postgres;Password=postgres"));
options.UseNpgsql(
builder.Configuration.GetConnectionString("Default") ?? builder.Configuration["DB_CONNECTION"] ?? "Host=localhost;Database=health_manager;Username=postgres;Password=postgres",
npgsql => npgsql.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
// ---- JWT 认证 ----
var jwtSecret = builder.Configuration["JWT_SECRET"];
@@ -137,6 +139,8 @@ builder.Services.AddScoped<IMaintenanceService, MaintenanceService>();
builder.Services.AddScoped<IMaintenanceRepository, EfMaintenanceRepository>();
builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>();
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
@@ -144,7 +148,6 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
builder.Services.AddScoped<DatabaseSchemaMigrator>();
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM----
builder.Services.AddHttpClient<DeepSeekClient>(client =>
@@ -167,6 +170,7 @@ builder.Services.AddHostedService<MedicationReminderWorker>();
builder.Services.AddHostedService<CleanupService>();
builder.Services.AddHostedService<ReportAnalysisWorker>();
builder.Services.AddHostedService<DietImageAnalysisWorker>();
builder.Services.AddHostedService<NotificationOutboxWorker>();
// ---- OpenAPI ----
builder.Services.AddOpenApi();
@@ -202,14 +206,12 @@ app.UseStaticFiles(new StaticFileOptions
if (app.Environment.IsDevelopment())
app.MapOpenApi();
// ---- 初始化数据库:创建空库,并用版本化补丁更新已有本地结构 ----
// ---- 数据库迁移EF Core Migrations可用 AUTO_MIGRATE=false 禁用自动迁移)----
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
await scope.ServiceProvider.GetRequiredService<DatabaseSchemaMigrator>().ApplyAsync();
await DataSeeder.SeedAsync(db);
await DevDataSeeder.SeedIfEnabled(db, app.Configuration);
if (app.Configuration.GetValue<bool>("AUTO_MIGRATE", true))
await db.Database.MigrateAsync();
}
// ---- 注册 API 端点 ----