using Evergreen.Health.Application.Health.Entities; using Evergreen.Health.Application.Health.Models; using Evergreen.Health.Application.Health.Security; using Furion.DataEncryption; 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 = "Health", Order = 50)] public sealed class HealthAppService : IDynamicApiController, ITransient { private const string HealthUserIdClaim = "health_user_id"; private static readonly HashSet SupportedModules = new(StringComparer.OrdinalIgnoreCase) { "sleep", "exercise", "diet" }; private readonly SqlSugarRepository _userRepository; private readonly SqlSugarRepository _profileRepository; private readonly SqlSugarRepository _moduleRepository; private readonly SqlSugarRepository _planRepository; private readonly MattressComfortService _mattressComfort; private readonly IHttpContextAccessor _httpContextAccessor; public HealthAppService( SqlSugarRepository userRepository, SqlSugarRepository profileRepository, SqlSugarRepository moduleRepository, SqlSugarRepository planRepository, MattressComfortService mattressComfort, IHttpContextAccessor httpContextAccessor) { _userRepository = userRepository; _profileRepository = profileRepository; _moduleRepository = moduleRepository; _planRepository = planRepository; _mattressComfort = mattressComfort; _httpContextAccessor = httpContextAccessor; } [AllowAnonymous] [HttpPost("/api/health/auth/register")] [UnitOfWork] public async Task Register(HealthRegisterInput input) { var phone = input.Phone.Trim(); if (await _userRepository.IsAnyAsync(user => user.Phone == phone && !user.IsDelete)) throw Oops.Oh("该手机号已经注册"); var user = await _userRepository.AsInsertable(new HealthUser { Phone = phone, DisplayName = input.DisplayName.Trim(), PasswordHash = HealthPasswordHasher.Hash(input.Password), Status = HealthUserStatus.Enabled }).ExecuteReturnEntityAsync(); await _profileRepository.InsertAsync(new HealthProfile { UserId = user.Id }); await _moduleRepository.InsertRangeAsync(new List { new HealthModuleSelection { UserId = user.Id, ModuleCode = "sleep", IsEnabled = true }, new HealthModuleSelection { UserId = user.Id, ModuleCode = "exercise", IsEnabled = true }, new HealthModuleSelection { UserId = user.Id, ModuleCode = "diet", IsEnabled = true } }); return CreateAuthOutput(user); } [AllowAnonymous] [HttpPost("/api/health/auth/login")] public async Task Login(HealthLoginInput input) { var phone = input.Phone.Trim(); var user = await _userRepository.GetFirstAsync(item => item.Phone == phone && !item.IsDelete); if (user is null || !HealthPasswordHasher.Verify(input.Password, user.PasswordHash)) throw Oops.Oh("手机号或密码错误").StatusCode(401); if (user.Status != HealthUserStatus.Enabled) throw Oops.Oh("账号已停用").StatusCode(403); user.LastLoginTime = DateTime.Now; user.LastLoginIp = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); await _userRepository.AsUpdateable(user) .UpdateColumns(item => new { item.LastLoginTime, item.LastLoginIp }) .ExecuteCommandAsync(); return CreateAuthOutput(user); } [Authorize] [HttpGet("/api/health/dashboard")] public async Task GetDashboard() { var userId = GetCurrentUserId(); var user = await GetCurrentUser(userId); var profile = await _profileRepository.GetFirstAsync(item => item.UserId == userId); var modules = await _moduleRepository.AsQueryable() .Where(item => item.UserId == userId && item.IsEnabled) .Select(item => item.ModuleCode) .ToListAsync(); var plan = await _planRepository.AsQueryable() .Where(item => item.UserId == userId && item.IsCurrent) .OrderByDescending(item => item.Version) .FirstAsync(); var hasCompletedAssessment = profile != null && !string.IsNullOrWhiteSpace(profile.AssessmentJson); decimal? bmi = null; var bmiStatus = string.Empty; if (profile?.HeightCm > 0 && profile.WeightKg > 0) { var h = profile.HeightCm.Value / 100m; bmi = Math.Round(profile.WeightKg.Value / (h * h), 1); bmiStatus = bmi < 18.5m ? "偏瘦" : bmi <= 23.9m ? "正常" : bmi <= 27.9m ? "超重" : "肥胖"; } var tcmResult = TcmConstitutionRules.Read(profile); MattressComfortOutput? mattressComfort = null; if (tcmResult.HasCompleted) { mattressComfort = await _mattressComfort.GetAsync(profile); MattressComfortService.ApplyTo(tcmResult.SceneConfig, mattressComfort); } return new HealthDashboardOutput { User = MapUser(user), Profile = profile is null ? null : MapProfile(profile), SelectedModules = modules, CurrentPlan = plan is null ? null : MapPlan(plan), HasCompletedAssessment = hasCompletedAssessment, Bmi = bmi, BmiStatus = bmiStatus, SceneConfig = tcmResult.HasCompleted ? tcmResult.SceneConfig : null, TcmResult = tcmResult, MattressComfort = mattressComfort }; } [Authorize] [HttpGet("/api/health/assessment")] public async Task GetAssessment() { var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); if (profile == null) { return new HealthAssessmentOutput { HasCompletedAssessment = false, BasicInfo = new SaveHealthProfileInput(), Answers = new HealthAssessmentAnswers() }; } var answers = string.IsNullOrWhiteSpace(profile.AssessmentJson) ? new HealthAssessmentAnswers() : JsonSerializer.Deserialize(profile.AssessmentJson) ?? new HealthAssessmentAnswers(); var hasCompleted = !string.IsNullOrWhiteSpace(profile.AssessmentJson); decimal? bmi = null; var bmiStatus = "正常"; if (profile.HeightCm > 0 && profile.WeightKg > 0) { var h = profile.HeightCm.Value / 100m; bmi = Math.Round(profile.WeightKg.Value / (h * h), 1); bmiStatus = bmi < 18.5m ? "偏瘦" : bmi <= 23.9m ? "正常" : bmi <= 27.9m ? "超重" : "肥胖"; } return new HealthAssessmentOutput { HasCompletedAssessment = hasCompleted, BasicInfo = MapProfile(profile), Answers = answers, Bmi = bmi, BmiStatus = bmiStatus }; } [Authorize] [HttpPost("/api/health/assessment")] [UnitOfWork] public async Task SaveAssessment(SaveHealthAssessmentInput input) { ValidateAssessment(input); var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); if (profile == null) { profile = new HealthProfile { UserId = userId }; ApplyProfile(profile, input.BasicInfo); profile.AssessmentJson = JsonSerializer.Serialize(input.Answers); await _profileRepository.InsertAsync(profile); } else { ApplyProfile(profile, input.BasicInfo); profile.AssessmentJson = JsonSerializer.Serialize(input.Answers); await _profileRepository.UpdateAsync(profile); } var modules = new List { "sleep", "exercise", "diet" }; var existingModules = await _moduleRepository.AsQueryable().Where(m => m.UserId == userId).ToListAsync(); foreach (var mod in modules) { var existing = existingModules.FirstOrDefault(m => m.ModuleCode == mod); if (existing == null) { await _moduleRepository.InsertAsync(new HealthModuleSelection { UserId = userId, ModuleCode = mod, IsEnabled = true }); } else if (!existing.IsEnabled) { existing.IsEnabled = true; await _moduleRepository.UpdateAsync(existing); } } var maxVersion = await _planRepository.AsQueryable().Where(p => p.UserId == userId).MaxAsync(p => (int?)p.Version) ?? 0; await _planRepository.UpdateAsync(p => new HealthPlan { IsCurrent = false }, p => p.UserId == userId && p.IsCurrent); var planItems = BuildPlanItems(profile, modules); var plan = new HealthPlan { UserId = userId, Version = maxVersion + 1, Title = $"个性化健康方案 v{maxVersion + 1}.0", Summary = BuildAssessmentPlanSummary(input.Answers), ParametersJson = JsonSerializer.Serialize(planItems), IsCurrent = true }; await _planRepository.InsertAsync(plan); return await GetAssessment(); } [Authorize] [HttpPut("/api/health/profile")] public async Task SaveProfile(SaveHealthProfileInput input) { var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(item => item.UserId == userId); if (profile is null) { profile = new HealthProfile { UserId = userId }; ApplyProfile(profile, input); await _profileRepository.InsertAsync(profile); } else { ApplyProfile(profile, input); await _profileRepository.UpdateAsync(profile); } return MapProfile(profile); } [Authorize] [HttpPut("/api/health/modules")] [UnitOfWork] public async Task> SaveModules(SaveHealthModulesInput input) { var userId = GetCurrentUserId(); var modules = input.ModuleCodes .Select(item => item.Trim().ToLowerInvariant()) .Where(SupportedModules.Contains) .Distinct() .ToList(); if (modules.Count == 0) throw Oops.Oh("请至少选择一个健康板块"); await _moduleRepository.DeleteAsync(item => item.UserId == userId); await _moduleRepository.InsertRangeAsync(modules.Select(module => new HealthModuleSelection { UserId = userId, ModuleCode = module, IsEnabled = true }).ToList()); return modules; } [Authorize] [HttpPost("/api/health/plans/generate")] [UnitOfWork] public async Task GeneratePlan() { var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(item => item.UserId == userId); var modules = await _moduleRepository.AsQueryable() .Where(item => item.UserId == userId && item.IsEnabled) .Select(item => item.ModuleCode) .ToListAsync(); if (modules.Count == 0) throw Oops.Oh("请先选择健康板块"); var items = BuildPlanItems(profile, modules); var currentVersion = await _planRepository.AsQueryable() .Where(item => item.UserId == userId) .MaxAsync(item => (int?)item.Version) ?? 0; await _planRepository.Context.Updateable() .SetColumns(item => item.IsCurrent == false) .Where(item => item.UserId == userId && item.IsCurrent) .ExecuteCommandAsync(); var plan = await _planRepository.AsInsertable(new HealthPlan { UserId = userId, Version = currentVersion + 1, Title = "您的专属健康方案", Summary = BuildSummary(profile, modules), ParametersJson = JsonSerializer.Serialize(items), IsCurrent = true }).ExecuteReturnEntityAsync(); return MapPlan(plan); } private long GetCurrentUserId() { var value = _httpContextAccessor.HttpContext?.User.FindFirstValue(HealthUserIdClaim); if (!long.TryParse(value, out var userId)) throw Oops.Oh("登录状态已失效").StatusCode(401); return userId; } private async Task GetCurrentUser(long userId) { return await _userRepository.GetFirstAsync(item => item.Id == userId && !item.IsDelete) ?? throw Oops.Oh("用户不存在").StatusCode(401); } private static void ApplyProfile(HealthProfile profile, SaveHealthProfileInput input) { profile.Birthday = input.Birthday; profile.Gender = input.Gender?.Trim(); profile.HeightCm = input.HeightCm; profile.WeightKg = input.WeightKg; profile.AverageSleepHours = input.AverageSleepHours; profile.DailyExerciseMinutes = input.DailyExerciseMinutes; profile.DietPreference = input.DietPreference?.Trim(); profile.HealthGoal = input.HealthGoal?.Trim(); } private static SaveHealthProfileInput MapProfile(HealthProfile profile) => new() { Birthday = profile.Birthday, Gender = profile.Gender, HeightCm = profile.HeightCm, WeightKg = profile.WeightKg, AverageSleepHours = profile.AverageSleepHours, DailyExerciseMinutes = profile.DailyExerciseMinutes, DietPreference = profile.DietPreference, HealthGoal = profile.HealthGoal }; private static HealthUserOutput MapUser(HealthUser user) => new() { Id = user.Id, Phone = user.Phone, DisplayName = user.DisplayName, AvatarUrl = user.AvatarUrl }; private static List BuildPlanItems(HealthProfile? profile, IEnumerable modules) { var items = new List(); foreach (var module in modules) { switch (module) { case "sleep": items.Add(new HealthPlanItemOutput { ModuleCode = module, Title = "睡眠质量", Target = profile?.AverageSleepHours is < 7 ? "逐步达到每晚 7–8 小时" : "保持每晚 7–8 小时", Advice = "固定入睡时间,睡前一小时减少屏幕使用,保持卧室安静。" }); break; case "exercise": items.Add(new HealthPlanItemOutput { ModuleCode = module, Title = "运动情况", Target = profile?.DailyExerciseMinutes is >= 30 ? "保持每日 30 分钟以上活动" : "从每日 20 分钟步行开始", Advice = "优先安排低到中等强度运动,每坐一小时起身活动 3–5 分钟。" }); break; case "diet": items.Add(new HealthPlanItemOutput { ModuleCode = module, Title = "饮食情况", Target = "三餐规律,晚餐不过量", Advice = string.IsNullOrWhiteSpace(profile?.DietPreference) ? "记录三餐与饮水情况,优先增加蔬菜和优质蛋白。" : $"结合“{profile.DietPreference}”偏好控制总量,并保持食物多样性。" }); break; } } return items; } private static string BuildSummary(HealthProfile? profile, IReadOnlyCollection modules) { var bmiText = string.Empty; if (profile?.HeightCm > 0 && profile.WeightKg > 0) { var height = profile.HeightCm.Value / 100m; var bmi = Math.Round(profile.WeightKg.Value / (height * height), 1); bmiText = $" 当前 BMI 约为 {bmi}。"; } return $"方案依据您选择的 {modules.Count} 个健康板块生成。{bmiText}建议仅用于日常健康管理演示,不替代专业医疗诊断。"; } private static HealthPlanOutput MapPlan(HealthPlan plan) => new() { Id = plan.Id, Version = plan.Version, Title = plan.Title, Summary = plan.Summary.Contains("智能家具启动参数", StringComparison.Ordinal) ? "已完成健康评估,可查看睡眠、运动与饮食建议。设备启动参数仅由中医四诊体质结果生成。" : plan.Summary, CreateTime = plan.CreateTime, Items = JsonSerializer.Deserialize>(plan.ParametersJson) ?? [] }; private static HealthAuthOutput CreateAuthOutput(HealthUser user) { const int accessTokenMinutes = 24 * 60; const int refreshTokenMinutes = 7 * 24 * 60; var accessToken = JWTEncryption.Encrypt(new Dictionary { [HealthUserIdClaim] = user.Id, [ClaimTypes.NameIdentifier] = user.Id, [ClaimTypes.Name] = user.DisplayName, [ClaimTypes.MobilePhone] = user.Phone, ["account_type"] = "health_user" }, accessTokenMinutes); return new HealthAuthOutput { AccessToken = accessToken, RefreshToken = JWTEncryption.GenerateRefreshToken(accessToken, refreshTokenMinutes), User = MapUser(user) }; } private static void ValidateAssessment(SaveHealthAssessmentInput input) { var info = input.BasicInfo ?? throw Oops.Oh("请填写个人基本信息"); var answers = input.Answers ?? throw Oops.Oh("请完成健康评估问卷"); if (info.Gender is not ("男" or "女")) throw Oops.Oh("请选择性别"); if (info.Birthday is null) throw Oops.Oh("请填写年龄"); var age = DateTime.Today.Year - info.Birthday.Value.Year; if (info.Birthday.Value.Date > DateTime.Today.AddYears(-age)) age--; if (age is < 1 or > 120) throw Oops.Oh("年龄应在 1 到 120 岁之间"); if (info.HeightCm is null or < 80 or > 250) throw Oops.Oh("身高应在 80 到 250cm 之间"); if (info.WeightKg is null or < 20 or > 300) throw Oops.Oh("体重应在 20 到 300kg 之间"); RequireChoice(answers.SleepDuration, "每晚平均睡眠时长", "<6", "6-7", "7-8", ">8"); RequireChoice(answers.FallingAsleepSpeed, "入睡耗时", "hard", "normal", "fast"); RequireChoice(answers.TemperaturePreference, "温度偏好", "heat_fear", "cold_fear", "normal"); RequireChoice(answers.NightAwakening, "夜醒情况", "frequent", "light", "none"); RequireChoice(answers.MattressPreference, "床垫偏好", "firm", "medium", "soft"); RequireChoice(answers.ExerciseFrequency, "运动频率", "none", "1-2", "3-5", "daily"); RequireChoice(answers.MuscleFatigueArea, "疲劳部位", "back_neck", "waist_back", "legs", "none"); RequireChoice(answers.ActiveTimeOfDay, "活跃时段", "morning", "evening", "night", "irregular"); RequireChoice(answers.MealRegularity, "三餐规律", "regular", "skip_breakfast", "irregular"); RequireChoice(answers.FlavorPreference, "口味偏好", "light", "moderate", "heavy"); RequireChoice(answers.LateNightEating, "夜宵频率", "often", "sometimes", "rarely"); RequireChoice(answers.DailyWaterIntake, "饮水情况", "abundant", "moderate", "scarce"); } private static void RequireChoice(string value, string label, params string[] allowed) { if (string.IsNullOrWhiteSpace(value) || !allowed.Contains(value, StringComparer.Ordinal)) throw Oops.Oh($"请完成“{label}”问题"); } private static string BuildAssessmentPlanSummary(HealthAssessmentAnswers answers) { var sleepNote = answers.FallingAsleepSpeed == "hard" ? "入睡较慢,建议保持规律的睡前作息;" : ""; var activityNote = answers.ExerciseFrequency == "none" ? "可从轻度日常活动开始;" : ""; var dietNote = answers.MealRegularity == "irregular" ? "建议逐步建立规律三餐;" : ""; return $"已完成健康评估。{sleepNote}{activityNote}{dietNote}结果用于了解身体基本情况,不参与智能家居启动参数计算。"; } }