Compare commits
30 Commits
0f2a9c1c1a
...
sccsbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a9d56b07c | ||
|
|
e52e21d295 | ||
| ec5af19d30 | |||
|
|
bd4350c17f | ||
|
|
fe4c81c54f | ||
|
|
d82e006cf4 | ||
|
|
1c020b8ae5 | ||
|
|
7a93237069 | ||
|
|
4507083f3f | ||
|
|
a748c316f2 | ||
|
|
fa2cc994fa | ||
|
|
df37076c46 | ||
|
|
86c7957144 | ||
|
|
18df2f3285 | ||
|
|
53ce19e8c3 | ||
|
|
7ff429e071 | ||
|
|
73d99d56f6 | ||
|
|
80540e2191 | ||
|
|
6ce010e46a | ||
|
|
431b72d49a | ||
|
|
a479e5e95d | ||
|
|
a7fb6e5ff3 | ||
|
|
415c7ca082 | ||
|
|
13714d9ed8 | ||
|
|
b57d0d16f4 | ||
|
|
aa44d6f0f0 | ||
|
|
4d213b5a44 | ||
|
|
c610417e29 | ||
|
|
21e67014aa | ||
|
|
fbaed0cf1d |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,6 +4,10 @@
|
||||
*.key
|
||||
*.pfx
|
||||
|
||||
# Android release keystore
|
||||
health_app/android/**/*.jks
|
||||
health_app/android/key.properties
|
||||
|
||||
# .NET build outputs
|
||||
backend/**/bin/
|
||||
backend/**/obj/
|
||||
@@ -37,6 +41,8 @@ health_app/ios/.symlinks/
|
||||
|
||||
# Uploads & logs
|
||||
backend/src/Health.WebApi/uploads/
|
||||
backend/src/Health.WebApi/data-protection-keys/
|
||||
*.log
|
||||
*.txt
|
||||
*.jpg
|
||||
*.png
|
||||
|
||||
13
backend/dotnet-tools.json
Normal file
13
backend/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.8",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
66
backend/src/Health.Application/AI/AiConversationContracts.cs
Normal file
66
backend/src/Health.Application/AI/AiConversationContracts.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
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, string? metadataJson, CancellationToken ct);
|
||||
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
||||
Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, 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 UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task<int> DeleteAllAsync(Guid userId, 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);
|
||||
}
|
||||
152
backend/src/Health.Application/AI/AiConversationService.cs
Normal file
152
backend/src/Health.Application/AI/AiConversationService.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||
{
|
||||
private const int HistoryConversationLimit = 7;
|
||||
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);
|
||||
await PruneHistoryAsync(userId, ct);
|
||||
return new OpenConversationResult(true, true, conversation.Id);
|
||||
}
|
||||
|
||||
public async Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
|
||||
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, metadataJson, ct);
|
||||
|
||||
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
||||
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson: null, ct);
|
||||
|
||||
public async Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
|
||||
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson, 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.Take(HistoryConversationLimit).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 UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetAsync(conversationId, ct)
|
||||
?? throw new InvalidOperationException("会话不存在");
|
||||
var normalized = summary.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized)) return;
|
||||
|
||||
conversation.Summary = normalized.Length > 24 ? normalized[..24] : normalized;
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var list = await _conversations.ListAsync(userId, ct);
|
||||
foreach (var conv in list)
|
||||
{
|
||||
_conversations.Delete(conv);
|
||||
}
|
||||
if (list.Count > 0)
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
return list.Count;
|
||||
}
|
||||
|
||||
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, string? metadataJson, 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,
|
||||
MetadataJson = metadataJson,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, ct);
|
||||
|
||||
conversation.MessageCount++;
|
||||
conversation.UpdatedAt = DateTime.UtcNow;
|
||||
if (updateSummary)
|
||||
conversation.Summary = content.Length > 100 ? content[..100] : content;
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
await PruneHistoryAsync(conversation.UserId, ct);
|
||||
}
|
||||
|
||||
private async Task PruneHistoryAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var list = await _conversations.ListAsync(userId, ct);
|
||||
var expired = list.Skip(HistoryConversationLimit).ToList();
|
||||
if (expired.Count == 0) return;
|
||||
|
||||
foreach (var conversation in expired)
|
||||
_conversations.Delete(conversation);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
/// <summary>
|
||||
/// 用户消息附带的图片/PDF 解析结果。
|
||||
/// 既用作 LLM 上下文拼装,也持久化到 ConversationMessage.MetadataJson。
|
||||
/// </summary>
|
||||
public sealed record AttachmentContext(
|
||||
string Kind, // "image" 或 "pdf"
|
||||
string? Category, // 仅图片:food / report / wound / drug / chart / other
|
||||
string? FileName, // PDF 文件原名
|
||||
string Summary, // 简短摘要,用于持久化和历史回顾
|
||||
string LlmContent); // 完整文本,拼到当前轮 LLM user content 前
|
||||
|
||||
public interface IAttachmentContextBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
|
||||
/// </summary>
|
||||
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
|
||||
}
|
||||
68
backend/src/Health.Application/AI/PatientContextService.cs
Normal file
68
backend/src/Health.Application/AI/PatientContextService.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
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 (archive.Surgeries.Count > 0)
|
||||
sb.AppendLine($"手术: {string.Join(";", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
|
||||
else 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);
|
||||
}
|
||||
15
backend/src/Health.Application/Auth/AuthContracts.cs
Normal file
15
backend/src/Health.Application/Auth/AuthContracts.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
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 sealed record AppleLoginCommand(string IdentityToken, string? AuthorizationCode, string? Name);
|
||||
|
||||
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> AppleLoginAsync(AppleLoginCommand command, 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);
|
||||
}
|
||||
103
backend/src/Health.Application/Diets/DietService.cs
Normal file
103
backend/src/Health.Application/Diets/DietService.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using Health.Domain;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Diets;
|
||||
|
||||
public sealed class DietService(IDietRepository diets) : IDietService
|
||||
{
|
||||
private const int MaxCalories = 20000; // 单条记录/单项热量上限(kcal)
|
||||
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)
|
||||
{
|
||||
ValidateCalories(request.TotalCalories, "总热量");
|
||||
ValidateScore(request.HealthScore);
|
||||
foreach (var item in request.FoodItems)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Name))
|
||||
throw new ValidationException("食物名称不能为空");
|
||||
ValidateCalories(item.Calories, $"食物「{item.Name.Trim()}」热量");
|
||||
}
|
||||
|
||||
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.Trim(),
|
||||
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;
|
||||
|
||||
ValidateCalories(request.TotalCalories, "总热量");
|
||||
ValidateScore(request.HealthScore);
|
||||
|
||||
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 void ValidateCalories(int? calories, string field)
|
||||
{
|
||||
if (calories.HasValue && (calories.Value < 0 || calories.Value > MaxCalories))
|
||||
throw new ValidationException($"{field}应在 0~{MaxCalories} kcal 之间,当前值 {calories.Value} 不合理");
|
||||
}
|
||||
|
||||
private static void ValidateScore(int? score)
|
||||
{
|
||||
if (score.HasValue && (score.Value < 0 || score.Value > 100))
|
||||
throw new ValidationException($"健康评分应在 0~100 之间,当前值 {score.Value} 不合理");
|
||||
}
|
||||
|
||||
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,67 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
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<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);
|
||||
}
|
||||
134
backend/src/Health.Application/Exercises/ExerciseService.cs
Normal file
134
backend/src/Health.Application/Exercises/ExerciseService.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using Health.Domain;
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseService
|
||||
{
|
||||
private const int MaxDurationMinutes = 1440; // 单次运动时长上限 24 小时
|
||||
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)
|
||||
{
|
||||
ValidateDuration(request.DurationMinutes);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// 时长上限校验:下限交由各方法兜底为 30,这里只拦截不合理的超大值
|
||||
private static void ValidateDuration(int durationMinutes)
|
||||
{
|
||||
if (durationMinutes > MaxDurationMinutes)
|
||||
throw new ValidationException($"单次运动时长不能超过 {MaxDurationMinutes} 分钟(24 小时),当前值 {durationMinutes} 不合理");
|
||||
}
|
||||
|
||||
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;
|
||||
if (item.ScheduledDate != BeijingToday())
|
||||
throw new ValidationException("只能打卡今天的运动任务");
|
||||
if (item.IsRestDay)
|
||||
throw new ValidationException("休息日无需打卡");
|
||||
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;
|
||||
if (item.ScheduledDate != BeijingToday())
|
||||
throw new ValidationException("只能打卡今天的运动任务");
|
||||
if (item.IsRestDay)
|
||||
throw new ValidationException("休息日无需打卡");
|
||||
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,49 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.HealthArchives;
|
||||
|
||||
public sealed record HealthArchiveSurgeryDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
DateOnly? Date);
|
||||
|
||||
public sealed record HealthArchiveSurgeryInput(
|
||||
string Type,
|
||||
DateOnly? Date);
|
||||
|
||||
public sealed record HealthArchiveDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string? Diagnosis,
|
||||
string? SurgeryType,
|
||||
DateOnly? SurgeryDate,
|
||||
IReadOnlyList<HealthArchiveSurgeryDto> Surgeries,
|
||||
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,
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
|
||||
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
Task<HealthArchiveDto?> GetAsync(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,171 @@
|
||||
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> 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,
|
||||
Surgeries = 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.Surgeries != null)
|
||||
{
|
||||
archive.Surgeries.Clear();
|
||||
var items = request.Surgeries
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Type))
|
||||
.Select((s, index) => new HealthArchiveSurgery
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
HealthArchiveId = archive.Id,
|
||||
Type = s.Type.Trim(),
|
||||
Date = s.Date,
|
||||
SortOrder = index,
|
||||
})
|
||||
.ToList();
|
||||
foreach (var item in items) archive.Surgeries.Add(item);
|
||||
SyncLegacySurgeryFields(archive);
|
||||
}
|
||||
else if (request.SurgeryType != null)
|
||||
{
|
||||
SeedLegacySurgeryIfNeeded(archive);
|
||||
var type = request.SurgeryType.Trim();
|
||||
if (type.Length > 0 && !archive.Surgeries.Any(s =>
|
||||
s.Type == type && s.Date == request.SurgeryDate))
|
||||
{
|
||||
archive.Surgeries.Add(new HealthArchiveSurgery
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
HealthArchiveId = archive.Id,
|
||||
Type = type,
|
||||
Date = request.SurgeryDate,
|
||||
SortOrder = archive.Surgeries.Count,
|
||||
});
|
||||
}
|
||||
SyncLegacySurgeryFields(archive);
|
||||
}
|
||||
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"),
|
||||
surgeries = archive.Surgeries.Select(s => new { s.Type, date = s.Date?.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,
|
||||
GetSurgeries(archive),
|
||||
archive.Allergies,
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
archive.UpdatedAt);
|
||||
|
||||
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
||||
{
|
||||
var surgeries = archive.Surgeries
|
||||
.OrderBy(s => s.SortOrder)
|
||||
.Select(s => new HealthArchiveSurgeryDto(s.Id, s.Type, s.Date))
|
||||
.ToList();
|
||||
if (surgeries.Count == 0 && !string.IsNullOrWhiteSpace(archive.SurgeryType))
|
||||
surgeries.Add(new HealthArchiveSurgeryDto(Guid.Empty, archive.SurgeryType, archive.SurgeryDate));
|
||||
return surgeries;
|
||||
}
|
||||
|
||||
private static void SyncLegacySurgeryFields(HealthArchive archive)
|
||||
{
|
||||
var first = archive.Surgeries.OrderBy(s => s.SortOrder).FirstOrDefault();
|
||||
archive.SurgeryType = first?.Type;
|
||||
archive.SurgeryDate = first?.Date;
|
||||
}
|
||||
|
||||
private static void SeedLegacySurgeryIfNeeded(HealthArchive archive)
|
||||
{
|
||||
if (archive.Surgeries.Count > 0 || string.IsNullOrWhiteSpace(archive.SurgeryType)) return;
|
||||
archive.Surgeries.Add(new HealthArchiveSurgery
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
HealthArchiveId = archive.Id,
|
||||
Type = archive.SurgeryType,
|
||||
Date = archive.SurgeryDate,
|
||||
SortOrder = 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,61 @@
|
||||
using Health.Domain;
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 合法性校验——拦截物理上不可能的数值(负数、超量程等),不合法即抛 ValidationException。
|
||||
/// 注意:这与 CheckAbnormal(医学异常预警)是两回事,前者拦脏数据,后者只是标记偏离正常范围。
|
||||
/// </summary>
|
||||
public static void Validate(HealthRecordUpsertRequest request)
|
||||
{
|
||||
switch (request.Type)
|
||||
{
|
||||
case HealthMetricType.BloodPressure:
|
||||
var sys = Required(request.Systolic, "收缩压");
|
||||
var dia = Required(request.Diastolic, "舒张压");
|
||||
InRange(sys, 40, 300, "收缩压", "mmHg");
|
||||
InRange(dia, 20, 250, "舒张压", "mmHg");
|
||||
if (sys <= dia)
|
||||
throw new ValidationException("收缩压必须大于舒张压");
|
||||
break;
|
||||
case HealthMetricType.HeartRate:
|
||||
InRange(Required(request.Value, "心率"), 20m, 300m, "心率", "次/分");
|
||||
break;
|
||||
case HealthMetricType.Glucose:
|
||||
InRange(Required(request.Value, "血糖"), 0.5m, 60m, "血糖", "mmol/L");
|
||||
break;
|
||||
case HealthMetricType.SpO2:
|
||||
InRange(Required(request.Value, "血氧"), 50m, 100m, "血氧", "%");
|
||||
break;
|
||||
case HealthMetricType.Weight:
|
||||
InRange(Required(request.Value, "体重"), 1m, 500m, "体重", "kg");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static int Required(int? value, string field) =>
|
||||
value ?? throw new ValidationException($"{field}不能为空");
|
||||
|
||||
private static decimal Required(decimal? value, string field) =>
|
||||
value ?? throw new ValidationException($"{field}不能为空");
|
||||
|
||||
private static void InRange(decimal value, decimal min, decimal max, string field, string unit)
|
||||
{
|
||||
if (value < min || value > max)
|
||||
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Application.Notifications;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Health.Application.HealthRecords;
|
||||
|
||||
public sealed class HealthRecordService(
|
||||
IHealthRecordRepository records,
|
||||
IUserNotificationProducer? notifications = null) : IHealthRecordService
|
||||
{
|
||||
private readonly IHealthRecordRepository _records = records;
|
||||
private readonly IUserNotificationProducer? _notifications = notifications;
|
||||
|
||||
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)
|
||||
{
|
||||
HealthRecordRules.Validate(request);
|
||||
|
||||
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);
|
||||
await EnqueueAbnormalNotificationAsync(record, 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;
|
||||
|
||||
HealthRecordRules.Validate(request);
|
||||
|
||||
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);
|
||||
await EnqueueAbnormalNotificationAsync(record, 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, 365 => 365, _ => 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.Unit, 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);
|
||||
|
||||
private async Task EnqueueAbnormalNotificationAsync(HealthRecord record, CancellationToken ct)
|
||||
{
|
||||
if (!record.IsAbnormal || _notifications == null) return;
|
||||
|
||||
var (title, message, severity, target) = record.MetricType switch
|
||||
{
|
||||
HealthMetricType.BloodPressure when record.Systolic >= 140 || record.Diastolic >= 90 =>
|
||||
("血压偏高提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg,高于参考范围。建议休息后复测;如伴明显不适,请及时就医。", "warning", "blood_pressure"),
|
||||
HealthMetricType.BloodPressure =>
|
||||
("血压偏低提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg,低于参考范围。请留意头晕、乏力等不适,必要时及时就医。", "warning", "blood_pressure"),
|
||||
HealthMetricType.HeartRate when record.Value > 100 =>
|
||||
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
||||
HealthMetricType.HeartRate =>
|
||||
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
||||
HealthMetricType.Glucose when record.Value >= 7.0m =>
|
||||
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
||||
HealthMetricType.Glucose =>
|
||||
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
||||
HealthMetricType.SpO2 =>
|
||||
("血氧偏低提醒", $"本次血氧为 {record.Value:0.#}%,低于参考范围。建议立即复测;如持续偏低或伴呼吸不适,请及时就医。", "critical", "spo2"),
|
||||
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
|
||||
};
|
||||
|
||||
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
|
||||
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
|
||||
await _notifications.EnqueueAsync(
|
||||
record.UserId,
|
||||
sourceId,
|
||||
new NotificationMessage("HealthMetricAlert", title, message, severity, "health", target),
|
||||
ct);
|
||||
}
|
||||
|
||||
private static Guid DeterministicGuid(string value)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
}
|
||||
@@ -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,105 @@
|
||||
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 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,72 @@
|
||||
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;
|
||||
|
||||
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
|
||||
{
|
||||
if (await _medications.DoseLogExistsAsync(
|
||||
medication.UserId,
|
||||
medication.Id,
|
||||
scheduledTime,
|
||||
todayStartUtc,
|
||||
todayEndUtc,
|
||||
ct))
|
||||
continue;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
246
backend/src/Health.Application/Medications/MedicationService.cs
Normal file
246
backend/src/Health.Application/Medications/MedicationService.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using Health.Domain;
|
||||
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 name = ValidateName(request.Name);
|
||||
ValidateDateRange(request.StartDate, request.EndDate);
|
||||
var times = NormalizeTimes(request.TimeOfDay);
|
||||
if (times.Count == 0) times = [new TimeOnly(8, 0)];
|
||||
|
||||
var med = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = name,
|
||||
Dosage = request.Dosage,
|
||||
Frequency = request.Frequency,
|
||||
TimeOfDay = times,
|
||||
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 (request.Name != null) med.Name = ValidateName(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 = NormalizeTimes(request.TimeOfDay);
|
||||
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
||||
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||
ValidateDateRange(med.StartDate, med.EndDate);
|
||||
if (request.Notes != null) med.Notes = request.Notes;
|
||||
med.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 输入校验 ──
|
||||
|
||||
private static string ValidateName(string? name)
|
||||
{
|
||||
var trimmed = name?.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
throw new ValidationException("药品名称不能为空");
|
||||
if (trimmed.Length > 200)
|
||||
throw new ValidationException("药品名称过长(不超过 200 字)");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static void ValidateDateRange(DateOnly? startDate, DateOnly? endDate)
|
||||
{
|
||||
if (startDate.HasValue && endDate.HasValue && endDate.Value < startDate.Value)
|
||||
throw new ValidationException("结束日期不能早于开始日期");
|
||||
}
|
||||
|
||||
// 同一时间点去重并排序;空集合交由调用方/实体默认处理
|
||||
private static List<TimeOnly> NormalizeTimes(IReadOnlyList<TimeOnly> times) =>
|
||||
times.Distinct().OrderBy(t => t).ToList();
|
||||
|
||||
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,60 @@
|
||||
namespace Health.Application.Notifications;
|
||||
|
||||
public sealed record InAppNotificationDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Title,
|
||||
string Message,
|
||||
string Severity,
|
||||
string? ActionType,
|
||||
string? ActionTargetId,
|
||||
bool IsRead,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record NotificationMessage(
|
||||
string Type,
|
||||
string Title,
|
||||
string Message,
|
||||
string Severity,
|
||||
string? ActionType,
|
||||
string? ActionTargetId);
|
||||
|
||||
public interface IInAppNotificationService
|
||||
{
|
||||
Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IInAppNotificationRepository
|
||||
{
|
||||
Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<InAppNotificationRecord>> GetHistoryAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record InAppNotificationRecord(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Title,
|
||||
string Message,
|
||||
string Severity,
|
||||
string? ActionType,
|
||||
string? ActionTargetId,
|
||||
bool IsRead,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public interface IUserNotificationProducer
|
||||
{
|
||||
Task<bool> EnqueueAsync(Guid userId, Guid sourceId, NotificationMessage message, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface INotificationOutboxProcessor
|
||||
{
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<bool> ProcessNextAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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 async Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var records = await _notifications.GetHistoryAsync(userId, ct);
|
||||
return records.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
|
||||
_notifications.AcknowledgeAsync(userId, notificationId, ct);
|
||||
|
||||
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
|
||||
_notifications.MarkAllReadAsync(userId, ct);
|
||||
|
||||
public Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
|
||||
_notifications.DeleteAsync(userId, notificationId, ct);
|
||||
|
||||
private static InAppNotificationDto ToDto(InAppNotificationRecord record) => new(
|
||||
record.Id, record.Type, record.Title, record.Message, record.Severity,
|
||||
record.ActionType, record.ActionTargetId, record.IsRead, 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 MaxReportFileBytes = 20 * 1024 * 1024;
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".webp", ".pdf"
|
||||
};
|
||||
|
||||
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 > MaxReportFileBytes)
|
||||
return Fail(400, "报告文件不能超过 20MB,请压缩后重新上传");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return Fail(400, "目前支持上传 JPG、PNG、WEBP 图片或 PDF 报告");
|
||||
|
||||
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
|
||||
var report = new Report
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
FileUrl = storedFile.FileUrl,
|
||||
FileType = ext == ".pdf" ? ReportFileType.Pdf : 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, // Apple 用户无手机号
|
||||
string Role,
|
||||
string? Name,
|
||||
string? Gender,
|
||||
string? BirthDate,
|
||||
string? AvatarUrl);
|
||||
|
||||
public sealed record UserProfileUpdateRequest(
|
||||
string? Name,
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct);
|
||||
Task DeleteAccountAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
35
backend/src/Health.Application/Users/UserService.cs
Normal file
35
backend/src/Health.Application/Users/UserService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace Health.Application.Users;
|
||||
|
||||
public sealed class UserService(IUserRepository users) : IUserService
|
||||
{
|
||||
private readonly IUserRepository _users = users;
|
||||
|
||||
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var user = await _users.GetAsync(userId, ct);
|
||||
return user == null ? null : new UserProfileDto(
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Role,
|
||||
user.Name,
|
||||
user.Gender,
|
||||
user.BirthDate?.ToString("yyyy-MM-dd"),
|
||||
user.AvatarUrl);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateProfileAsync(Guid userId, UserProfileUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
var user = await _users.GetAsync(userId, ct);
|
||||
if (user == null) return false;
|
||||
|
||||
if (request.Name != null) user.Name = request.Name;
|
||||
if (request.Gender != null) user.Gender = request.Gender;
|
||||
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _users.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
|
||||
_users.DeleteAccountDataAsync(userId, ct);
|
||||
}
|
||||
@@ -7,7 +7,9 @@ public sealed class ExercisePlan
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public DateOnly WeekStartDate { get; set; } // 起始日期
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public TimeOnly ReminderTime { get; set; } = new(19, 0);
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -22,7 +24,7 @@ public sealed class ExercisePlanItem
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PlanId { get; set; }
|
||||
public int DayOfWeek { get; set; } // C# DayOfWeek: 0=周日, 6=周六
|
||||
public DateOnly ScheduledDate { get; set; }
|
||||
public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳
|
||||
public int DurationMinutes { get; set; }
|
||||
public bool IsCompleted { get; set; }
|
||||
|
||||
@@ -38,6 +38,18 @@ public sealed class HealthArchive
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
public ICollection<HealthArchiveSurgery> Surgeries { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class HealthArchiveSurgery
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid HealthArchiveId { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public DateOnly? Date { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public HealthArchive HealthArchive { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -77,6 +89,13 @@ public sealed class NotificationPreference
|
||||
public bool FollowUpReminder { get; set; } = true;
|
||||
public bool DoctorReply { get; set; } = true;
|
||||
public bool AbnormalAlert { get; set; } = true;
|
||||
// 健康录入提醒——总开关 + 5 子项
|
||||
public bool HealthRecordReminder { get; set; } = true;
|
||||
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
||||
public bool HealthRecordReminderHeartRate { get; set; } = true;
|
||||
public bool HealthRecordReminderGlucose { get; set; } = true;
|
||||
public bool HealthRecordReminderSpO2 { get; set; } = true;
|
||||
public bool HealthRecordReminderWeight { get; set; } = true;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
@@ -97,3 +116,20 @@ public sealed class DeviceToken
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class UserNotification
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid SourceId { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = "info";
|
||||
public string? ActionType { get; set; }
|
||||
public string? ActionTargetId { get; set; }
|
||||
public bool IsRead { get; set; }
|
||||
public DateTime? ReadAt { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ namespace Health.Domain.Entities;
|
||||
public sealed class User
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public string? Phone { get; set; } // Apple 用户无手机号
|
||||
public string? AppleUserId { get; set; } // Apple Sign-In 唯一标识
|
||||
public string Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
|
||||
public Guid? DoctorId { get; set; } // 患者选择的医生
|
||||
public string? Name { get; set; }
|
||||
|
||||
@@ -71,6 +71,8 @@ public enum MedicationFrequency
|
||||
/// </summary>
|
||||
public enum ReportStatus
|
||||
{
|
||||
Analyzing, // AI 分析中
|
||||
AnalysisFailed, // AI 分析失败
|
||||
PendingDoctor, // 待医生确认
|
||||
DoctorReviewed // 医生已确认
|
||||
}
|
||||
|
||||
7
backend/src/Health.Domain/ValidationException.cs
Normal file
7
backend/src/Health.Domain/ValidationException.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Health.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// 业务输入校验失败异常——由 Application 层规则抛出,
|
||||
/// 中间件统一映射为 400 / code 40001,message 可安全展示给用户。
|
||||
/// </summary>
|
||||
public sealed class ValidationException(string message) : Exception(message);
|
||||
@@ -1,3 +1,6 @@
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
@@ -21,43 +24,38 @@ public static class CommonAgentHandler
|
||||
|
||||
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
|
||||
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
Guid userId,
|
||||
IHealthRecordService healthRecords,
|
||||
IHealthArchiveService archives,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args),
|
||||
"check_archive" => await ExecuteCheckArchive(db, userId),
|
||||
"query_health_records" => await ExecuteQueryHealthRecords(healthRecords, userId, args, ct),
|
||||
"check_archive" => await ExecuteCheckArchive(archives, userId, ct),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
|
||||
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
||||
|
||||
var query = db.HealthRecords.Where(r => r.UserId == userId);
|
||||
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
||||
query = query.Where(r => r.MetricType == mt);
|
||||
|
||||
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
|
||||
|
||||
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
|
||||
{
|
||||
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
|
||||
}).ToListAsync();
|
||||
|
||||
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
|
||||
return new { count = records.Count, records };
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
|
||||
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
|
||||
var archive = await archives.GetAsync(userId, ct);
|
||||
if (archive == null) return new { found = false };
|
||||
return new
|
||||
{
|
||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries,
|
||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
||||
};
|
||||
}
|
||||
@@ -85,49 +83,26 @@ public static class CommonAgentHandler
|
||||
}
|
||||
};
|
||||
|
||||
public static async Task<object> ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args)
|
||||
public static Task<object> ExecuteManageArchive(
|
||||
IHealthArchiveService archives,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId);
|
||||
if (archive == null)
|
||||
{
|
||||
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
|
||||
db.HealthArchives.Add(archive);
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "update_diagnosis":
|
||||
archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis;
|
||||
break;
|
||||
case "update_surgery":
|
||||
archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType;
|
||||
if (args.TryGetProperty("surgery_date", out var sd))
|
||||
archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate;
|
||||
break;
|
||||
case "update_allergies":
|
||||
if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array)
|
||||
archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)];
|
||||
break;
|
||||
case "update_chronic_diseases":
|
||||
if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array)
|
||||
archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)];
|
||||
break;
|
||||
case "update_diet_restrictions":
|
||||
if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array)
|
||||
archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)];
|
||||
break;
|
||||
default: // query
|
||||
return new
|
||||
{
|
||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
||||
};
|
||||
}
|
||||
|
||||
archive.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return new { success = true };
|
||||
var request = new HealthArchiveUpdateRequest(
|
||||
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
|
||||
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
|
||||
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
|
||||
ReadStringArray(args, "allergies"),
|
||||
ReadStringArray(args, "diet_restrictions"),
|
||||
ReadStringArray(args, "chronic_diseases"),
|
||||
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
|
||||
return archives.ExecuteAiActionAsync(userId, action, request, ct);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
|
||||
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
|
||||
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,4 @@ public static class ConsultationAgentHandler
|
||||
{
|
||||
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
|
||||
|
||||
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,4 @@ public static class DietAgentHandler
|
||||
{
|
||||
public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
|
||||
|
||||
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,83 @@
|
||||
using Health.Application.Exercises;
|
||||
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// 运动计划 Agent 工具处理器
|
||||
/// </summary>
|
||||
public static class ExerciseAgentHandler
|
||||
{
|
||||
public static readonly ToolDefinition ManageExerciseTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "manage_exercise", Description = "运动计划管理(创建/查询/打卡)",
|
||||
Parameters = new { type = "object", properties = new {
|
||||
action = new { type = "string", description = "create/query/checkin" },
|
||||
week_start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
||||
items = new { type = "array", description = "每天的运动项目", items = new { type = "object", properties = new {
|
||||
day_of_week = new { type = "integer", description = "0=周日 1=周一...6=周六" },
|
||||
exercise_type = new { type = "string", description = "运动类型,如散步/慢跑/游泳/太极" },
|
||||
duration_minutes = new { type = "integer", description = "时长(分钟)" },
|
||||
is_rest_day = new { type = "boolean", description = "是否休息日" }
|
||||
}}}
|
||||
}, required = new[] { "action" } }
|
||||
}
|
||||
Name = "manage_exercise",
|
||||
Description = "运动计划管理(创建/查询/打卡)",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "create/query/checkin" },
|
||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" },
|
||||
duration_days = new { type = "integer", description = "连续运动天数,如一周为7天" },
|
||||
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
|
||||
duration_minutes = new { type = "integer", description = "每天运动时长,单位分钟" },
|
||||
reminder_time = new { type = "string", description = "每天提醒时间 HH:mm,默认19:00" },
|
||||
item_id = new { type = "string", description = "打卡条目 ID" },
|
||||
},
|
||||
required = new[] { "action" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static List<ToolDefinition> Tools => [ManageExerciseTool];
|
||||
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
AppDbContext db,
|
||||
Guid userId,
|
||||
IExerciseService? exercises = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return toolName switch
|
||||
if (toolName != "manage_exercise" || exercises == null)
|
||||
return new { success = false, message = $"未知工具: {toolName}" };
|
||||
|
||||
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
|
||||
if (action == "create")
|
||||
{
|
||||
"manage_exercise" => await ExecuteManageExercise(db, userId, args),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
switch (action)
|
||||
{
|
||||
case "create":
|
||||
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
|
||||
if (args.TryGetProperty("items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
|
||||
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
|
||||
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
|
||||
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
|
||||
});
|
||||
}
|
||||
}
|
||||
db.ExercisePlans.Add(plan);
|
||||
await db.SaveChangesAsync();
|
||||
var firstItem = plan.Items.FirstOrDefault();
|
||||
return new {
|
||||
success = true, plan_id = plan.Id,
|
||||
exercise_type = firstItem?.ExerciseType ?? "运动",
|
||||
duration_minutes = firstItem?.DurationMinutes ?? 30,
|
||||
day_count = plan.Items.Count,
|
||||
};
|
||||
|
||||
case "checkin":
|
||||
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
|
||||
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
|
||||
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
|
||||
exerciseItem.IsCompleted = true;
|
||||
exerciseItem.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return new { success = true };
|
||||
|
||||
default:
|
||||
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
|
||||
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
|
||||
if (existingPlan == null) return new { found = false };
|
||||
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
|
||||
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
|
||||
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
|
||||
? parsedStart
|
||||
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var days = args.TryGetProperty("duration_days", out var daysValue) && daysValue.TryGetInt32(out var parsedDays)
|
||||
? Math.Clamp(parsedDays, 1, 366)
|
||||
: 7;
|
||||
var exerciseType = args.TryGetProperty("exercise_type", out var typeValue) ? typeValue.GetString() : "散步";
|
||||
var minutes = args.TryGetProperty("duration_minutes", out var minutesValue) && minutesValue.TryGetInt32(out var parsedMinutes) ? parsedMinutes : 30;
|
||||
var reminder = args.TryGetProperty("reminder_time", out var reminderValue) && TimeOnly.TryParse(reminderValue.GetString(), out var parsedReminder)
|
||||
? parsedReminder
|
||||
: new TimeOnly(19, 0);
|
||||
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(
|
||||
startDate, startDate.AddDays(days - 1), exerciseType, minutes, reminder), ct);
|
||||
return new { success = true, plan_id = planId, exercise_type = exerciseType, duration_minutes = minutes, day_count = days, start_date = startDate, end_date = startDate.AddDays(days - 1), reminder_time = reminder };
|
||||
}
|
||||
|
||||
if (action == "checkin")
|
||||
{
|
||||
var itemId = args.TryGetProperty("item_id", out var itemValue) && itemValue.TryGetGuid(out var parsedId) ? parsedId : Guid.Empty;
|
||||
var success = await exercises.CheckInAsync(userId, itemId, ct);
|
||||
return success ? new { success = true } : new { success = false, message = "条目不存在" };
|
||||
}
|
||||
|
||||
var plan = await exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null
|
||||
? new { found = false }
|
||||
: new
|
||||
{
|
||||
found = true,
|
||||
plan_id = plan.Id,
|
||||
plan.StartDate,
|
||||
plan.EndDate,
|
||||
plan.ReminderTime,
|
||||
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.Application.HealthRecords;
|
||||
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
@@ -16,70 +18,83 @@ public static class HealthDataAgentHandler
|
||||
|
||||
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"record_health_data" => await ExecuteRecordHealthData(db, userId, args),
|
||||
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
|
||||
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
|
||||
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
|
||||
if (metricType == null)
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "blood_pressure":
|
||||
record.MetricType = HealthMetricType.BloodPressure;
|
||||
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
|
||||
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
|
||||
record.Unit = "mmHg";
|
||||
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
|
||||
break;
|
||||
case "heart_rate":
|
||||
record.MetricType = HealthMetricType.HeartRate;
|
||||
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
|
||||
record.Unit = "次/分";
|
||||
record.IsAbnormal = record.Value > 100 || record.Value < 60;
|
||||
break;
|
||||
case "glucose":
|
||||
record.MetricType = HealthMetricType.Glucose;
|
||||
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
|
||||
record.Unit = "mmol/L";
|
||||
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
|
||||
break;
|
||||
case "spo2":
|
||||
record.MetricType = HealthMetricType.SpO2;
|
||||
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
|
||||
record.Unit = "%";
|
||||
record.IsAbnormal = record.Value <= 94;
|
||||
break;
|
||||
case "weight":
|
||||
record.MetricType = HealthMetricType.Weight;
|
||||
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
|
||||
record.Unit = "kg";
|
||||
record.IsAbnormal = false;
|
||||
break;
|
||||
default:
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
}
|
||||
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt)
|
||||
? dt
|
||||
: DateTime.UtcNow;
|
||||
|
||||
db.HealthRecords.Add(record);
|
||||
await db.SaveChangesAsync();
|
||||
var valStr = record.MetricType switch
|
||||
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
|
||||
{
|
||||
HealthMetricType.BloodPressure => $"{record.Systolic}/{record.Diastolic}",
|
||||
_ => record.Value?.ToString() ?? ""
|
||||
success = true,
|
||||
record_id = id,
|
||||
type = metricType.Value.ToString(),
|
||||
value = valStr,
|
||||
unit,
|
||||
isAbnormal = HealthRecordRules.CheckAbnormal(request)
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Health.Application.Medications;
|
||||
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// 药管家 Agent 工具处理器——用药管理
|
||||
/// 药管家 Agent 工具处理器。
|
||||
/// </summary>
|
||||
public static class MedicationAgentHandler
|
||||
{
|
||||
@@ -9,100 +11,128 @@ public static class MedicationAgentHandler
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "manage_medication", Description = "用药管理(创建/查询/确认服药)",
|
||||
Parameters = new { type = "object", properties = new {
|
||||
action = new { type = "string", description = "create/query/confirm" },
|
||||
name = new { type = "string", description = "药品名称" },
|
||||
dosage = new { type = "string", description = "剂量,如 100mg" },
|
||||
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
|
||||
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
|
||||
duration_days = new { type = "integer", description = "服用天数" },
|
||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
||||
}, required = new[] { "action" } }
|
||||
Name = "manage_medication",
|
||||
Description = "用药管理(创建/查询/确认服药)",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "create/query/confirm" },
|
||||
medication_id = new { type = "string", description = "药品 ID,确认服药时使用" },
|
||||
name = new { type = "string", description = "药品名称" },
|
||||
dosage = new { type = "string", description = "剂量,如 100mg" },
|
||||
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
|
||||
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
|
||||
duration_days = new { type = "integer", description = "服用天数" },
|
||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
||||
},
|
||||
required = new[] { "action" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
|
||||
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
Guid userId,
|
||||
IMedicationService medications,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"manage_medication" => await ExecuteManageMedication(db, userId, args),
|
||||
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
||||
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||
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(db, userId, args),
|
||||
"query" => await QueryMedications(db, userId),
|
||||
"confirm" => await ConfirmMedication(db, userId, args),
|
||||
"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(AppDbContext db, Guid userId, JsonElement args)
|
||||
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 frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
|
||||
var frequency = Enum.TryParse<MedicationFrequency>(frequencyStr, out var fr) ? fr : MedicationFrequency.Daily;
|
||||
|
||||
List<TimeOnly> times = [];
|
||||
if (args.TryGetProperty("time_of_day", out var tod) && tod.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var t in tod.EnumerateArray())
|
||||
{
|
||||
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
var frequency = ReadFrequency(args);
|
||||
var times = ReadTimes(args);
|
||||
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
||||
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
|
||||
var startDate = ReadStartDate(args);
|
||||
// 结束日为包含式(IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
|
||||
var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null;
|
||||
|
||||
var med = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
Name = name, Dosage = dosage, Frequency = frequency,
|
||||
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
|
||||
Source = MedicationSource.AiEntry, IsActive = true,
|
||||
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null,
|
||||
};
|
||||
db.Medications.Add(med);
|
||||
await db.SaveChangesAsync();
|
||||
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 = med.Id,
|
||||
name = med.Name, dosage = med.Dosage,
|
||||
frequency = med.Frequency.ToString(), time = timeLabels,
|
||||
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
|
||||
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(AppDbContext db, Guid userId)
|
||||
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
|
||||
{
|
||||
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
|
||||
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
|
||||
return new { count = meds.Count, medications = meds };
|
||||
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(AppDbContext db, Guid userId, JsonElement args)
|
||||
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;
|
||||
db.MedicationLogs.Add(new MedicationLog
|
||||
var success = await medications.ConfirmNowAsync(userId, medId, ct);
|
||||
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
||||
}
|
||||
|
||||
|
||||
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())
|
||||
{
|
||||
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
|
||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return new { success = true };
|
||||
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
|
||||
}
|
||||
|
||||
return times;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,6 @@ public static class ReportAgentHandler
|
||||
|
||||
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||
|
||||
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient)
|
||||
{
|
||||
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
|
||||
@@ -28,13 +19,13 @@ public static class ReportAgentHandler
|
||||
return new { success = false, message = "缺少报告图片" };
|
||||
|
||||
var prompt = """
|
||||
你是一个医学报告解读专家。请分析以下检查报告图片,以JSON格式返回:
|
||||
你是患者端医学报告预解读助手。请分析以下检查报告图片,以JSON格式返回:
|
||||
{
|
||||
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
|
||||
"indicators": [
|
||||
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
|
||||
],
|
||||
"summary": "初步分析摘要",
|
||||
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
|
||||
"needsDoctorReview": true/false
|
||||
}
|
||||
只返回JSON,不要其他内容。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.AI;
|
||||
using Health.Application.Exercises;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Medications;
|
||||
using Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
public sealed class AiToolExecutionService(
|
||||
AppDbContext db,
|
||||
VisionClient visionClient,
|
||||
IHealthArchiveService healthArchives,
|
||||
IHealthRecordService healthRecords,
|
||||
IExerciseService exercises,
|
||||
IMedicationService medications,
|
||||
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly VisionClient _visionClient = visionClient;
|
||||
private readonly IHealthArchiveService _healthArchives = healthArchives;
|
||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||
private readonly IExerciseService _exercises = exercises;
|
||||
private readonly IMedicationService _medications = medications;
|
||||
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
|
||||
|
||||
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
|
||||
{
|
||||
using var json = JsonDocument.Parse(arguments);
|
||||
var root = json.RootElement;
|
||||
return await (toolName switch
|
||||
{
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, 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, 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;
|
||||
}
|
||||
}
|
||||
179
backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs
Normal file
179
backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
public sealed class AttachmentContextBuilder(
|
||||
VisionClient vision,
|
||||
ILogger<AttachmentContextBuilder> logger) : IAttachmentContextBuilder
|
||||
{
|
||||
private const int MaxPdfChars = 6000;
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly VisionClient _vision = vision;
|
||||
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
|
||||
|
||||
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||
return await BuildImageAsync(imageUrl!, ct);
|
||||
if (!string.IsNullOrWhiteSpace(pdfUrl))
|
||||
return await BuildPdfAsync(pdfUrl!, ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 图片:调 VLM 输出结构化 JSON ──
|
||||
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
|
||||
{
|
||||
var filePath = ResolveLocalPath(imageUrl);
|
||||
if (filePath == null || !File.Exists(filePath))
|
||||
{
|
||||
_logger.LogWarning("Image file not found for {Url}", imageUrl);
|
||||
return new AttachmentContext("image", null, null, "图片暂时无法读取", "[图片附件读取失败,请描述图片内容]");
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(filePath, ct);
|
||||
var mime = Path.GetExtension(filePath).ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
".heic" => "image/heic",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
var dataUrl = $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
|
||||
|
||||
var prompt = """
|
||||
识别图片内容,输出 JSON:
|
||||
{"category":"food|report|wound|drug|chart|other","summary":"1-2句话描述图片","details":["关键信息1","关键信息2"]}
|
||||
|
||||
分类标准:
|
||||
- food:任何食物、饮品、餐食
|
||||
- report:医学检查报告、化验单、影像报告(X光/CT/MRI/B超截图等)
|
||||
- wound:伤口、术后切口、皮肤异常
|
||||
- drug:药品包装、药品说明书
|
||||
- chart:心电图、监护仪截图等
|
||||
- other:其他
|
||||
|
||||
details 给出最有价值的细节(食物种类、报告关键值、伤口部位等)。
|
||||
只输出 JSON 本身,不要任何前后缀。
|
||||
""";
|
||||
|
||||
string? raw = null;
|
||||
try
|
||||
{
|
||||
var resp = await _vision.VisionAsync(prompt, [dataUrl], userText: null, maxTokens: 1024, ct: ct);
|
||||
raw = resp.Choices?.FirstOrDefault()?.Message?.Content;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "VLM call failed for image {Url}", imageUrl);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return new AttachmentContext("image", null, null, "图片识别失败", "[图片附件识别失败,请简要描述图片内容]");
|
||||
|
||||
try
|
||||
{
|
||||
var cleaned = StripCodeFence(raw.Trim());
|
||||
using var doc = JsonDocument.Parse(cleaned);
|
||||
var root = doc.RootElement;
|
||||
var category = GetString(root, "category") ?? "other";
|
||||
var summary = GetString(root, "summary") ?? "图片内容";
|
||||
var details = root.TryGetProperty("details", out var d) && d.ValueKind == JsonValueKind.Array
|
||||
? string.Join("、", d.EnumerateArray().Select(x => x.GetString()).Where(s => !string.IsNullOrWhiteSpace(s)))
|
||||
: "";
|
||||
|
||||
var compact = string.IsNullOrEmpty(details)
|
||||
? $"图片识别:{summary}"
|
||||
: $"图片识别:{summary}({details})";
|
||||
var llmContent = $"[图片识别(类别 {category}):{summary}{(string.IsNullOrEmpty(details) ? "" : $",包含 {details}")}]";
|
||||
|
||||
return new AttachmentContext("image", category, null, compact, llmContent);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse VLM JSON: {Raw}", raw);
|
||||
// 兜底:原文也能用
|
||||
return new AttachmentContext("image", null, null, $"图片识别:{raw[..Math.Min(raw.Length, 80)]}", $"[图片识别:{raw}]");
|
||||
}
|
||||
}
|
||||
|
||||
// ── PDF:PdfPig 抽取文本 ──
|
||||
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
|
||||
{
|
||||
var filePath = ResolveLocalPath(pdfUrl);
|
||||
var fileName = Path.GetFileName(pdfUrl);
|
||||
if (filePath == null || !File.Exists(filePath))
|
||||
{
|
||||
_logger.LogWarning("PDF file not found for {Url}", pdfUrl);
|
||||
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
|
||||
"pdf", null, fileName, "PDF 文件读取失败",
|
||||
"[PDF 附件读取失败,请描述文档内容]"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var pdf = PdfDocument.Open(filePath);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (var page in pdf.GetPages())
|
||||
{
|
||||
sb.AppendLine(page.Text);
|
||||
if (sb.Length > MaxPdfChars) break;
|
||||
}
|
||||
var text = sb.ToString().Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
|
||||
"pdf", null, fileName, "PDF 内容为空或扫描件",
|
||||
"[PDF 看起来是扫描件或图片版,无法提取文字。请提示用户改用拍照上传,或简要描述报告内容]"));
|
||||
}
|
||||
|
||||
var truncated = text.Length > MaxPdfChars ? text[..MaxPdfChars] + "...(已截断)" : text;
|
||||
var summary = $"PDF:{fileName}";
|
||||
var llmContent = $"[用户上传 PDF 「{fileName}」,文档内容如下]\n{truncated}";
|
||||
|
||||
return Task.FromResult<AttachmentContext?>(new AttachmentContext("pdf", null, fileName, summary, llmContent));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse PDF {Url}", pdfUrl);
|
||||
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
|
||||
"pdf", null, fileName, "PDF 解析异常",
|
||||
"[PDF 解析失败,请提示用户文件可能损坏或加密]"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveLocalPath(string url)
|
||||
{
|
||||
// url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。
|
||||
var idx = url.IndexOf("/uploads/", StringComparison.Ordinal);
|
||||
if (idx < 0) return null;
|
||||
var relative = url[(idx + "/uploads/".Length)..];
|
||||
// 去掉可能的 query string
|
||||
var q = relative.IndexOf('?');
|
||||
if (q >= 0) relative = relative[..q];
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative);
|
||||
}
|
||||
|
||||
private static string StripCodeFence(string raw)
|
||||
{
|
||||
var t = raw.Trim();
|
||||
if (t.StartsWith("```"))
|
||||
{
|
||||
var firstNewline = t.IndexOf('\n');
|
||||
if (firstNewline > 0) t = t[(firstNewline + 1)..];
|
||||
if (t.EndsWith("```")) t = t[..^3];
|
||||
}
|
||||
return t.Trim();
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement el, string name) =>
|
||||
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
|
||||
using var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await AiHttpResponseGuard.ThrowIfFailedAsync(response, "DeepSeek", ct);
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using var reader = new StreamReader(stream);
|
||||
@@ -76,11 +76,9 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await _http.PostAsync("chat/completions", content, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||
using var response = await _http.PostAsync("chat/completions", content, ct);
|
||||
var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "DeepSeek", ct);
|
||||
return AiHttpResponseGuard.DeserializeRequired<ChatCompletionResponse>(body, _jsonOptions, "DeepSeek");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,17 +119,50 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
||||
Temperature = 0.1f, VlHighResolutionImages = true,
|
||||
EnableThinking = false,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var response = await _http.PostAsync("chat/completions", content, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException($"VLM API {response.StatusCode}: {errorBody}");
|
||||
}
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||
using var response = await _http.PostAsync("chat/completions", content, ct);
|
||||
var body = await AiHttpResponseGuard.ReadSuccessBodyAsync(response, "VLM", ct);
|
||||
return AiHttpResponseGuard.DeserializeRequired<ChatCompletionResponse>(body, _jsonOptions, "VLM");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AiHttpResponseGuard
|
||||
{
|
||||
private const int MaxErrorBodyLength = 2000;
|
||||
|
||||
public static async Task ThrowIfFailedAsync(HttpResponseMessage response, string provider, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException($"{provider} API {(int)response.StatusCode} ({response.StatusCode}): {Truncate(body)}");
|
||||
}
|
||||
|
||||
public static async Task<string> ReadSuccessBodyAsync(HttpResponseMessage response, string provider, CancellationToken ct)
|
||||
{
|
||||
await ThrowIfFailedAsync(response, provider, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
throw new InvalidDataException($"{provider} API返回了空响应");
|
||||
return body;
|
||||
}
|
||||
|
||||
public static T DeserializeRequired<T>(string body, JsonSerializerOptions options, string provider)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(body, options)
|
||||
?? throw new InvalidDataException($"{provider} API响应内容为空");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"{provider} API响应格式无效", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value) =>
|
||||
value.Length <= MaxErrorBodyLength ? value : value[..MaxErrorBodyLength] + "...";
|
||||
}
|
||||
|
||||
84
backend/src/Health.Infrastructure/AI/ai_contracts.cs
Normal file
84
backend/src/Health.Infrastructure/AI/ai_contracts.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
public sealed class ChatCompletionRequest
|
||||
{
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public List<ChatMessage> Messages { get; set; } = [];
|
||||
public bool Stream { get; set; }
|
||||
public int MaxTokens { get; set; } = 2048;
|
||||
public float Temperature { get; set; } = 0.7f;
|
||||
public float? TopP { get; set; }
|
||||
public List<ToolDefinition>? Tools { get; set; }
|
||||
public string? ToolChoice { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
||||
public bool VlHighResolutionImages { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
|
||||
public bool? EnableThinking { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChatMessage
|
||||
{
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public object? Content { get; set; } = string.Empty;
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ToolCallId { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<ToolCall>? ToolCalls { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ToolDefinition
|
||||
{
|
||||
public string Type { get; set; } = "function";
|
||||
public ToolFunction Function { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ToolFunction
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public object Parameters { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ToolCall
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "function";
|
||||
public ToolCallFunction Function { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ToolCallFunction
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Arguments { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ChatCompletionResponse
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public List<Choice> Choices { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class Choice
|
||||
{
|
||||
public int Index { get; set; }
|
||||
public ResponseMessage? Message { get; set; }
|
||||
public ResponseDelta? Delta { get; set; }
|
||||
public string? FinishReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResponseMessage
|
||||
{
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public string? Content { get; set; }
|
||||
public List<ToolCall>? ToolCalls { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResponseDelta
|
||||
{
|
||||
public string? Content { get; set; }
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。
|
||||
/// 我们后端的 DeepSeek 依旧负责对话生成与工具调用;
|
||||
/// 这里只把检索到的医学知识片段拼回 system prompt,提升回答准确度。
|
||||
/// </summary>
|
||||
public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger)
|
||||
{
|
||||
private readonly HttpClient _http = http;
|
||||
private readonly string _datasetId = config["FASTGPT_DATASET_ID"] ?? "";
|
||||
private readonly float _similarity = float.TryParse(config["FASTGPT_SIMILARITY"], out var s) ? s : 0.4f;
|
||||
private readonly int _limit = int.TryParse(config["FASTGPT_LIMIT"], out var l) ? l : 3000;
|
||||
private readonly ILogger<FastGptKnowledgeClient> _logger = logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId);
|
||||
|
||||
/// <summary>
|
||||
/// 按用户问题检索知识库,返回最相关的文档片段。
|
||||
/// 失败或未配置时返回空字符串,调用方应当容错——RAG 只是增强,不是必需。
|
||||
/// </summary>
|
||||
public async Task<string> SearchAsync(string question, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled || string.IsNullOrWhiteSpace(question)) return "";
|
||||
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
datasetId = _datasetId,
|
||||
text = question,
|
||||
limit = _limit,
|
||||
similarity = _similarity,
|
||||
searchMode = "embedding",
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await _http.PostAsync("core/dataset/searchTest", content, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("FastGPT 检索失败: {Status}", response.StatusCode);
|
||||
return "";
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data)) return "";
|
||||
if (!data.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) return "";
|
||||
|
||||
var snippets = new List<string>();
|
||||
foreach (var item in list.EnumerateArray())
|
||||
{
|
||||
var q = item.TryGetProperty("q", out var qEl) ? qEl.GetString() ?? "" : "";
|
||||
var a = item.TryGetProperty("a", out var aEl) ? aEl.GetString() ?? "" : "";
|
||||
if (string.IsNullOrWhiteSpace(q) && string.IsNullOrWhiteSpace(a)) continue;
|
||||
snippets.Add(string.IsNullOrWhiteSpace(a) ? q : $"{q}:{a}");
|
||||
}
|
||||
|
||||
return snippets.Count == 0 ? "" : string.Join("\n", snippets);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "FastGPT 检索异常,已忽略");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI 兼容协议 HTTP 客户端,统一调用 DeepSeek / 千问 VL
|
||||
/// </summary>
|
||||
public sealed class OpenAiCompatibleClient(string baseUrl, string apiKey, string model)
|
||||
{
|
||||
private readonly HttpClient _http = CreateHttpClient(baseUrl, apiKey);
|
||||
private readonly string _model = model;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static HttpClient CreateHttpClient(string baseUrl, string apiKey)
|
||||
{
|
||||
var client = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"),
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 流式 Chat Completions(SSE)
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<string> ChatStreamAsync(
|
||||
List<ChatMessage> messages,
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
Stream = true,
|
||||
MaxTokens = maxTokens,
|
||||
Temperature = temperature,
|
||||
Tools = tools,
|
||||
};
|
||||
|
||||
if (tools?.Count > 0)
|
||||
request.ToolChoice = "auto";
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
|
||||
var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
if (!line.StartsWith("data: ")) continue;
|
||||
|
||||
var data = line["data: ".Length..];
|
||||
if (data == "[DONE]") break;
|
||||
|
||||
yield return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 非流式 Chat Completions
|
||||
/// </summary>
|
||||
public async Task<ChatCompletionResponse> ChatAsync(
|
||||
List<ChatMessage> messages,
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
Stream = false,
|
||||
MaxTokens = maxTokens,
|
||||
Temperature = temperature,
|
||||
Tools = tools,
|
||||
};
|
||||
|
||||
if (tools?.Count > 0)
|
||||
request.ToolChoice = "auto";
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await _http.PostAsync("chat/completions", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vision 图片理解(非流式)
|
||||
/// </summary>
|
||||
public async Task<ChatCompletionResponse> VisionAsync(
|
||||
string systemPrompt,
|
||||
List<string> imageUrls,
|
||||
string? userText = null,
|
||||
int maxTokens = 2048)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
if (!string.IsNullOrEmpty(systemPrompt))
|
||||
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
|
||||
|
||||
// 构建多模态消息内容
|
||||
var contentParts = new List<object>();
|
||||
foreach (var url in imageUrls)
|
||||
contentParts.Add(new { type = "image_url", image_url = new { url } });
|
||||
if (!string.IsNullOrEmpty(userText))
|
||||
contentParts.Add(new { type = "text", text = userText });
|
||||
|
||||
var userMessage = new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = contentParts // 数组格式,让 JSON 序列化时保持为数组而非字符串
|
||||
};
|
||||
|
||||
messages.Add(userMessage);
|
||||
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model,
|
||||
Messages = messages,
|
||||
MaxTokens = maxTokens,
|
||||
Stream = false,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var response = await _http.PostAsync("chat/completions", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||
}
|
||||
}
|
||||
|
||||
#region 请求/响应模型
|
||||
|
||||
public sealed class ChatCompletionRequest
|
||||
{
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public List<ChatMessage> Messages { get; set; } = [];
|
||||
public bool Stream { get; set; }
|
||||
public int MaxTokens { get; set; } = 2048;
|
||||
public float Temperature { get; set; } = 0.7f;
|
||||
public float? TopP { get; set; }
|
||||
public List<ToolDefinition>? Tools { get; set; }
|
||||
public string? ToolChoice { get; set; }
|
||||
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
||||
public bool VlHighResolutionImages { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChatMessage
|
||||
{
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public object? Content { get; set; } = string.Empty;
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ToolCallId { get; set; }
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<ToolCall>? ToolCalls { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ToolDefinition
|
||||
{
|
||||
public string Type { get; set; } = "function";
|
||||
public ToolFunction Function { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ToolFunction
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public object Parameters { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ToolCall
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "function";
|
||||
public ToolCallFunction Function { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ToolCallFunction
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Arguments { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ChatCompletionResponse
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public List<Choice> Choices { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class Choice
|
||||
{
|
||||
public int Index { get; set; }
|
||||
public ResponseMessage? Message { get; set; }
|
||||
public ResponseDelta? Delta { get; set; }
|
||||
public string? FinishReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResponseMessage
|
||||
{
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public string? Content { get; set; }
|
||||
public List<ToolCall>? ToolCalls { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResponseDelta
|
||||
{
|
||||
public string? Content { get; set; }
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -8,18 +8,55 @@ public sealed class PromptManager
|
||||
/// <summary>
|
||||
/// 获取指定 Agent 的 System Prompt
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType) => agentType switch
|
||||
public string GetSystemPrompt(AgentType agentType)
|
||||
{
|
||||
AgentType.Default => DefaultPrompt,
|
||||
AgentType.Consultation => ConsultationPrompt,
|
||||
AgentType.Health => HealthDataPrompt,
|
||||
AgentType.Diet => DietPrompt,
|
||||
AgentType.Medication => MedicationPrompt,
|
||||
AgentType.Report => ReportPrompt,
|
||||
AgentType.Exercise => ExercisePrompt,
|
||||
AgentType.Unified => UnifiedPrompt,
|
||||
_ => DefaultPrompt
|
||||
};
|
||||
var prompt = agentType switch
|
||||
{
|
||||
AgentType.Default => DefaultPrompt,
|
||||
AgentType.Consultation => ConsultationPrompt,
|
||||
AgentType.Health => HealthDataPrompt,
|
||||
AgentType.Diet => DietPrompt,
|
||||
AgentType.Medication => MedicationPrompt,
|
||||
AgentType.Report => ReportPrompt,
|
||||
AgentType.Exercise => ExercisePrompt,
|
||||
AgentType.Unified => UnifiedPrompt,
|
||||
_ => DefaultPrompt
|
||||
};
|
||||
|
||||
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
|
||||
}
|
||||
|
||||
private const string SmartLinkRules = """
|
||||
智能跳转链接(按需嵌入):
|
||||
- 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。
|
||||
- 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。
|
||||
- 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。
|
||||
|
||||
硬性约束:
|
||||
- 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。
|
||||
- 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
|
||||
- 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。
|
||||
""";
|
||||
|
||||
private const string MedicalBoundaryRules = """
|
||||
医疗边界(必须遵守):
|
||||
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不要给出确定诊断,不要说“你就是/一定是/已经确诊”。
|
||||
- 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,必须建议咨询医生或药师。
|
||||
- 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。
|
||||
- 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。
|
||||
- 医疗相关分析末尾用一句自然的话提醒:以上为 AI 预分析,不能替代医生诊断和治疗建议。
|
||||
|
||||
回复格式规则(严格遵守):
|
||||
- 禁止使用 **粗体**、*斜体*、~~删除线~~ 等 Markdown 内联标记
|
||||
- 禁止使用 HTML 标签
|
||||
- 禁止使用 Markdown 表格(|--|--|)
|
||||
- 标题只使用 ### 三级标题,禁止使用 #、##、####
|
||||
- 列表使用 - 开头,每项一行
|
||||
- 数值和单位之间加空格:120 mmHg、6.2 mmol/L
|
||||
- 不要输出任何 $ 开头的占位符
|
||||
- 回复结尾不再重复免责声明,MedicalBoundaryRules 已统一处理
|
||||
""";
|
||||
|
||||
private const string DefaultPrompt = """
|
||||
你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。
|
||||
@@ -38,15 +75,15 @@ public sealed class PromptManager
|
||||
""";
|
||||
|
||||
private const string ConsultationPrompt = """
|
||||
你是一个心血管内科医生助手,负责对心脏术后患者进行多轮问诊。
|
||||
你是一个患者端 AI 预问诊助手,负责帮助心脏术后患者梳理症状和就医前信息。
|
||||
|
||||
规则:
|
||||
1. 每次只问一个问题,不要一次问多个
|
||||
2. 给出 2-3 个快捷选项让患者点击
|
||||
3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析
|
||||
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
|
||||
5. 遇到以下情况建议转医生:血压持续>160/100、心率>120或<50
|
||||
6. 所有分析末尾标注"以上为AI分析,具体请咨询医生"
|
||||
5. 遇到以下情况建议咨询医生:血压持续>160/100、心率>120或<50
|
||||
6. 只给出初步分析和下一步建议,不作诊断结论
|
||||
7. 问诊结束给出结构化小结
|
||||
""";
|
||||
|
||||
@@ -55,13 +92,13 @@ public sealed class PromptManager
|
||||
|
||||
规则:
|
||||
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
||||
2. 指标明确+数值明确→直接调用 record_health_data 录入
|
||||
2. 指标明确+数值明确→调用 record_health_data 生成待确认写入命令,不要直接声称已经录入
|
||||
3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")→多次调用 record_health_data
|
||||
4. 用户可以分时段说(如"早上血压120,下午血压130")→分别录入,recorded_at 参数用不同时间
|
||||
4. 用户可以分时段说(如"早上血压120,下午血压130")→分别生成待确认命令,recorded_at 参数用不同时间
|
||||
5. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
||||
6. 时间模糊→取当前时间
|
||||
7. 数值超出正常范围→附带异常提醒
|
||||
8. 每次调用 record_health_data 后,继续调用下一个,全部录入完成后生成确认卡片格式的回复
|
||||
8. 每次调用 record_health_data 后继续处理下一个;工具返回仅表示等待确认,必须提示用户点击确认卡片,不能说“录入成功”
|
||||
|
||||
正常值参考范围:
|
||||
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
||||
@@ -86,24 +123,24 @@ public sealed class PromptManager
|
||||
""";
|
||||
|
||||
private const string MedicationPrompt = """
|
||||
你是一个用药管理专家。
|
||||
你是一个用药记录与提醒助手。
|
||||
|
||||
规则:
|
||||
1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录
|
||||
2. 解析用户口中的药品信息(药名/剂量/频次/时间)
|
||||
3. "早饭后"等模糊时间→追问具体几点
|
||||
4. 解析完成后展示确认卡片
|
||||
4. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存
|
||||
5. 处方拍照→提取药品信息→生成用药计划→让用户确认
|
||||
6. 回答用药相关疑问
|
||||
6. 可以解释常见用药注意事项,但不要建议用户自行新增、停用、更换药物或调整剂量
|
||||
""";
|
||||
|
||||
private const string ReportPrompt = """
|
||||
你是一个医学报告解读专家。
|
||||
你是一个患者端医学报告预解读助手。
|
||||
|
||||
规则:
|
||||
1. 收到报告图片后,提取所有指标及其数值
|
||||
2. 标注异常指标(偏高/偏低/正常)
|
||||
3. 给出初步分析
|
||||
3. 给出初步分析和可能方向,不作诊断结论
|
||||
4. 所有内容标注"AI预解读,待医生确认"
|
||||
5. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
||||
""";
|
||||
@@ -120,7 +157,7 @@ public sealed class PromptManager
|
||||
""";
|
||||
|
||||
private const string UnifiedPrompt = """
|
||||
你是一个全能的AI健康管家,为心脏术后康复患者提供全方位服务。
|
||||
你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。
|
||||
|
||||
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
||||
|
||||
@@ -133,20 +170,21 @@ public sealed class PromptManager
|
||||
6. 历史数据查询 → 调用 query_health_records
|
||||
|
||||
运动计划参数格式(manage_exercise):
|
||||
- action="create", week_start_date="今天日期(YYYY-MM-DD)"
|
||||
- items: [{"day_of_week":0-6(周日=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...]
|
||||
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
|
||||
- duration_days=连续天数,exercise_type="散步",duration_minutes=30,reminder_time="19:00"
|
||||
- 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字
|
||||
- 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天
|
||||
- 用户说"坚持X天/月/周"则创建对应天数
|
||||
- items 数量必须等于持续天数
|
||||
- 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目
|
||||
|
||||
用药管理参数格式(manage_medication):
|
||||
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
|
||||
|
||||
规则:
|
||||
- 用户可能一句话涉及多个领域,全部处理
|
||||
- 数值明确+指标明确→直接录入,不要追问
|
||||
- 录入完成后生成确认卡片
|
||||
- 数值明确+指标明确→直接生成待确认命令,不要追问
|
||||
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温暖、专业
|
||||
""";
|
||||
|
||||
90
backend/src/Health.Infrastructure/Admin/AdminService.cs
Normal file
90
backend/src/Health.Infrastructure/Admin/AdminService.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using Health.Application.Admin;
|
||||
|
||||
namespace Health.Infrastructure.Admin;
|
||||
|
||||
public sealed class AdminService(AppDbContext db) : IAdminService
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public async Task<AdminResult> ListDoctorsAsync(CancellationToken ct)
|
||||
{
|
||||
var doctors = await _db.Doctors.AsNoTracking().OrderBy(x => x.CreatedAt)
|
||||
.Select(x => new { x.Id, x.Name, x.Title, x.Department, x.Phone, x.ProfessionalDirection, x.IsActive, x.AvatarUrl, x.Introduction, x.CreatedAt })
|
||||
.ToListAsync(ct);
|
||||
return Ok(doctors);
|
||||
}
|
||||
|
||||
public async Task<AdminResult> AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空");
|
||||
if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空");
|
||||
var doctor = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department,
|
||||
Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.Doctors.Add(doctor);
|
||||
if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
|
||||
{
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.Name, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
_db.Users.Add(user);
|
||||
_db.DoctorProfiles.Add(new DoctorProfile
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = command.Name,
|
||||
Title = command.Title, Department = command.Department, IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { doctor.Id, doctor.Name });
|
||||
}
|
||||
|
||||
public async Task<AdminResult> UpdateDoctorAsync(Guid id, UpdateDoctorCommand command, CancellationToken ct)
|
||||
{
|
||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null) return Error(404, "医生不存在");
|
||||
if (command.Name != null) doctor.Name = command.Name;
|
||||
if (command.Title != null) doctor.Title = command.Title;
|
||||
if (command.Department != null) doctor.Department = command.Department;
|
||||
if (command.Phone != null) doctor.Phone = command.Phone;
|
||||
if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { doctor.Id });
|
||||
}
|
||||
|
||||
public async Task<AdminResult> ToggleDoctorAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null) return Error(404, "医生不存在");
|
||||
doctor.IsActive = !doctor.IsActive;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { doctor.Id, doctor.IsActive });
|
||||
}
|
||||
|
||||
public async Task<AdminResult> DeleteDoctorAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var doctor = await _db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null) return Error(404, "医生不存在");
|
||||
await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
|
||||
_db.Doctors.Remove(doctor);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
public async Task<AdminResult> ListPatientsAsync(string? search, int page, int pageSize, CancellationToken ct)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var query = _db.Users.AsNoTracking().Where(x => x.Role == "User");
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(x => (x.Name != null && x.Name.Contains(search)) || x.Phone.Contains(search));
|
||||
var total = await query.CountAsync(ct);
|
||||
var patients = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new { x.Id, x.Name, x.Phone, x.Gender, x.BirthDate, x.CreatedAt, DoctorName = x.Doctor != null ? x.Doctor.Name : null, DoctorDepartment = x.Doctor != null ? x.Doctor.Department : null })
|
||||
.ToListAsync(ct);
|
||||
return Ok(new { patients, total, page, pageSize });
|
||||
}
|
||||
|
||||
private static AdminResult Ok(object data) => new(0, data);
|
||||
private static AdminResult Error(int code, string message) => new(code, null, message);
|
||||
}
|
||||
172
backend/src/Health.Infrastructure/Auth/AuthService.cs
Normal file
172
backend/src/Health.Infrastructure/Auth/AuthService.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using Health.Application.Auth;
|
||||
using Health.Infrastructure.Services;
|
||||
|
||||
namespace Health.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthService(
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
SmsService sms,
|
||||
AppleTokenValidator appleValidator // Apple Sign-In JWT 验证
|
||||
) : 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;
|
||||
private readonly AppleTokenValidator _appleValidator = appleValidator;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<AuthResult> AppleLoginAsync(AppleLoginCommand command, CancellationToken ct)
|
||||
{
|
||||
// 1. 验证 Apple identityToken
|
||||
string appleUserId;
|
||||
try
|
||||
{
|
||||
appleUserId = await _appleValidator.ValidateAsync(command.IdentityToken, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Error(40005, $"Apple 登录验证失败: {ex.Message}");
|
||||
}
|
||||
|
||||
// 2. 查找已有用户
|
||||
var existingUser = await _db.Users.FirstOrDefaultAsync(x => x.AppleUserId == appleUserId, ct);
|
||||
if (existingUser != null)
|
||||
{
|
||||
existingUser.UpdatedAt = DateTime.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(command.Name)) existingUser.Name ??= command.Name;
|
||||
var tokens = AddTokens(existingUser.Id, existingUser.Phone, existingUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
tokens.accessToken, tokens.refreshToken,
|
||||
user = new { existingUser.Id, existingUser.Phone, existingUser.Role, existingUser.Name, existingUser.Gender, existingUser.AvatarUrl, isNew = false }
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 新用户 → 创建账户(无需手机号、无需选医生)
|
||||
var newUser = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = null,
|
||||
AppleUserId = appleUserId,
|
||||
Role = "User",
|
||||
Name = command.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.Users.Add(newUser);
|
||||
_db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
_db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = newUser.Id });
|
||||
var newTokens = AddTokens(newUser.Id, null, newUser.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
newTokens.accessToken, newTokens.refreshToken,
|
||||
user = new { newUser.Id, Phone = (string?)null, newUser.Role, newUser.Name, isNew = true }
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
1443
backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs
generated
Normal file
1443
backend/src/Health.Infrastructure/Data/Migrations/20260621114547_InitialCreate.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,946 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AiWriteCommands",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ToolName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Arguments = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AiWriteCommands", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DietImageAnalysisTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FilePaths = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ResultCode = table.Column<int>(type: "integer", nullable: true),
|
||||
ResultData = table.Column<string>(type: "text", nullable: true),
|
||||
ResultMessage = table.Column<string>(type: "text", nullable: true),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DietImageAnalysisTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Doctors",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Department = table.Column<string>(type: "text", nullable: true),
|
||||
Phone = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||
ProfessionalDirection = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: true),
|
||||
Introduction = table.Column<string>(type: "text", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Doctors", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MedicationReminderTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Dosage = table.Column<string>(type: "text", nullable: true),
|
||||
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MedicationReminderTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationOutbox",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SourceTaskId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Payload = table.Column<string>(type: "jsonb", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationOutbox", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RefreshTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "text", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsRevoked = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReportAnalysisTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ReportId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FilePath = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Attempts = table.Column<int>(type: "integer", nullable: false),
|
||||
AvailableAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastError = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ReportAnalysisTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserNotifications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SourceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
Message = table.Column<string>(type: "text", nullable: false),
|
||||
Severity = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
ActionType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
ActionTargetId = table.Column<string>(type: "text", nullable: true),
|
||||
IsRead = table.Column<bool>(type: "boolean", nullable: false),
|
||||
ReadAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserNotifications", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VerificationCodes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Phone = table.Column<string>(type: "text", nullable: false),
|
||||
Code = table.Column<string>(type: "text", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsUsed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VerificationCodes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Phone = table.Column<string>(type: "text", nullable: false),
|
||||
Role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "User"),
|
||||
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Name = table.Column<string>(type: "text", nullable: true),
|
||||
Gender = table.Column<string>(type: "text", nullable: true),
|
||||
BirthDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Users_Doctors_DoctorId",
|
||||
column: x => x.DoctorId,
|
||||
principalTable: "Doctors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Consultations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DoctorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
Month = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Consultations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Consultations_Doctors_DoctorId",
|
||||
column: x => x.DoctorId,
|
||||
principalTable: "Doctors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Consultations_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Conversations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AgentType = table.Column<string>(type: "text", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Summary = table.Column<string>(type: "text", nullable: true),
|
||||
MessageCount = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Conversations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Conversations_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DeviceTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Platform = table.Column<string>(type: "text", nullable: false),
|
||||
PushToken = table.Column<string>(type: "text", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DeviceTokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DeviceTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DietRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MealType = table.Column<string>(type: "text", nullable: false),
|
||||
TotalCalories = table.Column<int>(type: "integer", nullable: true),
|
||||
HealthScore = table.Column<int>(type: "integer", nullable: true),
|
||||
RecordedAt = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DietRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DietRecords_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DoctorProfiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DoctorId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Name = table.Column<string>(type: "text", nullable: true),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Department = table.Column<string>(type: "text", nullable: true),
|
||||
Hospital = table.Column<string>(type: "text", nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: true),
|
||||
IsOnline = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DoctorProfiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DoctorProfiles_Doctors_DoctorId",
|
||||
column: x => x.DoctorId,
|
||||
principalTable: "Doctors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_DoctorProfiles_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExercisePlans",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ReminderTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExercisePlans", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExercisePlans_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FollowUps",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
DoctorName = table.Column<string>(type: "text", nullable: true),
|
||||
Department = table.Column<string>(type: "text", nullable: true),
|
||||
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Notes = table.Column<string>(type: "text", nullable: true),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FollowUps", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FollowUps_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthArchives",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Diagnosis = table.Column<string>(type: "text", nullable: true),
|
||||
SurgeryType = table.Column<string>(type: "text", nullable: true),
|
||||
SurgeryDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
Allergies = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
DietRestrictions = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
ChronicDiseases = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
FamilyHistory = table.Column<string>(type: "text", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthArchives", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthArchives_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MetricType = table.Column<string>(type: "text", nullable: false),
|
||||
Systolic = table.Column<int>(type: "integer", nullable: true),
|
||||
Diastolic = table.Column<int>(type: "integer", nullable: true),
|
||||
Value = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
Unit = table.Column<string>(type: "text", nullable: true),
|
||||
Source = table.Column<string>(type: "text", nullable: false),
|
||||
IsAbnormal = table.Column<bool>(type: "boolean", nullable: false),
|
||||
RecordedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthRecords_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Medications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Dosage = table.Column<string>(type: "text", nullable: true),
|
||||
Frequency = table.Column<string>(type: "text", nullable: false),
|
||||
TimeOfDay = table.Column<List<TimeOnly>>(type: "time[]", nullable: false),
|
||||
StartDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
EndDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Source = table.Column<string>(type: "text", nullable: false),
|
||||
Notes = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Medications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Medications_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationPreferences",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MedicationReminder = table.Column<bool>(type: "boolean", nullable: false),
|
||||
FollowUpReminder = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DoctorReply = table.Column<bool>(type: "boolean", nullable: false),
|
||||
AbnormalAlert = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationPreferences", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationPreferences_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Reports",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileUrl = table.Column<string>(type: "text", nullable: false),
|
||||
FileType = table.Column<string>(type: "text", nullable: false),
|
||||
Category = table.Column<string>(type: "text", nullable: false),
|
||||
AiSummary = table.Column<string>(type: "text", nullable: true),
|
||||
AiIndicators = table.Column<string>(type: "jsonb", nullable: true),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
Severity = table.Column<string>(type: "text", nullable: true),
|
||||
DoctorComment = table.Column<string>(type: "text", nullable: true),
|
||||
DoctorRecommendation = table.Column<string>(type: "text", nullable: true),
|
||||
DoctorName = table.Column<string>(type: "text", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Reports", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Reports_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConsultationMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ConsultationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SenderType = table.Column<string>(type: "text", nullable: false),
|
||||
SenderName = table.Column<string>(type: "text", nullable: true),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConsultationMessages", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConsultationMessages_Consultations_ConsultationId",
|
||||
column: x => x.ConsultationId,
|
||||
principalTable: "Consultations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConversationMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Role = table.Column<string>(type: "text", nullable: false),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
Intent = table.Column<string>(type: "text", nullable: true),
|
||||
MetadataJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConversationMessages", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConversationMessages_Conversations_ConversationId",
|
||||
column: x => x.ConversationId,
|
||||
principalTable: "Conversations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DietFoodItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DietRecordId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Portion = table.Column<string>(type: "text", nullable: true),
|
||||
Calories = table.Column<int>(type: "integer", nullable: true),
|
||||
ProteinGrams = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
CarbsGrams = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
FatGrams = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
Warning = table.Column<string>(type: "text", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DietFoodItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DietFoodItems_DietRecords_DietRecordId",
|
||||
column: x => x.DietRecordId,
|
||||
principalTable: "DietRecords",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExercisePlanItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PlanId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ScheduledDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
ExerciseType = table.Column<string>(type: "text", nullable: false),
|
||||
DurationMinutes = table.Column<int>(type: "integer", nullable: false),
|
||||
IsCompleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsRestDay = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExercisePlanItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExercisePlanItems_ExercisePlans_PlanId",
|
||||
column: x => x.PlanId,
|
||||
principalTable: "ExercisePlans",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HealthArchiveSurgeries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
HealthArchiveId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Date = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HealthArchiveSurgeries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HealthArchiveSurgeries_HealthArchives_HealthArchiveId",
|
||||
column: x => x.HealthArchiveId,
|
||||
principalTable: "HealthArchives",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MedicationLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MedicationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
ScheduledTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
|
||||
ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MedicationLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MedicationLogs_Medications_MedicationId",
|
||||
column: x => x.MedicationId,
|
||||
principalTable: "Medications",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AiWriteCommands_UserId_Status_ExpiresAt",
|
||||
table: "AiWriteCommands",
|
||||
columns: new[] { "UserId", "Status", "ExpiresAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConsultationMessages_ConsultationId_CreatedAt",
|
||||
table: "ConsultationMessages",
|
||||
columns: new[] { "ConsultationId", "CreatedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Consultations_DoctorId",
|
||||
table: "Consultations",
|
||||
column: "DoctorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Consultations_UserId",
|
||||
table: "Consultations",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConversationMessages_ConversationId_CreatedAt",
|
||||
table: "ConversationMessages",
|
||||
columns: new[] { "ConversationId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Conversations_UserId_UpdatedAt",
|
||||
table: "Conversations",
|
||||
columns: new[] { "UserId", "UpdatedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeviceTokens_UserId",
|
||||
table: "DeviceTokens",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DietFoodItems_DietRecordId",
|
||||
table: "DietFoodItems",
|
||||
column: "DietRecordId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DietImageAnalysisTasks_Status_AvailableAt",
|
||||
table: "DietImageAnalysisTasks",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DietRecords_UserId_RecordedAt",
|
||||
table: "DietRecords",
|
||||
columns: new[] { "UserId", "RecordedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DoctorProfiles_DoctorId",
|
||||
table: "DoctorProfiles",
|
||||
column: "DoctorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DoctorProfiles_UserId",
|
||||
table: "DoctorProfiles",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExercisePlanItems_PlanId_ScheduledDate",
|
||||
table: "ExercisePlanItems",
|
||||
columns: new[] { "PlanId", "ScheduledDate" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExercisePlans_UserId_StartDate_EndDate",
|
||||
table: "ExercisePlans",
|
||||
columns: new[] { "UserId", "StartDate", "EndDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FollowUps_UserId",
|
||||
table: "FollowUps",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthArchives_UserId",
|
||||
table: "HealthArchives",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthArchiveSurgeries_HealthArchiveId_SortOrder",
|
||||
table: "HealthArchiveSurgeries",
|
||||
columns: new[] { "HealthArchiveId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthRecords_UserId_MetricType",
|
||||
table: "HealthRecords",
|
||||
columns: new[] { "UserId", "MetricType" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HealthRecords_UserId_RecordedAt",
|
||||
table: "HealthRecords",
|
||||
columns: new[] { "UserId", "RecordedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MedicationLogs_MedicationId_CreatedAt",
|
||||
table: "MedicationLogs",
|
||||
columns: new[] { "MedicationId", "CreatedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MedicationReminderTasks_MedicationId_ScheduledDate_Schedule~",
|
||||
table: "MedicationReminderTasks",
|
||||
columns: new[] { "MedicationId", "ScheduledDate", "ScheduledTime" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MedicationReminderTasks_Status_AvailableAt",
|
||||
table: "MedicationReminderTasks",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Medications_UserId_IsActive",
|
||||
table: "Medications",
|
||||
columns: new[] { "UserId", "IsActive" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationOutbox_SourceTaskId",
|
||||
table: "NotificationOutbox",
|
||||
column: "SourceTaskId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationOutbox_Status_AvailableAt",
|
||||
table: "NotificationOutbox",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationPreferences_UserId",
|
||||
table: "NotificationPreferences",
|
||||
column: "UserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReportAnalysisTasks_ReportId",
|
||||
table: "ReportAnalysisTasks",
|
||||
column: "ReportId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReportAnalysisTasks_Status_AvailableAt",
|
||||
table: "ReportAnalysisTasks",
|
||||
columns: new[] { "Status", "AvailableAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reports_UserId",
|
||||
table: "Reports",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserNotifications_SourceId",
|
||||
table: "UserNotifications",
|
||||
column: "SourceId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserNotifications_UserId_IsDeleted_IsRead_CreatedAt",
|
||||
table: "UserNotifications",
|
||||
columns: new[] { "UserId", "IsDeleted", "IsRead", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_DoctorId",
|
||||
table: "Users",
|
||||
column: "DoctorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_Phone",
|
||||
table: "Users",
|
||||
column: "Phone",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VerificationCodes_Phone_CreatedAt",
|
||||
table: "VerificationCodes",
|
||||
columns: new[] { "Phone", "CreatedAt" },
|
||||
descending: new[] { false, true });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AiWriteCommands");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConsultationMessages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConversationMessages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DeviceTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DietFoodItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DietImageAnalysisTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DoctorProfiles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExercisePlanItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FollowUps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthArchiveSurgeries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MedicationLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MedicationReminderTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationOutbox");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RefreshTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReportAnalysisTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Reports");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserNotifications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VerificationCodes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Consultations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Conversations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DietRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExercisePlans");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HealthArchives");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Medications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Doctors");
|
||||
}
|
||||
}
|
||||
}
|
||||
1461
backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs
generated
Normal file
1461
backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHealthRecordReminder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminder",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderBloodPressure",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderGlucose",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderHeartRate",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderSpO2",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "HealthRecordReminderWeight",
|
||||
table: "NotificationPreferences",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminder",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderBloodPressure",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderGlucose",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderHeartRate",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderSpO2",
|
||||
table: "NotificationPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthRecordReminderWeight",
|
||||
table: "NotificationPreferences");
|
||||
}
|
||||
}
|
||||
}
|
||||
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
1466
backend/src/Health.Infrastructure/Data/Migrations/20260710052811_AddAppleSignIn.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppleSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AppleUserId",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users",
|
||||
column: "AppleUserId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AppleUserId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Phone",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
namespace Health.Infrastructure.Data.Records;
|
||||
|
||||
public sealed class AiWriteCommandRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public string ToolName { get; set; } = string.Empty;
|
||||
public string Arguments { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "Pending";
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Health.Infrastructure.Data.Records;
|
||||
|
||||
public sealed class DietImageAnalysisTaskRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string FilePaths { get; set; } = "[]";
|
||||
public string Status { get; set; } = "Pending";
|
||||
public int Attempts { get; set; }
|
||||
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
|
||||
public int? ResultCode { get; set; }
|
||||
public string? ResultData { get; set; }
|
||||
public string? ResultMessage { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Health.Infrastructure.Data.Records;
|
||||
|
||||
public sealed class MedicationReminderTaskRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid MedicationId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Dosage { get; set; }
|
||||
public DateOnly ScheduledDate { get; set; }
|
||||
public TimeOnly ScheduledTime { get; set; }
|
||||
public string Status { get; set; } = "Pending";
|
||||
public int Attempts { get; set; }
|
||||
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
|
||||
public string? LastError { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Health.Infrastructure.Data.Records;
|
||||
|
||||
public sealed class NotificationOutboxRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid SourceTaskId { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Payload { get; set; } = "{}";
|
||||
public string Status { get; set; } = "AwaitingProvider";
|
||||
public int Attempts { get; set; }
|
||||
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
|
||||
public string? LastError { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Health.Infrastructure.Data.Records;
|
||||
|
||||
public sealed class ReportAnalysisTaskRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ReportId { get; set; }
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "Pending";
|
||||
public int Attempts { get; set; }
|
||||
public DateTime AvailableAt { get; set; } = DateTime.UtcNow;
|
||||
public string? LastError { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
|
||||
namespace Health.Infrastructure.Data;
|
||||
|
||||
@@ -27,12 +28,19 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<DoctorProfile> DoctorProfiles => Set<DoctorProfile>();
|
||||
public DbSet<FollowUp> FollowUps => Set<FollowUp>();
|
||||
public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>();
|
||||
public DbSet<HealthArchiveSurgery> HealthArchiveSurgeries => Set<HealthArchiveSurgery>();
|
||||
|
||||
// 支撑表
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
|
||||
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
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)
|
||||
{
|
||||
@@ -42,6 +50,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
builder.Entity<User>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.Phone).IsUnique();
|
||||
e.HasIndex(u => u.AppleUserId).IsUnique(); // Apple Sign-In 唯一标识
|
||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
@@ -95,7 +104,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
// ---- ExercisePlan ----
|
||||
builder.Entity<ExercisePlan>(e =>
|
||||
{
|
||||
e.HasIndex(p => new { p.UserId, p.WeekStartDate }).IsDescending(false, true);
|
||||
e.HasIndex(p => new { p.UserId, p.StartDate, p.EndDate });
|
||||
});
|
||||
|
||||
builder.Entity<ExercisePlanItem>(e =>
|
||||
{
|
||||
e.HasIndex(i => new { i.PlanId, i.ScheduledDate }).IsUnique();
|
||||
});
|
||||
|
||||
// ---- Report ----
|
||||
@@ -143,10 +157,74 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.HasIndex(n => n.UserId).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<UserNotification>(e =>
|
||||
{
|
||||
e.ToTable("UserNotifications");
|
||||
e.HasIndex(n => n.SourceId).IsUnique();
|
||||
e.HasIndex(n => new { n.UserId, n.IsDeleted, n.IsRead, n.CreatedAt });
|
||||
e.Property(n => n.Type).HasMaxLength(64);
|
||||
e.Property(n => n.Severity).HasMaxLength(16);
|
||||
e.Property(n => n.ActionType).HasMaxLength(64);
|
||||
});
|
||||
|
||||
// ---- HealthArchive ----
|
||||
builder.Entity<HealthArchive>(e =>
|
||||
{
|
||||
e.HasIndex(a => a.UserId).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<HealthArchiveSurgery>(e =>
|
||||
{
|
||||
e.ToTable("HealthArchiveSurgeries");
|
||||
e.HasIndex(s => new { s.HealthArchiveId, s.SortOrder });
|
||||
e.Property(s => s.Type).HasMaxLength(256);
|
||||
e.HasOne(s => s.HealthArchive)
|
||||
.WithMany(a => a.Surgeries)
|
||||
.HasForeignKey(s => s.HealthArchiveId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace Health.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库种子数据
|
||||
/// </summary>
|
||||
public static class DataSeeder
|
||||
{
|
||||
public static async Task SeedAsync(AppDbContext db)
|
||||
{
|
||||
// 医生不再使用种子数据,通过注册创建
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Health.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 开发环境测试数据填充。生产环境不要调用!
|
||||
/// 开关:DEVDATA_ENABLED=true 才会执行
|
||||
/// </summary>
|
||||
public static class DevDataSeeder
|
||||
{
|
||||
public static async Task SeedIfEnabled(AppDbContext db, IConfiguration config)
|
||||
{
|
||||
// 通过环境变量控制:DEVDATA_ENABLED=true 才填充测试数据
|
||||
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
|
||||
if (enabled != "true") return;
|
||||
|
||||
// 已有任何真实用户时跳过(避免污染真实数据)
|
||||
if (db.Users.Any(u => u.Phone != "13800000001")) return;
|
||||
|
||||
// 检查是否已有测试用户(避免重复填充)
|
||||
if (db.Users.Any(u => u.Phone == "13800000001")) return;
|
||||
|
||||
// ---- 种子医生数据 ----
|
||||
if (!db.Doctors.Any())
|
||||
{
|
||||
var doctor1 = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = "张明", Title = "主任医师",
|
||||
Department = "心脏康复科", Phone = "13800000002",
|
||||
ProfessionalDirection = "冠心病康复、术后管理",
|
||||
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var doctor2 = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = "李芳", Title = "副主任医师",
|
||||
Department = "营养科", Phone = "13800000003",
|
||||
ProfessionalDirection = "糖尿病管理、饮食指导",
|
||||
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var doctor3 = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = "王建国", Title = "主任医师",
|
||||
Department = "心血管内科", Phone = "13800000004",
|
||||
ProfessionalDirection = "高血压管理、PCI术后随访",
|
||||
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Doctors.AddRange(doctor1, doctor2, doctor3);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- 创建测试患者 ----
|
||||
var doctorWang = db.Doctors.FirstOrDefault(d => d.Name == "王建国");
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(), Phone = "13800000001", Name = "张三",
|
||||
Gender = "男", BirthDate = new DateOnly(1970, 3, 15),
|
||||
DoctorId = doctorWang?.Id,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// ---- 健康档案 ----
|
||||
db.HealthArchives.Add(new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id,
|
||||
Diagnosis = "冠心病", SurgeryType = "PCI支架植入术",
|
||||
SurgeryDate = new DateOnly(2026, 3, 15),
|
||||
Allergies = ["青霉素"], DietRestrictions = ["低盐", "低脂"],
|
||||
ChronicDiseases = ["高血压", "高血脂"],
|
||||
FamilyHistory = "父亲冠心病",
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
// ---- 健康数据(过去 7 天)----
|
||||
var random = new Random(42);
|
||||
for (int i = 7; i >= 0; i--)
|
||||
{
|
||||
var date = DateTime.UtcNow.AddDays(-i);
|
||||
// 血压
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
|
||||
Systolic = 120 + random.Next(-5, 15), Diastolic = 75 + random.Next(-5, 10),
|
||||
Unit = "mmHg", Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
// 心率
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.HeartRate,
|
||||
Value = 68 + random.Next(-5, 10), Unit = "次/分",
|
||||
Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
// 血糖
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.Glucose,
|
||||
Value = 5.0m + (decimal)(random.NextDouble() * 1.5),
|
||||
Unit = "mmol/L", Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
// 血氧
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.SpO2,
|
||||
Value = 96 + random.Next(0, 3), Unit = "%",
|
||||
Source = HealthRecordSource.AiEntry,
|
||||
IsAbnormal = false, RecordedAt = date,
|
||||
});
|
||||
}
|
||||
// 一条异常血压
|
||||
db.HealthRecords.Add(new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
|
||||
Systolic = 148, Diastolic = 92, Unit = "mmHg",
|
||||
Source = HealthRecordSource.AiEntry, IsAbnormal = true,
|
||||
RecordedAt = DateTime.UtcNow.AddDays(-1),
|
||||
});
|
||||
|
||||
// ---- 用药计划 ----
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿司匹林", Dosage = "100mg",
|
||||
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)],
|
||||
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
|
||||
});
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿托伐他汀", Dosage = "20mg",
|
||||
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(20, 0)],
|
||||
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 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 };
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, ExerciseType = "散步", DurationMinutes = 30 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 3, IsRestDay = true });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 4, ExerciseType = "太极", DurationMinutes = 40 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 5, IsRestDay = true });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 6, ExerciseType = "散步", DurationMinutes = 30 });
|
||||
db.ExercisePlans.Add(plan);
|
||||
|
||||
// ---- 饮食记录 ----
|
||||
var lunch = new DietRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
|
||||
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
};
|
||||
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 });
|
||||
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 });
|
||||
db.DietRecords.Add(lunch);
|
||||
|
||||
// ---- 复查计划 ----
|
||||
db.FollowUps.Add(new FollowUp
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Title = "心内科复查",
|
||||
DoctorName = "王建国", Department = "心血管内科",
|
||||
ScheduledAt = DateTime.UtcNow.AddDays(3), Status = FollowUpStatus.Upcoming,
|
||||
});
|
||||
db.FollowUps.Add(new FollowUp
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Title = "术后3周复查",
|
||||
DoctorName = "王建国", Department = "心血管内科",
|
||||
ScheduledAt = DateTime.UtcNow.AddDays(-14), Status = FollowUpStatus.Completed,
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
Console.WriteLine($"[DEV] 测试数据已填充:用户 {user.Phone}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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 (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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 async Task RecoverStaleAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var staleCutoff = now.AddMinutes(-5);
|
||||
|
||||
// 超时但仍有重试余量 → 重新排队
|
||||
await _db.DietImageAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Pending")
|
||||
.SetProperty(x => x.AvailableAt, now)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
|
||||
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing(也让 WaitForResultAsync 能尽快返回失败)
|
||||
await _db.DietImageAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.ResultCode, 50001)
|
||||
.SetProperty(x => x.ResultMessage, "食物识别超时且已达最大重试次数")
|
||||
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
|
||||
.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";
|
||||
}
|
||||
}
|
||||
71
backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs
Normal file
71
backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
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":数字}]
|
||||
|
||||
## 份量估算依据(容器与参照物对照表)
|
||||
- 标准饭碗(口径11cm):满碗米饭≈200g、满碗面条≈250g、满碗粥≈250g、半碗≈100g
|
||||
- 标准汤碗(口径14cm):满碗汤≈400ml、半碗≈200ml
|
||||
- 标准盘子(口径23cm):满盘菜≈300g、半盘≈150g
|
||||
- 普通玻璃杯:满杯水/饮料≈250ml、纸杯≈200ml
|
||||
- 鸡蛋1个≈50g、馒头1个≈100g、包子1个≈80g、饺子1个≈15g
|
||||
- 香蕉1根≈100g、苹果1个≈200g、橘子1个≈120g
|
||||
- 肉块巴掌大小≈80-120g、鸡腿1个≈100g、鱼1条(中等)≈300g
|
||||
|
||||
## 输出示例(务必模仿此风格)
|
||||
正确:{"name":"米饭","portion":"约200g","calories":230}
|
||||
正确:{"name":"红烧肉","portion":"约150g","calories":420}
|
||||
正确:{"name":"豆浆","portion":"约250ml","calories":80}
|
||||
错误:{"name":"米饭","portion":"一碗","calories":230} ← portion 缺克数
|
||||
错误:{"name":"汤","portion":"一份","calories":80} ← portion 缺毫升数
|
||||
错误:{"name":"水果","portion":"少许","calories":50} ← portion 是模糊词
|
||||
|
||||
## 硬性要求
|
||||
1. 图片若无食物,输出 [] 即可。不要硬凑食物。
|
||||
2. portion 字段必须包含阿拉伯数字 + 单位(g 或 ml)。不允许任何只有量词没有克数的回答。
|
||||
3. 即使图片不够清楚,也要根据可见容器/参照物给出估算,可加"约"或区间("约80-120g")。
|
||||
4. 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,39 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.Exercises;
|
||||
using Health.Application.Notifications;
|
||||
|
||||
namespace Health.Infrastructure.Exercises;
|
||||
|
||||
public sealed class ExerciseReminderProducer(AppDbContext db, IUserNotificationProducer notifications) : IExerciseReminderProducer
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly IUserNotificationProducer _notifications = notifications;
|
||||
|
||||
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 created = 0;
|
||||
foreach (var item in dueItems)
|
||||
{
|
||||
if (await _notifications.EnqueueAsync(
|
||||
item.Plan.UserId,
|
||||
item.Id,
|
||||
new NotificationMessage(
|
||||
"ExerciseReminder",
|
||||
"今日运动计划",
|
||||
$"今天安排了 {item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
|
||||
"info",
|
||||
"exercise",
|
||||
item.PlanId.ToString()),
|
||||
ct))
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Health.Application\Health.Application.csproj" />
|
||||
<ProjectReference Include="..\Health.Domain\Health.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
||||
<PackageReference Include="Minio" Version="7.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.16-alpha-20260629-34229" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
.Include(a => a.Surgeries)
|
||||
.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,73 @@
|
||||
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 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,121 @@
|
||||
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 async Task RecoverStaleAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var staleCutoff = now.AddMinutes(-5);
|
||||
|
||||
// 超时但仍有重试余量 → 重新排队
|
||||
await _db.MedicationReminderTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Pending")
|
||||
.SetProperty(x => x.AvailableAt, now)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
|
||||
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
|
||||
await _db.MedicationReminderTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
|
||||
.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,39 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.Medications;
|
||||
using Health.Application.Notifications;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Health.Infrastructure.Medications;
|
||||
|
||||
public sealed class OutboxMedicationReminderDispatcher(
|
||||
IUserNotificationProducer notifications,
|
||||
ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher
|
||||
{
|
||||
private readonly IUserNotificationProducer _notifications = notifications;
|
||||
private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger;
|
||||
|
||||
public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct)
|
||||
{
|
||||
var details = string.Join(" · ", new[]
|
||||
{
|
||||
task.Dosage,
|
||||
task.ScheduledTime.ToString("HH:mm")
|
||||
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
await _notifications.EnqueueAsync(
|
||||
task.UserId,
|
||||
task.TaskId,
|
||||
new NotificationMessage(
|
||||
"MedicationReminder",
|
||||
"用药时间到了",
|
||||
$"请按计划服用{task.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。",
|
||||
"info",
|
||||
"medication",
|
||||
task.MedicationId.ToString()),
|
||||
ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"用药提醒已写入通知 Outbox,等待推送服务: {TaskId} {UserId}",
|
||||
task.TaskId,
|
||||
task.UserId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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.UserNotifications.AsNoTracking()
|
||||
.Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.Take(10)
|
||||
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message,
|
||||
x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<InAppNotificationRecord>> GetHistoryAsync(Guid userId, CancellationToken ct) =>
|
||||
await _db.UserNotifications.AsNoTracking()
|
||||
.Where(x => x.UserId == userId && !x.IsDeleted)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(100)
|
||||
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message,
|
||||
x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct)
|
||||
{
|
||||
var updated = await _db.UserNotifications
|
||||
.Where(x => x.Id == notificationId && x.UserId == userId && !x.IsRead && !x.IsDeleted)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.IsRead, true)
|
||||
.SetProperty(x => x.ReadAt, DateTime.UtcNow), ct);
|
||||
return updated > 0;
|
||||
}
|
||||
|
||||
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
|
||||
_db.UserNotifications
|
||||
.Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.IsRead, true)
|
||||
.SetProperty(x => x.ReadAt, DateTime.UtcNow), ct);
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct)
|
||||
{
|
||||
var deleted = await _db.UserNotifications
|
||||
.Where(x => x.Id == notificationId && x.UserId == userId && !x.IsDeleted)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.IsDeleted, true), ct);
|
||||
return deleted > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
using Npgsql;
|
||||
|
||||
namespace Health.Infrastructure.Notifications;
|
||||
|
||||
public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificationProducer
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public async Task<bool> EnqueueAsync(
|
||||
Guid userId,
|
||||
Guid sourceId,
|
||||
NotificationMessage message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
||||
return false;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
SourceTaskId = sourceId,
|
||||
Type = message.Type,
|
||||
Payload = JsonSerializer.Serialize(message),
|
||||
Status = "Pending",
|
||||
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 sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificationOutboxProcessor
|
||||
{
|
||||
private const int MaxAttempts = 5;
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public async Task RecoverStaleAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
await _db.NotificationOutbox
|
||||
.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);
|
||||
|
||||
await _db.NotificationOutbox
|
||||
.Where(x => x.Status == "Processing" &&
|
||||
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
public async Task<bool> ProcessNextAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var candidate = await _db.NotificationOutbox.AsNoTracking()
|
||||
.Where(x => x.Status == "Pending" &&
|
||||
x.AvailableAt <= now && x.Attempts < MaxAttempts)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (candidate == null) return false;
|
||||
|
||||
var claimed = await _db.NotificationOutbox
|
||||
.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 true;
|
||||
|
||||
try
|
||||
{
|
||||
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
|
||||
?? throw new InvalidOperationException("通知消息内容为空");
|
||||
|
||||
if (!await _db.UserNotifications.AsNoTracking()
|
||||
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
|
||||
{
|
||||
await _db.UserNotifications.AddAsync(new UserNotification
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = candidate.UserId,
|
||||
SourceId = candidate.SourceTaskId,
|
||||
Type = message.Type,
|
||||
Title = message.Title,
|
||||
Message = message.Message,
|
||||
Severity = message.Severity,
|
||||
ActionType = message.ActionType,
|
||||
ActionTargetId = message.ActionTargetId,
|
||||
CreatedAt = candidate.CreatedAt,
|
||||
}, ct);
|
||||
// Persist the inbox item first. If completion fails, stale-task
|
||||
// recovery can safely retry because SourceId is idempotent.
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Completed")
|
||||
.SetProperty(x => x.LastError, (string?)null)
|
||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var attempts = candidate.Attempts + 1;
|
||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
|
||||
.SetProperty(x => x.AvailableAt,
|
||||
DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5))
|
||||
.SetProperty(x => x.LastError,
|
||||
ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message)
|
||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
|
||||
namespace Health.Infrastructure.Reports;
|
||||
|
||||
public sealed class EfReportAnalysisQueue(
|
||||
AppDbContext db,
|
||||
IUserNotificationProducer? notifications = null) : IReportAnalysisQueue
|
||||
{
|
||||
private const int MaxAttempts = 3;
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly IUserNotificationProducer? _notifications = notifications;
|
||||
|
||||
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 async Task RecoverStaleAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var staleCutoff = now.AddMinutes(-5);
|
||||
|
||||
// 超时但仍有重试余量 → 重新排队
|
||||
await _db.ReportAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts < MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Pending")
|
||||
.SetProperty(x => x.AvailableAt, now)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
|
||||
// 超时且已达重试上限(多为最后一次执行中进程崩溃)→ 标记失败,避免永久卡在 Processing
|
||||
await _db.ReportAnalysisTasks
|
||||
.Where(x => x.Status == "Processing" && x.UpdatedAt < staleCutoff && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.LastError, "任务执行超时且已达最大重试次数")
|
||||
.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);
|
||||
|
||||
if (task.Status == "Failed" && _notifications != null)
|
||||
{
|
||||
var report = await _db.Reports.AsNoTracking()
|
||||
.Where(x => x.Id == task.ReportId)
|
||||
.Select(x => new { x.Id, x.UserId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (report != null)
|
||||
{
|
||||
await _notifications.EnqueueAsync(
|
||||
report.UserId,
|
||||
task.Id,
|
||||
new NotificationMessage(
|
||||
"ReportAnalysisFailed",
|
||||
"报告分析未完成",
|
||||
"这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
|
||||
"warning",
|
||||
"report",
|
||||
report.Id.ToString()),
|
||||
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,258 @@
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Infrastructure.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace Health.Infrastructure.Reports;
|
||||
|
||||
public sealed class ReportAnalysisService(
|
||||
IReportRepository reports,
|
||||
VisionClient vision,
|
||||
DeepSeekClient llm,
|
||||
IUserNotificationProducer notifications,
|
||||
ILogger<ReportAnalysisService> logger) : IReportAnalysisService
|
||||
{
|
||||
private readonly IReportRepository _reports = reports;
|
||||
private readonly VisionClient _vision = vision;
|
||||
private readonly DeepSeekClient _llm = llm;
|
||||
private readonly IUserNotificationProducer _notifications = notifications;
|
||||
private readonly ILogger<ReportAnalysisService> _logger = logger;
|
||||
private const int MaxPdfChars = 8000;
|
||||
|
||||
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);
|
||||
await NotifyAsync(report, job.TaskId, false, 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);
|
||||
await NotifyAsync(report, job.TaskId, true, ct);
|
||||
_logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// 进程停机导致的取消:不算失败,不消耗重试,交还给恢复机制
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId);
|
||||
report.Status = ReportStatus.AnalysisFailed;
|
||||
report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。";
|
||||
report.AiIndicators = "[]";
|
||||
await _reports.SaveChangesAsync(ct);
|
||||
|
||||
// 重新抛出,使 Worker 进入 RetryAsync(指数退避重试,超限后任务置 Failed);
|
||||
// 若为可恢复的瞬时故障,重试成功会把状态改回 PendingDoctor。
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
if (Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase))
|
||||
return await ExtractIndicatorsFromPdfAsync(filePath, 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 ?? "";
|
||||
// 不记录 VLM 返回正文:可能包含检验指标等敏感医疗信息,生产日志只记录长度
|
||||
_logger.LogInformation("报告 VLM 返回长度: {Length}", content.Length);
|
||||
|
||||
var json = TryParseJson(content);
|
||||
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ExtractIndicatorsFromPdfAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
var text = ExtractPdfText(filePath);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return JsonDocument.Parse("""
|
||||
{"isReport": false, "reason": "这份 PDF 可能是扫描件或图片版,当前无法直接提取文字。请上传清晰图片,或后续启用 PDF 页转图片后再用 VLM 识别。"}
|
||||
""").RootElement;
|
||||
}
|
||||
|
||||
var prompt = $$"""
|
||||
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
|
||||
|
||||
PDF 文本:
|
||||
{{text}}
|
||||
|
||||
第一步:判断这到底是不是一份医学检验报告。
|
||||
如果不是,直接返回:
|
||||
{"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 messages = new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "system", Content = "你是医学报告结构化抽取助手,只输出严格 JSON。" },
|
||||
new() { Role = "user", Content = prompt }
|
||||
};
|
||||
|
||||
var response = await _llm.ChatAsync(messages, maxTokens: 1800, temperature: 0.1f, ct: ct);
|
||||
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
|
||||
var json = TryParseJson(content);
|
||||
return json ?? throw new InvalidOperationException("PDF 报告解析结果格式异常");
|
||||
}
|
||||
|
||||
private static string ExtractPdfText(string filePath)
|
||||
{
|
||||
using var pdf = PdfDocument.Open(filePath);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (var page in pdf.GetPages())
|
||||
{
|
||||
sb.AppendLine(page.Text);
|
||||
if (sb.Length > MaxPdfChars) break;
|
||||
}
|
||||
var text = sb.ToString().Trim();
|
||||
return text.Length > MaxPdfChars ? text[..MaxPdfChars] : text;
|
||||
}
|
||||
|
||||
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 (JsonException) { return null; }
|
||||
}
|
||||
|
||||
private Task NotifyAsync(Report report, Guid sourceId, bool succeeded, CancellationToken ct) =>
|
||||
_notifications.EnqueueAsync(
|
||||
report.UserId,
|
||||
sourceId,
|
||||
new NotificationMessage(
|
||||
succeeded ? "ReportAnalysisCompleted" : "ReportAnalysisFailed",
|
||||
succeeded ? "报告分析完成" : "报告分析未完成",
|
||||
succeeded ? "您的报告已完成 AI 预解读,点击查看分析结果。" : "这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
|
||||
succeeded ? "success" : "warning",
|
||||
"report",
|
||||
report.Id.ToString()),
|
||||
ct);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Health.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Apple IdentityToken 验证:下载 Apple 公钥、验签、检查声明
|
||||
/// </summary>
|
||||
public sealed class AppleTokenValidator
|
||||
{
|
||||
private const string AppleKeysUrl = "https://appleid.apple.com/auth/keys";
|
||||
private const string AppleIssuer = "https://appleid.apple.com";
|
||||
private static readonly HttpClient _http = new();
|
||||
private static List<JsonWebKey>? _cachedKeys;
|
||||
private static DateTime _keysExpiry = DateTime.MinValue;
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
private readonly string _clientId;
|
||||
|
||||
public AppleTokenValidator(IConfiguration config)
|
||||
{
|
||||
_clientId = config["APPLE_CLIENT_ID"] ?? "com.datalumina.YYA";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Apple identityToken,返回 sub(用户唯一标识)
|
||||
/// </summary>
|
||||
public async Task<string> ValidateAsync(string identityToken, CancellationToken ct)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
// 先解码获取 kid
|
||||
var rawToken = handler.ReadJwtToken(identityToken);
|
||||
var kid = rawToken.Header.Kid;
|
||||
|
||||
var keys = await GetKeysAsync(ct);
|
||||
var key = keys.FirstOrDefault(k => k.KeyId == kid)
|
||||
?? throw new SecurityTokenException($"No matching key for kid: {kid}");
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = AppleIssuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = [_clientId, "com.datalumina.YYA.signin"],
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
|
||||
handler.ValidateToken(identityToken, validationParams, out _);
|
||||
// 从原始 JWT 取 sub(避免 .NET 的 ClaimType 映射把 "sub" 转成 NameIdentifier)
|
||||
var sub = rawToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(sub))
|
||||
throw new SecurityTokenException("Missing 'sub' claim in Apple identityToken");
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static async Task<List<JsonWebKey>> GetKeysAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_cachedKeys != null && DateTime.UtcNow < _keysExpiry) return _cachedKeys;
|
||||
|
||||
var response = await _http.GetStringAsync(AppleKeysUrl, ct);
|
||||
var jwks = new JsonWebKeySet(response);
|
||||
_cachedKeys = jwks.Keys.ToList();
|
||||
_keysExpiry = DateTime.UtcNow.AddHours(6); // 缓存 6 小时
|
||||
return _cachedKeys;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
}
|
||||
53
backend/src/Health.Infrastructure/Users/EfUserRepository.cs
Normal file
53
backend/src/Health.Infrastructure/Users/EfUserRepository.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
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.UserNotifications.Where(n => n.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.NotificationOutbox.Where(n => n.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.AiWriteCommands.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.DoctorProfiles.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
using Health.Application.Maintenance;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 数据清理后台服务(每小时检查一次)
|
||||
/// </summary>
|
||||
public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<CleanupService> logger) : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
@@ -15,44 +14,21 @@ public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<Cl
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
// 清理 30 天前的对话记录(先删消息再删对话,避免 FK 冲突)
|
||||
var cutoff = DateTime.UtcNow.AddDays(-30);
|
||||
var oldConvIds = await db.Conversations
|
||||
.Where(c => c.UpdatedAt < cutoff)
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (oldConvIds.Count > 0)
|
||||
var maintenance = scope.ServiceProvider.GetRequiredService<IMaintenanceService>();
|
||||
var result = await maintenance.CleanupAsync(DateTime.UtcNow, stoppingToken);
|
||||
if (result.Conversations + result.VerificationCodes + result.BackgroundTasks > 0)
|
||||
{
|
||||
// 先批量删消息
|
||||
var oldMessages = await db.ConversationMessages
|
||||
.Where(m => oldConvIds.Contains(m.ConversationId))
|
||||
.ToListAsync(stoppingToken);
|
||||
db.ConversationMessages.RemoveRange(oldMessages);
|
||||
|
||||
// 再删对话
|
||||
var oldConversations = await db.Conversations
|
||||
.Where(c => oldConvIds.Contains(c.Id))
|
||||
.ToListAsync(stoppingToken);
|
||||
db.Conversations.RemoveRange(oldConversations);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
_logger.LogInformation("清理 {Count} 条过期对话", oldConvIds.Count);
|
||||
}
|
||||
|
||||
// 清理过期验证码
|
||||
var expiredCodes = await db.VerificationCodes
|
||||
.Where(v => v.ExpiresAt < DateTime.UtcNow)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (expiredCodes.Count > 0)
|
||||
{
|
||||
db.VerificationCodes.RemoveRange(expiredCodes);
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
_logger.LogInformation(
|
||||
"自动清理完成: 会话 {Conversations}, 验证码 {Codes}, 后台任务 {Tasks}",
|
||||
result.Conversations,
|
||||
result.VerificationCodes,
|
||||
result.BackgroundTasks);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "数据清理异常");
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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 (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Health.Application.Notifications;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
||||
/// 对未录入指标且开关开启的用户推送提醒。
|
||||
/// 中国时区 UTC+8。
|
||||
/// </summary>
|
||||
public sealed class HealthRecordReminderService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
||||
|
||||
// 北京时间 9:00 和 12:00 触发
|
||||
private static readonly int[] ReminderHoursCst = [9, 12];
|
||||
|
||||
private DateTime _lastFired = DateTime.MinValue;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("健康录入提醒服务已启动");
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||
// 当前小时是否在触发列表,且今天该小时还没触发过
|
||||
if (ReminderHoursCst.Contains(nowCst.Hour)
|
||||
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||
{
|
||||
await ScanAndPushAsync(nowCst, stoppingToken);
|
||||
_lastFired = nowCst;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
||||
|
||||
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
|
||||
// 取所有启用了健康录入提醒的用户偏好
|
||||
var prefs = await db.NotificationPreferences
|
||||
.Where(p => p.HealthRecordReminder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var pref in prefs)
|
||||
{
|
||||
// 该用户当天已录入的指标
|
||||
var recordedTypes = await db.HealthRecords
|
||||
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
|
||||
.Select(r => r.MetricType)
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 哪些指标启用开关但当天没录入
|
||||
var missing = new List<(HealthMetricType type, bool enabled, string label)>
|
||||
{
|
||||
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
|
||||
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
|
||||
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
|
||||
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
|
||||
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
|
||||
};
|
||||
|
||||
var missingLabels = missing
|
||||
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
|
||||
.Select(m => m.label)
|
||||
.ToList();
|
||||
|
||||
if (missingLabels.Count == 0) continue;
|
||||
|
||||
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
|
||||
var sourceId = MakeSourceId(pref.UserId, nowCst);
|
||||
var summary = string.Join("、", missingLabels);
|
||||
|
||||
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
|
||||
Type: "health_record_reminder",
|
||||
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
|
||||
Message: $"建议录入:{summary}",
|
||||
Severity: "info",
|
||||
ActionType: "health",
|
||||
ActionTargetId: null), ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
||||
{
|
||||
// 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复
|
||||
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
|
||||
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,31 @@
|
||||
using Health.Application.Medications;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 用药提醒定时扫描服务(每分钟检查一次)
|
||||
/// </summary>
|
||||
public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory, ILogger<MedicationReminderService> logger) : BackgroundService
|
||||
public sealed class MedicationReminderService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<MedicationReminderService> logger) : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly ILogger<MedicationReminderService> _logger = logger;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("用药提醒服务已启动");
|
||||
|
||||
_logger.LogInformation("用药提醒扫描生产者已启动");
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessReminders(stoppingToken);
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var scanner = scope.ServiceProvider.GetRequiredService<IMedicationReminderScanner>();
|
||||
var queue = scope.ServiceProvider.GetRequiredService<IMedicationReminderQueue>();
|
||||
var tasks = await scanner.ScanAsync(DateTime.UtcNow, stoppingToken);
|
||||
foreach (var task in tasks)
|
||||
await queue.EnqueueAsync(task, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -26,37 +35,4 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessReminders(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
// 北京时间今天0点 → 转为UTC
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
|
||||
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内
|
||||
var beijingTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var windowStart = beijingTime.AddMinutes(-5);
|
||||
var medications = await db.Medications
|
||||
.Where(m => m.IsActive)
|
||||
.Where(m => m.TimeOfDay.Any(t =>
|
||||
beijingTime > windowStart ? (t >= windowStart && t <= beijingTime) : (t >= windowStart || t <= beijingTime)))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var med in medications)
|
||||
{
|
||||
// 检查今天是否已打卡(用正确的北京时间区间)
|
||||
var alreadyLogged = await db.MedicationLogs
|
||||
.AnyAsync(l => l.MedicationId == med.Id
|
||||
&& l.CreatedAt >= todayStartUtc
|
||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
||||
|
||||
if (alreadyLogged) continue;
|
||||
|
||||
// TODO: 调用极光推送发送用药提醒
|
||||
_logger.LogInformation("用药提醒: 用户 {UserId} 药品 {Name} {Dosage} 时间 {Time}",
|
||||
med.UserId, med.Name, med.Dosage, beijingTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Health.Application.Medications;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
public sealed class MedicationReminderWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<MedicationReminderWorker> logger) : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly ILogger<MedicationReminderWorker> _logger = logger;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("用药提醒持久化任务消费者已启动");
|
||||
var idleDelay = TimeSpan.FromSeconds(1);
|
||||
var nextRecovery = DateTime.MinValue;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var queue = scope.ServiceProvider.GetRequiredService<IMedicationReminderQueue>();
|
||||
if (DateTime.UtcNow >= nextRecovery)
|
||||
{
|
||||
await queue.RecoverStaleAsync(stoppingToken);
|
||||
nextRecovery = DateTime.UtcNow.AddMinutes(1);
|
||||
}
|
||||
|
||||
var task = await queue.TryTakeAsync(stoppingToken);
|
||||
if (task == null)
|
||||
{
|
||||
await Task.Delay(idleDelay, stoppingToken);
|
||||
idleDelay = TimeSpan.FromSeconds(Math.Min(idleDelay.TotalSeconds * 2, 5));
|
||||
continue;
|
||||
}
|
||||
|
||||
idleDelay = TimeSpan.FromSeconds(1);
|
||||
try
|
||||
{
|
||||
var dispatcher = scope.ServiceProvider.GetRequiredService<IMedicationReminderDispatcher>();
|
||||
await dispatcher.DispatchAsync(task, stoppingToken);
|
||||
await queue.CompleteAsync(task.TaskId, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "用药提醒消费失败: {MedicationId}", task.MedicationId);
|
||||
await queue.RetryAsync(task.TaskId, ex.Message, stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "用药提醒消费者循环异常");
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Health.Application.Notifications;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
public sealed class NotificationOutboxWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<NotificationOutboxWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var nextRecovery = DateTime.MinValue;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var processor = scope.ServiceProvider.GetRequiredService<INotificationOutboxProcessor>();
|
||||
if (DateTime.UtcNow >= nextRecovery)
|
||||
{
|
||||
await processor.RecoverStaleAsync(stoppingToken);
|
||||
nextRecovery = DateTime.UtcNow.AddMinutes(1);
|
||||
}
|
||||
if (!await processor.ProcessNextAsync(stoppingToken))
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "通知 Outbox 消费失败");
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user