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

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

View File

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

View 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",
_ => "—"
};
}

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

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

View File

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

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

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

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