feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Medications;
|
||||
|
||||
public sealed record MedicationUpsertRequest(
|
||||
string Name,
|
||||
string? Dosage,
|
||||
MedicationFrequency Frequency,
|
||||
IReadOnlyList<TimeOnly> TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
MedicationSource Source,
|
||||
string? Notes);
|
||||
|
||||
public sealed record MedicationPatchRequest(
|
||||
string? Name,
|
||||
string? Dosage,
|
||||
MedicationFrequency? Frequency,
|
||||
IReadOnlyList<TimeOnly>? TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? Notes);
|
||||
|
||||
public sealed record MedicationDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Dosage,
|
||||
string Frequency,
|
||||
IReadOnlyList<TimeOnly> TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
bool IsActive,
|
||||
string? Notes,
|
||||
string Source,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
bool TodayTaken);
|
||||
|
||||
public sealed record MedicationReminderDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Dosage,
|
||||
string ScheduledTime,
|
||||
string Status);
|
||||
|
||||
public sealed record MedicationReminderTask(
|
||||
Guid TaskId,
|
||||
Guid UserId,
|
||||
Guid MedicationId,
|
||||
string Name,
|
||||
string? Dosage,
|
||||
DateOnly ScheduledDate,
|
||||
TimeOnly ScheduledTime);
|
||||
|
||||
public interface IMedicationService
|
||||
{
|
||||
Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
|
||||
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationRepository
|
||||
{
|
||||
Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
||||
Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task AddMedicationAsync(Medication medication, CancellationToken ct);
|
||||
Task AddLogAsync(MedicationLog log, CancellationToken ct);
|
||||
void DeleteMedication(Medication medication);
|
||||
void DeleteLog(MedicationLog log);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationReminderScanner
|
||||
{
|
||||
Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationReminderQueue
|
||||
{
|
||||
Task<bool> EnqueueAsync(MedicationReminderTask task, CancellationToken ct);
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct);
|
||||
Task CompleteAsync(Guid taskId, CancellationToken ct);
|
||||
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationReminderDispatcher
|
||||
{
|
||||
Task DispatchAsync(MedicationReminderTask task, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Medications;
|
||||
|
||||
public sealed class MedicationReminderScanner(IMedicationRepository medications) : IMedicationReminderScanner
|
||||
{
|
||||
private readonly IMedicationRepository _medications = medications;
|
||||
|
||||
public async Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct)
|
||||
{
|
||||
var beijingNow = utcNow.AddHours(8);
|
||||
var date = DateOnly.FromDateTime(beijingNow);
|
||||
var time = TimeOnly.FromDateTime(beijingNow);
|
||||
var windowStart = time.AddMinutes(-5);
|
||||
var todayStartUtc = beijingNow.Date.AddHours(-8);
|
||||
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||
var active = await _medications.ListActiveForReminderScanAsync(ct);
|
||||
var tasks = new List<MedicationReminderTask>();
|
||||
|
||||
foreach (var medication in active)
|
||||
{
|
||||
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue;
|
||||
if (await _medications.HasTakenInWindowAsync(medication.UserId, medication.Id, todayStartUtc, todayEndUtc, ct)) continue;
|
||||
|
||||
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
|
||||
{
|
||||
tasks.Add(new MedicationReminderTask(
|
||||
Guid.NewGuid(),
|
||||
medication.UserId,
|
||||
medication.Id,
|
||||
medication.Name,
|
||||
medication.Dosage,
|
||||
date,
|
||||
scheduledTime));
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private static bool IsActiveOn(Medication medication, DateOnly date) =>
|
||||
(medication.StartDate == null || medication.StartDate <= date)
|
||||
&& (medication.EndDate == null || medication.EndDate >= date);
|
||||
|
||||
private static bool IsInWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
|
||||
end >= start ? value >= start && value <= end : value >= start || value <= end;
|
||||
|
||||
private static bool GetDosesForToday(Medication medication, DateOnly today)
|
||||
{
|
||||
if (medication.Frequency is MedicationFrequency.Daily
|
||||
or MedicationFrequency.TwiceDaily
|
||||
or MedicationFrequency.ThreeTimesDaily
|
||||
or MedicationFrequency.AsNeeded)
|
||||
return true;
|
||||
|
||||
var startDate = medication.StartDate ?? today;
|
||||
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
|
||||
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
|
||||
if (medication.Frequency == MedicationFrequency.Weekly)
|
||||
return today.DayOfWeek == startDate.DayOfWeek;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
217
backend/src/Health.Application/Medications/MedicationService.cs
Normal file
217
backend/src/Health.Application/Medications/MedicationService.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Medications;
|
||||
|
||||
public sealed class MedicationService(IMedicationRepository medications) : IMedicationService
|
||||
{
|
||||
private readonly IMedicationRepository _medications = medications;
|
||||
|
||||
public async Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct)
|
||||
{
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var meds = await _medications.ListAsync(userId, filter, ct);
|
||||
return meds.Select(m => ToDto(m, HasTakenToday(m, todayStartUtc, todayEndUtc))).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
var med = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = request.Name,
|
||||
Dosage = request.Dosage,
|
||||
Frequency = request.Frequency,
|
||||
TimeOfDay = request.TimeOfDay.Count > 0 ? request.TimeOfDay.ToList() : [new TimeOnly(8, 0)],
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Source = request.Source,
|
||||
Notes = request.Notes,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _medications.AddMedicationAsync(med, ct);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return med.Id;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct)
|
||||
{
|
||||
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
|
||||
if (med == null) return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name)) med.Name = request.Name;
|
||||
if (request.Dosage != null) med.Dosage = request.Dosage;
|
||||
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
||||
if (request.TimeOfDay != null) med.TimeOfDay = request.TimeOfDay.ToList();
|
||||
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
||||
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||
if (request.Notes != null) med.Notes = request.Notes;
|
||||
med.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
{
|
||||
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
|
||||
if (med == null) return false;
|
||||
|
||||
_medications.DeleteMedication(med);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct);
|
||||
if (existing != null)
|
||||
{
|
||||
_medications.DeleteLog(existing);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
var now = TimeOnly.FromDateTime(beijingNow);
|
||||
var today = DateOnly.FromDateTime(beijingNow);
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
|
||||
var meds = await _medications.ListActiveForRemindersAsync(userId, today, ct);
|
||||
var logs = await _medications.ListLogsInWindowAsync(userId, todayStartUtc, todayEndUtc, ct);
|
||||
|
||||
var reminders = new List<MedicationReminderDto>();
|
||||
foreach (var med in meds)
|
||||
{
|
||||
if (!GetDosesForToday(med, today)) continue;
|
||||
|
||||
foreach (var scheduledTime in med.TimeOfDay)
|
||||
{
|
||||
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
|
||||
string status;
|
||||
if (log != null)
|
||||
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
|
||||
else if (scheduledTime <= now.AddMinutes(-30))
|
||||
status = "overdue";
|
||||
else if (scheduledTime <= now.AddHours(3))
|
||||
status = "upcoming";
|
||||
else
|
||||
continue;
|
||||
|
||||
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
|
||||
}
|
||||
}
|
||||
|
||||
return reminders
|
||||
.OrderBy(r => r.Status == "overdue" ? 0 : r.Status == "upcoming" ? 1 : 2)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
|
||||
if (existing) return false;
|
||||
|
||||
await AddLogAsync(userId, medicationId, status, scheduledTime, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct)
|
||||
{
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var log = await _medications.GetDoseLogAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
|
||||
if (log == null) return false;
|
||||
|
||||
_medications.DeleteLog(log);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) =>
|
||||
ListAsync(userId, "active", ct);
|
||||
|
||||
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
{
|
||||
var result = await ConfirmDoseAsync(
|
||||
userId,
|
||||
medicationId,
|
||||
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
MedicationLogStatus.Taken,
|
||||
ct);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
|
||||
{
|
||||
await _medications.AddLogAsync(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MedicationId = medicationId,
|
||||
UserId = userId,
|
||||
Status = status,
|
||||
ScheduledTime = scheduledTime,
|
||||
ConfirmedAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, ct);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static MedicationDto ToDto(Medication med, bool todayTaken) => new(
|
||||
med.Id,
|
||||
med.Name,
|
||||
med.Dosage,
|
||||
med.Frequency.ToString(),
|
||||
med.TimeOfDay,
|
||||
med.StartDate,
|
||||
med.EndDate,
|
||||
med.IsActive,
|
||||
med.Notes,
|
||||
med.Source.ToString(),
|
||||
med.CreatedAt,
|
||||
med.UpdatedAt,
|
||||
todayTaken);
|
||||
|
||||
private static bool HasTakenToday(Medication med, DateTime todayStartUtc, DateTime todayEndUtc) =>
|
||||
med.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken);
|
||||
|
||||
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
|
||||
{
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
return (todayStartUtc, todayStartUtc.AddDays(1));
|
||||
}
|
||||
|
||||
private static bool GetDosesForToday(Medication med, DateOnly today)
|
||||
{
|
||||
if (med.Frequency is MedicationFrequency.Daily
|
||||
or MedicationFrequency.TwiceDaily
|
||||
or MedicationFrequency.ThreeTimesDaily
|
||||
or MedicationFrequency.AsNeeded)
|
||||
return true;
|
||||
|
||||
var startDate = med.StartDate ?? today;
|
||||
if (med.Frequency == MedicationFrequency.EveryOtherDay)
|
||||
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
|
||||
|
||||
if (med.Frequency == MedicationFrequency.Weekly)
|
||||
return today.DayOfWeek == startDate.DayOfWeek;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user