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

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

View File

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

View 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;
}
}