feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}" })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}" })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,不要其他内容。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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=30,reminder_time="19:00"
|
||||
- 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字
|
||||
- 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天
|
||||
- 用户说"坚持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 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温暖、专业
|
||||
""";
|
||||
|
||||
90
backend/src/Health.Infrastructure/Admin/AdminService.cs
Normal file
90
backend/src/Health.Infrastructure/Admin/AdminService.cs
Normal 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);
|
||||
}
|
||||
115
backend/src/Health.Infrastructure/Auth/AuthService.cs
Normal file
115
backend/src/Health.Infrastructure/Auth/AuthService.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
166
backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs
Normal file
166
backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
// ---- 饮食记录 ----
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
42
backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs
Normal file
42
backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
33
backend/src/Health.Infrastructure/Diets/EfDietRepository.cs
Normal file
33
backend/src/Health.Infrastructure/Diets/EfDietRepository.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
51
backend/src/Health.Infrastructure/Users/EfUserRepository.cs
Normal file
51
backend/src/Health.Infrastructure/Users/EfUserRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user