feat: 应用内通知系统 + 结构化手术史/用药等相关改动
- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker - 通知中心页面及前端通知服务接入 - 健康指标异常、用药/运动提醒等事件统一产出站内通知 - 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整 - AppDbContext 注册通知相关实体
This commit is contained in:
@@ -24,7 +24,10 @@ public sealed class PatientContextService(
|
||||
if (archive != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||
if (archive.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)}");
|
||||
|
||||
@@ -2,12 +2,22 @@ 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,
|
||||
@@ -21,7 +31,8 @@ public sealed record HealthArchiveUpdateRequest(
|
||||
IReadOnlyList<string>? Allergies,
|
||||
IReadOnlyList<string>? DietRestrictions,
|
||||
IReadOnlyList<string>? ChronicDiseases,
|
||||
string? FamilyHistory);
|
||||
string? FamilyHistory,
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
|
||||
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
|
||||
@@ -42,7 +42,8 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
"update_diagnosis" => request with
|
||||
{
|
||||
SurgeryType = null, SurgeryDate = null, Allergies = null,
|
||||
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null
|
||||
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null,
|
||||
Surgeries = null
|
||||
},
|
||||
"update_surgery" => request with
|
||||
{
|
||||
@@ -77,8 +78,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
|
||||
{
|
||||
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
|
||||
if (request.SurgeryType != null) archive.SurgeryType = request.SurgeryType;
|
||||
if (request.SurgeryDate.HasValue) archive.SurgeryDate = request.SurgeryDate.Value;
|
||||
if (request.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();
|
||||
@@ -91,6 +125,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
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,
|
||||
@@ -103,9 +138,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ public interface IMedicationRepository
|
||||
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
||||
Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task AddMedicationAsync(Medication medication, CancellationToken ct);
|
||||
Task AddLogAsync(MedicationLog log, CancellationToken ct);
|
||||
void DeleteMedication(Medication medication);
|
||||
|
||||
@@ -21,10 +21,18 @@ public sealed class MedicationReminderScanner(IMedicationRepository medications)
|
||||
foreach (var medication in active)
|
||||
{
|
||||
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue;
|
||||
if (await _medications.HasTakenInWindowAsync(medication.UserId, medication.Id, todayStartUtc, todayEndUtc, ct)) continue;
|
||||
|
||||
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
|
||||
{
|
||||
if (await _medications.DoseLogExistsAsync(
|
||||
medication.UserId,
|
||||
medication.Id,
|
||||
scheduledTime,
|
||||
todayStartUtc,
|
||||
todayEndUtc,
|
||||
ct))
|
||||
continue;
|
||||
|
||||
tasks.Add(new MedicationReminderTask(
|
||||
Guid.NewGuid(),
|
||||
medication.UserId,
|
||||
|
||||
@@ -5,22 +5,56 @@ public sealed record InAppNotificationDto(
|
||||
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 Payload,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Application.Notifications;
|
||||
|
||||
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
|
||||
@@ -12,36 +10,22 @@ public sealed class InAppNotificationService(IInAppNotificationRepository notifi
|
||||
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);
|
||||
|
||||
private static InAppNotificationDto ToDto(InAppNotificationRecord record)
|
||||
{
|
||||
if (record.Type == "MedicationReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var name = root.TryGetProperty("Name", out var nameValue) ? nameValue.GetString() : null;
|
||||
var dosage = root.TryGetProperty("Dosage", out var dosageValue) ? dosageValue.GetString() : null;
|
||||
var time = root.TryGetProperty("ScheduledTime", out var timeValue) ? timeValue.GetString() : null;
|
||||
var details = string.Join(" · ", new[] { dosage, time }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
return new InAppNotificationDto(
|
||||
record.Id,
|
||||
record.Type,
|
||||
"用药提醒",
|
||||
$"该服用{name ?? "药物"}{(details.Length > 0 ? $"({details})" : "")}了",
|
||||
record.CreatedAt);
|
||||
}
|
||||
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
|
||||
_notifications.MarkAllReadAsync(userId, ct);
|
||||
|
||||
if (record.Type == "ExerciseReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var type = root.TryGetProperty("ExerciseType", out var typeValue) ? typeValue.GetString() : "运动";
|
||||
var minutes = root.TryGetProperty("DurationMinutes", out var minutesValue) ? minutesValue.GetInt32() : 30;
|
||||
return new InAppNotificationDto(record.Id, record.Type, "运动提醒", $"今天的{type}计划还未完成,目标 {minutes} 分钟", record.CreatedAt);
|
||||
}
|
||||
public Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
|
||||
_notifications.DeleteAsync(userId, notificationId, ct);
|
||||
|
||||
return new InAppNotificationDto(record.Id, record.Type, "健康提醒", "您有一条新的健康提醒", record.CreatedAt);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user