Files
AI-Health/backend/src/Health.Application/HealthRecords/HealthRecordService.cs
MingNian fade61ac21 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 目录
2026-07-15 23:22:52 +08:00

206 lines
8.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Health.Domain.Entities;
using Health.Domain.Enums;
using Health.Application.Notifications;
using System.Security.Cryptography;
using System.Text;
namespace Health.Application.HealthRecords;
public sealed class HealthRecordService(
IHealthRecordRepository records,
IUserNotificationProducer? notifications = null) : IHealthRecordService
{
private readonly IHealthRecordRepository _records = records;
private readonly IUserNotificationProducer? _notifications = notifications;
public async Task<IReadOnlyList<HealthRecordDto>> GetRecordsAsync(Guid userId, string? type, int? days, CancellationToken ct)
{
HealthMetricType? metricType = null;
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var parsed))
metricType = parsed;
var recordedAfter = days.HasValue ? DateTime.UtcNow.AddDays(-days.Value) : (DateTime?)null;
var result = await _records.ListAsync(userId, metricType, recordedAfter, 100, ct);
return result.Select(ToDto).ToList();
}
public async Task<Guid> CreateAsync(Guid userId, HealthRecordUpsertRequest request, CancellationToken ct)
{
HealthRecordRules.Validate(request);
var record = 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 ?? DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
IsAbnormal = HealthRecordRules.CheckAbnormal(request),
};
await _records.AddAsync(record, ct);
await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
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);
if (record == null) return false;
HealthRecordRules.Validate(request);
record.MetricType = request.Type;
record.Systolic = request.Systolic;
record.Diastolic = request.Diastolic;
record.Value = request.Value;
record.Unit = request.Unit;
record.Source = request.Source;
record.RecordedAt = request.RecordedAt ?? record.RecordedAt;
record.IsAbnormal = HealthRecordRules.CheckAbnormal(request);
await _records.SaveChangesAsync(ct);
await EnqueueAbnormalNotificationAsync(record, ct);
return true;
}
public async Task<bool> DeleteAsync(Guid userId, Guid id, CancellationToken ct)
{
var record = await _records.GetOwnedAsync(userId, id, ct);
if (record == null) return false;
_records.Delete(record);
await _records.SaveChangesAsync(ct);
return true;
}
public async Task<Dictionary<string, object?>> GetLatestAsync(Guid userId, CancellationToken ct)
{
var types = new[]
{
HealthMetricType.BloodPressure,
HealthMetricType.HeartRate,
HealthMetricType.Glucose,
HealthMetricType.SpO2,
HealthMetricType.Weight
};
var result = new Dictionary<string, object?>();
foreach (var type in types)
{
var latest = await _records.GetLatestByTypeAsync(userId, type, ct);
result[type.ToString()] = latest == null ? null : new
{
latest.Systolic,
latest.Diastolic,
latest.Value,
latest.Unit,
latest.IsAbnormal,
latest.RecordedAt
};
}
return result;
}
public async Task<IReadOnlyList<object>> GetTrendAsync(Guid userId, HealthMetricType type, int period, CancellationToken ct)
{
var days = period switch { 7 => 7, 30 => 30, 90 => 90, 365 => 365, _ => 7 };
var records = await _records.GetTrendAsync(userId, type, DateTime.UtcNow.AddDays(-days), ct);
return records.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt }).Cast<object>().ToList();
}
public static HealthRecordDto ToDto(HealthRecord record) => new(
record.Id,
record.MetricType.ToString(),
record.Systolic,
record.Diastolic,
record.Value,
record.Unit,
record.Source.ToString(),
record.IsAbnormal,
record.RecordedAt);
private async Task EnqueueAbnormalNotificationAsync(HealthRecord record, CancellationToken ct)
{
if (!record.IsAbnormal || _notifications == null) return;
var (title, message, severity, target) = record.MetricType switch
{
HealthMetricType.BloodPressure when record.Systolic >= 140 || record.Diastolic >= 90 =>
("血压偏高提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg高于参考范围。建议休息后复测如伴明显不适请及时就医。", "warning", "blood_pressure"),
HealthMetricType.BloodPressure =>
("血压偏低提醒", $"本次血压为 {record.Systolic}/{record.Diastolic} mmHg低于参考范围。请留意头晕、乏力等不适必要时及时就医。", "warning", "blood_pressure"),
HealthMetricType.HeartRate when record.Value > 100 =>
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
HealthMetricType.HeartRate =>
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
HealthMetricType.Glucose when record.Value > 6.1m =>
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
HealthMetricType.Glucose =>
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L低于参考范围。请及时关注身体状况。", "critical", "glucose"),
HealthMetricType.SpO2 =>
("血氧偏低提醒", $"本次血氧为 {record.Value:0.#}%,低于参考范围。建议立即复测;如持续偏低或伴呼吸不适,请及时就医。", "critical", "spo2"),
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
};
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
await _notifications.EnqueueAsync(
record.UserId,
sourceId,
new NotificationMessage("HealthMetricAlert", title, message, severity, "health", target),
ct);
}
private static Guid DeterministicGuid(string value)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return new Guid(hash.AsSpan(0, 16));
}
}