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

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