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

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

View File

@@ -1,3 +1,5 @@
using Health.Application.Medications;
namespace Health.WebApi.Endpoints;
public static class MedicationEndpoints
@@ -6,255 +8,139 @@ public static class MedicationEndpoints
{
var group = app.MapGroup("/api/medications").RequireAuthorization();
group.MapGet("/", async (string? filter, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapGet("/", async (string? filter, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var query = db.Medications.Where(m => m.UserId == userId);
if (filter == "active") query = query.Where(m => m.IsActive);
else if (filter == "inactive") query = query.Where(m => !m.IsActive);
// 北京时间今天对应的 UTC 区间
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
var todayStartUtc = beijingToday.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var meds = await query.OrderByDescending(m => m.CreatedAt).Select(m => new
{
m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(),
m.TimeOfDay, m.StartDate, m.EndDate, m.IsActive, m.Notes,
Source = m.Source.ToString(), m.CreatedAt, m.UpdatedAt,
todayTaken = m.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken)
}).ToListAsync(ct);
var meds = await medications.ListAsync(userId, filter, ct);
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
});
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPost("/", async (HttpRequest request, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
request.EnableBuffering();
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync(ct);
request.Body.Position = 0;
using var json = JsonDocument.Parse(body);
var root = json.RootElement;
var med = new Medication
{
Id = Guid.NewGuid(), UserId = userId,
Name = root.GetProperty("name").GetString()!,
Dosage = root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
Frequency = Enum.TryParse<MedicationFrequency>(root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily", out var freq) ? freq : MedicationFrequency.Daily,
Source = Enum.TryParse<MedicationSource>(root.TryGetProperty("source", out var s) ? s.GetString() : "Manual", out var src) ? src : MedicationSource.Manual,
IsActive = true,
};
if (root.TryGetProperty("timeOfDay", out var tod))
med.TimeOfDay = tod.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList();
if (root.TryGetProperty("startDate", out var sd))
med.StartDate = DateOnly.TryParse(sd.GetString(), out var d) ? d : null;
if (root.TryGetProperty("endDate", out var ed))
med.EndDate = DateOnly.TryParse(ed.GetString(), out var d2) ? d2 : null;
if (root.TryGetProperty("notes", out var nt)) med.Notes = nt.GetString();
db.Medications.Add(med);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
var medId = await medications.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct);
return Results.Ok(new { code = 0, data = new { id = medId }, message = (string?)null });
});
group.MapPut("/{id:guid}", async (Guid id, HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPut("/{id:guid}", async (Guid id, HttpRequest request, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
request.EnableBuffering();
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync(ct);
request.Body.Position = 0;
using var json = JsonDocument.Parse(body);
var root = json.RootElement;
if (root.TryGetProperty("name", out var n)) med.Name = n.GetString()!;
if (root.TryGetProperty("dosage", out var dg2)) med.Dosage = dg2.GetString();
if (root.TryGetProperty("frequency", out var f2) && Enum.TryParse<MedicationFrequency>(f2.GetString(), out var freq2)) med.Frequency = freq2;
if (root.TryGetProperty("timeOfDay", out var tod2)) med.TimeOfDay = tod2.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList();
if (root.TryGetProperty("startDate", out var sd2) && DateOnly.TryParse(sd2.GetString(), out var d3)) med.StartDate = d3;
if (root.TryGetProperty("endDate", out var ed2) && DateOnly.TryParse(ed2.GetString(), out var d4)) med.EndDate = d4;
if (root.TryGetProperty("notes", out var nt2)) med.Notes = nt2.GetString();
med.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
var updated = await medications.UpdateAsync(userId, id, ReadPatchRequest(json.RootElement), ct);
if (!updated) return Results.Ok(new { code = 40004, message = "不存在" });
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
if (med != null) { db.Medications.Remove(med); await db.SaveChangesAsync(ct); }
await medications.DeleteAsync(userId, id, ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
var todayStartUtc = beijingToday.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken, ct);
if (existing != null) {
db.MedicationLogs.Remove(existing);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { taken = false }, message = (string?)null });
}
var log = new MedicationLog
{
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
};
db.MedicationLogs.Add(log);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { taken = true }, message = (string?)null });
var taken = await medications.ToggleTodayTakenAsync(userId, id, ct);
if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" });
return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null });
});
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var beijingNow = DateTime.UtcNow.AddHours(8);
var now = TimeOnly.FromDateTime(beijingNow);
var today = DateOnly.FromDateTime(beijingNow);
var todayUtc = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
// 只查活跃且在有效期内的药
var meds = await db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null
&& m.StartDate <= today && (m.EndDate == null || m.EndDate >= today))
.ToListAsync(ct);
// 查询今天所有服药记录
var logs = await db.MedicationLogs
.Where(l => l.UserId == userId && l.ConfirmedAt >= todayUtc)
.ToListAsync(ct);
// 按次生成提醒
var reminders = new List<object>();
foreach (var m in meds)
{
// 处理隔天/每周的逻辑
var dosesToday = GetDosesForToday(m, today);
if (!dosesToday) continue;
foreach (var t in m.TimeOfDay!)
{
var scheduledTime = t;
var doseTimeBeijing = today.ToDateTime(scheduledTime);
// 查该药该时间是否已打卡
var alreadyLogged = logs.Any(l =>
l.MedicationId == m.Id && l.ScheduledTime == scheduledTime);
string status;
if (alreadyLogged)
{
var log = logs.First(l => l.MedicationId == m.Id && l.ScheduledTime == scheduledTime);
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
}
else if (scheduledTime <= now.AddMinutes(-30))
{
status = "overdue"; // 过了30分钟还没吃
}
else if (scheduledTime <= now.AddHours(3))
{
status = "upcoming"; // 未来3小时内
}
else
{
continue; // 太远,不显示
}
reminders.Add(new
{
m.Id, m.Name, m.Dosage,
scheduledTime = scheduledTime.ToString("HH:mm"),
status,
});
}
}
// overdue 在前upcoming 在后taken 在最后
reminders = reminders.OrderBy(r =>
((dynamic)r).status == "overdue" ? 0 :
((dynamic)r).status == "upcoming" ? 1 : 2
).ToList();
var reminders = await medications.GetRemindersAsync(userId, ct);
return Results.Ok(new { code = 0, data = reminders, message = (string?)null });
});
// 按次打卡
group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
if (med == null) return Results.Ok(new { code = 404, message = "药品不存在" });
using var reader = new System.IO.StreamReader(http.Request.Body);
var body = await reader.ReadToEndAsync(ct);
var json = System.Text.Json.JsonDocument.Parse(body);
using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct));
var timeStr = json.RootElement.GetProperty("scheduledTime").GetString()!;
var statusStr = json.RootElement.TryGetProperty("status", out var st) ? st.GetString() : "taken";
var scheduledTime = TimeOnly.Parse(timeStr);
var status = statusStr == "taken" ? MedicationLogStatus.Taken : MedicationLogStatus.Skipped;
// 防止重复打卡
var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc);
var existing = await db.MedicationLogs.AnyAsync(l =>
l.MedicationId == id && l.UserId == userId
&& l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc, ct);
if (existing) return Results.Ok(new { code = 0, data = new { success = false, message = "已打卡" }, message = (string?)null });
var result = await medications.ConfirmDoseAsync(userId, id, scheduledTime, status, ct);
if (result == null) return Results.Ok(new { code = 404, message = "药品不存在" });
if (result == false) return Results.Ok(new { code = 0, data = new { success = false, message = "已打卡" }, message = (string?)null });
db.MedicationLogs.Add(new MedicationLog
{
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
Status = status, ScheduledTime = scheduledTime, ConfirmedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
// 取消打卡(删掉对应 log
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var scheduledTime = TimeOnly.Parse(time);
var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc);
var log = await db.MedicationLogs
.Where(l => l.MedicationId == id && l.UserId == userId
&& l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc)
.FirstOrDefaultAsync(ct);
if (log != null) { db.MedicationLogs.Remove(log); await db.SaveChangesAsync(ct); }
await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
}
/// <summary>
/// 判断今天是否该吃药(处理隔天/每周频率)
/// </summary>
private static bool GetDosesForToday(Medication m, DateOnly today)
private static MedicationUpsertRequest ReadCreateRequest(JsonElement root)
{
if (m.Frequency == MedicationFrequency.Daily) return true;
if (m.Frequency == MedicationFrequency.TwiceDaily) return true;
if (m.Frequency == MedicationFrequency.ThreeTimesDaily) return true;
if (m.Frequency == MedicationFrequency.AsNeeded) return true;
var frequency = Enum.TryParse<MedicationFrequency>(
root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily",
out var freq)
? freq
: MedicationFrequency.Daily;
var source = Enum.TryParse<MedicationSource>(
root.TryGetProperty("source", out var s) ? s.GetString() : "Manual",
out var src)
? src
: MedicationSource.Manual;
var startDate = m.StartDate ?? today;
if (m.Frequency == MedicationFrequency.EveryOtherDay)
{
var daysSinceStart = today.DayNumber - startDate.DayNumber;
return daysSinceStart % 2 == 0;
}
if (m.Frequency == MedicationFrequency.Weekly)
{
return today.DayOfWeek == startDate.DayOfWeek;
}
return true;
return new MedicationUpsertRequest(
root.GetProperty("name").GetString()!,
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
frequency,
ReadTimes(root, "timeOfDay"),
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
source,
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
}
private static bool InTimeWindow(TimeOnly t, TimeOnly start, TimeOnly end) =>
end > start ? (t >= start && t <= end) : (t >= start || t <= end); // 处理跨午夜
private static MedicationPatchRequest ReadPatchRequest(JsonElement root)
{
MedicationFrequency? frequency = null;
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
frequency = freq;
return new MedicationPatchRequest(
root.TryGetProperty("name", out var n) ? n.GetString() : null,
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
frequency,
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
}
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var tod) || tod.ValueKind != JsonValueKind.Array)
return [];
return tod.EnumerateArray()
.Select(t => TimeOnly.TryParse(t.GetString(), out var parsed) ? parsed : (TimeOnly?)null)
.Where(t => t.HasValue)
.Select(t => t!.Value)
.ToList();
}
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken ct)
{
request.EnableBuffering();
using var reader = new StreamReader(request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync(ct);
request.Body.Position = 0;
return string.IsNullOrWhiteSpace(body) ? "{}" : body;
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}