diff --git a/Evergreen.Health.Application/Configuration/HomeAssistant.json b/Evergreen.Health.Application/Configuration/HomeAssistant.json index d2bfdc0..71e8f92 100644 --- a/Evergreen.Health.Application/Configuration/HomeAssistant.json +++ b/Evergreen.Health.Application/Configuration/HomeAssistant.json @@ -22,6 +22,23 @@ "MattressRightHeatingLevel": "number.qushui_cn_2108064504_zerox_right_level_p_6_2", "MattressLeftMode": "select.qushui_cn_2108064504_zerox_left_mode_p_5_1", "MattressRightMode": "select.qushui_cn_2108064504_zerox_right_mode_p_5_2", + "MattressFirmness": { + "left_shoulder": "select.qushui_cn_2108064504_zerox_left_shoulder_level_p_5_3", + "right_shoulder": "select.qushui_cn_2108064504_zerox_right_shoulder_level_p_5_4", + "left_back": "select.qushui_cn_2108064504_zerox_left_back_level_p_5_5", + "right_back": "select.qushui_cn_2108064504_zerox_right_back_level_p_5_6", + "left_waist": "select.qushui_cn_2108064504_zerox_left_waist_level_p_5_7", + "right_waist": "select.qushui_cn_2108064504_zerox_right_waist_level_p_5_8", + "left_lumbar": "select.qushui_cn_2108064504_zerox_left_lumbar_level_p_5_9", + "right_lumbar": "select.qushui_cn_2108064504_zerox_right_lumbar_level_p_5_10", + "left_hip": "select.qushui_cn_2108064504_zerox_left_hip_level_p_5_11", + "right_hip": "select.qushui_cn_2108064504_zerox_right_hip_level_p_5_12", + "left_leg": "select.qushui_cn_2108064504_zerox_left_leg_level_p_5_13", + "right_leg": "select.qushui_cn_2108064504_zerox_right_leg_level_p_5_14" + }, + "MattressLeftMassagePart": "select.qushui_cn_2108064504_zerox_left_part_p_4_1", + "MattressRightMassagePart": "select.qushui_cn_2108064504_zerox_right_part_p_4_2", + "MattressMassageLevel": "select.qushui_cn_2108064504_zerox_level_p_4_3", "Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1", "Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2", "Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3", diff --git a/Evergreen.Health.Application/Health/Entities/HealthEntities.cs b/Evergreen.Health.Application/Health/Entities/HealthEntities.cs index 656e039..3294b7a 100644 --- a/Evergreen.Health.Application/Health/Entities/HealthEntities.cs +++ b/Evergreen.Health.Application/Health/Entities/HealthEntities.cs @@ -66,8 +66,15 @@ public sealed class HealthProfile : EntityBase [SugarColumn(ColumnDescription = "健康评估问卷答案JSON", ColumnDataType = "text", IsNullable = true)] public string? AssessmentJson { get; set; } - [SugarColumn(ColumnDescription = "个性化智能设备启动参数JSON", ColumnDataType = "text", IsNullable = true)] + // 保留旧版本数据,不再读写;一键启动仅读取中医四诊生成的 TcmResultJson。 + [SugarColumn(ColumnDescription = "旧版设备调节参数快照JSON", ColumnDataType = "text", IsNullable = true)] public string? CustomSceneConfigJson { get; set; } + + [SugarColumn(ColumnDescription = "中医四诊答案JSON", ColumnDataType = "text", IsNullable = true)] + public string? TcmAssessmentJson { get; set; } + + [SugarColumn(ColumnDescription = "中医体质倾向结果JSON", ColumnDataType = "text", IsNullable = true)] + public string? TcmResultJson { get; set; } } [SugarTable("health_module_selection", "用户健康板块选择")] diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs index b18e2e9..46e6add 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs @@ -32,6 +32,10 @@ public sealed class HomeAssistantEntityOptions public string MattressRightHeatingLevel { get; set; } = string.Empty; public string MattressLeftMode { get; set; } = string.Empty; public string MattressRightMode { get; set; } = string.Empty; + public Dictionary MattressFirmness { get; set; } = new(); + public string MattressLeftMassagePart { get; set; } = string.Empty; + public string MattressRightMassagePart { get; set; } = string.Empty; + public string MattressMassageLevel { get; set; } = string.Empty; public string Temperature { get; set; } = string.Empty; public string Humidity { get; set; } = string.Empty; public string Pm25 { get; set; } = string.Empty; diff --git a/Evergreen.Health.Application/Health/Models/HealthModels.cs b/Evergreen.Health.Application/Health/Models/HealthModels.cs index 98d22b8..19e3dc0 100644 --- a/Evergreen.Health.Application/Health/Models/HealthModels.cs +++ b/Evergreen.Health.Application/Health/Models/HealthModels.cs @@ -98,6 +98,45 @@ public sealed class HealthDashboardOutput public decimal? Bmi { get; set; } public string BmiStatus { get; set; } = string.Empty; public PersonalizedSceneConfig? SceneConfig { get; set; } + public TcmAssessmentResult? TcmResult { get; set; } + public MattressComfortOutput? MattressComfort { get; set; } +} + +public sealed class MattressComfortOutput +{ + public decimal? Bmi { get; set; } + public string? Firmness { get; set; } + public int HeatingLevel { get; set; } + public bool IsWinter { get; set; } + public double? OutdoorTemperatureCelsius { get; set; } + public double? FeelsLikeCelsius { get; set; } + public bool WeatherAvailable { get; set; } + public string? WeatherObservedAt { get; set; } + public string WeatherReason { get; set; } = string.Empty; + public string FirmnessReason { get; set; } = string.Empty; +} + +public sealed class TcmAssessmentInput +{ + public Dictionary Answers { get; set; } = new(); + public Dictionary Notes { get; set; } = new(); +} + +public sealed class TcmAssessmentResult +{ + public bool HasCompleted { get; set; } + public string ConstitutionCode { get; set; } = string.Empty; + public string ConstitutionName { get; set; } = string.Empty; + public string? SecondaryName { get; set; } + public string Summary { get; set; } = string.Empty; + public string AromaRecommendation { get; set; } = string.Empty; + public string MusicRecommendation { get; set; } = string.Empty; + public string RoomTemperatureRecommendation { get; set; } = string.Empty; + public Dictionary Scores { get; set; } = new(); + public Dictionary Answers { get; set; } = new(); + public Dictionary Notes { get; set; } = new(); + public PersonalizedSceneConfig SceneConfig { get; set; } = new(); + public DateTime? AssessedAt { get; set; } } public sealed class PersonalizedSceneConfig @@ -153,7 +192,6 @@ public sealed class HealthAssessmentOutput public bool HasCompletedAssessment { get; set; } public SaveHealthProfileInput BasicInfo { get; set; } = new(); public HealthAssessmentAnswers Answers { get; set; } = new(); - public PersonalizedSceneConfig SceneConfig { get; set; } = new(); public decimal? Bmi { get; set; } public string BmiStatus { get; set; } = "正常"; } diff --git a/Evergreen.Health.Application/Health/Services/HealthAppService.cs b/Evergreen.Health.Application/Health/Services/HealthAppService.cs index 70c81c7..92f814a 100644 --- a/Evergreen.Health.Application/Health/Services/HealthAppService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthAppService.cs @@ -24,6 +24,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient private readonly SqlSugarRepository _profileRepository; private readonly SqlSugarRepository _moduleRepository; private readonly SqlSugarRepository _planRepository; + private readonly MattressComfortService _mattressComfort; private readonly IHttpContextAccessor _httpContextAccessor; public HealthAppService( @@ -31,12 +32,14 @@ public sealed class HealthAppService : IDynamicApiController, ITransient SqlSugarRepository profileRepository, SqlSugarRepository moduleRepository, SqlSugarRepository planRepository, + MattressComfortService mattressComfort, IHttpContextAccessor httpContextAccessor) { _userRepository = userRepository; _profileRepository = profileRepository; _moduleRepository = moduleRepository; _planRepository = planRepository; + _mattressComfort = mattressComfort; _httpContextAccessor = httpContextAccessor; } @@ -114,21 +117,12 @@ public sealed class HealthAppService : IDynamicApiController, ITransient bmiStatus = bmi < 18.5m ? "偏瘦" : bmi <= 23.9m ? "正常" : bmi <= 27.9m ? "超重" : "肥胖"; } - PersonalizedSceneConfig? sceneConfig = null; - if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson)) + var tcmResult = TcmConstitutionRules.Read(profile); + MattressComfortOutput? mattressComfort = null; + if (tcmResult.HasCompleted) { - try - { - sceneConfig = JsonSerializer.Deserialize(profile.CustomSceneConfigJson); - } - catch - { - } - } - if (sceneConfig == null && hasCompletedAssessment) - { - var answers = JsonSerializer.Deserialize(profile!.AssessmentJson!) ?? new HealthAssessmentAnswers(); - sceneConfig = CalculatePersonalizedSceneConfig(answers, MapProfile(profile)); + mattressComfort = await _mattressComfort.GetAsync(profile); + MattressComfortService.ApplyTo(tcmResult.SceneConfig, mattressComfort); } return new HealthDashboardOutput @@ -140,7 +134,9 @@ public sealed class HealthAppService : IDynamicApiController, ITransient HasCompletedAssessment = hasCompletedAssessment, Bmi = bmi, BmiStatus = bmiStatus, - SceneConfig = sceneConfig + SceneConfig = tcmResult.HasCompleted ? tcmResult.SceneConfig : null, + TcmResult = tcmResult, + MattressComfort = mattressComfort }; } @@ -156,8 +152,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient { HasCompletedAssessment = false, BasicInfo = new SaveHealthProfileInput(), - Answers = new HealthAssessmentAnswers(), - SceneConfig = new PersonalizedSceneConfig() + Answers = new HealthAssessmentAnswers() }; } @@ -166,9 +161,6 @@ public sealed class HealthAppService : IDynamicApiController, ITransient : JsonSerializer.Deserialize(profile.AssessmentJson) ?? new HealthAssessmentAnswers(); var hasCompleted = !string.IsNullOrWhiteSpace(profile.AssessmentJson); - var sceneConfig = string.IsNullOrWhiteSpace(profile.CustomSceneConfigJson) - ? CalculatePersonalizedSceneConfig(answers, MapProfile(profile)) - : JsonSerializer.Deserialize(profile.CustomSceneConfigJson) ?? new PersonalizedSceneConfig(); decimal? bmi = null; var bmiStatus = "正常"; @@ -184,7 +176,6 @@ public sealed class HealthAppService : IDynamicApiController, ITransient HasCompletedAssessment = hasCompleted, BasicInfo = MapProfile(profile), Answers = answers, - SceneConfig = sceneConfig, Bmi = bmi, BmiStatus = bmiStatus }; @@ -198,21 +189,18 @@ public sealed class HealthAppService : IDynamicApiController, ITransient ValidateAssessment(input); var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); - var calculatedConfig = CalculatePersonalizedSceneConfig(input.Answers, input.BasicInfo); if (profile == null) { profile = new HealthProfile { UserId = userId }; ApplyProfile(profile, input.BasicInfo); profile.AssessmentJson = JsonSerializer.Serialize(input.Answers); - profile.CustomSceneConfigJson = JsonSerializer.Serialize(calculatedConfig); await _profileRepository.InsertAsync(profile); } else { ApplyProfile(profile, input.BasicInfo); profile.AssessmentJson = JsonSerializer.Serialize(input.Answers); - profile.CustomSceneConfigJson = JsonSerializer.Serialize(calculatedConfig); await _profileRepository.UpdateAsync(profile); } @@ -240,7 +228,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient UserId = userId, Version = maxVersion + 1, Title = $"个性化健康方案 v{maxVersion + 1}.0", - Summary = BuildAssessmentPlanSummary(profile, input.Answers), + Summary = BuildAssessmentPlanSummary(input.Answers), ParametersJson = JsonSerializer.Serialize(planItems), IsCurrent = true }; @@ -393,7 +381,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient ModuleCode = module, Title = "睡眠质量", Target = profile?.AverageSleepHours is < 7 ? "逐步达到每晚 7–8 小时" : "保持每晚 7–8 小时", - Advice = "固定入睡时间,睡前一小时降低灯光亮度,并使用卧室舒睡场景。" + Advice = "固定入睡时间,睡前一小时减少屏幕使用,保持卧室安静。" }); break; case "exercise": @@ -431,7 +419,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient bmiText = $" 当前 BMI 约为 {bmi}。"; } - return $"方案依据您选择的 {modules.Count} 个健康板块生成。{bmiText}第一版参数用于健康管理演示,不替代专业医疗诊断。"; + return $"方案依据您选择的 {modules.Count} 个健康板块生成。{bmiText}建议仅用于日常健康管理演示,不替代专业医疗诊断。"; } private static HealthPlanOutput MapPlan(HealthPlan plan) => new() @@ -439,7 +427,9 @@ public sealed class HealthAppService : IDynamicApiController, ITransient Id = plan.Id, Version = plan.Version, Title = plan.Title, - Summary = plan.Summary, + Summary = plan.Summary.Contains("智能家具启动参数", StringComparison.Ordinal) + ? "已完成健康评估,可查看睡眠、运动与饮食建议。设备启动参数仅由中医四诊体质结果生成。" + : plan.Summary, CreateTime = plan.CreateTime, Items = JsonSerializer.Deserialize>(plan.ParametersJson) ?? [] }; @@ -498,166 +488,11 @@ public sealed class HealthAppService : IDynamicApiController, ITransient throw Oops.Oh($"请完成“{label}”问题"); } - private static PersonalizedSceneConfig CalculatePersonalizedSceneConfig(HealthAssessmentAnswers answers, SaveHealthProfileInput basicInfo) + private static string BuildAssessmentPlanSummary(HealthAssessmentAnswers answers) { - var config = new PersonalizedSceneConfig(); - - switch (answers.TemperaturePreference) - { - case "heat_fear": - config.TargetHumidity = 48; - config.FanLevel = "2挡"; - config.MattressTargetTemp = 32m; - config.MattressHeatingLevel = 1; - break; - case "cold_fear": - config.TargetHumidity = 58; - config.FanLevel = "1挡"; - config.MattressTargetTemp = 37.5m; - config.MattressHeatingLevel = 3; - break; - default: - config.TargetHumidity = 52; - config.FanLevel = "1挡"; - config.MattressTargetTemp = 35.5m; - config.MattressHeatingLevel = 2; - break; - } - - switch (answers.FallingAsleepSpeed) - { - case "hard": - config.LightBrightnessPercent = 8; - config.LightColorTemperatureKelvin = 2200; - config.FragranceLevel = 2; - config.EnableSpeakerSleepMode = true; - config.SpeakerAudioMode = "助眠白噪音"; - config.SpeakerVolumePercent = 12; - break; - case "fast": - config.LightBrightnessPercent = 25; - config.LightColorTemperatureKelvin = 3000; - config.EnableSpeakerSleepMode = false; - config.SpeakerAudioMode = "关闭助眠音频"; - config.SpeakerVolumePercent = 10; - break; - default: - config.LightBrightnessPercent = 18; - config.LightColorTemperatureKelvin = 2700; - config.FragranceLevel = 1; - config.EnableSpeakerSleepMode = true; - config.SpeakerAudioMode = "舒缓轻音乐"; - config.SpeakerVolumePercent = 15; - break; - } - - switch (answers.SleepDuration) - { - case "<6": - config.LightBrightnessPercent -= 3; - config.SpeakerVolumePercent += 3; - config.FragranceLevel++; - break; - case "6-7": - config.LightBrightnessPercent -= 1; - config.SpeakerVolumePercent += 1; - break; - case ">8": - config.LightBrightnessPercent += 2; - config.SpeakerVolumePercent -= 2; - break; - } - - if (answers.MuscleFatigueArea is "waist_back" or "back_neck" || answers.MattressPreference == "firm") - { - config.MattressLeftFirmness = 7; - config.MattressRightFirmness = 7; - config.MattressMode = "智能腰托"; - } - else if (answers.MattressPreference == "soft") - { - config.MattressLeftFirmness = 4; - config.MattressRightFirmness = 4; - config.MattressMode = "瑜伽助眠"; - } - else - { - config.MattressLeftFirmness = answers.MuscleFatigueArea == "legs" ? 6 : 5; - config.MattressRightFirmness = answers.MuscleFatigueArea == "legs" ? 6 : 5; - config.MattressMode = "智能适配"; - } - - config.DehumidifierMode = answers.NightAwakening == "none" ? "智能" : "睡眠"; - if (answers.NightAwakening == "frequent") - { - config.FanLevel = "1挡"; - config.LightBrightnessPercent -= 2; - } - - switch (answers.ExerciseFrequency) - { - case "none": config.MattressHeatingLevel++; break; - case "daily": config.MattressHeatingLevel--; break; - } - - switch (answers.ActiveTimeOfDay) - { - case "morning": config.LightColorTemperatureKelvin += 150; break; - case "night": - config.LightColorTemperatureKelvin -= 250; - config.LightBrightnessPercent -= 2; - break; - case "irregular": - config.EnableSpeakerSleepMode = true; - config.SpeakerVolumePercent += 2; - break; - } - - config.EnableUv = answers.LateNightEating is "often" or "sometimes" || answers.FlavorPreference == "heavy"; - if (answers.MealRegularity == "irregular") - { - config.EnableUv = true; - config.FragranceLevel++; - } - else if (answers.MealRegularity == "skip_breakfast") - { - config.FragranceLevel++; - } - - if (answers.DailyWaterIntake == "scarce") config.TargetHumidity += 3; - if (answers.DailyWaterIntake == "abundant") config.TargetHumidity -= 2; - - if (basicInfo.HeightCm > 0 && basicInfo.WeightKg > 0) - { - var height = basicInfo.HeightCm.Value / 100m; - var bmi = basicInfo.WeightKg.Value / (height * height); - if (bmi < 18.5m) config.MattressHeatingLevel++; - if (bmi >= 28m) config.TargetHumidity -= 2; - } - if (basicInfo.Birthday.HasValue) - { - var age = DateTime.Today.Year - basicInfo.Birthday.Value.Year; - if (age >= 60) - { - config.LightBrightnessPercent += 3; - config.SpeakerVolumePercent = Math.Max(8, config.SpeakerVolumePercent - 2); - } - } - - config.TargetHumidity = Math.Clamp(config.TargetHumidity, 40, 70); - config.LightBrightnessPercent = Math.Clamp(config.LightBrightnessPercent, 1, 100); - config.LightColorTemperatureKelvin = Math.Clamp(config.LightColorTemperatureKelvin, 2200, 5000); - config.SpeakerVolumePercent = Math.Clamp(config.SpeakerVolumePercent, 1, 50); - config.FragranceLevel = Math.Clamp(config.FragranceLevel, 1, 3); - config.MattressHeatingLevel = Math.Clamp(config.MattressHeatingLevel, 0, 4); - return config; - } - - private static string BuildAssessmentPlanSummary(HealthProfile profile, HealthAssessmentAnswers answers) - { - var sleepNote = answers.FallingAsleepSpeed == "hard" ? "针对入睡耗时较长启用了音箱睡眠模式与极暗暖光;" : ""; - var tempNote = answers.TemperaturePreference == "heat_fear" ? "依据体感偏热降低了床垫加热档位与室内湿度;" : answers.TemperaturePreference == "cold_fear" ? "依据体感偏冷提高了床垫保温档位;" : ""; - var supportNote = answers.MuscleFatigueArea is "waist_back" or "back_neck" ? "针对腰背与颈部疲劳启用了床垫智能腰托模式;" : ""; - return $"已完成全面健康评估,{sleepNote}{tempNote}{supportNote}全套智能家具启动参数已完成个性化配置。"; + var sleepNote = answers.FallingAsleepSpeed == "hard" ? "入睡较慢,建议保持规律的睡前作息;" : ""; + var activityNote = answers.ExerciseFrequency == "none" ? "可从轻度日常活动开始;" : ""; + var dietNote = answers.MealRegularity == "irregular" ? "建议逐步建立规律三餐;" : ""; + return $"已完成健康评估。{sleepNote}{activityNote}{dietNote}结果用于了解身体基本情况,不参与智能家居启动参数计算。"; } } diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs index 75b58af..a2d6b47 100644 --- a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -22,6 +22,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie private readonly HomeAssistantOptions _options; private readonly SqlSugarRepository _executionRepository; private readonly SqlSugarRepository _profileRepository; + private readonly MattressComfortService _mattressComfort; private readonly IHttpContextAccessor _httpContextAccessor; public HealthHomeAssistantService( @@ -31,6 +32,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie IOptions options, SqlSugarRepository executionRepository, SqlSugarRepository profileRepository, + MattressComfortService mattressComfort, IHttpContextAccessor httpContextAccessor) { _homeAssistant = homeAssistant; @@ -39,6 +41,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie _options = options.Value; _executionRepository = executionRepository; _profileRepository = profileRepository; + _mattressComfort = mattressComfort; _httpContextAccessor = httpContextAccessor; } @@ -153,16 +156,21 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie { var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); - if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson)) + var tcmResult = TcmConstitutionRules.Read(profile); + if (tcmResult.HasCompleted) { - userConfig = JsonSerializer.Deserialize(profile.CustomSceneConfigJson); + userConfig = tcmResult.SceneConfig; + var comfort = await _mattressComfort.GetAsync(profile, cancellationToken); + MattressComfortService.ApplyTo(userConfig, comfort); } } catch { } - var scenePresets = BuildScenePresets(stateMap, userConfig); + var scenePresets = userConfig is null + ? new List() + : BuildScenePresets(stateMap, userConfig); return new HomeAssistantStatusOutput { @@ -188,7 +196,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie Devices = [], SceneDevices = BuildSceneDevicesSummary(new Dictionary(StringComparer.OrdinalIgnoreCase)), Environment = new HomeAssistantEnvironmentOutput(), - ScenePresets = BuildScenePresets(new Dictionary(StringComparer.OrdinalIgnoreCase)), + ScenePresets = [], ControlParameters = [] }; } @@ -531,6 +539,32 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie service = "select_option"; serviceData["option"] = ReadMattressMode(input.Value); break; + case "mattress_massage_left_part": + targetEntityId = _options.Entities.MattressLeftMassagePart; + domain = "select"; + service = "select_option"; + serviceData["option"] = ReadMassagePart(input.Value); + break; + case "mattress_massage_right_part": + targetEntityId = _options.Entities.MattressRightMassagePart; + domain = "select"; + service = "select_option"; + serviceData["option"] = ReadMassagePart(input.Value); + break; + case "mattress_massage_level": + targetEntityId = _options.Entities.MattressMassageLevel; + domain = "select"; + service = "select_option"; + serviceData["option"] = ReadMassageLevel(input.Value); + break; + case string firmnessType when firmnessType.StartsWith("mattress_firmness_", StringComparison.Ordinal): + var zone = firmnessType["mattress_firmness_".Length..]; + if (!_options.Entities.MattressFirmness.TryGetValue(zone, out targetEntityId)) + throw new InvalidOperationException("不支持该床垫分区"); + domain = "select"; + service = "select_option"; + serviceData["option"] = ReadFirmness(input.Value); + break; case "uv": targetEntityId = _options.Entities.DehumidifierUv; domain = "switch"; @@ -562,40 +596,6 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie serviceData["entity_id"] = targetEntityId; await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken); - try - { - var userId = GetCurrentUserId(); - var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); - if (profile != null) - { - var customConfig = string.IsNullOrWhiteSpace(profile.CustomSceneConfigJson) - ? new PersonalizedSceneConfig() - : JsonSerializer.Deserialize(profile.CustomSceneConfigJson) ?? new PersonalizedSceneConfig(); - - switch (parameterType) - { - case "humidity": customConfig.TargetHumidity = (int)serviceData["humidity"]!; break; - case "fan_level": customConfig.FanLevel = (string)serviceData["option"]!; break; - case "dehumidifier_mode": customConfig.DehumidifierMode = (string)serviceData["mode"]!; break; - case "brightness": customConfig.LightBrightnessPercent = (int)serviceData["brightness_pct"]!; break; - case "color_temp": customConfig.LightColorTemperatureKelvin = (int)serviceData["color_temp_kelvin"]!; break; - case "volume": customConfig.SpeakerVolumePercent = ReadInt(input.Value, 0, 100, "音箱音量"); break; - case "speaker_sleep_mode": customConfig.EnableSpeakerSleepMode = ReadBool(input.Value); break; - case "uv": customConfig.EnableUv = ReadBool(input.Value); break; - case "mattress_left_heating": - case "mattress_right_heating": customConfig.MattressHeatingLevel = ReadInt(input.Value, 0, 4, "床垫加热档位"); break; - case "mattress_left_mode": - case "mattress_right_mode": customConfig.MattressMode = ReadMattressMode(input.Value); break; - } - - profile.CustomSceneConfigJson = JsonSerializer.Serialize(customConfig); - await _profileRepository.UpdateAsync(profile); - } - } - catch - { - } - var successMessage = parameterType switch { "fragrance_quick_release" => "快速出香指令已发送", @@ -631,18 +631,20 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie public async Task ExecuteSleepScene(CancellationToken cancellationToken) { PersonalizedSceneConfig? customConfig = null; - try + var userId = GetCurrentUserId(); + var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); + var tcmResult = TcmConstitutionRules.Read(profile); + if (!tcmResult.HasCompleted) { - var userId = GetCurrentUserId(); - var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); - if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson)) + return new HomeAssistantSceneOutput { - customConfig = JsonSerializer.Deserialize(profile.CustomSceneConfigJson); - } - } - catch - { + SceneCode = "sleep", SceneName = "舒睡模式", Success = false, + Status = "AssessmentRequired", Message = "请先完成中医四诊,生成体质倾向后再一键启动" + }; } + customConfig = tcmResult.SceneConfig; + var mattressComfort = await _mattressComfort.GetAsync(profile, cancellationToken); + MattressComfortService.ApplyTo(customConfig, mattressComfort); return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) => ExecuteSleepActions(states, results, customConfig, ct), cancellationToken); } @@ -735,7 +737,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie var enableSpeakerSleepMode = customConfig?.EnableSpeakerSleepMode ?? scene.EnableSpeakerSleepMode; var speakerVolume = customConfig?.SpeakerVolumePercent ?? 15; var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel; - var mattressMode = customConfig?.MattressMode ?? scene.MattressMode; + var mattressFirmness = MattressComfortService.FirmnessName(customConfig?.MattressLeftFirmness ?? 0); await RunAction(states, results, "开启除湿机", entities.Dehumidifier, "humidifier", "turn_on", null, cancellationToken); await RunAction(states, results, "设置目标湿度", entities.Dehumidifier, "humidifier", "set_humidity", @@ -757,6 +759,11 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on", new Dictionary { ["brightness_pct"] = lightBrightness }, cancellationToken); } + else + { + await RunAction(states, results, "关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", null, cancellationToken); + await RunAction(states, results, "关闭香氛灯", entities.FragranceLight, "light", "turn_off", null, cancellationToken); + } if (string.IsNullOrWhiteSpace(entities.AmbientLight) || !states.TryGetValue(entities.AmbientLight, out var lightState) || !IsAvailable(lightState)) @@ -809,10 +816,24 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value", new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); - await RunAction(states, results, "设置床垫左侧模式", entities.MattressLeftMode, "select", "select_option", - new Dictionary { ["option"] = mattressMode }, cancellationToken); - await RunAction(states, results, "设置床垫右侧模式", entities.MattressRightMode, "select", "select_option", - new Dictionary { ["option"] = mattressMode }, cancellationToken); + // 左右承托模式共用气泵,一键场景不同时启动两侧模式。 + if (mattressFirmness == null) + { + results.Add(new HomeAssistantActionResult + { + Name = "按 BMI 调节床垫软硬度", Status = "Skipped", + Message = "健康档案缺少身高或体重,未自动修改软硬度" + }); + } + else + { + foreach (var (zone, entityId) in entities.MattressFirmness) + { + await RunAction(states, results, $"设置床垫{MattressZoneName(zone)}软硬度", + entityId, "select", "select_option", + new Dictionary { ["option"] = mattressFirmness }, cancellationToken); + } + } } } @@ -1268,8 +1289,13 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie ["mattress_left_heating"] = _options.Entities.MattressLeftHeatingLevel, ["mattress_right_heating"] = _options.Entities.MattressRightHeatingLevel, ["mattress_left_mode"] = _options.Entities.MattressLeftMode, - ["mattress_right_mode"] = _options.Entities.MattressRightMode + ["mattress_right_mode"] = _options.Entities.MattressRightMode, + ["mattress_massage_left_part"] = _options.Entities.MattressLeftMassagePart, + ["mattress_massage_right_part"] = _options.Entities.MattressRightMassagePart, + ["mattress_massage_level"] = _options.Entities.MattressMassageLevel }; + foreach (var (zone, entityId) in _options.Entities.MattressFirmness) + configured[$"mattress_firmness_{zone}"] = entityId; var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (parameterType, entityId) in configured) { @@ -1319,6 +1345,41 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie return mode; } + private static string ReadFirmness(object? value) + { + var firmness = ReadString(value); + if (firmness is not ("最软" or "很软" or "稍软" or "稍硬" or "很硬" or "最硬")) + throw new InvalidOperationException("床垫软硬度选项无效"); + return firmness; + } + + private static string MattressZoneName(string zone) => zone switch + { + "left_shoulder" => "左肩", "right_shoulder" => "右肩", + "left_back" => "左背", "right_back" => "右背", + "left_waist" => "左腰", "right_waist" => "右腰", + "left_lumbar" => "左腰椎", "right_lumbar" => "右腰椎", + "left_hip" => "左臀", "right_hip" => "右臀", + "left_leg" => "左腿", "right_leg" => "右腿", + _ => zone + }; + + private static string ReadMassagePart(object? value) + { + var part = ReadString(value); + if (part is not ("无" or "背部" or "腰部" or "牵引" or "腿部" or "全身")) + throw new InvalidOperationException("按摩部位选项无效"); + return part; + } + + private static string ReadMassageLevel(object? value) + { + var level = ReadString(value); + if (level is not ("轻柔" or "强劲")) + throw new InvalidOperationException("按摩力度选项无效"); + return level; + } + private List BuildScenePresets( IReadOnlyDictionary stateMap, PersonalizedSceneConfig? userConfig = null) @@ -1399,18 +1460,18 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie if (defaultScene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel)) { var heatingLevel = userConfig?.MattressHeatingLevel ?? defaultScene.MattressHeatingLevel; - var mattressMode = userConfig?.MattressMode ?? defaultScene.MattressMode; + var firmness = MattressComfortService.FirmnessName(userConfig?.MattressLeftFirmness ?? 0); presets.Add(new HomeAssistantScenePresetItem { DeviceName = "8H AI智能好睡眠床垫X", Category = nameof(HealthDeviceCategory.Sleep), EntityId = _options.Entities.MattressLeftHeatingLevel, - Action = "开启分区加热与承托模式", - Detail = $"左右加热 {heatingLevel} 档 · {mattressMode}", + Action = "按天气和 BMI 调节床垫", + Detail = $"左右加热 {heatingLevel} 档 · 软硬度{firmness ?? "待填写 BMI"}", Parameters = new() { ["heating_level"] = heatingLevel, - ["mode"] = mattressMode + ["firmness"] = firmness ?? "未自动调节" } }); } diff --git a/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs new file mode 100644 index 0000000..fa3995c --- /dev/null +++ b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs @@ -0,0 +1,198 @@ +using Evergreen.Health.Application.Health.Entities; +using Evergreen.Health.Application.Health.Models; +using Furion.DatabaseAccessor; +using Furion.FriendlyException; +using Microsoft.AspNetCore.Http; +using SqlSugar; +using System.Security.Claims; +using System.Text.Json; + +namespace Evergreen.Health.Application.Health.Services; + +// 演示用四诊倾向,不等同于标准量表或医学诊断。 +[ApiDescriptionSettings("健康管理", Name = "HealthTcmAssessment", Order = 52)] +public sealed class HealthTcmAssessmentService : IDynamicApiController, ITransient +{ + private readonly SqlSugarRepository _profiles; + private readonly IHttpContextAccessor _http; + + public HealthTcmAssessmentService(SqlSugarRepository profiles, IHttpContextAccessor http) + { + _profiles = profiles; + _http = http; + } + + [Authorize] + [HttpGet("/api/health/tcm-assessment")] + public async Task GetAssessment() + { + var userId = CurrentUserId(); + var profile = await _profiles.GetFirstAsync(p => p.UserId == userId); + return TcmConstitutionRules.Read(profile); + } + + [Authorize] + [HttpPost("/api/health/tcm-assessment")] + [UnitOfWork] + public async Task SaveAssessment(TcmAssessmentInput input) + { + var result = TcmConstitutionRules.Evaluate(input); + var userId = CurrentUserId(); + var profile = await _profiles.GetFirstAsync(p => p.UserId == userId); + if (profile == null) + { + profile = new HealthProfile { UserId = userId }; + profile.TcmAssessmentJson = JsonSerializer.Serialize(input); + profile.TcmResultJson = JsonSerializer.Serialize(result); + await _profiles.InsertAsync(profile); + } + else + { + profile.TcmAssessmentJson = JsonSerializer.Serialize(input); + profile.TcmResultJson = JsonSerializer.Serialize(result); + await _profiles.AsUpdateable(profile) + .UpdateColumns(p => new { p.TcmAssessmentJson, p.TcmResultJson }) + .ExecuteCommandAsync(); + } + return result; + } + + private long CurrentUserId() + { + var claim = _http.HttpContext?.User.FindFirstValue("health_user_id"); + if (!long.TryParse(claim, out var userId)) + throw Oops.Oh("登录状态已失效").StatusCode(401); + return userId; + } +} + +public static class TcmConstitutionRules +{ + private static readonly string[] Types = + [ + "balanced", "qi_deficient", "yang_deficient", "yin_deficient", + "phlegm_damp", "damp_heat", "blood_stasis", "qi_stagnation", "special" + ]; + private static readonly Dictionary Names = new() + { + ["balanced"] = "平和质", ["qi_deficient"] = "气虚质", ["yang_deficient"] = "阳虚质", + ["yin_deficient"] = "阴虚质", ["phlegm_damp"] = "痰湿质", ["damp_heat"] = "湿热质", + ["blood_stasis"] = "血瘀质", ["qi_stagnation"] = "气郁质", ["special"] = "特禀质" + }; + private static readonly Dictionary Choices = new() + { + ["face"] = ["rosy", "pale", "sallow", "flushed", "dusky"], + ["body"] = ["balanced", "heavy", "thin", "abdominal"], + ["spirit"] = ["energetic", "low", "tired"], + ["voice"] = ["clear", "normal", "weak"], + ["coldHeat"] = ["neutral", "cold", "hot"], + ["sweating"] = ["normal", "easy", "little"], + ["mouth"] = ["normal", "bland", "bitter", "dry"], + ["stool"] = ["normal", "dry", "loose", "sticky"], + ["sleep"] = ["good", "difficult", "waking", "dreams"], + ["emotion"] = ["steady", "anxious", "depressed", "irritable"], + ["pulse"] = ["normal", "wiry", "slippery", "thin", "rough", "deep", "floating", "weak", "unknown"], + ["allergy"] = ["none", "often"] + }; + + private static readonly Dictionary Weights = new() + { + ["face:pale"] = [("qi_deficient", 2), ("yang_deficient", 1)], + ["face:sallow"] = [("qi_deficient", 2), ("phlegm_damp", 1)], + ["face:flushed"] = [("yin_deficient", 2), ("damp_heat", 1)], + ["face:dusky"] = [("blood_stasis", 3)], + ["body:heavy"] = [("phlegm_damp", 3)], + ["body:thin"] = [("yin_deficient", 2)], + ["body:abdominal"] = [("phlegm_damp", 3), ("damp_heat", 1)], + ["spirit:low"] = [("qi_deficient", 2)], + ["spirit:tired"] = [("qi_deficient", 2), ("yang_deficient", 1)], + ["voice:weak"] = [("qi_deficient", 2)], + ["coldHeat:cold"] = [("yang_deficient", 4)], + ["coldHeat:hot"] = [("yin_deficient", 2), ("damp_heat", 2)], + ["sweating:easy"] = [("qi_deficient", 2)], + ["sweating:little"] = [("yin_deficient", 1)], + ["mouth:bland"] = [("yang_deficient", 1), ("phlegm_damp", 1)], + ["mouth:bitter"] = [("damp_heat", 3)], + ["mouth:dry"] = [("yin_deficient", 3)], + ["stool:dry"] = [("yin_deficient", 2), ("damp_heat", 1)], + ["stool:loose"] = [("yang_deficient", 2), ("qi_deficient", 1)], + ["stool:sticky"] = [("damp_heat", 3), ("phlegm_damp", 2)], + ["sleep:difficult"] = [("qi_stagnation", 2), ("yin_deficient", 1)], + ["sleep:waking"] = [("qi_deficient", 1), ("yin_deficient", 1)], + ["sleep:dreams"] = [("qi_stagnation", 2)], + ["emotion:anxious"] = [("qi_stagnation", 3)], + ["emotion:depressed"] = [("qi_stagnation", 3)], + ["emotion:irritable"] = [("qi_stagnation", 2), ("damp_heat", 1)], + ["pulse:wiry"] = [("qi_stagnation", 2)], + ["pulse:slippery"] = [("phlegm_damp", 2), ("damp_heat", 1)], + ["pulse:thin"] = [("yin_deficient", 2)], + ["pulse:rough"] = [("blood_stasis", 3)], + ["pulse:deep"] = [("yang_deficient", 1)], + ["pulse:floating"] = [("qi_deficient", 1)], + ["pulse:weak"] = [("qi_deficient", 2)], + ["allergy:often"] = [("special", 6)] + }; + + public static TcmAssessmentResult Read(HealthProfile? profile) + { + if (string.IsNullOrWhiteSpace(profile?.TcmResultJson)) return new TcmAssessmentResult(); + try { return JsonSerializer.Deserialize(profile.TcmResultJson) ?? new TcmAssessmentResult(); } + catch { return new TcmAssessmentResult(); } + } + + public static TcmAssessmentResult Evaluate(TcmAssessmentInput input) + { + if (input?.Answers == null) throw Oops.Oh("请完成四诊问卷"); + foreach (var (field, choices) in Choices) + if (!input.Answers.TryGetValue(field, out var answer) || !choices.Contains(answer)) + throw Oops.Oh($"四诊项目 {field} 未填写或选项无效"); + + var scores = Types.ToDictionary(type => type, _ => 0); + foreach (var (field, answer) in input.Answers) + if (Weights.TryGetValue($"{field}:{answer}", out var weights)) + foreach (var (type, points) in weights) scores[type] += points; + + // 无明显偏颇时才判平和;同分时按固定顺序取值,结果可复现。 + var ranked = Types.Skip(1).OrderByDescending(type => scores[type]).ToList(); + var primary = scores[ranked[0]] < 3 ? "balanced" : ranked[0]; + var notes = (input.Notes ?? new Dictionary()) + .Where(item => new[] { "tongue", "odor", "breath", "pulseNote" }.Contains(item.Key)) + .ToDictionary(item => item.Key, item => (item.Value ?? "").Trim()[..Math.Min((item.Value ?? "").Trim().Length, 300)]); + var (aroma, music, room, humidity, kelvin, fragrance, volume) = primary switch + { + "qi_deficient" => ("清淡木香", "舒缓自然声", "22–25℃", 55, 3000, true, 14), + "yang_deficient" => ("温暖木质香", "轻柔慢节奏", "24–26℃", 52, 2700, true, 14), + "yin_deficient" => ("淡菊花香", "轻柔海浪声", "21–24℃", 48, 4000, true, 12), + "phlegm_damp" => ("清新柑橘香", "舒缓雨声", "22–24℃", 45, 3500, true, 15), + "damp_heat" => ("清淡薄荷香", "流水白噪音", "21–24℃", 45, 4000, true, 12), + "blood_stasis" => ("淡花木香", "低频舒缓音乐", "23–25℃", 52, 3000, true, 14), + "qi_stagnation" => ("清新柑橘香", "轻柔吉他曲", "22–25℃", 51, 3000, true, 14), + "special" => ("建议不使用香氛,避免过敏原", "自然风声", "22–25℃", 50, 3500, false, 12), + _ => ("淡雅薰衣草香", "自然雨声", "22–25℃", 50, 3000, true, 15) + }; + return new TcmAssessmentResult + { + HasCompleted = true, + ConstitutionCode = primary, + ConstitutionName = Names[primary], + Summary = $"根据本次望闻问切选择,演示规则提示您偏向{Names[primary]}。仅作健康生活方式参考,不作为诊断。", + AromaRecommendation = input.Answers["allergy"] == "often" + ? "建议不使用香氛,避免过敏原" : aroma, + MusicRecommendation = music, + RoomTemperatureRecommendation = room, + Scores = scores, + Answers = Choices.Keys.ToDictionary(key => key, key => input.Answers[key]), + Notes = notes, + AssessedAt = DateTime.Now, + SceneConfig = new PersonalizedSceneConfig + { + TargetHumidity = humidity, DehumidifierMode = "睡眠", FanLevel = "1档", + LightBrightnessPercent = 20, LightColorTemperatureKelvin = kelvin, + MattressHeatingLevel = 0, MattressLeftFirmness = 0, MattressRightFirmness = 0, + MattressMode = "智能适配", + EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1, + EnableSpeakerSleepMode = true, SpeakerVolumePercent = volume + } + }; + } +} diff --git a/Evergreen.Health.Application/Health/Services/MattressComfortService.cs b/Evergreen.Health.Application/Health/Services/MattressComfortService.cs new file mode 100644 index 0000000..aad0a8e --- /dev/null +++ b/Evergreen.Health.Application/Health/Services/MattressComfortService.cs @@ -0,0 +1,127 @@ +using Evergreen.Health.Application.Health.Entities; +using Evergreen.Health.Application.Health.Models; +using System.Text.Json; + +namespace Evergreen.Health.Application.Health.Services; + +// 重庆演示场景:BMI 只给出可手动修改的软硬起点,不是医学或人体工学诊断。 +public sealed class MattressComfortService : ITransient +{ + // 演示使用 Open-Meteo 接口;正式商用前应确认许可或替换为有授权的天气源。 + private const string WeatherUrl = + "https://api.open-meteo.com/v1/forecast?latitude=29.56&longitude=106.55¤t=temperature_2m%2Capparent_temperature&timezone=Asia%2FShanghai"; + private static readonly HttpClient WeatherClient = new() { Timeout = TimeSpan.FromSeconds(6) }; + private static readonly SemaphoreSlim WeatherLock = new(1, 1); + private static WeatherSample? CachedWeather; + + public async Task GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default) + { + decimal? bmi = null; + if (profile?.HeightCm > 0 && profile.WeightKg > 0) + { + var height = profile.HeightCm.Value / 100m; + bmi = Math.Round(profile.WeightKg.Value / (height * height), 1); + } + + var firmness = bmi switch + { + < 18.5m => "很软", + < 24m => "稍软", + < 28m => "稍硬", + < 32m => "很硬", + >= 32m => "最硬", + _ => null + }; + var chinaNow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8)); + var winter = chinaNow.Month is 12 or 1 or 2; + var weather = await ReadWeatherAsync(cancellationToken); + var heating = 0; + if (winter && weather?.FeelsLikeCelsius is double feelsLike) + heating = feelsLike < 8 ? 2 : feelsLike < 16 ? 1 : 0; + + var weatherReason = !winter + ? "重庆非冬季,床垫加热关闭" + : weather == null + ? "重庆天气暂不可用,为安全起见不自动加热" + : heating == 0 + ? "重庆处于冬季,但当前体感温度较高,不自动加热" + : $"重庆冬季体感 {weather.FeelsLikeCelsius:0.#}℃,建议加热 {heating} 档"; + + return new MattressComfortOutput + { + Bmi = bmi, + Firmness = firmness, + HeatingLevel = heating, + IsWinter = winter, + OutdoorTemperatureCelsius = weather?.TemperatureCelsius, + FeelsLikeCelsius = weather?.FeelsLikeCelsius, + WeatherAvailable = weather != null, + WeatherObservedAt = weather?.ObservedAt, + WeatherReason = weatherReason, + FirmnessReason = firmness == null + ? "健康档案尚无身高和体重,暂不自动调节软硬度" + : $"按 BMI {bmi:0.0} 给出“{firmness}”演示起点,可在设备详情手动调整" + }; + } + + public static void ApplyTo(PersonalizedSceneConfig config, MattressComfortOutput recommendation) + { + config.MattressHeatingLevel = recommendation.HeatingLevel; + config.MattressLeftFirmness = recommendation.Firmness == null ? 0 : FirmnessLevel(recommendation.Firmness); + config.MattressRightFirmness = config.MattressLeftFirmness; + } + + public static int FirmnessLevel(string firmness) => firmness switch + { + "最软" => 1, "很软" => 2, "稍软" => 3, + "稍硬" => 4, "很硬" => 5, "最硬" => 6, + _ => 0 + }; + + public static string? FirmnessName(int level) => level switch + { + 1 => "最软", 2 => "很软", 3 => "稍软", + 4 => "稍硬", 5 => "很硬", 6 => "最硬", + _ => null + }; + + private static async Task ReadWeatherAsync(CancellationToken cancellationToken) + { + if (CachedWeather is { } cached && DateTimeOffset.UtcNow - cached.FetchedAt < TimeSpan.FromMinutes(30)) + return cached; + + await WeatherLock.WaitAsync(cancellationToken); + try + { + if (CachedWeather is { } fresh && DateTimeOffset.UtcNow - fresh.FetchedAt < TimeSpan.FromMinutes(30)) + return fresh; + + using var response = await WeatherClient.GetAsync(WeatherUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + var current = json.RootElement.GetProperty("current"); + var temperature = current.GetProperty("temperature_2m").GetDouble(); + var feelsLike = current.GetProperty("apparent_temperature").GetDouble(); + var observedAt = current.GetProperty("time").GetString(); + CachedWeather = new WeatherSample(temperature, feelsLike, observedAt, DateTimeOffset.UtcNow); + return CachedWeather; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // 天气失败时不使用过期值自动开启加热,冬季也安全关闭。 + return null; + } + finally + { + WeatherLock.Release(); + } + } + + private sealed record WeatherSample( + double TemperatureCelsius, double FeelsLikeCelsius, string? ObservedAt, DateTimeOffset FetchedAt); +}