feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化

- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
This commit is contained in:
MingNian
2026-06-20 20:41:42 +08:00
parent c610417e29
commit 4d213b5a44
132 changed files with 6733 additions and 2856 deletions

View File

@@ -1,3 +1,6 @@
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
@@ -21,38 +24,33 @@ public static class CommonAgentHandler
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(
string toolName,
JsonElement args,
Guid userId,
IHealthRecordService healthRecords,
IHealthArchiveService archives,
CancellationToken ct = default)
{
return toolName switch
{
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args),
"check_archive" => await ExecuteCheckArchive(db, userId),
"query_health_records" => await ExecuteQueryHealthRecords(healthRecords, userId, args, ct),
"check_archive" => await ExecuteCheckArchive(archives, userId, ct),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
var query = db.HealthRecords.Where(r => r.UserId == userId);
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
query = query.Where(r => r.MetricType == mt);
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
{
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
}).ToListAsync();
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
return new { count = records.Count, records };
}
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
{
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
var archive = await archives.GetAsync(userId, ct);
if (archive == null) return new { found = false };
return new
{
@@ -85,49 +83,26 @@ public static class CommonAgentHandler
}
};
public static async Task<object> ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args)
public static Task<object> ExecuteManageArchive(
IHealthArchiveService archives,
Guid userId,
JsonElement args,
CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId);
if (archive == null)
{
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
db.HealthArchives.Add(archive);
}
switch (action)
{
case "update_diagnosis":
archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis;
break;
case "update_surgery":
archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType;
if (args.TryGetProperty("surgery_date", out var sd))
archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate;
break;
case "update_allergies":
if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array)
archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_chronic_diseases":
if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array)
archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_diet_restrictions":
if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array)
archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)];
break;
default: // query
return new
{
found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
};
}
archive.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
var request = new HealthArchiveUpdateRequest(
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
ReadStringArray(args, "allergies"),
ReadStringArray(args, "diet_restrictions"),
ReadStringArray(args, "chronic_diseases"),
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
return archives.ExecuteAiActionAsync(userId, action, request, ct);
}
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
: null;
}

View File

@@ -7,12 +7,4 @@ public static class ConsultationAgentHandler
{
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
}

View File

@@ -7,12 +7,4 @@ public static class DietAgentHandler
{
public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
}

View File

@@ -1,85 +1,83 @@
using Health.Application.Exercises;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 运动计划 Agent 工具处理器
/// </summary>
public static class ExerciseAgentHandler
{
public static readonly ToolDefinition ManageExerciseTool = new()
{
Function = new()
{
Name = "manage_exercise", Description = "运动计划管理(创建/查询/打卡)",
Parameters = new { type = "object", properties = new {
action = new { type = "string", description = "create/query/checkin" },
week_start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
items = new { type = "array", description = "每天的运动项目", items = new { type = "object", properties = new {
day_of_week = new { type = "integer", description = "0=周日 1=周一...6=周六" },
exercise_type = new { type = "string", description = "运动类型,如散步/慢跑/游泳/太极" },
duration_minutes = new { type = "integer", description = "时长(分钟)" },
is_rest_day = new { type = "boolean", description = "是否休息日" }
}}}
}, required = new[] { "action" } }
}
Name = "manage_exercise",
Description = "运动计划管理(创建/查询/打卡)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/checkin" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" },
duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
duration_minutes = new { type = "integer", description = "每天运动时长,单位分钟" },
reminder_time = new { type = "string", description = "每天提醒时间 HH:mm默认19:00" },
item_id = new { type = "string", description = "打卡条目 ID" },
},
required = new[] { "action" },
},
},
};
public static List<ToolDefinition> Tools => [ManageExerciseTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(
string toolName,
JsonElement args,
AppDbContext db,
Guid userId,
IExerciseService? exercises = null,
CancellationToken ct = default)
{
return toolName switch
if (toolName != "manage_exercise" || exercises == null)
return new { success = false, message = $"未知工具: {toolName}" };
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
if (action == "create")
{
"manage_exercise" => await ExecuteManageExercise(db, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
switch (action)
{
case "create":
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
if (args.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
});
}
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync();
var firstItem = plan.Items.FirstOrDefault();
return new {
success = true, plan_id = plan.Id,
exercise_type = firstItem?.ExerciseType ?? "运动",
duration_minutes = firstItem?.DurationMinutes ?? 30,
day_count = plan.Items.Count,
};
case "checkin":
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
exerciseItem.IsCompleted = true;
exerciseItem.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
default:
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (existingPlan == null) return new { found = false };
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
? parsedStart
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var days = args.TryGetProperty("duration_days", out var daysValue) && daysValue.TryGetInt32(out var parsedDays)
? Math.Clamp(parsedDays, 1, 366)
: 7;
var exerciseType = args.TryGetProperty("exercise_type", out var typeValue) ? typeValue.GetString() : "散步";
var minutes = args.TryGetProperty("duration_minutes", out var minutesValue) && minutesValue.TryGetInt32(out var parsedMinutes) ? parsedMinutes : 30;
var reminder = args.TryGetProperty("reminder_time", out var reminderValue) && TimeOnly.TryParse(reminderValue.GetString(), out var parsedReminder)
? parsedReminder
: new TimeOnly(19, 0);
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(
startDate, startDate.AddDays(days - 1), exerciseType, minutes, reminder), ct);
return new { success = true, plan_id = planId, exercise_type = exerciseType, duration_minutes = minutes, day_count = days, start_date = startDate, end_date = startDate.AddDays(days - 1), reminder_time = reminder };
}
if (action == "checkin")
{
var itemId = args.TryGetProperty("item_id", out var itemValue) && itemValue.TryGetGuid(out var parsedId) ? parsedId : Guid.Empty;
var success = await exercises.CheckInAsync(userId, itemId, ct);
return success ? new { success = true } : new { success = false, message = "条目不存在" };
}
var plan = await exercises.GetLatestAsync(userId, ct);
return plan == null
? new { found = false }
: new
{
found = true,
plan_id = plan.Id,
plan.StartDate,
plan.EndDate,
plan.ReminderTime,
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
};
}
}

View File

@@ -1,3 +1,5 @@
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
@@ -16,16 +18,50 @@ 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)
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId, IHealthRecordService? healthRecords = null)
{
return toolName switch
{
"record_health_data" => await ExecuteRecordHealthData(db, userId, args),
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
"record_health_data" => healthRecords == null
? await ExecuteRecordHealthData(db, userId, args)
: await ExecuteRecordHealthData(healthRecords, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt)
? dt
: DateTime.UtcNow;
var request = new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
recordedAt);
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new
{
success = true,
record_id = id,
type = metricType.Value.ToString(),
value = valStr,
unit,
isAbnormal = HealthRecordRules.CheckAbnormal(request)
};
}
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
@@ -82,4 +118,42 @@ public static class HealthDataAgentHandler
};
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
{
"blood_pressure" => (
HealthMetricType.BloodPressure,
null,
"mmHg",
args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null,
args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null),
"heart_rate" => (
HealthMetricType.HeartRate,
args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null,
"次/分",
null,
null),
"glucose" => (
HealthMetricType.Glucose,
args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null,
"mmol/L",
null,
null),
"spo2" => (
HealthMetricType.SpO2,
args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null,
"%",
null,
null),
"weight" => (
HealthMetricType.Weight,
args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null,
"kg",
null,
null),
_ => (null, null, "", null, null)
};
}
}

View File

@@ -1,7 +1,9 @@
using Health.Application.Medications;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 药管家 Agent 工具处理器——用药管理
/// 药管家 Agent 工具处理器
/// </summary>
public static class MedicationAgentHandler
{
@@ -9,31 +11,105 @@ public static class MedicationAgentHandler
{
Function = new()
{
Name = "manage_medication", Description = "用药管理(创建/查询/确认服药)",
Parameters = new { type = "object", properties = new {
action = new { type = "string", description = "create/query/confirm" },
name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" },
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
duration_days = new { type = "integer", description = "服用天数" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
}, required = new[] { "action" } }
Name = "manage_medication",
Description = "用药管理(创建/查询/确认服药)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/confirm" },
medication_id = new { type = "string", description = "药品 ID确认服药时使用" },
name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" },
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
duration_days = new { type = "integer", description = "服用天数" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
},
required = new[] { "action" }
}
}
};
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(
string toolName,
JsonElement args,
AppDbContext db,
Guid userId,
IMedicationService? medications = null,
CancellationToken ct = default)
{
return toolName switch
{
"manage_medication" => await ExecuteManageMedication(db, userId, args),
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId),
"manage_medication" => medications != null
? await ExecuteManageMedication(medications, userId, args, ct)
: await ExecuteManageMedication(db, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
return action switch
{
"create" => await CreateMedication(medications, userId, args, ct),
"query" => await QueryMedications(medications, userId, ct),
"confirm" => await ConfirmMedication(medications, userId, args, ct),
_ => new { success = false, message = $"未知操作: {action}" }
};
}
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequency = ReadFrequency(args);
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;
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
name,
dosage,
frequency,
times.Count > 0 ? times : [new TimeOnly(8, 0)],
startDate,
endDate,
MedicationSource.AiEntry,
null), ct);
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new
{
success = true,
medication_id = medicationId,
name,
dosage,
frequency = frequency.ToString(),
time = timeLabels,
start_date = startDate.ToString("yyyy-MM-dd"),
duration_days = durationDays,
};
}
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
{
var meds = await medications.ListActiveAsync(userId, ct);
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) };
}
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
var success = await medications.ConfirmNowAsync(userId, medId, ct);
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
}
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
@@ -50,38 +126,36 @@ public static class MedicationAgentHandler
{
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
var frequency = Enum.TryParse<MedicationFrequency>(frequencyStr, out var fr) ? fr : MedicationFrequency.Daily;
List<TimeOnly> times = [];
if (args.TryGetProperty("time_of_day", out var tod) && tod.ValueKind == JsonValueKind.Array)
{
foreach (var t in tod.EnumerateArray())
{
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
}
}
var frequency = ReadFrequency(args);
var times = ReadTimes(args);
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
var startDate = ReadStartDate(args);
var med = new Medication
{
Id = Guid.NewGuid(), UserId = userId,
Name = name, Dosage = dosage, Frequency = frequency,
Id = Guid.NewGuid(),
UserId = userId,
Name = name,
Dosage = dosage,
Frequency = frequency,
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
Source = MedicationSource.AiEntry, IsActive = true,
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null,
Source = MedicationSource.AiEntry,
IsActive = true,
StartDate = startDate,
EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null,
};
db.Medications.Add(med);
await db.SaveChangesAsync();
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new {
success = true, medication_id = med.Id,
name = med.Name, dosage = med.Dosage,
frequency = med.Frequency.ToString(), time = timeLabels,
return new
{
success = true,
medication_id = med.Id,
name = med.Name,
dosage = med.Dosage,
frequency = med.Frequency.ToString(),
time = timeLabels,
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
duration_days = durationDays,
};
@@ -97,12 +171,47 @@ public static class MedicationAgentHandler
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
{
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId);
if (med == null) return new { success = false, message = "药品不存在" };
db.MedicationLogs.Add(new MedicationLog
{
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
Id = Guid.NewGuid(),
MedicationId = medId,
UserId = userId,
Status = MedicationLogStatus.Taken,
ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
ConfirmedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return new { success = true };
}
private static MedicationFrequency ReadFrequency(JsonElement args)
{
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
}
private static DateOnly ReadStartDate(JsonElement args)
{
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
return DateOnly.TryParse(startDateStr, out var parsedDate)
? parsedDate
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
}
private static List<TimeOnly> ReadTimes(JsonElement args)
{
List<TimeOnly> times = [];
if (!args.TryGetProperty("time_of_day", out var tod) || tod.ValueKind != JsonValueKind.Array)
return times;
foreach (var t in tod.EnumerateArray())
{
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
}
return times;
}
}

View File

@@ -12,15 +12,6 @@ public static class ReportAgentHandler
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient)
{
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
@@ -28,13 +19,13 @@ public static class ReportAgentHandler
return new { success = false, message = "缺少报告图片" };
var prompt = """
JSON格式返回
JSON格式返回
{
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
"indicators": [
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
],
"summary": "初步分析摘要",
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
"needsDoctorReview": true/false
}
JSON

View File

@@ -0,0 +1,78 @@
using System.Text.Json;
using Health.Application.AI;
using Health.Application.Exercises;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.Infrastructure.AI;
public sealed class AiToolExecutionService(
AppDbContext db,
VisionClient visionClient,
IHealthArchiveService healthArchives,
IHealthRecordService healthRecords,
IExerciseService exercises,
IMedicationService medications,
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
{
private readonly AppDbContext _db = db;
private readonly VisionClient _visionClient = visionClient;
private readonly IHealthArchiveService _healthArchives = healthArchives;
private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IExerciseService _exercises = exercises;
private readonly IMedicationService _medications = medications;
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
{
using var json = JsonDocument.Parse(arguments);
var root = json.RootElement;
return await (toolName switch
{
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, _db, 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),
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
});
}
public async Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var command = await _confirmations.TakeAsync(commandId, userId, ct);
if (command == null)
return new AiConfirmedWriteResult(40004, null, "确认请求不存在、已执行或已过期");
try
{
var result = await ExecuteAsync(command.ToolName, command.Arguments, userId, ct);
if (!Succeeded(result, out var error))
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(40001, result, error ?? "写入失败,请检查内容后重试");
}
await _confirmations.CompleteAsync(command, ct);
await transaction.CommitAsync(ct);
return new AiConfirmedWriteResult(0, result, null);
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(50001, null, $"写入失败: {ex.Message}");
}
}
private static bool Succeeded(object result, out string? error)
{
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
var root = json.RootElement;
error = root.TryGetProperty("message", out var message) ? message.GetString() : null;
return !root.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.False;
}
}

View File

@@ -0,0 +1,45 @@
using Health.Application.AI;
namespace Health.Infrastructure.AI;
public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversationRepository
{
private readonly AppDbContext _db = db;
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId, ct);
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId, ct);
public async Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
await _db.Conversations
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.UpdatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderBy(m => m.CreatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderByDescending(m => m.CreatedAt)
.Take(limit)
.ToListAsync(ct);
public async Task AddConversationAsync(Conversation conversation, CancellationToken ct) =>
await _db.Conversations.AddAsync(conversation, ct);
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
await _db.ConversationMessages.AddAsync(message, ct);
public void Delete(Conversation conversation) =>
_db.Conversations.Remove(conversation);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,98 @@
using Health.Application.AI;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.AI;
public sealed class EfAiWriteConfirmationStore(AppDbContext db) : IAiWriteConfirmationStore
{
private readonly AppDbContext _db = db;
public async Task<PendingAiWriteCommand> CreateAsync(
Guid userId,
string toolName,
string arguments,
TimeSpan lifetime,
CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var expired = await _db.AiWriteCommands.Where(x => x.Status == "Pending" && x.ExpiresAt <= now).ToListAsync(ct);
foreach (var item in expired) { item.Status = "Expired"; item.UpdatedAt = now; }
}
else
{
await _db.AiWriteCommands
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Expired")
.SetProperty(x => x.UpdatedAt, now), ct);
}
var command = new AiWriteCommandRecord
{
Id = Guid.NewGuid(),
UserId = userId,
ToolName = toolName,
Arguments = arguments,
Status = "Pending",
ExpiresAt = now.Add(lifetime),
CreatedAt = now,
UpdatedAt = now,
};
await _db.AiWriteCommands.AddAsync(command, ct);
await _db.SaveChangesAsync(ct);
return ToCommand(command);
}
public async Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now, ct);
if (tracked == null) return null;
tracked.Status = "Processing";
tracked.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
}
else
{
var updated = await _db.AiWriteCommands
.Where(x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.UpdatedAt, now), ct);
if (updated == 0) return null;
}
var record = await _db.AiWriteCommands.AsNoTracking().FirstAsync(x => x.Id == commandId, ct);
return ToCommand(record);
}
public async Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct)
{
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing", ct);
if (tracked != null) { tracked.Status = "Completed"; tracked.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); }
return;
}
await _db.AiWriteCommands
.Where(x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
private bool IsInMemory() => _db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory";
private static PendingAiWriteCommand ToCommand(AiWriteCommandRecord record) => new(
record.Id,
record.UserId,
record.ToolName,
record.Arguments,
record.ExpiresAt);
}

View File

@@ -8,18 +8,33 @@ public sealed class PromptManager
/// <summary>
/// 获取指定 Agent 的 System Prompt
/// </summary>
public string GetSystemPrompt(AgentType agentType) => agentType switch
public string GetSystemPrompt(AgentType agentType)
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
};
var prompt = agentType switch
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
};
return $"{prompt}\n\n{MedicalBoundaryRules}";
}
private const string MedicalBoundaryRules = """
- AI
- //
-
-
- 使
- AI
""";
private const string DefaultPrompt = """
AI健康管家
@@ -38,15 +53,15 @@ public sealed class PromptManager
""";
private const string ConsultationPrompt = """
AI
1.
2. 2-3
3.
4.
5. >160/100>120<50
6. "以上为AI分析具体请咨询医生"
5. >160/100>120<50
6.
7.
""";
@@ -55,13 +70,13 @@ public sealed class PromptManager
1. ////
2. + record_health_data
2. + record_health_data
3. "血压120/80血糖6.2血氧97" record_health_data
4. "早上血压120下午血压130"recorded_at
4. "早上血压120下午血压130"recorded_at
5. "120""收缩压还是血糖?"
6.
7.
8. record_health_data
8. record_health_data
- 90-139 mmHg 60-89 mmHg
@@ -86,24 +101,24 @@ public sealed class PromptManager
""";
private const string MedicationPrompt = """
1. manage_medication(action="query")
2. ///
3. "早饭后"
4.
4. manage_medication(action="create")
5.
6.
6.
""";
private const string ReportPrompt = """
1.
2. //
3.
3.
4. "AI预解读待医生确认"
5. /CT"需医生人工审阅"
""";
@@ -120,7 +135,7 @@ public sealed class PromptManager
""";
private const string UnifiedPrompt = """
AI健康管
AI
@@ -133,20 +148,21 @@ public sealed class PromptManager
6. query_health_records
manage_exercise
- action="create", week_start_date="今天日期(YYYY-MM-DD)"
- items: [{"day_of_week":0-6(=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...]
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
- duration_days=exercise_type="散步"duration_minutes=30reminder_time="19:00"
- =30 =60 =30 =90 =80
- "一个月"=30 "一周"=7 "10天"=107
- "坚持X天/月/周"
- items
- start_date + duration_days - 1
manage_medication
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
-
- +
-
- +
-
- pendingConfirmation=true
-
-
""";

View File

@@ -0,0 +1,90 @@
using Health.Application.Admin;
namespace Health.Infrastructure.Admin;
public sealed class AdminService(AppDbContext db) : IAdminService
{
private readonly AppDbContext _db = db;
public async Task<AdminResult> ListDoctorsAsync(CancellationToken ct)
{
var doctors = await _db.Doctors.AsNoTracking().OrderBy(x => x.CreatedAt)
.Select(x => new { x.Id, x.Name, x.Title, x.Department, x.Phone, x.ProfessionalDirection, x.IsActive, x.AvatarUrl, x.Introduction, x.CreatedAt })
.ToListAsync(ct);
return Ok(doctors);
}
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空");
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空");
var doctor = new Doctor
{
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department,
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow,
};
_db.Doctors.Add(doctor);
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
{
var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.Name, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
_db.Users.Add(user);
_db.DoctorProfiles.Add(new DoctorProfile
{
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name,
Title = command.Title, Department = command.Department, IsActive = true,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
});
}
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.Name });
}
public async Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
if (command.Name != null) doctor.Name = command.Name;
if (command.Title != null) doctor.Title = command.Title;
if (command.Department != null) doctor.Department = command.Department;
if (command.Phone != null) doctor.Phone = command.Phone;
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id });
}
public async Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
doctor.IsActive = !doctor.IsActive;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.IsActive });
}
public async Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
_db.Doctors.Remove(doctor);
await _db.SaveChangesAsync(ct);
return Ok(new { success = true });
}
public async Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search));
var total = await query.CountAsync(ct);
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
.ToListAsync(ct);
return Ok(new { patients, total, page, pageSize });
}
private static AdminResult Ok(object data) => new(0, data);
private static AdminResult Error(int code, string message) => new(code, null, message);
}

View File

@@ -0,0 +1,115 @@
using Health.Application.Auth;
using Health.Infrastructure.Services;
namespace Health.Infrastructure.Auth;
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
{
private const string AdminPhone = "12345678910";
private const string AdminSmsCode = "000000";
private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private readonly AppDbContext _db = db;
private readonly JwtProvider _jwt = jwt;
private readonly SmsService _sms = sms;
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{
var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode();
await _db.VerificationCodes.AddAsync(new VerificationCode
{
Id = Guid.NewGuid(), Phone = phone, Code = code,
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
}, ct);
await _db.SaveChangesAsync(ct);
if (phone != AdminPhone) await _sms.SendCodeAsync(phone, code);
return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null });
}
public async Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct)
{
var code = await TakeCodeAsync(command.Phone, command.SmsCode, ct);
if (code == null) return Error(40001, "验证码错误或已过期");
if (await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct)) return Error(40002, "该手机号已注册,请直接登录");
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40003, "请输入姓名");
if (!await _db.Doctors.AnyAsync(x => x.Id == command.DoctorId && x.IsActive, ct)) return Error(40004, "所选医生不存在或已停诊");
code.IsUsed = true;
var user = new User
{
Id = Guid.NewGuid(), Phone = command.Phone, Role = "User", Name = command.Name,
DoctorId = command.DoctorId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
_db.Users.Add(user);
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
var tokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { user.Id, user.Phone, user.Role, isNew = true } });
}
public async Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct)
{
var code = await TakeCodeAsync(phone, smsCode, ct);
if (code == null) return Error(40001, "验证码错误或已过期");
code.IsUsed = true;
if (phone == AdminPhone)
{
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { id = AdminId, phone = AdminPhone, role = "Admin", name = "管理员", isNew = false } });
}
var user = await _db.Users.FirstOrDefaultAsync(x => x.Phone == phone, ct);
if (user == null) return Error(40004, "该手机号未注册,请先注册");
user.UpdatedAt = DateTime.UtcNow;
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new
{
userTokens.accessToken, userTokens.refreshToken,
user = new { user.Id, user.Phone, user.Role, user.Name, user.Gender, user.AvatarUrl, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), isNew = false }
});
}
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct)
{
var oldToken = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken && !x.IsRevoked, ct);
if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow) return Error(40002, "登录已过期,请重新登录");
oldToken.IsRevoked = true;
if (oldToken.UserId == AdminId)
{
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } });
}
var user = await _db.Users.FindAsync([oldToken.UserId], ct);
if (user == null) return Error(40002, "用户不存在");
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { userTokens.accessToken, userTokens.refreshToken, user = new { user.Role } });
}
public async Task LogoutAsync(string refreshToken, CancellationToken ct)
{
var token = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken, ct);
if (token != null) token.IsRevoked = true;
await _db.SaveChangesAsync(ct);
}
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role)
{
var access = _jwt.GenerateAccessToken(userId, phone, role);
var refresh = _jwt.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
return (access, refresh);
}
private static AuthResult Error(int code, string message) => new(code, null, message);
}

View File

@@ -0,0 +1,27 @@
using Health.Application.Calendars;
namespace Health.Infrastructure.Calendars;
public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
{
private readonly AppDbContext _db = db;
public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
{
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var medications = await _db.Medications
.Where(m => m.UserId == userId && m.IsActive)
.ToListAsync(ct);
var exercisePlans = await _db.ExercisePlans
.Include(p => p.Items)
.Where(p => p.UserId == userId && p.StartDate < end && p.EndDate >= start)
.ToListAsync(ct);
var followUps = await _db.FollowUps
.Where(f => f.UserId == userId && f.ScheduledAt >= startUtc && f.ScheduledAt < endUtc)
.ToListAsync(ct);
return new CalendarDataSnapshot(medications, exercisePlans, followUps);
}
}

View File

@@ -0,0 +1,166 @@
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");
}
}

View File

@@ -0,0 +1,13 @@
namespace Health.Infrastructure.Data.Records;
public sealed class AiWriteCommandRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string ToolName { get; set; } = string.Empty;
public string Arguments { get; set; } = string.Empty;
public string Status { get; set; } = "Pending";
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,16 @@
namespace Health.Infrastructure.Data.Records;
public sealed class DietImageAnalysisTaskRecord
{
public Guid Id { get; set; }
public string FilePaths { get; set; } = "[]";
public string Status { get; set; } = "Pending";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public int? ResultCode { get; set; }
public string? ResultData { get; set; }
public string? ResultMessage { get; set; }
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,18 @@
namespace Health.Infrastructure.Data.Records;
public sealed class MedicationReminderTaskRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid MedicationId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Dosage { get; set; }
public DateOnly ScheduledDate { get; set; }
public TimeOnly ScheduledTime { get; set; }
public string Status { get; set; } = "Pending";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,16 @@
namespace Health.Infrastructure.Data.Records;
public sealed class NotificationOutboxRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid SourceTaskId { get; set; }
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = "{}";
public string Status { get; set; } = "AwaitingProvider";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,14 @@
namespace Health.Infrastructure.Data.Records;
public sealed class ReportAnalysisTaskRecord
{
public Guid Id { get; set; }
public Guid ReportId { get; set; }
public string FilePath { get; set; } = string.Empty;
public string Status { get; set; } = "Pending";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Data;
@@ -33,6 +34,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
public DbSet<NotificationOutboxRecord> NotificationOutbox => Set<NotificationOutboxRecord>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -95,7 +101,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
// ---- ExercisePlan ----
builder.Entity<ExercisePlan>(e =>
{
e.HasIndex(p => new { p.UserId, p.WeekStartDate }).IsDescending(false, true);
e.HasIndex(p => new { p.UserId, p.StartDate, p.EndDate });
});
builder.Entity<ExercisePlanItem>(e =>
{
e.HasIndex(i => new { i.PlanId, i.ScheduledDate }).IsUnique();
});
// ---- Report ----
@@ -148,5 +159,48 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
{
e.HasIndex(a => a.UserId).IsUnique();
});
builder.Entity<AiWriteCommandRecord>(e =>
{
e.ToTable("AiWriteCommands");
e.HasIndex(x => new { x.UserId, x.Status, x.ExpiresAt });
e.Property(x => x.ToolName).HasMaxLength(64);
e.Property(x => x.Status).HasMaxLength(32);
e.Property(x => x.Arguments).HasColumnType("jsonb");
});
builder.Entity<ReportAnalysisTaskRecord>(e =>
{
e.ToTable("ReportAnalysisTasks");
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.HasIndex(x => x.ReportId);
e.Property(x => x.Status).HasMaxLength(32);
});
builder.Entity<DietImageAnalysisTaskRecord>(e =>
{
e.ToTable("DietImageAnalysisTasks");
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.Property(x => x.FilePaths).HasColumnType("jsonb");
e.Property(x => x.Status).HasMaxLength(32);
});
builder.Entity<MedicationReminderTaskRecord>(e =>
{
e.ToTable("MedicationReminderTasks");
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.HasIndex(x => new { x.MedicationId, x.ScheduledDate, x.ScheduledTime }).IsUnique();
e.Property(x => x.Status).HasMaxLength(32);
});
builder.Entity<NotificationOutboxRecord>(e =>
{
e.ToTable("NotificationOutbox");
e.HasIndex(x => x.SourceTaskId).IsUnique();
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.Property(x => x.Type).HasMaxLength(64);
e.Property(x => x.Status).HasMaxLength(32);
e.Property(x => x.Payload).HasColumnType("jsonb");
});
}
}

View File

@@ -137,14 +137,14 @@ public static class DevDataSeeder
// ---- 运动计划 ----
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, WeekStartDate = monday };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, ExerciseType = "散步", DurationMinutes = 30 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 3, IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 4, ExerciseType = "太极", DurationMinutes = 40 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 5, IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 6, ExerciseType = "散步", DurationMinutes = 30 });
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);
// ---- 饮食记录 ----

View File

@@ -0,0 +1,59 @@
using Health.Application.Diets;
namespace Health.Infrastructure.Diets;
public sealed class DietImageAnalysisCoordinator(IDietImageAnalysisQueue queue) : IDietImageAnalysisCoordinator
{
private const long MaxImageBytes = 20 * 1024 * 1024;
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".heic"
};
private readonly IDietImageAnalysisQueue _queue = queue;
public async Task<DietImageAnalysisResult> AnalyzeAsync(IReadOnlyList<DietImageUploadFile> files, CancellationToken ct)
{
if (files.Count == 0)
return new DietImageAnalysisResult(false, 40001, null, "请上传至少一张图片");
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "diet-analysis");
Directory.CreateDirectory(uploadsDir);
var paths = new List<string>();
var handedOff = false;
try
{
foreach (var file in files)
{
if (file.Length <= 0 || file.Length > MaxImageBytes)
return new DietImageAnalysisResult(false, 40001, null, "文件大小超过 20MB 限制");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return new DietImageAnalysisResult(false, 40001, null, "不支持的图片格式,仅支持 JPG/PNG/HEIC");
var filePath = Path.Combine(uploadsDir, $"{Guid.NewGuid()}{extension}");
await using var stream = new FileStream(filePath, FileMode.CreateNew);
await file.Content.CopyToAsync(stream, ct);
paths.Add(filePath);
}
var jobId = await _queue.EnqueueAsync(paths, ct);
handedOff = true;
return await _queue.WaitForResultAsync(jobId, ct);
}
catch
{
if (!handedOff)
{
foreach (var path in paths)
{
try { if (File.Exists(path)) File.Delete(path); }
catch { }
}
}
throw;
}
}
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json;
using Health.Application.Diets;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Diets;
public sealed class DietImageAnalysisQueue(AppDbContext db) : IDietImageAnalysisQueue
{
private const int MaxAttempts = 3;
private readonly AppDbContext _db = db;
public async Task<Guid> EnqueueAsync(IReadOnlyList<string> filePaths, CancellationToken ct)
{
var now = DateTime.UtcNow;
var task = new DietImageAnalysisTaskRecord
{
Id = Guid.NewGuid(),
FilePaths = JsonSerializer.Serialize(filePaths),
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
};
await _db.DietImageAnalysisTasks.AddAsync(task, ct);
await _db.SaveChangesAsync(ct);
return task.Id;
}
public Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.DietImageAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.DietImageAnalysisTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.DietImageAnalysisTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
if (claimed == 0) return null;
var paths = JsonSerializer.Deserialize<List<string>>(candidate.FilePaths) ?? [];
return new DietImageAnalysisJob(candidate.Id, paths);
}
public async Task<DietImageAnalysisResult> WaitForResultAsync(Guid jobId, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var task = await _db.DietImageAnalysisTasks.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == jobId, ct);
if (task == null)
return new DietImageAnalysisResult(false, 40004, null, "饮食识别任务不存在");
if (task.Status is "Completed" or "Failed")
return new DietImageAnalysisResult(
task.Status == "Completed",
task.ResultCode ?? 50001,
task.ResultData,
task.ResultMessage);
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
}
throw new OperationCanceledException(ct);
}
public Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.DietImageAnalysisTasks.Where(x => x.Id == jobId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, result.Success ? "Completed" : "Failed")
.SetProperty(x => x.ResultCode, result.Code)
.SetProperty(x => x.ResultData, result.Data)
.SetProperty(x => x.ResultMessage, result.Message)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<bool> RetryAsync(Guid jobId, string error, CancellationToken ct)
{
var task = await _db.DietImageAnalysisTasks.FirstOrDefaultAsync(x => x.Id == jobId, ct);
if (task == null) return true;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
task.ResultCode = 50001;
task.ResultMessage = $"食物识别失败: {error}";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
return task.Status == "Failed";
}
}

View File

@@ -0,0 +1,42 @@
using Health.Application.Diets;
using Health.Infrastructure.AI;
namespace Health.Infrastructure.Diets;
public sealed class DietImageAnalyzer(VisionClient vision) : IDietImageAnalyzer
{
private readonly VisionClient _vision = vision;
public async Task<DietImageAnalysisResult> AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct)
{
var imageUrls = new List<string>();
foreach (var filePath in job.FilePaths)
{
if (!File.Exists(filePath))
return new DietImageAnalysisResult(false, 40004, null, "待识别图片不存在");
var bytes = await File.ReadAllBytesAsync(filePath, ct);
var base64 = Convert.ToBase64String(bytes);
if (base64.Length > 7 * 1024 * 1024)
return new DietImageAnalysisResult(false, 40001, null, "图片过大,请压缩后重新上传");
var extension = Path.GetExtension(filePath).ToLowerInvariant();
var mime = extension switch
{
".png" => "image/png",
".heic" => "image/heic",
_ => "image/jpeg"
};
imageUrls.Add($"data:{mime};base64,{base64}");
}
var prompt = """
JSON数组
[{"name":"名称","portion":"份量","calories":热量}]
JSON
""";
var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]";
return new DietImageAnalysisResult(true, 0, result, null);
}
}

View File

@@ -0,0 +1,33 @@
using Health.Application.Diets;
namespace Health.Infrastructure.Diets;
public sealed class EfDietRepository(AppDbContext db) : IDietRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<DietRecord>> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct)
{
var query = _db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
if (recordedAt.HasValue)
query = query.Where(r => r.RecordedAt == recordedAt.Value);
if (mealType.HasValue)
query = query.Where(r => r.MealType == mealType.Value);
return await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct);
}
public Task<DietRecord?> GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct) =>
_db.DietRecords.FirstOrDefaultAsync(r => r.Id == recordId && r.UserId == userId, ct);
public async Task AddAsync(DietRecord record, CancellationToken ct) =>
await _db.DietRecords.AddAsync(record, ct);
public void Delete(DietRecord record) =>
_db.DietRecords.Remove(record);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,44 @@
using Health.Application.Exercises;
namespace Health.Infrastructure.Exercises;
public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) =>
await _db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId && p.StartDate <= date && p.EndDate >= date)
.OrderByDescending(p => p.StartDate)
.ToListAsync(ct);
public async Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) =>
await _db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.StartDate)
.Take(limit)
.ToListAsync(ct);
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
_db.ExercisePlans.Include(p => p.Items)
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct);
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) =>
_db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.StartDate)
.FirstOrDefaultAsync(ct);
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) =>
_db.ExercisePlanItems.Include(i => i.Plan)
.FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct);
public async Task AddAsync(ExercisePlan plan, CancellationToken ct) =>
await _db.ExercisePlans.AddAsync(plan, ct);
public void Delete(ExercisePlan plan) =>
_db.ExercisePlans.Remove(plan);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,44 @@
using System.Text.Json;
using Health.Application.Exercises;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Exercises;
public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminderProducer
{
private readonly AppDbContext _db = db;
public async Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct)
{
var beijingNow = utcNow.AddHours(8);
var today = DateOnly.FromDateTime(beijingNow);
var currentTime = TimeOnly.FromDateTime(beijingNow);
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
.Include(x => x.Plan)
.Where(x => x.ScheduledDate == today && !x.IsCompleted && !x.IsRestDay && x.Plan.ReminderTime <= currentTime)
.ToListAsync(ct);
var sourceIds = dueItems.Select(x => x.Id).ToList();
var existing = await _db.NotificationOutbox.AsNoTracking()
.Where(x => sourceIds.Contains(x.SourceTaskId))
.Select(x => x.SourceTaskId)
.ToListAsync(ct);
var existingSet = existing.ToHashSet();
var now = DateTime.UtcNow;
foreach (var item in dueItems.Where(x => !existingSet.Contains(x.Id)))
{
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
{
Id = Guid.NewGuid(), UserId = item.Plan.UserId, SourceTaskId = item.Id,
Type = "ExerciseReminder",
Payload = JsonSerializer.Serialize(new
{
item.ExerciseType, item.DurationMinutes, item.ScheduledDate,
ReminderTime = item.Plan.ReminderTime,
}),
Status = "AwaitingProvider", AvailableAt = now, CreatedAt = now, UpdatedAt = now,
}, ct);
}
if (_db.ChangeTracker.HasChanges()) await _db.SaveChangesAsync(ct);
return dueItems.Count - existingSet.Count;
}
}

View File

@@ -1,12 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Health.Application\Health.Application.csproj" />
<ProjectReference Include="..\Health.Domain\Health.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />

View File

@@ -0,0 +1,17 @@
using Health.Application.HealthArchives;
namespace Health.Infrastructure.HealthArchives;
public sealed class EfHealthArchiveRepository(AppDbContext db) : IHealthArchiveRepository
{
private readonly AppDbContext _db = db;
public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) =>
_db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
public async Task AddAsync(HealthArchive archive, CancellationToken ct) =>
await _db.HealthArchives.AddAsync(archive, ct);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,45 @@
using Health.Application.HealthRecords;
namespace Health.Infrastructure.HealthRecords;
public sealed class EfHealthRecordRepository(AppDbContext db) : IHealthRecordRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct)
{
var query = _db.HealthRecords.Where(r => r.UserId == userId);
if (type.HasValue)
query = query.Where(r => r.MetricType == type.Value);
if (recordedAfter.HasValue)
query = query.Where(r => r.RecordedAt >= recordedAfter.Value);
return await query.OrderByDescending(r => r.RecordedAt).Take(limit).ToListAsync(ct);
}
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) =>
_db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
public Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct) =>
_db.HealthRecords
.Where(r => r.UserId == userId && r.MetricType == type)
.OrderByDescending(r => r.RecordedAt)
.FirstOrDefaultAsync(ct);
public async Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct) =>
await _db.HealthRecords
.Where(r => r.UserId == userId && r.MetricType == type && r.RecordedAt >= recordedAfter)
.OrderBy(r => r.RecordedAt)
.ToListAsync(ct);
public async Task AddAsync(HealthRecord record, CancellationToken ct) =>
await _db.HealthRecords.AddAsync(record, ct);
public void Delete(HealthRecord record) =>
_db.HealthRecords.Remove(record);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,52 @@
using Health.Application.Maintenance;
namespace Health.Infrastructure.Maintenance;
public sealed class EfMaintenanceRepository(AppDbContext db) : IMaintenanceRepository
{
private readonly AppDbContext _db = db;
public async Task<MaintenanceCleanupResult> CleanupAsync(
DateTime conversationCutoff,
DateTime taskCutoff,
DateTime utcNow,
CancellationToken ct)
{
var oldConversationIds = await _db.Conversations.AsNoTracking()
.Where(x => x.UpdatedAt < conversationCutoff)
.Select(x => x.Id)
.ToListAsync(ct);
if (oldConversationIds.Count > 0)
{
await _db.ConversationMessages
.Where(x => oldConversationIds.Contains(x.ConversationId))
.ExecuteDeleteAsync(ct);
await _db.Conversations
.Where(x => oldConversationIds.Contains(x.Id))
.ExecuteDeleteAsync(ct);
}
var verificationCodes = await _db.VerificationCodes
.Where(x => x.ExpiresAt < utcNow)
.ExecuteDeleteAsync(ct);
var backgroundTasks = 0;
backgroundTasks += await _db.ReportAnalysisTasks
.Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed"))
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.DietImageAnalysisTasks
.Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed"))
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.MedicationReminderTasks
.Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed"))
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.NotificationOutbox
.Where(x => x.UpdatedAt < taskCutoff)
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.AiWriteCommands
.Where(x => x.UpdatedAt < taskCutoff)
.ExecuteDeleteAsync(ct);
return new MaintenanceCleanupResult(oldConversationIds.Count, verificationCodes, backgroundTasks);
}
}

View File

@@ -0,0 +1,79 @@
using Health.Application.Medications;
namespace Health.Infrastructure.Medications;
public sealed class EfMedicationRepository(AppDbContext db) : IMedicationRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct)
{
var query = _db.Medications.Include(m => m.Logs).Where(m => m.UserId == userId);
if (filter == "active") query = query.Where(m => m.IsActive);
else if (filter == "inactive") query = query.Where(m => !m.IsActive);
return await query.OrderByDescending(m => m.CreatedAt).ToListAsync(ct);
}
public async Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) =>
await _db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0
&& (m.StartDate == null || m.StartDate <= today)
&& (m.EndDate == null || m.EndDate >= today))
.ToListAsync(ct);
public async Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
await _db.MedicationLogs
.Where(l => l.UserId == userId && l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc)
.ToListAsync(ct);
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct);
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
_db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct);
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.FirstOrDefaultAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
&& l.Status == MedicationLogStatus.Taken, ct);
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.AnyAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.ScheduledTime == scheduledTime
&& l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc, ct);
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.FirstOrDefaultAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.ScheduledTime == scheduledTime
&& l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc, ct);
public async Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) =>
await _db.Medications
.Where(m => m.IsActive && m.TimeOfDay.Count > 0)
.ToListAsync(ct);
public Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.AnyAsync(l =>
l.UserId == userId && l.MedicationId == medicationId
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
&& l.Status == MedicationLogStatus.Taken, ct);
public async Task AddMedicationAsync(Medication medication, CancellationToken ct) =>
await _db.Medications.AddAsync(medication, ct);
public async Task AddLogAsync(MedicationLog log, CancellationToken ct) =>
await _db.MedicationLogs.AddAsync(log, ct);
public void DeleteMedication(Medication medication) =>
_db.Medications.Remove(medication);
public void DeleteLog(MedicationLog log) =>
_db.MedicationLogs.Remove(log);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,110 @@
using Health.Application.Medications;
using Health.Infrastructure.Data.Records;
using Npgsql;
namespace Health.Infrastructure.Medications;
public sealed class MedicationReminderQueue(AppDbContext db) : IMedicationReminderQueue
{
private const int MaxAttempts = 5;
private readonly AppDbContext _db = db;
public async Task<bool> EnqueueAsync(MedicationReminderTask task, CancellationToken ct)
{
var exists = await _db.MedicationReminderTasks.AsNoTracking().AnyAsync(x =>
x.MedicationId == task.MedicationId &&
x.ScheduledDate == task.ScheduledDate &&
x.ScheduledTime == task.ScheduledTime, ct);
if (exists) return false;
var now = DateTime.UtcNow;
await _db.MedicationReminderTasks.AddAsync(new MedicationReminderTaskRecord
{
Id = task.TaskId,
UserId = task.UserId,
MedicationId = task.MedicationId,
Name = task.Name,
Dosage = task.Dosage,
ScheduledDate = task.ScheduledDate,
ScheduledTime = task.ScheduledTime,
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
try
{
await _db.SaveChangesAsync(ct);
return true;
}
catch (DbUpdateException ex) when (
ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
_db.ChangeTracker.Clear();
return false;
}
}
public Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.MedicationReminderTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.MedicationReminderTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.MedicationReminderTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
if (claimed == 0) return null;
return new MedicationReminderTask(
candidate.Id,
candidate.UserId,
candidate.MedicationId,
candidate.Name,
candidate.Dosage,
candidate.ScheduledDate,
candidate.ScheduledTime);
}
public Task CompleteAsync(Guid taskId, CancellationToken ct) =>
_db.MedicationReminderTasks.Where(x => x.Id == taskId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
public async Task RetryAsync(Guid taskId, string error, CancellationToken ct)
{
var task = await _db.MedicationReminderTasks.FirstOrDefaultAsync(x => x.Id == taskId, ct);
if (task == null) return;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
using Health.Application.Medications;
using Health.Infrastructure.Data.Records;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Medications;
public sealed class OutboxMedicationReminderDispatcher(
AppDbContext db,
ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher
{
private readonly AppDbContext _db = db;
private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger;
public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct)
{
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == task.TaskId, ct))
return;
var now = DateTime.UtcNow;
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
{
Id = Guid.NewGuid(),
UserId = task.UserId,
SourceTaskId = task.TaskId,
Type = "MedicationReminder",
Payload = JsonSerializer.Serialize(new
{
task.MedicationId,
task.Name,
task.Dosage,
task.ScheduledDate,
task.ScheduledTime,
}),
Status = "AwaitingProvider",
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
await _db.SaveChangesAsync(ct);
_logger.LogInformation(
"用药提醒已写入通知 Outbox等待推送服务: {TaskId} {UserId}",
task.TaskId,
task.UserId);
}
}

View File

@@ -0,0 +1,26 @@
using Health.Application.Notifications;
namespace Health.Infrastructure.Notifications;
public sealed class EfInAppNotificationRepository(AppDbContext db) : IInAppNotificationRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct) =>
await _db.NotificationOutbox.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == "AwaitingProvider")
.OrderBy(x => x.CreatedAt)
.Take(10)
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Payload, x.CreatedAt))
.ToListAsync(ct);
public async Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct)
{
var updated = await _db.NotificationOutbox
.Where(x => x.Id == notificationId && x.UserId == userId && x.Status == "AwaitingProvider")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "InAppDelivered")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
return updated > 0;
}
}

View File

@@ -0,0 +1,86 @@
using Health.Application.Reports;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Reports;
public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueue
{
private const int MaxAttempts = 3;
private readonly AppDbContext _db = db;
public async Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
await _db.ReportAnalysisTasks.AddAsync(new ReportAnalysisTaskRecord
{
Id = job.TaskId,
ReportId = job.ReportId,
FilePath = job.FilePath,
Status = "Pending",
Attempts = 0,
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
await _db.SaveChangesAsync(ct);
}
public Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.ReportAnalysisTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.ReportAnalysisTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
return claimed == 0
? null
: new ReportAnalysisJob(candidate.Id, candidate.ReportId, candidate.FilePath);
}
public async Task CompleteAsync(Guid taskId, CancellationToken ct)
{
await _db.ReportAnalysisTasks
.Where(x => x.Id == taskId && x.Status == "Processing")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
public async Task RetryAsync(Guid taskId, string error, CancellationToken ct)
{
var task = await _db.ReportAnalysisTasks.FirstOrDefaultAsync(x => x.Id == taskId, ct);
if (task == null) return;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,29 @@
using Health.Application.Reports;
namespace Health.Infrastructure.Reports;
public sealed class EfReportRepository(AppDbContext db) : IReportRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<Report>> ListAsync(Guid userId, CancellationToken ct) =>
await _db.Reports
.Where(r => r.UserId == userId)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync(ct);
public Task<Report?> GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct) =>
_db.Reports.FirstOrDefaultAsync(r => r.Id == reportId && r.UserId == userId, ct);
public Task<Report?> GetByIdAsync(Guid reportId, CancellationToken ct) =>
_db.Reports.FirstOrDefaultAsync(r => r.Id == reportId, ct);
public async Task AddAsync(Report report, CancellationToken ct) =>
await _db.Reports.AddAsync(report, ct);
public void Delete(Report report) =>
_db.Reports.Remove(report);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,30 @@
using Health.Application.Reports;
namespace Health.Infrastructure.Reports;
public sealed class LocalReportFileStorage : IReportFileStorage
{
public async Task<StoredReportFile> SaveAsync(ReportUploadFile file, string extension, CancellationToken ct)
{
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports");
Directory.CreateDirectory(uploadsDir);
var fileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(uploadsDir, fileName);
await using (var stream = new FileStream(filePath, FileMode.CreateNew))
{
await file.Content.CopyToAsync(stream, ct);
}
return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
}
public string GetLocalFilePath(string fileUrl) =>
Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/'));
public bool Exists(string filePath) =>
File.Exists(filePath);
public void Delete(string filePath) =>
File.Delete(filePath);
}

View File

@@ -0,0 +1,165 @@
using Health.Application.Reports;
using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Reports;
public sealed class ReportAnalysisService(
IReportRepository reports,
VisionClient vision,
DeepSeekClient llm,
ILogger<ReportAnalysisService> logger) : IReportAnalysisService
{
private readonly IReportRepository _reports = reports;
private readonly VisionClient _vision = vision;
private readonly DeepSeekClient _llm = llm;
private readonly ILogger<ReportAnalysisService> _logger = logger;
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
{
var report = await _reports.GetByIdAsync(job.ReportId, ct);
if (report == null) return;
try
{
var vlmJson = await ExtractIndicatorsAsync(job.FilePath, ct);
var isReport = vlmJson.TryGetProperty("isReport", out var ir) && ir.GetBoolean();
var reason = vlmJson.TryGetProperty("reason", out var rsn) ? rsn.GetString() : null;
if (!isReport)
{
report.AiSummary = $"AI 暂时无法完成解读:{reason ?? ""}。请重新上传清晰的报告图片。";
report.AiIndicators = "[]";
report.Status = ReportStatus.AnalysisFailed;
await _reports.SaveChangesAsync(ct);
return;
}
var reportType = vlmJson.TryGetProperty("reportType", out var rt) ? rt.GetString() ?? "Other" : "Other";
var indicatorsJson = vlmJson.TryGetProperty("indicators", out var inds) ? inds.GetRawText() : "[]";
report.Category = Enum.TryParse<ReportCategory>(reportType, out var cat) ? cat : ReportCategory.Other;
report.AiIndicators = indicatorsJson;
report.AiSummary = await GenerateSummaryAsync(indicatorsJson, ct);
report.Status = ReportStatus.PendingDoctor;
await _reports.SaveChangesAsync(ct);
_logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId);
}
catch (Exception ex)
{
_logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId);
report.Status = ReportStatus.AnalysisFailed;
report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。";
report.AiIndicators = "[]";
await _reports.SaveChangesAsync(ct);
}
}
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
{
var imageUrl = await GetLocalImageUrl(filePath, ct);
if (imageUrl == null)
throw new InvalidOperationException("报告图片文件不存在或无法读取");
var prompt = """
{"isReport": false, "reason": "这不是医学报告,原因:..."}
JSON格式markdown包裹
{
"isReport": true,
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
"indicators": [
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
]
}
- status =normal=high=low
-
""";
var response = await _vision.VisionAsync(
prompt,
[imageUrl],
userText: "请分析这份医学报告",
maxTokens: 2048,
ct: ct);
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
_logger.LogInformation("报告 VLM 返回: {Content}", content[..Math.Min(content.Length, 200)]);
var json = TryParseJson(content);
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
}
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
{
var prompt = $"""
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
检测指标:{indicatorsJson}
要求:
1. 总字数200-300字
2. 先总结整体情况
3. 指出异常指标及其可能原因,但不要给出确定诊断
4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议
5. 如指标明显异常或存在急症风险,优先建议及时就医
6. 末尾提醒"AI预解读"
JSON格式
""";
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" },
new() { Role = "user", Content = prompt }
};
var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct);
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
return !string.IsNullOrWhiteSpace(summary)
? summary
: throw new InvalidOperationException("AI 未返回有效解读内容");
}
private static async Task<string?> GetLocalImageUrl(string filePath, CancellationToken ct)
{
if (!File.Exists(filePath)) return null;
var bytes = await File.ReadAllBytesAsync(filePath, ct);
var base64 = Convert.ToBase64String(bytes);
var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.');
var mime = ext switch
{
"png" => "image/png",
"jpg" or "jpeg" => "image/jpeg",
"webp" => "image/webp",
_ => "image/png"
};
return $"data:{mime};base64,{base64}";
}
private static JsonElement? TryParseJson(string? content)
{
if (string.IsNullOrWhiteSpace(content)) return null;
content = content.Trim();
if (content.StartsWith("```"))
{
var end = content.IndexOf('\n');
if (end > 0) content = content[(end + 1)..];
if (content.EndsWith("```"))
content = content[..^3];
}
content = content.Trim();
try { return JsonDocument.Parse(content).RootElement; }
catch { return null; }
}
}

View File

@@ -0,0 +1,51 @@
using Health.Application.Users;
namespace Health.Infrastructure.Users;
public sealed class EfUserRepository(AppDbContext db) : IUserRepository
{
private readonly AppDbContext _db = db;
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
public async Task DeleteAccountDataAsync(Guid userId, CancellationToken ct)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct);
if (profile?.DoctorId != null)
{
await _db.Users.Where(u => u.DoctorId == profile.DoctorId)
.ExecuteUpdateAsync(u => u.SetProperty(x => x.DoctorId, (Guid?)null), ct);
await _db.Doctors.Where(d => d.Id == profile.DoctorId).ExecuteDeleteAsync(ct);
}
await _db.HealthRecords.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
await _db.MedicationLogs.Where(l => l.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Medications.Where(m => m.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DietFoodItems.Where(i => i.DietRecord!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DietRecords.Where(d => d.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ExercisePlanItems.Where(i => i.Plan!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ExercisePlans.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ReportAnalysisTasks.Where(t => _db.Reports.Any(r => r.Id == t.ReportId && r.UserId == userId)).ExecuteDeleteAsync(ct);
await _db.Reports.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ConversationMessages.Where(m => m.Conversation!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Conversations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ConsultationMessages.Where(m => m.Consultation!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Consultations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await _db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct);
await _db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await _db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct);
await _db.AiWriteCommands.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DoctorProfiles.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync(ct);
await transaction.CommitAsync(ct);
}
}