This commit is contained in:
fuenmao
2026-09-23 18:19:40 +08:00
parent db7bd2231a
commit 6a622c9bd5
8 changed files with 534 additions and 247 deletions
@@ -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",
@@ -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", "用户健康板块选择")]
@@ -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<string, string> 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;
@@ -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<string, string> Answers { get; set; } = new();
public Dictionary<string, string> 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<string, int> Scores { get; set; } = new();
public Dictionary<string, string> Answers { get; set; } = new();
public Dictionary<string, string> 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; } = "正常";
}
@@ -24,6 +24,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
private readonly SqlSugarRepository<HealthProfile> _profileRepository;
private readonly SqlSugarRepository<HealthModuleSelection> _moduleRepository;
private readonly SqlSugarRepository<HealthPlan> _planRepository;
private readonly MattressComfortService _mattressComfort;
private readonly IHttpContextAccessor _httpContextAccessor;
public HealthAppService(
@@ -31,12 +32,14 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
SqlSugarRepository<HealthProfile> profileRepository,
SqlSugarRepository<HealthModuleSelection> moduleRepository,
SqlSugarRepository<HealthPlan> 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<PersonalizedSceneConfig>(profile.CustomSceneConfigJson);
}
catch
{
}
}
if (sceneConfig == null && hasCompletedAssessment)
{
var answers = JsonSerializer.Deserialize<HealthAssessmentAnswers>(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<HealthAssessmentAnswers>(profile.AssessmentJson) ?? new HealthAssessmentAnswers();
var hasCompleted = !string.IsNullOrWhiteSpace(profile.AssessmentJson);
var sceneConfig = string.IsNullOrWhiteSpace(profile.CustomSceneConfigJson)
? CalculatePersonalizedSceneConfig(answers, MapProfile(profile))
: JsonSerializer.Deserialize<PersonalizedSceneConfig>(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<List<HealthPlanItemOutput>>(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}结果用于了解身体基本情况,不参与智能家居启动参数计算。";
}
}
@@ -22,6 +22,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
private readonly HomeAssistantOptions _options;
private readonly SqlSugarRepository<HealthSceneExecution> _executionRepository;
private readonly SqlSugarRepository<HealthProfile> _profileRepository;
private readonly MattressComfortService _mattressComfort;
private readonly IHttpContextAccessor _httpContextAccessor;
public HealthHomeAssistantService(
@@ -31,6 +32,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
IOptions<HomeAssistantOptions> options,
SqlSugarRepository<HealthSceneExecution> executionRepository,
SqlSugarRepository<HealthProfile> 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<PersonalizedSceneConfig>(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<HomeAssistantScenePresetItem>()
: BuildScenePresets(stateMap, userConfig);
return new HomeAssistantStatusOutput
{
@@ -188,7 +196,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
Devices = [],
SceneDevices = BuildSceneDevicesSummary(new Dictionary<string, HomeAssistantState>(StringComparer.OrdinalIgnoreCase)),
Environment = new HomeAssistantEnvironmentOutput(),
ScenePresets = BuildScenePresets(new Dictionary<string, HomeAssistantState>(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<PersonalizedSceneConfig>(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<HomeAssistantSceneOutput> 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<PersonalizedSceneConfig>(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<string, object?> { ["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<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
await RunAction(states, results, "设置床垫左侧模式", entities.MattressLeftMode, "select", "select_option",
new Dictionary<string, object?> { ["option"] = mattressMode }, cancellationToken);
await RunAction(states, results, "设置床垫右侧模式", entities.MattressRightMode, "select", "select_option",
new Dictionary<string, object?> { ["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<string, object?> { ["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<string, HomeAssistantParameterStateOutput>(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<HomeAssistantScenePresetItem> BuildScenePresets(
IReadOnlyDictionary<string, HomeAssistantState> 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 ?? "未自动调节"
}
});
}
@@ -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<HealthProfile> _profiles;
private readonly IHttpContextAccessor _http;
public HealthTcmAssessmentService(SqlSugarRepository<HealthProfile> profiles, IHttpContextAccessor http)
{
_profiles = profiles;
_http = http;
}
[Authorize]
[HttpGet("/api/health/tcm-assessment")]
public async Task<TcmAssessmentResult> 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<TcmAssessmentResult> 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<string, string> Names = new()
{
["balanced"] = "平和质", ["qi_deficient"] = "气虚质", ["yang_deficient"] = "阳虚质",
["yin_deficient"] = "阴虚质", ["phlegm_damp"] = "痰湿质", ["damp_heat"] = "湿热质",
["blood_stasis"] = "血瘀质", ["qi_stagnation"] = "气郁质", ["special"] = "特禀质"
};
private static readonly Dictionary<string, string[]> 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<string, (string Type, int Points)[]> 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<TcmAssessmentResult>(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<string, string>())
.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
}
};
}
}
@@ -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&current=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<MattressComfortOutput> 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<WeatherSample?> 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);
}