feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
63
backend/src/Health.Application/AI/AiConversationContracts.cs
Normal file
63
backend/src/Health.Application/AI/AiConversationContracts.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed record AiConversationDto(
|
||||
Guid Id,
|
||||
string AgentType,
|
||||
string? Title,
|
||||
string? Summary,
|
||||
int MessageCount,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record AiConversationMessageDto(
|
||||
Guid Id,
|
||||
string Role,
|
||||
string Content,
|
||||
string? Intent,
|
||||
string? MetadataJson,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record OpenConversationResult(
|
||||
bool Found,
|
||||
bool Created,
|
||||
Guid ConversationId);
|
||||
|
||||
public interface IAiConversationService
|
||||
{
|
||||
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
|
||||
Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
||||
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IAiConversationRepository
|
||||
{
|
||||
Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
||||
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
||||
void Delete(Conversation conversation);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IPatientContextService
|
||||
{
|
||||
Task<string> BuildAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record AiConfirmedWriteResult(int Code, object? Data, string? Message);
|
||||
|
||||
public interface IAiToolExecutionService
|
||||
{
|
||||
Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct);
|
||||
Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct);
|
||||
}
|
||||
111
backend/src/Health.Application/AI/AiConversationService.cs
Normal file
111
backend/src/Health.Application/AI/AiConversationService.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||
{
|
||||
private readonly IAiConversationRepository _conversations = conversations;
|
||||
|
||||
public async Task<OpenConversationResult> OpenAsync(
|
||||
Guid userId,
|
||||
Guid? conversationId,
|
||||
AgentType agentType,
|
||||
string firstMessage,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (conversationId.HasValue)
|
||||
{
|
||||
var existing = await _conversations.GetOwnedAsync(userId, conversationId.Value, ct);
|
||||
return existing == null
|
||||
? new OpenConversationResult(false, false, conversationId.Value)
|
||||
: new OpenConversationResult(true, false, existing.Id);
|
||||
}
|
||||
|
||||
var conversation = new Conversation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
AgentType = agentType,
|
||||
Title = firstMessage.Length > 30 ? firstMessage[..30] : firstMessage,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _conversations.AddConversationAsync(conversation, ct);
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
return new OpenConversationResult(true, true, conversation.Id);
|
||||
}
|
||||
|
||||
public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
||||
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct);
|
||||
|
||||
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
||||
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct);
|
||||
|
||||
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
|
||||
{
|
||||
var messages = await _conversations.GetRecentMessagesAsync(conversationId, limit, ct);
|
||||
return messages.OrderBy(m => m.CreatedAt).Select(ToMessageDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var conversations = await _conversations.ListAsync(userId, ct);
|
||||
return conversations.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||
if (conversation == null) return [];
|
||||
|
||||
var messages = await _conversations.GetMessagesAsync(conversationId, ct);
|
||||
return messages.Select(ToMessageDto).ToList();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||
if (conversation == null) return;
|
||||
|
||||
_conversations.Delete(conversation);
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetAsync(conversationId, ct)
|
||||
?? throw new InvalidOperationException("会话不存在");
|
||||
await _conversations.AddMessageAsync(new ConversationMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ConversationId = conversationId,
|
||||
Role = role,
|
||||
Content = content,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, ct);
|
||||
|
||||
conversation.MessageCount++;
|
||||
conversation.UpdatedAt = DateTime.UtcNow;
|
||||
if (updateSummary)
|
||||
conversation.Summary = content.Length > 100 ? content[..100] : content;
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static AiConversationDto ToDto(Conversation conversation) => new(
|
||||
conversation.Id,
|
||||
conversation.AgentType.ToString(),
|
||||
conversation.Title,
|
||||
conversation.Summary,
|
||||
conversation.MessageCount,
|
||||
conversation.CreatedAt,
|
||||
conversation.UpdatedAt);
|
||||
|
||||
private static AiConversationMessageDto ToMessageDto(ConversationMessage message) => new(
|
||||
message.Id,
|
||||
message.Role.ToString(),
|
||||
message.Content,
|
||||
message.Intent,
|
||||
message.MetadataJson,
|
||||
message.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed record PendingAiWriteCommand(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string ToolName,
|
||||
string Arguments,
|
||||
DateTime ExpiresAt);
|
||||
|
||||
public interface IAiWriteConfirmationStore
|
||||
{
|
||||
Task<PendingAiWriteCommand> CreateAsync(Guid userId, string toolName, string arguments, TimeSpan lifetime, CancellationToken ct);
|
||||
Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct);
|
||||
Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct);
|
||||
}
|
||||
65
backend/src/Health.Application/AI/PatientContextService.cs
Normal file
65
backend/src/Health.Application/AI/PatientContextService.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Text;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Medications;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed class PatientContextService(
|
||||
IHealthArchiveService archives,
|
||||
IHealthRecordService healthRecords,
|
||||
IMedicationService medications) : IPatientContextService
|
||||
{
|
||||
private readonly IHealthArchiveService _archives = archives;
|
||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||
private readonly IMedicationService _medications = medications;
|
||||
|
||||
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await _archives.GetAsync(userId, ct);
|
||||
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
|
||||
var activeMedications = await _medications.ListActiveAsync(userId, ct);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (archive != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||
if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}");
|
||||
if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}");
|
||||
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
|
||||
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
|
||||
}
|
||||
|
||||
if (activeMedications.Count > 0)
|
||||
{
|
||||
sb.AppendLine("当前用药:");
|
||||
foreach (var medication in activeMedications.Take(20))
|
||||
{
|
||||
var times = medication.TimeOfDay.Count > 0
|
||||
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
|
||||
: "未设置时间";
|
||||
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
|
||||
}
|
||||
}
|
||||
|
||||
if (recentRecords.Count > 0)
|
||||
{
|
||||
sb.AppendLine("近期健康数据:");
|
||||
foreach (var record in recentRecords)
|
||||
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RecordValue(HealthRecordDto record) => record.Type switch
|
||||
{
|
||||
"BloodPressure" => $"{record.Systolic}/{record.Diastolic}",
|
||||
"HeartRate" => $"{record.Value}次/分",
|
||||
"Glucose" => $"{record.Value}",
|
||||
"SpO2" => $"{record.Value}%",
|
||||
"Weight" => $"{record.Value}kg",
|
||||
_ => "—"
|
||||
};
|
||||
}
|
||||
15
backend/src/Health.Application/Admin/AdminContracts.cs
Normal file
15
backend/src/Health.Application/Admin/AdminContracts.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Health.Application.Admin;
|
||||
|
||||
public sealed record AdminResult(int Code, object? Data, string? Message = null);
|
||||
public sealed record AddDoctorCommand(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||
public sealed record UpdateDoctorCommand(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||
|
||||
public interface IAdminService
|
||||
{
|
||||
Task<AdminResult> ListDoctorsAsync(CancellationToken ct);
|
||||
Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct);
|
||||
Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct);
|
||||
Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct);
|
||||
Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct);
|
||||
Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct);
|
||||
}
|
||||
13
backend/src/Health.Application/Auth/AuthContracts.cs
Normal file
13
backend/src/Health.Application/Auth/AuthContracts.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Health.Application.Auth;
|
||||
|
||||
public sealed record AuthResult(int Code, object? Data, string? Message = null);
|
||||
public sealed record RegisterCommand(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct);
|
||||
Task<AuthResult> RegisterAsync(RegisterCommand command, CancellationToken ct);
|
||||
Task<AuthResult> LoginAsync(string phone, string smsCode, CancellationToken ct);
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||
Task LogoutAsync(string refreshToken, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Calendars;
|
||||
|
||||
public sealed record CalendarDataSnapshot(
|
||||
IReadOnlyList<Medication> Medications,
|
||||
IReadOnlyList<ExercisePlan> ExercisePlans,
|
||||
IReadOnlyList<FollowUp> FollowUps);
|
||||
|
||||
public interface ICalendarService
|
||||
{
|
||||
Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct);
|
||||
Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface ICalendarRepository
|
||||
{
|
||||
Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct);
|
||||
}
|
||||
92
backend/src/Health.Application/Calendars/CalendarService.cs
Normal file
92
backend/src/Health.Application/Calendars/CalendarService.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
namespace Health.Application.Calendars;
|
||||
|
||||
public sealed class CalendarService(ICalendarRepository calendar) : ICalendarService
|
||||
{
|
||||
private readonly ICalendarRepository _calendar = calendar;
|
||||
|
||||
public async Task<IReadOnlyList<object>> GetMonthAsync(Guid userId, int year, int month, CancellationToken ct)
|
||||
{
|
||||
var start = new DateOnly(year, month, 1);
|
||||
var end = start.AddMonths(1);
|
||||
var snapshot = await _calendar.GetSnapshotAsync(userId, start, end, ct);
|
||||
var result = new List<object>();
|
||||
|
||||
for (var date = start; date < end; date = date.AddDays(1))
|
||||
{
|
||||
var entries = BuildEntries(snapshot, date);
|
||||
if (entries.Count == 0) continue;
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
date = date.ToString("yyyy-MM-dd"),
|
||||
events = entries.Select(entry => entry.Type).Distinct().ToList(),
|
||||
details = entries.Select(entry => entry.Value).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<object> GetDayAsync(Guid userId, DateOnly date, CancellationToken ct)
|
||||
{
|
||||
var snapshot = await _calendar.GetSnapshotAsync(userId, date, date.AddDays(1), ct);
|
||||
var medications = snapshot.Medications
|
||||
.Where(m => IsMedicationActiveOn(m, date))
|
||||
.Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() })
|
||||
.ToList();
|
||||
var exercises = snapshot.ExercisePlans
|
||||
.SelectMany(p => p.Items)
|
||||
.Where(i => i.ScheduledDate == date && !i.IsRestDay)
|
||||
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
|
||||
.ToList();
|
||||
var followUps = snapshot.FollowUps
|
||||
.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)
|
||||
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
|
||||
.ToList();
|
||||
|
||||
return new { medications, exercises, followUps };
|
||||
}
|
||||
|
||||
private static List<CalendarEntry> BuildEntries(CalendarDataSnapshot snapshot, DateOnly date)
|
||||
{
|
||||
var entries = new List<CalendarEntry>();
|
||||
foreach (var medication in snapshot.Medications.Where(m => IsMedicationActiveOn(m, date)))
|
||||
{
|
||||
entries.Add(new CalendarEntry("medication", new
|
||||
{
|
||||
type = "medication",
|
||||
name = medication.Name,
|
||||
dosage = medication.Dosage,
|
||||
timeOfDay = medication.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList()
|
||||
}));
|
||||
}
|
||||
|
||||
foreach (var plan in snapshot.ExercisePlans)
|
||||
{
|
||||
foreach (var item in plan.Items.Where(i => i.ScheduledDate == date && !i.IsRestDay))
|
||||
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
|
||||
}
|
||||
|
||||
foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date))
|
||||
{
|
||||
entries.Add(new CalendarEntry("followup", new
|
||||
{
|
||||
type = "followup",
|
||||
title = followUp.Title,
|
||||
doctorName = followUp.DoctorName,
|
||||
department = followUp.Department,
|
||||
status = followUp.Status.ToString()
|
||||
}));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static bool IsMedicationActiveOn(Health.Domain.Entities.Medication medication, DateOnly date) =>
|
||||
medication.IsActive
|
||||
&& medication.TimeOfDay.Count > 0
|
||||
&& (medication.StartDate == null || medication.StartDate <= date)
|
||||
&& (medication.EndDate == null || medication.EndDate >= date);
|
||||
|
||||
private sealed record CalendarEntry(string Type, object Value);
|
||||
}
|
||||
51
backend/src/Health.Application/Diets/DietContracts.cs
Normal file
51
backend/src/Health.Application/Diets/DietContracts.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed record DietFoodItemInput(
|
||||
string Name,
|
||||
string? Portion,
|
||||
int? Calories,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record DietRecordCreateRequest(
|
||||
MealType MealType,
|
||||
int? TotalCalories,
|
||||
int? HealthScore,
|
||||
DateOnly RecordedAt,
|
||||
IReadOnlyList<DietFoodItemInput> FoodItems);
|
||||
|
||||
public sealed record DietRecordPatchRequest(
|
||||
int? TotalCalories,
|
||||
int? HealthScore);
|
||||
|
||||
public sealed record DietFoodItemDto(
|
||||
string Name,
|
||||
string? Portion,
|
||||
int? Calories);
|
||||
|
||||
public sealed record DietRecordDto(
|
||||
Guid Id,
|
||||
string MealType,
|
||||
int? TotalCalories,
|
||||
int? HealthScore,
|
||||
DateOnly RecordedAt,
|
||||
IReadOnlyList<DietFoodItemDto> FoodItems);
|
||||
|
||||
public interface IDietService
|
||||
{
|
||||
Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IDietRepository
|
||||
{
|
||||
Task<IReadOnlyList<DietRecord>> ListAsync(Guid userId, DateOnly? recordedAt, MealType? mealType, CancellationToken ct);
|
||||
Task<DietRecord?> GetOwnedAsync(Guid userId, Guid recordId, CancellationToken ct);
|
||||
Task AddAsync(DietRecord record, CancellationToken ct);
|
||||
void Delete(DietRecord record);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed record DietImageUploadFile(string FileName, long Length, Stream Content);
|
||||
|
||||
public sealed record DietImageAnalysisJob(Guid Id, IReadOnlyList<string> FilePaths);
|
||||
|
||||
public sealed record DietImageAnalysisResult(bool Success, int Code, string? Data, string? Message);
|
||||
|
||||
public interface IDietImageAnalysisCoordinator
|
||||
{
|
||||
Task<DietImageAnalysisResult> AnalyzeAsync(IReadOnlyList<DietImageUploadFile> files, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IDietImageAnalysisQueue
|
||||
{
|
||||
Task<Guid> EnqueueAsync(IReadOnlyList<string> filePaths, CancellationToken ct);
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<DietImageAnalysisJob?> TryTakeAsync(CancellationToken ct);
|
||||
Task<DietImageAnalysisResult> WaitForResultAsync(Guid jobId, CancellationToken ct);
|
||||
Task CompleteAsync(Guid jobId, DietImageAnalysisResult result, CancellationToken ct);
|
||||
Task<bool> RetryAsync(Guid jobId, string error, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IDietImageAnalyzer
|
||||
{
|
||||
Task<DietImageAnalysisResult> AnalyzeAsync(DietImageAnalysisJob job, CancellationToken ct);
|
||||
}
|
||||
77
backend/src/Health.Application/Diets/DietService.cs
Normal file
77
backend/src/Health.Application/Diets/DietService.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed class DietService(IDietRepository diets) : IDietService
|
||||
{
|
||||
private readonly IDietRepository _diets = diets;
|
||||
|
||||
public async Task<IReadOnlyList<DietRecordDto>> ListAsync(Guid userId, string? date, string? mealType, CancellationToken ct)
|
||||
{
|
||||
var recordedAt = DateOnly.TryParse(date, out var d) ? d : (DateOnly?)null;
|
||||
var parsedMealType = Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt) ? mt : (MealType?)null;
|
||||
|
||||
var records = await _diets.ListAsync(userId, recordedAt, parsedMealType, ct);
|
||||
return records.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, DietRecordCreateRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = new DietRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MealType = request.MealType,
|
||||
TotalCalories = request.TotalCalories,
|
||||
HealthScore = request.HealthScore,
|
||||
RecordedAt = request.RecordedAt,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
foreach (var item in request.FoodItems)
|
||||
{
|
||||
record.FoodItems.Add(new DietFoodItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = item.Name,
|
||||
Portion = item.Portion,
|
||||
Calories = item.Calories,
|
||||
SortOrder = item.SortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
await _diets.AddAsync(record, ct);
|
||||
await _diets.SaveChangesAsync(ct);
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid recordId, CancellationToken ct)
|
||||
{
|
||||
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
_diets.Delete(record);
|
||||
await _diets.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Guid userId, Guid recordId, DietRecordPatchRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = await _diets.GetOwnedAsync(userId, recordId, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
if (request.TotalCalories.HasValue) record.TotalCalories = request.TotalCalories.Value;
|
||||
if (request.HealthScore.HasValue) record.HealthScore = request.HealthScore.Value;
|
||||
await _diets.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DietRecordDto ToDto(DietRecord record) => new(
|
||||
record.Id,
|
||||
record.MealType.ToString(),
|
||||
record.TotalCalories,
|
||||
record.HealthScore,
|
||||
record.RecordedAt,
|
||||
record.FoodItems.OrderBy(f => f.SortOrder).Select(f => new DietFoodItemDto(f.Name, f.Portion, f.Calories)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
public sealed record ExercisePlanItemInput(
|
||||
DateOnly ScheduledDate,
|
||||
string? ExerciseType,
|
||||
int DurationMinutes,
|
||||
bool IsRestDay);
|
||||
|
||||
public sealed record ExercisePlanCreateRequest(
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
string? ExerciseType,
|
||||
int DurationMinutes,
|
||||
TimeOnly? ReminderTime = null);
|
||||
|
||||
public sealed record ExercisePlanItemDto(
|
||||
Guid Id,
|
||||
DateOnly ScheduledDate,
|
||||
string ExerciseType,
|
||||
int DurationMinutes,
|
||||
bool IsCompleted,
|
||||
DateTime? CompletedAt,
|
||||
bool IsRestDay);
|
||||
|
||||
public sealed record ExercisePlanDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
TimeOnly ReminderTime,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<ExercisePlanItemDto> Items);
|
||||
|
||||
public sealed record ExercisePlanSummaryDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
TimeOnly ReminderTime,
|
||||
DateTime CreatedAt,
|
||||
int TotalDays,
|
||||
int CompletedDays,
|
||||
IReadOnlyList<ExercisePlanItemDto> Items);
|
||||
|
||||
public interface IExerciseService
|
||||
{
|
||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||
Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IExerciseRepository
|
||||
{
|
||||
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task AddAsync(ExercisePlan plan, CancellationToken ct);
|
||||
void Delete(ExercisePlan plan);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IExerciseReminderProducer
|
||||
{
|
||||
Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
132
backend/src/Health.Application/Exercises/ExerciseService.cs
Normal file
132
backend/src/Health.Application/Exercises/ExerciseService.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService
|
||||
{
|
||||
private static readonly TimeOnly DefaultReminderTime = new(19, 0);
|
||||
private readonly IExerciseRepository _exercises = exercises;
|
||||
|
||||
public async Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var today = BeijingToday();
|
||||
var plans = await _exercises.ListActiveOnAsync(userId, today, ct);
|
||||
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.ScheduledDate == today));
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct)
|
||||
{
|
||||
var startDate = request.StartDate;
|
||||
var endDate = request.EndDate < startDate ? startDate : request.EndDate;
|
||||
if (endDate.DayNumber - startDate.DayNumber > 365)
|
||||
endDate = startDate.AddDays(365);
|
||||
|
||||
var exerciseType = string.IsNullOrWhiteSpace(request.ExerciseType) ? "运动" : request.ExerciseType.Trim();
|
||||
var duration = request.DurationMinutes > 0 ? request.DurationMinutes : 30;
|
||||
var plan = NewPlan(userId, startDate, endDate, request.ReminderTime);
|
||||
for (var date = startDate; date <= endDate; date = date.AddDays(1))
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
|
||||
DurationMinutes = duration, IsRestDay = false,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
|
||||
{
|
||||
var normalizedEnd = endDate < startDate ? startDate : endDate;
|
||||
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime);
|
||||
foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate,
|
||||
ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(),
|
||||
DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30,
|
||||
IsRestDay = item.IsRestDay,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plans = await _exercises.ListAsync(userId, 20, ct);
|
||||
return plans.Select(p => new ExercisePlanSummaryDto(
|
||||
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt,
|
||||
p.Items.Count, p.Items.Count(i => i.IsCompleted),
|
||||
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList();
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetOwnedPlanAsync(userId, planId, ct);
|
||||
if (plan == null) return false;
|
||||
_exercises.Delete(plan);
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
|
||||
{
|
||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||
if (item == null) return null;
|
||||
item.IsCompleted = !item.IsCompleted;
|
||||
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
|
||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
return item.IsCompleted;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct)
|
||||
{
|
||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||
if (item == null) return false;
|
||||
item.IsCompleted = true;
|
||||
item.CompletedAt = DateTime.UtcNow;
|
||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
|
||||
{
|
||||
await _exercises.AddAsync(plan, ct);
|
||||
await _exercises.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
|
||||
ReminderTime = reminderTime ?? DefaultReminderTime,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
|
||||
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
|
||||
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
||||
|
||||
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
|
||||
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
|
||||
item.IsCompleted, item.CompletedAt, item.IsRestDay);
|
||||
|
||||
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.HealthArchives;
|
||||
|
||||
public sealed record HealthArchiveDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string? Diagnosis,
|
||||
string? SurgeryType,
|
||||
DateOnly? SurgeryDate,
|
||||
IReadOnlyList<string> Allergies,
|
||||
IReadOnlyList<string> DietRestrictions,
|
||||
IReadOnlyList<string> ChronicDiseases,
|
||||
string? FamilyHistory,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record HealthArchiveUpdateRequest(
|
||||
string? Diagnosis,
|
||||
string? SurgeryType,
|
||||
DateOnly? SurgeryDate,
|
||||
IReadOnlyList<string>? Allergies,
|
||||
IReadOnlyList<string>? DietRestrictions,
|
||||
IReadOnlyList<string>? ChronicDiseases,
|
||||
string? FamilyHistory);
|
||||
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct);
|
||||
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IHealthArchiveRepository
|
||||
{
|
||||
Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct);
|
||||
Task AddAsync(HealthArchive archive, CancellationToken ct);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.HealthArchives;
|
||||
|
||||
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
||||
{
|
||||
private readonly IHealthArchiveRepository _archives = archives;
|
||||
|
||||
public async Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await _archives.GetByUserIdAsync(userId, ct);
|
||||
return archive == null ? null : ToDto(archive);
|
||||
}
|
||||
|
||||
public async Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||
await _archives.SaveChangesAsync(ct);
|
||||
return ToDto(archive);
|
||||
}
|
||||
|
||||
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||
Apply(archive, request);
|
||||
archive.UpdatedAt = DateTime.UtcNow;
|
||||
await _archives.SaveChangesAsync(ct);
|
||||
return ToDto(archive);
|
||||
}
|
||||
|
||||
public async Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
if (action == "query")
|
||||
{
|
||||
var archive = await GetAsync(userId, ct);
|
||||
return archive == null ? new { found = false } : ToAiResult(archive);
|
||||
}
|
||||
|
||||
var current = await GetOrCreateEntityAsync(userId, ct);
|
||||
var scopedRequest = action switch
|
||||
{
|
||||
"update_diagnosis" => request with
|
||||
{
|
||||
SurgeryType = null, SurgeryDate = null, Allergies = null,
|
||||
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null
|
||||
},
|
||||
"update_surgery" => request with
|
||||
{
|
||||
Diagnosis = null, Allergies = null, DietRestrictions = null,
|
||||
ChronicDiseases = null, FamilyHistory = null
|
||||
},
|
||||
"update_allergies" => new HealthArchiveUpdateRequest(null, null, null, request.Allergies, null, null, null),
|
||||
"update_chronic_diseases" => new HealthArchiveUpdateRequest(null, null, null, null, null, request.ChronicDiseases, null),
|
||||
"update_diet_restrictions" => new HealthArchiveUpdateRequest(null, null, null, null, request.DietRestrictions, null, null),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (scopedRequest == null)
|
||||
return new { success = false, message = $"未知档案操作: {action}" };
|
||||
|
||||
Apply(current, scopedRequest);
|
||||
current.UpdatedAt = DateTime.UtcNow;
|
||||
await _archives.SaveChangesAsync(ct);
|
||||
return new { success = true };
|
||||
}
|
||||
|
||||
private async Task<HealthArchive> GetOrCreateEntityAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await _archives.GetByUserIdAsync(userId, ct);
|
||||
if (archive != null) return archive;
|
||||
|
||||
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId, UpdatedAt = DateTime.UtcNow };
|
||||
await _archives.AddAsync(archive, ct);
|
||||
return archive;
|
||||
}
|
||||
|
||||
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
|
||||
{
|
||||
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
|
||||
if (request.SurgeryType != null) archive.SurgeryType = request.SurgeryType;
|
||||
if (request.SurgeryDate.HasValue) archive.SurgeryDate = request.SurgeryDate.Value;
|
||||
if (request.Allergies != null) archive.Allergies = request.Allergies.ToList();
|
||||
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
||||
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
||||
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
|
||||
}
|
||||
|
||||
private static object ToAiResult(HealthArchiveDto archive) => new
|
||||
{
|
||||
found = true,
|
||||
archive.Diagnosis,
|
||||
archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
archive.Allergies,
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
};
|
||||
|
||||
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
||||
archive.Id,
|
||||
archive.UserId,
|
||||
archive.Diagnosis,
|
||||
archive.SurgeryType,
|
||||
archive.SurgeryDate,
|
||||
archive.Allergies,
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
archive.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Health.Domain.Enums;
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.HealthRecords;
|
||||
|
||||
public sealed record HealthRecordUpsertRequest(
|
||||
HealthMetricType Type,
|
||||
int? Systolic,
|
||||
int? Diastolic,
|
||||
decimal? Value,
|
||||
string? Unit,
|
||||
HealthRecordSource Source,
|
||||
DateTime? RecordedAt);
|
||||
|
||||
public sealed record HealthRecordDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
int? Systolic,
|
||||
int? Diastolic,
|
||||
decimal? Value,
|
||||
string? Unit,
|
||||
string Source,
|
||||
bool IsAbnormal,
|
||||
DateTime RecordedAt);
|
||||
|
||||
public interface IHealthRecordService
|
||||
{
|
||||
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
||||
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IHealthRecordRepository
|
||||
{
|
||||
Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct);
|
||||
Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct);
|
||||
Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct);
|
||||
Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct);
|
||||
Task AddAsync(HealthRecord record, CancellationToken ct);
|
||||
void Delete(HealthRecord record);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.HealthRecords;
|
||||
|
||||
public static class HealthRecordRules
|
||||
{
|
||||
public static bool CheckAbnormal(HealthRecordUpsertRequest request) => request.Type switch
|
||||
{
|
||||
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
||||
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
||||
HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
|
||||
HealthMetricType.SpO2 => request.Value <= 94,
|
||||
HealthMetricType.Weight => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.HealthRecords;
|
||||
|
||||
public sealed class HealthRecordService(IHealthRecordRepository records) : IHealthRecordService
|
||||
{
|
||||
private readonly IHealthRecordRepository _records = records;
|
||||
|
||||
public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct)
|
||||
{
|
||||
HealthMetricType? metricType = null;
|
||||
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var parsed))
|
||||
metricType = parsed;
|
||||
|
||||
var recordedAfter = days.HasValue ? DateTime.UtcNow.AddDays(-days.Value) : (DateTime?)null;
|
||||
var result = await _records.ListAsync(userId, metricType, recordedAfter, 100, ct);
|
||||
return result.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MetricType = request.Type,
|
||||
Systolic = request.Systolic,
|
||||
Diastolic = request.Diastolic,
|
||||
Value = request.Value,
|
||||
Unit = request.Unit,
|
||||
Source = request.Source,
|
||||
RecordedAt = request.RecordedAt ?? DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
|
||||
};
|
||||
|
||||
await _records.AddAsync(record, ct);
|
||||
await _records.SaveChangesAsync(ct);
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
record.MetricType = request.Type;
|
||||
record.Systolic = request.Systolic;
|
||||
record.Diastolic = request.Diastolic;
|
||||
record.Value = request.Value;
|
||||
record.Unit = request.Unit;
|
||||
record.Source = request.Source;
|
||||
record.RecordedAt = request.RecordedAt ?? record.RecordedAt;
|
||||
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
|
||||
|
||||
await _records.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct)
|
||||
{
|
||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||
if (record == null) return false;
|
||||
|
||||
_records.Delete(record);
|
||||
await _records.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var types = new[]
|
||||
{
|
||||
HealthMetricType.BloodPressure,
|
||||
HealthMetricType.HeartRate,
|
||||
HealthMetricType.Glucose,
|
||||
HealthMetricType.SpO2,
|
||||
HealthMetricType.Weight
|
||||
};
|
||||
|
||||
var result = new Dictionary<string, object?>();
|
||||
foreach (var type in types)
|
||||
{
|
||||
var latest = await _records.GetLatestByTypeAsync(userId, type, ct);
|
||||
result[type.ToString()] = latest == null ? null : new
|
||||
{
|
||||
latest.Systolic,
|
||||
latest.Diastolic,
|
||||
latest.Value,
|
||||
latest.Unit,
|
||||
latest.RecordedAt
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
|
||||
{
|
||||
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 };
|
||||
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
|
||||
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
public static HealthRecordDto ToDto(HealthRecord record) => new(
|
||||
record.Id,
|
||||
record.MetricType.ToString(),
|
||||
record.Systolic,
|
||||
record.Diastolic,
|
||||
record.Value,
|
||||
record.Unit,
|
||||
record.Source.ToString(),
|
||||
record.IsAbnormal,
|
||||
record.RecordedAt);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Health.Application.Maintenance;
|
||||
|
||||
public sealed record MaintenanceCleanupResult(
|
||||
int Conversations,
|
||||
int VerificationCodes,
|
||||
int BackgroundTasks);
|
||||
|
||||
public interface IMaintenanceService
|
||||
{
|
||||
Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMaintenanceRepository
|
||||
{
|
||||
Task<MaintenanceCleanupResult> CleanupAsync(DateTime conversationCutoff, DateTime taskCutoff, DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Health.Application.Maintenance;
|
||||
|
||||
public sealed class MaintenanceService(IMaintenanceRepository repository) : IMaintenanceService
|
||||
{
|
||||
private readonly IMaintenanceRepository _repository = repository;
|
||||
|
||||
public Task<MaintenanceCleanupResult> CleanupAsync(DateTime utcNow, CancellationToken ct) =>
|
||||
_repository.CleanupAsync(utcNow.AddDays(-30), utcNow.AddDays(-30), utcNow, ct);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Medications;
|
||||
|
||||
public sealed record MedicationUpsertRequest(
|
||||
string Name,
|
||||
string? Dosage,
|
||||
MedicationFrequency Frequency,
|
||||
IReadOnlyList<TimeOnly> TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
MedicationSource Source,
|
||||
string? Notes);
|
||||
|
||||
public sealed record MedicationPatchRequest(
|
||||
string? Name,
|
||||
string? Dosage,
|
||||
MedicationFrequency? Frequency,
|
||||
IReadOnlyList<TimeOnly>? TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? Notes);
|
||||
|
||||
public sealed record MedicationDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Dosage,
|
||||
string Frequency,
|
||||
IReadOnlyList<TimeOnly> TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
bool IsActive,
|
||||
string? Notes,
|
||||
string Source,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
bool TodayTaken);
|
||||
|
||||
public sealed record MedicationReminderDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Dosage,
|
||||
string ScheduledTime,
|
||||
string Status);
|
||||
|
||||
public sealed record MedicationReminderTask(
|
||||
Guid TaskId,
|
||||
Guid UserId,
|
||||
Guid MedicationId,
|
||||
string Name,
|
||||
string? Dosage,
|
||||
DateOnly ScheduledDate,
|
||||
TimeOnly ScheduledTime);
|
||||
|
||||
public interface IMedicationService
|
||||
{
|
||||
Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
|
||||
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationRepository
|
||||
{
|
||||
Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
||||
Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task AddMedicationAsync(Medication medication, CancellationToken ct);
|
||||
Task AddLogAsync(MedicationLog log, CancellationToken ct);
|
||||
void DeleteMedication(Medication medication);
|
||||
void DeleteLog(MedicationLog log);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationReminderScanner
|
||||
{
|
||||
Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationReminderQueue
|
||||
{
|
||||
Task<bool> EnqueueAsync(MedicationReminderTask task, CancellationToken ct);
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<MedicationReminderTask?> TryTakeAsync(CancellationToken ct);
|
||||
Task CompleteAsync(Guid taskId, CancellationToken ct);
|
||||
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationReminderDispatcher
|
||||
{
|
||||
Task DispatchAsync(MedicationReminderTask task, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Medications;
|
||||
|
||||
public sealed class MedicationReminderScanner(IMedicationRepository medications) : IMedicationReminderScanner
|
||||
{
|
||||
private readonly IMedicationRepository _medications = medications;
|
||||
|
||||
public async Task<IReadOnlyList<MedicationReminderTask>> ScanAsync(DateTime utcNow, CancellationToken ct)
|
||||
{
|
||||
var beijingNow = utcNow.AddHours(8);
|
||||
var date = DateOnly.FromDateTime(beijingNow);
|
||||
var time = TimeOnly.FromDateTime(beijingNow);
|
||||
var windowStart = time.AddMinutes(-5);
|
||||
var todayStartUtc = beijingNow.Date.AddHours(-8);
|
||||
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||
var active = await _medications.ListActiveForReminderScanAsync(ct);
|
||||
var tasks = new List<MedicationReminderTask>();
|
||||
|
||||
foreach (var medication in active)
|
||||
{
|
||||
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue;
|
||||
if (await _medications.HasTakenInWindowAsync(medication.UserId, medication.Id, todayStartUtc, todayEndUtc, ct)) continue;
|
||||
|
||||
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
|
||||
{
|
||||
tasks.Add(new MedicationReminderTask(
|
||||
Guid.NewGuid(),
|
||||
medication.UserId,
|
||||
medication.Id,
|
||||
medication.Name,
|
||||
medication.Dosage,
|
||||
date,
|
||||
scheduledTime));
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private static bool IsActiveOn(Medication medication, DateOnly date) =>
|
||||
(medication.StartDate == null || medication.StartDate <= date)
|
||||
&& (medication.EndDate == null || medication.EndDate >= date);
|
||||
|
||||
private static bool IsInWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
|
||||
end >= start ? value >= start && value <= end : value >= start || value <= end;
|
||||
|
||||
private static bool GetDosesForToday(Medication medication, DateOnly today)
|
||||
{
|
||||
if (medication.Frequency is MedicationFrequency.Daily
|
||||
or MedicationFrequency.TwiceDaily
|
||||
or MedicationFrequency.ThreeTimesDaily
|
||||
or MedicationFrequency.AsNeeded)
|
||||
return true;
|
||||
|
||||
var startDate = medication.StartDate ?? today;
|
||||
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
|
||||
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
|
||||
if (medication.Frequency == MedicationFrequency.Weekly)
|
||||
return today.DayOfWeek == startDate.DayOfWeek;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
217
backend/src/Health.Application/Medications/MedicationService.cs
Normal file
217
backend/src/Health.Application/Medications/MedicationService.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Medications;
|
||||
|
||||
public sealed class MedicationService(IMedicationRepository medications) : IMedicationService
|
||||
{
|
||||
private readonly IMedicationRepository _medications = medications;
|
||||
|
||||
public async Task<IReadOnlyList<MedicationDto>> ListAsync(Guid userId, string? filter, CancellationToken ct)
|
||||
{
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var meds = await _medications.ListAsync(userId, filter, ct);
|
||||
return meds.Select(m => ToDto(m, HasTakenToday(m, todayStartUtc, todayEndUtc))).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
var med = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = request.Name,
|
||||
Dosage = request.Dosage,
|
||||
Frequency = request.Frequency,
|
||||
TimeOfDay = request.TimeOfDay.Count > 0 ? request.TimeOfDay.ToList() : [new TimeOnly(8, 0)],
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Source = request.Source,
|
||||
Notes = request.Notes,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _medications.AddMedicationAsync(med, ct);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return med.Id;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct)
|
||||
{
|
||||
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
|
||||
if (med == null) return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name)) med.Name = request.Name;
|
||||
if (request.Dosage != null) med.Dosage = request.Dosage;
|
||||
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
||||
if (request.TimeOfDay != null) med.TimeOfDay = request.TimeOfDay.ToList();
|
||||
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
||||
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||
if (request.Notes != null) med.Notes = request.Notes;
|
||||
med.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
{
|
||||
var med = await _medications.GetOwnedAsync(userId, medicationId, ct);
|
||||
if (med == null) return false;
|
||||
|
||||
_medications.DeleteMedication(med);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct);
|
||||
if (existing != null)
|
||||
{
|
||||
_medications.DeleteLog(existing);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
var now = TimeOnly.FromDateTime(beijingNow);
|
||||
var today = DateOnly.FromDateTime(beijingNow);
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
|
||||
var meds = await _medications.ListActiveForRemindersAsync(userId, today, ct);
|
||||
var logs = await _medications.ListLogsInWindowAsync(userId, todayStartUtc, todayEndUtc, ct);
|
||||
|
||||
var reminders = new List<MedicationReminderDto>();
|
||||
foreach (var med in meds)
|
||||
{
|
||||
if (!GetDosesForToday(med, today)) continue;
|
||||
|
||||
foreach (var scheduledTime in med.TimeOfDay)
|
||||
{
|
||||
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
|
||||
string status;
|
||||
if (log != null)
|
||||
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
|
||||
else if (scheduledTime <= now.AddMinutes(-30))
|
||||
status = "overdue";
|
||||
else if (scheduledTime <= now.AddHours(3))
|
||||
status = "upcoming";
|
||||
else
|
||||
continue;
|
||||
|
||||
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
|
||||
}
|
||||
}
|
||||
|
||||
return reminders
|
||||
.OrderBy(r => r.Status == "overdue" ? 0 : r.Status == "upcoming" ? 1 : 2)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
|
||||
if (existing) return false;
|
||||
|
||||
await AddLogAsync(userId, medicationId, status, scheduledTime, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct)
|
||||
{
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var log = await _medications.GetDoseLogAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
|
||||
if (log == null) return false;
|
||||
|
||||
_medications.DeleteLog(log);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) =>
|
||||
ListAsync(userId, "active", ct);
|
||||
|
||||
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
{
|
||||
var result = await ConfirmDoseAsync(
|
||||
userId,
|
||||
medicationId,
|
||||
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
MedicationLogStatus.Taken,
|
||||
ct);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
|
||||
{
|
||||
await _medications.AddLogAsync(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MedicationId = medicationId,
|
||||
UserId = userId,
|
||||
Status = status,
|
||||
ScheduledTime = scheduledTime,
|
||||
ConfirmedAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, ct);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static MedicationDto ToDto(Medication med, bool todayTaken) => new(
|
||||
med.Id,
|
||||
med.Name,
|
||||
med.Dosage,
|
||||
med.Frequency.ToString(),
|
||||
med.TimeOfDay,
|
||||
med.StartDate,
|
||||
med.EndDate,
|
||||
med.IsActive,
|
||||
med.Notes,
|
||||
med.Source.ToString(),
|
||||
med.CreatedAt,
|
||||
med.UpdatedAt,
|
||||
todayTaken);
|
||||
|
||||
private static bool HasTakenToday(Medication med, DateTime todayStartUtc, DateTime todayEndUtc) =>
|
||||
med.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken);
|
||||
|
||||
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
|
||||
{
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
return (todayStartUtc, todayStartUtc.AddDays(1));
|
||||
}
|
||||
|
||||
private static bool GetDosesForToday(Medication med, DateOnly today)
|
||||
{
|
||||
if (med.Frequency is MedicationFrequency.Daily
|
||||
or MedicationFrequency.TwiceDaily
|
||||
or MedicationFrequency.ThreeTimesDaily
|
||||
or MedicationFrequency.AsNeeded)
|
||||
return true;
|
||||
|
||||
var startDate = med.StartDate ?? today;
|
||||
if (med.Frequency == MedicationFrequency.EveryOtherDay)
|
||||
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
|
||||
|
||||
if (med.Frequency == MedicationFrequency.Weekly)
|
||||
return today.DayOfWeek == startDate.DayOfWeek;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Health.Application.Notifications;
|
||||
|
||||
public sealed record InAppNotificationDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Title,
|
||||
string Message,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public interface IInAppNotificationService
|
||||
{
|
||||
Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IInAppNotificationRepository
|
||||
{
|
||||
Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record InAppNotificationRecord(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Payload,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Application.Notifications;
|
||||
|
||||
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
|
||||
{
|
||||
private readonly IInAppNotificationRepository _notifications = notifications;
|
||||
|
||||
public async Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var records = await _notifications.GetPendingAsync(userId, ct);
|
||||
return records.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
|
||||
_notifications.AcknowledgeAsync(userId, notificationId, ct);
|
||||
|
||||
private static InAppNotificationDto ToDto(InAppNotificationRecord record)
|
||||
{
|
||||
if (record.Type == "MedicationReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var name = root.TryGetProperty("Name", out var nameValue) ? nameValue.GetString() : null;
|
||||
var dosage = root.TryGetProperty("Dosage", out var dosageValue) ? dosageValue.GetString() : null;
|
||||
var time = root.TryGetProperty("ScheduledTime", out var timeValue) ? timeValue.GetString() : null;
|
||||
var details = string.Join(" · ", new[] { dosage, time }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
return new InAppNotificationDto(
|
||||
record.Id,
|
||||
record.Type,
|
||||
"用药提醒",
|
||||
$"该服用{name ?? "药物"}{(details.Length > 0 ? $"({details})" : "")}了",
|
||||
record.CreatedAt);
|
||||
}
|
||||
|
||||
if (record.Type == "ExerciseReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var type = root.TryGetProperty("ExerciseType", out var typeValue) ? typeValue.GetString() : "运动";
|
||||
var minutes = root.TryGetProperty("DurationMinutes", out var minutesValue) ? minutesValue.GetInt32() : 30;
|
||||
return new InAppNotificationDto(record.Id, record.Type, "运动提醒", $"今天的{type}计划还未完成,目标 {minutes} 分钟", record.CreatedAt);
|
||||
}
|
||||
|
||||
return new InAppNotificationDto(record.Id, record.Type, "健康提醒", "您有一条新的健康提醒", record.CreatedAt);
|
||||
}
|
||||
}
|
||||
83
backend/src/Health.Application/Reports/ReportContracts.cs
Normal file
83
backend/src/Health.Application/Reports/ReportContracts.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Reports;
|
||||
|
||||
public sealed record ReportUploadFile(
|
||||
string FileName,
|
||||
long Length,
|
||||
Stream Content);
|
||||
|
||||
public sealed record ReportDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string FileUrl,
|
||||
string FileType,
|
||||
string Category,
|
||||
string Status,
|
||||
string AiStatus,
|
||||
string ReviewStatus,
|
||||
string? Severity,
|
||||
string? AiSummary,
|
||||
string? AiIndicators,
|
||||
string? DoctorComment,
|
||||
string? DoctorRecommendation,
|
||||
string? DoctorName,
|
||||
DateTime? ReviewedAt,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record ReportUploadResult(
|
||||
bool Success,
|
||||
int Code,
|
||||
string? Message,
|
||||
ReportDto? Report);
|
||||
|
||||
public sealed record ReportAnalysisJob(
|
||||
Guid TaskId,
|
||||
Guid ReportId,
|
||||
string FilePath);
|
||||
|
||||
public sealed record StoredReportFile(
|
||||
string FileUrl,
|
||||
string FilePath);
|
||||
|
||||
public interface IReportService
|
||||
{
|
||||
Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct);
|
||||
Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct);
|
||||
Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportAnalysisQueue
|
||||
{
|
||||
Task EnqueueAsync(ReportAnalysisJob job, CancellationToken ct = default);
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<ReportAnalysisJob?> TryTakeAsync(CancellationToken ct);
|
||||
Task CompleteAsync(Guid taskId, CancellationToken ct);
|
||||
Task RetryAsync(Guid taskId, string error, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportAnalysisService
|
||||
{
|
||||
Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportRepository
|
||||
{
|
||||
Task<IReadOnlyList<Report>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<Report?> GetOwnedAsync(Guid userId, Guid reportId, CancellationToken ct);
|
||||
Task<Report?> GetByIdAsync(Guid reportId, CancellationToken ct);
|
||||
Task AddAsync(Report report, CancellationToken ct);
|
||||
void Delete(Report report);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IReportFileStorage
|
||||
{
|
||||
Task<StoredReportFile> SaveAsync(ReportUploadFile file, string extension, CancellationToken ct);
|
||||
string GetLocalFilePath(string fileUrl);
|
||||
bool Exists(string filePath);
|
||||
void Delete(string filePath);
|
||||
}
|
||||
122
backend/src/Health.Application/Reports/ReportService.cs
Normal file
122
backend/src/Health.Application/Reports/ReportService.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Reports;
|
||||
|
||||
public sealed class ReportService(
|
||||
IReportRepository reports,
|
||||
IReportFileStorage fileStorage,
|
||||
IReportAnalysisQueue queue) : IReportService
|
||||
{
|
||||
private const long MaxReportImageBytes = 10 * 1024 * 1024;
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".webp"
|
||||
};
|
||||
|
||||
private readonly IReportRepository _reports = reports;
|
||||
private readonly IReportFileStorage _fileStorage = fileStorage;
|
||||
private readonly IReportAnalysisQueue _queue = queue;
|
||||
|
||||
public async Task<IReadOnlyList<ReportDto>> GetReportsAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var result = await _reports.ListAsync(userId, ct);
|
||||
return result.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<ReportDto?> GetReportAsync(Guid userId, Guid reportId, CancellationToken ct)
|
||||
{
|
||||
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
|
||||
return report == null ? null : ToDto(report);
|
||||
}
|
||||
|
||||
public async Task<ReportUploadResult> UploadReportAsync(Guid userId, ReportUploadFile file, CancellationToken ct)
|
||||
{
|
||||
if (file.Length <= 0)
|
||||
return Fail(400, "未上传文件");
|
||||
|
||||
if (file.Length > MaxReportImageBytes)
|
||||
return Fail(400, "报告图片不能超过 10MB,请压缩后重新上传");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return Fail(400, "目前仅支持上传 JPG、PNG、WEBP 格式的报告图片");
|
||||
|
||||
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
|
||||
var report = new Report
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
FileUrl = storedFile.FileUrl,
|
||||
FileType = ReportFileType.Image,
|
||||
Category = ReportCategory.Other,
|
||||
Status = ReportStatus.Analyzing,
|
||||
AiSummary = null,
|
||||
AiIndicators = null,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _reports.AddAsync(report, ct);
|
||||
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, storedFile.FilePath), ct);
|
||||
|
||||
return new ReportUploadResult(true, 0, "报告已上传,AI 正在分析中", ToDto(report));
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteReportAsync(Guid userId, Guid reportId, CancellationToken ct)
|
||||
{
|
||||
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
|
||||
if (report == null) return false;
|
||||
|
||||
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
|
||||
if (_fileStorage.Exists(filePath))
|
||||
_fileStorage.Delete(filePath);
|
||||
|
||||
_reports.Delete(report);
|
||||
await _reports.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ReanalyzeReportAsync(Guid userId, Guid reportId, CancellationToken ct)
|
||||
{
|
||||
var report = await _reports.GetOwnedAsync(userId, reportId, ct);
|
||||
if (report == null) return false;
|
||||
|
||||
var filePath = _fileStorage.GetLocalFilePath(report.FileUrl);
|
||||
if (!_fileStorage.Exists(filePath)) return false;
|
||||
|
||||
report.Status = ReportStatus.Analyzing;
|
||||
report.AiSummary = null;
|
||||
report.AiIndicators = null;
|
||||
await _queue.EnqueueAsync(new ReportAnalysisJob(Guid.NewGuid(), report.Id, filePath), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static ReportDto ToDto(Report report) => new(
|
||||
report.Id,
|
||||
report.UserId,
|
||||
report.FileUrl,
|
||||
report.FileType.ToString(),
|
||||
report.Category.ToString(),
|
||||
report.Status.ToString(),
|
||||
ToAiStatus(report),
|
||||
report.Status == ReportStatus.DoctorReviewed ? "Reviewed" : "Pending",
|
||||
report.Severity,
|
||||
report.AiSummary,
|
||||
report.AiIndicators,
|
||||
report.DoctorComment,
|
||||
report.DoctorRecommendation,
|
||||
report.DoctorName,
|
||||
report.ReviewedAt,
|
||||
report.CreatedAt);
|
||||
|
||||
private static string ToAiStatus(Report report) => report.Status switch
|
||||
{
|
||||
ReportStatus.Analyzing => "Analyzing",
|
||||
ReportStatus.AnalysisFailed => "Failed",
|
||||
_ when string.IsNullOrWhiteSpace(report.AiSummary) => "Analyzing",
|
||||
_ => "Succeeded"
|
||||
};
|
||||
|
||||
private static ReportUploadResult Fail(int code, string message) =>
|
||||
new(false, code, message, null);
|
||||
}
|
||||
31
backend/src/Health.Application/Users/UserContracts.cs
Normal file
31
backend/src/Health.Application/Users/UserContracts.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Users;
|
||||
|
||||
public sealed record UserProfileDto(
|
||||
Guid Id,
|
||||
string Phone,
|
||||
string Role,
|
||||
string? Name,
|
||||
string? Gender,
|
||||
string? BirthDate,
|
||||
string? AvatarUrl);
|
||||
|
||||
public sealed record UserProfileUpdateRequest(
|
||||
string? Name,
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct);
|
||||
Task DeleteAccountAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
35
backend/src/Health.Application/Users/UserService.cs
Normal file
35
backend/src/Health.Application/Users/UserService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace Health.Application.Users;
|
||||
|
||||
public sealed class UserService(IUserRepository users) : IUserService
|
||||
{
|
||||
private readonly IUserRepository _users = users;
|
||||
|
||||
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var user = await _users.GetAsync(userId, ct);
|
||||
return user == null ? null : new UserProfileDto(
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Role,
|
||||
user.Name,
|
||||
user.Gender,
|
||||
user.BirthDate?.ToString("yyyy-MM-dd"),
|
||||
user.AvatarUrl);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
var user = await _users.GetAsync(userId, ct);
|
||||
if (user == null) return false;
|
||||
|
||||
if (request.Name != null) user.Name = request.Name;
|
||||
if (request.Gender != null) user.Gender = request.Gender;
|
||||
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _users.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
|
||||
_users.DeleteAccountDataAsync(userId, ct);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -71,6 +71,8 @@ public enum MedicationFrequency
|
||||
/// </summary>
|
||||
public enum ReportStatus
|
||||
{
|
||||
Analyzing, // AI 分析中
|
||||
AnalysisFailed, // AI 分析失败
|
||||
PendingDoctor, // 待医生确认
|
||||
DoctorReviewed // 医生已确认
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}" })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}" })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,不要其他内容。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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=30,reminder_time="19:00"
|
||||
- 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字
|
||||
- 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天
|
||||
- 用户说"坚持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 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温暖、专业
|
||||
""";
|
||||
|
||||
90
backend/src/Health.Infrastructure/Admin/AdminService.cs
Normal file
90
backend/src/Health.Infrastructure/Admin/AdminService.cs
Normal 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);
|
||||
}
|
||||
115
backend/src/Health.Infrastructure/Auth/AuthService.cs
Normal file
115
backend/src/Health.Infrastructure/Auth/AuthService.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
166
backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs
Normal file
166
backend/src/Health.Infrastructure/Data/DatabaseSchemaMigrator.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
// ---- 饮食记录 ----
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
42
backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs
Normal file
42
backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
33
backend/src/Health.Infrastructure/Diets/EfDietRepository.cs
Normal file
33
backend/src/Health.Infrastructure/Diets/EfDietRepository.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
51
backend/src/Health.Infrastructure/Users/EfUserRepository.cs
Normal file
51
backend/src/Health.Infrastructure/Users/EfUserRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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, "数据清理异常");
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Health.Application\Health.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\Health.WebApi\Health.WebApi.csproj" />
|
||||
<ProjectReference Include="..\..\src\Health.Domain\Health.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Health.Infrastructure\Health.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
149
backend/tests/Health.Tests/application_service_tests.cs
Normal file
149
backend/tests/Health.Tests/application_service_tests.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using Health.Application.AI;
|
||||
using Health.Application.Calendars;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.Exercises;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class ApplicationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HealthArchive_AiUpdate_OnlyChangesRequestedSection()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Diagnosis = "原诊断",
|
||||
Allergies = ["青霉素"]
|
||||
});
|
||||
var service = new HealthArchiveService(repository);
|
||||
|
||||
var result = await service.ExecuteAiActionAsync(
|
||||
userId,
|
||||
"update_diagnosis",
|
||||
new HealthArchiveUpdateRequest("新诊断", "不应写入的手术", null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True((bool)result.GetType().GetProperty("success")!.GetValue(result)!);
|
||||
Assert.Equal("新诊断", repository.Archive!.Diagnosis);
|
||||
Assert.Null(repository.Archive.SurgeryType);
|
||||
Assert.Equal(["青霉素"], repository.Archive.Allergies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation()
|
||||
{
|
||||
var ownerId = Guid.NewGuid();
|
||||
var conversation = new Conversation { Id = Guid.NewGuid(), UserId = ownerId, AgentType = AgentType.Health };
|
||||
var service = new AiConversationService(new FakeConversationRepository(conversation));
|
||||
|
||||
var result = await service.OpenAsync(Guid.NewGuid(), conversation.Id, AgentType.Health, "测试", CancellationToken.None);
|
||||
|
||||
Assert.False(result.Found);
|
||||
Assert.False(result.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp()
|
||||
{
|
||||
var date = new DateOnly(2026, 6, 19);
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), IsActive = true, Name = "阿司匹林", StartDate = date.AddDays(-1), TimeOfDay = [new TimeOnly(8, 0)]
|
||||
};
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) };
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 });
|
||||
var followUp = new FollowUp { Id = Guid.NewGuid(), ScheduledAt = date.ToDateTime(new TimeOnly(9, 0)), Title = "复查" };
|
||||
var service = new CalendarService(new FakeCalendarRepository(new CalendarDataSnapshot([medication], [plan], [followUp])));
|
||||
|
||||
var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None);
|
||||
|
||||
var target = Assert.Single(result, item =>
|
||||
item.GetType().GetProperty("date")!.GetValue(item)?.ToString() == "2026-06-19");
|
||||
var events = (IEnumerable<string>)target.GetType().GetProperty("events")!.GetValue(target)!;
|
||||
Assert.Equal(["medication", "exercise", "followup"], events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates()
|
||||
{
|
||||
var repository = new FakeExerciseRepository();
|
||||
var service = new ExerciseService(repository);
|
||||
var start = new DateOnly(2026, 6, 20);
|
||||
|
||||
await service.CreateAsync(Guid.NewGuid(), new ExercisePlanCreateRequest(start, start.AddDays(9), "跑步", 30, new TimeOnly(19, 0)), CancellationToken.None);
|
||||
|
||||
var plan = Assert.IsType<ExercisePlan>(repository.Plan);
|
||||
Assert.Equal(start.AddDays(9), plan.EndDate);
|
||||
Assert.Equal(10, plan.Items.Count);
|
||||
Assert.Equal(10, plan.Items.Select(x => x.ScheduledDate).Distinct().Count());
|
||||
Assert.Equal(Enumerable.Range(0, 10).Select(start.AddDays), plan.Items.OrderBy(x => x.ScheduledDate).Select(x => x.ScheduledDate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExercisePlan_CurrentUsesExactScheduledDate()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var repository = new FakeExerciseRepository();
|
||||
var plan = new ExercisePlan
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today, EndDate = today.AddDays(9), ReminderTime = new TimeOnly(19, 0),
|
||||
};
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 });
|
||||
repository.SetPlan(plan);
|
||||
var service = new ExerciseService(repository);
|
||||
|
||||
var current = await service.GetCurrentAsync(plan.UserId, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(current);
|
||||
Assert.Equal(today, current!.Items.Single(x => x.ScheduledDate == today).ScheduledDate);
|
||||
Assert.Equal(2, current.Items.Count);
|
||||
}
|
||||
|
||||
private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository
|
||||
{
|
||||
public HealthArchive? Archive { get; private set; } = archive;
|
||||
public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) => Task.FromResult(Archive?.UserId == userId ? Archive : null);
|
||||
public Task AddAsync(HealthArchive value, CancellationToken ct) { Archive = value; return Task.CompletedTask; }
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
|
||||
{
|
||||
private readonly Conversation _conversation = conversation;
|
||||
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) => Task.FromResult(_conversation.Id == conversationId && _conversation.UserId == userId ? _conversation : null);
|
||||
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) => Task.FromResult(_conversation.Id == conversationId ? _conversation : null);
|
||||
public Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) => Task.FromResult<IReadOnlyList<Conversation>>([]);
|
||||
public Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||
public void Delete(Conversation value) { }
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository
|
||||
{
|
||||
public Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot);
|
||||
}
|
||||
|
||||
private sealed class FakeExerciseRepository : IExerciseRepository
|
||||
{
|
||||
public ExercisePlan? Plan { get; private set; }
|
||||
public void SetPlan(ExercisePlan plan) => Plan = plan;
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>([]);
|
||||
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan);
|
||||
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan);
|
||||
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId));
|
||||
public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; }
|
||||
public void Delete(ExercisePlan plan) { Plan = null; }
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -204,10 +204,10 @@ public class EntityTests
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var monday = new DateOnly(2026, 6, 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 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, IsRestDay = true });
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(2), ReminderTime = new TimeOnly(19, 0) };
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow });
|
||||
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), IsRestDay = true });
|
||||
db.ExercisePlans.Add(plan);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
76
backend/tests/Health.Tests/persistence_pipeline_tests.cs
Normal file
76
backend/tests/Health.Tests/persistence_pipeline_tests.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Infrastructure.Data;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
using Health.Infrastructure.Reports;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class PersistencePipelineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var store = new EfAiWriteConfirmationStore(db);
|
||||
var ownerId = Guid.NewGuid();
|
||||
var command = await store.CreateAsync(ownerId, "record_health_data", "{}", TimeSpan.FromMinutes(10), CancellationToken.None);
|
||||
|
||||
var wrongUser = await store.TakeAsync(command.Id, Guid.NewGuid(), CancellationToken.None);
|
||||
var firstTake = await store.TakeAsync(command.Id, ownerId, CancellationToken.None);
|
||||
var secondTake = await store.TakeAsync(command.Id, ownerId, CancellationToken.None);
|
||||
|
||||
Assert.Null(wrongUser);
|
||||
Assert.NotNull(firstTake);
|
||||
Assert.Null(secondTake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportTask_RetryUsesBackoffBeforeMaxAttempts()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var task = NewReportTask(attempts: 1);
|
||||
db.ReportAnalysisTasks.Add(task);
|
||||
await db.SaveChangesAsync();
|
||||
var queue = new EfReportAnalysisQueue(db);
|
||||
var before = DateTime.UtcNow;
|
||||
|
||||
await queue.RetryAsync(task.Id, "temporary failure", CancellationToken.None);
|
||||
|
||||
Assert.Equal("Pending", task.Status);
|
||||
Assert.True(task.AvailableAt > before);
|
||||
Assert.Equal("temporary failure", task.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportTask_RetryMarksFailedAtMaxAttempts()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var task = NewReportTask(attempts: 3);
|
||||
db.ReportAnalysisTasks.Add(task);
|
||||
await db.SaveChangesAsync();
|
||||
var queue = new EfReportAnalysisQueue(db);
|
||||
|
||||
await queue.RetryAsync(task.Id, new string('x', 2500), CancellationToken.None);
|
||||
|
||||
Assert.Equal("Failed", task.Status);
|
||||
Assert.Equal(2000, task.LastError!.Length);
|
||||
}
|
||||
|
||||
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ReportId = Guid.NewGuid(),
|
||||
FilePath = "report.jpg",
|
||||
Status = "Processing",
|
||||
Attempts = attempts,
|
||||
AvailableAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static AppDbContext CreateDbContext() => new(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options);
|
||||
}
|
||||
Reference in New Issue
Block a user