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

2
.gitignore vendored
View File

@@ -37,6 +37,8 @@ health_app/ios/.symlinks/
# Uploads & logs # Uploads & logs
backend/src/Health.WebApi/uploads/ backend/src/Health.WebApi/uploads/
backend/src/Health.WebApi/data-protection-keys/
*.log
*.txt *.txt
*.jpg *.jpg
*.png *.png

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

View File

@@ -7,7 +7,9 @@ public sealed class ExercisePlan
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid UserId { get; set; } public Guid UserId { get; set; }
public DateOnly WeekStartDate { get; set; } // 起始日期 public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public TimeOnly ReminderTime { get; set; } = new(19, 0);
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -22,7 +24,7 @@ public sealed class ExercisePlanItem
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid PlanId { get; set; } public Guid PlanId { get; set; }
public int DayOfWeek { get; set; } // C# DayOfWeek: 0=周日, 6=周六 public DateOnly ScheduledDate { get; set; }
public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳 public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳
public int DurationMinutes { get; set; } public int DurationMinutes { get; set; }
public bool IsCompleted { get; set; } public bool IsCompleted { get; set; }

View File

@@ -71,6 +71,8 @@ public enum MedicationFrequency
/// </summary> /// </summary>
public enum ReportStatus public enum ReportStatus
{ {
Analyzing, // AI 分析中
AnalysisFailed, // AI 分析失败
PendingDoctor, // 待医生确认 PendingDoctor, // 待医生确认
DoctorReviewed // 医生已确认 DoctorReviewed // 医生已确认
} }

View File

@@ -1,3 +1,6 @@
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
@@ -21,38 +24,33 @@ public static class CommonAgentHandler
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool]; public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) public static async Task<object> Execute(
string toolName,
JsonElement args,
Guid userId,
IHealthRecordService healthRecords,
IHealthArchiveService archives,
CancellationToken ct = default)
{ {
return toolName switch return toolName switch
{ {
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args), "query_health_records" => await ExecuteQueryHealthRecords(healthRecords, userId, args, ct),
"check_archive" => await ExecuteCheckArchive(db, userId), "check_archive" => await ExecuteCheckArchive(archives, userId, ct),
_ => new { success = false, message = $"未知工具: {toolName}" } _ => new { success = false, message = $"未知工具: {toolName}" }
}; };
} }
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args) private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
{ {
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null; var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7; var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
var query = db.HealthRecords.Where(r => r.UserId == userId);
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
query = query.Where(r => r.MetricType == mt);
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
{
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
}).ToListAsync();
return new { count = records.Count, records }; return new { count = records.Count, records };
} }
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId) private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
{ {
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId); var archive = await archives.GetAsync(userId, ct);
if (archive == null) return new { found = false }; if (archive == null) return new { found = false };
return new return new
{ {
@@ -85,49 +83,26 @@ public static class CommonAgentHandler
} }
}; };
public static async Task<object> ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args) public static Task<object> ExecuteManageArchive(
IHealthArchiveService archives,
Guid userId,
JsonElement args,
CancellationToken ct = default)
{ {
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId); var request = new HealthArchiveUpdateRequest(
if (archive == null) args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
{ args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId }; args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
db.HealthArchives.Add(archive); ReadStringArray(args, "allergies"),
} ReadStringArray(args, "diet_restrictions"),
ReadStringArray(args, "chronic_diseases"),
switch (action) args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
{ return archives.ExecuteAiActionAsync(userId, action, request, ct);
case "update_diagnosis":
archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis;
break;
case "update_surgery":
archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType;
if (args.TryGetProperty("surgery_date", out var sd))
archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate;
break;
case "update_allergies":
if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array)
archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_chronic_diseases":
if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array)
archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_diet_restrictions":
if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array)
archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)];
break;
default: // query
return new
{
found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
};
}
archive.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
} }
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
: null;
} }

View File

@@ -7,12 +7,4 @@ public static class ConsultationAgentHandler
{ {
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool]; public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
} }

View File

@@ -7,12 +7,4 @@ public static class DietAgentHandler
{ {
public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool]; public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
} }

View File

@@ -1,85 +1,83 @@
using Health.Application.Exercises;
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 运动计划 Agent 工具处理器
/// </summary>
public static class ExerciseAgentHandler public static class ExerciseAgentHandler
{ {
public static readonly ToolDefinition ManageExerciseTool = new() public static readonly ToolDefinition ManageExerciseTool = new()
{ {
Function = new() Function = new()
{ {
Name = "manage_exercise", Description = "运动计划管理(创建/查询/打卡)", Name = "manage_exercise",
Parameters = new { type = "object", properties = new { Description = "运动计划管理(创建/查询/打卡)",
action = new { type = "string", description = "create/query/checkin" }, Parameters = new
week_start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, {
items = new { type = "array", description = "每天的运动项目", items = new { type = "object", properties = new { type = "object",
day_of_week = new { type = "integer", description = "0=周日 1=周一...6=周六" }, properties = new
exercise_type = new { type = "string", description = "运动类型,如散步/慢跑/游泳/太极" }, {
duration_minutes = new { type = "integer", description = "时长(分钟)" }, action = new { type = "string", description = "create/query/checkin" },
is_rest_day = new { type = "boolean", description = "是否休息日" } start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" },
}}} duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
}, required = new[] { "action" } } exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
} duration_minutes = new { type = "integer", description = "每天运动时长,单位分钟" },
reminder_time = new { type = "string", description = "每天提醒时间 HH:mm默认19:00" },
item_id = new { type = "string", description = "打卡条目 ID" },
},
required = new[] { "action" },
},
},
}; };
public static List<ToolDefinition> Tools => [ManageExerciseTool]; public static List<ToolDefinition> Tools => [ManageExerciseTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) public static async Task<object> Execute(
string toolName,
JsonElement args,
AppDbContext db,
Guid userId,
IExerciseService? exercises = null,
CancellationToken ct = default)
{ {
return toolName switch if (toolName != "manage_exercise" || exercises == null)
return new { success = false, message = $"未知工具: {toolName}" };
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
if (action == "create")
{ {
"manage_exercise" => await ExecuteManageExercise(db, userId, args), var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
_ => new { success = false, message = $"未知工具: {toolName}" } ? parsedStart
}; : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
} var days = args.TryGetProperty("duration_days", out var daysValue) && daysValue.TryGetInt32(out var parsedDays)
? Math.Clamp(parsedDays, 1, 366)
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args) : 7;
{ var exerciseType = args.TryGetProperty("exercise_type", out var typeValue) ? typeValue.GetString() : "散步";
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; var minutes = args.TryGetProperty("duration_minutes", out var minutesValue) && minutesValue.TryGetInt32(out var parsedMinutes) ? parsedMinutes : 30;
switch (action) var reminder = args.TryGetProperty("reminder_time", out var reminderValue) && TimeOnly.TryParse(reminderValue.GetString(), out var parsedReminder)
{ ? parsedReminder
case "create": : new TimeOnly(19, 0);
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart }; startDate, startDate.AddDays(days - 1), exerciseType, minutes, reminder), ct);
if (args.TryGetProperty("items", out var items)) return new { success = true, plan_id = planId, exercise_type = exerciseType, duration_minutes = minutes, day_count = days, start_date = startDate, end_date = startDate.AddDays(days - 1), reminder_time = reminder };
{
foreach (var item in items.EnumerateArray())
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
});
}
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync();
var firstItem = plan.Items.FirstOrDefault();
return new {
success = true, plan_id = plan.Id,
exercise_type = firstItem?.ExerciseType ?? "运动",
duration_minutes = firstItem?.DurationMinutes ?? 30,
day_count = plan.Items.Count,
};
case "checkin":
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
exerciseItem.IsCompleted = true;
exerciseItem.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
default:
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (existingPlan == null) return new { found = false };
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
} }
if (action == "checkin")
{
var itemId = args.TryGetProperty("item_id", out var itemValue) && itemValue.TryGetGuid(out var parsedId) ? parsedId : Guid.Empty;
var success = await exercises.CheckInAsync(userId, itemId, ct);
return success ? new { success = true } : new { success = false, message = "条目不存在" };
}
var plan = await exercises.GetLatestAsync(userId, ct);
return plan == null
? new { found = false }
: new
{
found = true,
plan_id = plan.Id,
plan.StartDate,
plan.EndDate,
plan.ReminderTime,
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
};
} }
} }

View File

@@ -1,3 +1,5 @@
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
@@ -16,16 +18,50 @@ public static class HealthDataAgentHandler
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool]; public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId, IHealthRecordService? healthRecords = null)
{ {
return toolName switch return toolName switch
{ {
"record_health_data" => await ExecuteRecordHealthData(db, userId, args), "record_health_data" => healthRecords == null
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId), ? await ExecuteRecordHealthData(db, userId, args)
: await ExecuteRecordHealthData(healthRecords, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" } _ => new { success = false, message = $"未知工具: {toolName}" }
}; };
} }
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt)
? dt
: DateTime.UtcNow;
var request = new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
recordedAt);
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new
{
success = true,
record_id = id,
type = metricType.Value.ToString(),
value = valStr,
unit,
isAbnormal = HealthRecordRules.CheckAbnormal(request)
};
}
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args) private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
{ {
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : ""; var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
@@ -82,4 +118,42 @@ public static class HealthDataAgentHandler
}; };
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal }; return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
} }
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{
return type switch
{
"blood_pressure" => (
HealthMetricType.BloodPressure,
null,
"mmHg",
args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null,
args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null),
"heart_rate" => (
HealthMetricType.HeartRate,
args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null,
"次/分",
null,
null),
"glucose" => (
HealthMetricType.Glucose,
args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null,
"mmol/L",
null,
null),
"spo2" => (
HealthMetricType.SpO2,
args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null,
"%",
null,
null),
"weight" => (
HealthMetricType.Weight,
args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null,
"kg",
null,
null),
_ => (null, null, "", null, null)
};
}
} }

View File

@@ -1,7 +1,9 @@
using Health.Application.Medications;
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
/// 药管家 Agent 工具处理器——用药管理 /// 药管家 Agent 工具处理器
/// </summary> /// </summary>
public static class MedicationAgentHandler public static class MedicationAgentHandler
{ {
@@ -9,31 +11,105 @@ public static class MedicationAgentHandler
{ {
Function = new() Function = new()
{ {
Name = "manage_medication", Description = "用药管理(创建/查询/确认服药)", Name = "manage_medication",
Parameters = new { type = "object", properties = new { Description = "用药管理(创建/查询/确认服药)",
action = new { type = "string", description = "create/query/confirm" }, Parameters = new
name = new { type = "string", description = "药品名称" }, {
dosage = new { type = "string", description = "剂量,如 100mg" }, type = "object",
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" }, properties = new
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" }, {
duration_days = new { type = "integer", description = "服用天数" }, action = new { type = "string", description = "create/query/confirm" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, medication_id = new { type = "string", description = "药品 ID确认服药时使用" },
}, required = new[] { "action" } } name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" },
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
duration_days = new { type = "integer", description = "服用天数" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
},
required = new[] { "action" }
}
} }
}; };
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool]; public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) public static async Task<object> Execute(
string toolName,
JsonElement args,
AppDbContext db,
Guid userId,
IMedicationService? medications = null,
CancellationToken ct = default)
{ {
return toolName switch return toolName switch
{ {
"manage_medication" => await ExecuteManageMedication(db, userId, args), "manage_medication" => medications != null
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId), ? await ExecuteManageMedication(medications, userId, args, ct)
: await ExecuteManageMedication(db, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" } _ => new { success = false, message = $"未知工具: {toolName}" }
}; };
} }
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
return action switch
{
"create" => await CreateMedication(medications, userId, args, ct),
"query" => await QueryMedications(medications, userId, ct),
"confirm" => await ConfirmMedication(medications, userId, args, ct),
_ => new { success = false, message = $"未知操作: {action}" }
};
}
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequency = ReadFrequency(args);
var times = ReadTimes(args);
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
var startDate = ReadStartDate(args);
var endDate = durationDays > 0 ? startDate.AddDays(durationDays) : (DateOnly?)null;
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
name,
dosage,
frequency,
times.Count > 0 ? times : [new TimeOnly(8, 0)],
startDate,
endDate,
MedicationSource.AiEntry,
null), ct);
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new
{
success = true,
medication_id = medicationId,
name,
dosage,
frequency = frequency.ToString(),
time = timeLabels,
start_date = startDate.ToString("yyyy-MM-dd"),
duration_days = durationDays,
};
}
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
{
var meds = await medications.ListActiveAsync(userId, ct);
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) };
}
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
var success = await medications.ConfirmNowAsync(userId, medId, ct);
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
}
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args) private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
{ {
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
@@ -50,38 +126,36 @@ public static class MedicationAgentHandler
{ {
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : ""; var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null; var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily"; var frequency = ReadFrequency(args);
var frequency = Enum.TryParse<MedicationFrequency>(frequencyStr, out var fr) ? fr : MedicationFrequency.Daily; var times = ReadTimes(args);
List<TimeOnly> times = [];
if (args.TryGetProperty("time_of_day", out var tod) && tod.ValueKind == JsonValueKind.Array)
{
foreach (var t in tod.EnumerateArray())
{
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
}
}
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0; var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null; var startDate = ReadStartDate(args);
var med = new Medication var med = new Medication
{ {
Id = Guid.NewGuid(), UserId = userId, Id = Guid.NewGuid(),
Name = name, Dosage = dosage, Frequency = frequency, UserId = userId,
Name = name,
Dosage = dosage,
Frequency = frequency,
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)], TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
Source = MedicationSource.AiEntry, IsActive = true, Source = MedicationSource.AiEntry,
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), IsActive = true,
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null, StartDate = startDate,
EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null,
}; };
db.Medications.Add(med); db.Medications.Add(med);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00"; var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new { return new
success = true, medication_id = med.Id, {
name = med.Name, dosage = med.Dosage, success = true,
frequency = med.Frequency.ToString(), time = timeLabels, medication_id = med.Id,
name = med.Name,
dosage = med.Dosage,
frequency = med.Frequency.ToString(),
time = timeLabels,
start_date = med.StartDate?.ToString("yyyy-MM-dd"), start_date = med.StartDate?.ToString("yyyy-MM-dd"),
duration_days = durationDays, duration_days = durationDays,
}; };
@@ -97,12 +171,47 @@ public static class MedicationAgentHandler
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args) private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
{ {
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty; var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId);
if (med == null) return new { success = false, message = "药品不存在" };
db.MedicationLogs.Add(new MedicationLog db.MedicationLogs.Add(new MedicationLog
{ {
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId, Id = Guid.NewGuid(),
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow, MedicationId = medId,
UserId = userId,
Status = MedicationLogStatus.Taken,
ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
ConfirmedAt = DateTime.UtcNow,
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return new { success = true }; return new { success = true };
} }
private static MedicationFrequency ReadFrequency(JsonElement args)
{
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
}
private static DateOnly ReadStartDate(JsonElement args)
{
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
return DateOnly.TryParse(startDateStr, out var parsedDate)
? parsedDate
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
}
private static List<TimeOnly> ReadTimes(JsonElement args)
{
List<TimeOnly> times = [];
if (!args.TryGetProperty("time_of_day", out var tod) || tod.ValueKind != JsonValueKind.Array)
return times;
foreach (var t in tod.EnumerateArray())
{
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
}
return times;
}
} }

View File

@@ -12,15 +12,6 @@ public static class ReportAgentHandler
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool]; public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient) public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient)
{ {
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : ""; var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
@@ -28,13 +19,13 @@ public static class ReportAgentHandler
return new { success = false, message = "缺少报告图片" }; return new { success = false, message = "缺少报告图片" };
var prompt = """ var prompt = """
JSON格式返回 JSON格式返回
{ {
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)", "reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
"indicators": [ "indicators": [
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"} {"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
], ],
"summary": "初步分析摘要", "summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
"needsDoctorReview": true/false "needsDoctorReview": true/false
} }
JSON JSON

View File

@@ -0,0 +1,78 @@
using System.Text.Json;
using Health.Application.AI;
using Health.Application.Exercises;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.Infrastructure.AI;
public sealed class AiToolExecutionService(
AppDbContext db,
VisionClient visionClient,
IHealthArchiveService healthArchives,
IHealthRecordService healthRecords,
IExerciseService exercises,
IMedicationService medications,
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
{
private readonly AppDbContext _db = db;
private readonly VisionClient _visionClient = visionClient;
private readonly IHealthArchiveService _healthArchives = healthArchives;
private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IExerciseService _exercises = exercises;
private readonly IMedicationService _medications = medications;
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
{
using var json = JsonDocument.Parse(arguments);
var root = json.RootElement;
return await (toolName switch
{
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, _db, userId, _healthRecords),
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct),
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
});
}
public async Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var command = await _confirmations.TakeAsync(commandId, userId, ct);
if (command == null)
return new AiConfirmedWriteResult(40004, null, "确认请求不存在、已执行或已过期");
try
{
var result = await ExecuteAsync(command.ToolName, command.Arguments, userId, ct);
if (!Succeeded(result, out var error))
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(40001, result, error ?? "写入失败,请检查内容后重试");
}
await _confirmations.CompleteAsync(command, ct);
await transaction.CommitAsync(ct);
return new AiConfirmedWriteResult(0, result, null);
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(50001, null, $"写入失败: {ex.Message}");
}
}
private static bool Succeeded(object result, out string? error)
{
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
var root = json.RootElement;
error = root.TryGetProperty("message", out var message) ? message.GetString() : null;
return !root.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.False;
}
}

View File

@@ -0,0 +1,45 @@
using Health.Application.AI;
namespace Health.Infrastructure.AI;
public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversationRepository
{
private readonly AppDbContext _db = db;
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId, ct);
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId, ct);
public async Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
await _db.Conversations
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.UpdatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderBy(m => m.CreatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderByDescending(m => m.CreatedAt)
.Take(limit)
.ToListAsync(ct);
public async Task AddConversationAsync(Conversation conversation, CancellationToken ct) =>
await _db.Conversations.AddAsync(conversation, ct);
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
await _db.ConversationMessages.AddAsync(message, ct);
public void Delete(Conversation conversation) =>
_db.Conversations.Remove(conversation);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,98 @@
using Health.Application.AI;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.AI;
public sealed class EfAiWriteConfirmationStore(AppDbContext db) : IAiWriteConfirmationStore
{
private readonly AppDbContext _db = db;
public async Task<PendingAiWriteCommand> CreateAsync(
Guid userId,
string toolName,
string arguments,
TimeSpan lifetime,
CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var expired = await _db.AiWriteCommands.Where(x => x.Status == "Pending" && x.ExpiresAt <= now).ToListAsync(ct);
foreach (var item in expired) { item.Status = "Expired"; item.UpdatedAt = now; }
}
else
{
await _db.AiWriteCommands
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Expired")
.SetProperty(x => x.UpdatedAt, now), ct);
}
var command = new AiWriteCommandRecord
{
Id = Guid.NewGuid(),
UserId = userId,
ToolName = toolName,
Arguments = arguments,
Status = "Pending",
ExpiresAt = now.Add(lifetime),
CreatedAt = now,
UpdatedAt = now,
};
await _db.AiWriteCommands.AddAsync(command, ct);
await _db.SaveChangesAsync(ct);
return ToCommand(command);
}
public async Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now, ct);
if (tracked == null) return null;
tracked.Status = "Processing";
tracked.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
}
else
{
var updated = await _db.AiWriteCommands
.Where(x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.UpdatedAt, now), ct);
if (updated == 0) return null;
}
var record = await _db.AiWriteCommands.AsNoTracking().FirstAsync(x => x.Id == commandId, ct);
return ToCommand(record);
}
public async Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct)
{
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing", ct);
if (tracked != null) { tracked.Status = "Completed"; tracked.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); }
return;
}
await _db.AiWriteCommands
.Where(x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
private bool IsInMemory() => _db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory";
private static PendingAiWriteCommand ToCommand(AiWriteCommandRecord record) => new(
record.Id,
record.UserId,
record.ToolName,
record.Arguments,
record.ExpiresAt);
}

View File

@@ -8,18 +8,33 @@ public sealed class PromptManager
/// <summary> /// <summary>
/// 获取指定 Agent 的 System Prompt /// 获取指定 Agent 的 System Prompt
/// </summary> /// </summary>
public string GetSystemPrompt(AgentType agentType) => agentType switch public string GetSystemPrompt(AgentType agentType)
{ {
AgentType.Default => DefaultPrompt, var prompt = agentType switch
AgentType.Consultation => ConsultationPrompt, {
AgentType.Health => HealthDataPrompt, AgentType.Default => DefaultPrompt,
AgentType.Diet => DietPrompt, AgentType.Consultation => ConsultationPrompt,
AgentType.Medication => MedicationPrompt, AgentType.Health => HealthDataPrompt,
AgentType.Report => ReportPrompt, AgentType.Diet => DietPrompt,
AgentType.Exercise => ExercisePrompt, AgentType.Medication => MedicationPrompt,
AgentType.Unified => UnifiedPrompt, AgentType.Report => ReportPrompt,
_ => DefaultPrompt AgentType.Exercise => ExercisePrompt,
}; AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
};
return $"{prompt}\n\n{MedicalBoundaryRules}";
}
private const string MedicalBoundaryRules = """
- AI
- //
-
-
- 使
- AI
""";
private const string DefaultPrompt = """ private const string DefaultPrompt = """
AI健康管家 AI健康管家
@@ -38,15 +53,15 @@ public sealed class PromptManager
"""; """;
private const string ConsultationPrompt = """ private const string ConsultationPrompt = """
AI
1. 1.
2. 2-3 2. 2-3
3. 3.
4. 4.
5. >160/100>120<50 5. >160/100>120<50
6. "以上为AI分析具体请咨询医生" 6.
7. 7.
"""; """;
@@ -55,13 +70,13 @@ public sealed class PromptManager
1. //// 1. ////
2. + record_health_data 2. + record_health_data
3. "血压120/80血糖6.2血氧97" record_health_data 3. "血压120/80血糖6.2血氧97" record_health_data
4. "早上血压120下午血压130"recorded_at 4. "早上血压120下午血压130"recorded_at
5. "120""收缩压还是血糖?" 5. "120""收缩压还是血糖?"
6. 6.
7. 7.
8. record_health_data 8. record_health_data
- 90-139 mmHg 60-89 mmHg - 90-139 mmHg 60-89 mmHg
@@ -86,24 +101,24 @@ public sealed class PromptManager
"""; """;
private const string MedicationPrompt = """ private const string MedicationPrompt = """
1. manage_medication(action="query") 1. manage_medication(action="query")
2. /// 2. ///
3. "早饭后" 3. "早饭后"
4. 4. manage_medication(action="create")
5. 5.
6. 6.
"""; """;
private const string ReportPrompt = """ private const string ReportPrompt = """
1. 1.
2. // 2. //
3. 3.
4. "AI预解读待医生确认" 4. "AI预解读待医生确认"
5. /CT"需医生人工审阅" 5. /CT"需医生人工审阅"
"""; """;
@@ -120,7 +135,7 @@ public sealed class PromptManager
"""; """;
private const string UnifiedPrompt = """ private const string UnifiedPrompt = """
AI健康管 AI
@@ -133,20 +148,21 @@ public sealed class PromptManager
6. query_health_records 6. query_health_records
manage_exercise manage_exercise
- action="create", week_start_date="今天日期(YYYY-MM-DD)" - action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
- items: [{"day_of_week":0-6(=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...] - duration_days=exercise_type="散步"duration_minutes=30reminder_time="19:00"
- =30 =60 =30 =90 =80 - =30 =60 =30 =90 =80
- "一个月"=30 "一周"=7 "10天"=107 - "一个月"=30 "一周"=7 "10天"=107
- "坚持X天/月/周" - "坚持X天/月/周"
- items - start_date + duration_days - 1
manage_medication manage_medication
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7 - action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
- -
- + - +
- -
- pendingConfirmation=true
- -
- -
"""; """;

View File

@@ -0,0 +1,90 @@
using Health.Application.Admin;
namespace Health.Infrastructure.Admin;
public sealed class AdminService(AppDbContext db) : IAdminService
{
private readonly AppDbContext _db = db;
public async Task<AdminResult> ListDoctorsAsync(CancellationToken ct)
{
var doctors = await _db.Doctors.AsNoTracking().OrderBy(x => x.CreatedAt)
.Select(x => new { x.Id, x.Name, x.Title, x.Department, x.Phone, x.ProfessionalDirection, x.IsActive, x.AvatarUrl, x.Introduction, x.CreatedAt })
.ToListAsync(ct);
return Ok(doctors);
}
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空");
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空");
var doctor = new Doctor
{
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department,
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow,
};
_db.Doctors.Add(doctor);
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
{
var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.Name, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
_db.Users.Add(user);
_db.DoctorProfiles.Add(new DoctorProfile
{
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name,
Title = command.Title, Department = command.Department, IsActive = true,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
});
}
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.Name });
}
public async Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
if (command.Name != null) doctor.Name = command.Name;
if (command.Title != null) doctor.Title = command.Title;
if (command.Department != null) doctor.Department = command.Department;
if (command.Phone != null) doctor.Phone = command.Phone;
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id });
}
public async Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
doctor.IsActive = !doctor.IsActive;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.IsActive });
}
public async Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct)
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
_db.Doctors.Remove(doctor);
await _db.SaveChangesAsync(ct);
return Ok(new { success = true });
}
public async Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search));
var total = await query.CountAsync(ct);
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
.ToListAsync(ct);
return Ok(new { patients, total, page, pageSize });
}
private static AdminResult Ok(object data) => new(0, data);
private static AdminResult Error(int code, string message) => new(code, null, message);
}

View File

@@ -0,0 +1,115 @@
using Health.Application.Auth;
using Health.Infrastructure.Services;
namespace Health.Infrastructure.Auth;
public sealed class AuthService(AppDbContext db, JwtProvider jwt, SmsService sms) : IAuthService
{
private const string AdminPhone = "12345678910";
private const string AdminSmsCode = "000000";
private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private readonly AppDbContext _db = db;
private readonly JwtProvider _jwt = jwt;
private readonly SmsService _sms = sms;
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{
var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode();
await _db.VerificationCodes.AddAsync(new VerificationCode
{
Id = Guid.NewGuid(), Phone = phone, Code = code,
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
}, ct);
await _db.SaveChangesAsync(ct);
if (phone != AdminPhone) await _sms.SendCodeAsync(phone, code);
return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null });
}
public async Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct)
{
var code = await TakeCodeAsync(command.Phone, command.SmsCode, ct);
if (code == null) return Error(40001, "验证码错误或已过期");
if (await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct)) return Error(40002, "该手机号已注册,请直接登录");
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40003, "请输入姓名");
if (!await _db.Doctors.AnyAsync(x => x.Id == command.DoctorId && x.IsActive, ct)) return Error(40004, "所选医生不存在或已停诊");
code.IsUsed = true;
var user = new User
{
Id = Guid.NewGuid(), Phone = command.Phone, Role = "User", Name = command.Name,
DoctorId = command.DoctorId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
_db.Users.Add(user);
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
var tokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { user.Id, user.Phone, user.Role, isNew = true } });
}
public async Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct)
{
var code = await TakeCodeAsync(phone, smsCode, ct);
if (code == null) return Error(40001, "验证码错误或已过期");
code.IsUsed = true;
if (phone == AdminPhone)
{
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { id = AdminId, phone = AdminPhone, role = "Admin", name = "管理员", isNew = false } });
}
var user = await _db.Users.FirstOrDefaultAsync(x => x.Phone == phone, ct);
if (user == null) return Error(40004, "该手机号未注册,请先注册");
user.UpdatedAt = DateTime.UtcNow;
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new
{
userTokens.accessToken, userTokens.refreshToken,
user = new { user.Id, user.Phone, user.Role, user.Name, user.Gender, user.AvatarUrl, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), isNew = false }
});
}
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct)
{
var oldToken = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken && !x.IsRevoked, ct);
if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow) return Error(40002, "登录已过期,请重新登录");
oldToken.IsRevoked = true;
if (oldToken.UserId == AdminId)
{
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } });
}
var user = await _db.Users.FindAsync([oldToken.UserId], ct);
if (user == null) return Error(40002, "用户不存在");
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
await _db.SaveChangesAsync(ct);
return new AuthResult(0, new { userTokens.accessToken, userTokens.refreshToken, user = new { user.Role } });
}
public async Task LogoutAsync(string refreshToken, CancellationToken ct)
{
var token = await _db.RefreshTokens.FirstOrDefaultAsync(x => x.Token == refreshToken, ct);
if (token != null) token.IsRevoked = true;
await _db.SaveChangesAsync(ct);
}
private Task<VerificationCode?> TakeCodeAsync(string phone, string code, CancellationToken ct) =>
_db.VerificationCodes.Where(x => x.Phone == phone && x.Code == code && x.ExpiresAt > DateTime.UtcNow && !x.IsUsed)
.OrderByDescending(x => x.CreatedAt).FirstOrDefaultAsync(ct);
private (string accessToken, string refreshToken) AddTokens(Guid userId, string phone, string role)
{
var access = _jwt.GenerateAccessToken(userId, phone, role);
var refresh = _jwt.GenerateRefreshToken();
_db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = userId, Token = refresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
return (access, refresh);
}
private static AuthResult Error(int code, string message) => new(code, null, message);
}

View File

@@ -0,0 +1,27 @@
using Health.Application.Calendars;
namespace Health.Infrastructure.Calendars;
public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
{
private readonly AppDbContext _db = db;
public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
{
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var medications = await _db.Medications
.Where(m => m.UserId == userId && m.IsActive)
.ToListAsync(ct);
var exercisePlans = await _db.ExercisePlans
.Include(p => p.Items)
.Where(p => p.UserId == userId && p.StartDate < end && p.EndDate >= start)
.ToListAsync(ct);
var followUps = await _db.FollowUps
.Where(f => f.UserId == userId && f.ScheduledAt >= startUtc && f.ScheduledAt < endUtc)
.ToListAsync(ct);
return new CalendarDataSnapshot(medications, exercisePlans, followUps);
}
}

View File

@@ -0,0 +1,166 @@
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Data;
public sealed class DatabaseSchemaMigrator(AppDbContext db, ILogger<DatabaseSchemaMigrator> logger)
{
private readonly AppDbContext _db = db;
private readonly ILogger<DatabaseSchemaMigrator> _logger = logger;
public async Task ApplyAsync(CancellationToken ct = default)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
await _db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "__AppSchemaMigrations" (
"Id" varchar(128) PRIMARY KEY,
"AppliedAt" timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS "AiWriteCommands" (
"Id" uuid PRIMARY KEY,
"UserId" uuid NOT NULL,
"ToolName" varchar(64) NOT NULL,
"Arguments" jsonb NOT NULL,
"Status" varchar(32) NOT NULL,
"ExpiresAt" timestamptz NOT NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_AiWriteCommands_UserId_Status_ExpiresAt"
ON "AiWriteCommands" ("UserId", "Status", "ExpiresAt");
CREATE TABLE IF NOT EXISTS "ReportAnalysisTasks" (
"Id" uuid PRIMARY KEY,
"ReportId" uuid NOT NULL,
"FilePath" text NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_Status_AvailableAt"
ON "ReportAnalysisTasks" ("Status", "AvailableAt");
CREATE INDEX IF NOT EXISTS "IX_ReportAnalysisTasks_ReportId"
ON "ReportAnalysisTasks" ("ReportId");
CREATE TABLE IF NOT EXISTS "DietImageAnalysisTasks" (
"Id" uuid PRIMARY KEY,
"FilePaths" jsonb NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"ResultCode" integer NULL,
"ResultData" text NULL,
"ResultMessage" text NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_DietImageAnalysisTasks_Status_AvailableAt"
ON "DietImageAnalysisTasks" ("Status", "AvailableAt");
CREATE TABLE IF NOT EXISTS "MedicationReminderTasks" (
"Id" uuid PRIMARY KEY,
"UserId" uuid NOT NULL,
"MedicationId" uuid NOT NULL,
"Name" text NOT NULL,
"Dosage" text NULL,
"ScheduledDate" date NOT NULL,
"ScheduledTime" time without time zone NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_Status_AvailableAt"
ON "MedicationReminderTasks" ("Status", "AvailableAt");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_MedicationReminderTasks_MedicationId_ScheduledDate_ScheduledTime"
ON "MedicationReminderTasks" ("MedicationId", "ScheduledDate", "ScheduledTime");
CREATE TABLE IF NOT EXISTS "NotificationOutbox" (
"Id" uuid PRIMARY KEY,
"UserId" uuid NOT NULL,
"SourceTaskId" uuid NOT NULL,
"Type" varchar(64) NOT NULL,
"Payload" jsonb NOT NULL,
"Status" varchar(32) NOT NULL,
"Attempts" integer NOT NULL,
"AvailableAt" timestamptz NOT NULL,
"LastError" text NULL,
"CreatedAt" timestamptz NOT NULL,
"UpdatedAt" timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_NotificationOutbox_SourceTaskId"
ON "NotificationOutbox" ("SourceTaskId");
CREATE INDEX IF NOT EXISTS "IX_NotificationOutbox_Status_AvailableAt"
ON "NotificationOutbox" ("Status", "AvailableAt");
ALTER TABLE IF EXISTS "ExercisePlans"
ADD COLUMN IF NOT EXISTS "StartDate" date NULL,
ADD COLUMN IF NOT EXISTS "EndDate" date NULL,
ADD COLUMN IF NOT EXISTS "ReminderTime" time without time zone NOT NULL DEFAULT TIME '19:00';
ALTER TABLE IF EXISTS "ExercisePlanItems"
ADD COLUMN IF NOT EXISTS "ScheduledDate" date NULL;
DO $exercise_migration$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'ExercisePlans' AND column_name = 'WeekStartDate'
) THEN
UPDATE "ExercisePlans"
SET "StartDate" = COALESCE("StartDate", "WeekStartDate")
WHERE "StartDate" IS NULL;
WITH ranked AS (
SELECT i."Id", p."StartDate",
ROW_NUMBER() OVER (PARTITION BY i."PlanId" ORDER BY i."Id") - 1 AS day_offset
FROM "ExercisePlanItems" i
JOIN "ExercisePlans" p ON p."Id" = i."PlanId"
WHERE i."ScheduledDate" IS NULL
)
UPDATE "ExercisePlanItems" i
SET "ScheduledDate" = ranked."StartDate" + ranked.day_offset::integer
FROM ranked
WHERE i."Id" = ranked."Id";
UPDATE "ExercisePlans" p
SET "EndDate" = COALESCE(
p."EndDate",
(SELECT MAX(i."ScheduledDate") FROM "ExercisePlanItems" i WHERE i."PlanId" = p."Id"),
p."StartDate"
)
WHERE p."EndDate" IS NULL;
END IF;
END $exercise_migration$;
ALTER TABLE IF EXISTS "ExercisePlans"
ALTER COLUMN "StartDate" SET NOT NULL,
ALTER COLUMN "EndDate" SET NOT NULL;
ALTER TABLE IF EXISTS "ExercisePlanItems"
ALTER COLUMN "ScheduledDate" SET NOT NULL;
CREATE INDEX IF NOT EXISTS "IX_ExercisePlans_UserId_StartDate_EndDate"
ON "ExercisePlans" ("UserId", "StartDate", "EndDate");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_ExercisePlanItems_PlanId_ScheduledDate"
ON "ExercisePlanItems" ("PlanId", "ScheduledDate");
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
VALUES ('20260619_01_persistent_ai_commands_and_report_tasks', now())
ON CONFLICT ("Id") DO NOTHING;
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
VALUES ('20260619_02_persistent_diet_and_medication_tasks', now())
ON CONFLICT ("Id") DO NOTHING;
INSERT INTO "__AppSchemaMigrations" ("Id", "AppliedAt")
VALUES ('20260620_03_date_based_exercise_plans', now())
ON CONFLICT ("Id") DO NOTHING;
""", ct);
await transaction.CommitAsync(ct);
_logger.LogInformation("数据库结构迁移已检查: 20260620_03");
}
}

View File

@@ -0,0 +1,13 @@
namespace Health.Infrastructure.Data.Records;
public sealed class AiWriteCommandRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string ToolName { get; set; } = string.Empty;
public string Arguments { get; set; } = string.Empty;
public string Status { get; set; } = "Pending";
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,16 @@
namespace Health.Infrastructure.Data.Records;
public sealed class DietImageAnalysisTaskRecord
{
public Guid Id { get; set; }
public string FilePaths { get; set; } = "[]";
public string Status { get; set; } = "Pending";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public int? ResultCode { get; set; }
public string? ResultData { get; set; }
public string? ResultMessage { get; set; }
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,18 @@
namespace Health.Infrastructure.Data.Records;
public sealed class MedicationReminderTaskRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid MedicationId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Dosage { get; set; }
public DateOnly ScheduledDate { get; set; }
public TimeOnly ScheduledTime { get; set; }
public string Status { get; set; } = "Pending";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,16 @@
namespace Health.Infrastructure.Data.Records;
public sealed class NotificationOutboxRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid SourceTaskId { get; set; }
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = "{}";
public string Status { get; set; } = "AwaitingProvider";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,14 @@
namespace Health.Infrastructure.Data.Records;
public sealed class ReportAnalysisTaskRecord
{
public Guid Id { get; set; }
public Guid ReportId { get; set; }
public string FilePath { get; set; } = string.Empty;
public string Status { get; set; } = "Pending";
public int Attempts { get; set; }
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Data; namespace Health.Infrastructure.Data;
@@ -33,6 +34,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>(); public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>(); public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>(); public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
public DbSet<NotificationOutboxRecord> NotificationOutbox => Set<NotificationOutboxRecord>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@@ -95,7 +101,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
// ---- ExercisePlan ---- // ---- ExercisePlan ----
builder.Entity<ExercisePlan>(e => builder.Entity<ExercisePlan>(e =>
{ {
e.HasIndex(p => new { p.UserId, p.WeekStartDate }).IsDescending(false, true); e.HasIndex(p => new { p.UserId, p.StartDate, p.EndDate });
});
builder.Entity<ExercisePlanItem>(e =>
{
e.HasIndex(i => new { i.PlanId, i.ScheduledDate }).IsUnique();
}); });
// ---- Report ---- // ---- Report ----
@@ -148,5 +159,48 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
{ {
e.HasIndex(a => a.UserId).IsUnique(); e.HasIndex(a => a.UserId).IsUnique();
}); });
builder.Entity<AiWriteCommandRecord>(e =>
{
e.ToTable("AiWriteCommands");
e.HasIndex(x => new { x.UserId, x.Status, x.ExpiresAt });
e.Property(x => x.ToolName).HasMaxLength(64);
e.Property(x => x.Status).HasMaxLength(32);
e.Property(x => x.Arguments).HasColumnType("jsonb");
});
builder.Entity<ReportAnalysisTaskRecord>(e =>
{
e.ToTable("ReportAnalysisTasks");
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.HasIndex(x => x.ReportId);
e.Property(x => x.Status).HasMaxLength(32);
});
builder.Entity<DietImageAnalysisTaskRecord>(e =>
{
e.ToTable("DietImageAnalysisTasks");
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.Property(x => x.FilePaths).HasColumnType("jsonb");
e.Property(x => x.Status).HasMaxLength(32);
});
builder.Entity<MedicationReminderTaskRecord>(e =>
{
e.ToTable("MedicationReminderTasks");
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.HasIndex(x => new { x.MedicationId, x.ScheduledDate, x.ScheduledTime }).IsUnique();
e.Property(x => x.Status).HasMaxLength(32);
});
builder.Entity<NotificationOutboxRecord>(e =>
{
e.ToTable("NotificationOutbox");
e.HasIndex(x => x.SourceTaskId).IsUnique();
e.HasIndex(x => new { x.Status, x.AvailableAt });
e.Property(x => x.Type).HasMaxLength(64);
e.Property(x => x.Status).HasMaxLength(32);
e.Property(x => x.Payload).HasColumnType("jsonb");
});
} }
} }

View File

@@ -137,14 +137,14 @@ public static class DevDataSeeder
// ---- 运动计划 ---- // ---- 运动计划 ----
var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1)); var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday }; var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(6), ReminderTime = new TimeOnly(19, 0) };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, ExerciseType = "散步", DurationMinutes = 30 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), ExerciseType = "散步", DurationMinutes = 30 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 3, IsRestDay = true }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(3), IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 4, ExerciseType = "太极", DurationMinutes = 40 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(4), ExerciseType = "太极", DurationMinutes = 40 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 5, IsRestDay = true }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(5), IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 6, ExerciseType = "散步", DurationMinutes = 30 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(6), ExerciseType = "散步", DurationMinutes = 30 });
db.ExercisePlans.Add(plan); db.ExercisePlans.Add(plan);
// ---- 饮食记录 ---- // ---- 饮食记录 ----

View File

@@ -0,0 +1,59 @@
using Health.Application.Diets;
namespace Health.Infrastructure.Diets;
public sealed class DietImageAnalysisCoordinator(IDietImageAnalysisQueue queue) : IDietImageAnalysisCoordinator
{
private const long MaxImageBytes = 20 * 1024 * 1024;
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".heic"
};
private readonly IDietImageAnalysisQueue _queue = queue;
public async Task<DietImageAnalysisResult> AnalyzeAsync(IReadOnlyList<DietImageUploadFile> files, CancellationToken ct)
{
if (files.Count == 0)
return new DietImageAnalysisResult(false, 40001, null, "请上传至少一张图片");
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "diet-analysis");
Directory.CreateDirectory(uploadsDir);
var paths = new List<string>();
var handedOff = false;
try
{
foreach (var file in files)
{
if (file.Length <= 0 || file.Length > MaxImageBytes)
return new DietImageAnalysisResult(false, 40001, null, "文件大小超过 20MB 限制");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return new DietImageAnalysisResult(false, 40001, null, "不支持的图片格式,仅支持 JPG/PNG/HEIC");
var filePath = Path.Combine(uploadsDir, $"{Guid.NewGuid()}{extension}");
await using var stream = new FileStream(filePath, FileMode.CreateNew);
await file.Content.CopyToAsync(stream, ct);
paths.Add(filePath);
}
var jobId = await _queue.EnqueueAsync(paths, ct);
handedOff = true;
return await _queue.WaitForResultAsync(jobId, ct);
}
catch
{
if (!handedOff)
{
foreach (var path in paths)
{
try { if (File.Exists(path)) File.Delete(path); }
catch { }
}
}
throw;
}
}
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json;
using Health.Application.Diets;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Diets;
public sealed class DietImageAnalysisQueue(AppDbContext db) : IDietImageAnalysisQueue
{
private const int MaxAttempts = 3;
private readonly AppDbContext _db = db;
public async Task<Guid> EnqueueAsync(IReadOnlyList<string> filePaths, CancellationToken ct)
{
var now = DateTime.UtcNow;
var task = new DietImageAnalysisTaskRecord
{
Id = Guid.NewGuid(),
FilePaths = JsonSerializer.Serialize(filePaths),
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
};
await _db.DietImageAnalysisTasks.AddAsync(task, ct);
await _db.SaveChangesAsync(ct);
return task.Id;
}
public Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.DietImageAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.DietImageAnalysisTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.DietImageAnalysisTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
if (claimed == 0) return null;
var paths = JsonSerializer.Deserialize<List<string>>(candidate.FilePaths) ?? [];
return new DietImageAnalysisJob(candidate.Id, paths);
}
public async Task<DietImageAnalysisResult> WaitForResultAsync(Guid jobId, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var task = await _db.DietImageAnalysisTasks.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == jobId, ct);
if (task == null)
return new DietImageAnalysisResult(false, 40004, null, "饮食识别任务不存在");
if (task.Status is "Completed" or "Failed")
return new DietImageAnalysisResult(
task.Status == "Completed",
task.ResultCode ?? 50001,
task.ResultData,
task.ResultMessage);
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
}
throw new OperationCanceledException(ct);
}
public Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.DietImageAnalysisTasks.Where(x => x.Id == jobId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, result.Success ? "Completed" : "Failed")
.SetProperty(x => x.ResultCode, result.Code)
.SetProperty(x => x.ResultData, result.Data)
.SetProperty(x => x.ResultMessage, result.Message)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<bool> RetryAsync(Guid jobId, string error, CancellationToken ct)
{
var task = await _db.DietImageAnalysisTasks.FirstOrDefaultAsync(x => x.Id == jobId, ct);
if (task == null) return true;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
task.ResultCode = 50001;
task.ResultMessage = $"食物识别失败: {error}";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
return task.Status == "Failed";
}
}

View File

@@ -0,0 +1,42 @@
using Health.Application.Diets;
using Health.Infrastructure.AI;
namespace Health.Infrastructure.Diets;
public sealed class DietImageAnalyzer(VisionClient vision) : IDietImageAnalyzer
{
private readonly VisionClient _vision = vision;
public async Task<DietImageAnalysisResult> AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct)
{
var imageUrls = new List<string>();
foreach (var filePath in job.FilePaths)
{
if (!File.Exists(filePath))
return new DietImageAnalysisResult(false, 40004, null, "待识别图片不存在");
var bytes = await File.ReadAllBytesAsync(filePath, ct);
var base64 = Convert.ToBase64String(bytes);
if (base64.Length > 7 * 1024 * 1024)
return new DietImageAnalysisResult(false, 40001, null, "图片过大,请压缩后重新上传");
var extension = Path.GetExtension(filePath).ToLowerInvariant();
var mime = extension switch
{
".png" => "image/png",
".heic" => "image/heic",
_ => "image/jpeg"
};
imageUrls.Add($"data:{mime};base64,{base64}");
}
var prompt = """
JSON数组
[{"name":"名称","portion":"份量","calories":热量}]
JSON
""";
var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]";
return new DietImageAnalysisResult(true, 0, result, null);
}
}

View File

@@ -0,0 +1,33 @@
using Health.Application.Diets;
namespace Health.Infrastructure.Diets;
public sealed class EfDietRepository(AppDbContext db) : IDietRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<DietRecord>> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct)
{
var query = _db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
if (recordedAt.HasValue)
query = query.Where(r => r.RecordedAt == recordedAt.Value);
if (mealType.HasValue)
query = query.Where(r => r.MealType == mealType.Value);
return await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct);
}
public Task<DietRecord?> GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct) =>
_db.DietRecords.FirstOrDefaultAsync(r => r.Id == recordId && r.UserId == userId, ct);
public async Task AddAsync(DietRecord record, CancellationToken ct) =>
await _db.DietRecords.AddAsync(record, ct);
public void Delete(DietRecord record) =>
_db.DietRecords.Remove(record);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,44 @@
using Health.Application.Exercises;
namespace Health.Infrastructure.Exercises;
public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) =>
await _db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId && p.StartDate <= date && p.EndDate >= date)
.OrderByDescending(p => p.StartDate)
.ToListAsync(ct);
public async Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) =>
await _db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.StartDate)
.Take(limit)
.ToListAsync(ct);
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
_db.ExercisePlans.Include(p => p.Items)
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct);
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) =>
_db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.StartDate)
.FirstOrDefaultAsync(ct);
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) =>
_db.ExercisePlanItems.Include(i => i.Plan)
.FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct);
public async Task AddAsync(ExercisePlan plan, CancellationToken ct) =>
await _db.ExercisePlans.AddAsync(plan, ct);
public void Delete(ExercisePlan plan) =>
_db.ExercisePlans.Remove(plan);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,44 @@
using System.Text.Json;
using Health.Application.Exercises;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Exercises;
public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminderProducer
{
private readonly AppDbContext _db = db;
public async Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct)
{
var beijingNow = utcNow.AddHours(8);
var today = DateOnly.FromDateTime(beijingNow);
var currentTime = TimeOnly.FromDateTime(beijingNow);
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
.Include(x => x.Plan)
.Where(x => x.ScheduledDate == today && !x.IsCompleted && !x.IsRestDay && x.Plan.ReminderTime <= currentTime)
.ToListAsync(ct);
var sourceIds = dueItems.Select(x => x.Id).ToList();
var existing = await _db.NotificationOutbox.AsNoTracking()
.Where(x => sourceIds.Contains(x.SourceTaskId))
.Select(x => x.SourceTaskId)
.ToListAsync(ct);
var existingSet = existing.ToHashSet();
var now = DateTime.UtcNow;
foreach (var item in dueItems.Where(x => !existingSet.Contains(x.Id)))
{
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
{
Id = Guid.NewGuid(), UserId = item.Plan.UserId, SourceTaskId = item.Id,
Type = "ExerciseReminder",
Payload = JsonSerializer.Serialize(new
{
item.ExerciseType, item.DurationMinutes, item.ScheduledDate,
ReminderTime = item.Plan.ReminderTime,
}),
Status = "AwaitingProvider", AvailableAt = now, CreatedAt = now, UpdatedAt = now,
}, ct);
}
if (_db.ChangeTracker.HasChanges()) await _db.SaveChangesAsync(ct);
return dueItems.Count - existingSet.Count;
}
}

View File

@@ -1,12 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Health.Application\Health.Application.csproj" />
<ProjectReference Include="..\Health.Domain\Health.Domain.csproj" /> <ProjectReference Include="..\Health.Domain\Health.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" /> <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Minio" Version="7.0.0" /> <PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />

View File

@@ -0,0 +1,17 @@
using Health.Application.HealthArchives;
namespace Health.Infrastructure.HealthArchives;
public sealed class EfHealthArchiveRepository(AppDbContext db) : IHealthArchiveRepository
{
private readonly AppDbContext _db = db;
public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) =>
_db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
public async Task AddAsync(HealthArchive archive, CancellationToken ct) =>
await _db.HealthArchives.AddAsync(archive, ct);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,45 @@
using Health.Application.HealthRecords;
namespace Health.Infrastructure.HealthRecords;
public sealed class EfHealthRecordRepository(AppDbContext db) : IHealthRecordRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct)
{
var query = _db.HealthRecords.Where(r => r.UserId == userId);
if (type.HasValue)
query = query.Where(r => r.MetricType == type.Value);
if (recordedAfter.HasValue)
query = query.Where(r => r.RecordedAt >= recordedAfter.Value);
return await query.OrderByDescending(r => r.RecordedAt).Take(limit).ToListAsync(ct);
}
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) =>
_db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
public Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct) =>
_db.HealthRecords
.Where(r => r.UserId == userId && r.MetricType == type)
.OrderByDescending(r => r.RecordedAt)
.FirstOrDefaultAsync(ct);
public async Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct) =>
await _db.HealthRecords
.Where(r => r.UserId == userId && r.MetricType == type && r.RecordedAt >= recordedAfter)
.OrderBy(r => r.RecordedAt)
.ToListAsync(ct);
public async Task AddAsync(HealthRecord record, CancellationToken ct) =>
await _db.HealthRecords.AddAsync(record, ct);
public void Delete(HealthRecord record) =>
_db.HealthRecords.Remove(record);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,52 @@
using Health.Application.Maintenance;
namespace Health.Infrastructure.Maintenance;
public sealed class EfMaintenanceRepository(AppDbContext db) : IMaintenanceRepository
{
private readonly AppDbContext _db = db;
public async Task<MaintenanceCleanupResult> CleanupAsync(
DateTime conversationCutoff,
DateTime taskCutoff,
DateTime utcNow,
CancellationToken ct)
{
var oldConversationIds = await _db.Conversations.AsNoTracking()
.Where(x => x.UpdatedAt < conversationCutoff)
.Select(x => x.Id)
.ToListAsync(ct);
if (oldConversationIds.Count > 0)
{
await _db.ConversationMessages
.Where(x => oldConversationIds.Contains(x.ConversationId))
.ExecuteDeleteAsync(ct);
await _db.Conversations
.Where(x => oldConversationIds.Contains(x.Id))
.ExecuteDeleteAsync(ct);
}
var verificationCodes = await _db.VerificationCodes
.Where(x => x.ExpiresAt < utcNow)
.ExecuteDeleteAsync(ct);
var backgroundTasks = 0;
backgroundTasks += await _db.ReportAnalysisTasks
.Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed"))
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.DietImageAnalysisTasks
.Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed"))
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.MedicationReminderTasks
.Where(x => x.UpdatedAt < taskCutoff && (x.Status == "Completed" || x.Status == "Failed"))
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.NotificationOutbox
.Where(x => x.UpdatedAt < taskCutoff)
.ExecuteDeleteAsync(ct);
backgroundTasks += await _db.AiWriteCommands
.Where(x => x.UpdatedAt < taskCutoff)
.ExecuteDeleteAsync(ct);
return new MaintenanceCleanupResult(oldConversationIds.Count, verificationCodes, backgroundTasks);
}
}

View File

@@ -0,0 +1,79 @@
using Health.Application.Medications;
namespace Health.Infrastructure.Medications;
public sealed class EfMedicationRepository(AppDbContext db) : IMedicationRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct)
{
var query = _db.Medications.Include(m => m.Logs).Where(m => m.UserId == userId);
if (filter == "active") query = query.Where(m => m.IsActive);
else if (filter == "inactive") query = query.Where(m => !m.IsActive);
return await query.OrderByDescending(m => m.CreatedAt).ToListAsync(ct);
}
public async Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) =>
await _db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0
&& (m.StartDate == null || m.StartDate <= today)
&& (m.EndDate == null || m.EndDate >= today))
.ToListAsync(ct);
public async Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
await _db.MedicationLogs
.Where(l => l.UserId == userId && l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc)
.ToListAsync(ct);
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct);
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
_db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct);
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.FirstOrDefaultAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
&& l.Status == MedicationLogStatus.Taken, ct);
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.AnyAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.ScheduledTime == scheduledTime
&& l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc, ct);
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.FirstOrDefaultAsync(l =>
l.MedicationId == medicationId && l.UserId == userId
&& l.ScheduledTime == scheduledTime
&& l.ConfirmedAt >= startUtc && l.ConfirmedAt < endUtc, ct);
public async Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) =>
await _db.Medications
.Where(m => m.IsActive && m.TimeOfDay.Count > 0)
.ToListAsync(ct);
public Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.AnyAsync(l =>
l.UserId == userId && l.MedicationId == medicationId
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
&& l.Status == MedicationLogStatus.Taken, ct);
public async Task AddMedicationAsync(Medication medication, CancellationToken ct) =>
await _db.Medications.AddAsync(medication, ct);
public async Task AddLogAsync(MedicationLog log, CancellationToken ct) =>
await _db.MedicationLogs.AddAsync(log, ct);
public void DeleteMedication(Medication medication) =>
_db.Medications.Remove(medication);
public void DeleteLog(MedicationLog log) =>
_db.MedicationLogs.Remove(log);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,110 @@
using Health.Application.Medications;
using Health.Infrastructure.Data.Records;
using Npgsql;
namespace Health.Infrastructure.Medications;
public sealed class MedicationReminderQueue(AppDbContext db) : IMedicationReminderQueue
{
private const int MaxAttempts = 5;
private readonly AppDbContext _db = db;
public async Task<bool> EnqueueAsync(MedicationReminderTask task, CancellationToken ct)
{
var exists = await _db.MedicationReminderTasks.AsNoTracking().AnyAsync(x =>
x.MedicationId == task.MedicationId &&
x.ScheduledDate == task.ScheduledDate &&
x.ScheduledTime == task.ScheduledTime, ct);
if (exists) return false;
var now = DateTime.UtcNow;
await _db.MedicationReminderTasks.AddAsync(new MedicationReminderTaskRecord
{
Id = task.TaskId,
UserId = task.UserId,
MedicationId = task.MedicationId,
Name = task.Name,
Dosage = task.Dosage,
ScheduledDate = task.ScheduledDate,
ScheduledTime = task.ScheduledTime,
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
try
{
await _db.SaveChangesAsync(ct);
return true;
}
catch (DbUpdateException ex) when (
ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
_db.ChangeTracker.Clear();
return false;
}
}
public Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.MedicationReminderTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.MedicationReminderTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.MedicationReminderTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
if (claimed == 0) return null;
return new MedicationReminderTask(
candidate.Id,
candidate.UserId,
candidate.MedicationId,
candidate.Name,
candidate.Dosage,
candidate.ScheduledDate,
candidate.ScheduledTime);
}
public Task CompleteAsync(Guid taskId, CancellationToken ct) =>
_db.MedicationReminderTasks.Where(x => x.Id == taskId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
public async Task RetryAsync(Guid taskId, string error, CancellationToken ct)
{
var task = await _db.MedicationReminderTasks.FirstOrDefaultAsync(x => x.Id == taskId, ct);
if (task == null) return;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
using Health.Application.Medications;
using Health.Infrastructure.Data.Records;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Medications;
public sealed class OutboxMedicationReminderDispatcher(
AppDbContext db,
ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher
{
private readonly AppDbContext _db = db;
private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger;
public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct)
{
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == task.TaskId, ct))
return;
var now = DateTime.UtcNow;
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
{
Id = Guid.NewGuid(),
UserId = task.UserId,
SourceTaskId = task.TaskId,
Type = "MedicationReminder",
Payload = JsonSerializer.Serialize(new
{
task.MedicationId,
task.Name,
task.Dosage,
task.ScheduledDate,
task.ScheduledTime,
}),
Status = "AwaitingProvider",
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
await _db.SaveChangesAsync(ct);
_logger.LogInformation(
"用药提醒已写入通知 Outbox等待推送服务: {TaskId} {UserId}",
task.TaskId,
task.UserId);
}
}

View File

@@ -0,0 +1,26 @@
using Health.Application.Notifications;
namespace Health.Infrastructure.Notifications;
public sealed class EfInAppNotificationRepository(AppDbContext db) : IInAppNotificationRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct) =>
await _db.NotificationOutbox.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == "AwaitingProvider")
.OrderBy(x => x.CreatedAt)
.Take(10)
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Payload, x.CreatedAt))
.ToListAsync(ct);
public async Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct)
{
var updated = await _db.NotificationOutbox
.Where(x => x.Id == notificationId && x.UserId == userId && x.Status == "AwaitingProvider")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "InAppDelivered")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
return updated > 0;
}
}

View File

@@ -0,0 +1,86 @@
using Health.Application.Reports;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Reports;
public sealed class EfReportAnalysisQueue(AppDbContext db) : IReportAnalysisQueue
{
private const int MaxAttempts = 3;
private readonly AppDbContext _db = db;
public async Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
await _db.ReportAnalysisTasks.AddAsync(new ReportAnalysisTaskRecord
{
Id = job.TaskId,
ReportId = job.ReportId,
FilePath = job.FilePath,
Status = "Pending",
Attempts = 0,
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
await _db.SaveChangesAsync(ct);
}
public Task RecoverStaleAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
return _db.ReportAnalysisTasks
.Where(x => x.Status == "Processing" && x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Pending")
.SetProperty(x => x.AvailableAt, now)
.SetProperty(x => x.UpdatedAt, now), ct);
}
public async Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var candidate = await _db.ReportAnalysisTasks.AsNoTracking()
.Where(x => x.Status == "Pending" && x.AvailableAt <= now && x.Attempts < MaxAttempts)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (candidate == null) return null;
var claimed = await _db.ReportAnalysisTasks
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
.SetProperty(x => x.UpdatedAt, now), ct);
return claimed == 0
? null
: new ReportAnalysisJob(candidate.Id, candidate.ReportId, candidate.FilePath);
}
public async Task CompleteAsync(Guid taskId, CancellationToken ct)
{
await _db.ReportAnalysisTasks
.Where(x => x.Id == taskId && x.Status == "Processing")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
public async Task RetryAsync(Guid taskId, string error, CancellationToken ct)
{
var task = await _db.ReportAnalysisTasks.FirstOrDefaultAsync(x => x.Id == taskId, ct);
if (task == null) return;
task.LastError = error.Length > 2000 ? error[..2000] : error;
task.UpdatedAt = DateTime.UtcNow;
if (task.Attempts >= MaxAttempts)
{
task.Status = "Failed";
}
else
{
task.Status = "Pending";
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
}
await _db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,29 @@
using Health.Application.Reports;
namespace Health.Infrastructure.Reports;
public sealed class EfReportRepository(AppDbContext db) : IReportRepository
{
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<Report>> ListAsync(Guid userId, CancellationToken ct) =>
await _db.Reports
.Where(r => r.UserId == userId)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync(ct);
public Task<Report?> GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct) =>
_db.Reports.FirstOrDefaultAsync(r => r.Id == reportId && r.UserId == userId, ct);
public Task<Report?> GetByIdAsync(Guid reportId, CancellationToken ct) =>
_db.Reports.FirstOrDefaultAsync(r => r.Id == reportId, ct);
public async Task AddAsync(Report report, CancellationToken ct) =>
await _db.Reports.AddAsync(report, ct);
public void Delete(Report report) =>
_db.Reports.Remove(report);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,30 @@
using Health.Application.Reports;
namespace Health.Infrastructure.Reports;
public sealed class LocalReportFileStorage : IReportFileStorage
{
public async Task<StoredReportFile> SaveAsync(ReportUploadFile file, string extension, CancellationToken ct)
{
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports");
Directory.CreateDirectory(uploadsDir);
var fileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(uploadsDir, fileName);
await using (var stream = new FileStream(filePath, FileMode.CreateNew))
{
await file.Content.CopyToAsync(stream, ct);
}
return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
}
public string GetLocalFilePath(string fileUrl) =>
Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/'));
public bool Exists(string filePath) =>
File.Exists(filePath);
public void Delete(string filePath) =>
File.Delete(filePath);
}

View File

@@ -0,0 +1,165 @@
using Health.Application.Reports;
using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Reports;
public sealed class ReportAnalysisService(
IReportRepository reports,
VisionClient vision,
DeepSeekClient llm,
ILogger<ReportAnalysisService> logger) : IReportAnalysisService
{
private readonly IReportRepository _reports = reports;
private readonly VisionClient _vision = vision;
private readonly DeepSeekClient _llm = llm;
private readonly ILogger<ReportAnalysisService> _logger = logger;
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
{
var report = await _reports.GetByIdAsync(job.ReportId, ct);
if (report == null) return;
try
{
var vlmJson = await ExtractIndicatorsAsync(job.FilePath, ct);
var isReport = vlmJson.TryGetProperty("isReport", out var ir) && ir.GetBoolean();
var reason = vlmJson.TryGetProperty("reason", out var rsn) ? rsn.GetString() : null;
if (!isReport)
{
report.AiSummary = $"AI 暂时无法完成解读:{reason ?? ""}。请重新上传清晰的报告图片。";
report.AiIndicators = "[]";
report.Status = ReportStatus.AnalysisFailed;
await _reports.SaveChangesAsync(ct);
return;
}
var reportType = vlmJson.TryGetProperty("reportType", out var rt) ? rt.GetString() ?? "Other" : "Other";
var indicatorsJson = vlmJson.TryGetProperty("indicators", out var inds) ? inds.GetRawText() : "[]";
report.Category = Enum.TryParse<ReportCategory>(reportType, out var cat) ? cat : ReportCategory.Other;
report.AiIndicators = indicatorsJson;
report.AiSummary = await GenerateSummaryAsync(indicatorsJson, ct);
report.Status = ReportStatus.PendingDoctor;
await _reports.SaveChangesAsync(ct);
_logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId);
}
catch (Exception ex)
{
_logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId);
report.Status = ReportStatus.AnalysisFailed;
report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。";
report.AiIndicators = "[]";
await _reports.SaveChangesAsync(ct);
}
}
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
{
var imageUrl = await GetLocalImageUrl(filePath, ct);
if (imageUrl == null)
throw new InvalidOperationException("报告图片文件不存在或无法读取");
var prompt = """
{"isReport": false, "reason": "这不是医学报告,原因:..."}
JSON格式markdown包裹
{
"isReport": true,
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
"indicators": [
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
]
}
- status =normal=high=low
-
""";
var response = await _vision.VisionAsync(
prompt,
[imageUrl],
userText: "请分析这份医学报告",
maxTokens: 2048,
ct: ct);
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
_logger.LogInformation("报告 VLM 返回: {Content}", content[..Math.Min(content.Length, 200)]);
var json = TryParseJson(content);
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
}
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
{
var prompt = $"""
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
检测指标:{indicatorsJson}
要求:
1. 总字数200-300字
2. 先总结整体情况
3. 指出异常指标及其可能原因,但不要给出确定诊断
4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议
5. 如指标明显异常或存在急症风险,优先建议及时就医
6. 末尾提醒"AI预解读"
JSON格式
""";
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" },
new() { Role = "user", Content = prompt }
};
var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct);
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
return !string.IsNullOrWhiteSpace(summary)
? summary
: throw new InvalidOperationException("AI 未返回有效解读内容");
}
private static async Task<string?> GetLocalImageUrl(string filePath, CancellationToken ct)
{
if (!File.Exists(filePath)) return null;
var bytes = await File.ReadAllBytesAsync(filePath, ct);
var base64 = Convert.ToBase64String(bytes);
var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.');
var mime = ext switch
{
"png" => "image/png",
"jpg" or "jpeg" => "image/jpeg",
"webp" => "image/webp",
_ => "image/png"
};
return $"data:{mime};base64,{base64}";
}
private static JsonElement? TryParseJson(string? content)
{
if (string.IsNullOrWhiteSpace(content)) return null;
content = content.Trim();
if (content.StartsWith("```"))
{
var end = content.IndexOf('\n');
if (end > 0) content = content[(end + 1)..];
if (content.EndsWith("```"))
content = content[..^3];
}
content = content.Trim();
try { return JsonDocument.Parse(content).RootElement; }
catch { return null; }
}
}

View File

@@ -0,0 +1,51 @@
using Health.Application.Users;
namespace Health.Infrastructure.Users;
public sealed class EfUserRepository(AppDbContext db) : IUserRepository
{
private readonly AppDbContext _db = db;
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
public async Task DeleteAccountDataAsync(Guid userId, CancellationToken ct)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct);
if (profile?.DoctorId != null)
{
await _db.Users.Where(u => u.DoctorId == profile.DoctorId)
.ExecuteUpdateAsync(u => u.SetProperty(x => x.DoctorId, (Guid?)null), ct);
await _db.Doctors.Where(d => d.Id == profile.DoctorId).ExecuteDeleteAsync(ct);
}
await _db.HealthRecords.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
await _db.MedicationLogs.Where(l => l.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Medications.Where(m => m.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DietFoodItems.Where(i => i.DietRecord!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DietRecords.Where(d => d.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ExercisePlanItems.Where(i => i.Plan!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ExercisePlans.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ReportAnalysisTasks.Where(t => _db.Reports.Any(r => r.Id == t.ReportId && r.UserId == userId)).ExecuteDeleteAsync(ct);
await _db.Reports.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ConversationMessages.Where(m => m.Conversation!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Conversations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await _db.ConsultationMessages.Where(m => m.Consultation!.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Consultations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await _db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct);
await _db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await _db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct);
await _db.AiWriteCommands.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await _db.DoctorProfiles.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync(ct);
await transaction.CommitAsync(ct);
}
}

View File

@@ -1,8 +1,7 @@
using Health.Application.Maintenance;
namespace Health.WebApi.BackgroundServices; namespace Health.WebApi.BackgroundServices;
/// <summary>
/// 数据清理后台服务(每小时检查一次)
/// </summary>
public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<CleanupService> logger) : BackgroundService public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<CleanupService> logger) : BackgroundService
{ {
private readonly IServiceScopeFactory _scopeFactory = scopeFactory; private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
@@ -15,44 +14,21 @@ public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<Cl
try try
{ {
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var maintenance = scope.ServiceProvider.GetRequiredService<IMaintenanceService>();
var result = await maintenance.CleanupAsync(DateTime.UtcNow, stoppingToken);
// 清理 30 天前的对话记录(先删消息再删对话,避免 FK 冲突) if (result.Conversations + result.VerificationCodes + result.BackgroundTasks > 0)
var cutoff = DateTime.UtcNow.AddDays(-30);
var oldConvIds = await db.Conversations
.Where(c => c.UpdatedAt < cutoff)
.Select(c => c.Id)
.ToListAsync(stoppingToken);
if (oldConvIds.Count > 0)
{ {
// 先批量删消息 _logger.LogInformation(
var oldMessages = await db.ConversationMessages "自动清理完成: 会话 {Conversations}, 验证码 {Codes}, 后台任务 {Tasks}",
.Where(m => oldConvIds.Contains(m.ConversationId)) result.Conversations,
.ToListAsync(stoppingToken); result.VerificationCodes,
db.ConversationMessages.RemoveRange(oldMessages); result.BackgroundTasks);
// 再删对话
var oldConversations = await db.Conversations
.Where(c => oldConvIds.Contains(c.Id))
.ToListAsync(stoppingToken);
db.Conversations.RemoveRange(oldConversations);
await db.SaveChangesAsync(stoppingToken);
_logger.LogInformation("清理 {Count} 条过期对话", oldConvIds.Count);
}
// 清理过期验证码
var expiredCodes = await db.VerificationCodes
.Where(v => v.ExpiresAt < DateTime.UtcNow)
.ToListAsync(stoppingToken);
if (expiredCodes.Count > 0)
{
db.VerificationCodes.RemoveRange(expiredCodes);
await db.SaveChangesAsync(stoppingToken);
} }
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "数据清理异常"); _logger.LogError(ex, "数据清理异常");

View File

@@ -0,0 +1,73 @@
using Health.Application.Diets;
namespace Health.WebApi.BackgroundServices;
public sealed class DietImageAnalysisWorker(
IServiceScopeFactory scopeFactory,
ILogger<DietImageAnalysisWorker> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<DietImageAnalysisWorker> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("饮食图片识别持久化任务消费者已启动");
var idleDelay = TimeSpan.FromSeconds(1);
var nextRecovery = DateTime.MinValue;
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var queue = scope.ServiceProvider.GetRequiredService<IDietImageAnalysisQueue>();
if (DateTime.UtcNow >= nextRecovery)
{
await queue.RecoverStaleAsync(stoppingToken);
nextRecovery = DateTime.UtcNow.AddMinutes(1);
}
var job = await queue.TryTakeAsync(stoppingToken);
if (job == null)
{
await Task.Delay(idleDelay, stoppingToken);
idleDelay = TimeSpan.FromSeconds(Math.Min(idleDelay.TotalSeconds * 2, 5));
continue;
}
idleDelay = TimeSpan.FromSeconds(1);
try
{
var analyzer = scope.ServiceProvider.GetRequiredService<IDietImageAnalyzer>();
var result = await analyzer.AnalyzeAsync(job, stoppingToken);
await queue.CompleteAsync(job.Id, result, stoppingToken);
DeleteFiles(job.FilePaths);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "饮食图片识别任务失败: {JobId}", job.Id);
var terminal = await queue.RetryAsync(job.Id, ex.Message, stoppingToken);
if (terminal) DeleteFiles(job.FilePaths);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "饮食图片识别消费者循环异常");
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
private static void DeleteFiles(IEnumerable<string> paths)
{
foreach (var path in paths)
{
try { if (File.Exists(path)) File.Delete(path); }
catch { }
}
}
}

View File

@@ -0,0 +1,32 @@
using Health.Application.Exercises;
namespace Health.WebApi.BackgroundServices;
public sealed class ExerciseReminderService(IServiceScopeFactory scopeFactory, ILogger<ExerciseReminderService> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<ExerciseReminderService> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var producer = scope.ServiceProvider.GetRequiredService<IExerciseReminderProducer>();
var count = await producer.ProduceDueAsync(DateTime.UtcNow, stoppingToken);
if (count > 0) _logger.LogInformation("已生成 {Count} 条 App 内运动提醒", count);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "生成运动提醒失败");
}
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}

View File

@@ -1,22 +1,31 @@
using Health.Application.Medications;
namespace Health.WebApi.BackgroundServices; namespace Health.WebApi.BackgroundServices;
/// <summary> public sealed class MedicationReminderService(
/// 用药提醒定时扫描服务(每分钟检查一次) IServiceScopeFactory scopeFactory,
/// </summary> ILogger<MedicationReminderService> logger) : BackgroundService
public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory, ILogger<MedicationReminderService> logger) : BackgroundService
{ {
private readonly IServiceScopeFactory _scopeFactory = scopeFactory; private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<MedicationReminderService> _logger = logger; private readonly ILogger<MedicationReminderService> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
_logger.LogInformation("用药提醒服务已启动"); _logger.LogInformation("用药提醒扫描生产者已启动");
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
try try
{ {
await ProcessReminders(stoppingToken); using var scope = _scopeFactory.CreateScope();
var scanner = scope.ServiceProvider.GetRequiredService<IMedicationReminderScanner>();
var queue = scope.ServiceProvider.GetRequiredService<IMedicationReminderQueue>();
var tasks = await scanner.ScanAsync(DateTime.UtcNow, stoppingToken);
foreach (var task in tasks)
await queue.EnqueueAsync(task, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -26,37 +35,4 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
} }
} }
private async Task ProcessReminders(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 北京时间今天0点 → 转为UTC
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
var todayStartUtc = beijingToday.AddHours(-8);
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内
var beijingTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var windowStart = beijingTime.AddMinutes(-5);
var medications = await db.Medications
.Where(m => m.IsActive)
.Where(m => m.TimeOfDay.Any(t =>
beijingTime > windowStart ? (t >= windowStart && t <= beijingTime) : (t >= windowStart || t <= beijingTime)))
.ToListAsync(ct);
foreach (var med in medications)
{
// 检查今天是否已打卡(用正确的北京时间区间)
var alreadyLogged = await db.MedicationLogs
.AnyAsync(l => l.MedicationId == med.Id
&& l.CreatedAt >= todayStartUtc
&& l.Status == MedicationLogStatus.Taken, ct);
if (alreadyLogged) continue;
// TODO: 调用极光推送发送用药提醒
_logger.LogInformation("用药提醒: 用户 {UserId} 药品 {Name} {Dosage} 时间 {Time}",
med.UserId, med.Name, med.Dosage, beijingTime);
}
}
} }

View File

@@ -0,0 +1,62 @@
using Health.Application.Medications;
namespace Health.WebApi.BackgroundServices;
public sealed class MedicationReminderWorker(
IServiceScopeFactory scopeFactory,
ILogger<MedicationReminderWorker> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<MedicationReminderWorker> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("用药提醒持久化任务消费者已启动");
var idleDelay = TimeSpan.FromSeconds(1);
var nextRecovery = DateTime.MinValue;
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var queue = scope.ServiceProvider.GetRequiredService<IMedicationReminderQueue>();
if (DateTime.UtcNow >= nextRecovery)
{
await queue.RecoverStaleAsync(stoppingToken);
nextRecovery = DateTime.UtcNow.AddMinutes(1);
}
var task = await queue.TryTakeAsync(stoppingToken);
if (task == null)
{
await Task.Delay(idleDelay, stoppingToken);
idleDelay = TimeSpan.FromSeconds(Math.Min(idleDelay.TotalSeconds * 2, 5));
continue;
}
idleDelay = TimeSpan.FromSeconds(1);
try
{
var dispatcher = scope.ServiceProvider.GetRequiredService<IMedicationReminderDispatcher>();
await dispatcher.DispatchAsync(task, stoppingToken);
await queue.CompleteAsync(task.TaskId, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "用药提醒消费失败: {MedicationId}", task.MedicationId);
await queue.RetryAsync(task.TaskId, ex.Message, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "用药提醒消费者循环异常");
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
}

View File

@@ -0,0 +1,60 @@
using Health.Application.Reports;
namespace Health.WebApi.BackgroundServices;
public sealed class ReportAnalysisWorker(
IServiceScopeFactory scopeFactory,
ILogger<ReportAnalysisWorker> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<ReportAnalysisWorker> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("报告 AI 持久化任务消费者已启动");
var idleDelay = TimeSpan.FromSeconds(1);
var nextRecovery = DateTime.MinValue;
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var queue = scope.ServiceProvider.GetRequiredService<IReportAnalysisQueue>();
if (DateTime.UtcNow >= nextRecovery)
{
await queue.RecoverStaleAsync(stoppingToken);
nextRecovery = DateTime.UtcNow.AddMinutes(1);
}
var job = await queue.TryTakeAsync(stoppingToken);
if (job == null)
{
await Task.Delay(idleDelay, stoppingToken);
idleDelay = TimeSpan.FromSeconds(Math.Min(idleDelay.TotalSeconds * 2, 5));
continue;
}
idleDelay = TimeSpan.FromSeconds(1);
try
{
var analysisService = scope.ServiceProvider.GetRequiredService<IReportAnalysisService>();
await analysisService.AnalyzeAsync(job, stoppingToken);
await queue.CompleteAsync(job.TaskId, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "报告 AI 分析任务执行异常: {ReportId}", job.ReportId);
await queue.RetryAsync(job.TaskId, ex.Message, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "报告任务消费者循环异常");
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
}

View File

@@ -1,201 +1,33 @@
using System.Security.Claims; using System.Security.Claims;
using Health.Domain.Entities; using Health.Application.Admin;
using Microsoft.EntityFrameworkCore;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
/// <summary>
/// 管理员 API需JWT鉴权 + Role=Admin
/// </summary>
public static class AdminEndpoints public static class AdminEndpoints
{ {
private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
private static string GetUserRole(HttpContext http) =>
http.User.FindFirst("Role")?.Value ?? http.User.FindFirst(ClaimTypes.Role)?.Value ?? "User";
public static void MapAdminEndpoints(this WebApplication app) public static void MapAdminEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("/api/admin").RequireAuthorization(); var group = app.MapGroup("/api/admin").RequireAuthorization();
// 管理员身份校验
group.AddEndpointFilter(async (context, next) => group.AddEndpointFilter(async (context, next) =>
{ GetRole(context.HttpContext) == "Admin"
var role = GetUserRole(context.HttpContext); ? await next(context)
if (role != "Admin") : Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403));
return Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403);
return await next(context);
});
// ===== 医生列表 ===== group.MapGet("/doctors", async (IAdminService admin, CancellationToken ct) => ToResult(await admin.ListDoctorsAsync(ct)));
group.MapGet("/doctors", async (AppDbContext db) => group.MapPost("/doctors", async (AddDoctorRequest request, IAdminService admin, CancellationToken ct) =>
{ ToResult(await admin.AddDoctorAsync(new AddDoctorCommand(request.Phone, request.Name, request.Title, request.Department, request.ProfessionalDirection), ct)));
var doctors = await db.Doctors group.MapPut("/doctors/{id:guid}", async (Guid id, UpdateDoctorRequest request, IAdminService admin, CancellationToken ct) =>
.OrderBy(d => d.CreatedAt) ToResult(await admin.UpdateDoctorAsync(id, new UpdateDoctorCommand(request.Phone, request.Name, request.Title, request.Department, request.ProfessionalDirection), ct)));
.Select(d => new group.MapPut("/doctors/{id:guid}/disable", async (Guid id, IAdminService admin, CancellationToken ct) => ToResult(await admin.ToggleDoctorAsync(id, ct)));
{ group.MapDelete("/doctors/{id:guid}", async (Guid id, IAdminService admin, CancellationToken ct) => ToResult(await admin.DeleteDoctorAsync(id, ct)));
d.Id, d.Name, d.Title, d.Department, group.MapGet("/patients", async (IAdminService admin, string? search, int? page, int? pageSize, CancellationToken ct) =>
d.Phone, d.ProfessionalDirection, ToResult(await admin.ListPatientsAsync(search, page ?? 1, pageSize ?? 20, ct)));
d.IsActive, d.AvatarUrl, d.Introduction,
d.CreatedAt
})
.ToListAsync();
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
});
// ===== 新增医生 =====
group.MapPost("/doctors", async (
AddDoctorRequest request,
AppDbContext db,
CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.Phone))
return Results.Ok(new { code = 40001, data = (object?)null, message = "手机号不能为空" });
if (string.IsNullOrWhiteSpace(request.Name))
return Results.Ok(new { code = 40002, data = (object?)null, message = "姓名不能为空" });
var doctor = new Doctor
{
Id = Guid.NewGuid(),
Name = request.Name,
Title = request.Title,
Department = request.Department,
Phone = request.Phone,
ProfessionalDirection = request.ProfessionalDirection,
IsActive = true,
CreatedAt = DateTime.UtcNow,
};
db.Doctors.Add(doctor);
// 同步创建 User + DoctorProfile医生可用手机号登录
var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
if (existingUser == null)
{
var user = new User
{
Id = Guid.NewGuid(),
Phone = request.Phone,
Role = "Doctor",
Name = request.Name,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.Users.Add(user);
db.DoctorProfiles.Add(new DoctorProfile
{
Id = Guid.NewGuid(),
UserId = user.Id,
DoctorId = doctor.Id,
Name = request.Name,
Title = request.Title,
Department = request.Department,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
}
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.Name }, message = (string?)null });
});
// ===== 编辑医生 =====
group.MapPut("/doctors/{id:guid}", async (
Guid id, UpdateDoctorRequest request, AppDbContext db, CancellationToken ct) =>
{
var doctor = await db.Doctors.FindAsync([id], ct);
if (doctor == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
if (request.Name != null) doctor.Name = request.Name;
if (request.Title != null) doctor.Title = request.Title;
if (request.Department != null) doctor.Department = request.Department;
if (request.Phone != null) doctor.Phone = request.Phone;
if (request.ProfessionalDirection != null) doctor.ProfessionalDirection = request.ProfessionalDirection;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { doctor.Id }, message = (string?)null });
});
// ===== 启用/停用医生 =====
group.MapPut("/doctors/{id:guid}/disable", async (
Guid id, AppDbContext db, CancellationToken ct) =>
{
var doctor = await db.Doctors.FindAsync([id], ct);
if (doctor == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
doctor.IsActive = !doctor.IsActive;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.IsActive }, message = (string?)null });
});
// ===== 删除医生 =====
group.MapDelete("/doctors/{id:guid}", async (
Guid id, AppDbContext db, CancellationToken ct) =>
{
var doctor = await db.Doctors.FindAsync([id], ct);
if (doctor == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
// 清除患者的 DoctorId 关联
await db.Users.Where(u => u.DoctorId == id)
.ExecuteUpdateAsync(s => s.SetProperty(u => u.DoctorId, (Guid?)null), ct);
db.Doctors.Remove(doctor);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
// ===== 患者列表 =====
group.MapGet("/patients", async (
AppDbContext db,
string? search,
int? page,
int? pageSize) =>
{
var p = page ?? 1;
var ps = pageSize ?? 20;
var query = db.Users
.Where(u => u.Role == "User")
.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(u => u.Name!.Contains(search) || u.Phone.Contains(search));
}
var total = await query.CountAsync();
var patients = await query
.OrderByDescending(u => u.CreatedAt)
.Skip((p - 1) * ps)
.Take(ps)
.Select(u => new
{
u.Id, u.Name, u.Phone, u.Gender,
u.BirthDate, u.CreatedAt,
DoctorName = u.Doctor != null ? u.Doctor.Name : null,
DoctorDepartment = u.Doctor != null ? u.Doctor.Department : null,
})
.ToListAsync();
return Results.Ok(new
{
code = 0,
data = new { patients, total, page = p, pageSize = ps },
message = (string?)null
});
});
} }
private static string GetRole(HttpContext http) =>
http.User.FindFirst("Role")?.Value ?? http.User.FindFirst(ClaimTypes.Role)?.Value ?? "User";
private static IResult ToResult(AdminResult result) => Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
} }
// ---- DTO ----
public sealed record AddDoctorRequest(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection); public sealed record AddDoctorRequest(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
public sealed record UpdateDoctorRequest(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection); public sealed record UpdateDoctorRequest(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);

View File

@@ -1,6 +1,11 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using Health.Application.AI;
using Health.Application.Diets;
using Health.Application.Exercises;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Health.Infrastructure.AI.AgentHandlers; using Health.Infrastructure.AI.AgentHandlers;
@@ -26,10 +31,12 @@ public static class AiChatEndpoints
string token, string token,
string agentType, string agentType,
HttpContext http, HttpContext http,
AppDbContext db,
DeepSeekClient llmClient, DeepSeekClient llmClient,
PromptManager promptManager, PromptManager promptManager,
VisionClient visionClient, IAiToolExecutionService toolExecution,
IAiWriteConfirmationStore confirmations,
IAiConversationService conversations,
IPatientContextService patientContexts,
CancellationToken ct) => CancellationToken ct) =>
{ {
// 支持 token 通过 query string浏览器 EventSource或 header 传递 // 支持 token 通过 query string浏览器 EventSource或 header 传递
@@ -51,38 +58,57 @@ public static class AiChatEndpoints
http.Response.Headers.Connection = "keep-alive"; http.Response.Headers.Connection = "keep-alive";
http.Response.Headers["X-Accel-Buffering"] = "no"; http.Response.Headers["X-Accel-Buffering"] = "no";
// 创建或获取对话 // 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。
Conversation? conversation = null; Guid? requestedConversationId = null;
if (!string.IsNullOrEmpty(conversationId) && Guid.TryParse(conversationId, out var convId)) if (!string.IsNullOrWhiteSpace(conversationId))
conversation = await db.Conversations.FindAsync([convId], ct);
if (conversation == null)
{ {
conversation = new Conversation if (!Guid.TryParse(conversationId, out var convId))
{ {
Id = Guid.NewGuid(), UserId = userId.Value, AgentType = parsedType, await SseWriteAsync(http, new { action = "answer", data = "当前会话参数异常,请重新开始一次对话。" }, ct);
Title = message.Length > 30 ? message[..30] : message, await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, await http.Response.WriteAsync("data: [DONE]\n\n", ct);
}; return;
db.Conversations.Add(conversation); }
await db.SaveChangesAsync(ct); requestedConversationId = convId;
await SseWriteAsync(http, new { action = "conversation_id", data = conversation.Id.ToString() }, ct);
} }
// 保存用户消息 var opened = await conversations.OpenAsync(userId.Value, requestedConversationId, parsedType, message, ct);
var userMsg = new ConversationMessage if (!opened.Found)
{ {
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.User, await SseWriteAsync(http, new { action = "answer", data = "当前会话不存在或不属于当前账号,请重新开始一次对话。" }, ct);
Content = message, CreatedAt = DateTime.UtcNow, await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
}; await http.Response.WriteAsync("data: [DONE]\n\n", ct);
db.ConversationMessages.Add(userMsg); return;
conversation.MessageCount++; }
conversation.UpdatedAt = DateTime.UtcNow; var activeConversationId = opened.ConversationId;
await db.SaveChangesAsync(ct); if (opened.Created)
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
await conversations.AddUserMessageAsync(activeConversationId, message, ct);
var urgentWarning = DetectUrgentRisk(message);
if (!string.IsNullOrEmpty(urgentWarning))
{
var urgentResponse = $"""
{urgentWarning}
这种情况需要把安全放在第一位。请先停止自行判断和等待 AI 分析,尽快联系医生、互联网医院或前往急诊评估;如果症状正在加重,建议立即拨打当地急救电话。若身边有人,请让家人或同伴陪同,不要独自开车就医。
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
""";
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
await SseWriteAsync(http, new { action = "status", data = "done" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
return;
}
// 加载上下文 // 加载上下文
var systemPrompt = promptManager.GetSystemPrompt(parsedType); var systemPrompt = promptManager.GetSystemPrompt(parsedType);
var patientContext = await BuildPatientContext(db, userId.Value, ct); var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
var messages = new List<ChatMessage> var messages = new List<ChatMessage>
{ {
@@ -90,17 +116,13 @@ public static class AiChatEndpoints
}; };
// 加载历史对话(最近 10 条) // 加载历史对话(最近 10 条)
var history = await db.ConversationMessages var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
.Where(m => m.ConversationId == conversation.Id)
.OrderByDescending(m => m.CreatedAt)
.Take(12)
.ToListAsync(ct);
foreach (var h in history.Reverse<ConversationMessage>()) foreach (var h in history)
{ {
messages.Add(new ChatMessage messages.Add(new ChatMessage
{ {
Role = h.Role == MessageRole.User ? "user" : "assistant", Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant",
Content = h.Content, Content = h.Content,
}); });
} }
@@ -155,7 +177,9 @@ public static class AiChatEndpoints
object toolResult; object toolResult;
try try
{ {
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value, visionClient); toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments)
? await PreparePendingWriteAsync(confirmations, userId.Value, tc.Function.Name, tc.Function.Arguments, ct)
: await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -173,71 +197,62 @@ public static class AiChatEndpoints
// 保存 AI 回复 // 保存 AI 回复
if (!string.IsNullOrEmpty(fullResponse)) if (!string.IsNullOrEmpty(fullResponse))
{ await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct);
db.ConversationMessages.Add(new ConversationMessage
{
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.Assistant,
Content = fullResponse, CreatedAt = DateTime.UtcNow,
});
conversation.MessageCount++;
conversation.Summary = fullResponse.Length > 100 ? fullResponse[..100] : fullResponse;
conversation.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct); await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct); await http.Response.WriteAsync("data: [DONE]\n\n", ct);
}); });
app.MapPost("/api/ai/confirm-write/{commandId:guid}", async (
Guid commandId,
HttpContext http,
IAiToolExecutionService toolExecution,
CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null)
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
var result = await toolExecution.ConfirmAsync(commandId, userId.Value, ct);
return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
}).RequireAuthorization();
// 获取对话列表 // 获取对话列表
app.MapGet("/api/ai/conversations", async (HttpContext http, AppDbContext db, CancellationToken ct) => app.MapGet("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401); if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
var conversations = await db.Conversations var result = await conversations.ListAsync(userId.Value, ct);
.Where(c => c.UserId == userId.Value)
.OrderByDescending(c => c.UpdatedAt)
.Select(c => new { c.Id, AgentType = c.AgentType.ToString(), c.Title, c.Summary, c.MessageCount, c.CreatedAt, c.UpdatedAt })
.ToListAsync(ct);
return Results.Ok(new { code = 0, data = conversations, message = (string?)null }); return Results.Ok(new { code = 0, data = result, message = (string?)null });
}); });
// 获取对话历史 // 获取对话历史
app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var messages = await db.ConversationMessages var messages = await conversations.GetMessagesAsync(userId.Value, id, ct);
.Where(m => m.ConversationId == id && m.Conversation.UserId == userId.Value)
.OrderBy(m => m.CreatedAt)
.Select(m => new { m.Id, Role = m.Role.ToString(), m.Content, m.Intent, m.MetadataJson, m.CreatedAt })
.ToListAsync(ct);
return Results.Ok(new { code = 0, data = messages, message = (string?)null }); return Results.Ok(new { code = 0, data = messages, message = (string?)null });
}); });
// 删除对话 // 删除对话
app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var conv = await db.Conversations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId.Value, ct); await conversations.DeleteAsync(userId.Value, id, ct);
if (conv != null)
{
db.Conversations.Remove(conv);
await db.SaveChangesAsync(ct);
}
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
// VLM 食物识别
app.MapPost("/api/ai/analyze-food-image", async ( app.MapPost("/api/ai/analyze-food-image", async (
HttpRequest httpRequest, HttpContext http, HttpRequest httpRequest,
VisionClient visionClient, AppDbContext db, HttpContext http,
IDietImageAnalysisCoordinator dietAnalysis,
CancellationToken ct) => CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -245,61 +260,19 @@ public static class AiChatEndpoints
var form = await httpRequest.ReadFormAsync(ct); var form = await httpRequest.ReadFormAsync(ct);
var files = form.Files.GetFiles("images"); var files = form.Files.GetFiles("images");
if (files == null || files.Count == 0) var uploads = files
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" }); .Select(file => new DietImageUploadFile(file.FileName, file.Length, file.OpenReadStream()))
.ToList();
var imageUrls = new List<string>(); try
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsDir);
foreach (var file in files)
{ {
if (file.Length > 20 * 1024 * 1024) var result = await dietAnalysis.AnalyzeAsync(uploads, ct);
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小超过 20MB 限制" }); return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
}
var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); finally
if (ext is not ".jpg" and not ".jpeg" and not ".png" and not ".heic") {
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的图片格式,仅支持 JPG/PNG/HEIC" }); foreach (var upload in uploads)
await upload.Content.DisposeAsync();
var safeName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}";
var filePath = Path.Combine(uploadsDir, safeName);
using (var stream = new FileStream(filePath, FileMode.Create))
await file.CopyToAsync(stream, ct);
// 千问3.7-plus + vl_high_resolution_images=true 支持到 16M 像素,保留原图细节
// base64 ≥ 7MB官方限制时才压缩否则原图上传
var fileBytes = await File.ReadAllBytesAsync(filePath, ct);
var base64 = Convert.ToBase64String(fileBytes);
if (base64.Length > 7 * 1024 * 1024)
{
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 85L);
fileBytes = await File.ReadAllBytesAsync(compressedPath, ct);
base64 = Convert.ToBase64String(fileBytes);
}
imageUrls.Add($"data:image/jpeg;base64,{base64}");
} }
var prompt = """
JSON数组
[{"name":"名称","portion":"份量","calories":热量}]
JSON
""";
try
{
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
// 记录VLM原始返回用于排查
var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.UtcNow:HHmmss}.txt");
await File.WriteAllTextAsync(logPath, $"MODEL: {Environment.GetEnvironmentVariable("VLM_MODEL")}\nIMAGE_SIZE: {imageUrls.FirstOrDefault()?.Length ?? 0}\nRESPONSE:\n{result}", ct);
return Results.Ok(new { code = 0, data = result, message = (string?)null });
}
catch (Exception ex)
{
return Results.Ok(new { code = 50001, data = (object?)null, message = $"食物识别失败:{ex.Message}" });
}
}); });
} }
@@ -348,60 +321,176 @@ public static class AiChatEndpoints
_ => CommonAgentHandler.Tools, _ => CommonAgentHandler.Tools,
}; };
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId, VisionClient? visionClient = null) private static bool IsWriteToolCall(string toolName, string arguments)
{ {
using var jsonDoc = JsonDocument.Parse(arguments); if (toolName == "record_health_data") return true;
var root = jsonDoc.RootElement; if (toolName == "manage_archive") return GetToolAction(arguments) != "query";
if (toolName == "manage_medication") return GetToolAction(arguments) is "create" or "confirm";
if (toolName == "manage_exercise") return GetToolAction(arguments) is "create" or "checkin";
return false;
}
return toolName switch private static string GetToolAction(string arguments)
{
try
{ {
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, db, userId), using var json = JsonDocument.Parse(arguments);
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId), return json.RootElement.TryGetProperty("action", out var action)
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId), ? action.GetString()?.ToLowerInvariant() ?? ""
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId), : "";
"analyze_report" => visionClient != null }
? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient) catch
: Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }), {
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId), return "";
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root), }
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }) }
private static async Task<object> PreparePendingWriteAsync(
IAiWriteConfirmationStore confirmations,
Guid userId,
string toolName,
string arguments,
CancellationToken ct)
{
var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct);
using var json = JsonDocument.Parse(arguments);
var args = json.RootElement;
var preview = new Dictionary<string, object?>
{
["success"] = true,
["pendingConfirmation"] = true,
["confirmationId"] = command.Id,
["message"] = "等待用户确认后写入",
}; };
switch (toolName)
{
case "record_health_data":
AddHealthPreview(preview, args);
break;
case "manage_medication":
preview["type"] = "medication";
preview["name"] = GetString(args, "name") ?? "服药确认";
preview["dosage"] = GetString(args, "dosage") ?? "";
preview["frequency"] = GetString(args, "frequency") ?? "Daily";
preview["time"] = GetStringArray(args, "time_of_day");
preview["duration_days"] = GetInt(args, "duration_days") ?? 0;
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
break;
case "manage_exercise":
preview["type"] = "exercise";
preview["exercise_type"] = GetString(args, "exercise_type") ?? "运动";
preview["duration_minutes"] = GetInt(args, "duration_minutes") ?? 30;
preview["day_count"] = GetInt(args, "duration_days") ?? 7;
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
preview["reminder_time"] = GetString(args, "reminder_time") ?? "19:00";
break;
case "manage_archive":
preview["type"] = "archive";
preview["value"] = "健康档案更新";
preview["unit"] = "";
break;
}
return preview;
} }
// ── 患者上下文构建 ── private static void AddHealthPreview(Dictionary<string, object?> preview, JsonElement args)
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
{ {
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); var type = GetString(args, "type") ?? "";
var recentRecords = await db.HealthRecords.Where(r => r.UserId == userId) switch (type)
.OrderByDescending(r => r.RecordedAt).Take(10).ToListAsync(ct);
var sb = new System.Text.StringBuilder();
if (archive != null)
{ {
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}"); case "blood_pressure":
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})"); var systolic = GetInt(args, "systolic");
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}"); var diastolic = GetInt(args, "diastolic");
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}"); preview["type"] = HealthMetricType.BloodPressure.ToString();
preview["value"] = $"{systolic}/{diastolic}";
preview["unit"] = "mmHg";
preview["isAbnormal"] = systolic >= 140 || diastolic >= 90 || systolic <= 89 || diastolic <= 59;
break;
case "heart_rate":
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
break;
case "glucose":
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
break;
case "spo2":
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
break;
case "weight":
AddMetricPreview(preview, HealthMetricType.Weight, GetDecimal(args, "weight"), "kg", _ => false);
break;
} }
if (recentRecords.Count > 0)
{
sb.AppendLine("近期健康数据:");
foreach (var r in recentRecords)
sb.AppendLine($" {r.MetricType}: {RecordValue(r)} ({r.RecordedAt:MM-dd HH:mm})");
}
return sb.ToString();
} }
private static string RecordValue(HealthRecord r) => r.MetricType switch private static void AddMetricPreview(
Dictionary<string, object?> preview,
HealthMetricType type,
decimal? value,
string unit,
Func<decimal, bool> isAbnormal)
{ {
HealthMetricType.BloodPressure => $"{r.Systolic}/{r.Diastolic}", preview["type"] = type.ToString();
HealthMetricType.HeartRate => $"{r.Value}次/分", preview["value"] = value?.ToString() ?? "";
HealthMetricType.Glucose => $"{r.Value}", preview["unit"] = unit;
HealthMetricType.SpO2 => $"{r.Value}%", preview["isAbnormal"] = value.HasValue && isAbnormal(value.Value);
HealthMetricType.Weight => $"{r.Value}kg", }
_ => "—"
}; private static string? GetString(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
private static int? GetInt(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : null;
private static decimal? GetDecimal(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.TryGetDecimal(out var result) ? result : null;
private static string GetStringArray(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Array
? string.Join(", ", value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)))
: "";
private static string? DetectUrgentRisk(string message)
{
var text = message.Trim();
if (string.IsNullOrEmpty(text)) return null;
if (ContainsAny(text, "剧烈胸痛", "胸口剧痛", "胸痛明显", "胸闷憋气", "喘不上气", "呼吸困难", "呼吸很困难", "意识模糊", "昏厥", "晕厥", "说话不清", "口角歪斜", "一侧无力"))
return "你描述的症状可能属于需要紧急评估的危险信号。";
var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?<sys>\d{2,3})\s*/\s*(?<dia>\d{2,3})");
if (bp.Success &&
int.TryParse(bp.Groups["sys"].Value, out var systolic) &&
int.TryParse(bp.Groups["dia"].Value, out var diastolic) &&
(systolic >= 180 || diastolic >= 120))
{
return $"你提到的血压 {systolic}/{diastolic} mmHg 已达到明显危险范围。";
}
var spo2 = MatchMetricValue(text, "血氧|氧饱和|SpO2|spo2");
if (spo2.HasValue && spo2.Value <= 90)
return $"你提到的血氧 {spo2.Value:0.#}% 明显偏低。";
var glucose = MatchMetricValue(text, "血糖|葡萄糖|glucose");
if (glucose.HasValue && (glucose.Value >= 16.7m || glucose.Value <= 3.0m))
return $"你提到的血糖 {glucose.Value:0.#} mmol/L 属于需要尽快处理的异常范围。";
var heartRate = MatchMetricValue(text, "心率|脉搏|heart rate|HR|hr");
if (heartRate.HasValue && (heartRate.Value >= 130 || heartRate.Value <= 45))
return $"你提到的心率 {heartRate.Value:0.#} 次/分属于明显异常范围。";
return null;
}
private static bool ContainsAny(string text, params string[] keywords) =>
keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
private static decimal? MatchMetricValue(string text, string metricPattern)
{
var match = Regex.Match(text, $@"(?:{metricPattern})[^\d]{{0,8}}(?<value>\d{{1,3}}(?:\.\d+)?)", RegexOptions.IgnoreCase);
return match.Success && decimal.TryParse(match.Groups["value"].Value, out var value) ? value : null;
}
// ── JSON 类型安全转换 ── // ── JSON 类型安全转换 ──
private static int _ToInt(object? v) => v switch private static int _ToInt(object? v) => v switch
@@ -435,39 +524,59 @@ public static class AiChatEndpoints
catch { resultDict = new Dictionary<string, object>(); } catch { resultDict = new Dictionary<string, object>(); }
} }
var isPendingConfirmation = resultDict != null
&& resultDict.TryGetValue("pendingConfirmation", out var pendingValue)
&& _ToBool(pendingValue);
if (isPendingConfirmation && resultDict!.TryGetValue("confirmationId", out var confirmationIdValue))
{
var confirmationId = confirmationIdValue?.ToString()?.Trim('"');
if (!string.IsNullOrWhiteSpace(confirmationId))
{
if (!metadata.TryGetValue("confirmationIds", out var idsValue) || idsValue is not List<string> ids)
{
ids = [];
metadata["confirmationIds"] = ids;
}
ids.Add(confirmationId);
}
}
switch (toolName) switch (toolName)
{ {
case "record_health_data": case "record_health_data":
if (!isPendingConfirmation) break;
messageType = "data_confirm"; messageType = "data_confirm";
if (resultDict != null) if (resultDict != null)
{ {
if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type.ToString(); if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type?.ToString() ?? "";
if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val.ToString(); if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val?.ToString() ?? "";
if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit.ToString(); if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit?.ToString() ?? "";
if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = abn is bool b2 ? b2 : false; if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = _ToBool(abn);
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = success is bool b && b; if (resultDict.TryGetValue("success", out var success)) metadata["success"] = _ToBool(success);
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
} }
break; break;
case "manage_medication": case "manage_medication":
if (!isPendingConfirmation) break;
messageType = "medication_confirm"; messageType = "medication_confirm";
if (resultDict != null) if (resultDict != null)
{ {
if (resultDict.TryGetValue("name", out var name)) if (resultDict.TryGetValue("name", out var name))
metadata["name"] = name.ToString(); metadata["name"] = name?.ToString() ?? "";
if (resultDict.TryGetValue("dosage", out var dosage)) if (resultDict.TryGetValue("dosage", out var dosage))
metadata["dosage"] = dosage.ToString(); metadata["dosage"] = dosage?.ToString() ?? "";
if (resultDict.TryGetValue("time", out var time)) if (resultDict.TryGetValue("time", out var time))
metadata["time"] = time.ToString(); metadata["time"] = time?.ToString() ?? "";
if (resultDict.TryGetValue("frequency", out var freq)) if (resultDict.TryGetValue("frequency", out var freq))
metadata["frequency"] = freq.ToString(); metadata["frequency"] = freq?.ToString() ?? "";
if (resultDict.TryGetValue("duration_days", out var dd2) && dd2 is int d2) if (resultDict.TryGetValue("duration_days", out var dd2))
metadata["duration_days"] = d2; metadata["duration_days"] = _ToInt(dd2);
if (resultDict.TryGetValue("start_date", out var sd)) if (resultDict.TryGetValue("start_date", out var sd))
metadata["startDate"] = sd.ToString(); metadata["startDate"] = sd?.ToString() ?? "";
} }
break; break;
case "manage_exercise": case "manage_exercise":
if (!isPendingConfirmation) break;
messageType = "data_confirm"; messageType = "data_confirm";
if (resultDict != null) if (resultDict != null)
{ {
@@ -477,40 +586,27 @@ public static class AiChatEndpoints
resultDict.TryGetValue("duration_minutes", out var dm); resultDict.TryGetValue("duration_minutes", out var dm);
metadata["unit"] = dm != null ? $"每天{dm}分钟" : ""; metadata["unit"] = dm != null ? $"每天{dm}分钟" : "";
resultDict.TryGetValue("day_count", out var dc); resultDict.TryGetValue("day_count", out var dc);
if (dc != null) metadata["durationDays"] = dc.ToString(); if (dc != null) metadata["durationDays"] = dc.ToString() ?? "";
resultDict.TryGetValue("success", out var ok); resultDict.TryGetValue("success", out var ok);
metadata["success"] = ok?.ToString() == "True"; metadata["success"] = _ToBool(ok);
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
} }
break; break;
case "manage_archive":
if (!isPendingConfirmation) break;
messageType = "data_confirm";
metadata["type"] = "archive";
metadata["value"] = "健康档案更新";
metadata["unit"] = "";
metadata["success"] = true;
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
break;
case "analyze_report": case "analyze_report":
messageType = "report_analysis"; messageType = "report_analysis";
break; break;
} }
} }
// ── 图片处理 ──
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
{
using var image = Image.FromFile(inputPath);
var width = image.Width;
var height = image.Height;
if (width > maxWidth)
{
height = (int)((double)height / width * maxWidth);
width = maxWidth;
}
using var bitmap = new Bitmap(width, height);
using var graphics = Graphics.FromImage(bitmap);
graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
graphics.DrawImage(image, 0, 0, width, height);
var jpegCodec = ImageCodecInfo.GetImageEncoders().First(c => c.MimeType == "image/jpeg");
var parameters = new EncoderParameters(1);
parameters.Param[0] = new EncoderParameter(Encoder.Quality, quality);
bitmap.Save(outputPath, jpegCodec, parameters);
}
} }
public sealed record ChatRequest(string Message, string? ConversationId); public sealed record ChatRequest(string Message, string? ConversationId);

View File

@@ -1,281 +1,30 @@
using Health.Infrastructure.Services; using Health.Application.Auth;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
/// <summary>
/// 认证相关 API 端点
/// </summary>
public static class AuthEndpoints public static class AuthEndpoints
{ {
private const string AdminPhone = "12345678910";
private const string AdminSmsCode = "000000";
public static void MapAuthEndpoints(this WebApplication app) public static void MapAuthEndpoints(this WebApplication app)
{ {
// 发送短信验证码 app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
app.MapPost("/api/auth/send-sms", async ( ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
SendSmsRequest request, app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
AppDbContext db, ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
SmsService sms, app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
CancellationToken ct) => ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
{ {
var code = request.Phone == AdminPhone ? AdminSmsCode : sms.GenerateCode(); await auth.LogoutAsync(request.RefreshToken, ct);
var vc = new VerificationCode
{
Id = Guid.NewGuid(),
Phone = request.Phone,
Code = code,
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
};
db.VerificationCodes.Add(vc);
await db.SaveChangesAsync(ct);
if (request.Phone != AdminPhone)
await sms.SendCodeAsync(request.Phone, code);
return Results.Ok(new { code = 0, data = new { success = true, devCode = code }, message = (string?)null });
});
// ── 注册(仅限用户注册,需选择医生)──
app.MapPost("/api/auth/register", async (
RegisterRequest request,
AppDbContext db,
JwtProvider jwt,
CancellationToken ct) =>
{
// 验证码
var validCode = await db.VerificationCodes
.Where(v => v.Phone == request.Phone
&& v.Code == request.SmsCode
&& v.ExpiresAt > DateTime.UtcNow
&& !v.IsUsed)
.OrderByDescending(v => v.CreatedAt)
.FirstOrDefaultAsync(ct);
if (validCode == null)
return Results.Ok(new { code = 40001, data = (object?)null, message = "验证码错误或已过期" });
validCode.IsUsed = true;
// 检查手机号是否已注册
var existing = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
if (existing != null)
return Results.Ok(new { code = 40002, data = (object?)null, message = "该手机号已注册,请直接登录" });
// 校验姓名
if (string.IsNullOrWhiteSpace(request.Name))
return Results.Ok(new { code = 40003, data = (object?)null, message = "请输入姓名" });
// 校验医生
var doctor = await db.Doctors.FirstOrDefaultAsync(d => d.Id == request.DoctorId && d.IsActive, ct);
if (doctor == null)
return Results.Ok(new { code = 40004, data = (object?)null, message = "所选医生不存在或已停诊" });
var user = new User
{
Id = Guid.NewGuid(),
Phone = request.Phone,
Role = "User",
Name = request.Name,
DoctorId = request.DoctorId,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.Users.Add(user);
db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
await db.SaveChangesAsync(ct);
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, "User");
var refreshToken = jwt.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = refreshToken,
ExpiresAt = DateTime.UtcNow.AddDays(30),
});
await db.SaveChangesAsync(ct);
return Results.Ok(new
{
code = 0,
data = new
{
accessToken,
refreshToken,
user = new { user.Id, user.Phone, user.Role, isNew = true }
},
message = (string?)null
});
});
// ── 登录(已有账号 / 管理员)──
app.MapPost("/api/auth/login", async (
LoginRequest request,
AppDbContext db,
JwtProvider jwt,
CancellationToken ct) =>
{
var validCode = await db.VerificationCodes
.Where(v => v.Phone == request.Phone
&& v.Code == request.SmsCode
&& v.ExpiresAt > DateTime.UtcNow
&& !v.IsUsed)
.OrderByDescending(v => v.CreatedAt)
.FirstOrDefaultAsync(ct);
if (validCode == null)
return Results.Ok(new { code = 40001, data = (object?)null, message = "验证码错误或已过期" });
validCode.IsUsed = true;
// 管理员登录
if (request.Phone == AdminPhone)
{
var adminAccessToken = jwt.GenerateAccessToken(
Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin");
var adminRefreshToken = jwt.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
Token = adminRefreshToken,
ExpiresAt = DateTime.UtcNow.AddDays(30),
});
await db.SaveChangesAsync(ct);
return Results.Ok(new
{
code = 0,
data = new
{
accessToken = adminAccessToken,
refreshToken = adminRefreshToken,
user = new { id = "00000000-0000-0000-0000-000000000001", phone = AdminPhone, role = "Admin", name = "管理员", isNew = false }
},
message = (string?)null
});
}
var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
if (user == null)
return Results.Ok(new { code = 40004, data = (object?)null, message = "该手机号未注册,请先登录" });
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role);
var refreshToken = jwt.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = refreshToken,
ExpiresAt = DateTime.UtcNow.AddDays(30),
});
await db.SaveChangesAsync(ct);
return Results.Ok(new
{
code = 0,
data = new
{
accessToken,
refreshToken,
user = new {
user.Id, user.Phone, user.Role, user.Name,
user.Gender, user.AvatarUrl,
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"),
isNew = false
}
},
message = (string?)null
});
});
// 刷新 token
app.MapPost("/api/auth/refresh", async (
RefreshRequest request,
AppDbContext db,
JwtProvider jwt,
CancellationToken ct) =>
{
var oldToken = await db.RefreshTokens
.FirstOrDefaultAsync(t => t.Token == request.RefreshToken && !t.IsRevoked, ct);
if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow)
return Results.Ok(new { code = 40002, data = (object?)null, message = "登录已过期,请重新登录" });
oldToken.IsRevoked = true;
var user = await db.Users.FindAsync([oldToken.UserId], ct);
// 管理员刷新:无 User 记录,直接用固定信息
if (user == null && oldToken.UserId == Guid.Parse("00000000-0000-0000-0000-000000000001"))
{
var adminAccessToken = jwt.GenerateAccessToken(
Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin");
var adminNewRefresh = jwt.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
Token = adminNewRefresh,
ExpiresAt = DateTime.UtcNow.AddDays(30),
});
await db.SaveChangesAsync(ct);
return Results.Ok(new
{
code = 0,
data = new { accessToken = adminAccessToken, refreshToken = adminNewRefresh, user = new { role = "Admin" } },
message = (string?)null
});
}
if (user == null)
return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" });
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role);
var newRefreshToken = jwt.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = user.Id,
Token = newRefreshToken,
ExpiresAt = DateTime.UtcNow.AddDays(30),
});
await db.SaveChangesAsync(ct);
return Results.Ok(new
{
code = 0,
data = new { accessToken, refreshToken = newRefreshToken, user = new { user.Role } },
message = (string?)null
});
});
// 登出
app.MapPost("/api/auth/logout", async (
RefreshRequest request,
AppDbContext db,
CancellationToken ct) =>
{
var token = await db.RefreshTokens
.FirstOrDefaultAsync(t => t.Token == request.RefreshToken, ct);
if (token != null) token.IsRevoked = true;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
} }
private static IResult ToResult(AuthResult result) =>
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
} }
// ---- 请求 DTO ----
public sealed record SendSmsRequest(string Phone); public sealed record SendSmsRequest(string Phone);
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId); public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
public sealed record LoginRequest(string Phone, string SmsCode); public sealed record LoginRequest(string Phone, string SmsCode);

View File

@@ -1,3 +1,5 @@
using Health.Application.Calendars;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
public static class CalendarEndpoints public static class CalendarEndpoints
@@ -6,91 +8,22 @@ public static class CalendarEndpoints
{ {
var group = app.MapGroup("/api/calendar").RequireAuthorization(); var group = app.MapGroup("/api/calendar").RequireAuthorization();
group.MapGet("/", async (int year, int month, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapGet("/", async (int year, int month, HttpContext http, ICalendarService calendar, CancellationToken ct) =>
{ {
var userId = GetUserId(http); if (year is < 2000 or > 2100 || month is < 1 or > 12)
var start = new DateOnly(year, month, 1); return Results.Ok(new { code = 400, data = (object?)null, message = "年月格式错误" });
var end = start.AddMonths(1);
var startDt = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var endDt = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var medications = await db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0)
.ToListAsync(ct);
var plans = await db.ExercisePlans
.Where(p => p.UserId == userId).Include(p => p.Items).ToListAsync(ct);
var followups = await db.FollowUps
.Where(f => f.UserId == userId && f.ScheduledAt >= startDt && f.ScheduledAt < endDt)
.ToListAsync(ct);
var result = new List<object>();
for (var d = start; d < end; d = d.AddDays(1))
{
var detailList = new List<object>();
// 用药:当前日期在用药有效期内
var todayMedications = medications
.Where(m => m.StartDate <= d && (m.EndDate == null || m.EndDate >= d));
foreach (var m in todayMedications)
{
detailList.Add(new { type = "medication", name = m.Name, dosage = m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() });
}
// 运动:按计划有效周范围 + 星期几匹配
foreach (var p in plans)
{
var weekEnd = p.WeekStartDate.AddDays(6);
if (d >= p.WeekStartDate && d <= weekEnd)
{
var dayItems = p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay);
foreach (var i in dayItems)
detailList.Add(new { type = "exercise", name = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted });
}
}
// 随访
var todayFollowups = followups.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == d);
foreach (var f in todayFollowups)
detailList.Add(new { type = "followup", title = f.Title, doctorName = f.DoctorName, department = f.Department, status = f.Status.ToString() });
if (detailList.Count > 0)
result.Add(new { date = d.ToString("yyyy-MM-dd"), events = detailList.Select(x => ((dynamic)x).type as string).Distinct().ToList(), details = detailList });
}
var result = await calendar.GetMonthAsync(GetUserId(http), year, month, ct);
return Results.Ok(new { code = 0, data = result, message = (string?)null }); return Results.Ok(new { code = 0, data = result, message = (string?)null });
}); });
// 获取某一天的详细安排 group.MapGet("/day", async (string date, HttpContext http, ICalendarService calendar, CancellationToken ct) =>
group.MapGet("/day", async (string date, HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var userId = GetUserId(http); if (!DateOnly.TryParse(date, out var parsedDate))
if (!DateOnly.TryParse(date, out var d))
return Results.Ok(new { code = 400, data = (object?)null, message = "日期格式错误" }); return Results.Ok(new { code = 400, data = (object?)null, message = "日期格式错误" });
var medications = await db.Medications var result = await calendar.GetDayAsync(GetUserId(http), parsedDate, ct);
.Where(m => m.UserId == userId && m.IsActive && m.StartDate <= d && (m.EndDate == null || m.EndDate >= d)) return Results.Ok(new { code = 0, data = result, message = (string?)null });
.Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() })
.ToListAsync(ct);
var plans = await db.ExercisePlans
.Where(p => p.UserId == userId)
.Include(p => p.Items).ToListAsync(ct);
var exercises = new List<object>();
foreach (var p in plans)
{
if (d >= p.WeekStartDate && d <= p.WeekStartDate.AddDays(6))
{
foreach (var i in p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay))
exercises.Add(new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted });
}
}
var followUps = await db.FollowUps
.Where(f => f.UserId == userId && DateOnly.FromDateTime(f.ScheduledAt) == d)
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
.ToListAsync(ct);
return Results.Ok(new { code = 0, data = new { medications, exercises, followUps }, message = (string?)null });
}); });
} }

View File

@@ -49,11 +49,14 @@ public static class ConsultationEndpoints
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, IHubContext<Hubs.ConsultationHub> hubContext, CancellationToken ct) => group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, IHubContext<Hubs.ConsultationHub> hubContext, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow }; var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId, ct);
if (consultation == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = consultation.Id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
db.ConsultationMessages.Add(msg); db.ConsultationMessages.Add(msg);
// 用户发消息后,状态从 AiTalking → WaitingDoctor // 用户发消息后,状态从 AiTalking → WaitingDoctor
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct);
if (consultation != null && consultation.Status == ConsultationStatus.AiTalking) if (consultation != null && consultation.Status == ConsultationStatus.AiTalking)
{ {
consultation.Status = ConsultationStatus.WaitingDoctor; consultation.Status = ConsultationStatus.WaitingDoctor;

View File

@@ -1,3 +1,5 @@
using Health.Application.Diets;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
public static class DietEndpoints public static class DietEndpoints
@@ -6,82 +8,91 @@ public static class DietEndpoints
{ {
var group = app.MapGroup("/api/diet-records").RequireAuthorization(); var group = app.MapGroup("/api/diet-records").RequireAuthorization();
group.MapGet("/", async (string? date, string? mealType, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapGet("/", async (string? date, string? mealType, HttpContext http, IDietService diets, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId); var records = await diets.ListAsync(userId, date, mealType, ct);
if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d);
if (Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt);
var records = await query.OrderByDescending(r => r.RecordedAt).Select(r => new
{
r.Id, MealType = r.MealType.ToString(), r.TotalCalories, r.HealthScore, r.RecordedAt,
foodItems = r.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories })
}).ToListAsync(ct);
return Results.Ok(new { code = 0, data = records, message = (string?)null }); return Results.Ok(new { code = 0, data = records, message = (string?)null });
}); });
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapPost("/", async (HttpRequest request, HttpContext http, IDietService diets, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
request.EnableBuffering(); using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
using var reader = new StreamReader(request.Body); var recordId = await diets.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct);
var body = await reader.ReadToEndAsync(ct); return Results.Ok(new { code = 0, data = new { id = recordId }, message = (string?)null });
request.Body.Position = 0;
using var json = JsonDocument.Parse(body);
var root = json.RootElement;
var record = new DietRecord
{
Id = Guid.NewGuid(), UserId = userId,
MealType = Enum.TryParse<MealType>(root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch", out var meal) ? meal : MealType.Lunch,
TotalCalories = root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null,
HealthScore = root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null,
RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
};
if (root.TryGetProperty("foodItems", out var items))
{
var i = 0;
foreach (var fi in items.EnumerateArray())
{
record.FoodItems.Add(new DietFoodItem
{
Id = Guid.NewGuid(), Name = fi.TryGetProperty("name", out var n) ? n.GetString()! : "",
Portion = fi.TryGetProperty("portion", out var p) ? p.GetString() : null,
Calories = fi.TryGetProperty("calories", out var c) ? c.GetInt32() : null,
SortOrder = fi.TryGetProperty("sortOrder", out var so) ? so.GetInt32() : i,
});
i++;
}
}
db.DietRecords.Add(record);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
}); });
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); await diets.DeleteAsync(userId, id, ct);
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct));
if (record == null) return Results.Ok(new { code = 40004, message = "记录不存在" }); var updated = await diets.UpdateAsync(userId, id, ReadPatchRequest(json.RootElement), ct);
if (!updated) return Results.Ok(new { code = 40004, message = "记录不存在" });
using var reader = new StreamReader(http.Request.Body);
var body = await reader.ReadToEndAsync(ct);
var json = System.Text.Json.JsonDocument.Parse(body);
if (json.RootElement.TryGetProperty("totalCalories", out var cal)) record.TotalCalories = (int?)cal.GetInt32();
if (json.RootElement.TryGetProperty("healthScore", out var hs)) record.HealthScore = (int?)hs.GetInt32();
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
} }
private static DietRecordCreateRequest ReadCreateRequest(JsonElement root)
{
var mealType = Enum.TryParse<MealType>(
root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch",
out var meal)
? meal
: MealType.Lunch;
var recordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d)
? d
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
return new DietRecordCreateRequest(
mealType,
root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null,
root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null,
recordedAt,
ReadFoodItems(root));
}
private static DietRecordPatchRequest ReadPatchRequest(JsonElement root) => new(
root.TryGetProperty("totalCalories", out var cal) ? cal.GetInt32() : null,
root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null);
private static List<DietFoodItemInput> ReadFoodItems(JsonElement root)
{
var result = new List<DietFoodItemInput>();
if (!root.TryGetProperty("foodItems", out var items) || items.ValueKind != JsonValueKind.Array)
return result;
var i = 0;
foreach (var fi in items.EnumerateArray())
{
result.Add(new DietFoodItemInput(
fi.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
fi.TryGetProperty("portion", out var p) ? p.GetString() : null,
fi.TryGetProperty("calories", out var c) ? c.GetInt32() : null,
fi.TryGetProperty("sortOrder", out var so) ? so.GetInt32() : i));
i++;
}
return result;
}
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken ct)
{
request.EnableBuffering();
using var reader = new StreamReader(request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync(ct);
request.Body.Position = 0;
return string.IsNullOrWhiteSpace(body) ? "{}" : body;
}
private static Guid GetUserId(HttpContext http) => private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
} }

View File

@@ -49,31 +49,34 @@ public static class DoctorEndpoints
var profile = await GetDoctorProfile(http, db); var profile = await GetDoctorProfile(http, db);
if (profile == null) if (profile == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在,请先完善个人信息" }); return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在,请先完善个人信息" });
if (profile.DoctorId == null)
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var totalPatients = await db.Users.CountAsync(u => u.Role == "User"); var doctorId = profile.DoctorId.Value;
var activeConsultations = await db.Consultations.CountAsync(c => c.Status != ConsultationStatus.Closed); var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
var pendingReports = await db.Reports.CountAsync(r => r.Status == ReportStatus.PendingDoctor); var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
var todayStart = DateTime.UtcNow.Date; var todayStart = DateTime.UtcNow.Date;
var todayEnd = todayStart.AddDays(1); var todayEnd = todayStart.AddDays(1);
var todayFollowUps = await db.FollowUps.CountAsync(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming); var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
// 待办列表 // 待办列表
var pendingConsultations = await db.Consultations var pendingConsultations = await db.Consultations
.Where(c => c.Status == ConsultationStatus.WaitingDoctor) .Where(c => c.User.DoctorId == doctorId && c.Status == ConsultationStatus.WaitingDoctor)
.OrderByDescending(c => c.CreatedAt) .OrderByDescending(c => c.CreatedAt)
.Take(5) .Take(5)
.Select(c => new { c.Id, PatientName = c.User.Name ?? c.User.Phone, c.CreatedAt }) .Select(c => new { c.Id, PatientName = c.User.Name ?? c.User.Phone, c.CreatedAt })
.ToListAsync(); .ToListAsync();
var pendingReportList = await db.Reports var pendingReportList = await db.Reports
.Where(r => r.Status == ReportStatus.PendingDoctor) .Where(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor)
.OrderByDescending(r => r.CreatedAt) .OrderByDescending(r => r.CreatedAt)
.Take(5) .Take(5)
.Select(r => new { r.Id, PatientName = r.User.Name ?? r.User.Phone, r.Category, r.CreatedAt }) .Select(r => new { r.Id, PatientName = r.User.Name ?? r.User.Phone, r.Category, r.CreatedAt })
.ToListAsync(); .ToListAsync();
var todayFollowUpList = await db.FollowUps var todayFollowUpList = await db.FollowUps
.Where(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming) .Where(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming)
.OrderBy(f => f.ScheduledAt) .OrderBy(f => f.ScheduledAt)
.Select(f => new { f.Id, f.Title, PatientName = f.User.Name ?? f.User.Phone, f.ScheduledAt }) .Select(f => new { f.Id, f.Title, PatientName = f.User.Name ?? f.User.Phone, f.ScheduledAt })
.ToListAsync(); .ToListAsync();
@@ -121,11 +124,15 @@ public static class DoctorEndpoints
}); });
// ===== 患者详情 ===== // ===== 患者详情 =====
group.MapGet("/patients/{id:guid}", async (Guid id, AppDbContext db) => group.MapGet("/patients/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) =>
{ {
var doctorId = await GetDoctorEntityId(http, db);
if (doctorId == null)
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var user = await db.Users var user = await db.Users
.Include(u => u.HealthArchive) .Include(u => u.HealthArchive)
.FirstOrDefaultAsync(u => u.Id == id); .FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId);
if (user == null) if (user == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" }); return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
@@ -153,7 +160,7 @@ public static class DoctorEndpoints
.ToListAsync(); .ToListAsync();
var exercisePlans = await db.ExercisePlans var exercisePlans = await db.ExercisePlans
.Where(p => p.UserId == id).OrderByDescending(p => p.WeekStartDate).Take(1).Include(p => p.Items) .Where(p => p.UserId == id).OrderByDescending(p => p.StartDate).Take(1).Include(p => p.Items)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var reports = await db.Reports var reports = await db.Reports
@@ -177,7 +184,7 @@ public static class DoctorEndpoints
trendRecords, trendRecords,
medications, medications,
dietRecords = dietRecords.Select(d => new { d.Id, MealType = d.MealType.ToString(), d.TotalCalories, d.HealthScore, d.RecordedAt, foods = d.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories, f.Warning }) }), dietRecords = dietRecords.Select(d => new { d.Id, MealType = d.MealType.ToString(), d.TotalCalories, d.HealthScore, d.RecordedAt, foods = d.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories, f.Warning }) }),
exercisePlan = exercisePlans == null ? null : new { exercisePlans.WeekStartDate, items = exercisePlans.Items.Select(i => new { i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.IsRestDay, i.CompletedAt }) }, exercisePlan = exercisePlans == null ? null : new { exercisePlans.StartDate, exercisePlans.EndDate, exercisePlans.ReminderTime, items = exercisePlans.Items.Select(i => new { i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.IsRestDay, i.CompletedAt }) },
reports, followUps reports, followUps
}, },
message = (string?)null message = (string?)null
@@ -189,8 +196,10 @@ public static class DoctorEndpoints
{ {
var profile = await GetDoctorProfile(http, db); var profile = await GetDoctorProfile(http, db);
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var consultations = await db.Consultations var consultations = await db.Consultations
.Where(c => c.User.DoctorId == profile.DoctorId)
.OrderByDescending(c => c.CreatedAt) .OrderByDescending(c => c.CreatedAt)
.Select(c => new { c.Id, c.UserId, PatientName = c.User.Name, PatientPhone = c.User.Phone, Status = c.Status.ToString(), c.CreatedAt, c.ClosedAt, LastMessage = c.Messages.OrderByDescending(m => m.CreatedAt).Select(m => new { m.Content, SenderType = m.SenderType.ToString(), m.CreatedAt }).FirstOrDefault() }) .Select(c => new { c.Id, c.UserId, PatientName = c.User.Name, PatientPhone = c.User.Phone, Status = c.Status.ToString(), c.CreatedAt, c.ClosedAt, LastMessage = c.Messages.OrderByDescending(m => m.CreatedAt).Select(m => new { m.Content, SenderType = m.SenderType.ToString(), m.CreatedAt }).FirstOrDefault() })
.ToListAsync(); .ToListAsync();
@@ -199,15 +208,22 @@ public static class DoctorEndpoints
}); });
// ===== 问诊消息 ===== // ===== 问诊消息 =====
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, AppDbContext db) => group.MapGet("/consultations/{id:guid}/messages", async (Guid id, HttpContext http, AppDbContext db) =>
{ {
var doctorId = await GetDoctorEntityId(http, db);
if (doctorId == null)
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var consultation = await db.Consultations
.FirstOrDefaultAsync(c => c.Id == id && c.User.DoctorId == doctorId);
if (consultation == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
var messages = await db.ConsultationMessages var messages = await db.ConsultationMessages
.Where(m => m.ConsultationId == id).OrderBy(m => m.CreatedAt) .Where(m => m.ConsultationId == consultation.Id).OrderBy(m => m.CreatedAt)
.Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt }) .Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt })
.ToListAsync(); .ToListAsync();
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id);
return Results.Ok(new { code = 0, data = new { status = consultation?.Status.ToString() ?? "Closed", messages }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { status = consultation?.Status.ToString() ?? "Closed", messages }, message = (string?)null });
}); });
@@ -216,8 +232,9 @@ public static class DoctorEndpoints
{ {
var profile = await GetDoctorProfile(http, db); var profile = await GetDoctorProfile(http, db);
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct); var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id && c.User.DoctorId == profile.DoctorId, ct);
if (consultation == null) return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" }); if (consultation == null) return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
using var reader = new StreamReader(http.Request.Body); using var reader = new StreamReader(http.Request.Body);
@@ -249,9 +266,13 @@ public static class DoctorEndpoints
}); });
// ===== 报告列表 ===== // ===== 报告列表 =====
group.MapGet("/reports", async (string? status, AppDbContext db) => group.MapGet("/reports", async (string? status, HttpContext http, AppDbContext db) =>
{ {
var query = db.Reports.AsQueryable(); var doctorId = await GetDoctorEntityId(http, db);
if (doctorId == null)
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var query = db.Reports.Where(r => r.User.DoctorId == doctorId);
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<ReportStatus>(status, out var s)) if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<ReportStatus>(status, out var s))
query = query.Where(r => r.Status == s); query = query.Where(r => r.Status == s);
@@ -262,9 +283,13 @@ public static class DoctorEndpoints
}); });
// ===== 报告详情 ===== // ===== 报告详情 =====
group.MapGet("/reports/{id:guid}", async (Guid id, AppDbContext db) => group.MapGet("/reports/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) =>
{ {
var report = await db.Reports.Where(r => r.Id == id) var doctorId = await GetDoctorEntityId(http, db);
if (doctorId == null)
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId)
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
@@ -276,8 +301,9 @@ public static class DoctorEndpoints
{ {
var profile = await GetDoctorProfile(http, db); var profile = await GetDoctorProfile(http, db);
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id, ct); var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.User.DoctorId == profile.DoctorId, ct);
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
using var reader = new StreamReader(http.Request.Body); using var reader = new StreamReader(http.Request.Body);
@@ -299,9 +325,13 @@ public static class DoctorEndpoints
}); });
// ===== 随访列表 ===== // ===== 随访列表 =====
group.MapGet("/follow-ups", async (string? status, string? patientId, AppDbContext db) => group.MapGet("/follow-ups", async (string? status, string? patientId, HttpContext http, AppDbContext db) =>
{ {
var query = db.FollowUps.AsQueryable(); var doctorId = await GetDoctorEntityId(http, db);
if (doctorId == null)
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var query = db.FollowUps.Where(f => f.User.DoctorId == doctorId);
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<FollowUpStatus>(status, out var s)) if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<FollowUpStatus>(status, out var s))
query = query.Where(f => f.Status == s); query = query.Where(f => f.Status == s);
if (Guid.TryParse(patientId, out var pid)) if (Guid.TryParse(patientId, out var pid))
@@ -318,15 +348,19 @@ public static class DoctorEndpoints
{ {
var profile = await GetDoctorProfile(http, db); var profile = await GetDoctorProfile(http, db);
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
using var reader = new StreamReader(http.Request.Body); using var reader = new StreamReader(http.Request.Body);
var body = await reader.ReadToEndAsync(ct); var body = await reader.ReadToEndAsync(ct);
var json = System.Text.Json.JsonDocument.Parse(body); var json = System.Text.Json.JsonDocument.Parse(body);
var patientId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!);
var patientExists = await db.Users.AnyAsync(u => u.Id == patientId && u.DoctorId == profile.DoctorId, ct);
if (!patientExists) return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
var followUp = new FollowUp var followUp = new FollowUp
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
UserId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!), UserId = patientId,
Title = json.RootElement.GetProperty("title").GetString() ?? "", Title = json.RootElement.GetProperty("title").GetString() ?? "",
DoctorName = profile.Name, DoctorName = profile.Name,
Department = profile.Department, Department = profile.Department,
@@ -343,7 +377,11 @@ public static class DoctorEndpoints
// ===== 更新随访 ===== // ===== 更新随访 =====
group.MapPut("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapPut("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct); var doctorId = await GetDoctorEntityId(http, db);
if (doctorId == null)
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id && f.User.DoctorId == doctorId, ct);
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" }); if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
using var reader = new StreamReader(http.Request.Body); using var reader = new StreamReader(http.Request.Body);
@@ -358,9 +396,13 @@ public static class DoctorEndpoints
}); });
// ===== 删除随访 ===== // ===== 删除随访 =====
group.MapDelete("/follow-ups/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) => group.MapDelete("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct); var doctorId = await GetDoctorEntityId(http, db);
if (doctorId == null)
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id && f.User.DoctorId == doctorId, ct);
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" }); if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
db.FollowUps.Remove(followUp); db.FollowUps.Remove(followUp);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -371,9 +413,9 @@ public static class DoctorEndpoints
group.MapGet("/patients-simple", async (HttpContext http, AppDbContext db) => group.MapGet("/patients-simple", async (HttpContext http, AppDbContext db) =>
{ {
var doctorId = await GetDoctorEntityId(http, db); var doctorId = await GetDoctorEntityId(http, db);
var query = db.Users.Where(u => u.Role == "User"); if (doctorId == null)
if (doctorId != null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
query = query.Where(u => u.DoctorId == doctorId); var query = db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId);
var patients = await query.OrderBy(u => u.Name).Select(u => new { u.Id, u.Name, u.Phone }).ToListAsync(); var patients = await query.OrderBy(u => u.Name).Select(u => new { u.Id, u.Name, u.Phone }).ToListAsync();
return Results.Ok(new { code = 0, data = patients, message = (string?)null }); return Results.Ok(new { code = 0, data = patients, message = (string?)null });
}); });

View File

@@ -1,3 +1,5 @@
using Health.Application.Exercises;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
public static class ExerciseEndpoints public static class ExerciseEndpoints
@@ -6,126 +8,78 @@ public static class ExerciseEndpoints
{ {
var group = app.MapGroup("/api/exercise-plans").RequireAuthorization(); var group = app.MapGroup("/api/exercise-plans").RequireAuthorization();
group.MapGet("/current", async (HttpContext http, AppDbContext db) => group.MapGet("/current", async (HttpContext http, IExerciseService exercises, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); // 北京时间 var plan = await exercises.GetCurrentAsync(userId, ct);
// 查找覆盖今天的所有计划(不仅限于周一开始的) return Results.Ok(new { code = 0, data = plan, message = (string?)null });
var plans = await db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId && p.WeekStartDate <= today)
.OrderByDescending(p => p.WeekStartDate)
.ToListAsync();
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.DayOfWeek == (int)today.DayOfWeek));
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
return Results.Ok(new
{
code = 0,
data = new
{
plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt,
items = plan.Items.Select(i => new
{
i.Id, i.DayOfWeek,
i.ExerciseType, i.DurationMinutes,
i.IsCompleted, i.CompletedAt, i.IsRestDay
})
},
message = (string?)null
});
}); });
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapPost("/", async (HttpRequest request, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
request.EnableBuffering(); var body = await ReadBodyAsync(request, ct);
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync(ct);
request.Body.Position = 0;
using var json = JsonDocument.Parse(body); using var json = JsonDocument.Parse(body);
var root = json.RootElement; var root = json.RootElement;
var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动";
var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString() : "运动";
var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30; var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30;
var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd"))); var startDate = DateOnly.Parse(root.TryGetProperty("startDate", out var sd)
var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd"))); ? sd.GetString()!
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate }; : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd"));
for (var d = startDate; d <= endDate; d = d.AddDays(1)) var endDate = DateOnly.Parse(root.TryGetProperty("endDate", out var ed)
{ ? ed.GetString()!
plan.Items.Add(new ExercisePlanItem : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd"));
{ var reminderTime = root.TryGetProperty("reminderTime", out var rt) && TimeOnly.TryParse(rt.GetString(), out var parsedTime)
Id = Guid.NewGuid(), ? parsedTime
DayOfWeek = (int)d.DayOfWeek, : new TimeOnly(19, 0);
ExerciseType = exerciseType,
DurationMinutes = duration, var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(startDate, endDate, exerciseType, duration, reminderTime), ct);
IsRestDay = false, return Results.Ok(new { code = 0, data = new { id = planId }, message = (string?)null });
});
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
}); });
group.MapGet("/", async (HttpContext http, AppDbContext db) => group.MapGet("/", async (HttpContext http, IExerciseService exercises, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var plans = await db.ExercisePlans.Where(p => p.UserId == userId) var plans = await exercises.ListAsync(userId, ct);
.OrderByDescending(p => p.WeekStartDate).Take(20)
.Select(p => new
{
p.Id, p.WeekStartDate, p.CreatedAt,
totalDays = p.Items.Count,
completedDays = p.Items.Count(i => i.IsCompleted),
items = p.Items.OrderBy(i => i.DayOfWeek).Select(i => new
{
i.Id, i.DayOfWeek,
i.ExerciseType, i.DurationMinutes,
i.IsCompleted, i.IsRestDay
})
}).ToListAsync();
return Results.Ok(new { code = 0, data = plans, message = (string?)null }); return Results.Ok(new { code = 0, data = plans, message = (string?)null });
}); });
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) => group.MapGet("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var plan = await db.ExercisePlans.Include(p => p.Items) var plan = await exercises.GetByIdAsync(userId, id, ct);
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId);
if (plan == null) return Results.Ok(new { code = 40004, message = "不存在" }); if (plan == null) return Results.Ok(new { code = 40004, message = "不存在" });
return Results.Ok(new
{ return Results.Ok(new { code = 0, data = plan, message = (string?)null });
code = 0,
data = new
{
plan.Id, plan.WeekStartDate, plan.CreatedAt,
items = plan.Items.OrderBy(i => i.DayOfWeek).Select(i => new
{
i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes,
i.IsCompleted, i.CompletedAt, i.IsRestDay
})
},
message = (string?)null
});
}); });
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId, ct); await exercises.DeleteAsync(userId, id, ct);
if (plan != null) { db.ExercisePlans.Remove(plan); await db.SaveChangesAsync(ct); }
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
{ {
var item = await db.ExercisePlanItems.FindAsync([itemId], ct); var userId = GetUserId(http);
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" }); var completed = await exercises.ToggleCheckInAsync(userId, itemId, ct);
item.IsCompleted = !item.IsCompleted; if (completed == null) return Results.Ok(new { code = 40004, message = "不存在" });
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, data = new { completed }, message = (string?)null });
return Results.Ok(new { code = 0, data = new { completed = item.IsCompleted }, message = (string?)null });
}); });
} }
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken ct)
{
request.EnableBuffering();
using var reader = new StreamReader(request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync(ct);
request.Body.Position = 0;
return string.IsNullOrWhiteSpace(body) ? "{}" : body;
}
private static Guid GetUserId(HttpContext http) => private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
} }

View File

@@ -2,26 +2,55 @@ namespace Health.WebApi.Endpoints;
public static class FileEndpoints public static class FileEndpoints
{ {
private const long MaxFileSize = 20 * 1024 * 1024;
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf"
};
public static void MapFileEndpoints(this WebApplication app) public static void MapFileEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("/api/files").RequireAuthorization(); var group = app.MapGroup("/api/files").RequireAuthorization();
group.MapPost("/upload", async (HttpRequest request) => group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) =>
{ {
var form = await request.ReadFormAsync(); if (!request.HasFormContentType)
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
var form = await request.ReadFormAsync(ct);
var files = form.Files; var files = form.Files;
if (files.Count == 0)
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
var results = new List<object>(); var results = new List<object>();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsDir); Directory.CreateDirectory(uploadsDir);
foreach (var file in files) foreach (var file in files)
{ {
if (file.Length <= 0)
continue;
if (file.Length > MaxFileSize)
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小不能超过 20MB" });
var fileId = Guid.NewGuid().ToString(); var fileId = Guid.NewGuid().ToString();
var ext = Path.GetExtension(file.FileName); var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的文件类型" });
var storedName = $"{fileId}{ext}";
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}"); var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
using var stream = new FileStream(filePath, FileMode.Create); await using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream); await file.CopyToAsync(stream, ct);
results.Add(new { id = fileId, name = file.FileName, size = file.Length }); results.Add(new
{
id = fileId,
name = file.FileName,
size = file.Length,
url = $"/uploads/{storedName}",
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
});
} }
return Results.Ok(new { code = 0, data = results, message = (string?)null }); return Results.Ok(new { code = 0, data = results, message = (string?)null });

View File

@@ -1,3 +1,5 @@
using Health.Application.HealthRecords;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
/// <summary> /// <summary>
@@ -9,127 +11,82 @@ public static class HealthEndpoints
{ {
var group = app.MapGroup("/api/health-records").RequireAuthorization(); var group = app.MapGroup("/api/health-records").RequireAuthorization();
// 查询健康记录
group.MapGet("/", async ( group.MapGet("/", async (
string? type, int? days, string? type,
HttpContext http, AppDbContext db, CancellationToken ct) => int? days,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var query = db.HealthRecords.Where(r => r.UserId == userId); var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
query = query.Where(r => r.MetricType == mt);
if (days.HasValue)
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days.Value));
var records = await query.OrderByDescending(r => r.RecordedAt).Take(100)
.Select(r => new
{
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit,
Source = r.Source.ToString(), r.IsAbnormal, r.RecordedAt
}).ToListAsync(ct);
return Results.Ok(new { code = 0, data = records, message = (string?)null }); return Results.Ok(new { code = 0, data = records, message = (string?)null });
}); });
// 新增健康记录 group.MapPost("/", async (
group.MapPost("/", async (CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) => CreateHealthRecordRequest req,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var record = new HealthRecord var id = await healthRecords.CreateAsync(userId, req.ToServiceRequest(), ct);
{ return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
Id = Guid.NewGuid(), UserId = userId, MetricType = req.Type,
Systolic = req.Systolic, Diastolic = req.Diastolic, Value = req.Value,
Unit = req.Unit, Source = req.Source, RecordedAt = req.RecordedAt ?? DateTime.UtcNow,
IsAbnormal = CheckAbnormal(req),
};
db.HealthRecords.Add(record);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
}); });
// 修改健康记录 group.MapPut("/{id:guid}", async (
group.MapPut("/{id:guid}", async (Guid id, CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) => Guid id,
CreateHealthRecordRequest req,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); var ok = await healthRecords.UpdateAsync(userId, id, req.ToServiceRequest(), ct);
if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" }); return ok
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
record.Systolic = req.Systolic; : Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
record.Diastolic = req.Diastolic;
record.Value = req.Value;
record.Unit = req.Unit;
record.RecordedAt = req.RecordedAt ?? record.RecordedAt;
record.IsAbnormal = CheckAbnormal(req);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
// 删除健康记录 group.MapDelete("/{id:guid}", async (
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => Guid id,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); var ok = await healthRecords.DeleteAsync(userId, id, ct);
if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" }); return ok
db.HealthRecords.Remove(record); ? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
await db.SaveChangesAsync(ct); : Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
// 获取各指标最新值 group.MapGet("/latest", async (
group.MapGet("/latest", async (HttpContext http, AppDbContext db, CancellationToken ct) => HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var types = new[] { HealthMetricType.BloodPressure, HealthMetricType.HeartRate, HealthMetricType.Glucose, HealthMetricType.SpO2, HealthMetricType.Weight }; var latest = await healthRecords.GetLatestAsync(userId, ct);
var result = new Dictionary<string, object?>(); return Results.Ok(new { code = 0, data = latest, message = (string?)null });
foreach (var t in types)
{
var latest = await db.HealthRecords
.Where(r => r.UserId == userId && r.MetricType == t)
.OrderByDescending(r => r.RecordedAt)
.FirstOrDefaultAsync(ct);
result[t.ToString()] = latest == null ? null : new
{
latest.Systolic, latest.Diastolic, latest.Value, latest.Unit, latest.RecordedAt
};
}
return Results.Ok(new { code = 0, data = result, message = (string?)null });
}); });
// 趋势数据 group.MapGet("/trend", async (
group.MapGet("/trend", async (string type, int period, HttpContext http, AppDbContext db, CancellationToken ct) => string type,
int period,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
if (!Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt)) if (!Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var metricType))
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的指标类型" }); return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的指标类型" });
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 }; var records = await healthRecords.GetTrendAsync(userId, metricType, period, ct);
var records = await db.HealthRecords
.Where(r => r.UserId == userId && r.MetricType == mt && r.RecordedAt >= DateTime.UtcNow.AddDays(-days))
.OrderBy(r => r.RecordedAt)
.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt })
.ToListAsync(ct);
return Results.Ok(new { code = 0, data = records, message = (string?)null }); return Results.Ok(new { code = 0, data = records, message = (string?)null });
}); });
} }
private static bool CheckAbnormal(CreateHealthRecordRequest req) => req.Type switch
{
HealthMetricType.BloodPressure => req.Systolic >= 140 || req.Diastolic >= 90 || req.Systolic <= 89 || req.Diastolic <= 59,
HealthMetricType.HeartRate => req.Value > 100 || req.Value < 60,
HealthMetricType.Glucose => req.Value >= 7.0m || req.Value <= 3.8m,
HealthMetricType.SpO2 => req.Value <= 94,
HealthMetricType.Weight => false, // 体重无标准异常范围
_ => false
};
private static Guid GetUserId(HttpContext http) => private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
} }
@@ -143,4 +100,13 @@ public sealed class CreateHealthRecordRequest
public string? Unit { get; init; } public string? Unit { get; init; }
public HealthRecordSource Source { get; init; } public HealthRecordSource Source { get; init; }
public DateTime? RecordedAt { get; init; } public DateTime? RecordedAt { get; init; }
public HealthRecordUpsertRequest ToServiceRequest() => new(
Type,
Systolic,
Diastolic,
Value,
Unit,
Source,
RecordedAt);
} }

View File

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

View File

@@ -0,0 +1,31 @@
using System.Security.Claims;
using Health.Application.Notifications;
namespace Health.WebApi.Endpoints;
public static class NotificationEndpoints
{
public static void MapNotificationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/notifications").RequireAuthorization();
group.MapGet("/pending", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var result = await notifications.GetPendingAsync(userId, ct);
return Results.Ok(new { code = 0, data = result, message = (string?)null });
});
group.MapPost("/{id:guid}/acknowledge", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var acknowledged = await notifications.AcknowledgeAsync(userId, id, ct);
return Results.Ok(new { code = 0, data = new { acknowledged }, message = (string?)null });
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}

View File

@@ -1,4 +1,4 @@
using Health.Infrastructure.AI; using Health.Application.Reports;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
@@ -8,294 +8,62 @@ public static class ReportEndpoints
{ {
var group = app.MapGroup("/api/reports").RequireAuthorization(); var group = app.MapGroup("/api/reports").RequireAuthorization();
// 列表 group.MapGet("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct); var data = await reports.GetReportsAsync(userId, ct);
return Results.Ok(new { code = 0, data = reports, message = (string?)null }); return Results.Ok(new { code = 0, data, message = (string?)null });
}); });
// 删除 group.MapGet("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); var report = await reports.GetReportAsync(userId, id, ct);
if (report == null) return Results.Ok(new { code = 40004, message = "不存在" }); return report == null
// 删除本地文件 ? Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" })
if (!string.IsNullOrEmpty(report.FileUrl)) : Results.Ok(new { code = 0, data = report, message = (string?)null });
{
var filePath = Path.Combine(Directory.GetCurrentDirectory(), report.FileUrl.TrimStart('/'));
if (File.Exists(filePath)) File.Delete(filePath);
}
db.Reports.Remove(report);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, message = "已删除" });
}); });
// 详情 group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null });
});
// 上传 + AI 预解读VLM 提取指标 → LLM 生成摘要)
group.MapPost("/", async (HttpContext http, AppDbContext db, IServiceScopeFactory scopeFactory, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
// 读 multipart form
if (!http.Request.HasFormContentType) if (!http.Request.HasFormContentType)
return Results.Ok(new { code = 400, message = "需要 multipart/form-data" }); return Results.Ok(new { code = 400, data = (object?)null, message = "需要 multipart/form-data" });
var form = await http.Request.ReadFormAsync(ct); var form = await http.Request.ReadFormAsync(ct);
var file = form.Files.GetFile("file"); var file = form.Files.GetFile("file");
if (file == null || file.Length == 0) if (file == null)
return Results.Ok(new { code = 400, message = "未上传文件" }); return Results.Ok(new { code = 400, data = (object?)null, message = "未上传文件" });
// 保存文件 await using var stream = file.OpenReadStream();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports"); var result = await reports.UploadReportAsync(
Directory.CreateDirectory(uploadsDir); userId,
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}"; new ReportUploadFile(file.FileName, file.Length, stream),
var filePath = Path.Combine(uploadsDir, fileName); ct);
await using (var stream = new FileStream(filePath, FileMode.Create))
await file.CopyToAsync(stream, ct);
var fileUrl = $"/uploads/reports/{fileName}"; return Results.Ok(new { code = result.Code, data = result.Report, message = result.Message });
var isPdf = Path.GetExtension(fileName).ToLowerInvariant() == ".pdf"; });
// 创建报告记录 group.MapPost("/{id:guid}/reanalyze", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
var report = new Report {
{ var userId = GetUserId(http);
Id = Guid.NewGuid(), UserId = userId, var ok = await reports.ReanalyzeReportAsync(userId, id, ct);
FileUrl = fileUrl, return ok
FileType = isPdf ? ReportFileType.Pdf : ReportFileType.Image, ? Results.Ok(new { code = 0, data = new { success = true }, message = "已重新提交 AI 分析" })
Category = ReportCategory.Other, : Results.Ok(new { code = 40004, data = (object?)null, message = "报告不存在或原始文件丢失" });
Status = ReportStatus.PendingDoctor, });
CreatedAt = DateTime.UtcNow,
};
db.Reports.Add(report);
await db.SaveChangesAsync(ct);
// 异步 AI 分析(用独立 scope不依赖请求生命周期 group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
var reportId = report.Id; {
_ = Task.Run(async () => var userId = GetUserId(http);
{ var ok = await reports.DeleteReportAsync(userId, id, ct);
try return ok
{ ? Results.Ok(new { code = 0, data = new { success = true }, message = "已删除" })
using var scope = scopeFactory.CreateScope(); : Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" });
var taskDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var taskVision = scope.ServiceProvider.GetRequiredService<VisionClient>();
var taskLlm = scope.ServiceProvider.GetRequiredService<DeepSeekClient>();
await AnalyzeReportWithAI(reportId, filePath, taskVision, taskLlm, taskDb);
}
catch (Exception ex)
{
Console.WriteLine($"[Report] AI 分析失败: {ex.Message}");
// 回退:写入 fallback 数据
try
{
using var fbScope = scopeFactory.CreateScope();
var fbDb = fbScope.ServiceProvider.GetRequiredService<AppDbContext>();
var fbReport = await fbDb.Reports.FirstOrDefaultAsync(r => r.Id == reportId);
if (fbReport != null)
{
fbReport.AiSummary = GenerateFallbackSummary(fbReport.Category);
fbReport.AiIndicators = GenerateFallbackIndicators(fbReport.Category);
await fbDb.SaveChangesAsync();
}
}
catch { }
}
}, CancellationToken.None);
return Results.Ok(new { code = 0, data = new { report.Id, report.Status, report.FileUrl }, message = "报告已上传AI 正在分析中" });
}); });
} }
/// <summary>
/// VLM 识别 + LLM 解读
/// </summary>
private static async Task AnalyzeReportWithAI(
Guid reportId, string filePath,
VisionClient vision, DeepSeekClient llm, AppDbContext db)
{
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == reportId);
if (report == null) return;
try
{
// ===== Step 1: 使用 VLM 识别报告图片,提取指标 =====
var vlmPrompt = """
{"isReport": false, "reason": "这不是医学报告,原因:..."}
JSON格式markdown包裹
{
"isReport": true,
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
"indicators": [
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
]
}
- status =normal=high=low
-
""";
var vlmContent = "";
try
{
var isPdf = Path.GetExtension(filePath).ToLowerInvariant() == ".pdf";
var imageUrl = isPdf ? null : await GetLocalImageUrl(filePath);
var vlmResponse = await vision.VisionAsync(
vlmPrompt,
imageUrl != null ? [imageUrl] : [],
userText: "请分析这份医学报告",
maxTokens: 2048);
vlmContent = vlmResponse.Choices?.FirstOrDefault()?.Message?.Content ?? "";
Console.WriteLine($"[Report] VLM 返回: {vlmContent?[..Math.Min(vlmContent.Length, 200)]}");
}
catch (Exception ex)
{
Console.WriteLine($"[Report] VLM 调用失败: {ex.Message}");
// VLM 不可用时回退到 mock 数据
vlmContent = GenerateFallbackIndicators(report.Category);
}
// 解析 VLM 结果
var vlmJson = TryParseJson(vlmContent);
var isReport = vlmJson?.TryGetProperty("isReport", out var ir) == true && ir.GetBoolean();
var reason = vlmJson?.TryGetProperty("reason", out var rsn) == true ? rsn.GetString() : null;
if (!isReport && !string.IsNullOrEmpty(reason))
{
report.AiSummary = $"⚠️ 无法分析:{reason}";
report.AiIndicators = "[]";
report.Status = ReportStatus.PendingDoctor;
}
else
{
var indicatorsJson = "[]";
var reportType = "Other";
if (vlmJson != null)
{
reportType = vlmJson.Value.TryGetProperty("reportType", out var rt) ? rt.GetString()! : "Other";
if (vlmJson.Value.TryGetProperty("indicators", out var inds))
indicatorsJson = inds.GetRawText();
}
report.Category = Enum.TryParse<ReportCategory>(reportType, out var cat) ? cat : ReportCategory.Other;
report.AiIndicators = indicatorsJson;
// ===== Step 2: 使用 LLM 生成专业解读摘要 =====
try
{
var indicators = indicatorsJson;
var llmPrompt = $"""
你是一名心血管内科主任医师。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
检测指标:{indicators}
要求:
1. 总字数200-300字
2. 先总结整体情况
3. 指出异常指标及其可能原因
4. 给出生活建议
5. 末尾提醒"AI预解读"
JSON格式
""";
var llmMessages = new List<ChatMessage>
{
new() { Role = "system", Content = "你是一名心血管内科主任医师,擅长解读检验报告。" },
new() { Role = "user", Content = llmPrompt }
};
var llmResponse = await llm.ChatAsync(llmMessages, maxTokens: 800, temperature: 0.3f);
var summary = llmResponse.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
if (!string.IsNullOrEmpty(summary))
report.AiSummary = summary;
else
report.AiSummary = GenerateFallbackSummary(report.Category);
}
catch (Exception ex)
{
Console.WriteLine($"[Report] LLM 调用失败: {ex.Message}");
report.AiSummary = GenerateFallbackSummary(report.Category);
}
}
await db.SaveChangesAsync();
Console.WriteLine($"[Report] AI 分析完成: {reportId}");
}
catch (Exception ex)
{
Console.WriteLine($"[Report] 分析异常: {ex.Message}");
if (report != null)
{
report.AiSummary = GenerateFallbackSummary(report.Category);
report.AiIndicators = GenerateFallbackIndicators(report.Category);
await db.SaveChangesAsync();
}
}
}
private static async Task<string?> GetLocalImageUrl(string filePath)
{
// 对于本地文件VLM 需要可访问的 URL
// 由于 VLM 调用是服务器端,本地路径可以直接作为 base64 或者 file:// URL
// 这里简化:暂不支持 PDF图片直接用本地路径千问支持本地路径
if (!File.Exists(filePath)) return null;
var bytes = await File.ReadAllBytesAsync(filePath);
var base64 = Convert.ToBase64String(bytes);
var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.');
var mime = ext switch { "png" => "image/png", "jpg" or "jpeg" => "image/jpeg", "webp" => "image/webp", _ => "image/png" };
return $"data:{mime};base64,{base64}";
}
private static JsonElement? TryParseJson(string content)
{
if (string.IsNullOrWhiteSpace(content)) return null;
content = content.Trim();
// 去掉可能包裹的 markdown ```json ... ```
if (content.StartsWith("```"))
{
var end = content.IndexOf('\n');
if (end > 0) content = content[(end + 1)..];
if (content.EndsWith("```"))
content = content[..^3];
}
content = content.Trim();
try { return JsonDocument.Parse(content).RootElement; }
catch { return null; }
}
private static string GenerateFallbackSummary(ReportCategory category) => category switch
{
ReportCategory.BloodTest => "血常规显示白细胞计数正常红细胞及血红蛋白略低于参考范围下限提示可能存在轻度贫血。血小板计数在正常范围内。建议关注铁蛋白及维生素B12水平适当补充富含铁质的食物。以上为AI预解读具体请以医生诊断为准。",
ReportCategory.Ecg => "心电图显示窦性心律心率正常。无明显ST段改变或心律失常。以上为AI预解读具体请以医生诊断为准。",
_ => "检查指标基本在参考范围内。以上为AI预解读具体请以医生诊断为准。"
};
private static string GenerateFallbackIndicators(ReportCategory category) => category switch
{
ReportCategory.BloodTest => """[{"name":"","value":"7.5","unit":"×10^9/L","referenceRange":"4.0-10.0","status":"normal"},{"name":"","value":"4.1","unit":"×10^12/L","referenceRange":"4.3-5.8","status":"low"},{"name":"","value":"125","unit":"g/L","referenceRange":"130-175","status":"low"},{"name":"","value":"195","unit":"×10^9/L","referenceRange":"100-300","status":"normal"}]""",
_ => """[{"name":"","value":"","unit":"","referenceRange":"","status":"normal"}]"""
};
private static Guid GetUserId(HttpContext http) => private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
} }
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);

View File

@@ -1,105 +1,58 @@
using Health.Application.HealthArchives;
using Health.Application.Users;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
/// <summary>
/// 用户与健康档案 API 端点
/// </summary>
public static class UserEndpoints public static class UserEndpoints
{ {
public static void MapUserEndpoints(this WebApplication app) public static void MapUserEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("/api/user").RequireAuthorization(); var group = app.MapGroup("/api/user").RequireAuthorization();
// 获取个人信息 group.MapGet("/profile", async (HttpContext http, IUserService users, CancellationToken ct) =>
group.MapGet("/profile", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var profile = await users.GetProfileAsync(GetUserId(http), ct);
var user = await db.Users.Select(u => new return Results.Ok(new { code = 0, data = profile, message = (string?)null });
{
u.Id, u.Phone, u.Role, u.Name, u.Gender, BirthDate = u.BirthDate != null ? u.BirthDate.Value.ToString("yyyy-MM-dd") : null, u.AvatarUrl
}).FirstOrDefaultAsync(u => u.Id == userId, ct);
return Results.Ok(new { code = 0, data = user, message = (string?)null });
}); });
// 修改资料 group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, IUserService users, CancellationToken ct) =>
group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
var user = await db.Users.FindAsync([userId], ct); var updated = await users.UpdateProfileAsync(
if (user == null) return Results.Ok(new { code = 40004, message = "用户不存在" }); GetUserId(http),
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate),
user.Name = req.Name ?? user.Name; ct);
user.Gender = req.Gender ?? user.Gender; if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
if (DateOnly.TryParse(req.BirthDate, out var bd)) user.BirthDate = bd;
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
// 获取健康档案 group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
group.MapGet("/health-archive", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var archive = await archives.GetAsync(GetUserId(http), ct);
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
return Results.Ok(new { code = 0, data = archive, message = (string?)null }); return Results.Ok(new { code = 0, data = archive, message = (string?)null });
}); });
// 更新健康档案 group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null;
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); await archives.UpdateAsync(
if (archive == null) GetUserId(http),
{ new HealthArchiveUpdateRequest(
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId }; req.Diagnosis,
db.HealthArchives.Add(archive); req.SurgeryType,
} surgeryDate,
req.Allergies,
archive.Diagnosis = req.Diagnosis ?? archive.Diagnosis; req.DietRestrictions,
archive.SurgeryType = req.SurgeryType ?? archive.SurgeryType; req.ChronicDiseases,
if (DateOnly.TryParse(req.SurgeryDate, out var sd)) archive.SurgeryDate = sd; req.FamilyHistory),
if (req.Allergies != null) archive.Allergies = req.Allergies; ct);
if (req.DietRestrictions != null) archive.DietRestrictions = req.DietRestrictions;
if (req.ChronicDiseases != null) archive.ChronicDiseases = req.ChronicDiseases;
archive.FamilyHistory = req.FamilyHistory ?? archive.FamilyHistory;
archive.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
// 注销账号 group.MapDelete("/account", async (HttpContext http, IUserService users, CancellationToken ct) =>
group.MapDelete("/account", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var userId = GetUserId(http); await users.DeleteAccountAsync(GetUserId(http), ct);
// 医生清除Doctor关联
var profile = await db.DoctorProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct);
if (profile?.DoctorId != null)
{
await db.Users.Where(u => u.DoctorId == profile!.DoctorId).ExecuteUpdateAsync(u => u.SetProperty(x => x.DoctorId, (Guid?)null), ct);
await db.Doctors.Where(d => d.Id == profile.DoctorId).ExecuteDeleteAsync(ct);
}
// 删除所有关联数据
await db.HealthRecords.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
await db.MedicationLogs.Where(l => l.UserId == userId).ExecuteDeleteAsync(ct);
await db.Medications.Where(m => m.UserId == userId).ExecuteDeleteAsync(ct);
await db.DietFoodItems.Where(i => i.DietRecord!.UserId == userId).ExecuteDeleteAsync(ct);
await db.DietRecords.Where(d => d.UserId == userId).ExecuteDeleteAsync(ct);
await db.ExercisePlanItems.Where(i => i.Plan!.UserId == userId).ExecuteDeleteAsync(ct);
await db.ExercisePlans.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await db.Reports.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
await db.ConversationMessages.Where(m => m.Conversation!.UserId == userId).ExecuteDeleteAsync(ct);
await db.Conversations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await db.ConsultationMessages.Where(m => m.Consultation!.UserId == userId).ExecuteDeleteAsync(ct);
await db.Consultations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
await db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct);
await db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
await db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct);
await db.DoctorProfiles.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
} }

View File

@@ -1,13 +1,40 @@
using System.Text; using System.Text;
using Health.Application.AI;
using Health.Application.Admin;
using Health.Application.Auth;
using Health.Application.Calendars;
using Health.Application.Diets;
using Health.Application.Exercises;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
using Health.Application.Maintenance;
using Health.Application.Notifications;
using Health.Application.Reports;
using Health.Application.Users;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Health.Infrastructure.Admin;
using Health.Infrastructure.Auth;
using Health.Infrastructure.Calendars;
using Health.Infrastructure.Data; using Health.Infrastructure.Data;
using Health.Infrastructure.Diets;
using Health.Infrastructure.Exercises;
using Health.Infrastructure.HealthArchives;
using Health.Infrastructure.HealthRecords;
using Health.Infrastructure.Medications;
using Health.Infrastructure.Maintenance;
using Health.Infrastructure.Notifications;
using Health.Infrastructure.Reports;
using Health.Infrastructure.Services; using Health.Infrastructure.Services;
using Health.Infrastructure.Users;
using Health.WebApi.BackgroundServices; using Health.WebApi.BackgroundServices;
using Health.WebApi.Converters; using Health.WebApi.Converters;
using Health.WebApi.Endpoints; using Health.WebApi.Endpoints;
using Health.WebApi.Middleware; using Health.WebApi.Middleware;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
// 加载 .env 文件(开发环境) // 加载 .env 文件(开发环境)
@@ -28,6 +55,18 @@ if (File.Exists(envPath))
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment())
{
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
var dataProtectionPath = Path.Combine(Directory.GetCurrentDirectory(), "data-protection-keys");
Directory.CreateDirectory(dataProtectionPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath))
.SetApplicationName("HealthManager.Development");
}
// ---- JSON 配置枚举字符串、camelCase、UTC时间统一带Z---- // ---- JSON 配置枚举字符串、camelCase、UTC时间统一带Z----
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
{ {
@@ -68,6 +107,44 @@ builder.Services.AddAuthorization();
builder.Services.AddSingleton<JwtProvider>(); builder.Services.AddSingleton<JwtProvider>();
builder.Services.AddSingleton<SmsService>(); builder.Services.AddSingleton<SmsService>();
builder.Services.AddSingleton<PromptManager>(); builder.Services.AddSingleton<PromptManager>();
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
builder.Services.AddScoped<IAdminService, AdminService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAiConversationService, AiConversationService>();
builder.Services.AddScoped<IAiConversationRepository, EfAiConversationRepository>();
builder.Services.AddScoped<IPatientContextService, PatientContextService>();
builder.Services.AddScoped<ICalendarService, CalendarService>();
builder.Services.AddScoped<ICalendarRepository, EfCalendarRepository>();
builder.Services.AddScoped<IDietService, DietService>();
builder.Services.AddScoped<IDietRepository, EfDietRepository>();
builder.Services.AddScoped<IDietImageAnalysisCoordinator, DietImageAnalysisCoordinator>();
builder.Services.AddScoped<IDietImageAnalyzer, DietImageAnalyzer>();
builder.Services.AddScoped<IDietImageAnalysisQueue, DietImageAnalysisQueue>();
builder.Services.AddScoped<IExerciseService, ExerciseService>();
builder.Services.AddScoped<IExerciseRepository, EfExerciseRepository>();
builder.Services.AddScoped<IExerciseReminderProducer, ExerciseReminderProducer>();
builder.Services.AddScoped<IHealthArchiveService, HealthArchiveService>();
builder.Services.AddScoped<IHealthArchiveRepository, EfHealthArchiveRepository>();
builder.Services.AddScoped<IHealthRecordService, HealthRecordService>();
builder.Services.AddScoped<IHealthRecordRepository, EfHealthRecordRepository>();
builder.Services.AddScoped<IMedicationService, MedicationService>();
builder.Services.AddScoped<IMedicationRepository, EfMedicationRepository>();
builder.Services.AddScoped<IMedicationReminderScanner, MedicationReminderScanner>();
builder.Services.AddScoped<IMedicationReminderDispatcher, OutboxMedicationReminderDispatcher>();
builder.Services.AddScoped<IMedicationReminderQueue, MedicationReminderQueue>();
builder.Services.AddScoped<IMaintenanceService, MaintenanceService>();
builder.Services.AddScoped<IMaintenanceRepository, EfMaintenanceRepository>();
builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>();
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
builder.Services.AddScoped<DatabaseSchemaMigrator>();
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM---- // ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM----
builder.Services.AddHttpClient<DeepSeekClient>(client => builder.Services.AddHttpClient<DeepSeekClient>(client =>
@@ -85,7 +162,11 @@ builder.Services.AddHttpClient<VisionClient>(client =>
// ---- 后台服务 ---- // ---- 后台服务 ----
builder.Services.AddHostedService<MedicationReminderService>(); builder.Services.AddHostedService<MedicationReminderService>();
builder.Services.AddHostedService<ExerciseReminderService>();
builder.Services.AddHostedService<MedicationReminderWorker>();
builder.Services.AddHostedService<CleanupService>(); builder.Services.AddHostedService<CleanupService>();
builder.Services.AddHostedService<ReportAnalysisWorker>();
builder.Services.AddHostedService<DietImageAnalysisWorker>();
// ---- OpenAPI ---- // ---- OpenAPI ----
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
@@ -110,6 +191,14 @@ app.UseCors();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsPath);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploadsPath),
RequestPath = "/uploads"
});
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
@@ -118,6 +207,7 @@ using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await scope.ServiceProvider.GetRequiredService<DatabaseSchemaMigrator>().ApplyAsync();
await DataSeeder.SeedAsync(db); await DataSeeder.SeedAsync(db);
await DevDataSeeder.SeedIfEnabled(db, app.Configuration); await DevDataSeeder.SeedIfEnabled(db, app.Configuration);
} }
@@ -134,6 +224,7 @@ app.MapUserEndpoints();
app.MapAiChatEndpoints(); app.MapAiChatEndpoints();
app.MapFileEndpoints(); app.MapFileEndpoints();
app.MapCalendarEndpoints(); app.MapCalendarEndpoints();
app.MapNotificationEndpoints();
app.MapDoctorEndpoints(); app.MapDoctorEndpoints();
app.MapAdminEndpoints(); app.MapAdminEndpoints();
app.MapFollowUpEndpoints(); app.MapFollowUpEndpoints();

View File

@@ -21,9 +21,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Health.Application\Health.Application.csproj" />
<ProjectReference Include="..\..\src\Health.WebApi\Health.WebApi.csproj" /> <ProjectReference Include="..\..\src\Health.WebApi\Health.WebApi.csproj" />
<ProjectReference Include="..\..\src\Health.Domain\Health.Domain.csproj" /> <ProjectReference Include="..\..\src\Health.Domain\Health.Domain.csproj" />
<ProjectReference Include="..\..\src\Health.Infrastructure\Health.Infrastructure.csproj" /> <ProjectReference Include="..\..\src\Health.Infrastructure\Health.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,149 @@
using Health.Application.AI;
using Health.Application.Calendars;
using Health.Application.HealthArchives;
using Health.Application.Exercises;
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Tests;
public sealed class ApplicationServiceTests
{
[Fact]
public async Task HealthArchive_AiUpdate_OnlyChangesRequestedSection()
{
var userId = Guid.NewGuid();
var repository = new FakeHealthArchiveRepository(new HealthArchive
{
Id = Guid.NewGuid(),
UserId = userId,
Diagnosis = "原诊断",
Allergies = ["青霉素"]
});
var service = new HealthArchiveService(repository);
var result = await service.ExecuteAiActionAsync(
userId,
"update_diagnosis",
new HealthArchiveUpdateRequest("新诊断", "不应写入的手术", null, null, null, null, null),
CancellationToken.None);
Assert.True((bool)result.GetType().GetProperty("success")!.GetValue(result)!);
Assert.Equal("新诊断", repository.Archive!.Diagnosis);
Assert.Null(repository.Archive.SurgeryType);
Assert.Equal(["青霉素"], repository.Archive.Allergies);
}
[Fact]
public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation()
{
var ownerId = Guid.NewGuid();
var conversation = new Conversation { Id = Guid.NewGuid(), UserId = ownerId, AgentType = AgentType.Health };
var service = new AiConversationService(new FakeConversationRepository(conversation));
var result = await service.OpenAsync(Guid.NewGuid(), conversation.Id, AgentType.Health, "测试", CancellationToken.None);
Assert.False(result.Found);
Assert.False(result.Created);
}
[Fact]
public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp()
{
var date = new DateOnly(2026, 6, 19);
var medication = new Medication
{
Id = Guid.NewGuid(), IsActive = true, Name = "阿司匹林", StartDate = date.AddDays(-1), TimeOfDay = [new TimeOnly(8, 0)]
};
var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 });
var followUp = new FollowUp { Id = Guid.NewGuid(), ScheduledAt = date.ToDateTime(new TimeOnly(9, 0)), Title = "复查" };
var service = new CalendarService(new FakeCalendarRepository(new CalendarDataSnapshot([medication], [plan], [followUp])));
var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None);
var target = Assert.Single(result, item =>
item.GetType().GetProperty("date")!.GetValue(item)?.ToString() == "2026-06-19");
var events = (IEnumerable<string>)target.GetType().GetProperty("events")!.GetValue(target)!;
Assert.Equal(["medication", "exercise", "followup"], events);
}
[Fact]
public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates()
{
var repository = new FakeExerciseRepository();
var service = new ExerciseService(repository);
var start = new DateOnly(2026, 6, 20);
await service.CreateAsync(Guid.NewGuid(), new ExercisePlanCreateRequest(start, start.AddDays(9), "跑步", 30, new TimeOnly(19, 0)), CancellationToken.None);
var plan = Assert.IsType<ExercisePlan>(repository.Plan);
Assert.Equal(start.AddDays(9), plan.EndDate);
Assert.Equal(10, plan.Items.Count);
Assert.Equal(10, plan.Items.Select(x => x.ScheduledDate).Distinct().Count());
Assert.Equal(Enumerable.Range(0, 10).Select(start.AddDays), plan.Items.OrderBy(x => x.ScheduledDate).Select(x => x.ScheduledDate));
}
[Fact]
public async Task ExercisePlan_CurrentUsesExactScheduledDate()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var repository = new FakeExerciseRepository();
var plan = new ExercisePlan
{
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today, EndDate = today.AddDays(9), ReminderTime = new TimeOnly(19, 0),
};
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 });
repository.SetPlan(plan);
var service = new ExerciseService(repository);
var current = await service.GetCurrentAsync(plan.UserId, CancellationToken.None);
Assert.NotNull(current);
Assert.Equal(today, current!.Items.Single(x => x.ScheduledDate == today).ScheduledDate);
Assert.Equal(2, current.Items.Count);
}
private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository
{
public HealthArchive? Archive { get; private set; } = archive;
public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) => Task.FromResult(Archive?.UserId == userId ? Archive : null);
public Task AddAsync(HealthArchive value, CancellationToken ct) { Archive = value; return Task.CompletedTask; }
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
{
private readonly Conversation _conversation = conversation;
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) => Task.FromResult(_conversation.Id == conversationId && _conversation.UserId == userId ? _conversation : null);
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) => Task.FromResult(_conversation.Id == conversationId ? _conversation : null);
public Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) => Task.FromResult<IReadOnlyList<Conversation>>([]);
public Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
public void Delete(Conversation value) { }
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository
{
public Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot);
}
private sealed class FakeExerciseRepository : IExerciseRepository
{
public ExercisePlan? Plan { get; private set; }
public void SetPlan(ExercisePlan plan) => Plan = plan;
public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []);
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>([]);
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan);
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan);
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId));
public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; }
public void Delete(ExercisePlan plan) { Plan = null; }
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
}

View File

@@ -204,10 +204,10 @@ public class EntityTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var monday = new DateOnly(2026, 6, 1); var monday = new DateOnly(2026, 6, 1);
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday }; var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(2), ReminderTime = new TimeOnly(19, 0) };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, IsRestDay = true }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), IsRestDay = true });
db.ExercisePlans.Add(plan); db.ExercisePlans.Add(plan);
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@@ -0,0 +1,76 @@
using Health.Infrastructure.AI;
using Health.Infrastructure.Data;
using Health.Infrastructure.Data.Records;
using Health.Infrastructure.Reports;
using Microsoft.EntityFrameworkCore;
namespace Health.Tests;
public sealed class PersistencePipelineTests
{
[Fact]
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
{
await using var db = CreateDbContext();
var store = new EfAiWriteConfirmationStore(db);
var ownerId = Guid.NewGuid();
var command = await store.CreateAsync(ownerId, "record_health_data", "{}", TimeSpan.FromMinutes(10), CancellationToken.None);
var wrongUser = await store.TakeAsync(command.Id, Guid.NewGuid(), CancellationToken.None);
var firstTake = await store.TakeAsync(command.Id, ownerId, CancellationToken.None);
var secondTake = await store.TakeAsync(command.Id, ownerId, CancellationToken.None);
Assert.Null(wrongUser);
Assert.NotNull(firstTake);
Assert.Null(secondTake);
}
[Fact]
public async Task ReportTask_RetryUsesBackoffBeforeMaxAttempts()
{
await using var db = CreateDbContext();
var task = NewReportTask(attempts: 1);
db.ReportAnalysisTasks.Add(task);
await db.SaveChangesAsync();
var queue = new EfReportAnalysisQueue(db);
var before = DateTime.UtcNow;
await queue.RetryAsync(task.Id, "temporary failure", CancellationToken.None);
Assert.Equal("Pending", task.Status);
Assert.True(task.AvailableAt > before);
Assert.Equal("temporary failure", task.LastError);
}
[Fact]
public async Task ReportTask_RetryMarksFailedAtMaxAttempts()
{
await using var db = CreateDbContext();
var task = NewReportTask(attempts: 3);
db.ReportAnalysisTasks.Add(task);
await db.SaveChangesAsync();
var queue = new EfReportAnalysisQueue(db);
await queue.RetryAsync(task.Id, new string('x', 2500), CancellationToken.None);
Assert.Equal("Failed", task.Status);
Assert.Equal(2000, task.LastError!.Length);
}
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
{
Id = Guid.NewGuid(),
ReportId = Guid.NewGuid(),
FilePath = "report.jpg",
Status = "Processing",
Attempts = attempts,
AvailableAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
private static AppDbContext CreateDbContext() => new(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
}

View File

@@ -0,0 +1,283 @@
# 后端架构演进方案
日期2026-06-18
## 1. 背景
当前项目已经具备 `Health.Domain``Health.Application``Health.Infrastructure``Health.WebApi` 的分层目录,但实际业务逻辑主要仍写在 `Health.WebApi/Endpoints` 中。多数接口直接注入 `AppDbContext`,在 Endpoint 内完成查询、权限判断、状态流转和 `SaveChanges`
这种方式适合早期快速验证但随着患者端健康管理、AI 分析、报告、饮食、用药、运动、医生端等业务增长,会带来几个问题:
- 业务规则分散在 Endpoint、AI Agent Handler、后台服务中。
- 同一个业务动作可能被普通接口和 AI 工具重复实现。
- 权限和资源归属校验容易遗漏。
- 异步任务目前存在 `Task.Run` 形式,不便于限流、重试和统一管理。
- Application 层没有真正承接业务用例,后续测试和维护成本会升高。
技术目标是按 DDD 思路逐步演进:让 Endpoint 成为接口适配层,业务流程进入 Application Service外部能力和数据访问由 Infrastructure 支撑,耗时任务通过生产者-消费者管道处理。
## 2. 当前业务边界
### 2.1 当前核心患者端闭环
以下业务都需要继续做扎实:
1. AI 健康管家聊天
2. 健康指标记录与趋势:血压、心率、血糖、血氧、体重
3. 报告上传与 AI 预解读
4. 饮食拍照分析与保存
5. 用药管理与打卡
6. 运动计划
### 2.2 医生端当前策略
医生端可以做代码整理和权限边界收拢,但不作为当前核心业务闭环:
- 医患实时聊天:暂时搁置,后续接入互联网医院后再完善。
- 医生审核报告:暂时不是当前真实流程,患者端报告以 AI 预解读为主;界面可显示“医生审核中/待审核”一类状态。
- 医生工作台:保留现有页面和基础接口,不主动扩大功能范围,重构时以不影响当前项目运行为目标。
### 2.3 AI 写入规则
统一业务规则:
- AI 纯查询、解释、建议可以直接回复。
- AI 只要要写入用户健康相关数据,必须先让用户确认。
- 需要确认的写入包括但不限于:健康指标、用药计划、运动计划、健康档案修改。
- 饮食记录当前在饮食分析结果页由用户主动保存,保留该方式。
## 3. 目标架构
目标不是创建一个巨大的全能 Service而是按业务模块拆分 Application Service
```text
Health.WebApi
Endpoints
Hubs
BackgroundServices
Health.Application
Auth
Users
HealthRecords
Medications
Diet
Reports
Exercise
Consultations
Doctors
Ai
Common
Health.Domain
Entities
Enums
DomainRules
Health.Infrastructure
Data
AI
Services
Storage
```
职责边界:
- `WebApi`:接收 HTTP/SignalR 请求,解析参数,返回响应。
- `Application`:承接业务用例、状态流转、权限校验、任务入队。
- `Domain`:保存核心实体、枚举和稳定业务规则。
- `Infrastructure`EF Core、AI 客户端、短信、文件存储、推送、未来互联网医院适配。
## 4. 数据库读写收拢方式
短期先不强制引入 Repository 抽象,避免过度设计。第一阶段采用:
```text
Endpoint -> Application Service -> AppDbContext
```
也就是说,数据库读写先统一进入对应业务 Service而不是继续散落在 Endpoint 中。
后续如果业务复杂度继续提升,再考虑:
```text
Application Service -> Repository / UnitOfWork -> AppDbContext
```
第一阶段优先收拢这些模块:
1. `ReportService`
2. `DietService`
3. `MedicationService`
4. `ExerciseService`
5. `HealthRecordService`
6. `ConsultationService`
## 5. 生产者-消费者管道
不是所有业务都需要管道。同步、快速、必须立即返回的操作仍走普通 Service。
适合管道的任务:
- 报告 AI 分析
- 饮食图片识别
- 处方图片识别
- 用药提醒推送
- 健康周报生成
- 后期互联网医院数据同步
当前阶段采用 .NET 内置 `Channel<T>` + `BackgroundService`
```text
业务 Service = 生产者
Channel<TJob> = 内存任务队列
BackgroundService = 消费者
AI/推送/外部服务 = 实际执行器
```
单服务器和当前用户规模下,内存队列足够作为第一阶段方案。后续如果出现多实例部署、任务不能丢、重试次数和死信队列等要求,再迁移到 Redis Stream、RabbitMQ 或 Hangfire。
## 6. 第一阶段落地范围
先从报告模块落地因为它同时具备上传、AI、异步、状态流转、失败重试等典型场景。
### 6.1 报告模块目标
从当前:
```text
ReportEndpoints -> AppDbContext + Task.Run + AI 调用
```
演进为:
```text
ReportEndpoints
-> ReportService
-> 保存文件
-> 创建报告
-> 标记状态
-> 入队 ReportAnalysisJob
ReportAnalysisWorker
-> 消费 ReportAnalysisQueue
-> 调用 ReportAnalysisService / AI Analyzer
-> 更新报告状态
```
### 6.2 报告模块要保持的业务行为
- 上传只支持报告图片。
- 上传成功后状态为 `Analyzing`
- AI 成功后进入 `PendingDoctor`,患者端展示 AI 预解读。
- AI 失败后进入 `AnalysisFailed`,不生成假摘要、假指标。
- 患者端可以查看原始报告图片。
- 医生审核不是当前核心闭环,状态可保留但不扩大功能。
## 7. 当前已落地进展
截至 2026-06-18第一轮服务化已经完成以下模块
1. `ReportService`报告上传、图片校验、报告创建、重新分析、删除、AI 分析状态流转进入服务层;报告分析通过 `ReportAnalysisQueue` + `ReportAnalysisWorker` 异步消费。
2. `HealthRecordService`健康指标列表、创建、更新、删除、最新值、趋势查询、异常判断进入服务层AI 记数据工具复用同一套创建逻辑。
3. `ExerciseService`运动计划创建、列表、详情、删除、打卡、AI 创建/查询/打卡进入服务层。
4. `MedicationService`用药列表、创建、更新、删除、今日服药确认、按剂量打卡、提醒查询、AI 创建/查询/确认进入服务层。
5. `DietService`:饮食记录列表、保存、删除、热量/评分更新进入服务层。
其中以下模块已经继续演进为更严格的 Application Service + Repository 结构:
```text
Endpoint
-> Health.Application.*Service
-> Health.Application.I*Repository
-> Health.Infrastructure.Ef*Repository
-> AppDbContext
```
已完成:
1. `HealthRecordService` + `IHealthRecordRepository` + `EfHealthRecordRepository`
2. `ExerciseService` + `IExerciseRepository` + `EfExerciseRepository`
3. `DietService` + `IDietRepository` + `EfDietRepository`
4. `MedicationService` + `IMedicationRepository` + `EfMedicationRepository`
5. `ReportService` + `IReportRepository` + `EfReportRepository` + `IReportFileStorage` + `LocalReportFileStorage`
报告模块中,`ReportAnalysisQueue``ReportAnalysisService` 仍属于 Infrastructure前者是内存队列适配后者需要调用 AI 客户端并通过 `IReportRepository` 更新报告状态,不再直接依赖 `AppDbContext`
AI 写入确认机制已完成第一阶段落地:
```text
AI 写入工具调用
-> 创建 10 分钟有效的 PendingAiWriteCommand
-> 前端展示确认卡片,此时不写数据库
-> 用户点击确认
-> POST /api/ai/confirm-write/{commandId}
-> 校验当前用户和一次性命令
-> 执行对应 Application Service 写入
```
- 健康指标、创建/确认用药、创建/打卡运动、AI 修改健康档案均进入待确认流程。
- 纯查询工具继续直接执行。
- 命令只能由所属用户执行一次;执行失败时会恢复命令供用户重试,过期或服务重启后自动失效。
- AI 待确认命令已迁移到数据库表 `AiWriteCommands`,领取命令、业务写入和完成状态在同一事务内执行,避免重复写入。
- 报告分析任务已迁移到数据库表 `ReportAnalysisTasks`,支持服务重启恢复、原子领取、失败重试和最终失败状态。
- 项目当前仍使用 `EnsureCreated` 管理原有表;新增 `DatabaseSchemaMigrator``__AppSchemaMigrations` 版本表,为已有本地数据库安全补充基础设施表。
患者端其他业务收拢进展:
1. `HealthArchiveService` + `IHealthArchiveRepository`健康档案页面、AI 查询和确认写入统一执行。
2. `AiConversationService` + `IAiConversationRepository`:会话创建、消息保存、历史列表和删除统一执行。
3. `PatientContextService`:统一组合健康档案、近期指标和当前用药。
4. `UserService` + `IUserRepository`:个人资料和账号注销统一执行;账号数据清理使用事务。
5. `CalendarService` + `ICalendarRepository`:聚合用药、运动、随访日历。
生产者/消费者管道现状:
- 报告分析:数据库持久化任务 + `ReportAnalysisWorker`
- 饮食图片识别:任务持久化到 `DietImageAnalysisTasks`,由 `DietImageAnalysisWorker` 原子领取、失败重试和恢复处理中断任务;前端接口协议保持不变。
- 用药提醒:`MedicationReminderService` 负责扫描生产任务,任务持久化到 `MedicationReminderTasks` 并按药品/日期/时间唯一去重;`MedicationReminderWorker` 消费后写入 `NotificationOutbox`。真正的手机推送需在选定推送服务后消费 Outbox目前不会把未发送通知标记为已推送。
- 报告、饮食和用药任务消费者均采用 1 到 5 秒的自适应空闲轮询,过期 Processing 任务每分钟恢复一次,避免每秒重复执行恢复更新。
- App 内用药提醒通过 `NotificationOutbox` + `/api/notifications/pending` 提供,患者端前台每 30 秒获取并展示,展示后回执去重;暂不接入系统级或厂商推送。
- `MaintenanceService` 每小时执行一次维护,自动删除超过 30 天的已完成/失败后台任务、通知 Outbox、过期 AI 写入命令和旧 AI 会话,不删除用户健康记录、饮食记录或用药计划。
- 登录、注册、验证码、Token 刷新和退出已收拢到 `IAuthService`;管理员医生管理和患者列表已收拢到 `IAdminService`Endpoint 不再直接读写数据库。开发环境 `send-sms` 仍返回 `devCode` 供 Flutter 自动填入,非开发环境不返回验证码。
- AI 工具查询、确认写入和事务边界已收拢到 `IAiToolExecutionService`AI Endpoint 不再直接持有 `AppDbContext`
- 运动计划已从 `WeekStartDate + DayOfWeek` 周模板改为 `StartDate + EndDate + ReminderTime`,每日条目使用唯一的 `ScheduledDate`。连续 7 天、10 天或更长计划按真实日期生成,首页今日任务、健康日历、医生端只读详情和 AI 创建均使用同一日期模型。
- App 内运动提醒按每日条目的 `ScheduledDate` 和计划 `ReminderTime` 生成到 `NotificationOutbox`;已打卡和休息日不会提醒,同一每日条目只生成一次。
当前仍保留在 Endpoint 或旧 Handler 中的业务,需要后续逐步收拢:
- `ConsultationService`:医生聊天暂不作为当前核心业务,但基础权限和数据读写仍可继续服务化。
- `DoctorService` / `AdminDoctorService`:医生信息、患者列表、报告查看等目前不作为真实互联网医院流程,后续按老板和互联网医院接入方案调整。
- `CalendarService`:健康日历目前聚合运动、用药、饮食等多模块数据,适合在核心模块稳定后单独收拢。
- `User/ProfileService`:个人信息、健康档案、账号清理等可继续整理,尤其是 AI 修改健康档案前的确认规则。
## 8. 后续推广顺序
1. 报告Application Service + 分析队列 + Worker
2. 饮食:图片识别任务队列,用户修正后保存
3. 用药:提醒扫描和推送任务解耦
4. 运动:计划创建、打卡规则收拢到 Service
5. 健康指标:记录、异常判断、趋势查询收拢到 Service
6. 问诊:互联网医院接入前只收拢基础接口,不扩大聊天功能
7. 医生端:保留基础接口,后续根据互联网医院和老板决策再完善审核/聊天工作流
## 9. 不做的事
当前阶段暂不做:
- 不一次性重构全项目。
- 不立即引入 RabbitMQ/Kafka 等重型组件。
- 不强制引入复杂 Repository 层。
- 不改变当前患者端主要交互。
- 不把医生聊天/医生审核作为当前核心业务流。
## 10. 验收标准
第一阶段完成后应满足:
- 报告 Endpoint 不再直接承载主要业务流程。
- 报告 AI 分析不再使用 `Task.Run`
- 报告分析任务通过队列进入后台 Worker。
- 报告状态流转集中在 Application Service。
- 上传、列表、详情、删除、查看原图、分析失败状态保持可用。
- 后端编译通过,前端报告页分析通过。

View File

@@ -371,7 +371,186 @@ var conversation = await db.Conversations
5. 医生端做成真正可用的工作台:患者筛选、风险排序、随访提醒。 5. 医生端做成真正可用的工作台:患者筛选、风险排序、随访提醒。
6. 报告、饮食、用药、运动做长期趋势关联。 6. 报告、饮食、用药、运动做长期趋势关联。
## 11. 总体建议 ## 11. 第二轮深挖补充
这一轮额外对已有 `docs/BUG_REVIEW.md`、后端端点、AI Agent handler、SignalR、Flutter provider 生命周期、饮食/蓝牙/问诊页面做了交叉检查。需要注意:旧 bug 文档中有一些问题已经被修复或情况已经变化,本节以当前代码为准。
### 11.1 已确认仍然存在的高风险问题
| 优先级 | 位置 | 当前问题 | 风险说明 | 修复方向 |
| --- | --- | --- | --- | --- |
| P0 | `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | `/api/auth/send-sms` 仍返回 `devCode` | 任何客户端都能拿到验证码,短信验证等同失效 | 只在 Development 返回;生产环境完全移除 |
| P0 | `health_app/lib/pages/auth/login_page.dart` | 前端拿到 `devCode` 后自动填入验证码 | 把后端安全问题固化成产品行为 | 删除自动填充;开发环境可用 debug banner 或日志 |
| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | SSE token 走 query且 fallback 用 `ReadJwtToken` 解析 | query token 会进日志;`ReadJwtToken` 不验证签名 | SSE 改标准鉴权,或先换一次性 stream ticket |
| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | 创建/续写 AI 会话时 `FindAsync(conversationId)` 未校验 `UserId` | 可能跨用户写入/读取会话 | `FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId)` |
| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | SignalR Hub 没有鉴权,入组只靠 `consultationId` | 任意连接可加入任意问诊房间 | `MapHub().RequireAuthorization()`Join 前校验用户或医生权限 |
| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | `SendMessage` 按传入 `senderType``consultationId` 写库 | 客户端可冒充 Doctor/User向任意会话发消息 | senderType 从 Claims/角色推导,不信任客户端 |
| P0 | `backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs` | 患者发消息只查会话存在,未校验会话属于当前用户 | 用户 A 可向用户 B 的问诊发 HTTP 消息 | 查询加 `c.UserId == userId` |
| P0 | `backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs` | `/items/{itemId}/checkin``FindAsync(itemId)` | 用户可打卡/取消他人的运动计划项 | Include Plan 后校验 `Plan.UserId` |
| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs` | AI 运动 checkin 只按 itemId 操作 | 通过 AI 工具也可越权打卡 | 查询 item 时联表校验当前 userId |
| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs` | AI 用药 confirm 直接写 `MedicationLog`,不验证药品归属 | 可给他人药品写入服药记录 | 先查 `Medication.Id == medId && UserId == userId` |
| P1 | `backend/src/Health.WebApi/Endpoints/medication_endpoints.cs` | `/medications/{id}/confirm` 查 existing log 未限制 `UserId`,也未先确认药品归属 | 可能误删/写入不属于当前用户药品的日志 | 先查药品归属,再按 `MedicationId + UserId` 查日志 |
| P1 | `backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs` | 医生端已鉴权,但患者详情/报告/随访操作多处只按 id 查询 | 医生可能访问或修改非自己负责患者的数据 | 所有医生端资源都按医生关联患者过滤 |
| P1 | `backend/src/Health.WebApi/Endpoints/file_endpoints.cs` | 上传缺少大小、类型、内容校验,且返回结构与前端不匹配 | 恶意文件/超大文件风险,前端图片上传链路失败 | 加限制、白名单、URL 返回和访问控制 |
| P1 | `health_app/lib/pages/diet/diet_capture_page.dart` | `_fieldCtrls` 缓存 controller但页面没有 dispose | 多次进入饮食分析页会泄漏 TextEditingController | 在 State `dispose()` 中遍历释放 |
| P1 | `health_app/lib/providers/consultation_provider.dart` | Hub/轮询 stop 需要手动调用provider 自身没有自动释放 | 离开页面后可能继续 SignalR 或 5 秒轮询 | Notifier build 中注册 `ref.onDispose(stop)` |
| P1 | `health_app/lib/providers/chat_provider.dart` | SSE `_subscription` 和 timer 没看到 provider dispose 释放 | 聊天流中断/页面销毁后可能残留监听 | 注册 `ref.onDispose`,切换会话时取消旧流 |
| P1 | `backend/src/Health.WebApi/Program.cs` | `MapHub("/hubs/consultation")` 未 RequireAuthorization | 即使 API 鉴权,实时通道仍裸露 | Hub 映射处加鉴权并处理 token 传递 |
### 11.2 旧问题中已经变化或需要修正的判断
这些点在旧 `BUG_REVIEW.md` 里出现过,但当前代码已经不是原始状态,后续不要按旧结论机械修:
- `doctor_endpoints.cs` 不是“零授权”了:当前已有 `.RequireAuthorization()` 和医生角色过滤。真正问题是医生端数据授权粒度不够,部分详情/报告/随访接口没有限制到当前医生负责的患者。
- `open_ai_compatible_client.cs` 的 Vision content 当前已经是 `Content = contentParts`,不是把多模态数组序列化成字符串。旧的 VLM 序列化 bug 看起来已修复。
- `diet_agent_handler.cs``consultation_agent_handler.cs` 当前没有声明未实现工具,而是主动缩减为档案/记录查询。问题应描述为“AI Agent 能力和首页胶囊/欢迎卡片承诺不一致”,不是“声明工具但未实现”。
- `cleanup_service.cs` 当前已经先删 ConversationMessages 再删 Conversations旧的 FK 删除顺序问题已修。
- `device_scan_page.dart` 当前 `dispose()` 已取消 scan/read/connection 订阅,不能继续作为确定泄漏 bug。仍建议检查 `OmronBleService` 的全局 provider 生命周期和断线重连边界。
- `widget_test.dart``primary == primaryLight` 的错误断言已经改掉,目前测试更大的问题是覆盖面太浅。
### 11.3 后端授权边界需要系统性重查
项目里很多接口已经 `.RequireAuthorization()`,但“已登录”不等于“有权操作这个资源”。建议建立统一规则:
| 资源 | 当前风险 | 应该怎么查 |
| --- | --- | --- |
| AI Conversation | 续写时未绑定 `UserId` | `Conversation.Id == id && Conversation.UserId == currentUserId` |
| Consultation HTTP | POST message 未绑定 `UserId` | `Consultation.Id == id && Consultation.UserId == currentUserId` |
| Consultation Hub | 入组/发消息无服务端权限判断 | Join 和 SendMessage 都查用户或医生是否有权进入该 consultation |
| ExercisePlanItem | checkin 只按 itemId | `ExercisePlanItem.Id == itemId && Item.Plan.UserId == currentUserId` |
| Medication confirm | 部分确认接口未先校验药品归属 | `Medication.Id == id && Medication.UserId == currentUserId` |
| Doctor patient detail | 医生按任意 patient id 查详情 | `User.Id == patientId && User.DoctorId == currentDoctorId` |
| Doctor report review | 医生按任意 report id 审阅 | `Report.User.DoctorId == currentDoctorId` |
| Doctor follow-up update/delete | 医生按任意 followUp id 操作 | `FollowUp.User.DoctorId == currentDoctorId``DoctorName/DoctorId` 绑定 |
建议在后端加一层可复用 helper例如
```csharp
static IQueryable<User> ScopePatientsToDoctor(AppDbContext db, Guid doctorId) =>
db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId);
```
所有医生端接口都从这个 scope 派生,不要每个 endpoint 手写判断。
### 11.4 API 语义和错误码问题
现在不少接口用 HTTP 200 包业务错误码,比如 401/403/404/400 都包成 `{ code, message }`。这种风格可以保留,但要注意两个问题:
- 对认证授权失败HTTP 状态码最好仍返回 401/403方便客户端、网关、日志系统识别。
- 业务错误码需要统一枚举,否则前端只能靠字符串判断。
建议定义统一错误码:
- `0` 成功
- `40001` 参数错误
- `40002` 登录过期
- `40003` 无权限
- `40004` 资源不存在
- `40005` 业务状态冲突
- `50000` 服务端异常
并让 `ExceptionMiddleware` 只处理意外异常,业务错误由 endpoint 明确返回。
### 11.5 数据模型和索引补充
当前 `AppDbContext` 已经配置了不少索引和枚举转换,比旧报告里“完全没有 FK/索引”的描述更好。但仍建议补:
- `RefreshToken(Token)` 唯一或普通索引:刷新 token 查询会频繁发生。
- `RefreshToken(UserId, IsRevoked, ExpiresAt)`:便于清理和会话管理。
- `Report(UserId, CreatedAt)`:报告列表按用户和时间查询。
- `FollowUp(UserId, ScheduledAt)`:随访日历、医生待办会用到。
- `DeviceToken(UserId)`:推送服务上线后需要。
- `Consultation(UserId, CreatedAt)``Consultation(Status, CreatedAt)`:患者历史和医生待办都会用到。
另外,核心关系建议显式配置删除行为,尤其是 User 删除时关联 Consultation、Report、Conversation、MedicationLog 的级联或手动删除策略。
### 11.6 AI Agent 产品能力不一致
现在首页上有多个 agent/胶囊入口,但后端能力不完全一致:
- 饮食 Agent 当前只保留健康档案查询,真正饮食识别在专门图片接口。
- 问诊 Agent 当前只保留健康记录和档案查询,转医生走专门问诊流程。
- 用药/运动 Agent 有创建、查询、确认能力,但确认工具存在所有权校验问题。
- 通用 Agent 如果聚合多个工具,需要明确“哪些动作会写数据,哪些只是查询”。
建议 UI 文案和后端能力统一:
- 欢迎卡片不要暗示当前 agent 能完成它实际上做不到的写操作。
- 会写入健康数据、药品、运动计划、档案的 AI 动作,必须有确认卡片。
- AI 工具调用结果应返回结构化状态,前端不要只靠自然语言判断成功。
### 11.7 前端状态和生命周期问题
前端现在能跑起来,但长期运行会有状态残留风险:
- `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`
- `ChatNotifier` 的 SSE 订阅、流式响应 timer 需要在 provider 销毁、切换 agent、重新发送时取消。
- 饮食页 `_fieldCtrls` 需要 `dispose()`,否则每次识别食物后 controller 累积。
- 多个 `FutureProvider` 没有 `autoDispose`,页面级数据会缓存很久。健康最新值、药品提醒、当前运动计划这类数据建议明确刷新策略。
- 很多页面删除后只本地 `_load()`,没有 `ref.invalidate(...)`,跨页面缓存可能不同步。
### 11.8 UI 深层问题:不是再加渐变,而是建立层级
最近 UI 调整集中在侧边栏、欢迎卡片、饮食页、设置页、个人信息页等。颜色已经比最初丰富,但仍需要注意:
- 颜色角色要固定:蓝色用于主行动/健康状态,橙色用于饮食,紫色用于报告,绿色用于记录/恢复,青色用于设备或运动,浅红用于提醒/风险。
- 欢迎卡片和胶囊按钮的图标必须同语义、同线宽、同背景形状。用户已经多次指出“不只是颜色一样,图标内容也要一样”,这说明视觉一致性比单个渐变更重要。
- 健康仪表盘应优先展示数字、单位、状态、更新时间。图标可以弱化,避免抢数字层级。
- 设置页不应只是按钮列表,应分为账号、安全、通知、设备、隐私、关于。
- 个人信息页应像正式档案:基础信息、医疗信息、健康偏好、绑定医生、账号安全分区展示。
- 功能入口两行三列是合理的,但每个入口不要堆摘要,图标 + 名称 + 必要状态即可。
### 11.9 功能缺口再细化
| 模块 | 当前缺口 | 建议 |
| --- | --- | --- |
| 饮食记录 | 已有识别和保存,但历史记录编辑能力弱 | 支持编辑食物、份量、热量、餐次;支持从历史复制 |
| 报告管理 | 上传后异步 AI 分析,但失败/处理中状态不够细 | 增加 pending/analyzing/failed/retry 状态和轮询刷新 |
| 问诊 | 患者端创建即新会话,历史问诊入口弱 | 增加问诊历史、继续问诊、关闭问诊、评价医生 |
| 用药 | 提醒后台只记录日志,未实际推送 | 接入推送,支持漏服/补服/跳过原因 |
| 运动 | 有计划和打卡,但计划解释和详情不足 | 计划详情页、运动禁忌、完成趋势 |
| 健康日历 | 汇总用药/运动/随访,但和打卡状态联动有限 | 日历上直接展示已完成、未完成、逾期 |
| 医生端 | 已有工作台,但患者范围和流程需加强 | 风险患者排序、未读消息、待审报告、随访待办 |
| 管理员端 | 医生管理已有基础 | 增加操作审计、禁用账号、数据统计 |
### 11.10 更细的整改顺序
第一批必须先修安全:
1. 移除生产 `devCode` 和前端自动填充。
2. 修 SSE 认证,不再解析未验证 JWT。
3. AI conversation 按用户归属查询。
4. Consultation Hub 加鉴权、入组校验、senderType 服务端判定。
5. Consultation HTTP 发消息校验当前用户。
6. Exercise/Medication 的普通接口和 AI 工具都补所有权校验。
7. 医生端详情、报告、随访接口限制到当前医生负责患者。
第二批修接口契约和稳定性:
1. 文件上传返回 `{ id, name, size, url, contentType }`
2. 前端 `uploadFile` 兼容后端 envelope 和 list 返回,失败要提示。
3. 饮食页 controller dispose。
4. Chat/Consultation provider 注册 `ref.onDispose`
5. 后端补上传大小/类型限制。
6. 关闭生产 body 日志。
第三批做 UI 体系:
1. 把颜色、渐变、圆角、阴影、图标背景抽成设计 token。
2. 侧边栏、个人信息、设置、饮食分析页按同一设计语言重做。
3. 首页欢迎卡片和胶囊入口统一图标语义。
4. 健康仪表盘增加更新时间、单位、状态文字。
5. 对中老年用户做字号、对比度、触控面积检查。
第四批补测试:
1. 后端加越权测试:用户 A 不能操作用户 B 的 conversation/consultation/exercise/medication。
2. 加短信验证码生产环境不返回测试。
3. 加 SignalR Hub 入组权限测试。
4. 加文件上传类型/大小测试。
5. Flutter 加饮食保存、问诊连接释放、上传失败 UI 的 widget/provider 测试。
## 12. 总体建议
这个项目最有价值的方向是“围绕患者长期健康数据做 AI 辅助管理”,不是简单堆功能入口。接下来建议把项目重心从“页面多”转为“核心闭环扎实”: 这个项目最有价值的方向是“围绕患者长期健康数据做 AI 辅助管理”,不是简单堆功能入口。接下来建议把项目重心从“页面多”转为“核心闭环扎实”:
@@ -383,4 +562,3 @@ var conversation = await db.Conversations
- 安全和隐私经得起真实使用。 - 安全和隐私经得起真实使用。
UI 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。 UI 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。

Some files were not shown because too many files have changed in this diff Show More