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:
@@ -35,7 +35,7 @@ public interface IAiConversationService
|
||||
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Health.Application.AI;
|
||||
|
||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||
{
|
||||
private const int HistoryConversationLimit = 7;
|
||||
private const int HistoryConversationLimit = 30;
|
||||
private readonly IAiConversationRepository _conversations = conversations;
|
||||
|
||||
public async Task<OpenConversationResult> OpenAsync(
|
||||
@@ -83,13 +83,14 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||
{
|
||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||
if (conversation == null) return;
|
||||
if (conversation == null) return false;
|
||||
|
||||
_conversations.Delete(conversation);
|
||||
await _conversations.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
||||
|
||||
@@ -24,7 +24,9 @@ public sealed class PatientContextService(
|
||||
if (archive != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||
if (archive.Surgeries.Count > 0)
|
||||
if (archive.SurgeryHistoryStatus == "无")
|
||||
sb.AppendLine("手术史: 无");
|
||||
else 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})");
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed record HealthArchiveDto(
|
||||
IReadOnlyList<string> DietRestrictions,
|
||||
IReadOnlyList<string> ChronicDiseases,
|
||||
string? FamilyHistory,
|
||||
string? SurgeryHistoryStatus,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record HealthArchiveUpdateRequest(
|
||||
@@ -32,7 +33,8 @@ public sealed record HealthArchiveUpdateRequest(
|
||||
IReadOnlyList<string>? DietRestrictions,
|
||||
IReadOnlyList<string>? ChronicDiseases,
|
||||
string? FamilyHistory,
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null,
|
||||
string? SurgeryHistoryStatus = null);
|
||||
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Health.Domain.Entities;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Health.Application.HealthArchives;
|
||||
|
||||
public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IHealthArchiveService
|
||||
@@ -14,6 +16,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
|
||||
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
Validate(request);
|
||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||
Apply(archive, request);
|
||||
archive.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -110,6 +113,14 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
||||
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
||||
if (request.FamilyHistory != null) archive.FamilyHistory = request.FamilyHistory;
|
||||
if (request.SurgeryHistoryStatus != null)
|
||||
archive.SurgeryHistoryStatus = request.SurgeryHistoryStatus;
|
||||
}
|
||||
|
||||
private static void Validate(HealthArchiveUpdateRequest request)
|
||||
{
|
||||
if (request.SurgeryHistoryStatus is not null and not ("无" or "有"))
|
||||
throw new ValidationException("手术史状态格式不正确");
|
||||
}
|
||||
|
||||
private static object ToAiResult(HealthArchiveDto archive) => new
|
||||
@@ -123,6 +134,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
archive.SurgeryHistoryStatus,
|
||||
};
|
||||
|
||||
private static HealthArchiveDto ToDto(HealthArchive archive) => new(
|
||||
@@ -136,6 +148,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
archive.SurgeryHistoryStatus,
|
||||
archive.UpdatedAt);
|
||||
|
||||
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
||||
|
||||
@@ -27,6 +27,7 @@ public interface IHealthRecordService
|
||||
{
|
||||
Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<IReadOnlyList<Guid>> CreateManyAsync(Guid userId, IReadOnlyList<HealthRecordUpsertRequest> requests, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct);
|
||||
Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
|
||||
@@ -49,6 +49,44 @@ public sealed class HealthRecordService(
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Guid>> CreateManyAsync(
|
||||
Guid userId,
|
||||
IReadOnlyList<HealthRecordUpsertRequest> requests,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (requests.Count == 0)
|
||||
throw new ArgumentException("至少需要一条健康记录", nameof(requests));
|
||||
|
||||
foreach (var request in requests)
|
||||
HealthRecordRules.Validate(request);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var records = requests.Select(request => new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MetricType = request.Type,
|
||||
Systolic = request.Systolic,
|
||||
Diastolic = request.Diastolic,
|
||||
Value = request.Value,
|
||||
Unit = request.Unit,
|
||||
Source = request.Source,
|
||||
RecordedAt = request.RecordedAt ?? now,
|
||||
CreatedAt = now,
|
||||
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
|
||||
}).ToList();
|
||||
|
||||
foreach (var record in records)
|
||||
await _records.AddAsync(record, ct);
|
||||
|
||||
await _records.SaveChangesAsync(ct);
|
||||
|
||||
foreach (var record in records)
|
||||
await EnqueueAbnormalNotificationAsync(record, ct);
|
||||
|
||||
return records.Select(record => record.Id).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Guid userId, Guid id, HealthRecordUpsertRequest request, CancellationToken ct)
|
||||
{
|
||||
var record = await _records.GetOwnedAsync(userId, id, ct);
|
||||
|
||||
@@ -20,7 +20,8 @@ public sealed record MedicationPatchRequest(
|
||||
IReadOnlyList<TimeOnly>? TimeOfDay,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
bool ClearEndDate = false);
|
||||
|
||||
public sealed record MedicationDto(
|
||||
Guid Id,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Application.Medications;
|
||||
|
||||
public static class MedicationScheduleStatus
|
||||
{
|
||||
public static string Resolve(
|
||||
TimeOnly scheduledTime,
|
||||
TimeOnly currentTime,
|
||||
MedicationLogStatus? logStatus)
|
||||
{
|
||||
if (logStatus.HasValue)
|
||||
return logStatus == MedicationLogStatus.Taken ? "taken" : "skipped";
|
||||
if (scheduledTime <= currentTime.AddMinutes(-30)) return "overdue";
|
||||
if (scheduledTime <= currentTime.AddHours(3)) return "upcoming";
|
||||
return "scheduled";
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,8 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
if (request.Frequency.HasValue) med.Frequency = request.Frequency.Value;
|
||||
if (request.TimeOfDay != null) med.TimeOfDay = NormalizeTimes(request.TimeOfDay);
|
||||
if (request.StartDate.HasValue) med.StartDate = request.StartDate.Value;
|
||||
if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||
if (request.ClearEndDate) med.EndDate = null;
|
||||
else if (request.EndDate.HasValue) med.EndDate = request.EndDate.Value;
|
||||
ValidateDateRange(med.StartDate, med.EndDate);
|
||||
if (request.Notes != null) med.Notes = request.Notes;
|
||||
med.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -130,15 +131,10 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
foreach (var scheduledTime in med.TimeOfDay)
|
||||
{
|
||||
var log = logs.FirstOrDefault(l => l.MedicationId == med.Id && l.ScheduledTime == scheduledTime);
|
||||
string status;
|
||||
if (log != null)
|
||||
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
|
||||
else if (scheduledTime <= now.AddMinutes(-30))
|
||||
status = "overdue";
|
||||
else if (scheduledTime <= now.AddHours(3))
|
||||
status = "upcoming";
|
||||
else
|
||||
continue;
|
||||
var status = MedicationScheduleStatus.Resolve(
|
||||
scheduledTime,
|
||||
now,
|
||||
log?.Status);
|
||||
|
||||
reminders.Add(new MedicationReminderDto(med.Id, med.Name, med.Dosage, scheduledTime.ToString("HH:mm"), status));
|
||||
}
|
||||
|
||||
@@ -58,3 +58,10 @@ public interface INotificationOutboxProcessor
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<bool> ProcessNextAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record ReminderCatchUpResult(int CreatedCount);
|
||||
|
||||
public interface IReminderCatchUpService
|
||||
{
|
||||
Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed record UserProfileUpdateRequest(
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
|
||||
public sealed record AccountFileReferences(
|
||||
IReadOnlyList<string> FileUrls,
|
||||
IReadOnlyList<string> ConversationMetadataJson);
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct);
|
||||
@@ -26,6 +30,12 @@ public interface IUserService
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct);
|
||||
Task SaveChangesAsync(CancellationToken ct);
|
||||
Task DeleteAccountDataAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IAccountFileCleanup
|
||||
{
|
||||
Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
namespace Health.Application.Users;
|
||||
|
||||
public sealed class UserService(IUserRepository users) : IUserService
|
||||
public sealed class UserService(
|
||||
IUserRepository users,
|
||||
IAccountFileCleanup fileCleanup) : IUserService
|
||||
{
|
||||
private readonly IUserRepository _users = users;
|
||||
private readonly IAccountFileCleanup _fileCleanup = fileCleanup;
|
||||
|
||||
public async Task<UserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
@@ -30,6 +33,10 @@ public sealed class UserService(IUserRepository users) : IUserService
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task DeleteAccountAsync(Guid userId, CancellationToken ct) =>
|
||||
_users.DeleteAccountDataAsync(userId, ct);
|
||||
public async Task DeleteAccountAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var references = await _users.GetAccountFileReferencesAsync(userId, ct);
|
||||
await _fileCleanup.DeleteAsync(userId, references, ct);
|
||||
await _users.DeleteAccountDataAsync(userId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class HealthArchive
|
||||
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
||||
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
||||
public string? FamilyHistory { get; set; } // 家族病史
|
||||
public string? SurgeryHistoryStatus { get; set; } // 无/有,区分未填写与无手术史
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +1,66 @@
|
||||
using Health.Application.Notifications;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
||||
/// 对未录入指标且开关开启的用户推送提醒。
|
||||
/// 中国时区 UTC+8。
|
||||
/// Produces in-app catch-up reminders for health records.
|
||||
/// The actual reminder rules live in IReminderCatchUpService so foreground
|
||||
/// app checks and background scans stay consistent.
|
||||
/// </summary>
|
||||
public sealed class HealthRecordReminderService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
||||
|
||||
// 北京时间 9:00 和 12:00 触发
|
||||
private static readonly int[] ReminderHoursCst = [9, 12];
|
||||
|
||||
private static readonly int[] ReminderHoursCst = [9, 13, 19];
|
||||
private DateTime _lastFired = DateTime.MinValue;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("健康录入提醒服务已启动");
|
||||
logger.LogInformation("Health record in-app reminder service started");
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||
// 当前小时是否在触发列表,且今天该小时还没触发过
|
||||
if (ReminderHoursCst.Contains(nowCst.Hour)
|
||||
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||
if (ReminderHoursCst.Contains(nowCst.Hour) &&
|
||||
(_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||
{
|
||||
await ScanAndPushAsync(nowCst, stoppingToken);
|
||||
await CheckUsersAsync(DateTime.UtcNow, stoppingToken);
|
||||
_lastFired = nowCst;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Health record in-app reminder scan failed");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
|
||||
private async Task CheckUsersAsync(DateTime utcNow, CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
||||
var catchUp = scope.ServiceProvider.GetRequiredService<IReminderCatchUpService>();
|
||||
|
||||
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
|
||||
// 取所有启用了健康录入提醒的用户偏好
|
||||
var prefs = await db.NotificationPreferences
|
||||
.Where(p => p.PushEnabled && p.HealthRecordReminder)
|
||||
var userIds = await db.NotificationPreferences.AsNoTracking()
|
||||
.Where(x => x.PushEnabled && x.HealthRecordReminder)
|
||||
.Select(x => x.UserId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var pref in prefs)
|
||||
var created = 0;
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute;
|
||||
var inDnd = pref.DndEnabled && (pref.DndStartMinutes <= pref.DndEndMinutes
|
||||
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
|
||||
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes);
|
||||
if (inDnd) continue;
|
||||
// 该用户当天已录入的指标
|
||||
var recordedTypes = await db.HealthRecords
|
||||
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
|
||||
.Select(r => r.MetricType)
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 哪些指标启用开关但当天没录入
|
||||
var missing = new List<(HealthMetricType type, bool enabled, string label)>
|
||||
{
|
||||
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
|
||||
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
|
||||
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
|
||||
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
|
||||
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
|
||||
};
|
||||
|
||||
var missingLabels = missing
|
||||
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
|
||||
.Select(m => m.label)
|
||||
.ToList();
|
||||
|
||||
if (missingLabels.Count == 0) continue;
|
||||
|
||||
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
|
||||
var sourceId = MakeSourceId(pref.UserId, nowCst);
|
||||
var summary = string.Join("、", missingLabels);
|
||||
|
||||
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
|
||||
Type: "health_record_reminder",
|
||||
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
|
||||
Message: $"建议录入:{summary}",
|
||||
Severity: "info",
|
||||
ActionType: "health",
|
||||
ActionTargetId: null), ct);
|
||||
var result = await catchUp.CheckDueAsync(userId, utcNow, ct);
|
||||
created += result.CreatedCount;
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
||||
{
|
||||
// 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复
|
||||
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
|
||||
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||
return new Guid(bytes);
|
||||
if (created > 0)
|
||||
logger.LogInformation("Created {Count} in-app catch-up reminders", created);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,8 +321,12 @@ public static class AiChatEndpoints
|
||||
var userId = GetUserId(http);
|
||||
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||
|
||||
await conversations.DeleteAsync(userId.Value, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var deleted = await conversations.DeleteAsync(userId.Value, id, ct);
|
||||
return deleted
|
||||
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||
: Results.Json(
|
||||
new { code = 40401, data = (object?)null, message = "对话不存在" },
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
});
|
||||
|
||||
// 一键清空当前用户的全部对话
|
||||
|
||||
@@ -29,6 +29,20 @@ public static class DoctorEndpoints
|
||||
return profile?.DoctorId;
|
||||
}
|
||||
|
||||
public static (DateTime StartUtc, DateTime EndUtc) GetBeijingDayRange(DateTime utcNow)
|
||||
{
|
||||
var beijingNow = utcNow.ToUniversalTime().AddHours(8);
|
||||
var startUtc = new DateTime(
|
||||
beijingNow.Year,
|
||||
beijingNow.Month,
|
||||
beijingNow.Day,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
DateTimeKind.Utc).AddHours(-8);
|
||||
return (startUtc, startUtc.AddDays(1));
|
||||
}
|
||||
|
||||
public static void MapDoctorEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
||||
@@ -56,8 +70,7 @@ public static class DoctorEndpoints
|
||||
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
|
||||
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
|
||||
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
|
||||
var todayStart = DateTime.UtcNow.Date;
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
var (todayStart, todayEnd) = GetBeijingDayRange(DateTime.UtcNow);
|
||||
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
||||
|
||||
// 待办列表
|
||||
@@ -190,7 +203,8 @@ public static class DoctorEndpoints
|
||||
user.HealthArchive.Allergies,
|
||||
user.HealthArchive.DietRestrictions,
|
||||
user.HealthArchive.ChronicDiseases,
|
||||
user.HealthArchive.FamilyHistory
|
||||
user.HealthArchive.FamilyHistory,
|
||||
user.HealthArchive.SurgeryHistoryStatus
|
||||
},
|
||||
latestRecords,
|
||||
trendRecords,
|
||||
|
||||
@@ -57,8 +57,10 @@ public static class ExerciseEndpoints
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
await exercises.DeleteAsync(userId, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var deleted = await exercises.DeleteAsync(userId, id, ct);
|
||||
return deleted
|
||||
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||
: Results.Ok(new { code = 40004, data = (object?)null, message = "运动计划不存在" });
|
||||
});
|
||||
|
||||
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||
|
||||
@@ -12,8 +12,9 @@ public static class FileEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/files").RequireAuthorization();
|
||||
|
||||
group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) =>
|
||||
group.MapPost("/upload", async (HttpContext http, CancellationToken ct) =>
|
||||
{
|
||||
var request = http.Request;
|
||||
if (!request.HasFormContentType)
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
|
||||
|
||||
@@ -23,7 +24,12 @@ public static class FileEndpoints
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
|
||||
|
||||
var results = new List<object>();
|
||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
var userDirectoryName = GetUserId(http).ToString("N");
|
||||
var uploadsDir = Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"uploads",
|
||||
"users",
|
||||
userDirectoryName);
|
||||
Directory.CreateDirectory(uploadsDir);
|
||||
|
||||
foreach (var file in files)
|
||||
@@ -48,7 +54,7 @@ public static class FileEndpoints
|
||||
id = fileId,
|
||||
name = file.FileName,
|
||||
size = file.Length,
|
||||
url = $"/uploads/{storedName}",
|
||||
url = $"/uploads/users/{userDirectoryName}/{storedName}",
|
||||
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
||||
});
|
||||
}
|
||||
@@ -56,4 +62,9 @@ public static class FileEndpoints
|
||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
|
||||
? id
|
||||
: Guid.Empty;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,20 @@ public static class HealthEndpoints
|
||||
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/batch", async (
|
||||
IReadOnlyList<CreateHealthRecordRequest> requests,
|
||||
HttpContext http,
|
||||
IHealthRecordService healthRecords,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var ids = await healthRecords.CreateManyAsync(
|
||||
userId,
|
||||
requests.Select(request => request.ToServiceRequest()).ToList(),
|
||||
ct);
|
||||
return Results.Ok(new { code = 0, data = new { ids }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async (
|
||||
Guid id,
|
||||
CreateHealthRecordRequest req,
|
||||
|
||||
@@ -77,8 +77,10 @@ public static class MedicationEndpoints
|
||||
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var cancelled = await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
||||
return cancelled
|
||||
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||
: Results.Ok(new { code = 40004, data = (object?)null, message = "未找到对应的服药打卡记录" });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,14 +114,19 @@ public static class MedicationEndpoints
|
||||
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
||||
frequency = freq;
|
||||
|
||||
var hasEndDate = root.TryGetProperty("endDate", out var ed);
|
||||
var clearEndDate = hasEndDate &&
|
||||
(ed.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(ed.GetString()));
|
||||
|
||||
return new MedicationPatchRequest(
|
||||
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
||||
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||
frequency,
|
||||
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
||||
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
||||
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
||||
hasEndDate && !clearEndDate && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null,
|
||||
clearEndDate);
|
||||
}
|
||||
|
||||
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
||||
|
||||
@@ -19,6 +19,14 @@ public static class NotificationEndpoints
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/check-due", async (HttpContext http, IReminderCatchUpService reminders, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||
var result = await reminders.CheckDueAsync(userId, DateTime.UtcNow, ct);
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
@@ -104,7 +112,7 @@ public static class NotificationEndpoints
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
|
||||
if (body.HealthRecordReminder is true && pref.HealthRecordReminder)
|
||||
if (body.HealthRecordReminder is true && pref.PushEnabled && pref.HealthRecordReminder)
|
||||
{
|
||||
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||
var todayStart = nowCst.Date.AddHours(-8);
|
||||
|
||||
@@ -49,7 +49,8 @@ public static class UserEndpoints
|
||||
req.DietRestrictions,
|
||||
req.ChronicDiseases,
|
||||
req.FamilyHistory,
|
||||
surgeries),
|
||||
surgeries,
|
||||
req.SurgeryHistoryStatus),
|
||||
ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
@@ -70,6 +71,7 @@ public sealed record UpdateArchiveRequest(
|
||||
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
||||
List<string>? Allergies, List<string>? DietRestrictions,
|
||||
List<string>? ChronicDiseases, string? FamilyHistory,
|
||||
List<UpdateArchiveSurgeryRequest>? Surgeries);
|
||||
List<UpdateArchiveSurgeryRequest>? Surgeries,
|
||||
string? SurgeryHistoryStatus);
|
||||
|
||||
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
||||
|
||||
@@ -142,6 +142,7 @@ builder.Services.AddScoped<IInAppNotificationService, InAppNotificationService>(
|
||||
builder.Services.AddScoped<IInAppNotificationRepository, EfInAppNotificationRepository>();
|
||||
builder.Services.AddScoped<IUserNotificationProducer, EfUserNotificationProducer>();
|
||||
builder.Services.AddScoped<INotificationOutboxProcessor, EfNotificationOutboxProcessor>();
|
||||
builder.Services.AddScoped<IReminderCatchUpService, EfReminderCatchUpService>();
|
||||
builder.Services.AddScoped<IReportService, ReportService>();
|
||||
builder.Services.AddScoped<IReportRepository, EfReportRepository>();
|
||||
builder.Services.AddScoped<IReportFileStorage, LocalReportFileStorage>();
|
||||
@@ -149,6 +150,8 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
|
||||
|
||||
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
||||
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||
|
||||
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
120
backend/tests/Health.Tests/account_deletion_tests.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Health.Application.Users;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Infrastructure.Users;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class AccountDeletionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LocalCleanup_DeletesOwnedUploadsWithoutTouchingOtherFiles()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"health-account-delete-{Guid.NewGuid():N}");
|
||||
var userId = Guid.NewGuid();
|
||||
var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
|
||||
var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
|
||||
var chatImagePath = Path.Combine(root, "owned-chat.jpg");
|
||||
var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.jpg");
|
||||
var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(userDirectory);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(otherUserPath)!);
|
||||
await File.WriteAllTextAsync(Path.Combine(userDirectory, "unlinked-upload.jpg"), "owned");
|
||||
await File.WriteAllTextAsync(reportPath, "owned");
|
||||
await File.WriteAllTextAsync(chatImagePath, "owned");
|
||||
await File.WriteAllTextAsync(otherUserPath, "other");
|
||||
await File.WriteAllTextAsync(outsidePath, "outside");
|
||||
|
||||
var cleanup = new LocalAccountFileCleanup(root);
|
||||
var references = new AccountFileReferences(
|
||||
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"],
|
||||
["{\"imageUrl\":\"/uploads/owned-chat.jpg\"}"]);
|
||||
|
||||
await cleanup.DeleteAsync(userId, references, CancellationToken.None);
|
||||
|
||||
Assert.False(Directory.Exists(userDirectory));
|
||||
Assert.False(File.Exists(reportPath));
|
||||
Assert.False(File.Exists(chatImagePath));
|
||||
Assert.True(File.Exists(otherUserPath));
|
||||
Assert.True(File.Exists(outsidePath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
if (File.Exists(outsidePath)) File.Delete(outsidePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserService_CleansFilesBeforeDeletingDatabaseData()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var references = new AccountFileReferences(["/uploads/reports/report.pdf"], []);
|
||||
var repository = new FakeUserRepository(references, calls);
|
||||
var cleanup = new FakeAccountFileCleanup(calls);
|
||||
var service = new UserService(repository, cleanup);
|
||||
|
||||
await service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(["references", "files", "database"], calls);
|
||||
Assert.Same(references, cleanup.ReceivedReferences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserService_DoesNotDeleteDatabaseWhenFileCleanupFails()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var repository = new FakeUserRepository(new AccountFileReferences([], []), calls);
|
||||
var service = new UserService(repository, new FailingAccountFileCleanup(calls));
|
||||
|
||||
await Assert.ThrowsAsync<IOException>(() =>
|
||||
service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None));
|
||||
|
||||
Assert.Equal(["references", "files"], calls);
|
||||
}
|
||||
|
||||
private sealed class FakeUserRepository(
|
||||
AccountFileReferences references,
|
||||
List<string> calls) : IUserRepository
|
||||
{
|
||||
public Task<User?> GetAsync(Guid userId, CancellationToken ct) => Task.FromResult<User?>(null);
|
||||
|
||||
public Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
calls.Add("references");
|
||||
return Task.FromResult(references);
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DeleteAccountDataAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
calls.Add("database");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
||||
{
|
||||
public AccountFileReferences? ReceivedReferences { get; private set; }
|
||||
|
||||
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||
{
|
||||
calls.Add("files");
|
||||
ReceivedReferences = references;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
||||
{
|
||||
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||
{
|
||||
calls.Add("files");
|
||||
throw new IOException("file is locked");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,8 @@ public sealed class ApplicationServiceTests
|
||||
var userId = Guid.NewGuid();
|
||||
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
});
|
||||
var service = new HealthArchiveService(repository);
|
||||
|
||||
@@ -75,14 +76,35 @@ public sealed class ApplicationServiceTests
|
||||
Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthRecords_BatchPersistsAllReadingsWithOneSave()
|
||||
{
|
||||
var repository = new CapturingHealthRecordRepository();
|
||||
var service = new HealthRecordService(repository);
|
||||
|
||||
var ids = await service.CreateManyAsync(
|
||||
Guid.NewGuid(),
|
||||
[
|
||||
new HealthRecordUpsertRequest(HealthMetricType.BloodPressure, 116, 89, null, "mmHg", HealthRecordSource.DeviceSync, DateTime.UtcNow),
|
||||
new HealthRecordUpsertRequest(HealthMetricType.HeartRate, null, null, 72, "bpm", HealthRecordSource.DeviceSync, DateTime.UtcNow),
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, ids.Count);
|
||||
Assert.Equal(2, repository.Added.Count);
|
||||
Assert.Equal(1, repository.SaveCount);
|
||||
}
|
||||
|
||||
[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),
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
SurgeryType = "Legacy surgery",
|
||||
SurgeryDate = new DateOnly(2010, 1, 2),
|
||||
});
|
||||
var service = new HealthArchiveService(repository);
|
||||
|
||||
@@ -110,13 +132,71 @@ public sealed class ApplicationServiceTests
|
||||
Assert.False(result.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Conversation_List_ReturnsAtMostThirtyRecentItems()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var conversations = Enumerable.Range(0, 35)
|
||||
.Select(index => new Conversation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
AgentType = AgentType.Unified,
|
||||
UpdatedAt = DateTime.UtcNow.AddMinutes(-index),
|
||||
})
|
||||
.ToList();
|
||||
var service = new AiConversationService(new ListConversationRepository(conversations));
|
||||
|
||||
var result = await service.ListAsync(userId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(30, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conversation_Delete_ReportsWhetherARecordWasActuallyDeleted()
|
||||
{
|
||||
var method = typeof(IAiConversationService).GetMethod(nameof(IAiConversationService.DeleteAsync));
|
||||
|
||||
Assert.NotNull(method);
|
||||
Assert.Equal(typeof(Task<bool>), method!.ReturnType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Conversation_Open_ReactivatesTheSameOwnedConversation()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var conversation = new Conversation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
AgentType = AgentType.Unified,
|
||||
UpdatedAt = DateTime.UtcNow.AddDays(-1),
|
||||
};
|
||||
var service = new AiConversationService(new FakeConversationRepository(conversation));
|
||||
|
||||
var result = await service.OpenAsync(
|
||||
userId,
|
||||
conversation.Id,
|
||||
AgentType.Unified,
|
||||
"新的追问",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.False(result.Created);
|
||||
Assert.Equal(conversation.Id, result.ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Calendar_Month_AggregatesMedicationExerciseAndFollowUp()
|
||||
{
|
||||
var date = new DateOnly(2026, 6, 19);
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), IsActive = true, Name = "阿司匹林", StartDate = date.AddDays(-1), TimeOfDay = [new TimeOnly(8, 0)]
|
||||
Id = Guid.NewGuid(),
|
||||
IsActive = true,
|
||||
Name = "阿司匹林",
|
||||
StartDate = date.AddDays(-1),
|
||||
TimeOfDay = [new TimeOnly(8, 0)]
|
||||
};
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), StartDate = date, EndDate = date, ReminderTime = new TimeOnly(19, 0) };
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = "散步", DurationMinutes = 30 });
|
||||
@@ -154,7 +234,11 @@ public sealed class ApplicationServiceTests
|
||||
var repository = new FakeExerciseRepository();
|
||||
var plan = new ExercisePlan
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today, EndDate = today.AddDays(9), ReminderTime = new TimeOnly(19, 0),
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
StartDate = today,
|
||||
EndDate = today.AddDays(9),
|
||||
ReminderTime = new TimeOnly(19, 0),
|
||||
};
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today, ExerciseType = "跑步", DurationMinutes = 30 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), ScheduledDate = today.AddDays(7), ExerciseType = "跑步", DurationMinutes = 30 });
|
||||
@@ -175,11 +259,19 @@ public sealed class ApplicationServiceTests
|
||||
var repository = new FakeExerciseRepository();
|
||||
var plan = new ExercisePlan
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0),
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
StartDate = today.AddDays(-1),
|
||||
EndDate = today,
|
||||
ReminderTime = new TimeOnly(19, 0),
|
||||
};
|
||||
var item = new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30,
|
||||
Id = Guid.NewGuid(),
|
||||
Plan = plan,
|
||||
ScheduledDate = today.AddDays(-1),
|
||||
ExerciseType = "散步",
|
||||
DurationMinutes = 30,
|
||||
};
|
||||
plan.Items.Add(item);
|
||||
repository.SetPlan(plan);
|
||||
@@ -199,6 +291,8 @@ public sealed class ApplicationServiceTests
|
||||
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
|
||||
{
|
||||
public DateTime? RecordedAfter { get; private set; }
|
||||
public List<HealthRecord> Added { get; } = [];
|
||||
public int SaveCount { 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);
|
||||
@@ -208,9 +302,9 @@ public sealed class ApplicationServiceTests
|
||||
RecordedAfter = recordedAfter;
|
||||
return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||
}
|
||||
public Task AddAsync(HealthRecord record, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AddAsync(HealthRecord record, CancellationToken ct) { Added.Add(record); return Task.CompletedTask; }
|
||||
public void Delete(HealthRecord record) { }
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task SaveChangesAsync(CancellationToken ct) { SaveCount++; return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
|
||||
@@ -228,6 +322,33 @@ public sealed class ApplicationServiceTests
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class ListConversationRepository(List<Conversation> conversations) : IAiConversationRepository
|
||||
{
|
||||
private readonly List<Conversation> _conversations = conversations;
|
||||
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
|
||||
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId && item.UserId == userId));
|
||||
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
|
||||
Task.FromResult(_conversations.FirstOrDefault(item => item.Id == conversationId));
|
||||
public Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<Conversation>>(_conversations
|
||||
.Where(item => item.UserId == userId)
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.ToList());
|
||||
public Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||
public Task AddConversationAsync(Conversation value, CancellationToken ct)
|
||||
{
|
||||
_conversations.Add(value);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0);
|
||||
public void Delete(Conversation value) => _conversations.Remove(value);
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeCalendarRepository(CalendarDataSnapshot snapshot) : ICalendarRepository
|
||||
{
|
||||
public Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct) => Task.FromResult(snapshot);
|
||||
|
||||
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
17
backend/tests/Health.Tests/doctor_endpoint_tests.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Health.WebApi.Endpoints;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class DoctorEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public void BeijingDayRange_MapsEarlyUtcTimeToTheCorrectLocalDate()
|
||||
{
|
||||
var utcNow = new DateTime(2026, 7, 13, 17, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
var (startUtc, endUtc) = DoctorEndpoints.GetBeijingDayRange(utcNow);
|
||||
|
||||
Assert.Equal(new DateTime(2026, 7, 13, 16, 0, 0, DateTimeKind.Utc), startUtc);
|
||||
Assert.Equal(new DateTime(2026, 7, 14, 16, 0, 0, DateTimeKind.Utc), endUtc);
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,7 @@ public class EntityTests
|
||||
DietRestrictions = ["低盐", "低脂"],
|
||||
ChronicDiseases = ["高血压", "高血脂"],
|
||||
FamilyHistory = "父亲冠心病",
|
||||
SurgeryHistoryStatus = "有",
|
||||
};
|
||||
db.HealthArchives.Add(archive);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -193,6 +194,7 @@ public class EntityTests
|
||||
Assert.Equal("冠心病", saved!.Diagnosis);
|
||||
Assert.Equal(2, saved.DietRestrictions.Count);
|
||||
Assert.Contains("低盐", saved.DietRestrictions);
|
||||
Assert.Equal("有", saved.SurgeryHistoryStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
29
backend/tests/Health.Tests/medication_schedule_tests.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Health.Application.Medications;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class MedicationScheduleTests
|
||||
{
|
||||
[Fact]
|
||||
public void FutureDoseOutsideReminderWindow_RemainsVisibleAsScheduled()
|
||||
{
|
||||
var status = MedicationScheduleStatus.Resolve(
|
||||
new TimeOnly(18, 0),
|
||||
new TimeOnly(8, 0),
|
||||
logStatus: null);
|
||||
|
||||
Assert.Equal("scheduled", status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoggedDose_UsesItsActualStatus()
|
||||
{
|
||||
var status = MedicationScheduleStatus.Resolve(
|
||||
new TimeOnly(8, 0),
|
||||
new TimeOnly(12, 0),
|
||||
MedicationLogStatus.Taken);
|
||||
|
||||
Assert.Equal("taken", status);
|
||||
}
|
||||
}
|
||||
54
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
54
backend/tests/Health.Tests/medication_update_tests.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using Health.Application.Medications;
|
||||
using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class MedicationUpdateTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Update_CanClearAnExistingEndDate()
|
||||
{
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "阿司匹林",
|
||||
StartDate = new DateOnly(2026, 7, 1),
|
||||
EndDate = new DateOnly(2026, 7, 31),
|
||||
};
|
||||
var repository = new FakeMedicationRepository(medication);
|
||||
var service = new MedicationService(repository);
|
||||
|
||||
var updated = await service.UpdateAsync(
|
||||
medication.UserId,
|
||||
medication.Id,
|
||||
new MedicationPatchRequest(
|
||||
null, null, null, null, null, null, null,
|
||||
ClearEndDate: true),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(updated);
|
||||
Assert.Null(medication.EndDate);
|
||||
Assert.True(repository.Saved);
|
||||
}
|
||||
|
||||
private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository
|
||||
{
|
||||
public bool Saved { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<IReadOnlyList<MedicationLog>>([]);
|
||||
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
|
||||
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
|
||||
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
|
||||
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AddLogAsync(MedicationLog log, CancellationToken ct) => Task.CompletedTask;
|
||||
public void DeleteMedication(Medication value) { }
|
||||
public void DeleteLog(MedicationLog log) { }
|
||||
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,178 @@ public sealed class PersistencePipelineTests
|
||||
Assert.Empty(tasks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationOutbox_PushDisabledDoesNotCreateInAppNotification()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var sourceId = Guid.NewGuid();
|
||||
db.NotificationPreferences.Add(new NotificationPreference
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
PushEnabled = false,
|
||||
MedicationReminder = true,
|
||||
HealthRecordReminder = false,
|
||||
});
|
||||
db.NotificationOutbox.Add(new NotificationOutboxRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
SourceTaskId = sourceId,
|
||||
Type = "MedicationReminder",
|
||||
Payload = """
|
||||
{"Type":"MedicationReminder","Title":"用药时间到了","Message":"请按计划服药","Severity":"info","ActionType":"medication","ActionTargetId":null}
|
||||
""",
|
||||
Status = "Pending",
|
||||
AvailableAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var processor = new EfNotificationOutboxProcessor(db);
|
||||
|
||||
await processor.ProcessNextAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(db.UserNotifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReminderCatchUp_PushDisabledDoesNotCreateHealthRecordReminder()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
db.NotificationPreferences.Add(new NotificationPreference
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
PushEnabled = false,
|
||||
HealthRecordReminder = true,
|
||||
HealthRecordReminderBloodPressure = true,
|
||||
HealthRecordReminderHeartRate = false,
|
||||
HealthRecordReminderGlucose = false,
|
||||
HealthRecordReminderSpO2 = false,
|
||||
HealthRecordReminderWeight = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = new EfReminderCatchUpService(db);
|
||||
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc); // 13:00 Beijing
|
||||
|
||||
var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||
var secondPass = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, created.CreatedCount);
|
||||
Assert.Equal(0, secondPass.CreatedCount);
|
||||
Assert.Empty(db.UserNotifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReminderCatchUp_PushEnabledCreatesOnlySelectedMissingMetric()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
db.NotificationPreferences.Add(new NotificationPreference
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
PushEnabled = true,
|
||||
HealthRecordReminder = true,
|
||||
HealthRecordReminderBloodPressure = true,
|
||||
HealthRecordReminderHeartRate = false,
|
||||
HealthRecordReminderGlucose = false,
|
||||
HealthRecordReminderSpO2 = false,
|
||||
HealthRecordReminderWeight = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = new EfReminderCatchUpService(db);
|
||||
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.CreatedCount);
|
||||
var notification = Assert.Single(db.UserNotifications);
|
||||
Assert.Contains("血压", notification.Message);
|
||||
Assert.DoesNotContain("心率", notification.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReminderCatchUp_DndStillPreventsReminder()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
db.NotificationPreferences.Add(new NotificationPreference
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
PushEnabled = true,
|
||||
HealthRecordReminder = true,
|
||||
HealthRecordReminderBloodPressure = true,
|
||||
DndEnabled = true,
|
||||
DndStartMinutes = 12 * 60,
|
||||
DndEndMinutes = 14 * 60,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = new EfReminderCatchUpService(db);
|
||||
var nowUtc = new DateTime(2026, 7, 13, 5, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var result = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.CreatedCount);
|
||||
Assert.Empty(db.UserNotifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReminderCatchUp_CreatesMedicationAndExerciseRemindersForDueItems()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 7, 13);
|
||||
db.NotificationPreferences.Add(new NotificationPreference
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
PushEnabled = true,
|
||||
MedicationReminder = true,
|
||||
HealthRecordReminder = false,
|
||||
});
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = "测试药物",
|
||||
Dosage = "1片",
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
StartDate = date,
|
||||
IsActive = true,
|
||||
});
|
||||
var plan = new ExercisePlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
StartDate = date,
|
||||
EndDate = date,
|
||||
ReminderTime = new TimeOnly(9, 0),
|
||||
};
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ScheduledDate = date,
|
||||
ExerciseType = "散步",
|
||||
DurationMinutes = 30,
|
||||
});
|
||||
db.ExercisePlans.Add(plan);
|
||||
await db.SaveChangesAsync();
|
||||
var service = new EfReminderCatchUpService(db);
|
||||
var nowUtc = new DateTime(2026, 7, 13, 2, 0, 0, DateTimeKind.Utc); // 10:00 Beijing
|
||||
|
||||
var created = await service.CheckDueAsync(userId, nowUtc, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, created.CreatedCount);
|
||||
Assert.Contains(db.UserNotifications, x => x.Type == "MedicationReminder");
|
||||
Assert.Contains(db.UserNotifications, x => x.Type == "ExerciseReminder");
|
||||
}
|
||||
|
||||
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
|
||||
18
backend/tests/Health.Tests/prompt_manager_tests.cs
Normal file
18
backend/tests/Health.Tests/prompt_manager_tests.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Domain.Enums;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class PromptManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnifiedPrompt_DistinguishesQuestionsFromEntryRequests()
|
||||
{
|
||||
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
|
||||
|
||||
Assert.Contains("先判断用户是在咨询数值,还是希望记录数据", prompt);
|
||||
Assert.Contains("血氧98%是不是太高了", prompt);
|
||||
Assert.Contains("先回答问题,不调用 record_health_data", prompt);
|
||||
Assert.Contains("帮我记录血压116/89", prompt);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user