## 后端 - 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法 - 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展 - 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整 - 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景 - 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展 - AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理 - Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整 - BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑 - 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试 ## 前端 - UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新 - 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构 - 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取 - 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化 - 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化 - 趋势图: trend_page 大幅重构 - 登录: login_page 重构 - 个人资料: 新增 profile_edit_page; profile_page 优化 - 运动: 新增 exercise/ 目录 + care_plan_ui_logic - 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整 - 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化 - Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整 - AndroidManifest: 移除多余权限 - 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试 ## 文档 - 新增 ui-design-system.md 设计系统文档 - 新增 secondary-page-color-refresh 计划 + specs 目录
176 lines
6.6 KiB
C#
176 lines
6.6 KiB
C#
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)
|
|
{
|
|
var pushEnabled = await _db.NotificationPreferences.AsNoTracking()
|
|
.Where(x => x.UserId == userId)
|
|
.Select(x => (bool?)x.PushEnabled)
|
|
.FirstOrDefaultAsync(ct);
|
|
if (pushEnabled is false) return false;
|
|
|
|
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, "Notification task timed out and reached max retry count")
|
|
.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 task = await _db.NotificationOutbox
|
|
.FirstOrDefaultAsync(x => x.Id == candidate.Id && x.Status == "Pending", ct);
|
|
if (task == null) return true;
|
|
|
|
task.Status = "Processing";
|
|
task.Attempts += 1;
|
|
task.UpdatedAt = now;
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
try
|
|
{
|
|
var message = JsonSerializer.Deserialize<NotificationMessage>(task.Payload)
|
|
?? throw new InvalidOperationException("Notification payload is empty");
|
|
|
|
var preference = await _db.NotificationPreferences.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.UserId == task.UserId, ct);
|
|
var deliver = preference == null || ShouldDeliver(preference, message.Type, DateTime.UtcNow.AddHours(8));
|
|
|
|
if (deliver && !await _db.UserNotifications.AsNoTracking()
|
|
.AnyAsync(x => x.SourceId == task.SourceTaskId, ct))
|
|
{
|
|
await _db.UserNotifications.AddAsync(new UserNotification
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = task.UserId,
|
|
SourceId = task.SourceTaskId,
|
|
Type = message.Type,
|
|
Title = message.Title,
|
|
Message = message.Message,
|
|
Severity = message.Severity,
|
|
ActionType = message.ActionType,
|
|
ActionTargetId = message.ActionTargetId,
|
|
CreatedAt = task.CreatedAt,
|
|
}, ct);
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
task.Status = "Completed";
|
|
task.LastError = null;
|
|
task.UpdatedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
task.Status = task.Attempts >= MaxAttempts ? "Failed" : "Pending";
|
|
task.AvailableAt = DateTime.UtcNow.AddSeconds(Math.Pow(2, task.Attempts) * 5);
|
|
task.LastError = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
|
|
task.UpdatedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool ShouldDeliver(NotificationPreference preference, string type, DateTime nowCst)
|
|
{
|
|
if (!preference.PushEnabled) return false;
|
|
|
|
var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute;
|
|
var inDnd = preference.DndEnabled && (preference.DndStartMinutes <= preference.DndEndMinutes
|
|
? minuteOfDay >= preference.DndStartMinutes && minuteOfDay < preference.DndEndMinutes
|
|
: minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes);
|
|
if (inDnd) return false;
|
|
|
|
return NormalizeType(type) switch
|
|
{
|
|
"medication_reminder" => preference.MedicationReminder,
|
|
"exercise_reminder" => true,
|
|
"follow_up_reminder" => preference.FollowUpReminder,
|
|
"doctor_reply" => preference.DoctorReply,
|
|
"abnormal_alert" => preference.AbnormalAlert,
|
|
"health_record_reminder" => preference.HealthRecordReminder,
|
|
"health_metric_alert" => preference.AbnormalAlert,
|
|
_ => true,
|
|
};
|
|
}
|
|
|
|
private static string NormalizeType(string type) => type switch
|
|
{
|
|
"MedicationReminder" => "medication_reminder",
|
|
"ExerciseReminder" => "exercise_reminder",
|
|
"HealthMetricAlert" => "health_metric_alert",
|
|
"FollowUpReminder" => "follow_up_reminder",
|
|
_ => type.ToLowerInvariant(),
|
|
};
|
|
}
|