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:
MingNian
2026-07-15 23:22:52 +08:00
parent e654c1e0cc
commit fade61ac21
138 changed files with 12636 additions and 5013 deletions

View File

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

View File

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

View File

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

View File

@@ -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
{

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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!;

View File

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

View File

@@ -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_data98%
- record_health_data116/89
-
-
- record_health_data
-
- pendingConfirmation=true
-
- ///
- 2-5
-
-
-
""";
}

View File

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

View File

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

View File

@@ -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 =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
// 一键清空当前用户的全部对话

View File

@@ -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,

View File

@@ -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) =>

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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 =>

View 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");
}
}
}

View File

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

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

View File

@@ -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]

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

View 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; }
}
}

View File

@@ -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(),

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