feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,6 +37,8 @@ health_app/ios/.symlinks/
|
|||||||
|
|
||||||
# Uploads & logs
|
# Uploads & logs
|
||||||
backend/src/Health.WebApi/uploads/
|
backend/src/Health.WebApi/uploads/
|
||||||
|
backend/src/Health.WebApi/data-protection-keys/
|
||||||
|
*.log
|
||||||
*.txt
|
*.txt
|
||||||
*.jpg
|
*.jpg
|
||||||
*.png
|
*.png
|
||||||
|
|||||||
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 Id { get; set; }
|
||||||
public Guid UserId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
public DateOnly WeekStartDate { get; set; } // 起始日期
|
public DateOnly StartDate { get; set; }
|
||||||
|
public DateOnly EndDate { get; set; }
|
||||||
|
public TimeOnly ReminderTime { get; set; } = new(19, 0);
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ public sealed class ExercisePlanItem
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid PlanId { get; set; }
|
public Guid PlanId { get; set; }
|
||||||
public int DayOfWeek { get; set; } // C# DayOfWeek: 0=周日, 6=周六
|
public DateOnly ScheduledDate { get; set; }
|
||||||
public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳
|
public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳
|
||||||
public int DurationMinutes { get; set; }
|
public int DurationMinutes { get; set; }
|
||||||
public bool IsCompleted { get; set; }
|
public bool IsCompleted { get; set; }
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ public enum MedicationFrequency
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public enum ReportStatus
|
public enum ReportStatus
|
||||||
{
|
{
|
||||||
|
Analyzing, // AI 分析中
|
||||||
|
AnalysisFailed, // AI 分析失败
|
||||||
PendingDoctor, // 待医生确认
|
PendingDoctor, // 待医生确认
|
||||||
DoctorReviewed // 医生已确认
|
DoctorReviewed // 医生已确认
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using Health.Application.HealthArchives;
|
||||||
|
using Health.Application.HealthRecords;
|
||||||
|
|
||||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -21,38 +24,33 @@ public static class CommonAgentHandler
|
|||||||
|
|
||||||
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
|
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
|
||||||
|
|
||||||
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
public static async Task<object> Execute(
|
||||||
|
string toolName,
|
||||||
|
JsonElement args,
|
||||||
|
Guid userId,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
IHealthArchiveService archives,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return toolName switch
|
return toolName switch
|
||||||
{
|
{
|
||||||
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args),
|
"query_health_records" => await ExecuteQueryHealthRecords(healthRecords, userId, args, ct),
|
||||||
"check_archive" => await ExecuteCheckArchive(db, userId),
|
"check_archive" => await ExecuteCheckArchive(archives, userId, ct),
|
||||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
|
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||||
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
||||||
|
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
|
||||||
var query = db.HealthRecords.Where(r => r.UserId == userId);
|
|
||||||
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
|
||||||
query = query.Where(r => r.MetricType == mt);
|
|
||||||
|
|
||||||
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
|
|
||||||
|
|
||||||
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
|
|
||||||
{
|
|
||||||
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
|
|
||||||
}).ToListAsync();
|
|
||||||
|
|
||||||
return new { count = records.Count, records };
|
return new { count = records.Count, records };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
|
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
|
var archive = await archives.GetAsync(userId, ct);
|
||||||
if (archive == null) return new { found = false };
|
if (archive == null) return new { found = false };
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
@@ -85,49 +83,26 @@ public static class CommonAgentHandler
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public static async Task<object> ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args)
|
public static Task<object> ExecuteManageArchive(
|
||||||
|
IHealthArchiveService archives,
|
||||||
|
Guid userId,
|
||||||
|
JsonElement args,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId);
|
var request = new HealthArchiveUpdateRequest(
|
||||||
if (archive == null)
|
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
|
||||||
{
|
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
|
||||||
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
|
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
|
||||||
db.HealthArchives.Add(archive);
|
ReadStringArray(args, "allergies"),
|
||||||
}
|
ReadStringArray(args, "diet_restrictions"),
|
||||||
|
ReadStringArray(args, "chronic_diseases"),
|
||||||
switch (action)
|
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
|
||||||
{
|
return archives.ExecuteAiActionAsync(userId, action, request, ct);
|
||||||
case "update_diagnosis":
|
|
||||||
archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis;
|
|
||||||
break;
|
|
||||||
case "update_surgery":
|
|
||||||
archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType;
|
|
||||||
if (args.TryGetProperty("surgery_date", out var sd))
|
|
||||||
archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate;
|
|
||||||
break;
|
|
||||||
case "update_allergies":
|
|
||||||
if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array)
|
|
||||||
archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)];
|
|
||||||
break;
|
|
||||||
case "update_chronic_diseases":
|
|
||||||
if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array)
|
|
||||||
archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)];
|
|
||||||
break;
|
|
||||||
case "update_diet_restrictions":
|
|
||||||
if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array)
|
|
||||||
archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)];
|
|
||||||
break;
|
|
||||||
default: // query
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
|
||||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
|
||||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
archive.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return new { success = true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
|
||||||
|
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
|
||||||
|
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,4 @@ public static class ConsultationAgentHandler
|
|||||||
{
|
{
|
||||||
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
|
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
|
||||||
|
|
||||||
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
|
||||||
{
|
|
||||||
return toolName switch
|
|
||||||
{
|
|
||||||
"query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
|
|
||||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,4 @@ public static class DietAgentHandler
|
|||||||
{
|
{
|
||||||
public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
|
public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
|
||||||
|
|
||||||
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
|
||||||
{
|
|
||||||
return toolName switch
|
|
||||||
{
|
|
||||||
"check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
|
|
||||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,83 @@
|
|||||||
|
using Health.Application.Exercises;
|
||||||
|
|
||||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 运动计划 Agent 工具处理器
|
|
||||||
/// </summary>
|
|
||||||
public static class ExerciseAgentHandler
|
public static class ExerciseAgentHandler
|
||||||
{
|
{
|
||||||
public static readonly ToolDefinition ManageExerciseTool = new()
|
public static readonly ToolDefinition ManageExerciseTool = new()
|
||||||
{
|
{
|
||||||
Function = new()
|
Function = new()
|
||||||
{
|
{
|
||||||
Name = "manage_exercise", Description = "运动计划管理(创建/查询/打卡)",
|
Name = "manage_exercise",
|
||||||
Parameters = new { type = "object", properties = new {
|
Description = "运动计划管理(创建/查询/打卡)",
|
||||||
action = new { type = "string", description = "create/query/checkin" },
|
Parameters = new
|
||||||
week_start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
{
|
||||||
items = new { type = "array", description = "每天的运动项目", items = new { type = "object", properties = new {
|
type = "object",
|
||||||
day_of_week = new { type = "integer", description = "0=周日 1=周一...6=周六" },
|
properties = new
|
||||||
exercise_type = new { type = "string", description = "运动类型,如散步/慢跑/游泳/太极" },
|
{
|
||||||
duration_minutes = new { type = "integer", description = "时长(分钟)" },
|
action = new { type = "string", description = "create/query/checkin" },
|
||||||
is_rest_day = new { type = "boolean", description = "是否休息日" }
|
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" },
|
||||||
}}}
|
duration_days = new { type = "integer", description = "连续运动天数,如一周为7天" },
|
||||||
}, required = new[] { "action" } }
|
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
|
||||||
}
|
duration_minutes = new { type = "integer", description = "每天运动时长,单位分钟" },
|
||||||
|
reminder_time = new { type = "string", description = "每天提醒时间 HH:mm,默认19:00" },
|
||||||
|
item_id = new { type = "string", description = "打卡条目 ID" },
|
||||||
|
},
|
||||||
|
required = new[] { "action" },
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
public static List<ToolDefinition> Tools => [ManageExerciseTool];
|
public static List<ToolDefinition> Tools => [ManageExerciseTool];
|
||||||
|
|
||||||
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
public static async Task<object> Execute(
|
||||||
|
string toolName,
|
||||||
|
JsonElement args,
|
||||||
|
AppDbContext db,
|
||||||
|
Guid userId,
|
||||||
|
IExerciseService? exercises = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return toolName switch
|
if (toolName != "manage_exercise" || exercises == null)
|
||||||
|
return new { success = false, message = $"未知工具: {toolName}" };
|
||||||
|
|
||||||
|
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
|
||||||
|
if (action == "create")
|
||||||
{
|
{
|
||||||
"manage_exercise" => await ExecuteManageExercise(db, userId, args),
|
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
|
||||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
? parsedStart
|
||||||
};
|
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
}
|
var days = args.TryGetProperty("duration_days", out var daysValue) && daysValue.TryGetInt32(out var parsedDays)
|
||||||
|
? Math.Clamp(parsedDays, 1, 366)
|
||||||
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
|
: 7;
|
||||||
{
|
var exerciseType = args.TryGetProperty("exercise_type", out var typeValue) ? typeValue.GetString() : "散步";
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
var minutes = args.TryGetProperty("duration_minutes", out var minutesValue) && minutesValue.TryGetInt32(out var parsedMinutes) ? parsedMinutes : 30;
|
||||||
switch (action)
|
var reminder = args.TryGetProperty("reminder_time", out var reminderValue) && TimeOnly.TryParse(reminderValue.GetString(), out var parsedReminder)
|
||||||
{
|
? parsedReminder
|
||||||
case "create":
|
: new TimeOnly(19, 0);
|
||||||
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(
|
||||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
|
startDate, startDate.AddDays(days - 1), exerciseType, minutes, reminder), ct);
|
||||||
if (args.TryGetProperty("items", out var items))
|
return new { success = true, plan_id = planId, exercise_type = exerciseType, duration_minutes = minutes, day_count = days, start_date = startDate, end_date = startDate.AddDays(days - 1), reminder_time = reminder };
|
||||||
{
|
|
||||||
foreach (var item in items.EnumerateArray())
|
|
||||||
{
|
|
||||||
plan.Items.Add(new ExercisePlanItem
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
|
|
||||||
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
|
|
||||||
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
|
|
||||||
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.ExercisePlans.Add(plan);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
var firstItem = plan.Items.FirstOrDefault();
|
|
||||||
return new {
|
|
||||||
success = true, plan_id = plan.Id,
|
|
||||||
exercise_type = firstItem?.ExerciseType ?? "运动",
|
|
||||||
duration_minutes = firstItem?.DurationMinutes ?? 30,
|
|
||||||
day_count = plan.Items.Count,
|
|
||||||
};
|
|
||||||
|
|
||||||
case "checkin":
|
|
||||||
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
|
|
||||||
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
|
|
||||||
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
|
|
||||||
exerciseItem.IsCompleted = true;
|
|
||||||
exerciseItem.CompletedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return new { success = true };
|
|
||||||
|
|
||||||
default:
|
|
||||||
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
|
|
||||||
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
|
|
||||||
if (existingPlan == null) return new { found = false };
|
|
||||||
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
|
|
||||||
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action == "checkin")
|
||||||
|
{
|
||||||
|
var itemId = args.TryGetProperty("item_id", out var itemValue) && itemValue.TryGetGuid(out var parsedId) ? parsedId : Guid.Empty;
|
||||||
|
var success = await exercises.CheckInAsync(userId, itemId, ct);
|
||||||
|
return success ? new { success = true } : new { success = false, message = "条目不存在" };
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = await exercises.GetLatestAsync(userId, ct);
|
||||||
|
return plan == null
|
||||||
|
? new { found = false }
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
found = true,
|
||||||
|
plan_id = plan.Id,
|
||||||
|
plan.StartDate,
|
||||||
|
plan.EndDate,
|
||||||
|
plan.ReminderTime,
|
||||||
|
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.Application.HealthRecords;
|
||||||
|
|
||||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -16,16 +18,50 @@ public static class HealthDataAgentHandler
|
|||||||
|
|
||||||
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
|
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||||
|
|
||||||
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId, IHealthRecordService? healthRecords = null)
|
||||||
{
|
{
|
||||||
return toolName switch
|
return toolName switch
|
||||||
{
|
{
|
||||||
"record_health_data" => await ExecuteRecordHealthData(db, userId, args),
|
"record_health_data" => healthRecords == null
|
||||||
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
|
? await ExecuteRecordHealthData(db, userId, args)
|
||||||
|
: await ExecuteRecordHealthData(healthRecords, userId, args),
|
||||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||||
|
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
|
||||||
|
if (metricType == null)
|
||||||
|
return new { success = false, message = $"未知指标类型: {type}" };
|
||||||
|
|
||||||
|
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt)
|
||||||
|
? dt
|
||||||
|
: DateTime.UtcNow;
|
||||||
|
|
||||||
|
var request = new HealthRecordUpsertRequest(
|
||||||
|
metricType.Value,
|
||||||
|
systolic,
|
||||||
|
diastolic,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
HealthRecordSource.AiEntry,
|
||||||
|
recordedAt);
|
||||||
|
|
||||||
|
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
|
||||||
|
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
record_id = id,
|
||||||
|
type = metricType.Value.ToString(),
|
||||||
|
value = valStr,
|
||||||
|
unit,
|
||||||
|
isAbnormal = HealthRecordRules.CheckAbnormal(request)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
|
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
|
||||||
{
|
{
|
||||||
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||||
@@ -82,4 +118,42 @@ public static class HealthDataAgentHandler
|
|||||||
};
|
};
|
||||||
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
|
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
"blood_pressure" => (
|
||||||
|
HealthMetricType.BloodPressure,
|
||||||
|
null,
|
||||||
|
"mmHg",
|
||||||
|
args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null,
|
||||||
|
args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null),
|
||||||
|
"heart_rate" => (
|
||||||
|
HealthMetricType.HeartRate,
|
||||||
|
args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null,
|
||||||
|
"次/分",
|
||||||
|
null,
|
||||||
|
null),
|
||||||
|
"glucose" => (
|
||||||
|
HealthMetricType.Glucose,
|
||||||
|
args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null,
|
||||||
|
"mmol/L",
|
||||||
|
null,
|
||||||
|
null),
|
||||||
|
"spo2" => (
|
||||||
|
HealthMetricType.SpO2,
|
||||||
|
args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null,
|
||||||
|
"%",
|
||||||
|
null,
|
||||||
|
null),
|
||||||
|
"weight" => (
|
||||||
|
HealthMetricType.Weight,
|
||||||
|
args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null,
|
||||||
|
"kg",
|
||||||
|
null,
|
||||||
|
null),
|
||||||
|
_ => (null, null, "", null, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using Health.Application.Medications;
|
||||||
|
|
||||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 药管家 Agent 工具处理器——用药管理
|
/// 药管家 Agent 工具处理器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class MedicationAgentHandler
|
public static class MedicationAgentHandler
|
||||||
{
|
{
|
||||||
@@ -9,31 +11,105 @@ public static class MedicationAgentHandler
|
|||||||
{
|
{
|
||||||
Function = new()
|
Function = new()
|
||||||
{
|
{
|
||||||
Name = "manage_medication", Description = "用药管理(创建/查询/确认服药)",
|
Name = "manage_medication",
|
||||||
Parameters = new { type = "object", properties = new {
|
Description = "用药管理(创建/查询/确认服药)",
|
||||||
action = new { type = "string", description = "create/query/confirm" },
|
Parameters = new
|
||||||
name = new { type = "string", description = "药品名称" },
|
{
|
||||||
dosage = new { type = "string", description = "剂量,如 100mg" },
|
type = "object",
|
||||||
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
|
properties = new
|
||||||
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
|
{
|
||||||
duration_days = new { type = "integer", description = "服用天数" },
|
action = new { type = "string", description = "create/query/confirm" },
|
||||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
medication_id = new { type = "string", description = "药品 ID,确认服药时使用" },
|
||||||
}, required = new[] { "action" } }
|
name = new { type = "string", description = "药品名称" },
|
||||||
|
dosage = new { type = "string", description = "剂量,如 100mg" },
|
||||||
|
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
|
||||||
|
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
|
||||||
|
duration_days = new { type = "integer", description = "服用天数" },
|
||||||
|
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
||||||
|
},
|
||||||
|
required = new[] { "action" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
|
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
|
||||||
|
|
||||||
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
public static async Task<object> Execute(
|
||||||
|
string toolName,
|
||||||
|
JsonElement args,
|
||||||
|
AppDbContext db,
|
||||||
|
Guid userId,
|
||||||
|
IMedicationService? medications = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return toolName switch
|
return toolName switch
|
||||||
{
|
{
|
||||||
"manage_medication" => await ExecuteManageMedication(db, userId, args),
|
"manage_medication" => medications != null
|
||||||
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId),
|
? await ExecuteManageMedication(medications, userId, args, ct)
|
||||||
|
: await ExecuteManageMedication(db, userId, args),
|
||||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||||
|
return action switch
|
||||||
|
{
|
||||||
|
"create" => await CreateMedication(medications, userId, args, ct),
|
||||||
|
"query" => await QueryMedications(medications, userId, ct),
|
||||||
|
"confirm" => await ConfirmMedication(medications, userId, args, ct),
|
||||||
|
_ => new { success = false, message = $"未知操作: {action}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
||||||
|
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
|
||||||
|
var frequency = ReadFrequency(args);
|
||||||
|
var times = ReadTimes(args);
|
||||||
|
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
||||||
|
var startDate = ReadStartDate(args);
|
||||||
|
var endDate = durationDays > 0 ? startDate.AddDays(durationDays) : (DateOnly?)null;
|
||||||
|
|
||||||
|
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
|
||||||
|
name,
|
||||||
|
dosage,
|
||||||
|
frequency,
|
||||||
|
times.Count > 0 ? times : [new TimeOnly(8, 0)],
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
MedicationSource.AiEntry,
|
||||||
|
null), ct);
|
||||||
|
|
||||||
|
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
medication_id = medicationId,
|
||||||
|
name,
|
||||||
|
dosage,
|
||||||
|
frequency = frequency.ToString(),
|
||||||
|
time = timeLabels,
|
||||||
|
start_date = startDate.ToString("yyyy-MM-dd"),
|
||||||
|
duration_days = durationDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var meds = await medications.ListActiveAsync(userId, ct);
|
||||||
|
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||||
|
var success = await medications.ConfirmNowAsync(userId, medId, ct);
|
||||||
|
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
|
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||||
{
|
{
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||||
@@ -50,38 +126,36 @@ public static class MedicationAgentHandler
|
|||||||
{
|
{
|
||||||
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
||||||
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
|
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
|
||||||
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
|
var frequency = ReadFrequency(args);
|
||||||
var frequency = Enum.TryParse<MedicationFrequency>(frequencyStr, out var fr) ? fr : MedicationFrequency.Daily;
|
var times = ReadTimes(args);
|
||||||
|
|
||||||
List<TimeOnly> times = [];
|
|
||||||
if (args.TryGetProperty("time_of_day", out var tod) && tod.ValueKind == JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
foreach (var t in tod.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
||||||
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
|
var startDate = ReadStartDate(args);
|
||||||
|
|
||||||
var med = new Medication
|
var med = new Medication
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
Id = Guid.NewGuid(),
|
||||||
Name = name, Dosage = dosage, Frequency = frequency,
|
UserId = userId,
|
||||||
|
Name = name,
|
||||||
|
Dosage = dosage,
|
||||||
|
Frequency = frequency,
|
||||||
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
|
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
|
||||||
Source = MedicationSource.AiEntry, IsActive = true,
|
Source = MedicationSource.AiEntry,
|
||||||
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
IsActive = true,
|
||||||
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null,
|
StartDate = startDate,
|
||||||
|
EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null,
|
||||||
};
|
};
|
||||||
db.Medications.Add(med);
|
db.Medications.Add(med);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
|
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
|
||||||
return new {
|
return new
|
||||||
success = true, medication_id = med.Id,
|
{
|
||||||
name = med.Name, dosage = med.Dosage,
|
success = true,
|
||||||
frequency = med.Frequency.ToString(), time = timeLabels,
|
medication_id = med.Id,
|
||||||
|
name = med.Name,
|
||||||
|
dosage = med.Dosage,
|
||||||
|
frequency = med.Frequency.ToString(),
|
||||||
|
time = timeLabels,
|
||||||
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
|
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
|
||||||
duration_days = durationDays,
|
duration_days = durationDays,
|
||||||
};
|
};
|
||||||
@@ -97,12 +171,47 @@ public static class MedicationAgentHandler
|
|||||||
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
|
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||||
{
|
{
|
||||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||||
|
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId);
|
||||||
|
if (med == null) return new { success = false, message = "药品不存在" };
|
||||||
|
|
||||||
db.MedicationLogs.Add(new MedicationLog
|
db.MedicationLogs.Add(new MedicationLog
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
|
Id = Guid.NewGuid(),
|
||||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
|
MedicationId = medId,
|
||||||
|
UserId = userId,
|
||||||
|
Status = MedicationLogStatus.Taken,
|
||||||
|
ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||||
|
ConfirmedAt = DateTime.UtcNow,
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return new { success = true };
|
return new { success = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MedicationFrequency ReadFrequency(JsonElement args)
|
||||||
|
{
|
||||||
|
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
|
||||||
|
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly ReadStartDate(JsonElement args)
|
||||||
|
{
|
||||||
|
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
|
||||||
|
return DateOnly.TryParse(startDateStr, out var parsedDate)
|
||||||
|
? parsedDate
|
||||||
|
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TimeOnly> ReadTimes(JsonElement args)
|
||||||
|
{
|
||||||
|
List<TimeOnly> times = [];
|
||||||
|
if (!args.TryGetProperty("time_of_day", out var tod) || tod.ValueKind != JsonValueKind.Array)
|
||||||
|
return times;
|
||||||
|
|
||||||
|
foreach (var t in tod.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return times;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,6 @@ public static class ReportAgentHandler
|
|||||||
|
|
||||||
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
|
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||||
|
|
||||||
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
|
||||||
{
|
|
||||||
return toolName switch
|
|
||||||
{
|
|
||||||
"query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId),
|
|
||||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient)
|
public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient)
|
||||||
{
|
{
|
||||||
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
|
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
|
||||||
@@ -28,13 +19,13 @@ public static class ReportAgentHandler
|
|||||||
return new { success = false, message = "缺少报告图片" };
|
return new { success = false, message = "缺少报告图片" };
|
||||||
|
|
||||||
var prompt = """
|
var prompt = """
|
||||||
你是一个医学报告解读专家。请分析以下检查报告图片,以JSON格式返回:
|
你是患者端医学报告预解读助手。请分析以下检查报告图片,以JSON格式返回:
|
||||||
{
|
{
|
||||||
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
|
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
|
||||||
"indicators": [
|
"indicators": [
|
||||||
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
|
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
|
||||||
],
|
],
|
||||||
"summary": "初步分析摘要",
|
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
|
||||||
"needsDoctorReview": true/false
|
"needsDoctorReview": true/false
|
||||||
}
|
}
|
||||||
只返回JSON,不要其他内容。
|
只返回JSON,不要其他内容。
|
||||||
|
|||||||
@@ -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>
|
/// <summary>
|
||||||
/// 获取指定 Agent 的 System Prompt
|
/// 获取指定 Agent 的 System Prompt
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string GetSystemPrompt(AgentType agentType) => agentType switch
|
public string GetSystemPrompt(AgentType agentType)
|
||||||
{
|
{
|
||||||
AgentType.Default => DefaultPrompt,
|
var prompt = agentType switch
|
||||||
AgentType.Consultation => ConsultationPrompt,
|
{
|
||||||
AgentType.Health => HealthDataPrompt,
|
AgentType.Default => DefaultPrompt,
|
||||||
AgentType.Diet => DietPrompt,
|
AgentType.Consultation => ConsultationPrompt,
|
||||||
AgentType.Medication => MedicationPrompt,
|
AgentType.Health => HealthDataPrompt,
|
||||||
AgentType.Report => ReportPrompt,
|
AgentType.Diet => DietPrompt,
|
||||||
AgentType.Exercise => ExercisePrompt,
|
AgentType.Medication => MedicationPrompt,
|
||||||
AgentType.Unified => UnifiedPrompt,
|
AgentType.Report => ReportPrompt,
|
||||||
_ => DefaultPrompt
|
AgentType.Exercise => ExercisePrompt,
|
||||||
};
|
AgentType.Unified => UnifiedPrompt,
|
||||||
|
_ => DefaultPrompt
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"{prompt}\n\n{MedicalBoundaryRules}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string MedicalBoundaryRules = """
|
||||||
|
医疗边界(必须遵守):
|
||||||
|
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||||
|
- 可以解释健康数据、报告指标和症状可能方向,但不要给出确定诊断,不要说“你就是/一定是/已经确诊”。
|
||||||
|
- 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,必须建议咨询医生或药师。
|
||||||
|
- 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。
|
||||||
|
- 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。
|
||||||
|
- 医疗相关分析末尾用一句自然的话提醒:以上为 AI 预分析,不能替代医生诊断和治疗建议。
|
||||||
|
""";
|
||||||
|
|
||||||
private const string DefaultPrompt = """
|
private const string DefaultPrompt = """
|
||||||
你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。
|
你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。
|
||||||
@@ -38,15 +53,15 @@ public sealed class PromptManager
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
private const string ConsultationPrompt = """
|
private const string ConsultationPrompt = """
|
||||||
你是一个心血管内科医生助手,负责对心脏术后患者进行多轮问诊。
|
你是一个患者端 AI 预问诊助手,负责帮助心脏术后患者梳理症状和就医前信息。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 每次只问一个问题,不要一次问多个
|
1. 每次只问一个问题,不要一次问多个
|
||||||
2. 给出 2-3 个快捷选项让患者点击
|
2. 给出 2-3 个快捷选项让患者点击
|
||||||
3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析
|
3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析
|
||||||
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
|
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
|
||||||
5. 遇到以下情况建议转医生:血压持续>160/100、心率>120或<50
|
5. 遇到以下情况建议咨询医生:血压持续>160/100、心率>120或<50
|
||||||
6. 所有分析末尾标注"以上为AI分析,具体请咨询医生"
|
6. 只给出初步分析和下一步建议,不作诊断结论
|
||||||
7. 问诊结束给出结构化小结
|
7. 问诊结束给出结构化小结
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -55,13 +70,13 @@ public sealed class PromptManager
|
|||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
||||||
2. 指标明确+数值明确→直接调用 record_health_data 录入
|
2. 指标明确+数值明确→调用 record_health_data 生成待确认写入命令,不要直接声称已经录入
|
||||||
3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")→多次调用 record_health_data
|
3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")→多次调用 record_health_data
|
||||||
4. 用户可以分时段说(如"早上血压120,下午血压130")→分别录入,recorded_at 参数用不同时间
|
4. 用户可以分时段说(如"早上血压120,下午血压130")→分别生成待确认命令,recorded_at 参数用不同时间
|
||||||
5. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
5. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
||||||
6. 时间模糊→取当前时间
|
6. 时间模糊→取当前时间
|
||||||
7. 数值超出正常范围→附带异常提醒
|
7. 数值超出正常范围→附带异常提醒
|
||||||
8. 每次调用 record_health_data 后,继续调用下一个,全部录入完成后生成确认卡片格式的回复
|
8. 每次调用 record_health_data 后继续处理下一个;工具返回仅表示等待确认,必须提示用户点击确认卡片,不能说“录入成功”
|
||||||
|
|
||||||
正常值参考范围:
|
正常值参考范围:
|
||||||
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
||||||
@@ -86,24 +101,24 @@ public sealed class PromptManager
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
private const string MedicationPrompt = """
|
private const string MedicationPrompt = """
|
||||||
你是一个用药管理专家。
|
你是一个用药记录与提醒助手。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录
|
1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录
|
||||||
2. 解析用户口中的药品信息(药名/剂量/频次/时间)
|
2. 解析用户口中的药品信息(药名/剂量/频次/时间)
|
||||||
3. "早饭后"等模糊时间→追问具体几点
|
3. "早饭后"等模糊时间→追问具体几点
|
||||||
4. 解析完成后展示确认卡片
|
4. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存
|
||||||
5. 处方拍照→提取药品信息→生成用药计划→让用户确认
|
5. 处方拍照→提取药品信息→生成用药计划→让用户确认
|
||||||
6. 回答用药相关疑问
|
6. 可以解释常见用药注意事项,但不要建议用户自行新增、停用、更换药物或调整剂量
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string ReportPrompt = """
|
private const string ReportPrompt = """
|
||||||
你是一个医学报告解读专家。
|
你是一个患者端医学报告预解读助手。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 收到报告图片后,提取所有指标及其数值
|
1. 收到报告图片后,提取所有指标及其数值
|
||||||
2. 标注异常指标(偏高/偏低/正常)
|
2. 标注异常指标(偏高/偏低/正常)
|
||||||
3. 给出初步分析
|
3. 给出初步分析和可能方向,不作诊断结论
|
||||||
4. 所有内容标注"AI预解读,待医生确认"
|
4. 所有内容标注"AI预解读,待医生确认"
|
||||||
5. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
5. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
||||||
""";
|
""";
|
||||||
@@ -120,7 +135,7 @@ public sealed class PromptManager
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
private const string UnifiedPrompt = """
|
private const string UnifiedPrompt = """
|
||||||
你是一个全能的AI健康管家,为心脏术后康复患者提供全方位服务。
|
你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。
|
||||||
|
|
||||||
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
||||||
|
|
||||||
@@ -133,20 +148,21 @@ public sealed class PromptManager
|
|||||||
6. 历史数据查询 → 调用 query_health_records
|
6. 历史数据查询 → 调用 query_health_records
|
||||||
|
|
||||||
运动计划参数格式(manage_exercise):
|
运动计划参数格式(manage_exercise):
|
||||||
- action="create", week_start_date="今天日期(YYYY-MM-DD)"
|
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
|
||||||
- items: [{"day_of_week":0-6(周日=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...]
|
- duration_days=连续天数,exercise_type="散步",duration_minutes=30,reminder_time="19:00"
|
||||||
- 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字
|
- 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字
|
||||||
- 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天
|
- 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天
|
||||||
- 用户说"坚持X天/月/周"则创建对应天数
|
- 用户说"坚持X天/月/周"则创建对应天数
|
||||||
- items 数量必须等于持续天数
|
- 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目
|
||||||
|
|
||||||
用药管理参数格式(manage_medication):
|
用药管理参数格式(manage_medication):
|
||||||
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
|
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 用户可能一句话涉及多个领域,全部处理
|
- 用户可能一句话涉及多个领域,全部处理
|
||||||
- 数值明确+指标明确→直接录入,不要追问
|
- 数值明确+指标明确→直接生成待确认命令,不要追问
|
||||||
- 录入完成后生成确认卡片
|
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||||
|
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||||
- 回复语气温暖、专业
|
- 回复语气温暖、专业
|
||||||
""";
|
""";
|
||||||
|
|||||||
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;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Health.Infrastructure.Data.Records;
|
||||||
|
|
||||||
namespace Health.Infrastructure.Data;
|
namespace Health.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -33,6 +34,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
|
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
|
||||||
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
|
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
|
||||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||||
|
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
|
||||||
|
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
|
||||||
|
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
|
||||||
|
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
|
||||||
|
public DbSet<NotificationOutboxRecord> NotificationOutbox => Set<NotificationOutboxRecord>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
@@ -95,7 +101,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
// ---- ExercisePlan ----
|
// ---- ExercisePlan ----
|
||||||
builder.Entity<ExercisePlan>(e =>
|
builder.Entity<ExercisePlan>(e =>
|
||||||
{
|
{
|
||||||
e.HasIndex(p => new { p.UserId, p.WeekStartDate }).IsDescending(false, true);
|
e.HasIndex(p => new { p.UserId, p.StartDate, p.EndDate });
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExercisePlanItem>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(i => new { i.PlanId, i.ScheduledDate }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Report ----
|
// ---- Report ----
|
||||||
@@ -148,5 +159,48 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
{
|
{
|
||||||
e.HasIndex(a => a.UserId).IsUnique();
|
e.HasIndex(a => a.UserId).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<AiWriteCommandRecord>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("AiWriteCommands");
|
||||||
|
e.HasIndex(x => new { x.UserId, x.Status, x.ExpiresAt });
|
||||||
|
e.Property(x => x.ToolName).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Status).HasMaxLength(32);
|
||||||
|
e.Property(x => x.Arguments).HasColumnType("jsonb");
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ReportAnalysisTaskRecord>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("ReportAnalysisTasks");
|
||||||
|
e.HasIndex(x => new { x.Status, x.AvailableAt });
|
||||||
|
e.HasIndex(x => x.ReportId);
|
||||||
|
e.Property(x => x.Status).HasMaxLength(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<DietImageAnalysisTaskRecord>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("DietImageAnalysisTasks");
|
||||||
|
e.HasIndex(x => new { x.Status, x.AvailableAt });
|
||||||
|
e.Property(x => x.FilePaths).HasColumnType("jsonb");
|
||||||
|
e.Property(x => x.Status).HasMaxLength(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<MedicationReminderTaskRecord>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("MedicationReminderTasks");
|
||||||
|
e.HasIndex(x => new { x.Status, x.AvailableAt });
|
||||||
|
e.HasIndex(x => new { x.MedicationId, x.ScheduledDate, x.ScheduledTime }).IsUnique();
|
||||||
|
e.Property(x => x.Status).HasMaxLength(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<NotificationOutboxRecord>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("NotificationOutbox");
|
||||||
|
e.HasIndex(x => x.SourceTaskId).IsUnique();
|
||||||
|
e.HasIndex(x => new { x.Status, x.AvailableAt });
|
||||||
|
e.Property(x => x.Type).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Status).HasMaxLength(32);
|
||||||
|
e.Property(x => x.Payload).HasColumnType("jsonb");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,14 +137,14 @@ public static class DevDataSeeder
|
|||||||
|
|
||||||
// ---- 运动计划 ----
|
// ---- 运动计划 ----
|
||||||
var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1));
|
var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1));
|
||||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday };
|
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(6), ReminderTime = new TimeOnly(19, 0) };
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, ExerciseType = "散步", DurationMinutes = 30 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), ExerciseType = "散步", DurationMinutes = 30 });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 3, IsRestDay = true });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(3), IsRestDay = true });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 4, ExerciseType = "太极", DurationMinutes = 40 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(4), ExerciseType = "太极", DurationMinutes = 40 });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 5, IsRestDay = true });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(5), IsRestDay = true });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 6, ExerciseType = "散步", DurationMinutes = 30 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(6), ExerciseType = "散步", DurationMinutes = 30 });
|
||||||
db.ExercisePlans.Add(plan);
|
db.ExercisePlans.Add(plan);
|
||||||
|
|
||||||
// ---- 饮食记录 ----
|
// ---- 饮食记录 ----
|
||||||
|
|||||||
@@ -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">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Health.Application\Health.Application.csproj" />
|
||||||
<ProjectReference Include="..\Health.Domain\Health.Domain.csproj" />
|
<ProjectReference Include="..\Health.Domain\Health.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
||||||
<PackageReference Include="Minio" Version="7.0.0" />
|
<PackageReference Include="Minio" Version="7.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
|||||||
@@ -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;
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数据清理后台服务(每小时检查一次)
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<CleanupService> logger) : BackgroundService
|
public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<CleanupService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||||
@@ -15,44 +14,21 @@ public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<Cl
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var maintenance = scope.ServiceProvider.GetRequiredService<IMaintenanceService>();
|
||||||
|
var result = await maintenance.CleanupAsync(DateTime.UtcNow, stoppingToken);
|
||||||
// 清理 30 天前的对话记录(先删消息再删对话,避免 FK 冲突)
|
if (result.Conversations + result.VerificationCodes + result.BackgroundTasks > 0)
|
||||||
var cutoff = DateTime.UtcNow.AddDays(-30);
|
|
||||||
var oldConvIds = await db.Conversations
|
|
||||||
.Where(c => c.UpdatedAt < cutoff)
|
|
||||||
.Select(c => c.Id)
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
|
|
||||||
if (oldConvIds.Count > 0)
|
|
||||||
{
|
{
|
||||||
// 先批量删消息
|
_logger.LogInformation(
|
||||||
var oldMessages = await db.ConversationMessages
|
"自动清理完成: 会话 {Conversations}, 验证码 {Codes}, 后台任务 {Tasks}",
|
||||||
.Where(m => oldConvIds.Contains(m.ConversationId))
|
result.Conversations,
|
||||||
.ToListAsync(stoppingToken);
|
result.VerificationCodes,
|
||||||
db.ConversationMessages.RemoveRange(oldMessages);
|
result.BackgroundTasks);
|
||||||
|
|
||||||
// 再删对话
|
|
||||||
var oldConversations = await db.Conversations
|
|
||||||
.Where(c => oldConvIds.Contains(c.Id))
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
db.Conversations.RemoveRange(oldConversations);
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(stoppingToken);
|
|
||||||
_logger.LogInformation("清理 {Count} 条过期对话", oldConvIds.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理过期验证码
|
|
||||||
var expiredCodes = await db.VerificationCodes
|
|
||||||
.Where(v => v.ExpiresAt < DateTime.UtcNow)
|
|
||||||
.ToListAsync(stoppingToken);
|
|
||||||
|
|
||||||
if (expiredCodes.Count > 0)
|
|
||||||
{
|
|
||||||
db.VerificationCodes.RemoveRange(expiredCodes);
|
|
||||||
await db.SaveChangesAsync(stoppingToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "数据清理异常");
|
_logger.LogError(ex, "数据清理异常");
|
||||||
|
|||||||
@@ -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;
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
/// <summary>
|
public sealed class MedicationReminderService(
|
||||||
/// 用药提醒定时扫描服务(每分钟检查一次)
|
IServiceScopeFactory scopeFactory,
|
||||||
/// </summary>
|
ILogger<MedicationReminderService> logger) : BackgroundService
|
||||||
public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory, ILogger<MedicationReminderService> logger) : BackgroundService
|
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||||
private readonly ILogger<MedicationReminderService> _logger = logger;
|
private readonly ILogger<MedicationReminderService> _logger = logger;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("用药提醒服务已启动");
|
_logger.LogInformation("用药提醒扫描生产者已启动");
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ProcessReminders(stoppingToken);
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var scanner = scope.ServiceProvider.GetRequiredService<IMedicationReminderScanner>();
|
||||||
|
var queue = scope.ServiceProvider.GetRequiredService<IMedicationReminderQueue>();
|
||||||
|
var tasks = await scanner.ScanAsync(DateTime.UtcNow, stoppingToken);
|
||||||
|
foreach (var task in tasks)
|
||||||
|
await queue.EnqueueAsync(task, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -26,37 +35,4 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
|
|||||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProcessReminders(CancellationToken ct)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
||||||
// 北京时间今天0点 → 转为UTC
|
|
||||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
|
||||||
var todayStartUtc = beijingToday.AddHours(-8);
|
|
||||||
|
|
||||||
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内
|
|
||||||
var beijingTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
|
||||||
var windowStart = beijingTime.AddMinutes(-5);
|
|
||||||
var medications = await db.Medications
|
|
||||||
.Where(m => m.IsActive)
|
|
||||||
.Where(m => m.TimeOfDay.Any(t =>
|
|
||||||
beijingTime > windowStart ? (t >= windowStart && t <= beijingTime) : (t >= windowStart || t <= beijingTime)))
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
foreach (var med in medications)
|
|
||||||
{
|
|
||||||
// 检查今天是否已打卡(用正确的北京时间区间)
|
|
||||||
var alreadyLogged = await db.MedicationLogs
|
|
||||||
.AnyAsync(l => l.MedicationId == med.Id
|
|
||||||
&& l.CreatedAt >= todayStartUtc
|
|
||||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
|
||||||
|
|
||||||
if (alreadyLogged) continue;
|
|
||||||
|
|
||||||
// TODO: 调用极光推送发送用药提醒
|
|
||||||
_logger.LogInformation("用药提醒: 用户 {UserId} 药品 {Name} {Dosage} 时间 {Time}",
|
|
||||||
med.UserId, med.Name, med.Dosage, beijingTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 System.Security.Claims;
|
||||||
using Health.Domain.Entities;
|
using Health.Application.Admin;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 管理员 API(需JWT鉴权 + Role=Admin)
|
|
||||||
/// </summary>
|
|
||||||
public static class AdminEndpoints
|
public static class AdminEndpoints
|
||||||
{
|
{
|
||||||
private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
|
||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
|
||||||
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
|
||||||
|
|
||||||
private static string GetUserRole(HttpContext http) =>
|
|
||||||
http.User.FindFirst("Role")?.Value ?? http.User.FindFirst(ClaimTypes.Role)?.Value ?? "User";
|
|
||||||
|
|
||||||
public static void MapAdminEndpoints(this WebApplication app)
|
public static void MapAdminEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/admin").RequireAuthorization();
|
var group = app.MapGroup("/api/admin").RequireAuthorization();
|
||||||
|
|
||||||
// 管理员身份校验
|
|
||||||
group.AddEndpointFilter(async (context, next) =>
|
group.AddEndpointFilter(async (context, next) =>
|
||||||
{
|
GetRole(context.HttpContext) == "Admin"
|
||||||
var role = GetUserRole(context.HttpContext);
|
? await next(context)
|
||||||
if (role != "Admin")
|
: Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403));
|
||||||
return Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403);
|
|
||||||
return await next(context);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 医生列表 =====
|
group.MapGet("/doctors", async (IAdminService admin, CancellationToken ct) => ToResult(await admin.ListDoctorsAsync(ct)));
|
||||||
group.MapGet("/doctors", async (AppDbContext db) =>
|
group.MapPost("/doctors", async (AddDoctorRequest request, IAdminService admin, CancellationToken ct) =>
|
||||||
{
|
ToResult(await admin.AddDoctorAsync(new AddDoctorCommand(request.Phone, request.Name, request.Title, request.Department, request.ProfessionalDirection), ct)));
|
||||||
var doctors = await db.Doctors
|
group.MapPut("/doctors/{id:guid}", async (Guid id, UpdateDoctorRequest request, IAdminService admin, CancellationToken ct) =>
|
||||||
.OrderBy(d => d.CreatedAt)
|
ToResult(await admin.UpdateDoctorAsync(id, new UpdateDoctorCommand(request.Phone, request.Name, request.Title, request.Department, request.ProfessionalDirection), ct)));
|
||||||
.Select(d => new
|
group.MapPut("/doctors/{id:guid}/disable", async (Guid id, IAdminService admin, CancellationToken ct) => ToResult(await admin.ToggleDoctorAsync(id, ct)));
|
||||||
{
|
group.MapDelete("/doctors/{id:guid}", async (Guid id, IAdminService admin, CancellationToken ct) => ToResult(await admin.DeleteDoctorAsync(id, ct)));
|
||||||
d.Id, d.Name, d.Title, d.Department,
|
group.MapGet("/patients", async (IAdminService admin, string? search, int? page, int? pageSize, CancellationToken ct) =>
|
||||||
d.Phone, d.ProfessionalDirection,
|
ToResult(await admin.ListPatientsAsync(search, page ?? 1, pageSize ?? 20, ct)));
|
||||||
d.IsActive, d.AvatarUrl, d.Introduction,
|
|
||||||
d.CreatedAt
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 新增医生 =====
|
|
||||||
group.MapPost("/doctors", async (
|
|
||||||
AddDoctorRequest request,
|
|
||||||
AppDbContext db,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Phone))
|
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "手机号不能为空" });
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "姓名不能为空" });
|
|
||||||
|
|
||||||
var doctor = new Doctor
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Name = request.Name,
|
|
||||||
Title = request.Title,
|
|
||||||
Department = request.Department,
|
|
||||||
Phone = request.Phone,
|
|
||||||
ProfessionalDirection = request.ProfessionalDirection,
|
|
||||||
IsActive = true,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
db.Doctors.Add(doctor);
|
|
||||||
|
|
||||||
// 同步创建 User + DoctorProfile,医生可用手机号登录
|
|
||||||
var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
|
||||||
if (existingUser == null)
|
|
||||||
{
|
|
||||||
var user = new User
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Phone = request.Phone,
|
|
||||||
Role = "Doctor",
|
|
||||||
Name = request.Name,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
db.Users.Add(user);
|
|
||||||
db.DoctorProfiles.Add(new DoctorProfile
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
UserId = user.Id,
|
|
||||||
DoctorId = doctor.Id,
|
|
||||||
Name = request.Name,
|
|
||||||
Title = request.Title,
|
|
||||||
Department = request.Department,
|
|
||||||
IsActive = true,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.Name }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 编辑医生 =====
|
|
||||||
group.MapPut("/doctors/{id:guid}", async (
|
|
||||||
Guid id, UpdateDoctorRequest request, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var doctor = await db.Doctors.FindAsync([id], ct);
|
|
||||||
if (doctor == null)
|
|
||||||
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
|
|
||||||
|
|
||||||
if (request.Name != null) doctor.Name = request.Name;
|
|
||||||
if (request.Title != null) doctor.Title = request.Title;
|
|
||||||
if (request.Department != null) doctor.Department = request.Department;
|
|
||||||
if (request.Phone != null) doctor.Phone = request.Phone;
|
|
||||||
if (request.ProfessionalDirection != null) doctor.ProfessionalDirection = request.ProfessionalDirection;
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { doctor.Id }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 启用/停用医生 =====
|
|
||||||
group.MapPut("/doctors/{id:guid}/disable", async (
|
|
||||||
Guid id, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var doctor = await db.Doctors.FindAsync([id], ct);
|
|
||||||
if (doctor == null)
|
|
||||||
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
|
|
||||||
|
|
||||||
doctor.IsActive = !doctor.IsActive;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.IsActive }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 删除医生 =====
|
|
||||||
group.MapDelete("/doctors/{id:guid}", async (
|
|
||||||
Guid id, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var doctor = await db.Doctors.FindAsync([id], ct);
|
|
||||||
if (doctor == null)
|
|
||||||
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
|
|
||||||
|
|
||||||
// 清除患者的 DoctorId 关联
|
|
||||||
await db.Users.Where(u => u.DoctorId == id)
|
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(u => u.DoctorId, (Guid?)null), ct);
|
|
||||||
|
|
||||||
db.Doctors.Remove(doctor);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 患者列表 =====
|
|
||||||
group.MapGet("/patients", async (
|
|
||||||
AppDbContext db,
|
|
||||||
string? search,
|
|
||||||
int? page,
|
|
||||||
int? pageSize) =>
|
|
||||||
{
|
|
||||||
var p = page ?? 1;
|
|
||||||
var ps = pageSize ?? 20;
|
|
||||||
var query = db.Users
|
|
||||||
.Where(u => u.Role == "User")
|
|
||||||
.AsQueryable();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
|
||||||
{
|
|
||||||
query = query.Where(u => u.Name!.Contains(search) || u.Phone.Contains(search));
|
|
||||||
}
|
|
||||||
|
|
||||||
var total = await query.CountAsync();
|
|
||||||
var patients = await query
|
|
||||||
.OrderByDescending(u => u.CreatedAt)
|
|
||||||
.Skip((p - 1) * ps)
|
|
||||||
.Take(ps)
|
|
||||||
.Select(u => new
|
|
||||||
{
|
|
||||||
u.Id, u.Name, u.Phone, u.Gender,
|
|
||||||
u.BirthDate, u.CreatedAt,
|
|
||||||
DoctorName = u.Doctor != null ? u.Doctor.Name : null,
|
|
||||||
DoctorDepartment = u.Doctor != null ? u.Doctor.Department : null,
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
code = 0,
|
|
||||||
data = new { patients, total, page = p, pageSize = ps },
|
|
||||||
message = (string?)null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetRole(HttpContext http) =>
|
||||||
|
http.User.FindFirst("Role")?.Value ?? http.User.FindFirst(ClaimTypes.Role)?.Value ?? "User";
|
||||||
|
private static IResult ToResult(AdminResult result) => Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- DTO ----
|
|
||||||
public sealed record AddDoctorRequest(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
|
public sealed record AddDoctorRequest(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||||
public sealed record UpdateDoctorRequest(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);
|
public sealed record UpdateDoctorRequest(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
using System.Drawing;
|
|
||||||
using System.Drawing.Imaging;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Health.Application.AI;
|
||||||
|
using Health.Application.Diets;
|
||||||
|
using Health.Application.Exercises;
|
||||||
|
using Health.Application.HealthArchives;
|
||||||
|
using Health.Application.HealthRecords;
|
||||||
|
using Health.Application.Medications;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
using Health.Infrastructure.AI.AgentHandlers;
|
using Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
@@ -26,10 +31,12 @@ public static class AiChatEndpoints
|
|||||||
string token,
|
string token,
|
||||||
string agentType,
|
string agentType,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
AppDbContext db,
|
|
||||||
DeepSeekClient llmClient,
|
DeepSeekClient llmClient,
|
||||||
PromptManager promptManager,
|
PromptManager promptManager,
|
||||||
VisionClient visionClient,
|
IAiToolExecutionService toolExecution,
|
||||||
|
IAiWriteConfirmationStore confirmations,
|
||||||
|
IAiConversationService conversations,
|
||||||
|
IPatientContextService patientContexts,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
// 支持 token 通过 query string(浏览器 EventSource)或 header 传递
|
// 支持 token 通过 query string(浏览器 EventSource)或 header 传递
|
||||||
@@ -51,38 +58,57 @@ public static class AiChatEndpoints
|
|||||||
http.Response.Headers.Connection = "keep-alive";
|
http.Response.Headers.Connection = "keep-alive";
|
||||||
http.Response.Headers["X-Accel-Buffering"] = "no";
|
http.Response.Headers["X-Accel-Buffering"] = "no";
|
||||||
|
|
||||||
// 创建或获取对话
|
// 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。
|
||||||
Conversation? conversation = null;
|
Guid? requestedConversationId = null;
|
||||||
if (!string.IsNullOrEmpty(conversationId) && Guid.TryParse(conversationId, out var convId))
|
if (!string.IsNullOrWhiteSpace(conversationId))
|
||||||
conversation = await db.Conversations.FindAsync([convId], ct);
|
|
||||||
|
|
||||||
if (conversation == null)
|
|
||||||
{
|
{
|
||||||
conversation = new Conversation
|
if (!Guid.TryParse(conversationId, out var convId))
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId.Value, AgentType = parsedType,
|
await SseWriteAsync(http, new { action = "answer", data = "当前会话参数异常,请重新开始一次对话。" }, ct);
|
||||||
Title = message.Length > 30 ? message[..30] : message,
|
await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
|
||||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
};
|
return;
|
||||||
db.Conversations.Add(conversation);
|
}
|
||||||
await db.SaveChangesAsync(ct);
|
requestedConversationId = convId;
|
||||||
await SseWriteAsync(http, new { action = "conversation_id", data = conversation.Id.ToString() }, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存用户消息
|
var opened = await conversations.OpenAsync(userId.Value, requestedConversationId, parsedType, message, ct);
|
||||||
var userMsg = new ConversationMessage
|
if (!opened.Found)
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.User,
|
await SseWriteAsync(http, new { action = "answer", data = "当前会话不存在或不属于当前账号,请重新开始一次对话。" }, ct);
|
||||||
Content = message, CreatedAt = DateTime.UtcNow,
|
await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
|
||||||
};
|
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
db.ConversationMessages.Add(userMsg);
|
return;
|
||||||
conversation.MessageCount++;
|
}
|
||||||
conversation.UpdatedAt = DateTime.UtcNow;
|
var activeConversationId = opened.ConversationId;
|
||||||
await db.SaveChangesAsync(ct);
|
if (opened.Created)
|
||||||
|
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
|
||||||
|
|
||||||
|
await conversations.AddUserMessageAsync(activeConversationId, message, ct);
|
||||||
|
|
||||||
|
var urgentWarning = DetectUrgentRisk(message);
|
||||||
|
if (!string.IsNullOrEmpty(urgentWarning))
|
||||||
|
{
|
||||||
|
var urgentResponse = $"""
|
||||||
|
{urgentWarning}
|
||||||
|
|
||||||
|
这种情况需要把安全放在第一位。请先停止自行判断和等待 AI 分析,尽快联系医生、互联网医院或前往急诊评估;如果症状正在加重,建议立即拨打当地急救电话。若身边有人,请让家人或同伴陪同,不要独自开车就医。
|
||||||
|
|
||||||
|
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
|
||||||
|
""";
|
||||||
|
|
||||||
|
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
||||||
|
|
||||||
|
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
|
||||||
|
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
|
||||||
|
await SseWriteAsync(http, new { action = "status", data = "done" }, ct);
|
||||||
|
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 加载上下文
|
// 加载上下文
|
||||||
var systemPrompt = promptManager.GetSystemPrompt(parsedType);
|
var systemPrompt = promptManager.GetSystemPrompt(parsedType);
|
||||||
var patientContext = await BuildPatientContext(db, userId.Value, ct);
|
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||||
|
|
||||||
var messages = new List<ChatMessage>
|
var messages = new List<ChatMessage>
|
||||||
{
|
{
|
||||||
@@ -90,17 +116,13 @@ public static class AiChatEndpoints
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 加载历史对话(最近 10 条)
|
// 加载历史对话(最近 10 条)
|
||||||
var history = await db.ConversationMessages
|
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
|
||||||
.Where(m => m.ConversationId == conversation.Id)
|
|
||||||
.OrderByDescending(m => m.CreatedAt)
|
|
||||||
.Take(12)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
foreach (var h in history.Reverse<ConversationMessage>())
|
foreach (var h in history)
|
||||||
{
|
{
|
||||||
messages.Add(new ChatMessage
|
messages.Add(new ChatMessage
|
||||||
{
|
{
|
||||||
Role = h.Role == MessageRole.User ? "user" : "assistant",
|
Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant",
|
||||||
Content = h.Content,
|
Content = h.Content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -155,7 +177,9 @@ public static class AiChatEndpoints
|
|||||||
object toolResult;
|
object toolResult;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value, visionClient);
|
toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments)
|
||||||
|
? await PreparePendingWriteAsync(confirmations, userId.Value, tc.Function.Name, tc.Function.Arguments, ct)
|
||||||
|
: await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -173,71 +197,62 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
// 保存 AI 回复
|
// 保存 AI 回复
|
||||||
if (!string.IsNullOrEmpty(fullResponse))
|
if (!string.IsNullOrEmpty(fullResponse))
|
||||||
{
|
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct);
|
||||||
db.ConversationMessages.Add(new ConversationMessage
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.Assistant,
|
|
||||||
Content = fullResponse, CreatedAt = DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
conversation.MessageCount++;
|
|
||||||
conversation.Summary = fullResponse.Length > 100 ? fullResponse[..100] : fullResponse;
|
|
||||||
conversation.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
||||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/ai/confirm-write/{commandId:guid}", async (
|
||||||
|
Guid commandId,
|
||||||
|
HttpContext http,
|
||||||
|
IAiToolExecutionService toolExecution,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null)
|
||||||
|
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
||||||
|
|
||||||
|
var result = await toolExecution.ConfirmAsync(commandId, userId.Value, ct);
|
||||||
|
return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// 获取对话列表
|
// 获取对话列表
|
||||||
app.MapGet("/api/ai/conversations", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
app.MapGet("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
||||||
|
|
||||||
var conversations = await db.Conversations
|
var result = await conversations.ListAsync(userId.Value, ct);
|
||||||
.Where(c => c.UserId == userId.Value)
|
|
||||||
.OrderByDescending(c => c.UpdatedAt)
|
|
||||||
.Select(c => new { c.Id, AgentType = c.AgentType.ToString(), c.Title, c.Summary, c.MessageCount, c.CreatedAt, c.UpdatedAt })
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = conversations, message = (string?)null });
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取对话历史
|
// 获取对话历史
|
||||||
app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
var messages = await db.ConversationMessages
|
var messages = await conversations.GetMessagesAsync(userId.Value, id, ct);
|
||||||
.Where(m => m.ConversationId == id && m.Conversation.UserId == userId.Value)
|
|
||||||
.OrderBy(m => m.CreatedAt)
|
|
||||||
.Select(m => new { m.Id, Role = m.Role.ToString(), m.Content, m.Intent, m.MetadataJson, m.CreatedAt })
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
|
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 删除对话
|
// 删除对话
|
||||||
app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
var conv = await db.Conversations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId.Value, ct);
|
await conversations.DeleteAsync(userId.Value, id, ct);
|
||||||
if (conv != null)
|
|
||||||
{
|
|
||||||
db.Conversations.Remove(conv);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// VLM 食物识别
|
|
||||||
app.MapPost("/api/ai/analyze-food-image", async (
|
app.MapPost("/api/ai/analyze-food-image", async (
|
||||||
HttpRequest httpRequest, HttpContext http,
|
HttpRequest httpRequest,
|
||||||
VisionClient visionClient, AppDbContext db,
|
HttpContext http,
|
||||||
|
IDietImageAnalysisCoordinator dietAnalysis,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
@@ -245,61 +260,19 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
var form = await httpRequest.ReadFormAsync(ct);
|
var form = await httpRequest.ReadFormAsync(ct);
|
||||||
var files = form.Files.GetFiles("images");
|
var files = form.Files.GetFiles("images");
|
||||||
if (files == null || files.Count == 0)
|
var uploads = files
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
|
.Select(file => new DietImageUploadFile(file.FileName, file.Length, file.OpenReadStream()))
|
||||||
|
.ToList();
|
||||||
var imageUrls = new List<string>();
|
try
|
||||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
|
||||||
Directory.CreateDirectory(uploadsDir);
|
|
||||||
|
|
||||||
foreach (var file in files)
|
|
||||||
{
|
{
|
||||||
if (file.Length > 20 * 1024 * 1024)
|
var result = await dietAnalysis.AnalyzeAsync(uploads, ct);
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小超过 20MB 限制" });
|
return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||||
|
}
|
||||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
finally
|
||||||
if (ext is not ".jpg" and not ".jpeg" and not ".png" and not ".heic")
|
{
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的图片格式,仅支持 JPG/PNG/HEIC" });
|
foreach (var upload in uploads)
|
||||||
|
await upload.Content.DisposeAsync();
|
||||||
var safeName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}";
|
|
||||||
var filePath = Path.Combine(uploadsDir, safeName);
|
|
||||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
|
||||||
await file.CopyToAsync(stream, ct);
|
|
||||||
|
|
||||||
// 千问3.7-plus + vl_high_resolution_images=true 支持到 16M 像素,保留原图细节
|
|
||||||
// base64 ≥ 7MB(官方限制)时才压缩,否则原图上传
|
|
||||||
var fileBytes = await File.ReadAllBytesAsync(filePath, ct);
|
|
||||||
var base64 = Convert.ToBase64String(fileBytes);
|
|
||||||
if (base64.Length > 7 * 1024 * 1024)
|
|
||||||
{
|
|
||||||
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
|
||||||
CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 85L);
|
|
||||||
fileBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
|
||||||
base64 = Convert.ToBase64String(fileBytes);
|
|
||||||
}
|
|
||||||
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var prompt = """
|
|
||||||
识别图片中的食物和饮品,返回JSON数组:
|
|
||||||
[{"name":"名称","portion":"份量","calories":热量}]
|
|
||||||
只返回JSON,不要其他内容。
|
|
||||||
""";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
|
|
||||||
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
|
||||||
// 记录VLM原始返回用于排查
|
|
||||||
var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
|
||||||
var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.UtcNow:HHmmss}.txt");
|
|
||||||
await File.WriteAllTextAsync(logPath, $"MODEL: {Environment.GetEnvironmentVariable("VLM_MODEL")}\nIMAGE_SIZE: {imageUrls.FirstOrDefault()?.Length ?? 0}\nRESPONSE:\n{result}", ct);
|
|
||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Results.Ok(new { code = 50001, data = (object?)null, message = $"食物识别失败:{ex.Message}" });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,60 +321,176 @@ public static class AiChatEndpoints
|
|||||||
_ => CommonAgentHandler.Tools,
|
_ => CommonAgentHandler.Tools,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId, VisionClient? visionClient = null)
|
private static bool IsWriteToolCall(string toolName, string arguments)
|
||||||
{
|
{
|
||||||
using var jsonDoc = JsonDocument.Parse(arguments);
|
if (toolName == "record_health_data") return true;
|
||||||
var root = jsonDoc.RootElement;
|
if (toolName == "manage_archive") return GetToolAction(arguments) != "query";
|
||||||
|
if (toolName == "manage_medication") return GetToolAction(arguments) is "create" or "confirm";
|
||||||
|
if (toolName == "manage_exercise") return GetToolAction(arguments) is "create" or "checkin";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return toolName switch
|
private static string GetToolAction(string arguments)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, db, userId),
|
using var json = JsonDocument.Parse(arguments);
|
||||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
return json.RootElement.TryGetProperty("action", out var action)
|
||||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
? action.GetString()?.ToLowerInvariant() ?? ""
|
||||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
|
: "";
|
||||||
"analyze_report" => visionClient != null
|
}
|
||||||
? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient)
|
catch
|
||||||
: Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }),
|
{
|
||||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
|
return "";
|
||||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root),
|
}
|
||||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
}
|
||||||
|
|
||||||
|
private static async Task<object> PreparePendingWriteAsync(
|
||||||
|
IAiWriteConfirmationStore confirmations,
|
||||||
|
Guid userId,
|
||||||
|
string toolName,
|
||||||
|
string arguments,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct);
|
||||||
|
using var json = JsonDocument.Parse(arguments);
|
||||||
|
var args = json.RootElement;
|
||||||
|
|
||||||
|
var preview = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["pendingConfirmation"] = true,
|
||||||
|
["confirmationId"] = command.Id,
|
||||||
|
["message"] = "等待用户确认后写入",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
switch (toolName)
|
||||||
|
{
|
||||||
|
case "record_health_data":
|
||||||
|
AddHealthPreview(preview, args);
|
||||||
|
break;
|
||||||
|
case "manage_medication":
|
||||||
|
preview["type"] = "medication";
|
||||||
|
preview["name"] = GetString(args, "name") ?? "服药确认";
|
||||||
|
preview["dosage"] = GetString(args, "dosage") ?? "";
|
||||||
|
preview["frequency"] = GetString(args, "frequency") ?? "Daily";
|
||||||
|
preview["time"] = GetStringArray(args, "time_of_day");
|
||||||
|
preview["duration_days"] = GetInt(args, "duration_days") ?? 0;
|
||||||
|
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
|
||||||
|
break;
|
||||||
|
case "manage_exercise":
|
||||||
|
preview["type"] = "exercise";
|
||||||
|
preview["exercise_type"] = GetString(args, "exercise_type") ?? "运动";
|
||||||
|
preview["duration_minutes"] = GetInt(args, "duration_minutes") ?? 30;
|
||||||
|
preview["day_count"] = GetInt(args, "duration_days") ?? 7;
|
||||||
|
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
|
||||||
|
preview["reminder_time"] = GetString(args, "reminder_time") ?? "19:00";
|
||||||
|
break;
|
||||||
|
case "manage_archive":
|
||||||
|
preview["type"] = "archive";
|
||||||
|
preview["value"] = "健康档案更新";
|
||||||
|
preview["unit"] = "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preview;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 患者上下文构建 ──
|
private static void AddHealthPreview(Dictionary<string, object?> preview, JsonElement args)
|
||||||
|
|
||||||
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
var type = GetString(args, "type") ?? "";
|
||||||
var recentRecords = await db.HealthRecords.Where(r => r.UserId == userId)
|
switch (type)
|
||||||
.OrderByDescending(r => r.RecordedAt).Take(10).ToListAsync(ct);
|
|
||||||
|
|
||||||
var sb = new System.Text.StringBuilder();
|
|
||||||
if (archive != null)
|
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
case "blood_pressure":
|
||||||
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
var systolic = GetInt(args, "systolic");
|
||||||
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
|
var diastolic = GetInt(args, "diastolic");
|
||||||
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
|
preview["type"] = HealthMetricType.BloodPressure.ToString();
|
||||||
|
preview["value"] = $"{systolic}/{diastolic}";
|
||||||
|
preview["unit"] = "mmHg";
|
||||||
|
preview["isAbnormal"] = systolic >= 140 || diastolic >= 90 || systolic <= 89 || diastolic <= 59;
|
||||||
|
break;
|
||||||
|
case "heart_rate":
|
||||||
|
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
||||||
|
break;
|
||||||
|
case "glucose":
|
||||||
|
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
|
||||||
|
break;
|
||||||
|
case "spo2":
|
||||||
|
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
||||||
|
break;
|
||||||
|
case "weight":
|
||||||
|
AddMetricPreview(preview, HealthMetricType.Weight, GetDecimal(args, "weight"), "kg", _ => false);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (recentRecords.Count > 0)
|
|
||||||
{
|
|
||||||
sb.AppendLine("近期健康数据:");
|
|
||||||
foreach (var r in recentRecords)
|
|
||||||
sb.AppendLine($" {r.MetricType}: {RecordValue(r)} ({r.RecordedAt:MM-dd HH:mm})");
|
|
||||||
}
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string RecordValue(HealthRecord r) => r.MetricType switch
|
private static void AddMetricPreview(
|
||||||
|
Dictionary<string, object?> preview,
|
||||||
|
HealthMetricType type,
|
||||||
|
decimal? value,
|
||||||
|
string unit,
|
||||||
|
Func<decimal, bool> isAbnormal)
|
||||||
{
|
{
|
||||||
HealthMetricType.BloodPressure => $"{r.Systolic}/{r.Diastolic}",
|
preview["type"] = type.ToString();
|
||||||
HealthMetricType.HeartRate => $"{r.Value}次/分",
|
preview["value"] = value?.ToString() ?? "";
|
||||||
HealthMetricType.Glucose => $"{r.Value}",
|
preview["unit"] = unit;
|
||||||
HealthMetricType.SpO2 => $"{r.Value}%",
|
preview["isAbnormal"] = value.HasValue && isAbnormal(value.Value);
|
||||||
HealthMetricType.Weight => $"{r.Value}kg",
|
}
|
||||||
_ => "—"
|
|
||||||
};
|
private static string? GetString(JsonElement element, string name) =>
|
||||||
|
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
|
||||||
|
|
||||||
|
private static int? GetInt(JsonElement element, string name) =>
|
||||||
|
element.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : null;
|
||||||
|
|
||||||
|
private static decimal? GetDecimal(JsonElement element, string name) =>
|
||||||
|
element.TryGetProperty(name, out var value) && value.TryGetDecimal(out var result) ? result : null;
|
||||||
|
|
||||||
|
private static string GetStringArray(JsonElement element, string name) =>
|
||||||
|
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Array
|
||||||
|
? string.Join(", ", value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||||
|
: "";
|
||||||
|
|
||||||
|
private static string? DetectUrgentRisk(string message)
|
||||||
|
{
|
||||||
|
var text = message.Trim();
|
||||||
|
if (string.IsNullOrEmpty(text)) return null;
|
||||||
|
|
||||||
|
if (ContainsAny(text, "剧烈胸痛", "胸口剧痛", "胸痛明显", "胸闷憋气", "喘不上气", "呼吸困难", "呼吸很困难", "意识模糊", "昏厥", "晕厥", "说话不清", "口角歪斜", "一侧无力"))
|
||||||
|
return "你描述的症状可能属于需要紧急评估的危险信号。";
|
||||||
|
|
||||||
|
var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?<sys>\d{2,3})\s*/\s*(?<dia>\d{2,3})");
|
||||||
|
if (bp.Success &&
|
||||||
|
int.TryParse(bp.Groups["sys"].Value, out var systolic) &&
|
||||||
|
int.TryParse(bp.Groups["dia"].Value, out var diastolic) &&
|
||||||
|
(systolic >= 180 || diastolic >= 120))
|
||||||
|
{
|
||||||
|
return $"你提到的血压 {systolic}/{diastolic} mmHg 已达到明显危险范围。";
|
||||||
|
}
|
||||||
|
|
||||||
|
var spo2 = MatchMetricValue(text, "血氧|氧饱和|SpO2|spo2");
|
||||||
|
if (spo2.HasValue && spo2.Value <= 90)
|
||||||
|
return $"你提到的血氧 {spo2.Value:0.#}% 明显偏低。";
|
||||||
|
|
||||||
|
var glucose = MatchMetricValue(text, "血糖|葡萄糖|glucose");
|
||||||
|
if (glucose.HasValue && (glucose.Value >= 16.7m || glucose.Value <= 3.0m))
|
||||||
|
return $"你提到的血糖 {glucose.Value:0.#} mmol/L 属于需要尽快处理的异常范围。";
|
||||||
|
|
||||||
|
var heartRate = MatchMetricValue(text, "心率|脉搏|heart rate|HR|hr");
|
||||||
|
if (heartRate.HasValue && (heartRate.Value >= 130 || heartRate.Value <= 45))
|
||||||
|
return $"你提到的心率 {heartRate.Value:0.#} 次/分属于明显异常范围。";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(string text, params string[] keywords) =>
|
||||||
|
keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static decimal? MatchMetricValue(string text, string metricPattern)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(text, $@"(?:{metricPattern})[^\d]{{0,8}}(?<value>\d{{1,3}}(?:\.\d+)?)", RegexOptions.IgnoreCase);
|
||||||
|
return match.Success && decimal.TryParse(match.Groups["value"].Value, out var value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── JSON 类型安全转换 ──
|
// ── JSON 类型安全转换 ──
|
||||||
private static int _ToInt(object? v) => v switch
|
private static int _ToInt(object? v) => v switch
|
||||||
@@ -435,39 +524,59 @@ public static class AiChatEndpoints
|
|||||||
catch { resultDict = new Dictionary<string, object>(); }
|
catch { resultDict = new Dictionary<string, object>(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isPendingConfirmation = resultDict != null
|
||||||
|
&& resultDict.TryGetValue("pendingConfirmation", out var pendingValue)
|
||||||
|
&& _ToBool(pendingValue);
|
||||||
|
if (isPendingConfirmation && resultDict!.TryGetValue("confirmationId", out var confirmationIdValue))
|
||||||
|
{
|
||||||
|
var confirmationId = confirmationIdValue?.ToString()?.Trim('"');
|
||||||
|
if (!string.IsNullOrWhiteSpace(confirmationId))
|
||||||
|
{
|
||||||
|
if (!metadata.TryGetValue("confirmationIds", out var idsValue) || idsValue is not List<string> ids)
|
||||||
|
{
|
||||||
|
ids = [];
|
||||||
|
metadata["confirmationIds"] = ids;
|
||||||
|
}
|
||||||
|
ids.Add(confirmationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (toolName)
|
switch (toolName)
|
||||||
{
|
{
|
||||||
case "record_health_data":
|
case "record_health_data":
|
||||||
|
if (!isPendingConfirmation) break;
|
||||||
messageType = "data_confirm";
|
messageType = "data_confirm";
|
||||||
if (resultDict != null)
|
if (resultDict != null)
|
||||||
{
|
{
|
||||||
if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type.ToString();
|
if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type?.ToString() ?? "";
|
||||||
if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val.ToString();
|
if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val?.ToString() ?? "";
|
||||||
if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit.ToString();
|
if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit?.ToString() ?? "";
|
||||||
if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = abn is bool b2 ? b2 : false;
|
if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = _ToBool(abn);
|
||||||
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = success is bool b && b;
|
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = _ToBool(success);
|
||||||
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "manage_medication":
|
case "manage_medication":
|
||||||
|
if (!isPendingConfirmation) break;
|
||||||
messageType = "medication_confirm";
|
messageType = "medication_confirm";
|
||||||
if (resultDict != null)
|
if (resultDict != null)
|
||||||
{
|
{
|
||||||
if (resultDict.TryGetValue("name", out var name))
|
if (resultDict.TryGetValue("name", out var name))
|
||||||
metadata["name"] = name.ToString();
|
metadata["name"] = name?.ToString() ?? "";
|
||||||
if (resultDict.TryGetValue("dosage", out var dosage))
|
if (resultDict.TryGetValue("dosage", out var dosage))
|
||||||
metadata["dosage"] = dosage.ToString();
|
metadata["dosage"] = dosage?.ToString() ?? "";
|
||||||
if (resultDict.TryGetValue("time", out var time))
|
if (resultDict.TryGetValue("time", out var time))
|
||||||
metadata["time"] = time.ToString();
|
metadata["time"] = time?.ToString() ?? "";
|
||||||
if (resultDict.TryGetValue("frequency", out var freq))
|
if (resultDict.TryGetValue("frequency", out var freq))
|
||||||
metadata["frequency"] = freq.ToString();
|
metadata["frequency"] = freq?.ToString() ?? "";
|
||||||
if (resultDict.TryGetValue("duration_days", out var dd2) && dd2 is int d2)
|
if (resultDict.TryGetValue("duration_days", out var dd2))
|
||||||
metadata["duration_days"] = d2;
|
metadata["duration_days"] = _ToInt(dd2);
|
||||||
if (resultDict.TryGetValue("start_date", out var sd))
|
if (resultDict.TryGetValue("start_date", out var sd))
|
||||||
metadata["startDate"] = sd.ToString();
|
metadata["startDate"] = sd?.ToString() ?? "";
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "manage_exercise":
|
case "manage_exercise":
|
||||||
|
if (!isPendingConfirmation) break;
|
||||||
messageType = "data_confirm";
|
messageType = "data_confirm";
|
||||||
if (resultDict != null)
|
if (resultDict != null)
|
||||||
{
|
{
|
||||||
@@ -477,40 +586,27 @@ public static class AiChatEndpoints
|
|||||||
resultDict.TryGetValue("duration_minutes", out var dm);
|
resultDict.TryGetValue("duration_minutes", out var dm);
|
||||||
metadata["unit"] = dm != null ? $"每天{dm}分钟" : "";
|
metadata["unit"] = dm != null ? $"每天{dm}分钟" : "";
|
||||||
resultDict.TryGetValue("day_count", out var dc);
|
resultDict.TryGetValue("day_count", out var dc);
|
||||||
if (dc != null) metadata["durationDays"] = dc.ToString();
|
if (dc != null) metadata["durationDays"] = dc.ToString() ?? "";
|
||||||
resultDict.TryGetValue("success", out var ok);
|
resultDict.TryGetValue("success", out var ok);
|
||||||
metadata["success"] = ok?.ToString() == "True";
|
metadata["success"] = _ToBool(ok);
|
||||||
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "manage_archive":
|
||||||
|
if (!isPendingConfirmation) break;
|
||||||
|
messageType = "data_confirm";
|
||||||
|
metadata["type"] = "archive";
|
||||||
|
metadata["value"] = "健康档案更新";
|
||||||
|
metadata["unit"] = "";
|
||||||
|
metadata["success"] = true;
|
||||||
|
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||||
|
break;
|
||||||
case "analyze_report":
|
case "analyze_report":
|
||||||
messageType = "report_analysis";
|
messageType = "report_analysis";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 图片处理 ──
|
|
||||||
|
|
||||||
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
|
|
||||||
{
|
|
||||||
using var image = Image.FromFile(inputPath);
|
|
||||||
var width = image.Width;
|
|
||||||
var height = image.Height;
|
|
||||||
if (width > maxWidth)
|
|
||||||
{
|
|
||||||
height = (int)((double)height / width * maxWidth);
|
|
||||||
width = maxWidth;
|
|
||||||
}
|
|
||||||
using var bitmap = new Bitmap(width, height);
|
|
||||||
using var graphics = Graphics.FromImage(bitmap);
|
|
||||||
graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
|
|
||||||
graphics.DrawImage(image, 0, 0, width, height);
|
|
||||||
|
|
||||||
var jpegCodec = ImageCodecInfo.GetImageEncoders().First(c => c.MimeType == "image/jpeg");
|
|
||||||
var parameters = new EncoderParameters(1);
|
|
||||||
parameters.Param[0] = new EncoderParameter(Encoder.Quality, quality);
|
|
||||||
bitmap.Save(outputPath, jpegCodec, parameters);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ChatRequest(string Message, string? ConversationId);
|
public sealed record ChatRequest(string Message, string? ConversationId);
|
||||||
|
|||||||
@@ -1,281 +1,30 @@
|
|||||||
using Health.Infrastructure.Services;
|
using Health.Application.Auth;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 认证相关 API 端点
|
|
||||||
/// </summary>
|
|
||||||
public static class AuthEndpoints
|
public static class AuthEndpoints
|
||||||
{
|
{
|
||||||
private const string AdminPhone = "12345678910";
|
|
||||||
private const string AdminSmsCode = "000000";
|
|
||||||
|
|
||||||
public static void MapAuthEndpoints(this WebApplication app)
|
public static void MapAuthEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
// 发送短信验证码
|
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
app.MapPost("/api/auth/send-sms", async (
|
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
|
||||||
SendSmsRequest request,
|
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
AppDbContext db,
|
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||||
SmsService sms,
|
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
CancellationToken ct) =>
|
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||||
|
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
|
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||||
|
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var code = request.Phone == AdminPhone ? AdminSmsCode : sms.GenerateCode();
|
await auth.LogoutAsync(request.RefreshToken, ct);
|
||||||
var vc = new VerificationCode
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Phone = request.Phone,
|
|
||||||
Code = code,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
|
|
||||||
};
|
|
||||||
db.VerificationCodes.Add(vc);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
if (request.Phone != AdminPhone)
|
|
||||||
await sms.SendCodeAsync(request.Phone, code);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true, devCode = code }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 注册(仅限用户注册,需选择医生)──
|
|
||||||
app.MapPost("/api/auth/register", async (
|
|
||||||
RegisterRequest request,
|
|
||||||
AppDbContext db,
|
|
||||||
JwtProvider jwt,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
// 验证码
|
|
||||||
var validCode = await db.VerificationCodes
|
|
||||||
.Where(v => v.Phone == request.Phone
|
|
||||||
&& v.Code == request.SmsCode
|
|
||||||
&& v.ExpiresAt > DateTime.UtcNow
|
|
||||||
&& !v.IsUsed)
|
|
||||||
.OrderByDescending(v => v.CreatedAt)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
|
|
||||||
if (validCode == null)
|
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "验证码错误或已过期" });
|
|
||||||
|
|
||||||
validCode.IsUsed = true;
|
|
||||||
|
|
||||||
// 检查手机号是否已注册
|
|
||||||
var existing = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
|
||||||
if (existing != null)
|
|
||||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "该手机号已注册,请直接登录" });
|
|
||||||
|
|
||||||
// 校验姓名
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
return Results.Ok(new { code = 40003, data = (object?)null, message = "请输入姓名" });
|
|
||||||
|
|
||||||
// 校验医生
|
|
||||||
var doctor = await db.Doctors.FirstOrDefaultAsync(d => d.Id == request.DoctorId && d.IsActive, ct);
|
|
||||||
if (doctor == null)
|
|
||||||
return Results.Ok(new { code = 40004, data = (object?)null, message = "所选医生不存在或已停诊" });
|
|
||||||
|
|
||||||
var user = new User
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Phone = request.Phone,
|
|
||||||
Role = "User",
|
|
||||||
Name = request.Name,
|
|
||||||
DoctorId = request.DoctorId,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
db.Users.Add(user);
|
|
||||||
|
|
||||||
db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
|
|
||||||
db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, "User");
|
|
||||||
var refreshToken = jwt.GenerateRefreshToken();
|
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
UserId = user.Id,
|
|
||||||
Token = refreshToken,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
code = 0,
|
|
||||||
data = new
|
|
||||||
{
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
user = new { user.Id, user.Phone, user.Role, isNew = true }
|
|
||||||
},
|
|
||||||
message = (string?)null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 登录(已有账号 / 管理员)──
|
|
||||||
app.MapPost("/api/auth/login", async (
|
|
||||||
LoginRequest request,
|
|
||||||
AppDbContext db,
|
|
||||||
JwtProvider jwt,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var validCode = await db.VerificationCodes
|
|
||||||
.Where(v => v.Phone == request.Phone
|
|
||||||
&& v.Code == request.SmsCode
|
|
||||||
&& v.ExpiresAt > DateTime.UtcNow
|
|
||||||
&& !v.IsUsed)
|
|
||||||
.OrderByDescending(v => v.CreatedAt)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
|
|
||||||
if (validCode == null)
|
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "验证码错误或已过期" });
|
|
||||||
|
|
||||||
validCode.IsUsed = true;
|
|
||||||
|
|
||||||
// 管理员登录
|
|
||||||
if (request.Phone == AdminPhone)
|
|
||||||
{
|
|
||||||
var adminAccessToken = jwt.GenerateAccessToken(
|
|
||||||
Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin");
|
|
||||||
var adminRefreshToken = jwt.GenerateRefreshToken();
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
|
||||||
Token = adminRefreshToken,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
code = 0,
|
|
||||||
data = new
|
|
||||||
{
|
|
||||||
accessToken = adminAccessToken,
|
|
||||||
refreshToken = adminRefreshToken,
|
|
||||||
user = new { id = "00000000-0000-0000-0000-000000000001", phone = AdminPhone, role = "Admin", name = "管理员", isNew = false }
|
|
||||||
},
|
|
||||||
message = (string?)null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
|
||||||
if (user == null)
|
|
||||||
return Results.Ok(new { code = 40004, data = (object?)null, message = "该手机号未注册,请先登录" });
|
|
||||||
|
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role);
|
|
||||||
var refreshToken = jwt.GenerateRefreshToken();
|
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
UserId = user.Id,
|
|
||||||
Token = refreshToken,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
code = 0,
|
|
||||||
data = new
|
|
||||||
{
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
user = new {
|
|
||||||
user.Id, user.Phone, user.Role, user.Name,
|
|
||||||
user.Gender, user.AvatarUrl,
|
|
||||||
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"),
|
|
||||||
isNew = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
message = (string?)null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 刷新 token
|
|
||||||
app.MapPost("/api/auth/refresh", async (
|
|
||||||
RefreshRequest request,
|
|
||||||
AppDbContext db,
|
|
||||||
JwtProvider jwt,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var oldToken = await db.RefreshTokens
|
|
||||||
.FirstOrDefaultAsync(t => t.Token == request.RefreshToken && !t.IsRevoked, ct);
|
|
||||||
|
|
||||||
if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow)
|
|
||||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "登录已过期,请重新登录" });
|
|
||||||
|
|
||||||
oldToken.IsRevoked = true;
|
|
||||||
|
|
||||||
var user = await db.Users.FindAsync([oldToken.UserId], ct);
|
|
||||||
|
|
||||||
// 管理员刷新:无 User 记录,直接用固定信息
|
|
||||||
if (user == null && oldToken.UserId == Guid.Parse("00000000-0000-0000-0000-000000000001"))
|
|
||||||
{
|
|
||||||
var adminAccessToken = jwt.GenerateAccessToken(
|
|
||||||
Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin");
|
|
||||||
var adminNewRefresh = jwt.GenerateRefreshToken();
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
|
||||||
Token = adminNewRefresh,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
code = 0,
|
|
||||||
data = new { accessToken = adminAccessToken, refreshToken = adminNewRefresh, user = new { role = "Admin" } },
|
|
||||||
message = (string?)null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" });
|
|
||||||
|
|
||||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role);
|
|
||||||
var newRefreshToken = jwt.GenerateRefreshToken();
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
UserId = user.Id,
|
|
||||||
Token = newRefreshToken,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
code = 0,
|
|
||||||
data = new { accessToken, refreshToken = newRefreshToken, user = new { user.Role } },
|
|
||||||
message = (string?)null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 登出
|
|
||||||
app.MapPost("/api/auth/logout", async (
|
|
||||||
RefreshRequest request,
|
|
||||||
AppDbContext db,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var token = await db.RefreshTokens
|
|
||||||
.FirstOrDefaultAsync(t => t.Token == request.RefreshToken, ct);
|
|
||||||
if (token != null) token.IsRevoked = true;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IResult ToResult(AuthResult result) =>
|
||||||
|
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 请求 DTO ----
|
|
||||||
public sealed record SendSmsRequest(string Phone);
|
public sealed record SendSmsRequest(string Phone);
|
||||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.Application.Calendars;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
public static class CalendarEndpoints
|
public static class CalendarEndpoints
|
||||||
@@ -6,91 +8,22 @@ public static class CalendarEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/calendar").RequireAuthorization();
|
var group = app.MapGroup("/api/calendar").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("/", async (int year, int month, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/", async (int year, int month, HttpContext http, ICalendarService calendar, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
if (year is < 2000 or > 2100 || month is < 1 or > 12)
|
||||||
var start = new DateOnly(year, month, 1);
|
return Results.Ok(new { code = 400, data = (object?)null, message = "年月格式错误" });
|
||||||
var end = start.AddMonths(1);
|
|
||||||
var startDt = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
|
||||||
var endDt = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
var medications = await db.Medications
|
|
||||||
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
var plans = await db.ExercisePlans
|
|
||||||
.Where(p => p.UserId == userId).Include(p => p.Items).ToListAsync(ct);
|
|
||||||
var followups = await db.FollowUps
|
|
||||||
.Where(f => f.UserId == userId && f.ScheduledAt >= startDt && f.ScheduledAt < endDt)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
var result = new List<object>();
|
|
||||||
for (var d = start; d < end; d = d.AddDays(1))
|
|
||||||
{
|
|
||||||
var detailList = new List<object>();
|
|
||||||
|
|
||||||
// 用药:当前日期在用药有效期内
|
|
||||||
var todayMedications = medications
|
|
||||||
.Where(m => m.StartDate <= d && (m.EndDate == null || m.EndDate >= d));
|
|
||||||
foreach (var m in todayMedications)
|
|
||||||
{
|
|
||||||
detailList.Add(new { type = "medication", name = m.Name, dosage = m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 运动:按计划有效周范围 + 星期几匹配
|
|
||||||
foreach (var p in plans)
|
|
||||||
{
|
|
||||||
var weekEnd = p.WeekStartDate.AddDays(6);
|
|
||||||
if (d >= p.WeekStartDate && d <= weekEnd)
|
|
||||||
{
|
|
||||||
var dayItems = p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay);
|
|
||||||
foreach (var i in dayItems)
|
|
||||||
detailList.Add(new { type = "exercise", name = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 随访
|
|
||||||
var todayFollowups = followups.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == d);
|
|
||||||
foreach (var f in todayFollowups)
|
|
||||||
detailList.Add(new { type = "followup", title = f.Title, doctorName = f.DoctorName, department = f.Department, status = f.Status.ToString() });
|
|
||||||
|
|
||||||
if (detailList.Count > 0)
|
|
||||||
result.Add(new { date = d.ToString("yyyy-MM-dd"), events = detailList.Select(x => ((dynamic)x).type as string).Distinct().ToList(), details = detailList });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var result = await calendar.GetMonthAsync(GetUserId(http), year, month, ct);
|
||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取某一天的详细安排
|
group.MapGet("/day", async (string date, HttpContext http, ICalendarService calendar, CancellationToken ct) =>
|
||||||
group.MapGet("/day", async (string date, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
if (!DateOnly.TryParse(date, out var parsedDate))
|
||||||
if (!DateOnly.TryParse(date, out var d))
|
|
||||||
return Results.Ok(new { code = 400, data = (object?)null, message = "日期格式错误" });
|
return Results.Ok(new { code = 400, data = (object?)null, message = "日期格式错误" });
|
||||||
|
|
||||||
var medications = await db.Medications
|
var result = await calendar.GetDayAsync(GetUserId(http), parsedDate, ct);
|
||||||
.Where(m => m.UserId == userId && m.IsActive && m.StartDate <= d && (m.EndDate == null || m.EndDate >= d))
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
.Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() })
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
var plans = await db.ExercisePlans
|
|
||||||
.Where(p => p.UserId == userId)
|
|
||||||
.Include(p => p.Items).ToListAsync(ct);
|
|
||||||
var exercises = new List<object>();
|
|
||||||
foreach (var p in plans)
|
|
||||||
{
|
|
||||||
if (d >= p.WeekStartDate && d <= p.WeekStartDate.AddDays(6))
|
|
||||||
{
|
|
||||||
foreach (var i in p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay))
|
|
||||||
exercises.Add(new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var followUps = await db.FollowUps
|
|
||||||
.Where(f => f.UserId == userId && DateOnly.FromDateTime(f.ScheduledAt) == d)
|
|
||||||
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { medications, exercises, followUps }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,14 @@ public static class ConsultationEndpoints
|
|||||||
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, IHubContext<Hubs.ConsultationHub> hubContext, CancellationToken ct) =>
|
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, IHubContext<Hubs.ConsultationHub> hubContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
|
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId, ct);
|
||||||
|
if (consultation == null)
|
||||||
|
return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
|
||||||
|
|
||||||
|
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = consultation.Id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
|
||||||
db.ConsultationMessages.Add(msg);
|
db.ConsultationMessages.Add(msg);
|
||||||
|
|
||||||
// 用户发消息后,状态从 AiTalking → WaitingDoctor
|
// 用户发消息后,状态从 AiTalking → WaitingDoctor
|
||||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct);
|
|
||||||
if (consultation != null && consultation.Status == ConsultationStatus.AiTalking)
|
if (consultation != null && consultation.Status == ConsultationStatus.AiTalking)
|
||||||
{
|
{
|
||||||
consultation.Status = ConsultationStatus.WaitingDoctor;
|
consultation.Status = ConsultationStatus.WaitingDoctor;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.Application.Diets;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
public static class DietEndpoints
|
public static class DietEndpoints
|
||||||
@@ -6,82 +8,91 @@ public static class DietEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/diet-records").RequireAuthorization();
|
var group = app.MapGroup("/api/diet-records").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("/", async (string? date, string? mealType, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/", async (string? date, string? mealType, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
|
var records = await diets.ListAsync(userId, date, mealType, ct);
|
||||||
if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d);
|
|
||||||
if (Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt);
|
|
||||||
var records = await query.OrderByDescending(r => r.RecordedAt).Select(r => new
|
|
||||||
{
|
|
||||||
r.Id, MealType = r.MealType.ToString(), r.TotalCalories, r.HealthScore, r.RecordedAt,
|
|
||||||
foodItems = r.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories })
|
|
||||||
}).ToListAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPost("/", async (HttpRequest request, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
request.EnableBuffering();
|
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
|
||||||
using var reader = new StreamReader(request.Body);
|
var recordId = await diets.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct);
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
return Results.Ok(new { code = 0, data = new { id = recordId }, message = (string?)null });
|
||||||
request.Body.Position = 0;
|
|
||||||
using var json = JsonDocument.Parse(body);
|
|
||||||
var root = json.RootElement;
|
|
||||||
var record = new DietRecord
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
|
||||||
MealType = Enum.TryParse<MealType>(root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch", out var meal) ? meal : MealType.Lunch,
|
|
||||||
TotalCalories = root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null,
|
|
||||||
HealthScore = root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null,
|
|
||||||
RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
|
||||||
};
|
|
||||||
if (root.TryGetProperty("foodItems", out var items))
|
|
||||||
{
|
|
||||||
var i = 0;
|
|
||||||
foreach (var fi in items.EnumerateArray())
|
|
||||||
{
|
|
||||||
record.FoodItems.Add(new DietFoodItem
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), Name = fi.TryGetProperty("name", out var n) ? n.GetString()! : "",
|
|
||||||
Portion = fi.TryGetProperty("portion", out var p) ? p.GetString() : null,
|
|
||||||
Calories = fi.TryGetProperty("calories", out var c) ? c.GetInt32() : null,
|
|
||||||
SortOrder = fi.TryGetProperty("sortOrder", out var so) ? so.GetInt32() : i,
|
|
||||||
});
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.DietRecords.Add(record);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
await diets.DeleteAsync(userId, id, ct);
|
||||||
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct));
|
||||||
if (record == null) return Results.Ok(new { code = 40004, message = "记录不存在" });
|
var updated = await diets.UpdateAsync(userId, id, ReadPatchRequest(json.RootElement), ct);
|
||||||
|
if (!updated) return Results.Ok(new { code = 40004, message = "记录不存在" });
|
||||||
|
|
||||||
using var reader = new StreamReader(http.Request.Body);
|
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
|
||||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
|
||||||
if (json.RootElement.TryGetProperty("totalCalories", out var cal)) record.TotalCalories = (int?)cal.GetInt32();
|
|
||||||
if (json.RootElement.TryGetProperty("healthScore", out var hs)) record.HealthScore = (int?)hs.GetInt32();
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DietRecordCreateRequest ReadCreateRequest(JsonElement root)
|
||||||
|
{
|
||||||
|
var mealType = Enum.TryParse<MealType>(
|
||||||
|
root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch",
|
||||||
|
out var meal)
|
||||||
|
? meal
|
||||||
|
: MealType.Lunch;
|
||||||
|
var recordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d)
|
||||||
|
? d
|
||||||
|
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
|
||||||
|
return new DietRecordCreateRequest(
|
||||||
|
mealType,
|
||||||
|
root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null,
|
||||||
|
root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null,
|
||||||
|
recordedAt,
|
||||||
|
ReadFoodItems(root));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DietRecordPatchRequest ReadPatchRequest(JsonElement root) => new(
|
||||||
|
root.TryGetProperty("totalCalories", out var cal) ? cal.GetInt32() : null,
|
||||||
|
root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null);
|
||||||
|
|
||||||
|
private static List<DietFoodItemInput> ReadFoodItems(JsonElement root)
|
||||||
|
{
|
||||||
|
var result = new List<DietFoodItemInput>();
|
||||||
|
if (!root.TryGetProperty("foodItems", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
foreach (var fi in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
result.Add(new DietFoodItemInput(
|
||||||
|
fi.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
|
||||||
|
fi.TryGetProperty("portion", out var p) ? p.GetString() : null,
|
||||||
|
fi.TryGetProperty("calories", out var c) ? c.GetInt32() : null,
|
||||||
|
fi.TryGetProperty("sortOrder", out var so) ? so.GetInt32() : i));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
request.EnableBuffering();
|
||||||
|
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
||||||
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
|
request.Body.Position = 0;
|
||||||
|
return string.IsNullOrWhiteSpace(body) ? "{}" : body;
|
||||||
|
}
|
||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,31 +49,34 @@ public static class DoctorEndpoints
|
|||||||
var profile = await GetDoctorProfile(http, db);
|
var profile = await GetDoctorProfile(http, db);
|
||||||
if (profile == null)
|
if (profile == null)
|
||||||
return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在,请先完善个人信息" });
|
return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在,请先完善个人信息" });
|
||||||
|
if (profile.DoctorId == null)
|
||||||
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
var totalPatients = await db.Users.CountAsync(u => u.Role == "User");
|
var doctorId = profile.DoctorId.Value;
|
||||||
var activeConsultations = await db.Consultations.CountAsync(c => c.Status != ConsultationStatus.Closed);
|
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
|
||||||
var pendingReports = await db.Reports.CountAsync(r => r.Status == ReportStatus.PendingDoctor);
|
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
|
||||||
|
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
|
||||||
var todayStart = DateTime.UtcNow.Date;
|
var todayStart = DateTime.UtcNow.Date;
|
||||||
var todayEnd = todayStart.AddDays(1);
|
var todayEnd = todayStart.AddDays(1);
|
||||||
var todayFollowUps = await db.FollowUps.CountAsync(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
||||||
|
|
||||||
// 待办列表
|
// 待办列表
|
||||||
var pendingConsultations = await db.Consultations
|
var pendingConsultations = await db.Consultations
|
||||||
.Where(c => c.Status == ConsultationStatus.WaitingDoctor)
|
.Where(c => c.User.DoctorId == doctorId && c.Status == ConsultationStatus.WaitingDoctor)
|
||||||
.OrderByDescending(c => c.CreatedAt)
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.Select(c => new { c.Id, PatientName = c.User.Name ?? c.User.Phone, c.CreatedAt })
|
.Select(c => new { c.Id, PatientName = c.User.Name ?? c.User.Phone, c.CreatedAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var pendingReportList = await db.Reports
|
var pendingReportList = await db.Reports
|
||||||
.Where(r => r.Status == ReportStatus.PendingDoctor)
|
.Where(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor)
|
||||||
.OrderByDescending(r => r.CreatedAt)
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.Select(r => new { r.Id, PatientName = r.User.Name ?? r.User.Phone, r.Category, r.CreatedAt })
|
.Select(r => new { r.Id, PatientName = r.User.Name ?? r.User.Phone, r.Category, r.CreatedAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var todayFollowUpList = await db.FollowUps
|
var todayFollowUpList = await db.FollowUps
|
||||||
.Where(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming)
|
.Where(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming)
|
||||||
.OrderBy(f => f.ScheduledAt)
|
.OrderBy(f => f.ScheduledAt)
|
||||||
.Select(f => new { f.Id, f.Title, PatientName = f.User.Name ?? f.User.Phone, f.ScheduledAt })
|
.Select(f => new { f.Id, f.Title, PatientName = f.User.Name ?? f.User.Phone, f.ScheduledAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -121,11 +124,15 @@ public static class DoctorEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 患者详情 =====
|
// ===== 患者详情 =====
|
||||||
group.MapGet("/patients/{id:guid}", async (Guid id, AppDbContext db) =>
|
group.MapGet("/patients/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
|
if (doctorId == null)
|
||||||
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
var user = await db.Users
|
var user = await db.Users
|
||||||
.Include(u => u.HealthArchive)
|
.Include(u => u.HealthArchive)
|
||||||
.FirstOrDefaultAsync(u => u.Id == id);
|
.FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
|
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
|
||||||
|
|
||||||
@@ -153,7 +160,7 @@ public static class DoctorEndpoints
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var exercisePlans = await db.ExercisePlans
|
var exercisePlans = await db.ExercisePlans
|
||||||
.Where(p => p.UserId == id).OrderByDescending(p => p.WeekStartDate).Take(1).Include(p => p.Items)
|
.Where(p => p.UserId == id).OrderByDescending(p => p.StartDate).Take(1).Include(p => p.Items)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
var reports = await db.Reports
|
var reports = await db.Reports
|
||||||
@@ -177,7 +184,7 @@ public static class DoctorEndpoints
|
|||||||
trendRecords,
|
trendRecords,
|
||||||
medications,
|
medications,
|
||||||
dietRecords = dietRecords.Select(d => new { d.Id, MealType = d.MealType.ToString(), d.TotalCalories, d.HealthScore, d.RecordedAt, foods = d.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories, f.Warning }) }),
|
dietRecords = dietRecords.Select(d => new { d.Id, MealType = d.MealType.ToString(), d.TotalCalories, d.HealthScore, d.RecordedAt, foods = d.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories, f.Warning }) }),
|
||||||
exercisePlan = exercisePlans == null ? null : new { exercisePlans.WeekStartDate, items = exercisePlans.Items.Select(i => new { i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.IsRestDay, i.CompletedAt }) },
|
exercisePlan = exercisePlans == null ? null : new { exercisePlans.StartDate, exercisePlans.EndDate, exercisePlans.ReminderTime, items = exercisePlans.Items.Select(i => new { i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.IsRestDay, i.CompletedAt }) },
|
||||||
reports, followUps
|
reports, followUps
|
||||||
},
|
},
|
||||||
message = (string?)null
|
message = (string?)null
|
||||||
@@ -189,8 +196,10 @@ public static class DoctorEndpoints
|
|||||||
{
|
{
|
||||||
var profile = await GetDoctorProfile(http, db);
|
var profile = await GetDoctorProfile(http, db);
|
||||||
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
||||||
|
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
var consultations = await db.Consultations
|
var consultations = await db.Consultations
|
||||||
|
.Where(c => c.User.DoctorId == profile.DoctorId)
|
||||||
.OrderByDescending(c => c.CreatedAt)
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
.Select(c => new { c.Id, c.UserId, PatientName = c.User.Name, PatientPhone = c.User.Phone, Status = c.Status.ToString(), c.CreatedAt, c.ClosedAt, LastMessage = c.Messages.OrderByDescending(m => m.CreatedAt).Select(m => new { m.Content, SenderType = m.SenderType.ToString(), m.CreatedAt }).FirstOrDefault() })
|
.Select(c => new { c.Id, c.UserId, PatientName = c.User.Name, PatientPhone = c.User.Phone, Status = c.Status.ToString(), c.CreatedAt, c.ClosedAt, LastMessage = c.Messages.OrderByDescending(m => m.CreatedAt).Select(m => new { m.Content, SenderType = m.SenderType.ToString(), m.CreatedAt }).FirstOrDefault() })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -199,15 +208,22 @@ public static class DoctorEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 问诊消息 =====
|
// ===== 问诊消息 =====
|
||||||
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, AppDbContext db) =>
|
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, HttpContext http, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
|
if (doctorId == null)
|
||||||
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
|
var consultation = await db.Consultations
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == id && c.User.DoctorId == doctorId);
|
||||||
|
if (consultation == null)
|
||||||
|
return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
|
||||||
|
|
||||||
var messages = await db.ConsultationMessages
|
var messages = await db.ConsultationMessages
|
||||||
.Where(m => m.ConsultationId == id).OrderBy(m => m.CreatedAt)
|
.Where(m => m.ConsultationId == consultation.Id).OrderBy(m => m.CreatedAt)
|
||||||
.Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt })
|
.Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { status = consultation?.Status.ToString() ?? "Closed", messages }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { status = consultation?.Status.ToString() ?? "Closed", messages }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,8 +232,9 @@ public static class DoctorEndpoints
|
|||||||
{
|
{
|
||||||
var profile = await GetDoctorProfile(http, db);
|
var profile = await GetDoctorProfile(http, db);
|
||||||
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
||||||
|
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct);
|
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id && c.User.DoctorId == profile.DoctorId, ct);
|
||||||
if (consultation == null) return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
|
if (consultation == null) return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
|
||||||
|
|
||||||
using var reader = new StreamReader(http.Request.Body);
|
using var reader = new StreamReader(http.Request.Body);
|
||||||
@@ -249,9 +266,13 @@ public static class DoctorEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 报告列表 =====
|
// ===== 报告列表 =====
|
||||||
group.MapGet("/reports", async (string? status, AppDbContext db) =>
|
group.MapGet("/reports", async (string? status, HttpContext http, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var query = db.Reports.AsQueryable();
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
|
if (doctorId == null)
|
||||||
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
|
var query = db.Reports.Where(r => r.User.DoctorId == doctorId);
|
||||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<ReportStatus>(status, out var s))
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<ReportStatus>(status, out var s))
|
||||||
query = query.Where(r => r.Status == s);
|
query = query.Where(r => r.Status == s);
|
||||||
|
|
||||||
@@ -262,9 +283,13 @@ public static class DoctorEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 报告详情 =====
|
// ===== 报告详情 =====
|
||||||
group.MapGet("/reports/{id:guid}", async (Guid id, AppDbContext db) =>
|
group.MapGet("/reports/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var report = await db.Reports.Where(r => r.Id == id)
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
|
if (doctorId == null)
|
||||||
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
|
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId)
|
||||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
||||||
@@ -276,8 +301,9 @@ public static class DoctorEndpoints
|
|||||||
{
|
{
|
||||||
var profile = await GetDoctorProfile(http, db);
|
var profile = await GetDoctorProfile(http, db);
|
||||||
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
||||||
|
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id, ct);
|
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.User.DoctorId == profile.DoctorId, ct);
|
||||||
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
||||||
|
|
||||||
using var reader = new StreamReader(http.Request.Body);
|
using var reader = new StreamReader(http.Request.Body);
|
||||||
@@ -299,9 +325,13 @@ public static class DoctorEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 随访列表 =====
|
// ===== 随访列表 =====
|
||||||
group.MapGet("/follow-ups", async (string? status, string? patientId, AppDbContext db) =>
|
group.MapGet("/follow-ups", async (string? status, string? patientId, HttpContext http, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var query = db.FollowUps.AsQueryable();
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
|
if (doctorId == null)
|
||||||
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
|
var query = db.FollowUps.Where(f => f.User.DoctorId == doctorId);
|
||||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<FollowUpStatus>(status, out var s))
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<FollowUpStatus>(status, out var s))
|
||||||
query = query.Where(f => f.Status == s);
|
query = query.Where(f => f.Status == s);
|
||||||
if (Guid.TryParse(patientId, out var pid))
|
if (Guid.TryParse(patientId, out var pid))
|
||||||
@@ -318,15 +348,19 @@ public static class DoctorEndpoints
|
|||||||
{
|
{
|
||||||
var profile = await GetDoctorProfile(http, db);
|
var profile = await GetDoctorProfile(http, db);
|
||||||
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
||||||
|
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
using var reader = new StreamReader(http.Request.Body);
|
using var reader = new StreamReader(http.Request.Body);
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||||
|
var patientId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!);
|
||||||
|
var patientExists = await db.Users.AnyAsync(u => u.Id == patientId && u.DoctorId == profile.DoctorId, ct);
|
||||||
|
if (!patientExists) return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
|
||||||
|
|
||||||
var followUp = new FollowUp
|
var followUp = new FollowUp
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
UserId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!),
|
UserId = patientId,
|
||||||
Title = json.RootElement.GetProperty("title").GetString() ?? "",
|
Title = json.RootElement.GetProperty("title").GetString() ?? "",
|
||||||
DoctorName = profile.Name,
|
DoctorName = profile.Name,
|
||||||
Department = profile.Department,
|
Department = profile.Department,
|
||||||
@@ -343,7 +377,11 @@ public static class DoctorEndpoints
|
|||||||
// ===== 更新随访 =====
|
// ===== 更新随访 =====
|
||||||
group.MapPut("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPut("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct);
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
|
if (doctorId == null)
|
||||||
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
|
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id && f.User.DoctorId == doctorId, ct);
|
||||||
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
||||||
|
|
||||||
using var reader = new StreamReader(http.Request.Body);
|
using var reader = new StreamReader(http.Request.Body);
|
||||||
@@ -358,9 +396,13 @@ public static class DoctorEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 删除随访 =====
|
// ===== 删除随访 =====
|
||||||
group.MapDelete("/follow-ups/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) =>
|
group.MapDelete("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct);
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
|
if (doctorId == null)
|
||||||
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
|
||||||
|
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id && f.User.DoctorId == doctorId, ct);
|
||||||
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
||||||
db.FollowUps.Remove(followUp);
|
db.FollowUps.Remove(followUp);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
@@ -371,9 +413,9 @@ public static class DoctorEndpoints
|
|||||||
group.MapGet("/patients-simple", async (HttpContext http, AppDbContext db) =>
|
group.MapGet("/patients-simple", async (HttpContext http, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var doctorId = await GetDoctorEntityId(http, db);
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
var query = db.Users.Where(u => u.Role == "User");
|
if (doctorId == null)
|
||||||
if (doctorId != null)
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
query = query.Where(u => u.DoctorId == doctorId);
|
var query = db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId);
|
||||||
var patients = await query.OrderBy(u => u.Name).Select(u => new { u.Id, u.Name, u.Phone }).ToListAsync();
|
var patients = await query.OrderBy(u => u.Name).Select(u => new { u.Id, u.Name, u.Phone }).ToListAsync();
|
||||||
return Results.Ok(new { code = 0, data = patients, message = (string?)null });
|
return Results.Ok(new { code = 0, data = patients, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.Application.Exercises;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
public static class ExerciseEndpoints
|
public static class ExerciseEndpoints
|
||||||
@@ -6,126 +8,78 @@ public static class ExerciseEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/exercise-plans").RequireAuthorization();
|
var group = app.MapGroup("/api/exercise-plans").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
|
group.MapGet("/current", async (HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); // 北京时间
|
var plan = await exercises.GetCurrentAsync(userId, ct);
|
||||||
// 查找覆盖今天的所有计划(不仅限于周一开始的)
|
return Results.Ok(new { code = 0, data = plan, message = (string?)null });
|
||||||
var plans = await db.ExercisePlans.Include(p => p.Items)
|
|
||||||
.Where(p => p.UserId == userId && p.WeekStartDate <= today)
|
|
||||||
.OrderByDescending(p => p.WeekStartDate)
|
|
||||||
.ToListAsync();
|
|
||||||
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.DayOfWeek == (int)today.DayOfWeek));
|
|
||||||
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
code = 0,
|
|
||||||
data = new
|
|
||||||
{
|
|
||||||
plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt,
|
|
||||||
items = plan.Items.Select(i => new
|
|
||||||
{
|
|
||||||
i.Id, i.DayOfWeek,
|
|
||||||
i.ExerciseType, i.DurationMinutes,
|
|
||||||
i.IsCompleted, i.CompletedAt, i.IsRestDay
|
|
||||||
})
|
|
||||||
},
|
|
||||||
message = (string?)null
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPost("/", async (HttpRequest request, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
request.EnableBuffering();
|
var body = await ReadBodyAsync(request, ct);
|
||||||
using var reader = new StreamReader(request.Body);
|
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
|
||||||
request.Body.Position = 0;
|
|
||||||
using var json = JsonDocument.Parse(body);
|
using var json = JsonDocument.Parse(body);
|
||||||
var root = json.RootElement;
|
var root = json.RootElement;
|
||||||
var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动";
|
|
||||||
|
var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString() : "运动";
|
||||||
var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30;
|
var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30;
|
||||||
var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd")));
|
var startDate = DateOnly.Parse(root.TryGetProperty("startDate", out var sd)
|
||||||
var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd")));
|
? sd.GetString()!
|
||||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate };
|
: DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd"));
|
||||||
for (var d = startDate; d <= endDate; d = d.AddDays(1))
|
var endDate = DateOnly.Parse(root.TryGetProperty("endDate", out var ed)
|
||||||
{
|
? ed.GetString()!
|
||||||
plan.Items.Add(new ExercisePlanItem
|
: DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd"));
|
||||||
{
|
var reminderTime = root.TryGetProperty("reminderTime", out var rt) && TimeOnly.TryParse(rt.GetString(), out var parsedTime)
|
||||||
Id = Guid.NewGuid(),
|
? parsedTime
|
||||||
DayOfWeek = (int)d.DayOfWeek,
|
: new TimeOnly(19, 0);
|
||||||
ExerciseType = exerciseType,
|
|
||||||
DurationMinutes = duration,
|
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(startDate, endDate, exerciseType, duration, reminderTime), ct);
|
||||||
IsRestDay = false,
|
return Results.Ok(new { code = 0, data = new { id = planId }, message = (string?)null });
|
||||||
});
|
|
||||||
}
|
|
||||||
db.ExercisePlans.Add(plan);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapGet("/", async (HttpContext http, AppDbContext db) =>
|
group.MapGet("/", async (HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var plans = await db.ExercisePlans.Where(p => p.UserId == userId)
|
var plans = await exercises.ListAsync(userId, ct);
|
||||||
.OrderByDescending(p => p.WeekStartDate).Take(20)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
|
||||||
p.Id, p.WeekStartDate, p.CreatedAt,
|
|
||||||
totalDays = p.Items.Count,
|
|
||||||
completedDays = p.Items.Count(i => i.IsCompleted),
|
|
||||||
items = p.Items.OrderBy(i => i.DayOfWeek).Select(i => new
|
|
||||||
{
|
|
||||||
i.Id, i.DayOfWeek,
|
|
||||||
i.ExerciseType, i.DurationMinutes,
|
|
||||||
i.IsCompleted, i.IsRestDay
|
|
||||||
})
|
|
||||||
}).ToListAsync();
|
|
||||||
return Results.Ok(new { code = 0, data = plans, message = (string?)null });
|
return Results.Ok(new { code = 0, data = plans, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) =>
|
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var plan = await db.ExercisePlans.Include(p => p.Items)
|
var plan = await exercises.GetByIdAsync(userId, id, ct);
|
||||||
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId);
|
|
||||||
if (plan == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
if (plan == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||||
return Results.Ok(new
|
|
||||||
{
|
return Results.Ok(new { code = 0, data = plan, message = (string?)null });
|
||||||
code = 0,
|
|
||||||
data = new
|
|
||||||
{
|
|
||||||
plan.Id, plan.WeekStartDate, plan.CreatedAt,
|
|
||||||
items = plan.Items.OrderBy(i => i.DayOfWeek).Select(i => new
|
|
||||||
{
|
|
||||||
i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes,
|
|
||||||
i.IsCompleted, i.CompletedAt, i.IsRestDay
|
|
||||||
})
|
|
||||||
},
|
|
||||||
message = (string?)null
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId, ct);
|
await exercises.DeleteAsync(userId, id, ct);
|
||||||
if (plan != null) { db.ExercisePlans.Remove(plan); await db.SaveChangesAsync(ct); }
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var item = await db.ExercisePlanItems.FindAsync([itemId], ct);
|
var userId = GetUserId(http);
|
||||||
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
var completed = await exercises.ToggleCheckInAsync(userId, itemId, ct);
|
||||||
item.IsCompleted = !item.IsCompleted;
|
if (completed == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||||
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
|
|
||||||
await db.SaveChangesAsync(ct);
|
return Results.Ok(new { code = 0, data = new { completed }, message = (string?)null });
|
||||||
return Results.Ok(new { code = 0, data = new { completed = item.IsCompleted }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
request.EnableBuffering();
|
||||||
|
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
||||||
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
|
request.Body.Position = 0;
|
||||||
|
return string.IsNullOrWhiteSpace(body) ? "{}" : body;
|
||||||
|
}
|
||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,55 @@ namespace Health.WebApi.Endpoints;
|
|||||||
|
|
||||||
public static class FileEndpoints
|
public static class FileEndpoints
|
||||||
{
|
{
|
||||||
|
private const long MaxFileSize = 20 * 1024 * 1024;
|
||||||
|
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf"
|
||||||
|
};
|
||||||
|
|
||||||
public static void MapFileEndpoints(this WebApplication app)
|
public static void MapFileEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/files").RequireAuthorization();
|
var group = app.MapGroup("/api/files").RequireAuthorization();
|
||||||
|
|
||||||
group.MapPost("/upload", async (HttpRequest request) =>
|
group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var form = await request.ReadFormAsync();
|
if (!request.HasFormContentType)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
|
||||||
|
|
||||||
|
var form = await request.ReadFormAsync(ct);
|
||||||
var files = form.Files;
|
var files = form.Files;
|
||||||
|
if (files.Count == 0)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
|
||||||
|
|
||||||
var results = new List<object>();
|
var results = new List<object>();
|
||||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||||
Directory.CreateDirectory(uploadsDir);
|
Directory.CreateDirectory(uploadsDir);
|
||||||
|
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
{
|
{
|
||||||
|
if (file.Length <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (file.Length > MaxFileSize)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小不能超过 20MB" });
|
||||||
|
|
||||||
var fileId = Guid.NewGuid().ToString();
|
var fileId = Guid.NewGuid().ToString();
|
||||||
var ext = Path.GetExtension(file.FileName);
|
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
|
if (!AllowedExtensions.Contains(ext))
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的文件类型" });
|
||||||
|
|
||||||
|
var storedName = $"{fileId}{ext}";
|
||||||
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
|
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
|
||||||
using var stream = new FileStream(filePath, FileMode.Create);
|
await using var stream = new FileStream(filePath, FileMode.Create);
|
||||||
await file.CopyToAsync(stream);
|
await file.CopyToAsync(stream, ct);
|
||||||
results.Add(new { id = fileId, name = file.FileName, size = file.Length });
|
results.Add(new
|
||||||
|
{
|
||||||
|
id = fileId,
|
||||||
|
name = file.FileName,
|
||||||
|
size = file.Length,
|
||||||
|
url = $"/uploads/{storedName}",
|
||||||
|
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.Application.HealthRecords;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -9,127 +11,82 @@ public static class HealthEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/health-records").RequireAuthorization();
|
var group = app.MapGroup("/api/health-records").RequireAuthorization();
|
||||||
|
|
||||||
// 查询健康记录
|
|
||||||
group.MapGet("/", async (
|
group.MapGet("/", async (
|
||||||
string? type, int? days,
|
string? type,
|
||||||
HttpContext http, AppDbContext db, CancellationToken ct) =>
|
int? days,
|
||||||
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var query = db.HealthRecords.Where(r => r.UserId == userId);
|
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
|
||||||
query = query.Where(r => r.MetricType == mt);
|
|
||||||
|
|
||||||
if (days.HasValue)
|
|
||||||
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days.Value));
|
|
||||||
|
|
||||||
var records = await query.OrderByDescending(r => r.RecordedAt).Take(100)
|
|
||||||
.Select(r => new
|
|
||||||
{
|
|
||||||
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit,
|
|
||||||
Source = r.Source.ToString(), r.IsAbnormal, r.RecordedAt
|
|
||||||
}).ToListAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 新增健康记录
|
group.MapPost("/", async (
|
||||||
group.MapPost("/", async (CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
CreateHealthRecordRequest req,
|
||||||
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var record = new HealthRecord
|
var id = await healthRecords.CreateAsync(userId, req.ToServiceRequest(), ct);
|
||||||
{
|
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
||||||
Id = Guid.NewGuid(), UserId = userId, MetricType = req.Type,
|
|
||||||
Systolic = req.Systolic, Diastolic = req.Diastolic, Value = req.Value,
|
|
||||||
Unit = req.Unit, Source = req.Source, RecordedAt = req.RecordedAt ?? DateTime.UtcNow,
|
|
||||||
IsAbnormal = CheckAbnormal(req),
|
|
||||||
};
|
|
||||||
db.HealthRecords.Add(record);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 修改健康记录
|
group.MapPut("/{id:guid}", async (
|
||||||
group.MapPut("/{id:guid}", async (Guid id, CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
Guid id,
|
||||||
|
CreateHealthRecordRequest req,
|
||||||
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
var ok = await healthRecords.UpdateAsync(userId, id, req.ToServiceRequest(), ct);
|
||||||
if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
|
return ok
|
||||||
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
record.Systolic = req.Systolic;
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
|
||||||
record.Diastolic = req.Diastolic;
|
|
||||||
record.Value = req.Value;
|
|
||||||
record.Unit = req.Unit;
|
|
||||||
record.RecordedAt = req.RecordedAt ?? record.RecordedAt;
|
|
||||||
record.IsAbnormal = CheckAbnormal(req);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 删除健康记录
|
group.MapDelete("/{id:guid}", async (
|
||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
Guid id,
|
||||||
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
var ok = await healthRecords.DeleteAsync(userId, id, ct);
|
||||||
if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
|
return ok
|
||||||
db.HealthRecords.Remove(record);
|
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||||
await db.SaveChangesAsync(ct);
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取各指标最新值
|
group.MapGet("/latest", async (
|
||||||
group.MapGet("/latest", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var types = new[] { HealthMetricType.BloodPressure, HealthMetricType.HeartRate, HealthMetricType.Glucose, HealthMetricType.SpO2, HealthMetricType.Weight };
|
var latest = await healthRecords.GetLatestAsync(userId, ct);
|
||||||
var result = new Dictionary<string, object?>();
|
return Results.Ok(new { code = 0, data = latest, message = (string?)null });
|
||||||
|
|
||||||
foreach (var t in types)
|
|
||||||
{
|
|
||||||
var latest = await db.HealthRecords
|
|
||||||
.Where(r => r.UserId == userId && r.MetricType == t)
|
|
||||||
.OrderByDescending(r => r.RecordedAt)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
|
|
||||||
result[t.ToString()] = latest == null ? null : new
|
|
||||||
{
|
|
||||||
latest.Systolic, latest.Diastolic, latest.Value, latest.Unit, latest.RecordedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 趋势数据
|
group.MapGet("/trend", async (
|
||||||
group.MapGet("/trend", async (string type, int period, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
string type,
|
||||||
|
int period,
|
||||||
|
HttpContext http,
|
||||||
|
IHealthRecordService healthRecords,
|
||||||
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
if (!Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
if (!Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var metricType))
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的指标类型" });
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的指标类型" });
|
||||||
|
|
||||||
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 };
|
var records = await healthRecords.GetTrendAsync(userId, metricType, period, ct);
|
||||||
var records = await db.HealthRecords
|
|
||||||
.Where(r => r.UserId == userId && r.MetricType == mt && r.RecordedAt >= DateTime.UtcNow.AddDays(-days))
|
|
||||||
.OrderBy(r => r.RecordedAt)
|
|
||||||
.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt })
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool CheckAbnormal(CreateHealthRecordRequest req) => req.Type switch
|
|
||||||
{
|
|
||||||
HealthMetricType.BloodPressure => req.Systolic >= 140 || req.Diastolic >= 90 || req.Systolic <= 89 || req.Diastolic <= 59,
|
|
||||||
HealthMetricType.HeartRate => req.Value > 100 || req.Value < 60,
|
|
||||||
HealthMetricType.Glucose => req.Value >= 7.0m || req.Value <= 3.8m,
|
|
||||||
HealthMetricType.SpO2 => req.Value <= 94,
|
|
||||||
HealthMetricType.Weight => false, // 体重无标准异常范围
|
|
||||||
_ => false
|
|
||||||
};
|
|
||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
@@ -143,4 +100,13 @@ public sealed class CreateHealthRecordRequest
|
|||||||
public string? Unit { get; init; }
|
public string? Unit { get; init; }
|
||||||
public HealthRecordSource Source { get; init; }
|
public HealthRecordSource Source { get; init; }
|
||||||
public DateTime? RecordedAt { get; init; }
|
public DateTime? RecordedAt { get; init; }
|
||||||
|
|
||||||
|
public HealthRecordUpsertRequest ToServiceRequest() => new(
|
||||||
|
Type,
|
||||||
|
Systolic,
|
||||||
|
Diastolic,
|
||||||
|
Value,
|
||||||
|
Unit,
|
||||||
|
Source,
|
||||||
|
RecordedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.Application.Medications;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
public static class MedicationEndpoints
|
public static class MedicationEndpoints
|
||||||
@@ -6,255 +8,139 @@ public static class MedicationEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/medications").RequireAuthorization();
|
var group = app.MapGroup("/api/medications").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("/", async (string? filter, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/", async (string? filter, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var query = db.Medications.Where(m => m.UserId == userId);
|
var meds = await medications.ListAsync(userId, filter, ct);
|
||||||
if (filter == "active") query = query.Where(m => m.IsActive);
|
|
||||||
else if (filter == "inactive") query = query.Where(m => !m.IsActive);
|
|
||||||
// 北京时间今天对应的 UTC 区间
|
|
||||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
|
||||||
var todayStartUtc = beijingToday.AddHours(-8);
|
|
||||||
var todayEndUtc = todayStartUtc.AddDays(1);
|
|
||||||
var meds = await query.OrderByDescending(m => m.CreatedAt).Select(m => new
|
|
||||||
{
|
|
||||||
m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(),
|
|
||||||
m.TimeOfDay, m.StartDate, m.EndDate, m.IsActive, m.Notes,
|
|
||||||
Source = m.Source.ToString(), m.CreatedAt, m.UpdatedAt,
|
|
||||||
todayTaken = m.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken)
|
|
||||||
}).ToListAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
|
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPost("/", async (HttpRequest request, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
request.EnableBuffering();
|
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
|
||||||
using var reader = new StreamReader(request.Body);
|
var medId = await medications.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct);
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
return Results.Ok(new { code = 0, data = new { id = medId }, message = (string?)null });
|
||||||
request.Body.Position = 0;
|
|
||||||
using var json = JsonDocument.Parse(body);
|
|
||||||
var root = json.RootElement;
|
|
||||||
var med = new Medication
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
|
||||||
Name = root.GetProperty("name").GetString()!,
|
|
||||||
Dosage = root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
|
||||||
Frequency = Enum.TryParse<MedicationFrequency>(root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily", out var freq) ? freq : MedicationFrequency.Daily,
|
|
||||||
Source = Enum.TryParse<MedicationSource>(root.TryGetProperty("source", out var s) ? s.GetString() : "Manual", out var src) ? src : MedicationSource.Manual,
|
|
||||||
IsActive = true,
|
|
||||||
};
|
|
||||||
if (root.TryGetProperty("timeOfDay", out var tod))
|
|
||||||
med.TimeOfDay = tod.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList();
|
|
||||||
if (root.TryGetProperty("startDate", out var sd))
|
|
||||||
med.StartDate = DateOnly.TryParse(sd.GetString(), out var d) ? d : null;
|
|
||||||
if (root.TryGetProperty("endDate", out var ed))
|
|
||||||
med.EndDate = DateOnly.TryParse(ed.GetString(), out var d2) ? d2 : null;
|
|
||||||
if (root.TryGetProperty("notes", out var nt)) med.Notes = nt.GetString();
|
|
||||||
db.Medications.Add(med);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (Guid id, HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPut("/{id:guid}", async (Guid id, HttpRequest request, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
|
||||||
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
var updated = await medications.UpdateAsync(userId, id, ReadPatchRequest(json.RootElement), ct);
|
||||||
request.EnableBuffering();
|
if (!updated) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||||
using var reader = new StreamReader(request.Body);
|
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
|
||||||
request.Body.Position = 0;
|
|
||||||
using var json = JsonDocument.Parse(body);
|
|
||||||
var root = json.RootElement;
|
|
||||||
if (root.TryGetProperty("name", out var n)) med.Name = n.GetString()!;
|
|
||||||
if (root.TryGetProperty("dosage", out var dg2)) med.Dosage = dg2.GetString();
|
|
||||||
if (root.TryGetProperty("frequency", out var f2) && Enum.TryParse<MedicationFrequency>(f2.GetString(), out var freq2)) med.Frequency = freq2;
|
|
||||||
if (root.TryGetProperty("timeOfDay", out var tod2)) med.TimeOfDay = tod2.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList();
|
|
||||||
if (root.TryGetProperty("startDate", out var sd2) && DateOnly.TryParse(sd2.GetString(), out var d3)) med.StartDate = d3;
|
|
||||||
if (root.TryGetProperty("endDate", out var ed2) && DateOnly.TryParse(ed2.GetString(), out var d4)) med.EndDate = d4;
|
|
||||||
if (root.TryGetProperty("notes", out var nt2)) med.Notes = nt2.GetString();
|
|
||||||
med.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
await medications.DeleteAsync(userId, id, ct);
|
||||||
if (med != null) { db.Medications.Remove(med); await db.SaveChangesAsync(ct); }
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
var taken = await medications.ToggleTodayTakenAsync(userId, id, ct);
|
||||||
var todayStartUtc = beijingToday.AddHours(-8);
|
if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" });
|
||||||
var todayEndUtc = todayStartUtc.AddDays(1);
|
|
||||||
var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken, ct);
|
return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null });
|
||||||
if (existing != null) {
|
|
||||||
db.MedicationLogs.Remove(existing);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { taken = false }, message = (string?)null });
|
|
||||||
}
|
|
||||||
var log = new MedicationLog
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
|
||||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
db.MedicationLogs.Add(log);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { taken = true }, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
var reminders = await medications.GetRemindersAsync(userId, ct);
|
||||||
var now = TimeOnly.FromDateTime(beijingNow);
|
|
||||||
var today = DateOnly.FromDateTime(beijingNow);
|
|
||||||
var todayUtc = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
// 只查活跃且在有效期内的药
|
|
||||||
var meds = await db.Medications
|
|
||||||
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null
|
|
||||||
&& m.StartDate <= today && (m.EndDate == null || m.EndDate >= today))
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
// 查询今天所有服药记录
|
|
||||||
var logs = await db.MedicationLogs
|
|
||||||
.Where(l => l.UserId == userId && l.ConfirmedAt >= todayUtc)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
// 按次生成提醒
|
|
||||||
var reminders = new List<object>();
|
|
||||||
foreach (var m in meds)
|
|
||||||
{
|
|
||||||
// 处理隔天/每周的逻辑
|
|
||||||
var dosesToday = GetDosesForToday(m, today);
|
|
||||||
if (!dosesToday) continue;
|
|
||||||
|
|
||||||
foreach (var t in m.TimeOfDay!)
|
|
||||||
{
|
|
||||||
var scheduledTime = t;
|
|
||||||
var doseTimeBeijing = today.ToDateTime(scheduledTime);
|
|
||||||
|
|
||||||
// 查该药该时间是否已打卡
|
|
||||||
var alreadyLogged = logs.Any(l =>
|
|
||||||
l.MedicationId == m.Id && l.ScheduledTime == scheduledTime);
|
|
||||||
|
|
||||||
string status;
|
|
||||||
if (alreadyLogged)
|
|
||||||
{
|
|
||||||
var log = logs.First(l => l.MedicationId == m.Id && l.ScheduledTime == scheduledTime);
|
|
||||||
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
|
|
||||||
}
|
|
||||||
else if (scheduledTime <= now.AddMinutes(-30))
|
|
||||||
{
|
|
||||||
status = "overdue"; // 过了30分钟还没吃
|
|
||||||
}
|
|
||||||
else if (scheduledTime <= now.AddHours(3))
|
|
||||||
{
|
|
||||||
status = "upcoming"; // 未来3小时内
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
continue; // 太远,不显示
|
|
||||||
}
|
|
||||||
|
|
||||||
reminders.Add(new
|
|
||||||
{
|
|
||||||
m.Id, m.Name, m.Dosage,
|
|
||||||
scheduledTime = scheduledTime.ToString("HH:mm"),
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// overdue 在前,upcoming 在后,taken 在最后
|
|
||||||
reminders = reminders.OrderBy(r =>
|
|
||||||
((dynamic)r).status == "overdue" ? 0 :
|
|
||||||
((dynamic)r).status == "upcoming" ? 1 : 2
|
|
||||||
).ToList();
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = reminders, message = (string?)null });
|
return Results.Ok(new { code = 0, data = reminders, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 按次打卡
|
group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct));
|
||||||
if (med == null) return Results.Ok(new { code = 404, message = "药品不存在" });
|
|
||||||
|
|
||||||
using var reader = new System.IO.StreamReader(http.Request.Body);
|
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
|
||||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
|
||||||
var timeStr = json.RootElement.GetProperty("scheduledTime").GetString()!;
|
var timeStr = json.RootElement.GetProperty("scheduledTime").GetString()!;
|
||||||
var statusStr = json.RootElement.TryGetProperty("status", out var st) ? st.GetString() : "taken";
|
var statusStr = json.RootElement.TryGetProperty("status", out var st) ? st.GetString() : "taken";
|
||||||
var scheduledTime = TimeOnly.Parse(timeStr);
|
var scheduledTime = TimeOnly.Parse(timeStr);
|
||||||
var status = statusStr == "taken" ? MedicationLogStatus.Taken : MedicationLogStatus.Skipped;
|
var status = statusStr == "taken" ? MedicationLogStatus.Taken : MedicationLogStatus.Skipped;
|
||||||
|
|
||||||
// 防止重复打卡
|
var result = await medications.ConfirmDoseAsync(userId, id, scheduledTime, status, ct);
|
||||||
var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc);
|
if (result == null) return Results.Ok(new { code = 404, message = "药品不存在" });
|
||||||
var existing = await db.MedicationLogs.AnyAsync(l =>
|
if (result == false) return Results.Ok(new { code = 0, data = new { success = false, message = "已打卡" }, message = (string?)null });
|
||||||
l.MedicationId == id && l.UserId == userId
|
|
||||||
&& l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc, ct);
|
|
||||||
if (existing) return Results.Ok(new { code = 0, data = new { success = false, message = "已打卡" }, message = (string?)null });
|
|
||||||
|
|
||||||
db.MedicationLogs.Add(new MedicationLog
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
|
||||||
Status = status, ScheduledTime = scheduledTime, ConfirmedAt = DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 取消打卡(删掉对应 log)
|
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||||
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var scheduledTime = TimeOnly.Parse(time);
|
await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
||||||
var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc);
|
|
||||||
var log = await db.MedicationLogs
|
|
||||||
.Where(l => l.MedicationId == id && l.UserId == userId
|
|
||||||
&& l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
if (log != null) { db.MedicationLogs.Remove(log); await db.SaveChangesAsync(ct); }
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private static MedicationUpsertRequest ReadCreateRequest(JsonElement root)
|
||||||
/// 判断今天是否该吃药(处理隔天/每周频率)
|
|
||||||
/// </summary>
|
|
||||||
private static bool GetDosesForToday(Medication m, DateOnly today)
|
|
||||||
{
|
{
|
||||||
if (m.Frequency == MedicationFrequency.Daily) return true;
|
var frequency = Enum.TryParse<MedicationFrequency>(
|
||||||
if (m.Frequency == MedicationFrequency.TwiceDaily) return true;
|
root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily",
|
||||||
if (m.Frequency == MedicationFrequency.ThreeTimesDaily) return true;
|
out var freq)
|
||||||
if (m.Frequency == MedicationFrequency.AsNeeded) return true;
|
? freq
|
||||||
|
: MedicationFrequency.Daily;
|
||||||
|
var source = Enum.TryParse<MedicationSource>(
|
||||||
|
root.TryGetProperty("source", out var s) ? s.GetString() : "Manual",
|
||||||
|
out var src)
|
||||||
|
? src
|
||||||
|
: MedicationSource.Manual;
|
||||||
|
|
||||||
var startDate = m.StartDate ?? today;
|
return new MedicationUpsertRequest(
|
||||||
if (m.Frequency == MedicationFrequency.EveryOtherDay)
|
root.GetProperty("name").GetString()!,
|
||||||
{
|
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||||
var daysSinceStart = today.DayNumber - startDate.DayNumber;
|
frequency,
|
||||||
return daysSinceStart % 2 == 0;
|
ReadTimes(root, "timeOfDay"),
|
||||||
}
|
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
||||||
if (m.Frequency == MedicationFrequency.Weekly)
|
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||||
{
|
source,
|
||||||
return today.DayOfWeek == startDate.DayOfWeek;
|
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool InTimeWindow(TimeOnly t, TimeOnly start, TimeOnly end) =>
|
private static MedicationPatchRequest ReadPatchRequest(JsonElement root)
|
||||||
end > start ? (t >= start && t <= end) : (t >= start || t <= end); // 处理跨午夜
|
{
|
||||||
|
MedicationFrequency? frequency = null;
|
||||||
|
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
||||||
|
frequency = freq;
|
||||||
|
|
||||||
|
return new MedicationPatchRequest(
|
||||||
|
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
||||||
|
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||||
|
frequency,
|
||||||
|
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
||||||
|
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
||||||
|
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||||
|
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty(propertyName, out var tod) || tod.ValueKind != JsonValueKind.Array)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return tod.EnumerateArray()
|
||||||
|
.Select(t => TimeOnly.TryParse(t.GetString(), out var parsed) ? parsed : (TimeOnly?)null)
|
||||||
|
.Where(t => t.HasValue)
|
||||||
|
.Select(t => t!.Value)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
request.EnableBuffering();
|
||||||
|
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
||||||
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
|
request.Body.Position = 0;
|
||||||
|
return string.IsNullOrWhiteSpace(body) ? "{}" : body;
|
||||||
|
}
|
||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
@@ -8,294 +8,62 @@ public static class ReportEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/reports").RequireAuthorization();
|
var group = app.MapGroup("/api/reports").RequireAuthorization();
|
||||||
|
|
||||||
// 列表
|
group.MapGet("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||||
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct);
|
var data = await reports.GetReportsAsync(userId, ct);
|
||||||
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
return Results.Ok(new { code = 0, data, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 删除
|
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
var report = await reports.GetReportAsync(userId, id, ct);
|
||||||
if (report == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
return report == null
|
||||||
// 删除本地文件
|
? Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" })
|
||||||
if (!string.IsNullOrEmpty(report.FileUrl))
|
: Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||||
{
|
|
||||||
var filePath = Path.Combine(Directory.GetCurrentDirectory(), report.FileUrl.TrimStart('/'));
|
|
||||||
if (File.Exists(filePath)) File.Delete(filePath);
|
|
||||||
}
|
|
||||||
db.Reports.Remove(report);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, message = "已删除" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 详情
|
group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||||
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
|
||||||
return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
// 上传 + AI 预解读(VLM 提取指标 → LLM 生成摘要)
|
|
||||||
group.MapPost("/", async (HttpContext http, AppDbContext db, IServiceScopeFactory scopeFactory, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
|
|
||||||
// 读 multipart form
|
|
||||||
if (!http.Request.HasFormContentType)
|
if (!http.Request.HasFormContentType)
|
||||||
return Results.Ok(new { code = 400, message = "需要 multipart/form-data" });
|
return Results.Ok(new { code = 400, data = (object?)null, message = "需要 multipart/form-data" });
|
||||||
|
|
||||||
var form = await http.Request.ReadFormAsync(ct);
|
var form = await http.Request.ReadFormAsync(ct);
|
||||||
var file = form.Files.GetFile("file");
|
var file = form.Files.GetFile("file");
|
||||||
if (file == null || file.Length == 0)
|
if (file == null)
|
||||||
return Results.Ok(new { code = 400, message = "未上传文件" });
|
return Results.Ok(new { code = 400, data = (object?)null, message = "未上传文件" });
|
||||||
|
|
||||||
// 保存文件
|
await using var stream = file.OpenReadStream();
|
||||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports");
|
var result = await reports.UploadReportAsync(
|
||||||
Directory.CreateDirectory(uploadsDir);
|
userId,
|
||||||
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
|
new ReportUploadFile(file.FileName, file.Length, stream),
|
||||||
var filePath = Path.Combine(uploadsDir, fileName);
|
ct);
|
||||||
await using (var stream = new FileStream(filePath, FileMode.Create))
|
|
||||||
await file.CopyToAsync(stream, ct);
|
|
||||||
|
|
||||||
var fileUrl = $"/uploads/reports/{fileName}";
|
return Results.Ok(new { code = result.Code, data = result.Report, message = result.Message });
|
||||||
var isPdf = Path.GetExtension(fileName).ToLowerInvariant() == ".pdf";
|
});
|
||||||
|
|
||||||
// 创建报告记录
|
group.MapPost("/{id:guid}/reanalyze", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||||
var report = new Report
|
{
|
||||||
{
|
var userId = GetUserId(http);
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
var ok = await reports.ReanalyzeReportAsync(userId, id, ct);
|
||||||
FileUrl = fileUrl,
|
return ok
|
||||||
FileType = isPdf ? ReportFileType.Pdf : ReportFileType.Image,
|
? Results.Ok(new { code = 0, data = new { success = true }, message = "已重新提交 AI 分析" })
|
||||||
Category = ReportCategory.Other,
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "报告不存在或原始文件丢失" });
|
||||||
Status = ReportStatus.PendingDoctor,
|
});
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
db.Reports.Add(report);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
// 异步 AI 分析(用独立 scope,不依赖请求生命周期)
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||||
var reportId = report.Id;
|
{
|
||||||
_ = Task.Run(async () =>
|
var userId = GetUserId(http);
|
||||||
{
|
var ok = await reports.DeleteReportAsync(userId, id, ct);
|
||||||
try
|
return ok
|
||||||
{
|
? Results.Ok(new { code = 0, data = new { success = true }, message = "已删除" })
|
||||||
using var scope = scopeFactory.CreateScope();
|
: Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" });
|
||||||
var taskDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
||||||
var taskVision = scope.ServiceProvider.GetRequiredService<VisionClient>();
|
|
||||||
var taskLlm = scope.ServiceProvider.GetRequiredService<DeepSeekClient>();
|
|
||||||
await AnalyzeReportWithAI(reportId, filePath, taskVision, taskLlm, taskDb);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Report] AI 分析失败: {ex.Message}");
|
|
||||||
// 回退:写入 fallback 数据
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var fbScope = scopeFactory.CreateScope();
|
|
||||||
var fbDb = fbScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
||||||
var fbReport = await fbDb.Reports.FirstOrDefaultAsync(r => r.Id == reportId);
|
|
||||||
if (fbReport != null)
|
|
||||||
{
|
|
||||||
fbReport.AiSummary = GenerateFallbackSummary(fbReport.Category);
|
|
||||||
fbReport.AiIndicators = GenerateFallbackIndicators(fbReport.Category);
|
|
||||||
await fbDb.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
}, CancellationToken.None);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { report.Id, report.Status, report.FileUrl }, message = "报告已上传,AI 正在分析中" });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// VLM 识别 + LLM 解读
|
|
||||||
/// </summary>
|
|
||||||
private static async Task AnalyzeReportWithAI(
|
|
||||||
Guid reportId, string filePath,
|
|
||||||
VisionClient vision, DeepSeekClient llm, AppDbContext db)
|
|
||||||
{
|
|
||||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == reportId);
|
|
||||||
if (report == null) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// ===== Step 1: 使用 VLM 识别报告图片,提取指标 =====
|
|
||||||
var vlmPrompt = """
|
|
||||||
你是一名医学检验报告分析专家。请仔细识别这份医学报告。
|
|
||||||
|
|
||||||
第一步:判断这到底是不是一份医学检验报告。
|
|
||||||
如果不是(比如是自拍、风景照、食物、文件等),直接返回:
|
|
||||||
{"isReport": false, "reason": "这不是医学报告,原因:..."}
|
|
||||||
|
|
||||||
如果是医学报告,提取所有检测指标,返回严格JSON格式(不要markdown包裹):
|
|
||||||
{
|
|
||||||
"isReport": true,
|
|
||||||
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
|
|
||||||
"indicators": [
|
|
||||||
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
注意:
|
|
||||||
- status 判断:数值在参考范围内=normal,超出上限=high,低于下限=low
|
|
||||||
- 如果参考范围不明确,根据常见医学标准判断
|
|
||||||
""";
|
|
||||||
|
|
||||||
var vlmContent = "";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var isPdf = Path.GetExtension(filePath).ToLowerInvariant() == ".pdf";
|
|
||||||
var imageUrl = isPdf ? null : await GetLocalImageUrl(filePath);
|
|
||||||
var vlmResponse = await vision.VisionAsync(
|
|
||||||
vlmPrompt,
|
|
||||||
imageUrl != null ? [imageUrl] : [],
|
|
||||||
userText: "请分析这份医学报告",
|
|
||||||
maxTokens: 2048);
|
|
||||||
|
|
||||||
vlmContent = vlmResponse.Choices?.FirstOrDefault()?.Message?.Content ?? "";
|
|
||||||
Console.WriteLine($"[Report] VLM 返回: {vlmContent?[..Math.Min(vlmContent.Length, 200)]}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Report] VLM 调用失败: {ex.Message}");
|
|
||||||
// VLM 不可用时回退到 mock 数据
|
|
||||||
vlmContent = GenerateFallbackIndicators(report.Category);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 VLM 结果
|
|
||||||
var vlmJson = TryParseJson(vlmContent);
|
|
||||||
var isReport = vlmJson?.TryGetProperty("isReport", out var ir) == true && ir.GetBoolean();
|
|
||||||
var reason = vlmJson?.TryGetProperty("reason", out var rsn) == true ? rsn.GetString() : null;
|
|
||||||
|
|
||||||
if (!isReport && !string.IsNullOrEmpty(reason))
|
|
||||||
{
|
|
||||||
report.AiSummary = $"⚠️ 无法分析:{reason}";
|
|
||||||
report.AiIndicators = "[]";
|
|
||||||
report.Status = ReportStatus.PendingDoctor;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var indicatorsJson = "[]";
|
|
||||||
var reportType = "Other";
|
|
||||||
if (vlmJson != null)
|
|
||||||
{
|
|
||||||
reportType = vlmJson.Value.TryGetProperty("reportType", out var rt) ? rt.GetString()! : "Other";
|
|
||||||
if (vlmJson.Value.TryGetProperty("indicators", out var inds))
|
|
||||||
indicatorsJson = inds.GetRawText();
|
|
||||||
}
|
|
||||||
|
|
||||||
report.Category = Enum.TryParse<ReportCategory>(reportType, out var cat) ? cat : ReportCategory.Other;
|
|
||||||
report.AiIndicators = indicatorsJson;
|
|
||||||
|
|
||||||
// ===== Step 2: 使用 LLM 生成专业解读摘要 =====
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var indicators = indicatorsJson;
|
|
||||||
var llmPrompt = $"""
|
|
||||||
你是一名心血管内科主任医师。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
|
|
||||||
|
|
||||||
检测指标:{indicators}
|
|
||||||
|
|
||||||
要求:
|
|
||||||
1. 总字数200-300字
|
|
||||||
2. 先总结整体情况
|
|
||||||
3. 指出异常指标及其可能原因
|
|
||||||
4. 给出生活建议
|
|
||||||
5. 末尾提醒"以上为AI预解读,具体请以医生诊断为准"
|
|
||||||
|
|
||||||
只返回解读文本,不要JSON格式。
|
|
||||||
""";
|
|
||||||
|
|
||||||
var llmMessages = new List<ChatMessage>
|
|
||||||
{
|
|
||||||
new() { Role = "system", Content = "你是一名心血管内科主任医师,擅长解读检验报告。" },
|
|
||||||
new() { Role = "user", Content = llmPrompt }
|
|
||||||
};
|
|
||||||
|
|
||||||
var llmResponse = await llm.ChatAsync(llmMessages, maxTokens: 800, temperature: 0.3f);
|
|
||||||
var summary = llmResponse.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(summary))
|
|
||||||
report.AiSummary = summary;
|
|
||||||
else
|
|
||||||
report.AiSummary = GenerateFallbackSummary(report.Category);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Report] LLM 调用失败: {ex.Message}");
|
|
||||||
report.AiSummary = GenerateFallbackSummary(report.Category);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
Console.WriteLine($"[Report] AI 分析完成: {reportId}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[Report] 分析异常: {ex.Message}");
|
|
||||||
if (report != null)
|
|
||||||
{
|
|
||||||
report.AiSummary = GenerateFallbackSummary(report.Category);
|
|
||||||
report.AiIndicators = GenerateFallbackIndicators(report.Category);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string?> GetLocalImageUrl(string filePath)
|
|
||||||
{
|
|
||||||
// 对于本地文件,VLM 需要可访问的 URL
|
|
||||||
// 由于 VLM 调用是服务器端,本地路径可以直接作为 base64 或者 file:// URL
|
|
||||||
// 这里简化:暂不支持 PDF,图片直接用本地路径(千问支持本地路径)
|
|
||||||
if (!File.Exists(filePath)) return null;
|
|
||||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
|
||||||
var base64 = Convert.ToBase64String(bytes);
|
|
||||||
var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.');
|
|
||||||
var mime = ext switch { "png" => "image/png", "jpg" or "jpeg" => "image/jpeg", "webp" => "image/webp", _ => "image/png" };
|
|
||||||
return $"data:{mime};base64,{base64}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JsonElement? TryParseJson(string content)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(content)) return null;
|
|
||||||
content = content.Trim();
|
|
||||||
// 去掉可能包裹的 markdown ```json ... ```
|
|
||||||
if (content.StartsWith("```"))
|
|
||||||
{
|
|
||||||
var end = content.IndexOf('\n');
|
|
||||||
if (end > 0) content = content[(end + 1)..];
|
|
||||||
if (content.EndsWith("```"))
|
|
||||||
content = content[..^3];
|
|
||||||
}
|
|
||||||
content = content.Trim();
|
|
||||||
try { return JsonDocument.Parse(content).RootElement; }
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateFallbackSummary(ReportCategory category) => category switch
|
|
||||||
{
|
|
||||||
ReportCategory.BloodTest => "血常规显示白细胞计数正常,红细胞及血红蛋白略低于参考范围下限,提示可能存在轻度贫血。血小板计数在正常范围内。建议关注铁蛋白及维生素B12水平,适当补充富含铁质的食物。以上为AI预解读,具体请以医生诊断为准。",
|
|
||||||
ReportCategory.Ecg => "心电图显示窦性心律,心率正常。无明显ST段改变或心律失常。以上为AI预解读,具体请以医生诊断为准。",
|
|
||||||
_ => "检查指标基本在参考范围内。以上为AI预解读,具体请以医生诊断为准。"
|
|
||||||
};
|
|
||||||
|
|
||||||
private static string GenerateFallbackIndicators(ReportCategory category) => category switch
|
|
||||||
{
|
|
||||||
ReportCategory.BloodTest => """[{"name":"白细胞计数","value":"7.5","unit":"×10^9/L","referenceRange":"4.0-10.0","status":"normal"},{"name":"红细胞计数","value":"4.1","unit":"×10^12/L","referenceRange":"4.3-5.8","status":"low"},{"name":"血红蛋白","value":"125","unit":"g/L","referenceRange":"130-175","status":"low"},{"name":"血小板计数","value":"195","unit":"×10^9/L","referenceRange":"100-300","status":"normal"}]""",
|
|
||||||
_ => """[{"name":"检查结果","value":"正常","unit":"","referenceRange":"","status":"normal"}]"""
|
|
||||||
};
|
|
||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);
|
|
||||||
@@ -1,105 +1,58 @@
|
|||||||
|
using Health.Application.HealthArchives;
|
||||||
|
using Health.Application.Users;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 用户与健康档案 API 端点
|
|
||||||
/// </summary>
|
|
||||||
public static class UserEndpoints
|
public static class UserEndpoints
|
||||||
{
|
{
|
||||||
public static void MapUserEndpoints(this WebApplication app)
|
public static void MapUserEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/user").RequireAuthorization();
|
var group = app.MapGroup("/api/user").RequireAuthorization();
|
||||||
|
|
||||||
// 获取个人信息
|
group.MapGet("/profile", async (HttpContext http, IUserService users, CancellationToken ct) =>
|
||||||
group.MapGet("/profile", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var profile = await users.GetProfileAsync(GetUserId(http), ct);
|
||||||
var user = await db.Users.Select(u => new
|
return Results.Ok(new { code = 0, data = profile, message = (string?)null });
|
||||||
{
|
|
||||||
u.Id, u.Phone, u.Role, u.Name, u.Gender, BirthDate = u.BirthDate != null ? u.BirthDate.Value.ToString("yyyy-MM-dd") : null, u.AvatarUrl
|
|
||||||
}).FirstOrDefaultAsync(u => u.Id == userId, ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = user, message = (string?)null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 修改资料
|
group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, IUserService users, CancellationToken ct) =>
|
||||||
group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
|
||||||
var user = await db.Users.FindAsync([userId], ct);
|
var updated = await users.UpdateProfileAsync(
|
||||||
if (user == null) return Results.Ok(new { code = 40004, message = "用户不存在" });
|
GetUserId(http),
|
||||||
|
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate),
|
||||||
user.Name = req.Name ?? user.Name;
|
ct);
|
||||||
user.Gender = req.Gender ?? user.Gender;
|
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
|
||||||
if (DateOnly.TryParse(req.BirthDate, out var bd)) user.BirthDate = bd;
|
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取健康档案
|
group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
|
||||||
group.MapGet("/health-archive", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var archive = await archives.GetAsync(GetUserId(http), ct);
|
||||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
|
||||||
return Results.Ok(new { code = 0, data = archive, message = (string?)null });
|
return Results.Ok(new { code = 0, data = archive, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新健康档案
|
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
|
||||||
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null;
|
||||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
await archives.UpdateAsync(
|
||||||
if (archive == null)
|
GetUserId(http),
|
||||||
{
|
new HealthArchiveUpdateRequest(
|
||||||
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
|
req.Diagnosis,
|
||||||
db.HealthArchives.Add(archive);
|
req.SurgeryType,
|
||||||
}
|
surgeryDate,
|
||||||
|
req.Allergies,
|
||||||
archive.Diagnosis = req.Diagnosis ?? archive.Diagnosis;
|
req.DietRestrictions,
|
||||||
archive.SurgeryType = req.SurgeryType ?? archive.SurgeryType;
|
req.ChronicDiseases,
|
||||||
if (DateOnly.TryParse(req.SurgeryDate, out var sd)) archive.SurgeryDate = sd;
|
req.FamilyHistory),
|
||||||
if (req.Allergies != null) archive.Allergies = req.Allergies;
|
ct);
|
||||||
if (req.DietRestrictions != null) archive.DietRestrictions = req.DietRestrictions;
|
|
||||||
if (req.ChronicDiseases != null) archive.ChronicDiseases = req.ChronicDiseases;
|
|
||||||
archive.FamilyHistory = req.FamilyHistory ?? archive.FamilyHistory;
|
|
||||||
archive.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 注销账号
|
group.MapDelete("/account", async (HttpContext http, IUserService users, CancellationToken ct) =>
|
||||||
group.MapDelete("/account", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
await users.DeleteAccountAsync(GetUserId(http), ct);
|
||||||
// 医生:清除Doctor关联
|
|
||||||
var profile = await db.DoctorProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
|
||||||
if (profile?.DoctorId != null)
|
|
||||||
{
|
|
||||||
await db.Users.Where(u => u.DoctorId == profile!.DoctorId).ExecuteUpdateAsync(u => u.SetProperty(x => x.DoctorId, (Guid?)null), ct);
|
|
||||||
await db.Doctors.Where(d => d.Id == profile.DoctorId).ExecuteDeleteAsync(ct);
|
|
||||||
}
|
|
||||||
// 删除所有关联数据
|
|
||||||
await db.HealthRecords.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.MedicationLogs.Where(l => l.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.Medications.Where(m => m.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.DietFoodItems.Where(i => i.DietRecord!.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.DietRecords.Where(d => d.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.ExercisePlanItems.Where(i => i.Plan!.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.ExercisePlans.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.Reports.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.ConversationMessages.Where(m => m.Conversation!.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.Conversations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.ConsultationMessages.Where(m => m.Consultation!.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.Consultations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.DoctorProfiles.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
|
|
||||||
await db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,40 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Health.Application.AI;
|
||||||
|
using Health.Application.Admin;
|
||||||
|
using Health.Application.Auth;
|
||||||
|
using Health.Application.Calendars;
|
||||||
|
using Health.Application.Diets;
|
||||||
|
using Health.Application.Exercises;
|
||||||
|
using Health.Application.HealthArchives;
|
||||||
|
using Health.Application.HealthRecords;
|
||||||
|
using Health.Application.Medications;
|
||||||
|
using Health.Application.Maintenance;
|
||||||
|
using Health.Application.Notifications;
|
||||||
|
using Health.Application.Reports;
|
||||||
|
using Health.Application.Users;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
|
using Health.Infrastructure.Admin;
|
||||||
|
using Health.Infrastructure.Auth;
|
||||||
|
using Health.Infrastructure.Calendars;
|
||||||
using Health.Infrastructure.Data;
|
using Health.Infrastructure.Data;
|
||||||
|
using Health.Infrastructure.Diets;
|
||||||
|
using Health.Infrastructure.Exercises;
|
||||||
|
using Health.Infrastructure.HealthArchives;
|
||||||
|
using Health.Infrastructure.HealthRecords;
|
||||||
|
using Health.Infrastructure.Medications;
|
||||||
|
using Health.Infrastructure.Maintenance;
|
||||||
|
using Health.Infrastructure.Notifications;
|
||||||
|
using Health.Infrastructure.Reports;
|
||||||
using Health.Infrastructure.Services;
|
using Health.Infrastructure.Services;
|
||||||
|
using Health.Infrastructure.Users;
|
||||||
using Health.WebApi.BackgroundServices;
|
using Health.WebApi.BackgroundServices;
|
||||||
using Health.WebApi.Converters;
|
using Health.WebApi.Converters;
|
||||||
using Health.WebApi.Endpoints;
|
using Health.WebApi.Endpoints;
|
||||||
using Health.WebApi.Middleware;
|
using Health.WebApi.Middleware;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
// 加载 .env 文件(开发环境)
|
// 加载 .env 文件(开发环境)
|
||||||
@@ -28,6 +55,18 @@ if (File.Exists(envPath))
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
if (builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.AddConsole();
|
||||||
|
builder.Logging.AddDebug();
|
||||||
|
var dataProtectionPath = Path.Combine(Directory.GetCurrentDirectory(), "data-protection-keys");
|
||||||
|
Directory.CreateDirectory(dataProtectionPath);
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath))
|
||||||
|
.SetApplicationName("HealthManager.Development");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- JSON 配置(枚举字符串、camelCase、UTC时间统一带Z)----
|
// ---- JSON 配置(枚举字符串、camelCase、UTC时间统一带Z)----
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
{
|
{
|
||||||
@@ -68,6 +107,44 @@ builder.Services.AddAuthorization();
|
|||||||
builder.Services.AddSingleton<JwtProvider>();
|
builder.Services.AddSingleton<JwtProvider>();
|
||||||
builder.Services.AddSingleton<SmsService>();
|
builder.Services.AddSingleton<SmsService>();
|
||||||
builder.Services.AddSingleton<PromptManager>();
|
builder.Services.AddSingleton<PromptManager>();
|
||||||
|
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||||
|
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||||
|
builder.Services.AddScoped<IAdminService, AdminService>();
|
||||||
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
|
builder.Services.AddScoped<IAiConversationService, AiConversationService>();
|
||||||
|
builder.Services.AddScoped<IAiConversationRepository, EfAiConversationRepository>();
|
||||||
|
builder.Services.AddScoped<IPatientContextService, PatientContextService>();
|
||||||
|
builder.Services.AddScoped<ICalendarService, CalendarService>();
|
||||||
|
builder.Services.AddScoped<ICalendarRepository, EfCalendarRepository>();
|
||||||
|
builder.Services.AddScoped<IDietService, DietService>();
|
||||||
|
builder.Services.AddScoped<IDietRepository, EfDietRepository>();
|
||||||
|
builder.Services.AddScoped<IDietImageAnalysisCoordinator, DietImageAnalysisCoordinator>();
|
||||||
|
builder.Services.AddScoped<IDietImageAnalyzer, DietImageAnalyzer>();
|
||||||
|
builder.Services.AddScoped<IDietImageAnalysisQueue, DietImageAnalysisQueue>();
|
||||||
|
builder.Services.AddScoped<IExerciseService, ExerciseService>();
|
||||||
|
builder.Services.AddScoped<IExerciseRepository, EfExerciseRepository>();
|
||||||
|
builder.Services.AddScoped<IExerciseReminderProducer, ExerciseReminderProducer>();
|
||||||
|
builder.Services.AddScoped<IHealthArchiveService, HealthArchiveService>();
|
||||||
|
builder.Services.AddScoped<IHealthArchiveRepository, EfHealthArchiveRepository>();
|
||||||
|
builder.Services.AddScoped<IHealthRecordService, HealthRecordService>();
|
||||||
|
builder.Services.AddScoped<IHealthRecordRepository, EfHealthRecordRepository>();
|
||||||
|
builder.Services.AddScoped<IMedicationService, MedicationService>();
|
||||||
|
builder.Services.AddScoped<IMedicationRepository, EfMedicationRepository>();
|
||||||
|
builder.Services.AddScoped<IMedicationReminderScanner, MedicationReminderScanner>();
|
||||||
|
builder.Services.AddScoped<IMedicationReminderDispatcher, OutboxMedicationReminderDispatcher>();
|
||||||
|
builder.Services.AddScoped<IMedicationReminderQueue, MedicationReminderQueue>();
|
||||||
|
builder.Services.AddScoped<IMaintenanceService, MaintenanceService>();
|
||||||
|
builder.Services.AddScoped<IMaintenanceRepository, EfMaintenanceRepository>();
|
||||||
|
builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>();
|
||||||
|
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
||||||
|
builder.Services.AddScoped<IReportService, ReportService>();
|
||||||
|
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
||||||
|
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
||||||
|
builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
||||||
|
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||||
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
|
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||||
|
builder.Services.AddScoped<DatabaseSchemaMigrator>();
|
||||||
|
|
||||||
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
||||||
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||||
@@ -85,7 +162,11 @@ builder.Services.AddHttpClient<VisionClient>(client =>
|
|||||||
|
|
||||||
// ---- 后台服务 ----
|
// ---- 后台服务 ----
|
||||||
builder.Services.AddHostedService<MedicationReminderService>();
|
builder.Services.AddHostedService<MedicationReminderService>();
|
||||||
|
builder.Services.AddHostedService<ExerciseReminderService>();
|
||||||
|
builder.Services.AddHostedService<MedicationReminderWorker>();
|
||||||
builder.Services.AddHostedService<CleanupService>();
|
builder.Services.AddHostedService<CleanupService>();
|
||||||
|
builder.Services.AddHostedService<ReportAnalysisWorker>();
|
||||||
|
builder.Services.AddHostedService<DietImageAnalysisWorker>();
|
||||||
|
|
||||||
// ---- OpenAPI ----
|
// ---- OpenAPI ----
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
@@ -110,6 +191,14 @@ app.UseCors();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||||
|
Directory.CreateDirectory(uploadsPath);
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
FileProvider = new PhysicalFileProvider(uploadsPath),
|
||||||
|
RequestPath = "/uploads"
|
||||||
|
});
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|
||||||
@@ -118,6 +207,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
await scope.ServiceProvider.GetRequiredService<DatabaseSchemaMigrator>().ApplyAsync();
|
||||||
await DataSeeder.SeedAsync(db);
|
await DataSeeder.SeedAsync(db);
|
||||||
await DevDataSeeder.SeedIfEnabled(db, app.Configuration);
|
await DevDataSeeder.SeedIfEnabled(db, app.Configuration);
|
||||||
}
|
}
|
||||||
@@ -134,6 +224,7 @@ app.MapUserEndpoints();
|
|||||||
app.MapAiChatEndpoints();
|
app.MapAiChatEndpoints();
|
||||||
app.MapFileEndpoints();
|
app.MapFileEndpoints();
|
||||||
app.MapCalendarEndpoints();
|
app.MapCalendarEndpoints();
|
||||||
|
app.MapNotificationEndpoints();
|
||||||
app.MapDoctorEndpoints();
|
app.MapDoctorEndpoints();
|
||||||
app.MapAdminEndpoints();
|
app.MapAdminEndpoints();
|
||||||
app.MapFollowUpEndpoints();
|
app.MapFollowUpEndpoints();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Health.Application\Health.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Health.WebApi\Health.WebApi.csproj" />
|
<ProjectReference Include="..\..\src\Health.WebApi\Health.WebApi.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Health.Domain\Health.Domain.csproj" />
|
<ProjectReference Include="..\..\src\Health.Domain\Health.Domain.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Health.Infrastructure\Health.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\src\Health.Infrastructure\Health.Infrastructure.csproj" />
|
||||||
|
|||||||
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();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var monday = new DateOnly(2026, 6, 1);
|
var monday = new DateOnly(2026, 6, 1);
|
||||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday };
|
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, StartDate = monday, EndDate = monday.AddDays(2), ReminderTime = new TimeOnly(19, 0) };
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(1), ExerciseType = "慢跑", DurationMinutes = 20 });
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, IsRestDay = true });
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = monday.AddDays(2), IsRestDay = true });
|
||||||
db.ExercisePlans.Add(plan);
|
db.ExercisePlans.Add(plan);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
283
docs/backend_architecture_evolution.md
Normal file
283
docs/backend_architecture_evolution.md
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
# 后端架构演进方案
|
||||||
|
|
||||||
|
日期:2026-06-18
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
当前项目已经具备 `Health.Domain`、`Health.Application`、`Health.Infrastructure`、`Health.WebApi` 的分层目录,但实际业务逻辑主要仍写在 `Health.WebApi/Endpoints` 中。多数接口直接注入 `AppDbContext`,在 Endpoint 内完成查询、权限判断、状态流转和 `SaveChanges`。
|
||||||
|
|
||||||
|
这种方式适合早期快速验证,但随着患者端健康管理、AI 分析、报告、饮食、用药、运动、医生端等业务增长,会带来几个问题:
|
||||||
|
|
||||||
|
- 业务规则分散在 Endpoint、AI Agent Handler、后台服务中。
|
||||||
|
- 同一个业务动作可能被普通接口和 AI 工具重复实现。
|
||||||
|
- 权限和资源归属校验容易遗漏。
|
||||||
|
- 异步任务目前存在 `Task.Run` 形式,不便于限流、重试和统一管理。
|
||||||
|
- Application 层没有真正承接业务用例,后续测试和维护成本会升高。
|
||||||
|
|
||||||
|
技术目标是按 DDD 思路逐步演进:让 Endpoint 成为接口适配层,业务流程进入 Application Service,外部能力和数据访问由 Infrastructure 支撑,耗时任务通过生产者-消费者管道处理。
|
||||||
|
|
||||||
|
## 2. 当前业务边界
|
||||||
|
|
||||||
|
### 2.1 当前核心患者端闭环
|
||||||
|
|
||||||
|
以下业务都需要继续做扎实:
|
||||||
|
|
||||||
|
1. AI 健康管家聊天
|
||||||
|
2. 健康指标记录与趋势:血压、心率、血糖、血氧、体重
|
||||||
|
3. 报告上传与 AI 预解读
|
||||||
|
4. 饮食拍照分析与保存
|
||||||
|
5. 用药管理与打卡
|
||||||
|
6. 运动计划
|
||||||
|
|
||||||
|
### 2.2 医生端当前策略
|
||||||
|
|
||||||
|
医生端可以做代码整理和权限边界收拢,但不作为当前核心业务闭环:
|
||||||
|
|
||||||
|
- 医患实时聊天:暂时搁置,后续接入互联网医院后再完善。
|
||||||
|
- 医生审核报告:暂时不是当前真实流程,患者端报告以 AI 预解读为主;界面可显示“医生审核中/待审核”一类状态。
|
||||||
|
- 医生工作台:保留现有页面和基础接口,不主动扩大功能范围,重构时以不影响当前项目运行为目标。
|
||||||
|
|
||||||
|
### 2.3 AI 写入规则
|
||||||
|
|
||||||
|
统一业务规则:
|
||||||
|
|
||||||
|
- AI 纯查询、解释、建议可以直接回复。
|
||||||
|
- AI 只要要写入用户健康相关数据,必须先让用户确认。
|
||||||
|
- 需要确认的写入包括但不限于:健康指标、用药计划、运动计划、健康档案修改。
|
||||||
|
- 饮食记录当前在饮食分析结果页由用户主动保存,保留该方式。
|
||||||
|
|
||||||
|
## 3. 目标架构
|
||||||
|
|
||||||
|
目标不是创建一个巨大的全能 Service,而是按业务模块拆分 Application Service:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Health.WebApi
|
||||||
|
Endpoints
|
||||||
|
Hubs
|
||||||
|
BackgroundServices
|
||||||
|
|
||||||
|
Health.Application
|
||||||
|
Auth
|
||||||
|
Users
|
||||||
|
HealthRecords
|
||||||
|
Medications
|
||||||
|
Diet
|
||||||
|
Reports
|
||||||
|
Exercise
|
||||||
|
Consultations
|
||||||
|
Doctors
|
||||||
|
Ai
|
||||||
|
Common
|
||||||
|
|
||||||
|
Health.Domain
|
||||||
|
Entities
|
||||||
|
Enums
|
||||||
|
DomainRules
|
||||||
|
|
||||||
|
Health.Infrastructure
|
||||||
|
Data
|
||||||
|
AI
|
||||||
|
Services
|
||||||
|
Storage
|
||||||
|
```
|
||||||
|
|
||||||
|
职责边界:
|
||||||
|
|
||||||
|
- `WebApi`:接收 HTTP/SignalR 请求,解析参数,返回响应。
|
||||||
|
- `Application`:承接业务用例、状态流转、权限校验、任务入队。
|
||||||
|
- `Domain`:保存核心实体、枚举和稳定业务规则。
|
||||||
|
- `Infrastructure`:EF Core、AI 客户端、短信、文件存储、推送、未来互联网医院适配。
|
||||||
|
|
||||||
|
## 4. 数据库读写收拢方式
|
||||||
|
|
||||||
|
短期先不强制引入 Repository 抽象,避免过度设计。第一阶段采用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Endpoint -> Application Service -> AppDbContext
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说,数据库读写先统一进入对应业务 Service,而不是继续散落在 Endpoint 中。
|
||||||
|
|
||||||
|
后续如果业务复杂度继续提升,再考虑:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Application Service -> Repository / UnitOfWork -> AppDbContext
|
||||||
|
```
|
||||||
|
|
||||||
|
第一阶段优先收拢这些模块:
|
||||||
|
|
||||||
|
1. `ReportService`
|
||||||
|
2. `DietService`
|
||||||
|
3. `MedicationService`
|
||||||
|
4. `ExerciseService`
|
||||||
|
5. `HealthRecordService`
|
||||||
|
6. `ConsultationService`
|
||||||
|
|
||||||
|
## 5. 生产者-消费者管道
|
||||||
|
|
||||||
|
不是所有业务都需要管道。同步、快速、必须立即返回的操作仍走普通 Service。
|
||||||
|
|
||||||
|
适合管道的任务:
|
||||||
|
|
||||||
|
- 报告 AI 分析
|
||||||
|
- 饮食图片识别
|
||||||
|
- 处方图片识别
|
||||||
|
- 用药提醒推送
|
||||||
|
- 健康周报生成
|
||||||
|
- 后期互联网医院数据同步
|
||||||
|
|
||||||
|
当前阶段采用 .NET 内置 `Channel<T>` + `BackgroundService`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
业务 Service = 生产者
|
||||||
|
Channel<TJob> = 内存任务队列
|
||||||
|
BackgroundService = 消费者
|
||||||
|
AI/推送/外部服务 = 实际执行器
|
||||||
|
```
|
||||||
|
|
||||||
|
单服务器和当前用户规模下,内存队列足够作为第一阶段方案。后续如果出现多实例部署、任务不能丢、重试次数和死信队列等要求,再迁移到 Redis Stream、RabbitMQ 或 Hangfire。
|
||||||
|
|
||||||
|
## 6. 第一阶段落地范围
|
||||||
|
|
||||||
|
先从报告模块落地,因为它同时具备上传、AI、异步、状态流转、失败重试等典型场景。
|
||||||
|
|
||||||
|
### 6.1 报告模块目标
|
||||||
|
|
||||||
|
从当前:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ReportEndpoints -> AppDbContext + Task.Run + AI 调用
|
||||||
|
```
|
||||||
|
|
||||||
|
演进为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ReportEndpoints
|
||||||
|
-> ReportService
|
||||||
|
-> 保存文件
|
||||||
|
-> 创建报告
|
||||||
|
-> 标记状态
|
||||||
|
-> 入队 ReportAnalysisJob
|
||||||
|
|
||||||
|
ReportAnalysisWorker
|
||||||
|
-> 消费 ReportAnalysisQueue
|
||||||
|
-> 调用 ReportAnalysisService / AI Analyzer
|
||||||
|
-> 更新报告状态
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 报告模块要保持的业务行为
|
||||||
|
|
||||||
|
- 上传只支持报告图片。
|
||||||
|
- 上传成功后状态为 `Analyzing`。
|
||||||
|
- AI 成功后进入 `PendingDoctor`,患者端展示 AI 预解读。
|
||||||
|
- AI 失败后进入 `AnalysisFailed`,不生成假摘要、假指标。
|
||||||
|
- 患者端可以查看原始报告图片。
|
||||||
|
- 医生审核不是当前核心闭环,状态可保留但不扩大功能。
|
||||||
|
|
||||||
|
## 7. 当前已落地进展
|
||||||
|
|
||||||
|
截至 2026-06-18,第一轮服务化已经完成以下模块:
|
||||||
|
|
||||||
|
1. `ReportService`:报告上传、图片校验、报告创建、重新分析、删除、AI 分析状态流转进入服务层;报告分析通过 `ReportAnalysisQueue` + `ReportAnalysisWorker` 异步消费。
|
||||||
|
2. `HealthRecordService`:健康指标列表、创建、更新、删除、最新值、趋势查询、异常判断进入服务层;AI 记数据工具复用同一套创建逻辑。
|
||||||
|
3. `ExerciseService`:运动计划创建、列表、详情、删除、打卡、AI 创建/查询/打卡进入服务层。
|
||||||
|
4. `MedicationService`:用药列表、创建、更新、删除、今日服药确认、按剂量打卡、提醒查询、AI 创建/查询/确认进入服务层。
|
||||||
|
5. `DietService`:饮食记录列表、保存、删除、热量/评分更新进入服务层。
|
||||||
|
|
||||||
|
其中以下模块已经继续演进为更严格的 Application Service + Repository 结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Endpoint
|
||||||
|
-> Health.Application.*Service
|
||||||
|
-> Health.Application.I*Repository
|
||||||
|
-> Health.Infrastructure.Ef*Repository
|
||||||
|
-> AppDbContext
|
||||||
|
```
|
||||||
|
|
||||||
|
已完成:
|
||||||
|
|
||||||
|
1. `HealthRecordService` + `IHealthRecordRepository` + `EfHealthRecordRepository`
|
||||||
|
2. `ExerciseService` + `IExerciseRepository` + `EfExerciseRepository`
|
||||||
|
3. `DietService` + `IDietRepository` + `EfDietRepository`
|
||||||
|
4. `MedicationService` + `IMedicationRepository` + `EfMedicationRepository`
|
||||||
|
5. `ReportService` + `IReportRepository` + `EfReportRepository` + `IReportFileStorage` + `LocalReportFileStorage`
|
||||||
|
|
||||||
|
报告模块中,`ReportAnalysisQueue` 和 `ReportAnalysisService` 仍属于 Infrastructure:前者是内存队列适配,后者需要调用 AI 客户端并通过 `IReportRepository` 更新报告状态,不再直接依赖 `AppDbContext`。
|
||||||
|
|
||||||
|
AI 写入确认机制已完成第一阶段落地:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AI 写入工具调用
|
||||||
|
-> 创建 10 分钟有效的 PendingAiWriteCommand
|
||||||
|
-> 前端展示确认卡片,此时不写数据库
|
||||||
|
-> 用户点击确认
|
||||||
|
-> POST /api/ai/confirm-write/{commandId}
|
||||||
|
-> 校验当前用户和一次性命令
|
||||||
|
-> 执行对应 Application Service 写入
|
||||||
|
```
|
||||||
|
|
||||||
|
- 健康指标、创建/确认用药、创建/打卡运动、AI 修改健康档案均进入待确认流程。
|
||||||
|
- 纯查询工具继续直接执行。
|
||||||
|
- 命令只能由所属用户执行一次;执行失败时会恢复命令供用户重试,过期或服务重启后自动失效。
|
||||||
|
- AI 待确认命令已迁移到数据库表 `AiWriteCommands`,领取命令、业务写入和完成状态在同一事务内执行,避免重复写入。
|
||||||
|
- 报告分析任务已迁移到数据库表 `ReportAnalysisTasks`,支持服务重启恢复、原子领取、失败重试和最终失败状态。
|
||||||
|
- 项目当前仍使用 `EnsureCreated` 管理原有表;新增 `DatabaseSchemaMigrator` 和 `__AppSchemaMigrations` 版本表,为已有本地数据库安全补充基础设施表。
|
||||||
|
|
||||||
|
患者端其他业务收拢进展:
|
||||||
|
|
||||||
|
1. `HealthArchiveService` + `IHealthArchiveRepository`:健康档案页面、AI 查询和确认写入统一执行。
|
||||||
|
2. `AiConversationService` + `IAiConversationRepository`:会话创建、消息保存、历史列表和删除统一执行。
|
||||||
|
3. `PatientContextService`:统一组合健康档案、近期指标和当前用药。
|
||||||
|
4. `UserService` + `IUserRepository`:个人资料和账号注销统一执行;账号数据清理使用事务。
|
||||||
|
5. `CalendarService` + `ICalendarRepository`:聚合用药、运动、随访日历。
|
||||||
|
|
||||||
|
生产者/消费者管道现状:
|
||||||
|
|
||||||
|
- 报告分析:数据库持久化任务 + `ReportAnalysisWorker`。
|
||||||
|
- 饮食图片识别:任务持久化到 `DietImageAnalysisTasks`,由 `DietImageAnalysisWorker` 原子领取、失败重试和恢复处理中断任务;前端接口协议保持不变。
|
||||||
|
- 用药提醒:`MedicationReminderService` 负责扫描生产任务,任务持久化到 `MedicationReminderTasks` 并按药品/日期/时间唯一去重;`MedicationReminderWorker` 消费后写入 `NotificationOutbox`。真正的手机推送需在选定推送服务后消费 Outbox,目前不会把未发送通知标记为已推送。
|
||||||
|
- 报告、饮食和用药任务消费者均采用 1 到 5 秒的自适应空闲轮询,过期 Processing 任务每分钟恢复一次,避免每秒重复执行恢复更新。
|
||||||
|
- App 内用药提醒通过 `NotificationOutbox` + `/api/notifications/pending` 提供,患者端前台每 30 秒获取并展示,展示后回执去重;暂不接入系统级或厂商推送。
|
||||||
|
- `MaintenanceService` 每小时执行一次维护,自动删除超过 30 天的已完成/失败后台任务、通知 Outbox、过期 AI 写入命令和旧 AI 会话,不删除用户健康记录、饮食记录或用药计划。
|
||||||
|
- 登录、注册、验证码、Token 刷新和退出已收拢到 `IAuthService`;管理员医生管理和患者列表已收拢到 `IAdminService`,Endpoint 不再直接读写数据库。开发环境 `send-sms` 仍返回 `devCode` 供 Flutter 自动填入,非开发环境不返回验证码。
|
||||||
|
- AI 工具查询、确认写入和事务边界已收拢到 `IAiToolExecutionService`,AI Endpoint 不再直接持有 `AppDbContext`。
|
||||||
|
- 运动计划已从 `WeekStartDate + DayOfWeek` 周模板改为 `StartDate + EndDate + ReminderTime`,每日条目使用唯一的 `ScheduledDate`。连续 7 天、10 天或更长计划按真实日期生成,首页今日任务、健康日历、医生端只读详情和 AI 创建均使用同一日期模型。
|
||||||
|
- App 内运动提醒按每日条目的 `ScheduledDate` 和计划 `ReminderTime` 生成到 `NotificationOutbox`;已打卡和休息日不会提醒,同一每日条目只生成一次。
|
||||||
|
|
||||||
|
当前仍保留在 Endpoint 或旧 Handler 中的业务,需要后续逐步收拢:
|
||||||
|
|
||||||
|
- `ConsultationService`:医生聊天暂不作为当前核心业务,但基础权限和数据读写仍可继续服务化。
|
||||||
|
- `DoctorService` / `AdminDoctorService`:医生信息、患者列表、报告查看等目前不作为真实互联网医院流程,后续按老板和互联网医院接入方案调整。
|
||||||
|
- `CalendarService`:健康日历目前聚合运动、用药、饮食等多模块数据,适合在核心模块稳定后单独收拢。
|
||||||
|
- `User/ProfileService`:个人信息、健康档案、账号清理等可继续整理,尤其是 AI 修改健康档案前的确认规则。
|
||||||
|
|
||||||
|
## 8. 后续推广顺序
|
||||||
|
|
||||||
|
1. 报告:Application Service + 分析队列 + Worker
|
||||||
|
2. 饮食:图片识别任务队列,用户修正后保存
|
||||||
|
3. 用药:提醒扫描和推送任务解耦
|
||||||
|
4. 运动:计划创建、打卡规则收拢到 Service
|
||||||
|
5. 健康指标:记录、异常判断、趋势查询收拢到 Service
|
||||||
|
6. 问诊:互联网医院接入前只收拢基础接口,不扩大聊天功能
|
||||||
|
7. 医生端:保留基础接口,后续根据互联网医院和老板决策再完善审核/聊天工作流
|
||||||
|
|
||||||
|
## 9. 不做的事
|
||||||
|
|
||||||
|
当前阶段暂不做:
|
||||||
|
|
||||||
|
- 不一次性重构全项目。
|
||||||
|
- 不立即引入 RabbitMQ/Kafka 等重型组件。
|
||||||
|
- 不强制引入复杂 Repository 层。
|
||||||
|
- 不改变当前患者端主要交互。
|
||||||
|
- 不把医生聊天/医生审核作为当前核心业务流。
|
||||||
|
|
||||||
|
## 10. 验收标准
|
||||||
|
|
||||||
|
第一阶段完成后应满足:
|
||||||
|
|
||||||
|
- 报告 Endpoint 不再直接承载主要业务流程。
|
||||||
|
- 报告 AI 分析不再使用 `Task.Run`。
|
||||||
|
- 报告分析任务通过队列进入后台 Worker。
|
||||||
|
- 报告状态流转集中在 Application Service。
|
||||||
|
- 上传、列表、详情、删除、查看原图、分析失败状态保持可用。
|
||||||
|
- 后端编译通过,前端报告页分析通过。
|
||||||
@@ -371,7 +371,186 @@ var conversation = await db.Conversations
|
|||||||
5. 医生端做成真正可用的工作台:患者筛选、风险排序、随访提醒。
|
5. 医生端做成真正可用的工作台:患者筛选、风险排序、随访提醒。
|
||||||
6. 报告、饮食、用药、运动做长期趋势关联。
|
6. 报告、饮食、用药、运动做长期趋势关联。
|
||||||
|
|
||||||
## 11. 总体建议
|
## 11. 第二轮深挖补充
|
||||||
|
|
||||||
|
这一轮额外对已有 `docs/BUG_REVIEW.md`、后端端点、AI Agent handler、SignalR、Flutter provider 生命周期、饮食/蓝牙/问诊页面做了交叉检查。需要注意:旧 bug 文档中有一些问题已经被修复或情况已经变化,本节以当前代码为准。
|
||||||
|
|
||||||
|
### 11.1 已确认仍然存在的高风险问题
|
||||||
|
|
||||||
|
| 优先级 | 位置 | 当前问题 | 风险说明 | 修复方向 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| P0 | `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | `/api/auth/send-sms` 仍返回 `devCode` | 任何客户端都能拿到验证码,短信验证等同失效 | 只在 Development 返回;生产环境完全移除 |
|
||||||
|
| P0 | `health_app/lib/pages/auth/login_page.dart` | 前端拿到 `devCode` 后自动填入验证码 | 把后端安全问题固化成产品行为 | 删除自动填充;开发环境可用 debug banner 或日志 |
|
||||||
|
| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | SSE token 走 query,且 fallback 用 `ReadJwtToken` 解析 | query token 会进日志;`ReadJwtToken` 不验证签名 | SSE 改标准鉴权,或先换一次性 stream ticket |
|
||||||
|
| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | 创建/续写 AI 会话时 `FindAsync(conversationId)` 未校验 `UserId` | 可能跨用户写入/读取会话 | `FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId)` |
|
||||||
|
| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | SignalR Hub 没有鉴权,入组只靠 `consultationId` | 任意连接可加入任意问诊房间 | `MapHub().RequireAuthorization()`,Join 前校验用户或医生权限 |
|
||||||
|
| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | `SendMessage` 按传入 `senderType` 和 `consultationId` 写库 | 客户端可冒充 Doctor/User,向任意会话发消息 | senderType 从 Claims/角色推导,不信任客户端 |
|
||||||
|
| P0 | `backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs` | 患者发消息只查会话存在,未校验会话属于当前用户 | 用户 A 可向用户 B 的问诊发 HTTP 消息 | 查询加 `c.UserId == userId` |
|
||||||
|
| P0 | `backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs` | `/items/{itemId}/checkin` 只 `FindAsync(itemId)` | 用户可打卡/取消他人的运动计划项 | Include Plan 后校验 `Plan.UserId` |
|
||||||
|
| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs` | AI 运动 checkin 只按 itemId 操作 | 通过 AI 工具也可越权打卡 | 查询 item 时联表校验当前 userId |
|
||||||
|
| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs` | AI 用药 confirm 直接写 `MedicationLog`,不验证药品归属 | 可给他人药品写入服药记录 | 先查 `Medication.Id == medId && UserId == userId` |
|
||||||
|
| P1 | `backend/src/Health.WebApi/Endpoints/medication_endpoints.cs` | `/medications/{id}/confirm` 查 existing log 未限制 `UserId`,也未先确认药品归属 | 可能误删/写入不属于当前用户药品的日志 | 先查药品归属,再按 `MedicationId + UserId` 查日志 |
|
||||||
|
| P1 | `backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs` | 医生端已鉴权,但患者详情/报告/随访操作多处只按 id 查询 | 医生可能访问或修改非自己负责患者的数据 | 所有医生端资源都按医生关联患者过滤 |
|
||||||
|
| P1 | `backend/src/Health.WebApi/Endpoints/file_endpoints.cs` | 上传缺少大小、类型、内容校验,且返回结构与前端不匹配 | 恶意文件/超大文件风险,前端图片上传链路失败 | 加限制、白名单、URL 返回和访问控制 |
|
||||||
|
| P1 | `health_app/lib/pages/diet/diet_capture_page.dart` | `_fieldCtrls` 缓存 controller,但页面没有 dispose | 多次进入饮食分析页会泄漏 TextEditingController | 在 State `dispose()` 中遍历释放 |
|
||||||
|
| P1 | `health_app/lib/providers/consultation_provider.dart` | Hub/轮询 stop 需要手动调用,provider 自身没有自动释放 | 离开页面后可能继续 SignalR 或 5 秒轮询 | Notifier build 中注册 `ref.onDispose(stop)` |
|
||||||
|
| P1 | `health_app/lib/providers/chat_provider.dart` | SSE `_subscription` 和 timer 没看到 provider dispose 释放 | 聊天流中断/页面销毁后可能残留监听 | 注册 `ref.onDispose`,切换会话时取消旧流 |
|
||||||
|
| P1 | `backend/src/Health.WebApi/Program.cs` | `MapHub("/hubs/consultation")` 未 RequireAuthorization | 即使 API 鉴权,实时通道仍裸露 | Hub 映射处加鉴权并处理 token 传递 |
|
||||||
|
|
||||||
|
### 11.2 旧问题中已经变化或需要修正的判断
|
||||||
|
|
||||||
|
这些点在旧 `BUG_REVIEW.md` 里出现过,但当前代码已经不是原始状态,后续不要按旧结论机械修:
|
||||||
|
|
||||||
|
- `doctor_endpoints.cs` 不是“零授权”了:当前已有 `.RequireAuthorization()` 和医生角色过滤。真正问题是医生端数据授权粒度不够,部分详情/报告/随访接口没有限制到当前医生负责的患者。
|
||||||
|
- `open_ai_compatible_client.cs` 的 Vision content 当前已经是 `Content = contentParts`,不是把多模态数组序列化成字符串。旧的 VLM 序列化 bug 看起来已修复。
|
||||||
|
- `diet_agent_handler.cs` 和 `consultation_agent_handler.cs` 当前没有声明未实现工具,而是主动缩减为档案/记录查询。问题应描述为“AI Agent 能力和首页胶囊/欢迎卡片承诺不一致”,不是“声明工具但未实现”。
|
||||||
|
- `cleanup_service.cs` 当前已经先删 ConversationMessages 再删 Conversations,旧的 FK 删除顺序问题已修。
|
||||||
|
- `device_scan_page.dart` 当前 `dispose()` 已取消 scan/read/connection 订阅,不能继续作为确定泄漏 bug。仍建议检查 `OmronBleService` 的全局 provider 生命周期和断线重连边界。
|
||||||
|
- `widget_test.dart` 中 `primary == primaryLight` 的错误断言已经改掉,目前测试更大的问题是覆盖面太浅。
|
||||||
|
|
||||||
|
### 11.3 后端授权边界需要系统性重查
|
||||||
|
|
||||||
|
项目里很多接口已经 `.RequireAuthorization()`,但“已登录”不等于“有权操作这个资源”。建议建立统一规则:
|
||||||
|
|
||||||
|
| 资源 | 当前风险 | 应该怎么查 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AI Conversation | 续写时未绑定 `UserId` | `Conversation.Id == id && Conversation.UserId == currentUserId` |
|
||||||
|
| Consultation HTTP | POST message 未绑定 `UserId` | `Consultation.Id == id && Consultation.UserId == currentUserId` |
|
||||||
|
| Consultation Hub | 入组/发消息无服务端权限判断 | Join 和 SendMessage 都查用户或医生是否有权进入该 consultation |
|
||||||
|
| ExercisePlanItem | checkin 只按 itemId | `ExercisePlanItem.Id == itemId && Item.Plan.UserId == currentUserId` |
|
||||||
|
| Medication confirm | 部分确认接口未先校验药品归属 | `Medication.Id == id && Medication.UserId == currentUserId` |
|
||||||
|
| Doctor patient detail | 医生按任意 patient id 查详情 | `User.Id == patientId && User.DoctorId == currentDoctorId` |
|
||||||
|
| Doctor report review | 医生按任意 report id 审阅 | `Report.User.DoctorId == currentDoctorId` |
|
||||||
|
| Doctor follow-up update/delete | 医生按任意 followUp id 操作 | `FollowUp.User.DoctorId == currentDoctorId` 或 `DoctorName/DoctorId` 绑定 |
|
||||||
|
|
||||||
|
建议在后端加一层可复用 helper,例如:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
static IQueryable<User> ScopePatientsToDoctor(AppDbContext db, Guid doctorId) =>
|
||||||
|
db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId);
|
||||||
|
```
|
||||||
|
|
||||||
|
所有医生端接口都从这个 scope 派生,不要每个 endpoint 手写判断。
|
||||||
|
|
||||||
|
### 11.4 API 语义和错误码问题
|
||||||
|
|
||||||
|
现在不少接口用 HTTP 200 包业务错误码,比如 401/403/404/400 都包成 `{ code, message }`。这种风格可以保留,但要注意两个问题:
|
||||||
|
|
||||||
|
- 对认证授权失败,HTTP 状态码最好仍返回 401/403,方便客户端、网关、日志系统识别。
|
||||||
|
- 业务错误码需要统一枚举,否则前端只能靠字符串判断。
|
||||||
|
|
||||||
|
建议定义统一错误码:
|
||||||
|
|
||||||
|
- `0` 成功
|
||||||
|
- `40001` 参数错误
|
||||||
|
- `40002` 登录过期
|
||||||
|
- `40003` 无权限
|
||||||
|
- `40004` 资源不存在
|
||||||
|
- `40005` 业务状态冲突
|
||||||
|
- `50000` 服务端异常
|
||||||
|
|
||||||
|
并让 `ExceptionMiddleware` 只处理意外异常,业务错误由 endpoint 明确返回。
|
||||||
|
|
||||||
|
### 11.5 数据模型和索引补充
|
||||||
|
|
||||||
|
当前 `AppDbContext` 已经配置了不少索引和枚举转换,比旧报告里“完全没有 FK/索引”的描述更好。但仍建议补:
|
||||||
|
|
||||||
|
- `RefreshToken(Token)` 唯一或普通索引:刷新 token 查询会频繁发生。
|
||||||
|
- `RefreshToken(UserId, IsRevoked, ExpiresAt)`:便于清理和会话管理。
|
||||||
|
- `Report(UserId, CreatedAt)`:报告列表按用户和时间查询。
|
||||||
|
- `FollowUp(UserId, ScheduledAt)`:随访日历、医生待办会用到。
|
||||||
|
- `DeviceToken(UserId)`:推送服务上线后需要。
|
||||||
|
- `Consultation(UserId, CreatedAt)` 和 `Consultation(Status, CreatedAt)`:患者历史和医生待办都会用到。
|
||||||
|
|
||||||
|
另外,核心关系建议显式配置删除行为,尤其是 User 删除时关联 Consultation、Report、Conversation、MedicationLog 的级联或手动删除策略。
|
||||||
|
|
||||||
|
### 11.6 AI Agent 产品能力不一致
|
||||||
|
|
||||||
|
现在首页上有多个 agent/胶囊入口,但后端能力不完全一致:
|
||||||
|
|
||||||
|
- 饮食 Agent 当前只保留健康档案查询,真正饮食识别在专门图片接口。
|
||||||
|
- 问诊 Agent 当前只保留健康记录和档案查询,转医生走专门问诊流程。
|
||||||
|
- 用药/运动 Agent 有创建、查询、确认能力,但确认工具存在所有权校验问题。
|
||||||
|
- 通用 Agent 如果聚合多个工具,需要明确“哪些动作会写数据,哪些只是查询”。
|
||||||
|
|
||||||
|
建议 UI 文案和后端能力统一:
|
||||||
|
|
||||||
|
- 欢迎卡片不要暗示当前 agent 能完成它实际上做不到的写操作。
|
||||||
|
- 会写入健康数据、药品、运动计划、档案的 AI 动作,必须有确认卡片。
|
||||||
|
- AI 工具调用结果应返回结构化状态,前端不要只靠自然语言判断成功。
|
||||||
|
|
||||||
|
### 11.7 前端状态和生命周期问题
|
||||||
|
|
||||||
|
前端现在能跑起来,但长期运行会有状态残留风险:
|
||||||
|
|
||||||
|
- `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`。
|
||||||
|
- `ChatNotifier` 的 SSE 订阅、流式响应 timer 需要在 provider 销毁、切换 agent、重新发送时取消。
|
||||||
|
- 饮食页 `_fieldCtrls` 需要 `dispose()`,否则每次识别食物后 controller 累积。
|
||||||
|
- 多个 `FutureProvider` 没有 `autoDispose`,页面级数据会缓存很久。健康最新值、药品提醒、当前运动计划这类数据建议明确刷新策略。
|
||||||
|
- 很多页面删除后只本地 `_load()`,没有 `ref.invalidate(...)`,跨页面缓存可能不同步。
|
||||||
|
|
||||||
|
### 11.8 UI 深层问题:不是再加渐变,而是建立层级
|
||||||
|
|
||||||
|
最近 UI 调整集中在侧边栏、欢迎卡片、饮食页、设置页、个人信息页等。颜色已经比最初丰富,但仍需要注意:
|
||||||
|
|
||||||
|
- 颜色角色要固定:蓝色用于主行动/健康状态,橙色用于饮食,紫色用于报告,绿色用于记录/恢复,青色用于设备或运动,浅红用于提醒/风险。
|
||||||
|
- 欢迎卡片和胶囊按钮的图标必须同语义、同线宽、同背景形状。用户已经多次指出“不只是颜色一样,图标内容也要一样”,这说明视觉一致性比单个渐变更重要。
|
||||||
|
- 健康仪表盘应优先展示数字、单位、状态、更新时间。图标可以弱化,避免抢数字层级。
|
||||||
|
- 设置页不应只是按钮列表,应分为账号、安全、通知、设备、隐私、关于。
|
||||||
|
- 个人信息页应像正式档案:基础信息、医疗信息、健康偏好、绑定医生、账号安全分区展示。
|
||||||
|
- 功能入口两行三列是合理的,但每个入口不要堆摘要,图标 + 名称 + 必要状态即可。
|
||||||
|
|
||||||
|
### 11.9 功能缺口再细化
|
||||||
|
|
||||||
|
| 模块 | 当前缺口 | 建议 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 饮食记录 | 已有识别和保存,但历史记录编辑能力弱 | 支持编辑食物、份量、热量、餐次;支持从历史复制 |
|
||||||
|
| 报告管理 | 上传后异步 AI 分析,但失败/处理中状态不够细 | 增加 pending/analyzing/failed/retry 状态和轮询刷新 |
|
||||||
|
| 问诊 | 患者端创建即新会话,历史问诊入口弱 | 增加问诊历史、继续问诊、关闭问诊、评价医生 |
|
||||||
|
| 用药 | 提醒后台只记录日志,未实际推送 | 接入推送,支持漏服/补服/跳过原因 |
|
||||||
|
| 运动 | 有计划和打卡,但计划解释和详情不足 | 计划详情页、运动禁忌、完成趋势 |
|
||||||
|
| 健康日历 | 汇总用药/运动/随访,但和打卡状态联动有限 | 日历上直接展示已完成、未完成、逾期 |
|
||||||
|
| 医生端 | 已有工作台,但患者范围和流程需加强 | 风险患者排序、未读消息、待审报告、随访待办 |
|
||||||
|
| 管理员端 | 医生管理已有基础 | 增加操作审计、禁用账号、数据统计 |
|
||||||
|
|
||||||
|
### 11.10 更细的整改顺序
|
||||||
|
|
||||||
|
第一批必须先修安全:
|
||||||
|
|
||||||
|
1. 移除生产 `devCode` 和前端自动填充。
|
||||||
|
2. 修 SSE 认证,不再解析未验证 JWT。
|
||||||
|
3. AI conversation 按用户归属查询。
|
||||||
|
4. Consultation Hub 加鉴权、入组校验、senderType 服务端判定。
|
||||||
|
5. Consultation HTTP 发消息校验当前用户。
|
||||||
|
6. Exercise/Medication 的普通接口和 AI 工具都补所有权校验。
|
||||||
|
7. 医生端详情、报告、随访接口限制到当前医生负责患者。
|
||||||
|
|
||||||
|
第二批修接口契约和稳定性:
|
||||||
|
|
||||||
|
1. 文件上传返回 `{ id, name, size, url, contentType }`。
|
||||||
|
2. 前端 `uploadFile` 兼容后端 envelope 和 list 返回,失败要提示。
|
||||||
|
3. 饮食页 controller dispose。
|
||||||
|
4. Chat/Consultation provider 注册 `ref.onDispose`。
|
||||||
|
5. 后端补上传大小/类型限制。
|
||||||
|
6. 关闭生产 body 日志。
|
||||||
|
|
||||||
|
第三批做 UI 体系:
|
||||||
|
|
||||||
|
1. 把颜色、渐变、圆角、阴影、图标背景抽成设计 token。
|
||||||
|
2. 侧边栏、个人信息、设置、饮食分析页按同一设计语言重做。
|
||||||
|
3. 首页欢迎卡片和胶囊入口统一图标语义。
|
||||||
|
4. 健康仪表盘增加更新时间、单位、状态文字。
|
||||||
|
5. 对中老年用户做字号、对比度、触控面积检查。
|
||||||
|
|
||||||
|
第四批补测试:
|
||||||
|
|
||||||
|
1. 后端加越权测试:用户 A 不能操作用户 B 的 conversation/consultation/exercise/medication。
|
||||||
|
2. 加短信验证码生产环境不返回测试。
|
||||||
|
3. 加 SignalR Hub 入组权限测试。
|
||||||
|
4. 加文件上传类型/大小测试。
|
||||||
|
5. Flutter 加饮食保存、问诊连接释放、上传失败 UI 的 widget/provider 测试。
|
||||||
|
|
||||||
|
## 12. 总体建议
|
||||||
|
|
||||||
这个项目最有价值的方向是“围绕患者长期健康数据做 AI 辅助管理”,不是简单堆功能入口。接下来建议把项目重心从“页面多”转为“核心闭环扎实”:
|
这个项目最有价值的方向是“围绕患者长期健康数据做 AI 辅助管理”,不是简单堆功能入口。接下来建议把项目重心从“页面多”转为“核心闭环扎实”:
|
||||||
|
|
||||||
@@ -383,4 +562,3 @@ var conversation = await db.Conversations
|
|||||||
- 安全和隐私经得起真实使用。
|
- 安全和隐私经得起真实使用。
|
||||||
|
|
||||||
UI 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。
|
UI 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user