feat: 应用内通知系统 + 结构化手术史/用药等相关改动
- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker - 通知中心页面及前端通知服务接入 - 健康指标异常、用药/运动提醒等事件统一产出站内通知 - 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整 - AppDbContext 注册通知相关实体
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
using Health.Application.Medications;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Infrastructure.Data;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
using Health.Infrastructure.Medications;
|
||||
using Health.Infrastructure.Notifications;
|
||||
using Health.Infrastructure.Reports;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -57,6 +62,112 @@ public sealed class PersistencePipelineTests
|
||||
Assert.Equal(2000, task.LastError!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportTask_OnlyEnqueuesFailureNotificationAtFinalAttempt()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var report = new Report
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
FileUrl = "report.jpg",
|
||||
Status = ReportStatus.Analyzing,
|
||||
};
|
||||
var task = NewReportTask(attempts: 2);
|
||||
task.ReportId = report.Id;
|
||||
db.Reports.Add(report);
|
||||
db.ReportAnalysisTasks.Add(task);
|
||||
await db.SaveChangesAsync();
|
||||
var queue = new EfReportAnalysisQueue(db, new EfUserNotificationProducer(db));
|
||||
|
||||
await queue.RetryAsync(task.Id, "temporary failure", CancellationToken.None);
|
||||
Assert.Empty(db.NotificationOutbox);
|
||||
|
||||
task.Status = "Processing";
|
||||
task.Attempts = 3;
|
||||
await db.SaveChangesAsync();
|
||||
await queue.RetryAsync(task.Id, "final failure", CancellationToken.None);
|
||||
|
||||
var notification = Assert.Single(db.NotificationOutbox);
|
||||
Assert.Equal(task.Id, notification.SourceTaskId);
|
||||
Assert.Equal("ReportAnalysisFailed", notification.Type);
|
||||
Assert.Equal(userId, notification.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MedicationReminder_MorningDoseDoesNotSuppressEveningDose()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var medicationId = Guid.NewGuid();
|
||||
var scanTimeUtc = new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc);
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = medicationId,
|
||||
UserId = userId,
|
||||
Name = "测试药物",
|
||||
Frequency = MedicationFrequency.TwiceDaily,
|
||||
TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)],
|
||||
StartDate = new DateOnly(2026, 6, 20),
|
||||
IsActive = true,
|
||||
});
|
||||
db.MedicationLogs.Add(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MedicationId = medicationId,
|
||||
Status = MedicationLogStatus.Taken,
|
||||
ScheduledTime = new TimeOnly(8, 0),
|
||||
ConfirmedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc),
|
||||
CreatedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var scanner = new MedicationReminderScanner(new EfMedicationRepository(db));
|
||||
|
||||
var tasks = await scanner.ScanAsync(scanTimeUtc, CancellationToken.None);
|
||||
|
||||
var reminder = Assert.Single(tasks);
|
||||
Assert.Equal(new TimeOnly(20, 0), reminder.ScheduledTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MedicationReminder_ConfirmedDoseIsNotRemindedAgain()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var medicationId = Guid.NewGuid();
|
||||
var confirmedAt = new DateTime(2026, 6, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = medicationId,
|
||||
UserId = userId,
|
||||
Name = "测试药物",
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(20, 0)],
|
||||
StartDate = new DateOnly(2026, 6, 20),
|
||||
IsActive = true,
|
||||
});
|
||||
db.MedicationLogs.Add(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MedicationId = medicationId,
|
||||
Status = MedicationLogStatus.Taken,
|
||||
ScheduledTime = new TimeOnly(20, 0),
|
||||
ConfirmedAt = confirmedAt,
|
||||
CreatedAt = confirmedAt,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var scanner = new MedicationReminderScanner(new EfMedicationRepository(db));
|
||||
|
||||
var tasks = await scanner.ScanAsync(
|
||||
new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Empty(tasks);
|
||||
}
|
||||
|
||||
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
|
||||
Reference in New Issue
Block a user