feat: 应用内通知系统 + 结构化手术史/用药等相关改动

- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker
- 通知中心页面及前端通知服务接入
- 健康指标异常、用药/运动提醒等事件统一产出站内通知
- 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整
- AppDbContext 注册通知相关实体
This commit is contained in:
MingNian
2026-06-21 21:06:29 +08:00
parent b57d0d16f4
commit 13714d9ed8
34 changed files with 1541 additions and 200 deletions

View File

@@ -55,7 +55,7 @@ public static class CommonAgentHandler
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,
};
}

View File

@@ -28,11 +28,13 @@ 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>();
@@ -154,12 +156,33 @@ 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");

View File

@@ -1,12 +1,13 @@
using System.Text.Json;
using Health.Application.Exercises;
using Health.Infrastructure.Data.Records;
using Health.Application.Notifications;
namespace Health.Infrastructure.Exercises;
public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminderProducer
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)
{
@@ -17,28 +18,22 @@ public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminde
.Include(x => x.Plan)
.Where(x => x.ScheduledDate == today && !x.IsCompleted && !x.IsRestDay && x.Plan.ReminderTime <= currentTime)
.ToListAsync(ct);
var sourceIds = dueItems.Select(x => x.Id).ToList();
var existing = await _db.NotificationOutbox.AsNoTracking()
.Where(x => sourceIds.Contains(x.SourceTaskId))
.Select(x => x.SourceTaskId)
.ToListAsync(ct);
var existingSet = existing.ToHashSet();
var now = DateTime.UtcNow;
foreach (var item in dueItems.Where(x => !existingSet.Contains(x.Id)))
var created = 0;
foreach (var item in dueItems)
{
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
{
Id = Guid.NewGuid(), UserId = item.Plan.UserId, SourceTaskId = item.Id,
Type = "ExerciseReminder",
Payload = JsonSerializer.Serialize(new
{
item.ExerciseType, item.DurationMinutes, item.ScheduledDate,
ReminderTime = item.Plan.ReminderTime,
}),
Status = "AwaitingProvider", AvailableAt = now, CreatedAt = now, UpdatedAt = now,
}, ct);
if (await _notifications.EnqueueAsync(
item.Plan.UserId,
item.Id,
new NotificationMessage(
"ExerciseReminder",
"今日运动计划",
$"今天安排了 {item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
"info",
"exercise",
item.PlanId.ToString()),
ct))
created++;
}
if (_db.ChangeTracker.HasChanges()) await _db.SaveChangesAsync(ct);
return dueItems.Count - existingSet.Count;
return created;
}
}

View File

@@ -7,7 +7,9 @@ public sealed class EfHealthArchiveRepository(AppDbContext db) : IHealthArchiveR
private readonly AppDbContext _db = db;
public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) =>
_db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, 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);

View File

@@ -56,12 +56,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit
.Where(m => m.IsActive && m.TimeOfDay.Count > 0)
.ToListAsync(ct);
public Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
_db.MedicationLogs.AnyAsync(l =>
l.UserId == userId && l.MedicationId == medicationId
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
&& l.Status == MedicationLogStatus.Taken, ct);
public async Task AddMedicationAsync(Medication medication, CancellationToken ct) =>
await _db.Medications.AddAsync(medication, ct);

View File

@@ -1,43 +1,35 @@
using System.Text.Json;
using Health.Application.Medications;
using Health.Infrastructure.Data.Records;
using Health.Application.Notifications;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Medications;
public sealed class OutboxMedicationReminderDispatcher(
AppDbContext db,
IUserNotificationProducer notifications,
ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher
{
private readonly AppDbContext _db = db;
private readonly IUserNotificationProducer _notifications = notifications;
private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger;
public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct)
{
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == task.TaskId, ct))
return;
var now = DateTime.UtcNow;
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
var details = string.Join(" · ", new[]
{
Id = Guid.NewGuid(),
UserId = task.UserId,
SourceTaskId = task.TaskId,
Type = "MedicationReminder",
Payload = JsonSerializer.Serialize(new
{
task.MedicationId,
task.Name,
task.Dosage,
task.ScheduledDate,
task.ScheduledTime,
}),
Status = "AwaitingProvider",
AvailableAt = now,
CreatedAt = now,
UpdatedAt = now,
}, ct);
await _db.SaveChangesAsync(ct);
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}",

View File

@@ -7,20 +7,46 @@ public sealed class EfInAppNotificationRepository(AppDbContext db) : IInAppNotif
private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct) =>
await _db.NotificationOutbox.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == "AwaitingProvider")
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.Payload, x.CreatedAt))
.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.NotificationOutbox
.Where(x => x.Id == notificationId && x.UserId == userId && x.Status == "AwaitingProvider")
var updated = await _db.UserNotifications
.Where(x => x.Id == notificationId && x.UserId == userId && !x.IsRead && !x.IsDeleted)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "InAppDelivered")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
.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;
}
}

View File

@@ -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;
}
}

View File

@@ -40,6 +40,8 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
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);