feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端 - 健康档案: 新增手术状态字段 + 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 目录
This commit is contained in:
@@ -43,7 +43,7 @@ public static class MedicationAgentHandler
|
||||
return toolName switch
|
||||
{
|
||||
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
||||
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -157,9 +157,9 @@ public sealed class PromptManager
|
||||
""";
|
||||
|
||||
private const string UnifiedPrompt = """
|
||||
你是一个患者端 AI 健康管家,为心脏术后康复患者提供健康解释、记录和预问诊辅助服务。
|
||||
你是患者端统一 AI 健康助手“小脉”。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的健康解释、数据记录、用药管理、运动管理和预问诊辅助。
|
||||
|
||||
核心规则:首先理解用户意图属于哪个领域,然后调用对应的工具。
|
||||
核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称“已录入”“已保存”或“请点击确认”,实际却没有调用写入工具并生成确认卡片。
|
||||
|
||||
领域判断与可用工具:
|
||||
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
||||
@@ -182,10 +182,18 @@ public sealed class PromptManager
|
||||
|
||||
规则:
|
||||
- 用户可能一句话涉及多个领域,全部处理
|
||||
- 数值明确+指标明确→直接生成待确认命令,不要追问
|
||||
- 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入
|
||||
- 明确提问时先回答问题,不调用 record_health_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录
|
||||
- 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令
|
||||
- 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录
|
||||
- 指标或数值缺失时只追问缺失部分,不猜测数值
|
||||
- 多个明确指标要逐项调用 record_health_data,不能遗漏
|
||||
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||
- 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片
|
||||
- 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊
|
||||
- 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温暖、专业
|
||||
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||
""";
|
||||
}
|
||||
|
||||
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
1477
backend/src/Health.Infrastructure/Data/Migrations/20260713132830_AddHealthArchiveSurgeryStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Health.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHealthArchiveSurgeryStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SurgeryHistoryStatus",
|
||||
table: "HealthArchives",
|
||||
type: "character varying(8)",
|
||||
maxLength: 8,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SurgeryHistoryStatus",
|
||||
table: "HealthArchives");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -486,6 +486,10 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.Property<DateOnly?>("SurgeryDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("SurgeryHistoryStatus")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("SurgeryType")
|
||||
.HasColumnType("text");
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
builder.Entity<HealthArchive>(e =>
|
||||
{
|
||||
e.HasIndex(a => a.UserId).IsUnique();
|
||||
e.Property(a => a.SurgeryHistoryStatus).HasMaxLength(8);
|
||||
});
|
||||
|
||||
builder.Entity<HealthArchiveSurgery>(e =>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
using Health.Application.Notifications;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Infrastructure.Notifications;
|
||||
|
||||
public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public async Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct)
|
||||
{
|
||||
var pref = await GetPreferenceAsync(userId, ct);
|
||||
if (!pref.PushEnabled) return new ReminderCatchUpResult(0);
|
||||
|
||||
var beijingNow = utcNow.AddHours(8);
|
||||
var today = DateOnly.FromDateTime(beijingNow);
|
||||
var todayStartUtc = beijingNow.Date.AddHours(-8);
|
||||
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||
var created = 0;
|
||||
|
||||
created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct);
|
||||
created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct);
|
||||
created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct);
|
||||
created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct);
|
||||
|
||||
if (created > 0)
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return new ReminderCatchUpResult(created);
|
||||
}
|
||||
|
||||
private async Task<NotificationPreference> GetPreferenceAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct);
|
||||
if (pref != null) return pref;
|
||||
|
||||
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||
await _db.NotificationPreferences.AddAsync(pref, ct);
|
||||
return pref;
|
||||
}
|
||||
|
||||
private async Task<int> CreateHealthRecordReminderAsync(
|
||||
Guid userId,
|
||||
NotificationPreference pref,
|
||||
DateTime beijingNow,
|
||||
DateTime todayStartUtc,
|
||||
DateTime todayEndUtc,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||
var window = HealthRecordWindow(beijingNow);
|
||||
if (window == null) return 0;
|
||||
|
||||
var recordedTypes = await _db.HealthRecords.AsNoTracking()
|
||||
.Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc)
|
||||
.Select(r => r.MetricType)
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var missing = EnabledHealthMetrics(pref)
|
||||
.Where(x => !recordedTypes.Contains(x.Type))
|
||||
.ToList();
|
||||
if (missing.Count == 0) return 0;
|
||||
|
||||
var date = DateOnly.FromDateTime(beijingNow);
|
||||
var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}");
|
||||
return await AddNotificationIfMissingAsync(
|
||||
userId,
|
||||
sourceId,
|
||||
"health_record_reminder",
|
||||
window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标",
|
||||
$"建议录入:{string.Join("、", missing.Select(x => x.Label))}",
|
||||
"info",
|
||||
"health",
|
||||
null,
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task<int> CreateMedicationRemindersAsync(
|
||||
Guid userId,
|
||||
NotificationPreference pref,
|
||||
DateTime beijingNow,
|
||||
DateOnly today,
|
||||
DateTime todayStartUtc,
|
||||
DateTime todayEndUtc,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||
var now = TimeOnly.FromDateTime(beijingNow);
|
||||
var earliest = now.AddHours(-4);
|
||||
var medications = await _db.Medications.AsNoTracking()
|
||||
.Where(m => m.UserId == userId && m.IsActive)
|
||||
.ToListAsync(ct);
|
||||
var created = 0;
|
||||
|
||||
foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today)))
|
||||
{
|
||||
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now)))
|
||||
{
|
||||
var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l =>
|
||||
l.UserId == userId &&
|
||||
l.MedicationId == medication.Id &&
|
||||
l.ScheduledTime == scheduledTime &&
|
||||
l.CreatedAt >= todayStartUtc &&
|
||||
l.CreatedAt < todayEndUtc, ct);
|
||||
if (logged) continue;
|
||||
|
||||
var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}");
|
||||
var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
created += await AddNotificationIfMissingAsync(
|
||||
userId,
|
||||
sourceId,
|
||||
"MedicationReminder",
|
||||
"用药时间到了",
|
||||
$"请按计划服用{medication.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。",
|
||||
"info",
|
||||
"medication",
|
||||
medication.Id.ToString(),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<int> CreateExerciseRemindersAsync(
|
||||
Guid userId,
|
||||
NotificationPreference pref,
|
||||
DateTime beijingNow,
|
||||
DateOnly today,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (IsInDnd(pref, beijingNow)) return 0;
|
||||
var now = TimeOnly.FromDateTime(beijingNow);
|
||||
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
|
||||
.Include(x => x.Plan)
|
||||
.Where(x => x.Plan.UserId == userId &&
|
||||
x.ScheduledDate == today &&
|
||||
!x.IsCompleted &&
|
||||
!x.IsRestDay &&
|
||||
x.Plan.ReminderTime <= now)
|
||||
.ToListAsync(ct);
|
||||
var created = 0;
|
||||
|
||||
foreach (var item in dueItems)
|
||||
{
|
||||
var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}");
|
||||
created += await AddNotificationIfMissingAsync(
|
||||
userId,
|
||||
sourceId,
|
||||
"ExerciseReminder",
|
||||
"今日运动计划",
|
||||
$"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
|
||||
"info",
|
||||
"exercise",
|
||||
item.PlanId.ToString(),
|
||||
ct);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<int> CreateFollowUpRemindersAsync(
|
||||
Guid userId,
|
||||
NotificationPreference pref,
|
||||
DateTime beijingNow,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0;
|
||||
var nowUtc = beijingNow.AddHours(-8);
|
||||
var upcomingEndUtc = nowUtc.AddHours(24);
|
||||
var followUps = await _db.FollowUps.AsNoTracking()
|
||||
.Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc)
|
||||
.ToListAsync(ct);
|
||||
var created = 0;
|
||||
|
||||
foreach (var followUp in followUps)
|
||||
{
|
||||
var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h");
|
||||
created += await AddNotificationIfMissingAsync(
|
||||
userId,
|
||||
sourceId,
|
||||
"follow_up_reminder",
|
||||
"复查提醒",
|
||||
$"{followUp.Title} 即将到期,请提前安排时间。",
|
||||
"info",
|
||||
"followup",
|
||||
followUp.Id.ToString(),
|
||||
ct);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<int> AddNotificationIfMissingAsync(
|
||||
Guid userId,
|
||||
Guid sourceId,
|
||||
string type,
|
||||
string title,
|
||||
string message,
|
||||
string severity,
|
||||
string? actionType,
|
||||
string? actionTargetId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct))
|
||||
return 0;
|
||||
|
||||
await _db.UserNotifications.AddAsync(new UserNotification
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
SourceId = sourceId,
|
||||
Type = type,
|
||||
Title = title,
|
||||
Message = message,
|
||||
Severity = severity,
|
||||
ActionType = actionType,
|
||||
ActionTargetId = actionTargetId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, ct);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static string? HealthRecordWindow(DateTime beijingNow)
|
||||
{
|
||||
var minutes = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||
if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning";
|
||||
if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon";
|
||||
if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref)
|
||||
{
|
||||
var result = new List<(HealthMetricType, string)>();
|
||||
if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压"));
|
||||
if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率"));
|
||||
if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖"));
|
||||
if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧"));
|
||||
if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重"));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow)
|
||||
{
|
||||
if (!pref.DndEnabled) return false;
|
||||
var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute;
|
||||
return pref.DndStartMinutes <= pref.DndEndMinutes
|
||||
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
|
||||
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes;
|
||||
}
|
||||
|
||||
private static bool IsMedicationActiveOn(Medication medication, DateOnly date) =>
|
||||
(medication.StartDate == null || medication.StartDate <= date) &&
|
||||
(medication.EndDate == null || medication.EndDate >= date);
|
||||
|
||||
private static bool ShouldDoseToday(Medication medication, DateOnly today)
|
||||
{
|
||||
if (medication.Frequency is MedicationFrequency.Daily
|
||||
or MedicationFrequency.TwiceDaily
|
||||
or MedicationFrequency.ThreeTimesDaily
|
||||
or MedicationFrequency.AsNeeded)
|
||||
return true;
|
||||
|
||||
var startDate = medication.StartDate ?? today;
|
||||
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
|
||||
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
|
||||
if (medication.Frequency == MedicationFrequency.Weekly)
|
||||
return today.DayOfWeek == startDate.DayOfWeek;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
|
||||
end >= start ? value >= start && value <= end : value >= start || value <= end;
|
||||
|
||||
private static Guid StableGuid(string key)
|
||||
{
|
||||
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificat
|
||||
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;
|
||||
|
||||
@@ -67,7 +73,7 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
||||
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
|
||||
.SetProperty(x => x.LastError, "Notification task timed out and reached max retry count")
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
@@ -81,61 +87,55 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
||||
.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;
|
||||
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>(candidate.Payload)
|
||||
?? throw new InvalidOperationException("通知消息内容为空");
|
||||
var message = JsonSerializer.Deserialize<NotificationMessage>(task.Payload)
|
||||
?? throw new InvalidOperationException("Notification payload is empty");
|
||||
|
||||
var preference = await _db.NotificationPreferences.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.UserId == candidate.UserId, ct);
|
||||
.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 == candidate.SourceTaskId, ct))
|
||||
.AnyAsync(x => x.SourceId == task.SourceTaskId, ct))
|
||||
{
|
||||
await _db.UserNotifications.AddAsync(new UserNotification
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = candidate.UserId,
|
||||
SourceId = candidate.SourceTaskId,
|
||||
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 = candidate.CreatedAt,
|
||||
CreatedAt = task.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);
|
||||
task.Status = "Completed";
|
||||
task.LastError = null;
|
||||
task.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(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);
|
||||
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;
|
||||
@@ -151,14 +151,25 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
|
||||
: minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes);
|
||||
if (inDnd) return false;
|
||||
|
||||
return type switch
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,44 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
||||
public Task<User?> GetAsync(Guid userId, CancellationToken ct) =>
|
||||
_db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||
|
||||
public async Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var fileUrls = new List<string>();
|
||||
|
||||
var userAvatar = await _db.Users
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u => u.AvatarUrl)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
AddIfPresent(fileUrls, userAvatar);
|
||||
|
||||
var doctorProfile = await _db.DoctorProfiles
|
||||
.Where(p => p.UserId == userId)
|
||||
.Select(p => new { p.DoctorId, p.AvatarUrl })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
AddIfPresent(fileUrls, doctorProfile?.AvatarUrl);
|
||||
|
||||
if (doctorProfile?.DoctorId != null)
|
||||
{
|
||||
var doctorAvatar = await _db.Doctors
|
||||
.Where(d => d.Id == doctorProfile.DoctorId)
|
||||
.Select(d => d.AvatarUrl)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
AddIfPresent(fileUrls, doctorAvatar);
|
||||
}
|
||||
|
||||
fileUrls.AddRange(await _db.Reports
|
||||
.Where(r => r.UserId == userId && r.FileUrl != "")
|
||||
.Select(r => r.FileUrl)
|
||||
.ToListAsync(ct));
|
||||
|
||||
var metadata = await _db.ConversationMessages
|
||||
.Where(m => m.Conversation!.UserId == userId && m.MetadataJson != null && m.MetadataJson != "")
|
||||
.Select(m => m.MetadataJson!)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new AccountFileReferences(fileUrls.Distinct().ToList(), metadata);
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct) =>
|
||||
_db.SaveChangesAsync(ct);
|
||||
|
||||
@@ -50,4 +88,9 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
private static void AddIfPresent(List<string> values, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)) values.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.Users;
|
||||
|
||||
namespace Health.Infrastructure.Users;
|
||||
|
||||
public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCleanup
|
||||
{
|
||||
private readonly string _uploadsRoot = Path.GetFullPath(uploadsRoot);
|
||||
|
||||
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||
{
|
||||
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||
AddMetadataUrls(fileUrls, metadataJson);
|
||||
|
||||
foreach (var fileUrl in fileUrls)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var localPath = ResolveLocalPath(fileUrl);
|
||||
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var userDirectory = Path.Combine(_uploadsRoot, "users", userId.ToString("N"));
|
||||
if (Directory.Exists(userDirectory)) Directory.Delete(userDirectory, recursive: true);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void AddMetadataUrls(HashSet<string> fileUrls, string metadataJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(metadataJson);
|
||||
AddStringProperty(document.RootElement, "imageUrl", fileUrls);
|
||||
AddStringProperty(document.RootElement, "pdfUrl", fileUrls);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 旧消息元数据可能不是合法 JSON;不影响其余账号数据注销。
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddStringProperty(JsonElement root, string propertyName, HashSet<string> fileUrls)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object ||
|
||||
!root.TryGetProperty(propertyName, out var property) ||
|
||||
property.ValueKind != JsonValueKind.String)
|
||||
return;
|
||||
|
||||
var value = property.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
|
||||
}
|
||||
|
||||
private string? ResolveLocalPath(string fileUrl)
|
||||
{
|
||||
var urlPath = fileUrl;
|
||||
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
|
||||
urlPath = absoluteUri.AbsolutePath;
|
||||
|
||||
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
|
||||
if (uploadsIndex < 0) return null;
|
||||
|
||||
string relativePath;
|
||||
try
|
||||
{
|
||||
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]);
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var queryIndex = relativePath.IndexOfAny(['?', '#']);
|
||||
if (queryIndex >= 0) relativePath = relativePath[..queryIndex];
|
||||
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath));
|
||||
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user