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

@@ -1,6 +1,7 @@
using Health.Application.AI;
using Health.Application.Calendars;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Exercises;
using Health.Domain.Entities;
using Health.Domain.Enums;
@@ -34,6 +35,67 @@ public sealed class ApplicationServiceTests
Assert.Equal(["青霉素"], repository.Archive.Allergies);
}
[Fact]
public async Task HealthArchive_ManualUpdateStoresMultipleStructuredSurgeries()
{
var userId = Guid.NewGuid();
var repository = new FakeHealthArchiveRepository(new HealthArchive
{
Id = Guid.NewGuid(), UserId = userId,
});
var service = new HealthArchiveService(repository);
var result = await service.UpdateAsync(
userId,
new HealthArchiveUpdateRequest(
null, null, null, null, null, null, null,
[
new HealthArchiveSurgeryInput("PCI", new DateOnly(2025, 3, 1)),
new HealthArchiveSurgeryInput("Appendectomy", new DateOnly(2010, 6, 2)),
]),
CancellationToken.None);
Assert.Equal(2, result.Surgeries.Count);
Assert.Equal("PCI", result.SurgeryType);
Assert.Equal(new DateOnly(2025, 3, 1), result.SurgeryDate);
Assert.Equal([0, 1], repository.Archive!.Surgeries.OrderBy(s => s.SortOrder).Select(s => s.SortOrder));
}
[Fact]
public async Task HealthTrend_365DaysUsesFullYearWindow()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
var before = DateTime.UtcNow;
await service.GetTrendAsync(Guid.NewGuid(), HealthMetricType.Weight, 365, CancellationToken.None);
Assert.NotNull(repository.RecordedAfter);
Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01);
}
[Fact]
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
{
var userId = Guid.NewGuid();
var repository = new FakeHealthArchiveRepository(new HealthArchive
{
Id = Guid.NewGuid(), UserId = userId,
SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2),
});
var service = new HealthArchiveService(repository);
await service.ExecuteAiActionAsync(
userId,
"update_surgery",
new HealthArchiveUpdateRequest(null, "New surgery", new DateOnly(2026, 6, 20), null, null, null, null),
CancellationToken.None);
Assert.Equal(2, repository.Archive!.Surgeries.Count);
Assert.Contains(repository.Archive.Surgeries, s => s.Type == "Legacy surgery");
Assert.Contains(repository.Archive.Surgeries, s => s.Type == "New surgery");
}
[Fact]
public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation()
{
@@ -113,6 +175,23 @@ public sealed class ApplicationServiceTests
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
{
public DateTime? RecordedAfter { get; private set; }
public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<HealthRecord>>([]);
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
public Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
public Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct)
{
RecordedAfter = recordedAfter;
return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
}
public Task AddAsync(HealthRecord record, CancellationToken ct) => Task.CompletedTask;
public void Delete(HealthRecord record) { }
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
{
private readonly Conversation _conversation = conversation;