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

@@ -24,7 +24,10 @@ public sealed class PatientContextService(
if (archive != null) if (archive != null)
{ {
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}"); 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 (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}");
if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}"); if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}");
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}"); if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");

View File

@@ -2,12 +2,22 @@ using Health.Domain.Entities;
namespace Health.Application.HealthArchives; 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( public sealed record HealthArchiveDto(
Guid Id, Guid Id,
Guid UserId, Guid UserId,
string? Diagnosis, string? Diagnosis,
string? SurgeryType, string? SurgeryType,
DateOnly? SurgeryDate, DateOnly? SurgeryDate,
IReadOnlyList<HealthArchiveSurgeryDto> Surgeries,
IReadOnlyList<string> Allergies, IReadOnlyList<string> Allergies,
IReadOnlyList<string> DietRestrictions, IReadOnlyList<string> DietRestrictions,
IReadOnlyList<string> ChronicDiseases, IReadOnlyList<string> ChronicDiseases,
@@ -21,7 +31,8 @@ public sealed record HealthArchiveUpdateRequest(
IReadOnlyList<string>? Allergies, IReadOnlyList<string>? Allergies,
IReadOnlyList<string>? DietRestrictions, IReadOnlyList<string>? DietRestrictions,
IReadOnlyList<string>? ChronicDiseases, IReadOnlyList<string>? ChronicDiseases,
string? FamilyHistory); string? FamilyHistory,
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
public interface IHealthArchiveService public interface IHealthArchiveService
{ {

View File

@@ -42,7 +42,8 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
"update_diagnosis" => request with "update_diagnosis" => request with
{ {
SurgeryType = null, SurgeryDate = null, Allergies = null, SurgeryType = null, SurgeryDate = null, Allergies = null,
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null,
Surgeries = null
}, },
"update_surgery" => request with "update_surgery" => request with
{ {
@@ -77,8 +78,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request) private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
{ {
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis; if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
if (request.SurgeryType != null) archive.SurgeryType = request.SurgeryType; if (request.Surgeries != null)
if (request.SurgeryDate.HasValue) archive.SurgeryDate = request.SurgeryDate.Value; {
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.Allergies != null) archive.Allergies = request.Allergies.ToList();
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList(); if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList(); if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
@@ -91,6 +125,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
archive.Diagnosis, archive.Diagnosis,
archive.SurgeryType, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), 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.Allergies,
archive.DietRestrictions, archive.DietRestrictions,
archive.ChronicDiseases, archive.ChronicDiseases,
@@ -103,9 +138,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
archive.Diagnosis, archive.Diagnosis,
archive.SurgeryType, archive.SurgeryType,
archive.SurgeryDate, archive.SurgeryDate,
GetSurgeries(archive),
archive.Allergies, archive.Allergies,
archive.DietRestrictions, archive.DietRestrictions,
archive.ChronicDiseases, archive.ChronicDiseases,
archive.FamilyHistory, archive.FamilyHistory,
archive.UpdatedAt); 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,
});
}
} }

View File

@@ -78,7 +78,6 @@ public interface IMedicationRepository
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, 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<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(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 AddMedicationAsync(Medication medication, CancellationToken ct);
Task AddLogAsync(MedicationLog log, CancellationToken ct); Task AddLogAsync(MedicationLog log, CancellationToken ct);
void DeleteMedication(Medication medication); void DeleteMedication(Medication medication);

View File

@@ -21,10 +21,18 @@ public sealed class MedicationReminderScanner(IMedicationRepository medications)
foreach (var medication in active) foreach (var medication in active)
{ {
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue; 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))) 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( tasks.Add(new MedicationReminderTask(
Guid.NewGuid(), Guid.NewGuid(),
medication.UserId, medication.UserId,

View File

@@ -5,22 +5,56 @@ public sealed record InAppNotificationDto(
string Type, string Type,
string Title, string Title,
string Message, string Message,
string Severity,
string? ActionType,
string? ActionTargetId,
bool IsRead,
DateTime CreatedAt); DateTime CreatedAt);
public sealed record NotificationMessage(
string Type,
string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId);
public interface IInAppNotificationService public interface IInAppNotificationService
{ {
Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct); 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<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 public interface IInAppNotificationRepository
{ {
Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct); 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<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( public sealed record InAppNotificationRecord(
Guid Id, Guid Id,
string Type, string Type,
string Payload, string Title,
string Message,
string Severity,
string? ActionType,
string? ActionTargetId,
bool IsRead,
DateTime CreatedAt); 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);
}

View File

@@ -1,5 +1,3 @@
using System.Text.Json;
namespace Health.Application.Notifications; namespace Health.Application.Notifications;
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
@@ -12,36 +10,22 @@ public sealed class InAppNotificationService(IInAppNotificationRepository notifi
return records.Select(ToDto).ToList(); 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) => public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
_notifications.AcknowledgeAsync(userId, notificationId, ct); _notifications.AcknowledgeAsync(userId, notificationId, ct);
private static InAppNotificationDto ToDto(InAppNotificationRecord record) public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
{ _notifications.MarkAllReadAsync(userId, ct);
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);
}
if (record.Type == "ExerciseReminder") public Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
{ _notifications.DeleteAsync(userId, notificationId, ct);
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);
}
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);
} }

View File

@@ -38,6 +38,18 @@ public sealed class HealthArchive
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!; 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> /// <summary>
@@ -97,3 +109,20 @@ public sealed class DeviceToken
public User User { get; set; } = null!; 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;
}

View File

@@ -55,7 +55,7 @@ public static class CommonAgentHandler
return new return new
{ {
found = true, archive.Diagnosis, archive.SurgeryType, 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, 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<DoctorProfile> DoctorProfiles => Set<DoctorProfile>();
public DbSet<FollowUp> FollowUps => Set<FollowUp>(); public DbSet<FollowUp> FollowUps => Set<FollowUp>();
public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>(); public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>();
public DbSet<HealthArchiveSurgery> HealthArchiveSurgeries => Set<HealthArchiveSurgery>();
// 支撑表 // 支撑表
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>(); public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>(); public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>(); public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>(); public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>(); public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
@@ -154,12 +156,33 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
e.HasIndex(n => n.UserId).IsUnique(); 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 ---- // ---- HealthArchive ----
builder.Entity<HealthArchive>(e => builder.Entity<HealthArchive>(e =>
{ {
e.HasIndex(a => a.UserId).IsUnique(); 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 => builder.Entity<AiWriteCommandRecord>(e =>
{ {
e.ToTable("AiWriteCommands"); e.ToTable("AiWriteCommands");

View File

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

View File

@@ -7,7 +7,9 @@ public sealed class EfHealthArchiveRepository(AppDbContext db) : IHealthArchiveR
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) => 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) => public async Task AddAsync(HealthArchive archive, CancellationToken ct) =>
await _db.HealthArchives.AddAsync(archive, 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) .Where(m => m.IsActive && m.TimeOfDay.Count > 0)
.ToListAsync(ct); .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) => public async Task AddMedicationAsync(Medication medication, CancellationToken ct) =>
await _db.Medications.AddAsync(medication, ct); await _db.Medications.AddAsync(medication, ct);

View File

@@ -1,43 +1,35 @@
using System.Text.Json; using System.Text.Json;
using Health.Application.Medications; using Health.Application.Medications;
using Health.Infrastructure.Data.Records; using Health.Application.Notifications;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Medications; namespace Health.Infrastructure.Medications;
public sealed class OutboxMedicationReminderDispatcher( public sealed class OutboxMedicationReminderDispatcher(
AppDbContext db, IUserNotificationProducer notifications,
ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher
{ {
private readonly AppDbContext _db = db; private readonly IUserNotificationProducer _notifications = notifications;
private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger; private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger;
public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct) public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct)
{ {
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == task.TaskId, ct)) var details = string.Join(" · ", new[]
return;
var now = DateTime.UtcNow;
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
{ {
Id = Guid.NewGuid(),
UserId = task.UserId,
SourceTaskId = task.TaskId,
Type = "MedicationReminder",
Payload = JsonSerializer.Serialize(new
{
task.MedicationId,
task.Name,
task.Dosage, task.Dosage,
task.ScheduledDate, task.ScheduledTime.ToString("HH:mm")
task.ScheduledTime, }.Where(x => !string.IsNullOrWhiteSpace(x)));
}), await _notifications.EnqueueAsync(
Status = "AwaitingProvider", task.UserId,
AvailableAt = now, task.TaskId,
CreatedAt = now, new NotificationMessage(
UpdatedAt = now, "MedicationReminder",
}, ct); "用药时间到了",
await _db.SaveChangesAsync(ct); $"请按计划服用{task.Name}{(details.Length > 0 ? $"{details}" : "")},服用后记得打卡。",
"info",
"medication",
task.MedicationId.ToString()),
ct);
_logger.LogInformation( _logger.LogInformation(
"用药提醒已写入通知 Outbox等待推送服务: {TaskId} {UserId}", "用药提醒已写入通知 Outbox等待推送服务: {TaskId} {UserId}",

View File

@@ -7,20 +7,46 @@ public sealed class EfInAppNotificationRepository(AppDbContext db) : IInAppNotif
private readonly AppDbContext _db = db; private readonly AppDbContext _db = db;
public async Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct) => public async Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct) =>
await _db.NotificationOutbox.AsNoTracking() await _db.UserNotifications.AsNoTracking()
.Where(x => x.UserId == userId && x.Status == "AwaitingProvider") .Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted)
.OrderBy(x => x.CreatedAt) .OrderBy(x => x.CreatedAt)
.Take(10) .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); .ToListAsync(ct);
public async Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) public async Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct)
{ {
var updated = await _db.NotificationOutbox var updated = await _db.UserNotifications
.Where(x => x.Id == notificationId && x.UserId == userId && x.Status == "AwaitingProvider") .Where(x => x.Id == notificationId && x.UserId == userId && !x.IsRead && !x.IsDeleted)
.ExecuteUpdateAsync(setters => setters .ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "InAppDelivered") .SetProperty(x => x.IsRead, true)
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct); .SetProperty(x => x.ReadAt, DateTime.UtcNow), ct);
return updated > 0; 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.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct);
await _db.RefreshTokens.Where(t => t.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.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.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
await _db.HealthArchives.Where(a => a.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.AiWriteCommands.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);

View File

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

View File

@@ -132,6 +132,7 @@ public static class DoctorEndpoints
var user = await db.Users var user = await db.Users
.Include(u => u.HealthArchive) .Include(u => u.HealthArchive)
.ThenInclude(a => a!.Surgeries)
.FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId); .FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId);
if (user == null) if (user == null)
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" }); return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
@@ -179,7 +180,18 @@ public static class DoctorEndpoints
data = new data = new
{ {
profile = new { user.Id, user.Phone, user.Name, user.Gender, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), user.AvatarUrl }, profile = new { user.Id, user.Phone, user.Name, user.Gender, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), user.AvatarUrl },
archive = user.HealthArchive == null ? null : new { user.HealthArchive.Diagnosis, user.HealthArchive.SurgeryType, SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"), user.HealthArchive.Allergies, user.HealthArchive.DietRestrictions, user.HealthArchive.ChronicDiseases, user.HealthArchive.FamilyHistory }, archive = user.HealthArchive == null ? null : new
{
user.HealthArchive.Diagnosis,
user.HealthArchive.SurgeryType,
SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"),
surgeries = user.HealthArchive.Surgeries.OrderBy(s => s.SortOrder)
.Select(s => new { s.Type, Date = s.Date.HasValue ? s.Date.Value.ToString("yyyy-MM-dd") : null }),
user.HealthArchive.Allergies,
user.HealthArchive.DietRestrictions,
user.HealthArchive.ChronicDiseases,
user.HealthArchive.FamilyHistory
},
latestRecords, latestRecords,
trendRecords, trendRecords,
medications, medications,

View File

@@ -17,6 +17,19 @@ public static class NotificationEndpoints
return Results.Ok(new { code = 0, data = result, message = (string?)null }); return Results.Ok(new { code = 0, data = result, message = (string?)null });
}); });
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var result = await notifications.GetHistoryAsync(userId, ct);
return Results.Ok(new
{
code = 0,
data = new { unreadCount = result.Count(x => !x.IsRead), items = result },
message = (string?)null
});
});
group.MapPost("/{id:guid}/acknowledge", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) => group.MapPost("/{id:guid}/acknowledge", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -24,6 +37,25 @@ public static class NotificationEndpoints
var acknowledged = await notifications.AcknowledgeAsync(userId, id, ct); var acknowledged = await notifications.AcknowledgeAsync(userId, id, ct);
return Results.Ok(new { code = 0, data = new { acknowledged }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { acknowledged }, message = (string?)null });
}); });
group.MapPost("/read-all", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var count = await notifications.MarkAllReadAsync(userId, ct);
return Results.Ok(new { code = 0, data = new { count }, message = (string?)null });
});
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var deleted = await notifications.DeleteAsync(userId, id, ct);
return deleted
? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null })
: Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" });
});
} }
private static Guid GetUserId(HttpContext http) => private static Guid GetUserId(HttpContext http) =>

View File

@@ -36,6 +36,9 @@ public static class UserEndpoints
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) => group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
{ {
var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null; var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null;
var surgeries = req.Surgeries?.Select(s => new HealthArchiveSurgeryInput(
s.Type,
DateOnly.TryParse(s.Date, out var date) ? date : null)).ToList();
await archives.UpdateAsync( await archives.UpdateAsync(
GetUserId(http), GetUserId(http),
new HealthArchiveUpdateRequest( new HealthArchiveUpdateRequest(
@@ -45,7 +48,8 @@ public static class UserEndpoints
req.Allergies, req.Allergies,
req.DietRestrictions, req.DietRestrictions,
req.ChronicDiseases, req.ChronicDiseases,
req.FamilyHistory), req.FamilyHistory,
surgeries),
ct); ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
@@ -65,4 +69,7 @@ public sealed record UpdateProfileRequest(string? Name, string? Gender, string?
public sealed record UpdateArchiveRequest( public sealed record UpdateArchiveRequest(
string? Diagnosis, string? SurgeryType, string? SurgeryDate, string? Diagnosis, string? SurgeryType, string? SurgeryDate,
List<string>? Allergies, List<string>? DietRestrictions, List<string>? Allergies, List<string>? DietRestrictions,
List<string>? ChronicDiseases, string? FamilyHistory); List<string>? ChronicDiseases, string? FamilyHistory,
List<UpdateArchiveSurgeryRequest>? Surgeries);
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);

View File

@@ -1,6 +1,7 @@
using Health.Application.AI; using Health.Application.AI;
using Health.Application.Calendars; using Health.Application.Calendars;
using Health.Application.HealthArchives; using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Exercises; using Health.Application.Exercises;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
@@ -34,6 +35,67 @@ public sealed class ApplicationServiceTests
Assert.Equal(["青霉素"], repository.Archive.Allergies); 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] [Fact]
public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation() public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation()
{ {
@@ -113,6 +175,23 @@ public sealed class ApplicationServiceTests
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; 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 sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
{ {
private readonly Conversation _conversation = conversation; private readonly Conversation _conversation = conversation;

View File

@@ -1,6 +1,11 @@
using Health.Application.Medications;
using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Health.Infrastructure.Data; using Health.Infrastructure.Data;
using Health.Infrastructure.Data.Records; using Health.Infrastructure.Data.Records;
using Health.Infrastructure.Medications;
using Health.Infrastructure.Notifications;
using Health.Infrastructure.Reports; using Health.Infrastructure.Reports;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -57,6 +62,112 @@ public sealed class PersistencePipelineTests
Assert.Equal(2000, task.LastError!.Length); 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() private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),

View File

@@ -113,6 +113,8 @@ AI 问诊、饮食分析、报告解读是项目亮点,但要注意:
### 5.1 API 配置不适合真实移动端 ### 5.1 API 配置不适合真实移动端
> 2026-06-20 更新:已支持 `--dart-define=API_BASE_URL=...`,并保留 `http://localhost:5000` 作为 USB `adb reverse` 本地开发默认值。本节的环境配置问题已处理。
`health_app/lib/core/api_client.dart``baseUrl` 默认是 `http://localhost:5000`。这在 Android/iOS 真机上通常不可用,也不适合测试/生产环境切换。 `health_app/lib/core/api_client.dart``baseUrl` 默认是 `http://localhost:5000`。这在 Android/iOS 真机上通常不可用,也不适合测试/生产环境切换。
建议: 建议:
@@ -174,6 +176,8 @@ AI 问诊、饮食分析、报告解读是项目亮点,但要注意:
### 5.6 错误处理过于安静 ### 5.6 错误处理过于安静
> 2026-06-20 更新:`ApiClient` 已统一识别 `{ code, message }` 业务错误及 HTTP/Dio 网络错误,并转换为可直接展示的 `ApiException`。后端历史 endpoint 的 HTTP 状态码仍需按模块逐步规范,避免一次性破坏现有前端协议。
一些 provider 会 catch 异常后返回 fallback 数据,例如医生列表、运动计划。这个方式能让页面不崩,但会掩盖真实后端错误。 一些 provider 会 catch 异常后返回 fallback 数据,例如医生列表、运动计划。这个方式能让页面不崩,但会掩盖真实后端错误。
建议: 建议:
@@ -481,6 +485,8 @@ static IQueryable<User> ScopePatientsToDoctor(AppDbContext db, Guid doctorId) =>
### 11.7 前端状态和生命周期问题 ### 11.7 前端状态和生命周期问题
> 2026-06-20 更新:健康最新值、用药列表、用药提醒、当前运动计划已改为 `autoDispose`AI 确认写入后会同时刷新这些核心数据。报告和饮食使用各自 Notifier/页面加载流程,尚未强行合并成一个全局缓存。
前端现在能跑起来,但长期运行会有状态残留风险: 前端现在能跑起来,但长期运行会有状态残留风险:
- `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)` - `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`

View File

@@ -3,8 +3,21 @@ import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'local_database.dart'; import 'local_database.dart';
/// API 基础地址 /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = 'http://localhost:5000'; // adb reverse 或同WiFi直连时改为PC的IP const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:5000',
);
class ApiException implements Exception {
final int? code;
final String message;
const ApiException(this.message, {this.code});
@override
String toString() => message;
}
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新 /// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
class ApiClient { class ApiClient {
@@ -40,22 +53,22 @@ class ApiClient {
/// 带 token 的 GET 请求 /// 带 token 的 GET 请求
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async { Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
return _dio.get(path, queryParameters: queryParameters); return _request(_dio.get(path, queryParameters: queryParameters));
} }
/// 带 token 的 POST 请求 /// 带 token 的 POST 请求
Future<Response> post(String path, {dynamic data}) async { Future<Response> post(String path, {dynamic data}) async {
return _dio.post(path, data: data); return _request(_dio.post(path, data: data));
} }
/// 带 token 的 PUT 请求 /// 带 token 的 PUT 请求
Future<Response> put(String path, {dynamic data}) async { Future<Response> put(String path, {dynamic data}) async {
return _dio.put(path, data: data); return _request(_dio.put(path, data: data));
} }
/// 带 token 的 DELETE 请求 /// 带 token 的 DELETE 请求
Future<Response> delete(String path) async { Future<Response> delete(String path) async {
return _dio.delete(path); return _request(_dio.delete(path));
} }
/// 上传文件multipart返回文件 URL /// 上传文件multipart返回文件 URL
@@ -63,7 +76,7 @@ class ApiClient {
final formData = FormData.fromMap({ final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last), fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
}); });
final res = await _dio.post(path, data: formData); final res = await _request(_dio.post(path, data: formData));
final data = res.data; final data = res.data;
if (data is Map) { if (data is Map) {
final topUrl = data['url']?.toString(); final topUrl = data['url']?.toString();
@@ -82,6 +95,46 @@ class ApiClient {
} }
return null; return null;
} }
Response _ensureBusinessSuccess(Response response) {
final body = response.data;
if (body is Map && body.containsKey('code')) {
final rawCode = body['code'];
final code = rawCode is int ? rawCode : int.tryParse('$rawCode');
if (code != null && code != 0) {
throw ApiException(
body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: '请求失败',
code: code,
);
}
}
return response;
}
Future<Response> _request(Future<Response> request) async {
try {
return _ensureBusinessSuccess(await request);
} on DioException catch (error) {
final body = error.response?.data;
final message = body is Map && body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout
? '请求超时,请稍后重试'
: error.type == DioExceptionType.connectionError
? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试';
final rawCode = body is Map ? body['code'] : null;
throw ApiException(
message,
code: rawCode is int
? rawCode
: int.tryParse('$rawCode') ?? error.response?.statusCode,
);
}
}
} }
/// 认证拦截器:自动注入 token + 401 刷新 /// 认证拦截器:自动注入 token + 401 刷新

View File

@@ -12,6 +12,7 @@ import '../pages/report/ai_analysis_page.dart';
import '../pages/consultation/consultation_pages.dart'; import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart'; import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_page.dart'; import '../pages/settings/notification_prefs_page.dart';
import '../pages/notifications/notification_center_page.dart';
import '../pages/profile/profile_page.dart'; import '../pages/profile/profile_page.dart';
import '../pages/diet/diet_capture_page.dart'; import '../pages/diet/diet_capture_page.dart';
import '../pages/device/device_scan_page.dart'; import '../pages/device/device_scan_page.dart';
@@ -97,6 +98,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const SettingsPage(); return const SettingsPage();
case 'notificationPrefs': case 'notificationPrefs':
return const NotificationPrefsPage(); return const NotificationPrefsPage();
case 'notifications':
return const NotificationCenterPage();
case 'staticText': case 'staticText':
return StaticTextPage(type: params['type']!); return StaticTextPage(type: params['type']!);
case 'dietDetail': case 'dietDetail':

View File

@@ -121,7 +121,17 @@ class DoctorPatientDetailPage extends ConsumerWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
if (archive['diagnosis'] != null) if (archive['diagnosis'] != null)
_row('诊断', archive['diagnosis']), _row('诊断', archive['diagnosis']),
if (archive['surgeryType'] != null) if (archive['surgeries'] is List &&
(archive['surgeries'] as List).isNotEmpty)
_row(
'手术史',
(archive['surgeries'] as List).map((item) {
final surgery = item as Map;
final date = surgery['date']?.toString() ?? '';
return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}';
}).join(''),
)
else if (archive['surgeryType'] != null)
_row( _row(
'手术史', '手术史',
'${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}', '${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}',

View File

@@ -12,7 +12,6 @@ import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../widgets/health_drawer.dart'; import '../../widgets/health_drawer.dart';
import '../../services/in_app_notification_service.dart';
import '../diet/diet_capture_page.dart'; import '../diet/diet_capture_page.dart';
import 'widgets/chat_messages_view.dart'; import 'widgets/chat_messages_view.dart';
@@ -29,15 +28,16 @@ class _HomePageState extends ConsumerState<HomePage> {
String? _pickedImagePath; String? _pickedImagePath;
int _lastMsgCount = 0; int _lastMsgCount = 0;
Timer? _notificationTimer; Timer? _notificationTimer;
bool _checkingNotifications = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkNotifications()); WidgetsBinding.instance.addPostFrameCallback(
(_) => ref.invalidate(notificationUnreadCountProvider),
);
_notificationTimer = Timer.periodic( _notificationTimer = Timer.periodic(
const Duration(seconds: 30), const Duration(seconds: 30),
(_) => _checkNotifications(), (_) => ref.invalidate(notificationUnreadCountProvider),
); );
} }
@@ -50,52 +50,6 @@ class _HomePageState extends ConsumerState<HomePage> {
super.dispose(); super.dispose();
} }
Future<void> _checkNotifications() async {
if (!mounted || _checkingNotifications) return;
if (!ref.read(authProvider).isLoggedIn) return;
_checkingNotifications = true;
try {
final service = InAppNotificationService(ref.read(apiClientProvider));
final notifications = await service.getPending();
for (final notification in notifications) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 5),
content: Row(
children: [
Icon(
notification.type == 'ExerciseReminder'
? Icons.directions_run_outlined
: Icons.medication_outlined,
color: Colors.white,
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(notification.title,
style: const TextStyle(fontWeight: FontWeight.w700)),
Text(notification.message),
],
),
),
],
),
),
);
await service.acknowledge(notification.id);
}
} catch (_) {
// App 内提醒失败不阻断主页使用,下次轮询会重试。
} finally {
_checkingNotifications = false;
}
}
void _sendMessage() { void _sendMessage() {
final text = _textCtrl.text.trim(); final text = _textCtrl.text.trim();
final imagePath = _pickedImagePath; final imagePath = _pickedImagePath;
@@ -169,6 +123,7 @@ class _HomePageState extends ConsumerState<HomePage> {
final name = (user?.name != null && user!.name!.isNotEmpty) final name = (user?.name != null && user!.name!.isNotEmpty)
? user.name ? user.name
: '用户'; : '用户';
final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0;
return Container( return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -221,7 +176,8 @@ class _HomePageState extends ConsumerState<HomePage> {
), ),
_HeaderIconButton( _HeaderIconButton(
icon: LucideIcons.bell, icon: LucideIcons.bell,
onTap: () => pushRoute(ref, 'notificationPrefs'), badgeCount: unreadCount,
onTap: () => pushRoute(ref, 'notifications'),
), ),
], ],
), ),
@@ -565,13 +521,21 @@ class _HomePageState extends ConsumerState<HomePage> {
class _HeaderIconButton extends StatelessWidget { class _HeaderIconButton extends StatelessWidget {
final IconData icon; final IconData icon;
final VoidCallback onTap; final VoidCallback onTap;
const _HeaderIconButton({required this.icon, required this.onTap}); final int badgeCount;
const _HeaderIconButton({
required this.icon,
required this.onTap,
this.badgeCount = 0,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 38, width: 38,
height: 38, height: 38,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -582,6 +546,31 @@ class _HeaderIconButton extends StatelessWidget {
), ),
child: Icon(icon, size: 20, color: AppColors.textPrimary), child: Icon(icon, size: 20, color: AppColors.textPrimary),
), ),
if (badgeCount > 0)
Positioned(
right: -5,
top: -5,
child: Container(
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 2),
),
alignment: Alignment.center,
child: Text(
badgeCount > 99 ? '99+' : '$badgeCount',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
); );
} }
} }

View File

@@ -1464,13 +1464,13 @@ class ChatMessagesView extends ConsumerWidget {
? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}' ? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}'
: null; : null;
final hr = healthData['HeartRate']; final hr = healthData['HeartRate'];
final hrText = hr is int ? '$hr' : null; final hrText = hr is Map && hr['value'] is num ? '${hr['value']}' : null;
final bs = healthData['BloodSugar']; final bs = healthData['Glucose'];
final bsText = bs is num ? '$bs' : null; final bsText = bs is Map && bs['value'] is num ? '${bs['value']}' : null;
final bo = healthData['BloodOxygen']; final bo = healthData['SpO2'];
final boText = bo is num ? '$bo' : null; final boText = bo is Map && bo['value'] is num ? '${bo['value']}' : null;
final wt = healthData['Weight']; final wt = healthData['Weight'];
final wtText = wt is num ? '$wt' : null; final wtText = wt is Map && wt['value'] is num ? '${wt['value']}' : null;
final allNull = final allNull =
bpText == null && bpText == null &&
hrText == null && hrText == null &&

View File

@@ -0,0 +1,593 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../services/in_app_notification_service.dart';
class NotificationCenterPage extends ConsumerStatefulWidget {
const NotificationCenterPage({super.key});
@override
ConsumerState<NotificationCenterPage> createState() =>
_NotificationCenterPageState();
}
class _NotificationCenterPageState
extends ConsumerState<NotificationCenterPage> {
InAppNotificationHistory? _history;
bool _loading = true;
bool _markingAll = false;
String? _error;
InAppNotificationService get _service =>
ref.read(inAppNotificationServiceProvider);
@override
void initState() {
super.initState();
Future.microtask(_load);
}
Future<void> _load() async {
if (mounted) {
setState(() {
_loading = true;
_error = null;
});
}
try {
final history = await _service.getHistory();
if (!mounted) return;
setState(() {
_history = history;
_loading = false;
});
ref.invalidate(notificationUnreadCountProvider);
} catch (error) {
if (mounted) {
setState(() {
_error = '$error';
_loading = false;
});
}
}
}
Future<void> _open(InAppNotification item) async {
if (!item.isRead) {
await _service.acknowledge(item.id);
if (!mounted) return;
setState(() {
final current = _history;
if (current == null) return;
_history = InAppNotificationHistory(
unreadCount: (current.unreadCount - 1).clamp(0, current.unreadCount),
items: current.items
.map(
(value) =>
value.id == item.id ? value.copyWith(isRead: true) : value,
)
.toList(),
);
});
ref.invalidate(notificationUnreadCountProvider);
}
if (!mounted || item.actionType == null) return;
switch (item.actionType) {
case 'medication':
pushRoute(ref, 'medCheckIn', params: {'id': item.actionTargetId ?? ''});
return;
case 'exercise':
pushRoute(ref, 'exercisePlan');
return;
case 'health':
pushRoute(ref, 'trend', params: {'type': item.actionTargetId ?? ''});
return;
case 'report':
final reportId = item.actionTargetId;
if (reportId != null && reportId.isNotEmpty) {
pushRoute(ref, 'aiAnalysis', params: {'id': reportId});
}
return;
}
}
Future<void> _markAllRead() async {
if (_markingAll || (_history?.unreadCount ?? 0) == 0) return;
setState(() => _markingAll = true);
try {
await _service.markAllRead();
if (!mounted) return;
final current = _history;
if (current != null) {
setState(
() => _history = InAppNotificationHistory(
unreadCount: 0,
items: current.items
.map((item) => item.copyWith(isRead: true))
.toList(),
),
);
}
ref.invalidate(notificationUnreadCountProvider);
} finally {
if (mounted) setState(() => _markingAll = false);
}
}
Future<void> _delete(InAppNotification item) async {
try {
await _service.delete(item.id);
if (!mounted) return;
final current = _history;
if (current != null) {
setState(
() => _history = InAppNotificationHistory(
unreadCount: item.isRead
? current.unreadCount
: (current.unreadCount - 1).clamp(0, current.unreadCount),
items: current.items.where((value) => value.id != item.id).toList(),
),
);
}
ref.invalidate(notificationUnreadCountProvider);
} catch (_) {
if (mounted) await _load();
}
}
@override
Widget build(BuildContext context) {
final items = _history?.items ?? const <InAppNotification>[];
return Scaffold(
body: AppBackground(
safeArea: true,
child: Column(
children: [
_Header(
unreadCount: _history?.unreadCount ?? 0,
markingAll: _markingAll,
onBack: () => popRoute(ref),
onMarkAllRead: _markAllRead,
),
Expanded(
child: RefreshIndicator(
onRefresh: _load,
color: const Color(0xFF438CE1),
child: _buildBody(items),
),
),
],
),
),
);
}
Widget _buildBody(List<InAppNotification> items) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return _MessageState(
icon: LucideIcons.wifiOff,
title: '通知加载失败',
message: '请检查网络连接后重试',
buttonText: '重新加载',
onPressed: _load,
);
}
if (items.isEmpty) {
return const _MessageState(
icon: LucideIcons.bellOff,
title: '暂时没有通知',
message: '用药、运动、健康指标和报告消息会显示在这里',
);
}
final today = <InAppNotification>[];
final earlier = <InAppNotification>[];
final now = DateTime.now();
for (final item in items) {
final local = item.createdAt.toLocal();
final isToday =
local.year == now.year &&
local.month == now.month &&
local.day == now.day;
(isToday ? today : earlier).add(item);
}
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 32),
children: [
if (today.isNotEmpty) ...[
const _SectionTitle('今天'),
...today.map(_buildDismissible),
],
if (earlier.isNotEmpty) ...[
const SizedBox(height: 10),
const _SectionTitle('更早'),
...earlier.map(_buildDismissible),
],
],
);
}
Widget _buildDismissible(InAppNotification item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(18),
),
child: const Icon(LucideIcons.trash2, color: Colors.white),
),
onDismissed: (_) => _delete(item),
child: _NotificationCard(item: item, onTap: () => _open(item)),
),
);
}
class _Header extends StatelessWidget {
final int unreadCount;
final bool markingAll;
final VoidCallback onBack;
final VoidCallback onMarkAllRead;
const _Header({
required this.unreadCount,
required this.markingAll,
required this.onBack,
required this.onMarkAllRead,
});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 12, 10),
child: Row(
children: [
IconButton(
tooltip: '返回',
onPressed: onBack,
icon: const Icon(LucideIcons.chevronLeft),
),
const SizedBox(width: 2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'通知中心',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
unreadCount == 0 ? '所有消息均已读' : '$unreadCount 条未读消息',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
if (unreadCount > 0)
TextButton.icon(
onPressed: markingAll ? null : onMarkAllRead,
icon: markingAll
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(LucideIcons.checkCheck, size: 17),
label: const Text('全部已读'),
),
],
),
);
}
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 4, 10),
child: Text(
text,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
);
}
class _NotificationCard extends StatelessWidget {
final InAppNotification item;
final VoidCallback onTap;
const _NotificationCard({required this.item, required this.onTap});
@override
Widget build(BuildContext context) {
final visual = _NotificationVisual.of(item);
return Material(
color: item.isRead
? Colors.white.withValues(alpha: 0.72)
: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(18),
elevation: item.isRead ? 0 : 1,
shadowColor: visual.color.withValues(alpha: 0.12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: item.isRead
? const Color(0xFFE9EEF5)
: visual.color.withValues(alpha: 0.18),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [visual.lightColor, Colors.white],
),
borderRadius: BorderRadius.circular(14),
),
child: Icon(visual.icon, size: 22, color: visual.color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.title,
style: TextStyle(
fontSize: 15,
fontWeight: item.isRead
? FontWeight.w600
: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
if (!item.isRead)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: visual.color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: visual.color.withValues(alpha: 0.3),
blurRadius: 5,
),
],
),
),
],
),
const SizedBox(height: 5),
Text(
item.message,
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 9),
Row(
children: [
Text(
_formatTime(item.createdAt),
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
),
),
const Spacer(),
if (item.actionType != null)
Row(
children: [
Text(
'查看详情',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: visual.color,
),
),
const SizedBox(width: 2),
Icon(
LucideIcons.chevronRight,
size: 14,
color: visual.color,
),
],
),
],
),
],
),
),
],
),
),
),
);
}
static String _formatTime(DateTime value) {
final local = value.toLocal();
final now = DateTime.now();
final time =
'${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
if (local.year == now.year &&
local.month == now.month &&
local.day == now.day) {
return time;
}
if (local.year == now.year) return '${local.month}${local.day}$time';
return '${local.year}${local.month}${local.day}$time';
}
}
class _NotificationVisual {
final IconData icon;
final Color color;
final Color lightColor;
const _NotificationVisual(this.icon, this.color, this.lightColor);
factory _NotificationVisual.of(InAppNotification item) {
switch (item.actionType) {
case 'exercise':
return const _NotificationVisual(
LucideIcons.activity,
Color(0xFF2C9A70),
Color(0xFFDDF7EC),
);
case 'health':
if (item.severity == 'critical') {
return const _NotificationVisual(
LucideIcons.triangleAlert,
Color(0xFFE55353),
Color(0xFFFFE7E7),
);
}
return const _NotificationVisual(
LucideIcons.heartPulse,
Color(0xFFE8863A),
Color(0xFFFFEEDC),
);
case 'report':
if (item.severity == 'error') {
return const _NotificationVisual(
LucideIcons.fileWarning,
Color(0xFFE55353),
Color(0xFFFFE7E7),
);
}
return const _NotificationVisual(
LucideIcons.fileCheck2,
Color(0xFF7B68CE),
Color(0xFFEDE9FF),
);
default:
return const _NotificationVisual(
LucideIcons.pill,
Color(0xFF468AD8),
Color(0xFFE4F0FF),
);
}
}
}
class _MessageState extends StatelessWidget {
final IconData icon;
final String title;
final String message;
final String? buttonText;
final VoidCallback? onPressed;
const _MessageState({
required this.icon,
required this.title,
required this.message,
this.buttonText,
this.onPressed,
});
@override
Widget build(BuildContext context) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(28, 120, 28, 32),
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 34),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0.95),
const Color(0xFFF1F7FF).withValues(alpha: 0.9),
],
),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFE5EDF7)),
),
child: Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: const Color(0xFFE5F0FF),
borderRadius: BorderRadius.circular(20),
),
child: Icon(icon, size: 29, color: const Color(0xFF5D91CF)),
),
const SizedBox(height: 18),
Text(
title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 7),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
height: 1.5,
color: AppColors.textSecondary,
),
),
if (buttonText != null) ...[
const SizedBox(height: 20),
FilledButton(onPressed: onPressed, child: Text(buttonText!)),
],
],
),
),
],
);
}

View File

@@ -85,6 +85,8 @@ class AuthNotifier extends Notifier<AuthState> {
} }
} }
Future<void> refreshProfile() => _loadProfile();
/// 发送验证码 /// 发送验证码
Future<({String? error, String? devCode})> sendSms(String phone) async { Future<({String? error, String? devCode})> sendSms(String phone) async {
try { try {

View File

@@ -119,7 +119,9 @@ class ChatNotifier extends Notifier<ChatState> {
msgs[i].confirmed = true; msgs[i].confirmed = true;
state = state.copyWith(messages: msgs); state = state.copyWith(messages: msgs);
ref.invalidate(medicationListProvider); ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.invalidate(latestHealthProvider); ref.invalidate(latestHealthProvider);
ref.invalidate(currentExercisePlanProvider);
return null; return null;
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import '../services/health_service.dart'; import '../services/health_service.dart';
import '../services/admin_service.dart'; import '../services/admin_service.dart';
import '../services/in_app_notification_service.dart';
final exerciseServiceProvider = Provider<ExerciseService>((ref) { final exerciseServiceProvider = Provider<ExerciseService>((ref) {
return ExerciseService(ref.watch(apiClientProvider)); return ExerciseService(ref.watch(apiClientProvider));
@@ -36,8 +37,25 @@ final adminServiceProvider = Provider<AdminService>((ref) {
return AdminService(ref.watch(apiClientProvider)); return AdminService(ref.watch(apiClientProvider));
}); });
final inAppNotificationServiceProvider = Provider<InAppNotificationService>((
ref,
) {
return InAppNotificationService(ref.watch(apiClientProvider));
});
final notificationUnreadCountProvider = FutureProvider.autoDispose<int>((
ref,
) async {
final history = await ref
.watch(inAppNotificationServiceProvider)
.getHistory();
return history.unreadCount;
});
/// 最新健康数据 Provider /// 最新健康数据 Provider
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async { final latestHealthProvider = FutureProvider.autoDispose<Map<String, dynamic>>((
ref,
) async {
final service = ref.watch(healthServiceProvider); final service = ref.watch(healthServiceProvider);
return service.getLatest(); return service.getLatest();
}); });
@@ -49,48 +67,61 @@ void refreshHealthData(WidgetRef ref) {
} }
/// 用药列表 Provider /// 用药列表 Provider
final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final medicationListProvider =
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider); final service = ref.watch(medicationServiceProvider);
return service.getList(); return service.getList();
}); });
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final medicationReminderProvider =
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider); final service = ref.watch(medicationServiceProvider);
return service.getReminders(); return service.getReminders();
}); });
/// 医生列表 Provider /// 医生列表 Provider
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
final service = ref.watch(consultationServiceProvider); final service = ref.watch(consultationServiceProvider);
return service.getDoctors().timeout(const Duration(seconds: 8)); return service.getDoctors().timeout(const Duration(seconds: 8));
}); });
/// 问诊配额 Provider /// 问诊配额 Provider
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async { final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((
ref,
) async {
final service = ref.watch(consultationServiceProvider); final service = ref.watch(consultationServiceProvider);
return service.getQuota(); return service.getQuota();
}); });
/// 当前运动计划 Provider /// 当前运动计划 Provider
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async { final currentExercisePlanProvider =
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {
final service = ref.watch(exerciseServiceProvider); final service = ref.watch(exerciseServiceProvider);
return service.getCurrentPlan().timeout(const Duration(seconds: 8)); return service.getCurrentPlan().timeout(const Duration(seconds: 8));
}); });
/// 拍照/相册直接触发(无需跳转页面) /// 拍照/相册直接触发(无需跳转页面)
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(CameraActionNotifier.new); final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(
CameraActionNotifier.new,
);
class CameraActionNotifier extends Notifier<String?> { class CameraActionNotifier extends Notifier<String?> {
@override String? build() => null; @override
String? build() => null;
void trigger(String action) => state = action; void trigger(String action) => state = action;
void clear() => state = null; void clear() => state = null;
} }
/// 拍饮食动作触发(不用跳多余页面) /// 拍饮食动作触发(不用跳多余页面)
final dietActionProvider = NotifierProvider<DietActionNotifier, String?>(DietActionNotifier.new); final dietActionProvider = NotifierProvider<DietActionNotifier, String?>(
DietActionNotifier.new,
);
class DietActionNotifier extends Notifier<String?> { class DietActionNotifier extends Notifier<String?> {
@override String? build() => null; @override
String? build() => null;
void trigger(String action) => state = action; void trigger(String action) => state = action;
void clear() => state = null; void clear() => state = null;
} }

View File

@@ -5,12 +5,22 @@ class InAppNotification {
final String type; final String type;
final String title; final String title;
final String message; final String message;
final String severity;
final String? actionType;
final String? actionTargetId;
final bool isRead;
final DateTime createdAt;
const InAppNotification({ const InAppNotification({
required this.id, required this.id,
required this.type, required this.type,
required this.title, required this.title,
required this.message, required this.message,
required this.severity,
this.actionType,
this.actionTargetId,
required this.isRead,
required this.createdAt,
}); });
factory InAppNotification.fromJson(Map<String, dynamic> json) => factory InAppNotification.fromJson(Map<String, dynamic> json) =>
@@ -19,7 +29,36 @@ class InAppNotification {
type: json['type']?.toString() ?? '', type: json['type']?.toString() ?? '',
title: json['title']?.toString() ?? '健康提醒', title: json['title']?.toString() ?? '健康提醒',
message: json['message']?.toString() ?? '', message: json['message']?.toString() ?? '',
severity: json['severity']?.toString() ?? 'info',
actionType: json['actionType']?.toString(),
actionTargetId: json['actionTargetId']?.toString(),
isRead: json['isRead'] == true,
createdAt:
DateTime.tryParse(json['createdAt']?.toString() ?? '') ??
DateTime.now(),
); );
InAppNotification copyWith({bool? isRead}) => InAppNotification(
id: id,
type: type,
title: title,
message: message,
severity: severity,
actionType: actionType,
actionTargetId: actionTargetId,
isRead: isRead ?? this.isRead,
createdAt: createdAt,
);
}
class InAppNotificationHistory {
final int unreadCount;
final List<InAppNotification> items;
const InAppNotificationHistory({
required this.unreadCount,
required this.items,
});
} }
class InAppNotificationService { class InAppNotificationService {
@@ -32,7 +71,9 @@ class InAppNotificationService {
final items = response.data['data'] as List? ?? const []; final items = response.data['data'] as List? ?? const [];
return items return items
.whereType<Map>() .whereType<Map>()
.map((item) => InAppNotification.fromJson(Map<String, dynamic>.from(item))) .map(
(item) => InAppNotification.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.id.isNotEmpty) .where((item) => item.id.isNotEmpty)
.toList(); .toList();
} }
@@ -40,4 +81,29 @@ class InAppNotificationService {
Future<void> acknowledge(String id) async { Future<void> acknowledge(String id) async {
await _api.post('/api/notifications/$id/acknowledge'); await _api.post('/api/notifications/$id/acknowledge');
} }
Future<void> markAllRead() async {
await _api.post('/api/notifications/read-all');
}
Future<InAppNotificationHistory> getHistory() async {
final response = await _api.get('/api/notifications');
final data = response.data['data'] as Map? ?? const {};
final rawItems = data['items'] as List? ?? const [];
return InAppNotificationHistory(
unreadCount: data['unreadCount'] as int? ?? 0,
items: rawItems
.whereType<Map>()
.map(
(item) =>
InAppNotification.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.id.isNotEmpty)
.toList(),
);
}
Future<void> delete(String id) async {
await _api.delete('/api/notifications/$id');
}
} }