feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化

- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
This commit is contained in:
MingNian
2026-06-20 20:41:42 +08:00
parent c610417e29
commit 4d213b5a44
132 changed files with 6733 additions and 2856 deletions

View File

@@ -0,0 +1,63 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.AI;
public sealed record AiConversationDto(
Guid Id,
string AgentType,
string? Title,
string? Summary,
int MessageCount,
DateTime CreatedAt,
DateTime UpdatedAt);
public sealed record AiConversationMessageDto(
Guid Id,
string Role,
string Content,
string? Intent,
string? MetadataJson,
DateTime CreatedAt);
public sealed record OpenConversationResult(
bool Found,
bool Created,
Guid ConversationId);
public interface IAiConversationService
{
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
}
public interface IAiConversationRepository
{
Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct);
Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct);
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
void Delete(Conversation conversation);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IPatientContextService
{
Task<string> BuildAsync(Guid userId, CancellationToken ct);
}
public sealed record AiConfirmedWriteResult(int Code, object? Data, string? Message);
public interface IAiToolExecutionService
{
Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct);
Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct);
}

View File

@@ -0,0 +1,111 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.AI;
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
{
private readonly IAiConversationRepository _conversations = conversations;
public async Task<OpenConversationResult> OpenAsync(
Guid userId,
Guid? conversationId,
AgentType agentType,
string firstMessage,
CancellationToken ct)
{
if (conversationId.HasValue)
{
var existing = await _conversations.GetOwnedAsync(userId, conversationId.Value, ct);
return existing == null
? new OpenConversationResult(false, false, conversationId.Value)
: new OpenConversationResult(true, false, existing.Id);
}
var conversation = new Conversation
{
Id = Guid.NewGuid(),
UserId = userId,
AgentType = agentType,
Title = firstMessage.Length > 30 ? firstMessage[..30] : firstMessage,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _conversations.AddConversationAsync(conversation, ct);
await _conversations.SaveChangesAsync(ct);
return new OpenConversationResult(true, true, conversation.Id);
}
public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct);
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
{
var messages = await _conversations.GetRecentMessagesAsync(conversationId, limit, ct);
return messages.OrderBy(m => m.CreatedAt).Select(ToMessageDto).ToList();
}
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
{
var conversations = await _conversations.ListAsync(userId, ct);
return conversations.Select(ToDto).ToList();
}
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
if (conversation == null) return [];
var messages = await _conversations.GetMessagesAsync(conversationId, ct);
return messages.Select(ToMessageDto).ToList();
}
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
if (conversation == null) return;
_conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct);
}
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct)
{
var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在");
await _conversations.AddMessageAsync(new ConversationMessage
{
Id = Guid.NewGuid(),
ConversationId = conversationId,
Role = role,
Content = content,
CreatedAt = DateTime.UtcNow,
}, ct);
conversation.MessageCount++;
conversation.UpdatedAt = DateTime.UtcNow;
if (updateSummary)
conversation.Summary = content.Length > 100 ? content[..100] : content;
await _conversations.SaveChangesAsync(ct);
}
private static AiConversationDto ToDto(Conversation conversation) => new(
conversation.Id,
conversation.AgentType.ToString(),
conversation.Title,
conversation.Summary,
conversation.MessageCount,
conversation.CreatedAt,
conversation.UpdatedAt);
private static AiConversationMessageDto ToMessageDto(ConversationMessage message) => new(
message.Id,
message.Role.ToString(),
message.Content,
message.Intent,
message.MetadataJson,
message.CreatedAt);
}

View File

@@ -0,0 +1,15 @@
namespace Health.Application.AI;
public sealed record PendingAiWriteCommand(
Guid Id,
Guid UserId,
string ToolName,
string Arguments,
DateTime ExpiresAt);
public interface IAiWriteConfirmationStore
{
Task<PendingAiWriteCommand> CreateAsync(Guid userId, string toolName, string arguments, TimeSpan lifetime, CancellationToken ct);
Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct);
Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct);
}

View File

@@ -0,0 +1,65 @@
using System.Text;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
namespace Health.Application.AI;
public sealed class PatientContextService(
IHealthArchiveService archives,
IHealthRecordService healthRecords,
IMedicationService medications) : IPatientContextService
{
private readonly IHealthArchiveService _archives = archives;
private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IMedicationService _medications = medications;
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetAsync(userId, ct);
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
var activeMedications = await _medications.ListActiveAsync(userId, ct);
var sb = new StringBuilder();
if (archive != null)
{
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}");
if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}");
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
}
if (activeMedications.Count > 0)
{
sb.AppendLine("当前用药:");
foreach (var medication in activeMedications.Take(20))
{
var times = medication.TimeOfDay.Count > 0
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
: "未设置时间";
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
}
}
if (recentRecords.Count > 0)
{
sb.AppendLine("近期健康数据:");
foreach (var record in recentRecords)
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})");
}
return sb.ToString();
}
private static string RecordValue(HealthRecordDto record) => record.Type switch
{
"BloodPressure" => $"{record.Systolic}/{record.Diastolic}",
"HeartRate" => $"{record.Value}次/分",
"Glucose" => $"{record.Value}",
"SpO2" => $"{record.Value}%",
"Weight" => $"{record.Value}kg",
_ => "—"
};
}

View File

@@ -0,0 +1,15 @@
namespace Health.Application.Admin;
public sealed record AdminResult(int Code, object? Data, string? Message = null);
public sealed record AddDoctorCommand(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
public sealed record UpdateDoctorCommand(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);
public interface IAdminService
{
Task<AdminResult> ListDoctorsAsync(CancellationToken ct);
Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct);
Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct);
Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct);
Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct);
Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct);
}

View File

@@ -0,0 +1,13 @@
namespace Health.Application.Auth;
public sealed record AuthResult(int Code, object? Data, string? Message = null);
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
public interface IAuthService
{
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
Task LogoutAsync(string refreshToken, CancellationToken ct);
}

View File

@@ -0,0 +1,19 @@
using Health.Domain.Entities;
namespace Health.Application.Calendars;
public sealed record CalendarDataSnapshot(
IReadOnlyList<Medication> Medications,
IReadOnlyList<ExercisePlan> ExercisePlans,
IReadOnlyList<FollowUp> FollowUps);
public interface ICalendarService
{
Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct);
Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct);
}
public interface ICalendarRepository
{
Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct);
}

View File

@@ -0,0 +1,92 @@
namespace Health.Application.Calendars;
public sealed class CalendarService(ICalendarRepository calendar) : ICalendarService
{
private readonly ICalendarRepository _calendar = calendar;
public async Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct)
{
var start = new DateOnly(year, month, 1);
var end = start.AddMonths(1);
var snapshot = await _calendar.GetSnapshotAsync(userId, start, end, ct);
var result = new List<object>();
for (var date = start; date < end; date = date.AddDays(1))
{
var entries = BuildEntries(snapshot, date);
if (entries.Count == 0) continue;
result.Add(new
{
date = date.ToString("yyyy-MM-dd"),
events = entries.Select(entry => entry.Type).Distinct().ToList(),
details = entries.Select(entry => entry.Value).ToList()
});
}
return result;
}
public async Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct)
{
var snapshot = await _calendar.GetSnapshotAsync(userId, date, date.AddDays(1), ct);
var medications = snapshot.Medications
.Where(m => IsMedicationActiveOn(m, date))
.Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() })
.ToList();
var exercises = snapshot.ExercisePlans
.SelectMany(p => p.Items)
.Where(i => i.ScheduledDate == date && !i.IsRestDay)
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
.ToList();
var followUps = snapshot.FollowUps
.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
.ToList();
return new { medications, exercises, followUps };
}
private static List<CalendarEntry> BuildEntries(CalendarDataSnapshot snapshot, DateOnly date)
{
var entries = new List<CalendarEntry>();
foreach (var medication in snapshot.Medications.Where(m => IsMedicationActiveOn(m, date)))
{
entries.Add(new CalendarEntry("medication", new
{
type = "medication",
name = medication.Name,
dosage = medication.Dosage,
timeOfDay = medication.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList()
}));
}
foreach (var plan in snapshot.ExercisePlans)
{
foreach (var item in plan.Items.Where(i => i.ScheduledDate == date && !i.IsRestDay))
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
}
foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date))
{
entries.Add(new CalendarEntry("followup", new
{
type = "followup",
title = followUp.Title,
doctorName = followUp.DoctorName,
department = followUp.Department,
status = followUp.Status.ToString()
}));
}
return entries;
}
private static bool IsMedicationActiveOn(Health.Domain.Entities.Medication medication, DateOnly date) =>
medication.IsActive
&& medication.TimeOfDay.Count > 0
&& (medication.StartDate == null || medication.StartDate <= date)
&& (medication.EndDate == null || medication.EndDate >= date);
private sealed record CalendarEntry(string Type, object Value);
}

View File

@@ -0,0 +1,51 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Diets;
public sealed record DietFoodItemInput(
string Name,
string? Portion,
int? Calories,
int SortOrder);
public sealed record DietRecordCreateRequest(
MealType MealType,
int? TotalCalories,
int? HealthScore,
DateOnly RecordedAt,
IReadOnlyList<DietFoodItemInput> FoodItems);
public sealed record DietRecordPatchRequest(
int? TotalCalories,
int? HealthScore);
public sealed record DietFoodItemDto(
string Name,
string? Portion,
int? Calories);
public sealed record DietRecordDto(
Guid Id,
string MealType,
int? TotalCalories,
int? HealthScore,
DateOnly RecordedAt,
IReadOnlyList<DietFoodItemDto> FoodItems);
public interface IDietService
{
Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct);
}
public interface IDietRepository
{
Task<IReadOnlyList<DietRecord>> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct);
Task<DietRecord?> GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct);
Task AddAsync(DietRecord record, CancellationToken ct);
void Delete(DietRecord record);
Task SaveChangesAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,27 @@
namespace Health.Application.Diets;
public sealed record DietImageUploadFile(string FileName, long Length, Stream Content);
public sealed record DietImageAnalysisJob(Guid Id, IReadOnlyList<string> FilePaths);
public sealed record DietImageAnalysisResult(bool Success, int Code, string? Data, string? Message);
public interface IDietImageAnalysisCoordinator
{
Task<DietImageAnalysisResult> AnalyzeAsync(IReadOnlyList<DietImageUploadFile> files, CancellationToken ct);
}
public interface IDietImageAnalysisQueue
{
Task<Guid> EnqueueAsync(IReadOnlyList<string> filePaths, CancellationToken ct);
Task RecoverStaleAsync(CancellationToken ct);
Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct);
Task<DietImageAnalysisResult> WaitForResultAsync(Guid jobId, CancellationToken ct);
Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct);
Task<bool> RetryAsync(Guid jobId, string error, CancellationToken ct);
}
public interface IDietImageAnalyzer
{
Task<DietImageAnalysisResult> AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct);
}

View File

@@ -0,0 +1,77 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Diets;
public sealed class DietService(IDietRepository diets) : IDietService
{
private readonly IDietRepository _diets = diets;
public async Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct)
{
var recordedAt = DateOnly.TryParse(date, out var d) ? d : (DateOnly?)null;
var parsedMealType = Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt) ? mt : (MealType?)null;
var records = await _diets.ListAsync(userId, recordedAt, parsedMealType, ct);
return records.Select(ToDto).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct)
{
var record = new DietRecord
{
Id = Guid.NewGuid(),
UserId = userId,
MealType = request.MealType,
TotalCalories = request.TotalCalories,
HealthScore = request.HealthScore,
RecordedAt = request.RecordedAt,
CreatedAt = DateTime.UtcNow,
};
foreach (var item in request.FoodItems)
{
record.FoodItems.Add(new DietFoodItem
{
Id = Guid.NewGuid(),
Name = item.Name,
Portion = item.Portion,
Calories = item.Calories,
SortOrder = item.SortOrder,
});
}
await _diets.AddAsync(record, ct);
await _diets.SaveChangesAsync(ct);
return record.Id;
}
public async Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct)
{
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
if (record == null) return false;
_diets.Delete(record);
await _diets.SaveChangesAsync(ct);
return true;
}
public async Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct)
{
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
if (record == null) return false;
if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value;
if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value;
await _diets.SaveChangesAsync(ct);
return true;
}
private static DietRecordDto ToDto(DietRecord record) => new(
record.Id,
record.MealType.ToString(),
record.TotalCalories,
record.HealthScore,
record.RecordedAt,
record.FoodItems.OrderBy(f => f.SortOrder).Select(f => new DietFoodItemDto(f.Name, f.Portion, f.Calories)).ToList());
}

View File

@@ -0,0 +1,74 @@
using Health.Domain.Entities;
namespace Health.Application.Exercises;
public sealed record ExercisePlanItemInput(
DateOnly ScheduledDate,
string? ExerciseType,
int DurationMinutes,
bool IsRestDay);
public sealed record ExercisePlanCreateRequest(
DateOnly StartDate,
DateOnly EndDate,
string? ExerciseType,
int DurationMinutes,
TimeOnly? ReminderTime = null);
public sealed record ExercisePlanItemDto(
Guid Id,
DateOnly ScheduledDate,
string ExerciseType,
int DurationMinutes,
bool IsCompleted,
DateTime? CompletedAt,
bool IsRestDay);
public sealed record ExercisePlanDto(
Guid Id,
DateOnly StartDate,
DateOnly EndDate,
TimeOnly ReminderTime,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<ExercisePlanItemDto> Items);
public sealed record ExercisePlanSummaryDto(
Guid Id,
DateOnly StartDate,
DateOnly EndDate,
TimeOnly ReminderTime,
DateTime CreatedAt,
int TotalDays,
int CompletedDays,
IReadOnlyList<ExercisePlanItemDto> Items);
public interface IExerciseService
{
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct);
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
}
public interface IExerciseRepository
{
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
Task AddAsync(ExercisePlan plan, CancellationToken ct);
void Delete(ExercisePlan plan);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IExerciseReminderProducer
{
Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct);
}

View File

@@ -0,0 +1,132 @@
using Health.Domain.Entities;
namespace Health.Application.Exercises;
public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService
{
private static readonly TimeOnly DefaultReminderTime = new(19, 0);
private readonly IExerciseRepository _exercises = exercises;
public async Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct)
{
var today = BeijingToday();
var plans = await _exercises.ListActiveOnAsync(userId, today, ct);
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.ScheduledDate == today));
return plan == null ? null : ToDto(plan);
}
public async Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct)
{
var startDate = request.StartDate;
var endDate = request.EndDate < startDate ? startDate : request.EndDate;
if (endDate.DayNumber - startDate.DayNumber > 365)
endDate = startDate.AddDays(365);
var exerciseType = string.IsNullOrWhiteSpace(request.ExerciseType) ? "运动" : request.ExerciseType.Trim();
var duration = request.DurationMinutes > 0 ? request.DurationMinutes : 30;
var plan = NewPlan(userId, startDate, endDate, request.ReminderTime);
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
DurationMinutes = duration, IsRestDay = false,
});
}
await SaveNewAsync(plan, ct);
return plan.Id;
}
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
{
var normalizedEnd = endDate < startDate ? startDate : endDate;
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime);
foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate,
ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(),
DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30,
IsRestDay = item.IsRestDay,
});
}
await SaveNewAsync(plan, ct);
return plan.Id;
}
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
{
var plans = await _exercises.ListAsync(userId, 20, ct);
return plans.Select(p => new ExercisePlanSummaryDto(
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt,
p.Items.Count, p.Items.Count(i => i.IsCompleted),
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList();
}
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
{
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
return plan == null ? null : ToDto(plan);
}
public async Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct)
{
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
if (plan == null) return false;
_exercises.Delete(plan);
await _exercises.SaveChangesAsync(ct);
return true;
}
public async Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return null;
item.IsCompleted = !item.IsCompleted;
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
item.Plan.UpdatedAt = DateTime.UtcNow;
await _exercises.SaveChangesAsync(ct);
return item.IsCompleted;
}
public async Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return false;
item.IsCompleted = true;
item.CompletedAt = DateTime.UtcNow;
item.Plan.UpdatedAt = DateTime.UtcNow;
await _exercises.SaveChangesAsync(ct);
return true;
}
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
{
var plan = await _exercises.GetLatestAsync(userId, ct);
return plan == null ? null : ToDto(plan);
}
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
{
await _exercises.AddAsync(plan, ct);
await _exercises.SaveChangesAsync(ct);
}
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
{
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
ReminderTime = reminderTime ?? DefaultReminderTime,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
item.IsCompleted, item.CompletedAt, item.IsRestDay);
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
}

View File

@@ -0,0 +1,39 @@
using Health.Domain.Entities;
namespace Health.Application.HealthArchives;
public sealed record HealthArchiveDto(
Guid Id,
Guid UserId,
string? Diagnosis,
string? SurgeryType,
DateOnly? SurgeryDate,
IReadOnlyList<string> Allergies,
IReadOnlyList<string> DietRestrictions,
IReadOnlyList<string> ChronicDiseases,
string? FamilyHistory,
DateTime UpdatedAt);
public sealed record HealthArchiveUpdateRequest(
string? Diagnosis,
string? SurgeryType,
DateOnly? SurgeryDate,
IReadOnlyList<string>? Allergies,
IReadOnlyList<string>? DietRestrictions,
IReadOnlyList<string>? ChronicDiseases,
string? FamilyHistory);
public interface IHealthArchiveService
{
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct);
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
}
public interface IHealthArchiveRepository
{
Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct);
Task AddAsync(HealthArchive archive, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,111 @@
using Health.Domain.Entities;
namespace Health.Application.HealthArchives;
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
{
private readonly IHealthArchiveRepository _archives = archives;
public async Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetByUserIdAsync(userId, ct);
return archive == null ? null : ToDto(archive);
}
public async Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct)
{
var archive = await GetOrCreateEntityAsync(userId, ct);
await _archives.SaveChangesAsync(ct);
return ToDto(archive);
}
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
{
var archive = await GetOrCreateEntityAsync(userId, ct);
Apply(archive, request);
archive.UpdatedAt = DateTime.UtcNow;
await _archives.SaveChangesAsync(ct);
return ToDto(archive);
}
public async Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct)
{
if (action == "query")
{
var archive = await GetAsync(userId, ct);
return archive == null ? new { found = false } : ToAiResult(archive);
}
var current = await GetOrCreateEntityAsync(userId, ct);
var scopedRequest = action switch
{
"update_diagnosis" => request with
{
SurgeryType = null, SurgeryDate = null, Allergies = null,
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null
},
"update_surgery" => request with
{
Diagnosis = null, Allergies = null, DietRestrictions = null,
ChronicDiseases = null, FamilyHistory = null
},
"update_allergies" => new HealthArchiveUpdateRequest(null, null, null, request.Allergies, null, null, null),
"update_chronic_diseases" => new HealthArchiveUpdateRequest(null, null, null, null, null, request.ChronicDiseases, null),
"update_diet_restrictions" => new HealthArchiveUpdateRequest(null, null, null, null, request.DietRestrictions, null, null),
_ => null
};
if (scopedRequest == null)
return new { success = false, message = $"未知档案操作: {action}" };
Apply(current, scopedRequest);
current.UpdatedAt = DateTime.UtcNow;
await _archives.SaveChangesAsync(ct);
return new { success = true };
}
private async Task<HealthArchive> GetOrCreateEntityAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetByUserIdAsync(userId, ct);
if (archive != null) return archive;
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId, UpdatedAt = DateTime.UtcNow };
await _archives.AddAsync(archive, ct);
return archive;
}
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
{
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
if (request.SurgeryType != null) archive.SurgeryType = request.SurgeryType;
if (request.SurgeryDate.HasValue) archive.SurgeryDate = request.SurgeryDate.Value;
if (request.Allergies != null) archive.Allergies = request.Allergies.ToList();
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
}
private static object ToAiResult(HealthArchiveDto archive) => new
{
found = true,
archive.Diagnosis,
archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Allergies,
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
};
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
archive.Id,
archive.UserId,
archive.Diagnosis,
archive.SurgeryType,
archive.SurgeryDate,
archive.Allergies,
archive.DietRestrictions,
archive.ChronicDiseases,
archive.FamilyHistory,
archive.UpdatedAt);
}

View File

@@ -0,0 +1,45 @@
using Health.Domain.Enums;
using Health.Domain.Entities;
namespace Health.Application.HealthRecords;
public sealed record HealthRecordUpsertRequest(
HealthMetricType Type,
int? Systolic,
int? Diastolic,
decimal? Value,
string? Unit,
HealthRecordSource Source,
DateTime? RecordedAt);
public sealed record HealthRecordDto(
Guid Id,
string Type,
int? Systolic,
int? Diastolic,
decimal? Value,
string? Unit,
string Source,
bool IsAbnormal,
DateTime RecordedAt);
public interface IHealthRecordService
{
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct);
}
public interface IHealthRecordRepository
{
Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct);
Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct);
Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct);
Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct);
Task AddAsync(HealthRecord record, CancellationToken ct);
void Delete(HealthRecord record);
Task SaveChangesAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,16 @@
using Health.Domain.Enums;
namespace Health.Application.HealthRecords;
public static class HealthRecordRules
{
public static bool CheckAbnormal(HealthRecordUpsertRequest request) => request.Type switch
{
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
HealthMetricType.SpO2 => request.Value <= 94,
HealthMetricType.Weight => false,
_ => false
};
}

View File

@@ -0,0 +1,116 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.HealthRecords;
public sealed class HealthRecordService(IHealthRecordRepository records) : IHealthRecordService
{
private readonly IHealthRecordRepository _records = records;
public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct)
{
HealthMetricType? metricType = null;
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var parsed))
metricType = parsed;
var recordedAfter = days.HasValue ? DateTime.UtcNow.AddDays(-days.Value) : (DateTime?)null;
var result = await _records.ListAsync(userId, metricType, recordedAfter, 100, ct);
return result.Select(ToDto).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
{
var record = new HealthRecord
{
Id = Guid.NewGuid(),
UserId = userId,
MetricType = request.Type,
Systolic = request.Systolic,
Diastolic = request.Diastolic,
Value = request.Value,
Unit = request.Unit,
Source = request.Source,
RecordedAt = request.RecordedAt ?? DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
};
await _records.AddAsync(record, ct);
await _records.SaveChangesAsync(ct);
return record.Id;
}
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
{
var record = await _records.GetOwnedAsync(userId, id, ct);
if (record == null) return false;
record.MetricType = request.Type;
record.Systolic = request.Systolic;
record.Diastolic = request.Diastolic;
record.Value = request.Value;
record.Unit = request.Unit;
record.Source = request.Source;
record.RecordedAt = request.RecordedAt ?? record.RecordedAt;
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
await _records.SaveChangesAsync(ct);
return true;
}
public async Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct)
{
var record = await _records.GetOwnedAsync(userId, id, ct);
if (record == null) return false;
_records.Delete(record);
await _records.SaveChangesAsync(ct);
return true;
}
public async Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct)
{
var types = new[]
{
HealthMetricType.BloodPressure,
HealthMetricType.HeartRate,
HealthMetricType.Glucose,
HealthMetricType.SpO2,
HealthMetricType.Weight
};
var result = new Dictionary<string, object?>();
foreach (var type in types)
{
var latest = await _records.GetLatestByTypeAsync(userId, type, ct);
result[type.ToString()] = latest == null ? null : new
{
latest.Systolic,
latest.Diastolic,
latest.Value,
latest.Unit,
latest.RecordedAt
};
}
return result;
}
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
{
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 };
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
}
public static HealthRecordDto ToDto(HealthRecord record) => new(
record.Id,
record.MetricType.ToString(),
record.Systolic,
record.Diastolic,
record.Value,
record.Unit,
record.Source.ToString(),
record.IsAbnormal,
record.RecordedAt);
}

View File

@@ -0,0 +1,16 @@
namespace Health.Application.Maintenance;
public sealed record MaintenanceCleanupResult(
int Conversations,
int VerificationCodes,
int BackgroundTasks);
public interface IMaintenanceService
{
Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct);
}
public interface IMaintenanceRepository
{
Task<MaintenanceCleanupResult> CleanupAsync(DateTime conversationCutoff, DateTime taskCutoff, DateTime utcNow, CancellationToken ct);
}

View File

@@ -0,0 +1,9 @@
namespace Health.Application.Maintenance;
public sealed class MaintenanceService(IMaintenanceRepository repository) : IMaintenanceService
{
private readonly IMaintenanceRepository _repository = repository;
public Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct) =>
_repository.CleanupAsync(utcNow.AddDays(-30), utcNow.AddDays(-30), utcNow, ct);
}

View File

@@ -0,0 +1,106 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Medications;
public sealed record MedicationUpsertRequest(
string Name,
string? Dosage,
MedicationFrequency Frequency,
IReadOnlyList<TimeOnly> TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
MedicationSource Source,
string? Notes);
public sealed record MedicationPatchRequest(
string? Name,
string? Dosage,
MedicationFrequency? Frequency,
IReadOnlyList<TimeOnly>? TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
string? Notes);
public sealed record MedicationDto(
Guid Id,
string Name,
string? Dosage,
string Frequency,
IReadOnlyList<TimeOnly> TimeOfDay,
DateOnly? StartDate,
DateOnly? EndDate,
bool IsActive,
string? Notes,
string Source,
DateTime CreatedAt,
DateTime UpdatedAt,
bool TodayTaken);
public sealed record MedicationReminderDto(
Guid Id,
string Name,
string? Dosage,
string ScheduledTime,
string Status);
public sealed record MedicationReminderTask(
Guid TaskId,
Guid UserId,
Guid MedicationId,
string Name,
string? Dosage,
DateOnly ScheduledDate,
TimeOnly ScheduledTime);
public interface IMedicationService
{
Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct);
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
}
public interface IMedicationRepository
{
Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct);
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task AddMedicationAsync(Medication medication, CancellationToken ct);
Task AddLogAsync(MedicationLog log, CancellationToken ct);
void DeleteMedication(Medication medication);
void DeleteLog(MedicationLog log);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IMedicationReminderScanner
{
Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct);
}
public interface IMedicationReminderQueue
{
Task<bool> EnqueueAsync(MedicationReminderTask task, CancellationToken ct);
Task RecoverStaleAsync(CancellationToken ct);
Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct);
Task CompleteAsync(Guid taskId, CancellationToken ct);
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
}
public interface IMedicationReminderDispatcher
{
Task DispatchAsync(MedicationReminderTask task, CancellationToken ct);
}

View File

@@ -0,0 +1,64 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Medications;
public sealed class MedicationReminderScanner(IMedicationRepository medications) : IMedicationReminderScanner
{
private readonly IMedicationRepository _medications = medications;
public async Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct)
{
var beijingNow = utcNow.AddHours(8);
var date = DateOnly.FromDateTime(beijingNow);
var time = TimeOnly.FromDateTime(beijingNow);
var windowStart = time.AddMinutes(-5);
var todayStartUtc = beijingNow.Date.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var active = await _medications.ListActiveForReminderScanAsync(ct);
var tasks = new List<MedicationReminderTask>();
foreach (var medication in active)
{
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue;
if (await _medications.HasTakenInWindowAsync(medication.UserId, medication.Id, todayStartUtc, todayEndUtc, ct)) continue;
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
{
tasks.Add(new MedicationReminderTask(
Guid.NewGuid(),
medication.UserId,
medication.Id,
medication.Name,
medication.Dosage,
date,
scheduledTime));
}
}
return tasks;
}
private static bool IsActiveOn(Medication medication, DateOnly date) =>
(medication.StartDate == null || medication.StartDate <= date)
&& (medication.EndDate == null || medication.EndDate >= date);
private static bool IsInWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
end >= start ? value >= start && value <= end : value >= start || value <= end;
private static bool GetDosesForToday(Medication medication, DateOnly today)
{
if (medication.Frequency is MedicationFrequency.Daily
or MedicationFrequency.TwiceDaily
or MedicationFrequency.ThreeTimesDaily
or MedicationFrequency.AsNeeded)
return true;
var startDate = medication.StartDate ?? today;
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
if (medication.Frequency == MedicationFrequency.Weekly)
return today.DayOfWeek == startDate.DayOfWeek;
return true;
}
}

View File

@@ -0,0 +1,217 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Medications;
public sealed class MedicationService(IMedicationRepository medications) : IMedicationService
{
private readonly IMedicationRepository _medications = medications;
public async Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct)
{
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var meds = await _medications.ListAsync(userId, filter, ct);
return meds.Select(m => ToDto(m, HasTakenToday(m, todayStartUtc, todayEndUtc))).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct)
{
var med = new Medication
{
Id = Guid.NewGuid(),
UserId = userId,
Name = request.Name,
Dosage = request.Dosage,
Frequency = request.Frequency,
TimeOfDay = request.TimeOfDay.Count > 0 ? request.TimeOfDay.ToList() : [new TimeOnly(8, 0)],
StartDate = request.StartDate,
EndDate = request.EndDate,
Source = request.Source,
Notes = request.Notes,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _medications.AddMedicationAsync(med, ct);
await _medications.SaveChangesAsync(ct);
return med.Id;
}
public async Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct)
{
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (med == null) return false;
if (!string.IsNullOrWhiteSpace(request.Name)) med.Name = request.Name;
if (request.Dosage != null) med.Dosage = request.Dosage;
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
if (request.TimeOfDay != null) med.TimeOfDay = request.TimeOfDay.ToList();
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
if (request.Notes != null) med.Notes = request.Notes;
med.UpdatedAt = DateTime.UtcNow;
await _medications.SaveChangesAsync(ct);
return true;
}
public async Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct)
{
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
if (med == null) return false;
_medications.DeleteMedication(med);
await _medications.SaveChangesAsync(ct);
return true;
}
public async Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct)
{
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct);
if (existing != null)
{
_medications.DeleteLog(existing);
await _medications.SaveChangesAsync(ct);
return false;
}
await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct);
return true;
}
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
{
var beijingNow = DateTime.UtcNow.AddHours(8);
var now = TimeOnly.FromDateTime(beijingNow);
var today = DateOnly.FromDateTime(beijingNow);
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var meds = await _medications.ListActiveForRemindersAsync(userId, today, ct);
var logs = await _medications.ListLogsInWindowAsync(userId, todayStartUtc, todayEndUtc, ct);
var reminders = new List<MedicationReminderDto>();
foreach (var med in meds)
{
if (!GetDosesForToday(med, today)) continue;
foreach (var scheduledTime in med.TimeOfDay)
{
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
string status;
if (log != null)
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
else if (scheduledTime <= now.AddMinutes(-30))
status = "overdue";
else if (scheduledTime <= now.AddHours(3))
status = "upcoming";
else
continue;
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
}
}
return reminders
.OrderBy(r => r.Status == "overdue" ? 0 : r.Status == "upcoming" ? 1 : 2)
.ToList();
}
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
{
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
if (existing) return false;
await AddLogAsync(userId, medicationId, status, scheduledTime, ct);
return true;
}
public async Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct)
{
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
var log = await _medications.GetDoseLogAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
if (log == null) return false;
_medications.DeleteLog(log);
await _medications.SaveChangesAsync(ct);
return true;
}
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) =>
ListAsync(userId, "active", ct);
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct)
{
var result = await ConfirmDoseAsync(
userId,
medicationId,
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
MedicationLogStatus.Taken,
ct);
return result == true;
}
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
{
await _medications.AddLogAsync(new MedicationLog
{
Id = Guid.NewGuid(),
MedicationId = medicationId,
UserId = userId,
Status = status,
ScheduledTime = scheduledTime,
ConfirmedAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
}, ct);
await _medications.SaveChangesAsync(ct);
}
private static MedicationDto ToDto(Medication med, bool todayTaken) => new(
med.Id,
med.Name,
med.Dosage,
med.Frequency.ToString(),
med.TimeOfDay,
med.StartDate,
med.EndDate,
med.IsActive,
med.Notes,
med.Source.ToString(),
med.CreatedAt,
med.UpdatedAt,
todayTaken);
private static bool HasTakenToday(Medication med, DateTime todayStartUtc, DateTime todayEndUtc) =>
med.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken);
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
{
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
var todayStartUtc = beijingToday.AddHours(-8);
return (todayStartUtc, todayStartUtc.AddDays(1));
}
private static bool GetDosesForToday(Medication med, DateOnly today)
{
if (med.Frequency is MedicationFrequency.Daily
or MedicationFrequency.TwiceDaily
or MedicationFrequency.ThreeTimesDaily
or MedicationFrequency.AsNeeded)
return true;
var startDate = med.StartDate ?? today;
if (med.Frequency == MedicationFrequency.EveryOtherDay)
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
if (med.Frequency == MedicationFrequency.Weekly)
return today.DayOfWeek == startDate.DayOfWeek;
return true;
}
}

View File

@@ -0,0 +1,26 @@
namespace Health.Application.Notifications;
public sealed record InAppNotificationDto(
Guid Id,
string Type,
string Title,
string Message,
DateTime CreatedAt);
public interface IInAppNotificationService
{
Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct);
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
}
public interface IInAppNotificationRepository
{
Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct);
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
}
public sealed record InAppNotificationRecord(
Guid Id,
string Type,
string Payload,
DateTime CreatedAt);

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
namespace Health.Application.Notifications;
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
{
private readonly IInAppNotificationRepository _notifications = notifications;
public async Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct)
{
var records = await _notifications.GetPendingAsync(userId, ct);
return records.Select(ToDto).ToList();
}
public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
_notifications.AcknowledgeAsync(userId, notificationId, ct);
private static InAppNotificationDto ToDto(InAppNotificationRecord record)
{
if (record.Type == "MedicationReminder")
{
using var json = JsonDocument.Parse(record.Payload);
var root = json.RootElement;
var name = root.TryGetProperty("Name", out var nameValue) ? nameValue.GetString() : null;
var dosage = root.TryGetProperty("Dosage", out var dosageValue) ? dosageValue.GetString() : null;
var time = root.TryGetProperty("ScheduledTime", out var timeValue) ? timeValue.GetString() : null;
var details = string.Join(" · ", new[] { dosage, time }.Where(x => !string.IsNullOrWhiteSpace(x)));
return new InAppNotificationDto(
record.Id,
record.Type,
"用药提醒",
$"该服用{name ?? ""}{(details.Length > 0 ? $"{details}" : "")}了",
record.CreatedAt);
}
if (record.Type == "ExerciseReminder")
{
using var json = JsonDocument.Parse(record.Payload);
var root = json.RootElement;
var type = root.TryGetProperty("ExerciseType", out var typeValue) ? typeValue.GetString() : "运动";
var minutes = root.TryGetProperty("DurationMinutes", out var minutesValue) ? minutesValue.GetInt32() : 30;
return new InAppNotificationDto(record.Id, record.Type, "运动提醒", $"今天的{type}计划还未完成,目标 {minutes} 分钟", record.CreatedAt);
}
return new InAppNotificationDto(record.Id, record.Type, "健康提醒", "您有一条新的健康提醒", record.CreatedAt);
}
}

View File

@@ -0,0 +1,83 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Reports;
public sealed record ReportUploadFile(
string FileName,
long Length,
Stream Content);
public sealed record ReportDto(
Guid Id,
Guid UserId,
string FileUrl,
string FileType,
string Category,
string Status,
string AiStatus,
string ReviewStatus,
string? Severity,
string? AiSummary,
string? AiIndicators,
string? DoctorComment,
string? DoctorRecommendation,
string? DoctorName,
DateTime? ReviewedAt,
DateTime CreatedAt);
public sealed record ReportUploadResult(
bool Success,
int Code,
string? Message,
ReportDto? Report);
public sealed record ReportAnalysisJob(
Guid TaskId,
Guid ReportId,
string FilePath);
public sealed record StoredReportFile(
string FileUrl,
string FilePath);
public interface IReportService
{
Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct);
Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct);
Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct);
Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct);
Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct);
}
public interface IReportAnalysisQueue
{
Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default);
Task RecoverStaleAsync(CancellationToken ct);
Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct);
Task CompleteAsync(Guid taskId, CancellationToken ct);
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
}
public interface IReportAnalysisService
{
Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct);
}
public interface IReportRepository
{
Task<IReadOnlyList<Report>> ListAsync(Guid userId, CancellationToken ct);
Task<Report?> GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct);
Task<Report?> GetByIdAsync(Guid reportId, CancellationToken ct);
Task AddAsync(Report report, CancellationToken ct);
void Delete(Report report);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IReportFileStorage
{
Task<StoredReportFile> SaveAsync(ReportUploadFile file, string extension, CancellationToken ct);
string GetLocalFilePath(string fileUrl);
bool Exists(string filePath);
void Delete(string filePath);
}

View File

@@ -0,0 +1,122 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.Reports;
public sealed class ReportService(
IReportRepository reports,
IReportFileStorage fileStorage,
IReportAnalysisQueue queue) : IReportService
{
private const long MaxReportImageBytes = 10 * 1024 * 1024;
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".webp"
};
private readonly IReportRepository _reports = reports;
private readonly IReportFileStorage _fileStorage = fileStorage;
private readonly IReportAnalysisQueue _queue = queue;
public async Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct)
{
var result = await _reports.ListAsync(userId, ct);
return result.Select(ToDto).ToList();
}
public async Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct)
{
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
return report == null ? null : ToDto(report);
}
public async Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct)
{
if (file.Length <= 0)
return Fail(400, "未上传文件");
if (file.Length > MaxReportImageBytes)
return Fail(400, "报告图片不能超过 10MB请压缩后重新上传");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return Fail(400, "目前仅支持上传 JPG、PNG、WEBP 格式的报告图片");
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
var report = new Report
{
Id = Guid.NewGuid(),
UserId = userId,
FileUrl = storedFile.FileUrl,
FileType = ReportFileType.Image,
Category = ReportCategory.Other,
Status = ReportStatus.Analyzing,
AiSummary = null,
AiIndicators = null,
CreatedAt = DateTime.UtcNow,
};
await _reports.AddAsync(report, ct);
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, storedFile.FilePath), ct);
return new ReportUploadResult(true, 0, "报告已上传AI 正在分析中", ToDto(report));
}
public async Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct)
{
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
if (report == null) return false;
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
if (_fileStorage.Exists(filePath))
_fileStorage.Delete(filePath);
_reports.Delete(report);
await _reports.SaveChangesAsync(ct);
return true;
}
public async Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct)
{
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
if (report == null) return false;
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
if (!_fileStorage.Exists(filePath)) return false;
report.Status = ReportStatus.Analyzing;
report.AiSummary = null;
report.AiIndicators = null;
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, filePath), ct);
return true;
}
public static ReportDto ToDto(Report report) => new(
report.Id,
report.UserId,
report.FileUrl,
report.FileType.ToString(),
report.Category.ToString(),
report.Status.ToString(),
ToAiStatus(report),
report.Status == ReportStatus.DoctorReviewed ? "Reviewed" : "Pending",
report.Severity,
report.AiSummary,
report.AiIndicators,
report.DoctorComment,
report.DoctorRecommendation,
report.DoctorName,
report.ReviewedAt,
report.CreatedAt);
private static string ToAiStatus(Report report) => report.Status switch
{
ReportStatus.Analyzing => "Analyzing",
ReportStatus.AnalysisFailed => "Failed",
_ when string.IsNullOrWhiteSpace(report.AiSummary) => "Analyzing",
_ => "Succeeded"
};
private static ReportUploadResult Fail(int code, string message) =>
new(false, code, message, null);
}

View File

@@ -0,0 +1,31 @@
using Health.Domain.Entities;
namespace Health.Application.Users;
public sealed record UserProfileDto(
Guid Id,
string Phone,
string Role,
string? Name,
string? Gender,
string? BirthDate,
string? AvatarUrl);
public sealed record UserProfileUpdateRequest(
string? Name,
string? Gender,
DateOnly? BirthDate);
public interface IUserService
{
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct);
Task DeleteAccountAsync(Guid userId, CancellationToken ct);
}
public interface IUserRepository
{
Task<User?> GetAsync(Guid userId, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
}

View File

@@ -0,0 +1,35 @@
namespace Health.Application.Users;
public sealed class UserService(IUserRepository users) : IUserService
{
private readonly IUserRepository _users = users;
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
{
var user = await _users.GetAsync(userId, ct);
return user == null ? null : new UserProfileDto(
user.Id,
user.Phone,
user.Role,
user.Name,
user.Gender,
user.BirthDate?.ToString("yyyy-MM-dd"),
user.AvatarUrl);
}
public async Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct)
{
var user = await _users.GetAsync(userId, ct);
if (user == null) return false;
if (request.Name != null) user.Name = request.Name;
if (request.Gender != null) user.Gender = request.Gender;
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
user.UpdatedAt = DateTime.UtcNow;
await _users.SaveChangesAsync(ct);
return true;
}
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
_users.DeleteAccountDataAsync(userId, ct);
}

View File

@@ -7,7 +7,9 @@ public sealed class ExercisePlan
{
public Guid Id { 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 UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -22,7 +24,7 @@ public sealed class ExercisePlanItem
{
public Guid Id { 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 int DurationMinutes { get; set; }
public bool IsCompleted { get; set; }

View File

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

View File

@@ -1,3 +1,6 @@
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
@@ -21,38 +24,33 @@ public static class CommonAgentHandler
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
{
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args),
"check_archive" => await ExecuteCheckArchive(db, userId),
"query_health_records" => await ExecuteQueryHealthRecords(healthRecords, userId, args, ct),
"check_archive" => await ExecuteCheckArchive(archives, userId, ct),
_ => 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 days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
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();
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
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 };
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 archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId);
if (archive == null)
{
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
db.HealthArchives.Add(archive);
}
switch (action)
{
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 };
var request = new HealthArchiveUpdateRequest(
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
ReadStringArray(args, "allergies"),
ReadStringArray(args, "diet_restrictions"),
ReadStringArray(args, "chronic_diseases"),
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
return archives.ExecuteAiActionAsync(userId, action, request, ct);
}
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 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 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;
/// <summary>
/// 运动计划 Agent 工具处理器
/// </summary>
public static class ExerciseAgentHandler
{
public static readonly ToolDefinition ManageExerciseTool = new()
{
Function = new()
{
Name = "manage_exercise", Description = "运动计划管理(创建/查询/打卡)",
Parameters = new { type = "object", properties = new {
action = new { type = "string", description = "create/query/checkin" },
week_start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
items = new { type = "array", description = "每天的运动项目", items = new { type = "object", properties = new {
day_of_week = new { type = "integer", description = "0=周日 1=周一...6=周六" },
exercise_type = new { type = "string", description = "运动类型,如散步/慢跑/游泳/太极" },
duration_minutes = new { type = "integer", description = "时长(分钟)" },
is_rest_day = new { type = "boolean", description = "是否休息日" }
}}}
}, required = new[] { "action" } }
}
Name = "manage_exercise",
Description = "运动计划管理(创建/查询/打卡)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/checkin" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" },
duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
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 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),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
switch (action)
{
case "create":
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
if (args.TryGetProperty("items", out var items))
{
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 }) };
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
? 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)
: 7;
var exerciseType = args.TryGetProperty("exercise_type", out var typeValue) ? typeValue.GetString() : "散步";
var minutes = args.TryGetProperty("duration_minutes", out var minutesValue) && minutesValue.TryGetInt32(out var parsedMinutes) ? parsedMinutes : 30;
var reminder = args.TryGetProperty("reminder_time", out var reminderValue) && TimeOnly.TryParse(reminderValue.GetString(), out var parsedReminder)
? parsedReminder
: new TimeOnly(19, 0);
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(
startDate, startDate.AddDays(days - 1), exerciseType, minutes, reminder), ct);
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 };
}
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;
/// <summary>
@@ -16,16 +18,50 @@ public static class HealthDataAgentHandler
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
{
"record_health_data" => await ExecuteRecordHealthData(db, userId, args),
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
"record_health_data" => healthRecords == null
? await ExecuteRecordHealthData(db, userId, args)
: await ExecuteRecordHealthData(healthRecords, userId, args),
_ => 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)
{
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 };
}
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;
/// <summary>
/// 药管家 Agent 工具处理器——用药管理
/// 药管家 Agent 工具处理器
/// </summary>
public static class MedicationAgentHandler
{
@@ -9,31 +11,105 @@ public static class MedicationAgentHandler
{
Function = new()
{
Name = "manage_medication", Description = "用药管理(创建/查询/确认服药)",
Parameters = new { type = "object", properties = new {
action = new { type = "string", description = "create/query/confirm" },
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" } }
Name = "manage_medication",
Description = "用药管理(创建/查询/确认服药)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/confirm" },
medication_id = new { type = "string", description = "药品 ID确认服药时使用" },
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 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
{
"manage_medication" => await ExecuteManageMedication(db, userId, args),
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId),
"manage_medication" => medications != null
? await ExecuteManageMedication(medications, userId, args, ct)
: await ExecuteManageMedication(db, userId, args),
_ => 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)
{
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 dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
var frequency = Enum.TryParse<MedicationFrequency>(frequencyStr, out var fr) ? fr : MedicationFrequency.Daily;
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 frequency = ReadFrequency(args);
var times = ReadTimes(args);
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
{
Id = Guid.NewGuid(), UserId = userId,
Name = name, Dosage = dosage, Frequency = frequency,
Id = Guid.NewGuid(),
UserId = userId,
Name = name,
Dosage = dosage,
Frequency = frequency,
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
Source = MedicationSource.AiEntry, IsActive = true,
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null,
Source = MedicationSource.AiEntry,
IsActive = true,
StartDate = startDate,
EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null,
};
db.Medications.Add(med);
await db.SaveChangesAsync();
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new {
success = true, medication_id = med.Id,
name = med.Name, dosage = med.Dosage,
frequency = med.Frequency.ToString(), time = timeLabels,
return new
{
success = true,
medication_id = med.Id,
name = med.Name,
dosage = med.Dosage,
frequency = med.Frequency.ToString(),
time = timeLabels,
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
duration_days = durationDays,
};
@@ -97,12 +171,47 @@ public static class MedicationAgentHandler
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 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
{
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
Id = Guid.NewGuid(),
MedicationId = medId,
UserId = userId,
Status = MedicationLogStatus.Taken,
ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
ConfirmedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
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 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)
{
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
@@ -28,13 +19,13 @@ public static class ReportAgentHandler
return new { success = false, message = "缺少报告图片" };
var prompt = """
JSON格式返回
JSON格式返回
{
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
"indicators": [
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
],
"summary": "初步分析摘要",
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
"needsDoctorReview": true/false
}
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>
/// 获取指定 Agent 的 System Prompt
/// </summary>
public string GetSystemPrompt(AgentType agentType) => agentType switch
public string GetSystemPrompt(AgentType agentType)
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
};
var prompt = agentType switch
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
};
return $"{prompt}\n\n{MedicalBoundaryRules}";
}
private const string MedicalBoundaryRules = """
- AI
- //
-
-
- 使
- AI
""";
private const string DefaultPrompt = """
AI健康管家
@@ -38,15 +53,15 @@ public sealed class PromptManager
""";
private const string ConsultationPrompt = """
AI
1.
2. 2-3
3.
4.
5. >160/100>120<50
6. "以上为AI分析具体请咨询医生"
5. >160/100>120<50
6.
7.
""";
@@ -55,13 +70,13 @@ public sealed class PromptManager
1. ////
2. + record_health_data
2. + record_health_data
3. "血压120/80血糖6.2血氧97" record_health_data
4. "早上血压120下午血压130"recorded_at
4. "早上血压120下午血压130"recorded_at
5. "120""收缩压还是血糖?"
6.
7.
8. record_health_data
8. record_health_data
- 90-139 mmHg 60-89 mmHg
@@ -86,24 +101,24 @@ public sealed class PromptManager
""";
private const string MedicationPrompt = """
1. manage_medication(action="query")
2. ///
3. "早饭后"
4.
4. manage_medication(action="create")
5.
6.
6.
""";
private const string ReportPrompt = """
1.
2. //
3.
3.
4. "AI预解读待医生确认"
5. /CT"需医生人工审阅"
""";
@@ -120,7 +135,7 @@ public sealed class PromptManager
""";
private const string UnifiedPrompt = """
AI健康管
AI
@@ -133,20 +148,21 @@ public sealed class PromptManager
6. query_health_records
manage_exercise
- action="create", week_start_date="今天日期(YYYY-MM-DD)"
- items: [{"day_of_week":0-6(=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...]
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
- duration_days=exercise_type="散步"duration_minutes=30reminder_time="19:00"
- =30 =60 =30 =90 =80
- "一个月"=30 "一周"=7 "10天"=107
- "坚持X天/月/周"
- items
- start_date + duration_days - 1
manage_medication
- 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.Storage.ValueConversion;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.Data;
@@ -33,6 +34,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
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)
{
@@ -95,7 +101,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
// ---- ExercisePlan ----
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 ----
@@ -148,5 +159,48 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
{
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 plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday };
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(), DayOfWeek = 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(), DayOfWeek = 3, IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 4, ExerciseType = "太极", DurationMinutes = 40 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 5, IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 6, ExerciseType = "散步", DurationMinutes = 30 });
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(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), ExerciseType = "散步", DurationMinutes = 30 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(3), IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(4), ExerciseType = "太极", DurationMinutes = 40 });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(5), IsRestDay = true });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(6), ExerciseType = "散步", DurationMinutes = 30 });
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">
<ItemGroup>
<ProjectReference Include="..\Health.Application\Health.Application.csproj" />
<ProjectReference Include="..\Health.Domain\Health.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" 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="Minio" Version="7.0.0" />
<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;
/// <summary>
/// 数据清理后台服务(每小时检查一次)
/// </summary>
public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<CleanupService> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
@@ -15,44 +14,21 @@ public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<Cl
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 清理 30 天前的对话记录(先删消息再删对话,避免 FK 冲突)
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)
var maintenance = scope.ServiceProvider.GetRequiredService<IMaintenanceService>();
var result = await maintenance.CleanupAsync(DateTime.UtcNow, stoppingToken);
if (result.Conversations + result.VerificationCodes + result.BackgroundTasks > 0)
{
// 先批量删消息
var oldMessages = await db.ConversationMessages
.Where(m => oldConvIds.Contains(m.ConversationId))
.ToListAsync(stoppingToken);
db.ConversationMessages.RemoveRange(oldMessages);
// 再删对话
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);
_logger.LogInformation(
"自动清理完成: 会话 {Conversations}, 验证码 {Codes}, 后台任务 {Tasks}",
result.Conversations,
result.VerificationCodes,
result.BackgroundTasks);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception 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;
/// <summary>
/// 用药提醒定时扫描服务(每分钟检查一次)
/// </summary>
public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory, ILogger<MedicationReminderService> logger) : BackgroundService
public sealed class MedicationReminderService(
IServiceScopeFactory scopeFactory,
ILogger<MedicationReminderService> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<MedicationReminderService> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("用药提醒服务已启动");
_logger.LogInformation("用药提醒扫描生产者已启动");
while (!stoppingToken.IsCancellationRequested)
{
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)
{
@@ -26,37 +35,4 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
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 Health.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Health.Application.Admin;
namespace Health.WebApi.Endpoints;
/// <summary>
/// 管理员 API需JWT鉴权 + Role=Admin
/// </summary>
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)
{
var group = app.MapGroup("/api/admin").RequireAuthorization();
// 管理员身份校验
group.AddEndpointFilter(async (context, next) =>
{
var role = GetUserRole(context.HttpContext);
if (role != "Admin")
return Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403);
return await next(context);
});
GetRole(context.HttpContext) == "Admin"
? await next(context)
: Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403));
// ===== 医生列表 =====
group.MapGet("/doctors", async (AppDbContext db) =>
{
var doctors = await db.Doctors
.OrderBy(d => d.CreatedAt)
.Select(d => new
{
d.Id, d.Name, d.Title, d.Department,
d.Phone, d.ProfessionalDirection,
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
});
});
group.MapGet("/doctors", async (IAdminService admin, CancellationToken ct) => ToResult(await admin.ListDoctorsAsync(ct)));
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)));
group.MapPut("/doctors/{id:guid}", async (Guid id, UpdateDoctorRequest request, IAdminService admin, CancellationToken ct) =>
ToResult(await admin.UpdateDoctorAsync(id, new UpdateDoctorCommand(request.Phone, request.Name, request.Title, request.Department, request.ProfessionalDirection), ct)));
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)));
group.MapGet("/patients", async (IAdminService admin, string? search, int? page, int? pageSize, CancellationToken ct) =>
ToResult(await admin.ListPatientsAsync(search, page ?? 1, pageSize ?? 20, ct)));
}
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 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.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.AgentHandlers;
@@ -26,10 +31,12 @@ public static class AiChatEndpoints
string token,
string agentType,
HttpContext http,
AppDbContext db,
DeepSeekClient llmClient,
PromptManager promptManager,
VisionClient visionClient,
IAiToolExecutionService toolExecution,
IAiWriteConfirmationStore confirmations,
IAiConversationService conversations,
IPatientContextService patientContexts,
CancellationToken ct) =>
{
// 支持 token 通过 query string浏览器 EventSource或 header 传递
@@ -51,38 +58,57 @@ public static class AiChatEndpoints
http.Response.Headers.Connection = "keep-alive";
http.Response.Headers["X-Accel-Buffering"] = "no";
// 创建或获取对话
Conversation? conversation = null;
if (!string.IsNullOrEmpty(conversationId) && Guid.TryParse(conversationId, out var convId))
conversation = await db.Conversations.FindAsync([convId], ct);
if (conversation == null)
// 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。
Guid? requestedConversationId = null;
if (!string.IsNullOrWhiteSpace(conversationId))
{
conversation = new Conversation
if (!Guid.TryParse(conversationId, out var convId))
{
Id = Guid.NewGuid(), UserId = userId.Value, AgentType = parsedType,
Title = message.Length > 30 ? message[..30] : message,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
};
db.Conversations.Add(conversation);
await db.SaveChangesAsync(ct);
await SseWriteAsync(http, new { action = "conversation_id", data = conversation.Id.ToString() }, ct);
await SseWriteAsync(http, new { action = "answer", data = "当前会话参数异常,请重新开始一次对话。" }, ct);
await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
return;
}
requestedConversationId = convId;
}
// 保存用户消息
var userMsg = new ConversationMessage
var opened = await conversations.OpenAsync(userId.Value, requestedConversationId, parsedType, message, ct);
if (!opened.Found)
{
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.User,
Content = message, CreatedAt = DateTime.UtcNow,
};
db.ConversationMessages.Add(userMsg);
conversation.MessageCount++;
conversation.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
await SseWriteAsync(http, new { action = "answer", data = "当前会话不存在或不属于当前账号,请重新开始一次对话。" }, ct);
await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
return;
}
var activeConversationId = opened.ConversationId;
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 patientContext = await BuildPatientContext(db, userId.Value, ct);
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
var messages = new List<ChatMessage>
{
@@ -90,17 +116,13 @@ public static class AiChatEndpoints
};
// 加载历史对话(最近 10 条)
var history = await db.ConversationMessages
.Where(m => m.ConversationId == conversation.Id)
.OrderByDescending(m => m.CreatedAt)
.Take(12)
.ToListAsync(ct);
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
foreach (var h in history.Reverse<ConversationMessage>())
foreach (var h in history)
{
messages.Add(new ChatMessage
{
Role = h.Role == MessageRole.User ? "user" : "assistant",
Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant",
Content = h.Content,
});
}
@@ -155,7 +177,9 @@ public static class AiChatEndpoints
object toolResult;
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)
{
@@ -173,71 +197,62 @@ public static class AiChatEndpoints
// 保存 AI 回复
if (!string.IsNullOrEmpty(fullResponse))
{
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 conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct);
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, 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);
if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
var conversations = await db.Conversations
.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);
var result = await conversations.ListAsync(userId.Value, 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);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var messages = await db.ConversationMessages
.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);
var messages = await conversations.GetMessagesAsync(userId.Value, id, ct);
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);
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);
if (conv != null)
{
db.Conversations.Remove(conv);
await db.SaveChangesAsync(ct);
}
await conversations.DeleteAsync(userId.Value, id, ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
// VLM 食物识别
app.MapPost("/api/ai/analyze-food-image", async (
HttpRequest httpRequest, HttpContext http,
VisionClient visionClient, AppDbContext db,
HttpRequest httpRequest,
HttpContext http,
IDietImageAnalysisCoordinator dietAnalysis,
CancellationToken ct) =>
{
var userId = GetUserId(http);
@@ -245,61 +260,19 @@ public static class AiChatEndpoints
var form = await httpRequest.ReadFormAsync(ct);
var files = form.Files.GetFiles("images");
if (files == null || files.Count == 0)
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
var imageUrls = new List<string>();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsDir);
foreach (var file in files)
var uploads = files
.Select(file => new DietImageUploadFile(file.FileName, file.Length, file.OpenReadStream()))
.ToList();
try
{
if (file.Length > 20 * 1024 * 1024)
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小超过 20MB 限制" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
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" });
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 result = await dietAnalysis.AnalyzeAsync(uploads, ct);
return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
}
finally
{
foreach (var upload in uploads)
await upload.Content.DisposeAsync();
}
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,
};
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);
var root = jsonDoc.RootElement;
if (toolName == "record_health_data") return true;
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),
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
"analyze_report" => visionClient != null
? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient)
: Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
using var json = JsonDocument.Parse(arguments);
return json.RootElement.TryGetProperty("action", out var action)
? action.GetString()?.ToLowerInvariant() ?? ""
: "";
}
catch
{
return "";
}
}
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 async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
private static void AddHealthPreview(Dictionary<string, object?> preview, JsonElement args)
{
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
var recentRecords = await db.HealthRecords.Where(r => r.UserId == userId)
.OrderByDescending(r => r.RecordedAt).Take(10).ToListAsync(ct);
var sb = new System.Text.StringBuilder();
if (archive != null)
var type = GetString(args, "type") ?? "";
switch (type)
{
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
case "blood_pressure":
var systolic = GetInt(args, "systolic");
var diastolic = GetInt(args, "diastolic");
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}",
HealthMetricType.HeartRate => $"{r.Value}次/分",
HealthMetricType.Glucose => $"{r.Value}",
HealthMetricType.SpO2 => $"{r.Value}%",
HealthMetricType.Weight => $"{r.Value}kg",
_ => "—"
};
preview["type"] = type.ToString();
preview["value"] = value?.ToString() ?? "";
preview["unit"] = unit;
preview["isAbnormal"] = value.HasValue && isAbnormal(value.Value);
}
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 类型安全转换 ──
private static int _ToInt(object? v) => v switch
@@ -435,39 +524,59 @@ public static class AiChatEndpoints
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)
{
case "record_health_data":
if (!isPendingConfirmation) break;
messageType = "data_confirm";
if (resultDict != null)
{
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("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("success", out var success)) metadata["success"] = success is bool b && b;
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("unit", out var unit)) metadata["unit"] = unit?.ToString() ?? "";
if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = _ToBool(abn);
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = _ToBool(success);
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
}
break;
case "manage_medication":
if (!isPendingConfirmation) break;
messageType = "medication_confirm";
if (resultDict != null)
{
if (resultDict.TryGetValue("name", out var name))
metadata["name"] = name.ToString();
metadata["name"] = name?.ToString() ?? "";
if (resultDict.TryGetValue("dosage", out var dosage))
metadata["dosage"] = dosage.ToString();
metadata["dosage"] = dosage?.ToString() ?? "";
if (resultDict.TryGetValue("time", out var time))
metadata["time"] = time.ToString();
metadata["time"] = time?.ToString() ?? "";
if (resultDict.TryGetValue("frequency", out var freq))
metadata["frequency"] = freq.ToString();
if (resultDict.TryGetValue("duration_days", out var dd2) && dd2 is int d2)
metadata["duration_days"] = d2;
metadata["frequency"] = freq?.ToString() ?? "";
if (resultDict.TryGetValue("duration_days", out var dd2))
metadata["duration_days"] = _ToInt(dd2);
if (resultDict.TryGetValue("start_date", out var sd))
metadata["startDate"] = sd.ToString();
metadata["startDate"] = sd?.ToString() ?? "";
}
break;
case "manage_exercise":
if (!isPendingConfirmation) break;
messageType = "data_confirm";
if (resultDict != null)
{
@@ -477,40 +586,27 @@ public static class AiChatEndpoints
resultDict.TryGetValue("duration_minutes", out var dm);
metadata["unit"] = dm != null ? $"每天{dm}分钟" : "";
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);
metadata["success"] = ok?.ToString() == "True";
metadata["success"] = _ToBool(ok);
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
}
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":
messageType = "report_analysis";
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);

View File

@@ -1,281 +1,30 @@
using Health.Infrastructure.Services;
using Health.Application.Auth;
namespace Health.WebApi.Endpoints;
/// <summary>
/// 认证相关 API 端点
/// </summary>
public static class AuthEndpoints
{
private const string AdminPhone = "12345678910";
private const string AdminSmsCode = "000000";
public static void MapAuthEndpoints(this WebApplication app)
{
// 发送短信验证码
app.MapPost("/api/auth/send-sms", async (
SendSmsRequest request,
AppDbContext db,
SmsService sms,
CancellationToken ct) =>
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, 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();
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);
await auth.LogoutAsync(request.RefreshToken, ct);
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 RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
public sealed record LoginRequest(string Phone, string SmsCode);

View File

@@ -1,3 +1,5 @@
using Health.Application.Calendars;
namespace Health.WebApi.Endpoints;
public static class CalendarEndpoints
@@ -6,91 +8,22 @@ public static class CalendarEndpoints
{
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);
var start = new DateOnly(year, month, 1);
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 });
}
if (year is < 2000 or > 2100 || month is < 1 or > 12)
return Results.Ok(new { code = 400, data = (object?)null, message = "年月格式错误" });
var result = await calendar.GetMonthAsync(GetUserId(http), year, month, ct);
return Results.Ok(new { code = 0, data = result, message = (string?)null });
});
// 获取某一天的详细安排
group.MapGet("/day", async (string date, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapGet("/day", async (string date, HttpContext http, ICalendarService calendar, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (!DateOnly.TryParse(date, out var d))
if (!DateOnly.TryParse(date, out var parsedDate))
return Results.Ok(new { code = 400, data = (object?)null, message = "日期格式错误" });
var medications = await db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.StartDate <= d && (m.EndDate == null || m.EndDate >= d))
.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 });
var result = await calendar.GetDayAsync(GetUserId(http), parsedDate, ct);
return Results.Ok(new { code = 0, data = result, 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) =>
{
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);
// 用户发消息后,状态从 AiTalking → WaitingDoctor
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct);
if (consultation != null && consultation.Status == ConsultationStatus.AiTalking)
{
consultation.Status = ConsultationStatus.WaitingDoctor;

View File

@@ -1,3 +1,5 @@
using Health.Application.Diets;
namespace Health.WebApi.Endpoints;
public static class DietEndpoints
@@ -6,82 +8,91 @@ public static class DietEndpoints
{
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 query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
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);
var records = await diets.ListAsync(userId, date, mealType, ct);
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);
request.EnableBuffering();
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;
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 });
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
var recordId = await diets.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct);
return Results.Ok(new { code = 0, data = new { id = recordId }, 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 record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
await diets.DeleteAsync(userId, id, ct);
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 record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
if (record == null) return Results.Ok(new { code = 40004, message = "记录不存在" });
using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct));
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 });
});
}
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) =>
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);
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 totalPatients = await db.Users.CountAsync(u => u.Role == "User");
var activeConsultations = await db.Consultations.CountAsync(c => c.Status != ConsultationStatus.Closed);
var pendingReports = await db.Reports.CountAsync(r => r.Status == ReportStatus.PendingDoctor);
var doctorId = profile.DoctorId.Value;
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
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 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
.Where(c => c.Status == ConsultationStatus.WaitingDoctor)
.Where(c => c.User.DoctorId == doctorId && c.Status == ConsultationStatus.WaitingDoctor)
.OrderByDescending(c => c.CreatedAt)
.Take(5)
.Select(c => new { c.Id, PatientName = c.User.Name ?? c.User.Phone, c.CreatedAt })
.ToListAsync();
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)
.Take(5)
.Select(r => new { r.Id, PatientName = r.User.Name ?? r.User.Phone, r.Category, r.CreatedAt })
.ToListAsync();
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)
.Select(f => new { f.Id, f.Title, PatientName = f.User.Name ?? f.User.Phone, f.ScheduledAt })
.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
.Include(u => u.HealthArchive)
.FirstOrDefaultAsync(u => u.Id == id);
.FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId);
if (user == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
@@ -153,7 +160,7 @@ public static class DoctorEndpoints
.ToListAsync();
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();
var reports = await db.Reports
@@ -177,7 +184,7 @@ public static class DoctorEndpoints
trendRecords,
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 }) }),
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
},
message = (string?)null
@@ -189,8 +196,10 @@ public static class DoctorEndpoints
{
var profile = await GetDoctorProfile(http, db);
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
.Where(c => c.User.DoctorId == profile.DoctorId)
.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() })
.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
.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 })
.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 });
});
@@ -216,8 +232,9 @@ public static class DoctorEndpoints
{
var profile = await GetDoctorProfile(http, db);
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 = "问诊不存在" });
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))
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 })
.FirstOrDefaultAsync();
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);
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 = "报告不存在" });
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))
query = query.Where(f => f.Status == s);
if (Guid.TryParse(patientId, out var pid))
@@ -318,15 +348,19 @@ public static class DoctorEndpoints
{
var profile = await GetDoctorProfile(http, db);
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);
var body = await reader.ReadToEndAsync(ct);
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
{
Id = Guid.NewGuid(),
UserId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!),
UserId = patientId,
Title = json.RootElement.GetProperty("title").GetString() ?? "",
DoctorName = profile.Name,
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) =>
{
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 = "随访不存在" });
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 = "随访不存在" });
db.FollowUps.Remove(followUp);
await db.SaveChangesAsync(ct);
@@ -371,9 +413,9 @@ public static class DoctorEndpoints
group.MapGet("/patients-simple", async (HttpContext http, AppDbContext db) =>
{
var doctorId = await GetDoctorEntityId(http, db);
var query = db.Users.Where(u => u.Role == "User");
if (doctorId != null)
query = query.Where(u => u.DoctorId == doctorId);
if (doctorId == null)
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
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();
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;
public static class ExerciseEndpoints
@@ -6,126 +8,78 @@ public static class ExerciseEndpoints
{
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 today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); // 北京时间
// 查找覆盖今天的所有计划(不仅限于周一开始的)
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
});
var plan = await exercises.GetCurrentAsync(userId, ct);
return Results.Ok(new { code = 0, data = plan, 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);
request.EnableBuffering();
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync(ct);
request.Body.Position = 0;
var body = await ReadBodyAsync(request, ct);
using var json = JsonDocument.Parse(body);
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 startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd")));
var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd")));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate };
for (var d = startDate; d <= endDate; d = d.AddDays(1))
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
DayOfWeek = (int)d.DayOfWeek,
ExerciseType = exerciseType,
DurationMinutes = duration,
IsRestDay = false,
});
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
var startDate = DateOnly.Parse(root.TryGetProperty("startDate", out var sd)
? sd.GetString()!
: DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd"));
var endDate = DateOnly.Parse(root.TryGetProperty("endDate", out var ed)
? ed.GetString()!
: 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)
? parsedTime
: new TimeOnly(19, 0);
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(startDate, endDate, exerciseType, duration, reminderTime), ct);
return Results.Ok(new { code = 0, data = new { id = planId }, message = (string?)null });
});
group.MapGet("/", async (HttpContext http, AppDbContext db) =>
group.MapGet("/", async (HttpContext http, IExerciseService exercises, CancellationToken ct) =>
{
var userId = GetUserId(http);
var plans = await db.ExercisePlans.Where(p => p.UserId == userId)
.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();
var plans = await exercises.ListAsync(userId, ct);
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 plan = await db.ExercisePlans.Include(p => p.Items)
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId);
var plan = await exercises.GetByIdAsync(userId, id, ct);
if (plan == null) return Results.Ok(new { code = 40004, message = "不存在" });
return Results.Ok(new
{
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
});
return Results.Ok(new { code = 0, data = plan, 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 plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId, ct);
if (plan != null) { db.ExercisePlans.Remove(plan); await db.SaveChangesAsync(ct); }
await exercises.DeleteAsync(userId, id, ct);
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);
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" });
item.IsCompleted = !item.IsCompleted;
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { completed = item.IsCompleted }, message = (string?)null });
var userId = GetUserId(http);
var completed = await exercises.ToggleCheckInAsync(userId, itemId, ct);
if (completed == null) return Results.Ok(new { code = 40004, message = "不存在" });
return Results.Ok(new { code = 0, data = new { completed }, 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) =>
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
{
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)
{
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;
if (files.Count == 0)
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
var results = new List<object>();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsDir);
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 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}");
using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
results.Add(new { id = fileId, name = file.FileName, size = file.Length });
await using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream, ct);
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 });

View File

@@ -1,3 +1,5 @@
using Health.Application.HealthRecords;
namespace Health.WebApi.Endpoints;
/// <summary>
@@ -9,127 +11,82 @@ public static class HealthEndpoints
{
var group = app.MapGroup("/api/health-records").RequireAuthorization();
// 查询健康记录
group.MapGet("/", async (
string? type, int? days,
HttpContext http, AppDbContext db, CancellationToken ct) =>
string? type,
int? days,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{
var userId = GetUserId(http);
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);
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);
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
return Results.Ok(new { code = 0, data = records, message = (string?)null });
});
// 新增健康记录
group.MapPost("/", async (CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPost("/", async (
CreateHealthRecordRequest req,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{
var userId = GetUserId(http);
var record = new HealthRecord
{
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 });
var id = await healthRecords.CreateAsync(userId, req.ToServiceRequest(), ct);
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
});
// 修改健康记录
group.MapPut("/{id:guid}", async (Guid id, CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPut("/{id:guid}", async (
Guid id,
CreateHealthRecordRequest req,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{
var userId = GetUserId(http);
var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
record.Systolic = req.Systolic;
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 });
var ok = await healthRecords.UpdateAsync(userId, id, req.ToServiceRequest(), ct);
return ok
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
});
// 删除健康记录
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapDelete("/{id:guid}", async (
Guid id,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{
var userId = GetUserId(http);
var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
db.HealthRecords.Remove(record);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
var ok = await healthRecords.DeleteAsync(userId, id, ct);
return ok
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
});
// 获取各指标最新值
group.MapGet("/latest", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapGet("/latest", async (
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{
var userId = GetUserId(http);
var types = new[] { HealthMetricType.BloodPressure, HealthMetricType.HeartRate, HealthMetricType.Glucose, HealthMetricType.SpO2, HealthMetricType.Weight };
var result = new Dictionary<string, object?>();
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 });
var latest = await healthRecords.GetLatestAsync(userId, ct);
return Results.Ok(new { code = 0, data = latest, message = (string?)null });
});
// 趋势数据
group.MapGet("/trend", async (string type, int period, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapGet("/trend", async (
string type,
int period,
HttpContext http,
IHealthRecordService healthRecords,
CancellationToken ct) =>
{
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 = "不支持的指标类型" });
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 };
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);
var records = await healthRecords.GetTrendAsync(userId, metricType, period, ct);
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) =>
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 HealthRecordSource Source { 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;
public static class MedicationEndpoints
@@ -6,255 +8,139 @@ public static class MedicationEndpoints
{
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 query = db.Medications.Where(m => m.UserId == userId);
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);
var meds = await medications.ListAsync(userId, filter, ct);
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);
request.EnableBuffering();
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;
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 });
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
var medId = await medications.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct);
return Results.Ok(new { code = 0, data = new { id = medId }, 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 med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
request.EnableBuffering();
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);
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
var updated = await medications.UpdateAsync(userId, id, ReadPatchRequest(json.RootElement), ct);
if (!updated) return Results.Ok(new { code = 40004, message = "不存在" });
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 med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
if (med != null) { db.Medications.Remove(med); await db.SaveChangesAsync(ct); }
await medications.DeleteAsync(userId, id, ct);
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 beijingToday = DateTime.UtcNow.AddHours(8).Date;
var todayStartUtc = beijingToday.AddHours(-8);
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);
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 });
var taken = await medications.ToggleTodayTakenAsync(userId, id, ct);
if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" });
return Results.Ok(new { code = 0, data = new { taken }, 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 beijingNow = DateTime.UtcNow.AddHours(8);
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();
var reminders = await medications.GetRemindersAsync(userId, ct);
return Results.Ok(new { code = 0, data = reminders, message = (string?)null });
});
// 按次打卡
group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, 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);
using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct));
var timeStr = json.RootElement.GetProperty("scheduledTime").GetString()!;
var statusStr = json.RootElement.TryGetProperty("status", out var st) ? st.GetString() : "taken";
var scheduledTime = TimeOnly.Parse(timeStr);
var status = statusStr == "taken" ? MedicationLogStatus.Taken : MedicationLogStatus.Skipped;
// 防止重复打卡
var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc);
var existing = await db.MedicationLogs.AnyAsync(l =>
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 });
var result = await medications.ConfirmDoseAsync(userId, id, scheduledTime, status, ct);
if (result == null) return Results.Ok(new { code = 404, message = "药品不存在" });
if (result == false) 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 });
});
// 取消打卡(删掉对应 log
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{
var userId = GetUserId(http);
var scheduledTime = TimeOnly.Parse(time);
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); }
await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
}
/// <summary>
/// 判断今天是否该吃药(处理隔天/每周频率)
/// </summary>
private static bool GetDosesForToday(Medication m, DateOnly today)
private static MedicationUpsertRequest ReadCreateRequest(JsonElement root)
{
if (m.Frequency == MedicationFrequency.Daily) return true;
if (m.Frequency == MedicationFrequency.TwiceDaily) return true;
if (m.Frequency == MedicationFrequency.ThreeTimesDaily) return true;
if (m.Frequency == MedicationFrequency.AsNeeded) return true;
var frequency = Enum.TryParse<MedicationFrequency>(
root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily",
out var freq)
? 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;
if (m.Frequency == MedicationFrequency.EveryOtherDay)
{
var daysSinceStart = today.DayNumber - startDate.DayNumber;
return daysSinceStart % 2 == 0;
}
if (m.Frequency == MedicationFrequency.Weekly)
{
return today.DayOfWeek == startDate.DayOfWeek;
}
return true;
return new MedicationUpsertRequest(
root.GetProperty("name").GetString()!,
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
frequency,
ReadTimes(root, "timeOfDay"),
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,
source,
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
}
private static bool InTimeWindow(TimeOnly t, TimeOnly start, TimeOnly end) =>
end > start ? (t >= start && t <= end) : (t >= start || t <= end); // 处理跨午夜
private static MedicationPatchRequest ReadPatchRequest(JsonElement root)
{
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) =>
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;
@@ -8,294 +8,62 @@ public static class ReportEndpoints
{
var group = app.MapGroup("/api/reports").RequireAuthorization();
// 列表
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapGet("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
{
var userId = GetUserId(http);
var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct);
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
var data = await reports.GetReportsAsync(userId, ct);
return Results.Ok(new { code = 0, data, message = (string?)null });
});
// 删除
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
{
var userId = GetUserId(http);
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
if (report == null) return Results.Ok(new { code = 40004, message = "不存在" });
// 删除本地文件
if (!string.IsNullOrEmpty(report.FileUrl))
{
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 = "已删除" });
var report = await reports.GetReportAsync(userId, id, ct);
return report == null
? Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" })
: Results.Ok(new { code = 0, data = report, message = (string?)null });
});
// 详情
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) =>
group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
{
var userId = GetUserId(http);
// 读 multipart form
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 file = form.Files.GetFile("file");
if (file == null || file.Length == 0)
return Results.Ok(new { code = 400, message = "未上传文件" });
if (file == null)
return Results.Ok(new { code = 400, data = (object?)null, message = "未上传文件" });
// 保存文件
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports");
Directory.CreateDirectory(uploadsDir);
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var filePath = Path.Combine(uploadsDir, fileName);
await using (var stream = new FileStream(filePath, FileMode.Create))
await file.CopyToAsync(stream, ct);
await using var stream = file.OpenReadStream();
var result = await reports.UploadReportAsync(
userId,
new ReportUploadFile(file.FileName, file.Length, stream),
ct);
var fileUrl = $"/uploads/reports/{fileName}";
var isPdf = Path.GetExtension(fileName).ToLowerInvariant() == ".pdf";
return Results.Ok(new { code = result.Code, data = result.Report, message = result.Message });
});
// 创建报告记录
var report = new Report
{
Id = Guid.NewGuid(), UserId = userId,
FileUrl = fileUrl,
FileType = isPdf ? ReportFileType.Pdf : ReportFileType.Image,
Category = ReportCategory.Other,
Status = ReportStatus.PendingDoctor,
CreatedAt = DateTime.UtcNow,
};
db.Reports.Add(report);
await db.SaveChangesAsync(ct);
group.MapPost("/{id:guid}/reanalyze", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
{
var userId = GetUserId(http);
var ok = await reports.ReanalyzeReportAsync(userId, id, ct);
return ok
? Results.Ok(new { code = 0, data = new { success = true }, message = "已重新提交 AI 分析" })
: Results.Ok(new { code = 40004, data = (object?)null, message = "报告不存在或原始文件丢失" });
});
// 异步 AI 分析(用独立 scope不依赖请求生命周期
var reportId = report.Id;
_ = Task.Run(async () =>
{
try
{
using var scope = scopeFactory.CreateScope();
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 正在分析中" });
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
{
var userId = GetUserId(http);
var ok = await reports.DeleteReportAsync(userId, id, ct);
return ok
? Results.Ok(new { code = 0, data = new { success = true }, message = "已删除" })
: Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" });
});
}
/// <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) =>
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;
/// <summary>
/// 用户与健康档案 API 端点
/// </summary>
public static class UserEndpoints
{
public static void MapUserEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/user").RequireAuthorization();
// 获取个人信息
group.MapGet("/profile", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapGet("/profile", async (HttpContext http, IUserService users, CancellationToken ct) =>
{
var userId = GetUserId(http);
var user = await db.Users.Select(u => new
{
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 });
var profile = await users.GetProfileAsync(GetUserId(http), ct);
return Results.Ok(new { code = 0, data = profile, message = (string?)null });
});
// 修改资料
group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, IUserService users, CancellationToken ct) =>
{
var userId = GetUserId(http);
var user = await db.Users.FindAsync([userId], ct);
if (user == null) return Results.Ok(new { code = 40004, message = "用户不存在" });
user.Name = req.Name ?? user.Name;
user.Gender = req.Gender ?? user.Gender;
if (DateOnly.TryParse(req.BirthDate, out var bd)) user.BirthDate = bd;
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
var updated = await users.UpdateProfileAsync(
GetUserId(http),
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate),
ct);
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
// 获取健康档案
group.MapGet("/health-archive", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
{
var userId = GetUserId(http);
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
var archive = await archives.GetAsync(GetUserId(http), ct);
return Results.Ok(new { code = 0, data = archive, message = (string?)null });
});
// 更新健康档案
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
{
var userId = GetUserId(http);
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
if (archive == null)
{
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
db.HealthArchives.Add(archive);
}
archive.Diagnosis = req.Diagnosis ?? archive.Diagnosis;
archive.SurgeryType = req.SurgeryType ?? archive.SurgeryType;
if (DateOnly.TryParse(req.SurgeryDate, out var sd)) archive.SurgeryDate = sd;
if (req.Allergies != null) archive.Allergies = req.Allergies;
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);
var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null;
await archives.UpdateAsync(
GetUserId(http),
new HealthArchiveUpdateRequest(
req.Diagnosis,
req.SurgeryType,
surgeryDate,
req.Allergies,
req.DietRestrictions,
req.ChronicDiseases,
req.FamilyHistory),
ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
// 注销账号
group.MapDelete("/account", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapDelete("/account", async (HttpContext http, IUserService users, CancellationToken ct) =>
{
var userId = GetUserId(http);
// 医生清除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);
await users.DeleteAccountAsync(GetUserId(http), ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
}

View File

@@ -1,13 +1,40 @@
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.Admin;
using Health.Infrastructure.Auth;
using Health.Infrastructure.Calendars;
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.Users;
using Health.WebApi.BackgroundServices;
using Health.WebApi.Converters;
using Health.WebApi.Endpoints;
using Health.WebApi.Middleware;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens;
// 加载 .env 文件(开发环境)
@@ -28,6 +55,18 @@ if (File.Exists(envPath))
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----
builder.Services.ConfigureHttpJsonOptions(options =>
{
@@ -68,6 +107,44 @@ builder.Services.AddAuthorization();
builder.Services.AddSingleton<JwtProvider>();
builder.Services.AddSingleton<SmsService>();
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----
builder.Services.AddHttpClient<DeepSeekClient>(client =>
@@ -85,7 +162,11 @@ builder.Services.AddHttpClient<VisionClient>(client =>
// ---- 后台服务 ----
builder.Services.AddHostedService<MedicationReminderService>();
builder.Services.AddHostedService<ExerciseReminderService>();
builder.Services.AddHostedService<MedicationReminderWorker>();
builder.Services.AddHostedService<CleanupService>();
builder.Services.AddHostedService<ReportAnalysisWorker>();
builder.Services.AddHostedService<DietImageAnalysisWorker>();
// ---- OpenAPI ----
builder.Services.AddOpenApi();
@@ -110,6 +191,14 @@ app.UseCors();
app.UseAuthentication();
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())
app.MapOpenApi();
@@ -118,6 +207,7 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
await scope.ServiceProvider.GetRequiredService<DatabaseSchemaMigrator>().ApplyAsync();
await DataSeeder.SeedAsync(db);
await DevDataSeeder.SeedIfEnabled(db, app.Configuration);
}
@@ -134,6 +224,7 @@ app.MapUserEndpoints();
app.MapAiChatEndpoints();
app.MapFileEndpoints();
app.MapCalendarEndpoints();
app.MapNotificationEndpoints();
app.MapDoctorEndpoints();
app.MapAdminEndpoints();
app.MapFollowUpEndpoints();