feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
63
backend/src/Health.Application/AI/AiConversationContracts.cs
Normal file
63
backend/src/Health.Application/AI/AiConversationContracts.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed record AiConversationDto(
|
||||
Guid Id,
|
||||
string AgentType,
|
||||
string? Title,
|
||||
string? Summary,
|
||||
int MessageCount,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record AiConversationMessageDto(
|
||||
Guid Id,
|
||||
string Role,
|
||||
string Content,
|
||||
string? Intent,
|
||||
string? MetadataJson,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record OpenConversationResult(
|
||||
bool Found,
|
||||
bool Created,
|
||||
Guid ConversationId);
|
||||
|
||||
public interface IAiConversationService
|
||||
{
|
||||
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
|
||||
Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
||||
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IAiConversationRepository
|
||||
{
|
||||
Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
||||
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
||||
void Delete(Conversation conversation);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IPatientContextService
|
||||
{
|
||||
Task<string> BuildAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record AiConfirmedWriteResult(int Code, object? Data, string? Message);
|
||||
|
||||
public interface IAiToolExecutionService
|
||||
{
|
||||
Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct);
|
||||
Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct);
|
||||
}
|
||||
111
backend/src/Health.Application/AI/AiConversationService.cs
Normal file
111
backend/src/Health.Application/AI/AiConversationService.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||
{
|
||||
private readonly IAiConversationRepository _conversations = conversations;
|
||||
|
||||
public async Task<OpenConversationResult> OpenAsync(
|
||||
Guid userId,
|
||||
Guid? conversationId,
|
||||
AgentType agentType,
|
||||
string firstMessage,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (conversationId.HasValue)
|
||||
{
|
||||
var existing = await _conversations.GetOwnedAsync(userId, conversationId.Value, ct);
|
||||
return existing == null
|
||||
? new OpenConversationResult(false, false, conversationId.Value)
|
||||
: new OpenConversationResult(true, false, existing.Id);
|
||||
}
|
||||
|
||||
var conversation = new Conversation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
AgentType = agentType,
|
||||
Title = firstMessage.Length > 30 ? firstMessage[..30] : firstMessage,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _conversations.AddConversationAsync(conversation, ct);
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
return new OpenConversationResult(true, true, conversation.Id);
|
||||
}
|
||||
|
||||
public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
||||
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct);
|
||||
|
||||
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
||||
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct);
|
||||
|
||||
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
|
||||
{
|
||||
var messages = await _conversations.GetRecentMessagesAsync(conversationId, limit, ct);
|
||||
return messages.OrderBy(m => m.CreatedAt).Select(ToMessageDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var conversations = await _conversations.ListAsync(userId, ct);
|
||||
return conversations.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||
if (conversation == null) return [];
|
||||
|
||||
var messages = await _conversations.GetMessagesAsync(conversationId, ct);
|
||||
return messages.Select(ToMessageDto).ToList();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||
if (conversation == null) return;
|
||||
|
||||
_conversations.Delete(conversation);
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetAsync(conversationId, ct)
|
||||
?? throw new InvalidOperationException("会话不存在");
|
||||
await _conversations.AddMessageAsync(new ConversationMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ConversationId = conversationId,
|
||||
Role = role,
|
||||
Content = content,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, ct);
|
||||
|
||||
conversation.MessageCount++;
|
||||
conversation.UpdatedAt = DateTime.UtcNow;
|
||||
if (updateSummary)
|
||||
conversation.Summary = content.Length > 100 ? content[..100] : content;
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static AiConversationDto ToDto(Conversation conversation) => new(
|
||||
conversation.Id,
|
||||
conversation.AgentType.ToString(),
|
||||
conversation.Title,
|
||||
conversation.Summary,
|
||||
conversation.MessageCount,
|
||||
conversation.CreatedAt,
|
||||
conversation.UpdatedAt);
|
||||
|
||||
private static AiConversationMessageDto ToMessageDto(ConversationMessage message) => new(
|
||||
message.Id,
|
||||
message.Role.ToString(),
|
||||
message.Content,
|
||||
message.Intent,
|
||||
message.MetadataJson,
|
||||
message.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed record PendingAiWriteCommand(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string ToolName,
|
||||
string Arguments,
|
||||
DateTime ExpiresAt);
|
||||
|
||||
public interface IAiWriteConfirmationStore
|
||||
{
|
||||
Task<PendingAiWriteCommand> CreateAsync(Guid userId, string toolName, string arguments, TimeSpan lifetime, CancellationToken ct);
|
||||
Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct);
|
||||
Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct);
|
||||
}
|
||||
65
backend/src/Health.Application/AI/PatientContextService.cs
Normal file
65
backend/src/Health.Application/AI/PatientContextService.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Text;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Medications;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed class PatientContextService(
|
||||
IHealthArchiveService archives,
|
||||
IHealthRecordService healthRecords,
|
||||
IMedicationService medications) : IPatientContextService
|
||||
{
|
||||
private readonly IHealthArchiveService _archives = archives;
|
||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||
private readonly IMedicationService _medications = medications;
|
||||
|
||||
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await _archives.GetAsync(userId, ct);
|
||||
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
|
||||
var activeMedications = await _medications.ListActiveAsync(userId, ct);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (archive != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||
if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}");
|
||||
if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}");
|
||||
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
|
||||
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
|
||||
}
|
||||
|
||||
if (activeMedications.Count > 0)
|
||||
{
|
||||
sb.AppendLine("当前用药:");
|
||||
foreach (var medication in activeMedications.Take(20))
|
||||
{
|
||||
var times = medication.TimeOfDay.Count > 0
|
||||
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
|
||||
: "未设置时间";
|
||||
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
|
||||
}
|
||||
}
|
||||
|
||||
if (recentRecords.Count > 0)
|
||||
{
|
||||
sb.AppendLine("近期健康数据:");
|
||||
foreach (var record in recentRecords)
|
||||
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RecordValue(HealthRecordDto record) => record.Type switch
|
||||
{
|
||||
"BloodPressure" => $"{record.Systolic}/{record.Diastolic}",
|
||||
"HeartRate" => $"{record.Value}次/分",
|
||||
"Glucose" => $"{record.Value}",
|
||||
"SpO2" => $"{record.Value}%",
|
||||
"Weight" => $"{record.Value}kg",
|
||||
_ => "—"
|
||||
};
|
||||
}
|
||||
15
backend/src/Health.Application/Admin/AdminContracts.cs
Normal file
15
backend/src/Health.Application/Admin/AdminContracts.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Health.Application.Admin;
|
||||
|
||||
public sealed record AdminResult(int Code, object? Data, string? Message = null);
|
||||
public sealed record AddDoctorCommand(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||
public sealed record UpdateDoctorCommand(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||
|
||||
public interface IAdminService
|
||||
{
|
||||
Task<AdminResult> ListDoctorsAsync(CancellationToken ct);
|
||||
Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct);
|
||||
Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct);
|
||||
Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct);
|
||||
Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct);
|
||||
Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct);
|
||||
}
|
||||
13
backend/src/Health.Application/Auth/AuthContracts.cs
Normal file
13
backend/src/Health.Application/Auth/AuthContracts.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Health.Application.Auth;
|
||||
|
||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Calendars;
|
||||
|
||||
public sealed record CalendarDataSnapshot(
|
||||
IReadOnlyList<Medication> Medications,
|
||||
IReadOnlyList<ExercisePlan> ExercisePlans,
|
||||
IReadOnlyList<FollowUp> FollowUps);
|
||||
|
||||
public interface ICalendarService
|
||||
{
|
||||
Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct);
|
||||
Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface ICalendarRepository
|
||||
{
|
||||
Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct);
|
||||
}
|
||||
92
backend/src/Health.Application/Calendars/CalendarService.cs
Normal file
92
backend/src/Health.Application/Calendars/CalendarService.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
namespace Health.Application.Calendars;
|
||||
|
||||
public sealed class CalendarService(ICalendarRepository calendar) : ICalendarService
|
||||
{
|
||||
private readonly ICalendarRepository _calendar = calendar;
|
||||
|
||||
public async Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct)
|
||||
{
|
||||
var start = new DateOnly(year, month, 1);
|
||||
var end = start.AddMonths(1);
|
||||
var snapshot = await _calendar.GetSnapshotAsync(userId, start, end, ct);
|
||||
var result = new List<object>();
|
||||
|
||||
for (var date = start; date < end; date = date.AddDays(1))
|
||||
{
|
||||
var entries = BuildEntries(snapshot, date);
|
||||
if (entries.Count == 0) continue;
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
date = date.ToString("yyyy-MM-dd"),
|
||||
events = entries.Select(entry => entry.Type).Distinct().ToList(),
|
||||
details = entries.Select(entry => entry.Value).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct)
|
||||
{
|
||||
var snapshot = await _calendar.GetSnapshotAsync(userId, date, date.AddDays(1), ct);
|
||||
var medications = snapshot.Medications
|
||||
.Where(m => IsMedicationActiveOn(m, date))
|
||||
.Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() })
|
||||
.ToList();
|
||||
var exercises = snapshot.ExercisePlans
|
||||
.SelectMany(p => p.Items)
|
||||
.Where(i => i.ScheduledDate == date && !i.IsRestDay)
|
||||
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
|
||||
.ToList();
|
||||
var followUps = snapshot.FollowUps
|
||||
.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)
|
||||
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
|
||||
.ToList();
|
||||
|
||||
return new { medications, exercises, followUps };
|
||||
}
|
||||
|
||||
private static List<CalendarEntry> BuildEntries(CalendarDataSnapshot snapshot, DateOnly date)
|
||||
{
|
||||
var entries = new List<CalendarEntry>();
|
||||
foreach (var medication in snapshot.Medications.Where(m => IsMedicationActiveOn(m, date)))
|
||||
{
|
||||
entries.Add(new CalendarEntry("medication", new
|
||||
{
|
||||
type = "medication",
|
||||
name = medication.Name,
|
||||
dosage = medication.Dosage,
|
||||
timeOfDay = medication.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList()
|
||||
}));
|
||||
}
|
||||
|
||||
foreach (var plan in snapshot.ExercisePlans)
|
||||
{
|
||||
foreach (var item in plan.Items.Where(i => i.ScheduledDate == date && !i.IsRestDay))
|
||||
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
|
||||
}
|
||||
|
||||
foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date))
|
||||
{
|
||||
entries.Add(new CalendarEntry("followup", new
|
||||
{
|
||||
type = "followup",
|
||||
title = followUp.Title,
|
||||
doctorName = followUp.DoctorName,
|
||||
department = followUp.Department,
|
||||
status = followUp.Status.ToString()
|
||||
}));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static bool IsMedicationActiveOn(Health.Domain.Entities.Medication medication, DateOnly date) =>
|
||||
medication.IsActive
|
||||
&& medication.TimeOfDay.Count > 0
|
||||
&& (medication.StartDate == null || medication.StartDate <= date)
|
||||
&& (medication.EndDate == null || medication.EndDate >= date);
|
||||
|
||||
private sealed record CalendarEntry(string Type, object Value);
|
||||
}
|
||||
51
backend/src/Health.Application/Diets/DietContracts.cs
Normal file
51
backend/src/Health.Application/Diets/DietContracts.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed record DietFoodItemInput(
|
||||
string Name,
|
||||
string? Portion,
|
||||
int? Calories,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record DietRecordCreateRequest(
|
||||
MealType MealType,
|
||||
int? TotalCalories,
|
||||
int? HealthScore,
|
||||
DateOnly RecordedAt,
|
||||
IReadOnlyList<DietFoodItemInput> FoodItems);
|
||||
|
||||
public sealed record DietRecordPatchRequest(
|
||||
int? TotalCalories,
|
||||
int? HealthScore);
|
||||
|
||||
public sealed record DietFoodItemDto(
|
||||
string Name,
|
||||
string? Portion,
|
||||
int? Calories);
|
||||
|
||||
public sealed record DietRecordDto(
|
||||
Guid Id,
|
||||
string MealType,
|
||||
int? TotalCalories,
|
||||
int? HealthScore,
|
||||
DateOnly RecordedAt,
|
||||
IReadOnlyList<DietFoodItemDto> FoodItems);
|
||||
|
||||
public interface IDietService
|
||||
{
|
||||
Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IDietRepository
|
||||
{
|
||||
Task<IReadOnlyList<DietRecord>> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct);
|
||||
Task<DietRecord?> GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct);
|
||||
Task AddAsync(DietRecord record, CancellationToken ct);
|
||||
void Delete(DietRecord record);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed record DietImageUploadFile(string FileName, long Length, Stream Content);
|
||||
|
||||
public sealed record DietImageAnalysisJob(Guid Id, IReadOnlyList<string> FilePaths);
|
||||
|
||||
public sealed record DietImageAnalysisResult(bool Success, int Code, string? Data, string? Message);
|
||||
|
||||
public interface IDietImageAnalysisCoordinator
|
||||
{
|
||||
Task<DietImageAnalysisResult> AnalyzeAsync(IReadOnlyList<DietImageUploadFile> files, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IDietImageAnalysisQueue
|
||||
{
|
||||
Task<Guid> EnqueueAsync(IReadOnlyList<string> filePaths, CancellationToken ct);
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct);
|
||||
Task<DietImageAnalysisResult> WaitForResultAsync(Guid jobId, CancellationToken ct);
|
||||
Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct);
|
||||
Task<bool> RetryAsync(Guid jobId, string error, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IDietImageAnalyzer
|
||||
{
|
||||
Task<DietImageAnalysisResult> AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct);
|
||||
}
|
||||
77
backend/src/Health.Application/Diets/DietService.cs
Normal file
77
backend/src/Health.Application/Diets/DietService.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed class DietService(IDietRepository diets) : IDietService
|
||||
{
|
||||
private readonly IDietRepository _diets = diets;
|
||||
|
||||
public async Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct)
|
||||
{
|
||||
var recordedAt = DateOnly.TryParse(date, out var d) ? d : (DateOnly?)null;
|
||||
var parsedMealType = Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt) ? mt : (MealType?)null;
|
||||
|
||||
var records = await _diets.ListAsync(userId, recordedAt, parsedMealType, ct);
|
||||
return records.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = new DietRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MealType = request.MealType,
|
||||
TotalCalories = request.TotalCalories,
|
||||
HealthScore = request.HealthScore,
|
||||
RecordedAt = request.RecordedAt,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
foreach (var item in request.FoodItems)
|
||||
{
|
||||
record.FoodItems.Add(new DietFoodItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = item.Name,
|
||||
Portion = item.Portion,
|
||||
Calories = item.Calories,
|
||||
SortOrder = item.SortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
await _diets.AddAsync(record, ct);
|
||||
await _diets.SaveChangesAsync(ct);
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct)
|
||||
{
|
||||
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
_diets.Delete(record);
|
||||
await _diets.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value;
|
||||
if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value;
|
||||
await _diets.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DietRecordDto ToDto(DietRecord record) => new(
|
||||
record.Id,
|
||||
record.MealType.ToString(),
|
||||
record.TotalCalories,
|
||||
record.HealthScore,
|
||||
record.RecordedAt,
|
||||
record.FoodItems.OrderBy(f => f.SortOrder).Select(f => new DietFoodItemDto(f.Name, f.Portion, f.Calories)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
public sealed record ExercisePlanItemInput(
|
||||
DateOnly ScheduledDate,
|
||||
string? ExerciseType,
|
||||
int DurationMinutes,
|
||||
bool IsRestDay);
|
||||
|
||||
public sealed record ExercisePlanCreateRequest(
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
string? ExerciseType,
|
||||
int DurationMinutes,
|
||||
TimeOnly? ReminderTime = null);
|
||||
|
||||
public sealed record ExercisePlanItemDto(
|
||||
Guid Id,
|
||||
DateOnly ScheduledDate,
|
||||
string ExerciseType,
|
||||
int DurationMinutes,
|
||||
bool IsCompleted,
|
||||
DateTime? CompletedAt,
|
||||
bool IsRestDay);
|
||||
|
||||
public sealed record ExercisePlanDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
TimeOnly ReminderTime,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<ExercisePlanItemDto> Items);
|
||||
|
||||
public sealed record ExercisePlanSummaryDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
TimeOnly ReminderTime,
|
||||
DateTime CreatedAt,
|
||||
int TotalDays,
|
||||
int CompletedDays,
|
||||
IReadOnlyList<ExercisePlanItemDto> Items);
|
||||
|
||||
public interface IExerciseService
|
||||
{
|
||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||
Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IExerciseRepository
|
||||
{
|
||||
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task AddAsync(ExercisePlan plan, CancellationToken ct);
|
||||
void Delete(ExercisePlan plan);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IExerciseReminderProducer
|
||||
{
|
||||
Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
132
backend/src/Health.Application/Exercises/ExerciseService.cs
Normal file
132
backend/src/Health.Application/Exercises/ExerciseService.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService
|
||||
{
|
||||
private static readonly TimeOnly DefaultReminderTime = new(19, 0);
|
||||
private readonly IExerciseRepository _exercises = exercises;
|
||||
|
||||
public async Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var today = BeijingToday();
|
||||
var plans = await _exercises.ListActiveOnAsync(userId, today, ct);
|
||||
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.ScheduledDate == today));
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct)
|
||||
{
|
||||
var startDate = request.StartDate;
|
||||
var endDate = request.EndDate < startDate ? startDate : request.EndDate;
|
||||
if (endDate.DayNumber - startDate.DayNumber > 365)
|
||||
endDate = startDate.AddDays(365);
|
||||
|
||||
var exerciseType = string.IsNullOrWhiteSpace(request.ExerciseType) ? "运动" : request.ExerciseType.Trim();
|
||||
var duration = request.DurationMinutes > 0 ? request.DurationMinutes : 30;
|
||||
var plan = NewPlan(userId, startDate, endDate, request.ReminderTime);
|
||||
for (var date = startDate; date <= endDate; date = date.AddDays(1))
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
|
||||
DurationMinutes = duration, IsRestDay = false,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
|
||||
{
|
||||
var normalizedEnd = endDate < startDate ? startDate : endDate;
|
||||
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime);
|
||||
foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate,
|
||||
ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(),
|
||||
DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30,
|
||||
IsRestDay = item.IsRestDay,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plans = await _exercises.ListAsync(userId, 20, ct);
|
||||
return plans.Select(p => new ExercisePlanSummaryDto(
|
||||
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt,
|
||||
p.Items.Count, p.Items.Count(i => i.IsCompleted),
|
||||
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList();
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
|
||||
if (plan == null) return false;
|
||||
_exercises.Delete(plan);
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
|
||||
{
|
||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||
if (item == null) return null;
|
||||
item.IsCompleted = !item.IsCompleted;
|
||||
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
|
||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
return item.IsCompleted;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
|
||||
{
|
||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||
if (item == null) return false;
|
||||
item.IsCompleted = true;
|
||||
item.CompletedAt = DateTime.UtcNow;
|
||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
|
||||
{
|
||||
await _exercises.AddAsync(plan, ct);
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
|
||||
ReminderTime = reminderTime ?? DefaultReminderTime,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
|
||||
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
|
||||
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
||||
|
||||
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
|
||||
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
|
||||
item.IsCompleted, item.CompletedAt, item.IsRestDay);
|
||||
|
||||
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.HealthArchives;
|
||||
|
||||
public sealed record HealthArchiveDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string? Diagnosis,
|
||||
string? SurgeryType,
|
||||
DateOnly? SurgeryDate,
|
||||
IReadOnlyList<string> Allergies,
|
||||
IReadOnlyList<string> DietRestrictions,
|
||||
IReadOnlyList<string> ChronicDiseases,
|
||||
string? FamilyHistory,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record HealthArchiveUpdateRequest(
|
||||
string? Diagnosis,
|
||||
string? SurgeryType,
|
||||
DateOnly? SurgeryDate,
|
||||
IReadOnlyList<string>? Allergies,
|
||||
IReadOnlyList<string>? DietRestrictions,
|
||||
IReadOnlyList<string>? ChronicDiseases,
|
||||
string? FamilyHistory);
|
||||
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct);
|
||||
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IHealthArchiveRepository
|
||||
{
|
||||
Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct);
|
||||
Task AddAsync(HealthArchive archive, CancellationToken ct);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.HealthArchives;
|
||||
|
||||
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
||||
{
|
||||
private readonly IHealthArchiveRepository _archives = archives;
|
||||
|
||||
public async Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await _archives.GetByUserIdAsync(userId, ct);
|
||||
return archive == null ? null : ToDto(archive);
|
||||
}
|
||||
|
||||
public async Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||
await _archives.SaveChangesAsync(ct);
|
||||
return ToDto(archive);
|
||||
}
|
||||
|
||||
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||
Apply(archive, request);
|
||||
archive.UpdatedAt = DateTime.UtcNow;
|
||||
await _archives.SaveChangesAsync(ct);
|
||||
return ToDto(archive);
|
||||
}
|
||||
|
||||
public async Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
if (action == "query")
|
||||
{
|
||||
var archive = await GetAsync(userId, ct);
|
||||
return archive == null ? new { found = false } : ToAiResult(archive);
|
||||
}
|
||||
|
||||
var current = await GetOrCreateEntityAsync(userId, ct);
|
||||
var scopedRequest = action switch
|
||||
{
|
||||
"update_diagnosis" => request with
|
||||
{
|
||||
SurgeryType = null, SurgeryDate = null, Allergies = null,
|
||||
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null
|
||||
},
|
||||
"update_surgery" => request with
|
||||
{
|
||||
Diagnosis = null, Allergies = null, DietRestrictions = null,
|
||||
ChronicDiseases = null, FamilyHistory = null
|
||||
},
|
||||
"update_allergies" => new HealthArchiveUpdateRequest(null, null, null, request.Allergies, null, null, null),
|
||||
"update_chronic_diseases" => new HealthArchiveUpdateRequest(null, null, null, null, null, request.ChronicDiseases, null),
|
||||
"update_diet_restrictions" => new HealthArchiveUpdateRequest(null, null, null, null, request.DietRestrictions, null, null),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (scopedRequest == null)
|
||||
return new { success = false, message = $"未知档案操作: {action}" };
|
||||
|
||||
Apply(current, scopedRequest);
|
||||
current.UpdatedAt = DateTime.UtcNow;
|
||||
await _archives.SaveChangesAsync(ct);
|
||||
return new { success = true };
|
||||
}
|
||||
|
||||
private async Task<HealthArchive> GetOrCreateEntityAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await _archives.GetByUserIdAsync(userId, ct);
|
||||
if (archive != null) return archive;
|
||||
|
||||
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId, UpdatedAt = DateTime.UtcNow };
|
||||
await _archives.AddAsync(archive, ct);
|
||||
return archive;
|
||||
}
|
||||
|
||||
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
|
||||
{
|
||||
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
|
||||
if (request.SurgeryType != null) archive.SurgeryType = request.SurgeryType;
|
||||
if (request.SurgeryDate.HasValue) archive.SurgeryDate = request.SurgeryDate.Value;
|
||||
if (request.Allergies != null) archive.Allergies = request.Allergies.ToList();
|
||||
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
||||
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
||||
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
|
||||
}
|
||||
|
||||
private static object ToAiResult(HealthArchiveDto archive) => new
|
||||
{
|
||||
found = true,
|
||||
archive.Diagnosis,
|
||||
archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
archive.Allergies,
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
};
|
||||
|
||||
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
||||
archive.Id,
|
||||
archive.UserId,
|
||||
archive.Diagnosis,
|
||||
archive.SurgeryType,
|
||||
archive.SurgeryDate,
|
||||
archive.Allergies,
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
archive.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Health.Domain.Enums;
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.HealthRecords;
|
||||
|
||||
public sealed record HealthRecordUpsertRequest(
|
||||
HealthMetricType Type,
|
||||
int? Systolic,
|
||||
int? Diastolic,
|
||||
decimal? Value,
|
||||
string? Unit,
|
||||
HealthRecordSource Source,
|
||||
DateTime? RecordedAt);
|
||||
|
||||
public sealed record HealthRecordDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
int? Systolic,
|
||||
int? Diastolic,
|
||||
decimal? Value,
|
||||
string? Unit,
|
||||
string Source,
|
||||
bool IsAbnormal,
|
||||
DateTime RecordedAt);
|
||||
|
||||
public interface IHealthRecordService
|
||||
{
|
||||
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
||||
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IHealthRecordRepository
|
||||
{
|
||||
Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct);
|
||||
Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct);
|
||||
Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct);
|
||||
Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct);
|
||||
Task AddAsync(HealthRecord record, CancellationToken ct);
|
||||
void Delete(HealthRecord record);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.HealthRecords;
|
||||
|
||||
public static class HealthRecordRules
|
||||
{
|
||||
public static bool CheckAbnormal(HealthRecordUpsertRequest request) => request.Type switch
|
||||
{
|
||||
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
||||
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
||||
HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
|
||||
HealthMetricType.SpO2 => request.Value <= 94,
|
||||
HealthMetricType.Weight => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.HealthRecords;
|
||||
|
||||
public sealed class HealthRecordService(IHealthRecordRepository records) : IHealthRecordService
|
||||
{
|
||||
private readonly IHealthRecordRepository _records = records;
|
||||
|
||||
public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct)
|
||||
{
|
||||
HealthMetricType? metricType = null;
|
||||
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var parsed))
|
||||
metricType = parsed;
|
||||
|
||||
var recordedAfter = days.HasValue ? DateTime.UtcNow.AddDays(-days.Value) : (DateTime?)null;
|
||||
var result = await _records.ListAsync(userId, metricType, recordedAfter, 100, ct);
|
||||
return result.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MetricType = request.Type,
|
||||
Systolic = request.Systolic,
|
||||
Diastolic = request.Diastolic,
|
||||
Value = request.Value,
|
||||
Unit = request.Unit,
|
||||
Source = request.Source,
|
||||
RecordedAt = request.RecordedAt ?? DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
|
||||
};
|
||||
|
||||
await _records.AddAsync(record, ct);
|
||||
await _records.SaveChangesAsync(ct);
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
record.MetricType = request.Type;
|
||||
record.Systolic = request.Systolic;
|
||||
record.Diastolic = request.Diastolic;
|
||||
record.Value = request.Value;
|
||||
record.Unit = request.Unit;
|
||||
record.Source = request.Source;
|
||||
record.RecordedAt = request.RecordedAt ?? record.RecordedAt;
|
||||
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
|
||||
|
||||
await _records.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct)
|
||||
{
|
||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
_records.Delete(record);
|
||||
await _records.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var types = new[]
|
||||
{
|
||||
HealthMetricType.BloodPressure,
|
||||
HealthMetricType.HeartRate,
|
||||
HealthMetricType.Glucose,
|
||||
HealthMetricType.SpO2,
|
||||
HealthMetricType.Weight
|
||||
};
|
||||
|
||||
var result = new Dictionary<string, object?>();
|
||||
foreach (var type in types)
|
||||
{
|
||||
var latest = await _records.GetLatestByTypeAsync(userId, type, ct);
|
||||
result[type.ToString()] = latest == null ? null : new
|
||||
{
|
||||
latest.Systolic,
|
||||
latest.Diastolic,
|
||||
latest.Value,
|
||||
latest.Unit,
|
||||
latest.RecordedAt
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
|
||||
{
|
||||
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 };
|
||||
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
|
||||
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
public static HealthRecordDto ToDto(HealthRecord record) => new(
|
||||
record.Id,
|
||||
record.MetricType.ToString(),
|
||||
record.Systolic,
|
||||
record.Diastolic,
|
||||
record.Value,
|
||||
record.Unit,
|
||||
record.Source.ToString(),
|
||||
record.IsAbnormal,
|
||||
record.RecordedAt);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Health.Application.Maintenance;
|
||||
|
||||
public sealed record MaintenanceCleanupResult(
|
||||
int Conversations,
|
||||
int VerificationCodes,
|
||||
int BackgroundTasks);
|
||||
|
||||
public interface IMaintenanceService
|
||||
{
|
||||
Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMaintenanceRepository
|
||||
{
|
||||
Task<MaintenanceCleanupResult> CleanupAsync(DateTime conversationCutoff, DateTime taskCutoff, DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Health.Application.Maintenance;
|
||||
|
||||
public sealed class MaintenanceService(IMaintenanceRepository repository) : IMaintenanceService
|
||||
{
|
||||
private readonly IMaintenanceRepository _repository = repository;
|
||||
|
||||
public Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct) =>
|
||||
_repository.CleanupAsync(utcNow.AddDays(-30), utcNow.AddDays(-30), utcNow, ct);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Health.Application.Notifications;
|
||||
|
||||
public sealed record InAppNotificationDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Title,
|
||||
string Message,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public interface IInAppNotificationService
|
||||
{
|
||||
Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IInAppNotificationRepository
|
||||
{
|
||||
Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record InAppNotificationRecord(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Payload,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Application.Notifications;
|
||||
|
||||
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
|
||||
{
|
||||
private readonly IInAppNotificationRepository _notifications = notifications;
|
||||
|
||||
public async Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var records = await _notifications.GetPendingAsync(userId, ct);
|
||||
return records.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
|
||||
_notifications.AcknowledgeAsync(userId, notificationId, ct);
|
||||
|
||||
private static InAppNotificationDto ToDto(InAppNotificationRecord record)
|
||||
{
|
||||
if (record.Type == "MedicationReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var name = root.TryGetProperty("Name", out var nameValue) ? nameValue.GetString() : null;
|
||||
var dosage = root.TryGetProperty("Dosage", out var dosageValue) ? dosageValue.GetString() : null;
|
||||
var time = root.TryGetProperty("ScheduledTime", out var timeValue) ? timeValue.GetString() : null;
|
||||
var details = string.Join(" · ", new[] { dosage, time }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
return new InAppNotificationDto(
|
||||
record.Id,
|
||||
record.Type,
|
||||
"用药提醒",
|
||||
$"该服用{name ?? "药物"}{(details.Length > 0 ? $"({details})" : "")}了",
|
||||
record.CreatedAt);
|
||||
}
|
||||
|
||||
if (record.Type == "ExerciseReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var type = root.TryGetProperty("ExerciseType", out var typeValue) ? typeValue.GetString() : "运动";
|
||||
var minutes = root.TryGetProperty("DurationMinutes", out var minutesValue) ? minutesValue.GetInt32() : 30;
|
||||
return new InAppNotificationDto(record.Id, record.Type, "运动提醒", $"今天的{type}计划还未完成,目标 {minutes} 分钟", record.CreatedAt);
|
||||
}
|
||||
|
||||
return new InAppNotificationDto(record.Id, record.Type, "健康提醒", "您有一条新的健康提醒", record.CreatedAt);
|
||||
}
|
||||
}
|
||||
83
backend/src/Health.Application/Reports/ReportContracts.cs
Normal file
83
backend/src/Health.Application/Reports/ReportContracts.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Reports;
|
||||
|
||||
public sealed record ReportUploadFile(
|
||||
string FileName,
|
||||
long Length,
|
||||
Stream Content);
|
||||
|
||||
public sealed record ReportDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string FileUrl,
|
||||
string FileType,
|
||||
string Category,
|
||||
string Status,
|
||||
string AiStatus,
|
||||
string ReviewStatus,
|
||||
string? Severity,
|
||||
string? AiSummary,
|
||||
string? AiIndicators,
|
||||
string? DoctorComment,
|
||||
string? DoctorRecommendation,
|
||||
string? DoctorName,
|
||||
DateTime? ReviewedAt,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record ReportUploadResult(
|
||||
bool Success,
|
||||
int Code,
|
||||
string? Message,
|
||||
ReportDto? Report);
|
||||
|
||||
public sealed record ReportAnalysisJob(
|
||||
Guid TaskId,
|
||||
Guid ReportId,
|
||||
string FilePath);
|
||||
|
||||
public sealed record StoredReportFile(
|
||||
string FileUrl,
|
||||
string FilePath);
|
||||
|
||||
public interface IReportService
|
||||
{
|
||||
Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct);
|
||||
Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct);
|
||||
Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportAnalysisQueue
|
||||
{
|
||||
Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default);
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct);
|
||||
Task CompleteAsync(Guid taskId, CancellationToken ct);
|
||||
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportAnalysisService
|
||||
{
|
||||
Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportRepository
|
||||
{
|
||||
Task<IReadOnlyList<Report>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<Report?> GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
Task<Report?> GetByIdAsync(Guid reportId, CancellationToken ct);
|
||||
Task AddAsync(Report report, CancellationToken ct);
|
||||
void Delete(Report report);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportFileStorage
|
||||
{
|
||||
Task<StoredReportFile> SaveAsync(ReportUploadFile file, string extension, CancellationToken ct);
|
||||
string GetLocalFilePath(string fileUrl);
|
||||
bool Exists(string filePath);
|
||||
void Delete(string filePath);
|
||||
}
|
||||
122
backend/src/Health.Application/Reports/ReportService.cs
Normal file
122
backend/src/Health.Application/Reports/ReportService.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Reports;
|
||||
|
||||
public sealed class ReportService(
|
||||
IReportRepository reports,
|
||||
IReportFileStorage fileStorage,
|
||||
IReportAnalysisQueue queue) : IReportService
|
||||
{
|
||||
private const long MaxReportImageBytes = 10 * 1024 * 1024;
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".webp"
|
||||
};
|
||||
|
||||
private readonly IReportRepository _reports = reports;
|
||||
private readonly IReportFileStorage _fileStorage = fileStorage;
|
||||
private readonly IReportAnalysisQueue _queue = queue;
|
||||
|
||||
public async Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var result = await _reports.ListAsync(userId, ct);
|
||||
return result.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct)
|
||||
{
|
||||
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
|
||||
return report == null ? null : ToDto(report);
|
||||
}
|
||||
|
||||
public async Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct)
|
||||
{
|
||||
if (file.Length <= 0)
|
||||
return Fail(400, "未上传文件");
|
||||
|
||||
if (file.Length > MaxReportImageBytes)
|
||||
return Fail(400, "报告图片不能超过 10MB,请压缩后重新上传");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return Fail(400, "目前仅支持上传 JPG、PNG、WEBP 格式的报告图片");
|
||||
|
||||
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
|
||||
var report = new Report
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
FileUrl = storedFile.FileUrl,
|
||||
FileType = ReportFileType.Image,
|
||||
Category = ReportCategory.Other,
|
||||
Status = ReportStatus.Analyzing,
|
||||
AiSummary = null,
|
||||
AiIndicators = null,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _reports.AddAsync(report, ct);
|
||||
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, storedFile.FilePath), ct);
|
||||
|
||||
return new ReportUploadResult(true, 0, "报告已上传,AI 正在分析中", ToDto(report));
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct)
|
||||
{
|
||||
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
|
||||
if (report == null) return false;
|
||||
|
||||
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
|
||||
if (_fileStorage.Exists(filePath))
|
||||
_fileStorage.Delete(filePath);
|
||||
|
||||
_reports.Delete(report);
|
||||
await _reports.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct)
|
||||
{
|
||||
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
|
||||
if (report == null) return false;
|
||||
|
||||
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
|
||||
if (!_fileStorage.Exists(filePath)) return false;
|
||||
|
||||
report.Status = ReportStatus.Analyzing;
|
||||
report.AiSummary = null;
|
||||
report.AiIndicators = null;
|
||||
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, filePath), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static ReportDto ToDto(Report report) => new(
|
||||
report.Id,
|
||||
report.UserId,
|
||||
report.FileUrl,
|
||||
report.FileType.ToString(),
|
||||
report.Category.ToString(),
|
||||
report.Status.ToString(),
|
||||
ToAiStatus(report),
|
||||
report.Status == ReportStatus.DoctorReviewed ? "Reviewed" : "Pending",
|
||||
report.Severity,
|
||||
report.AiSummary,
|
||||
report.AiIndicators,
|
||||
report.DoctorComment,
|
||||
report.DoctorRecommendation,
|
||||
report.DoctorName,
|
||||
report.ReviewedAt,
|
||||
report.CreatedAt);
|
||||
|
||||
private static string ToAiStatus(Report report) => report.Status switch
|
||||
{
|
||||
ReportStatus.Analyzing => "Analyzing",
|
||||
ReportStatus.AnalysisFailed => "Failed",
|
||||
_ when string.IsNullOrWhiteSpace(report.AiSummary) => "Analyzing",
|
||||
_ => "Succeeded"
|
||||
};
|
||||
|
||||
private static ReportUploadResult Fail(int code, string message) =>
|
||||
new(false, code, message, null);
|
||||
}
|
||||
31
backend/src/Health.Application/Users/UserContracts.cs
Normal file
31
backend/src/Health.Application/Users/UserContracts.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Users;
|
||||
|
||||
public sealed record UserProfileDto(
|
||||
Guid Id,
|
||||
string Phone,
|
||||
string Role,
|
||||
string? Name,
|
||||
string? Gender,
|
||||
string? BirthDate,
|
||||
string? AvatarUrl);
|
||||
|
||||
public sealed record UserProfileUpdateRequest(
|
||||
string? Name,
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct);
|
||||
Task DeleteAccountAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
35
backend/src/Health.Application/Users/UserService.cs
Normal file
35
backend/src/Health.Application/Users/UserService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace Health.Application.Users;
|
||||
|
||||
public sealed class UserService(IUserRepository users) : IUserService
|
||||
{
|
||||
private readonly IUserRepository _users = users;
|
||||
|
||||
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var user = await _users.GetAsync(userId, ct);
|
||||
return user == null ? null : new UserProfileDto(
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Role,
|
||||
user.Name,
|
||||
user.Gender,
|
||||
user.BirthDate?.ToString("yyyy-MM-dd"),
|
||||
user.AvatarUrl);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
var user = await _users.GetAsync(userId, ct);
|
||||
if (user == null) return false;
|
||||
|
||||
if (request.Name != null) user.Name = request.Name;
|
||||
if (request.Gender != null) user.Gender = request.Gender;
|
||||
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _users.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
|
||||
_users.DeleteAccountDataAsync(userId, ct);
|
||||
}
|
||||
Reference in New Issue
Block a user