Files
Evergreen-Health/Evergreen.Health.Application/Health/Services/HealthAppService.cs
T
2026-11-09 16:10:05 +08:00

499 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using 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<string> SupportedModules = new(StringComparer.OrdinalIgnoreCase)
{
"sleep", "exercise", "diet"
};
private readonly SqlSugarRepository<HealthUser> _userRepository;
private readonly SqlSugarRepository<HealthProfile> _profileRepository;
private readonly SqlSugarRepository<HealthModuleSelection> _moduleRepository;
private readonly SqlSugarRepository<HealthPlan> _planRepository;
private readonly EnvironmentAdviceService _environmentAdvice;
private readonly IHttpContextAccessor _httpContextAccessor;
public HealthAppService(
SqlSugarRepository<HealthUser> userRepository,
SqlSugarRepository<HealthProfile> profileRepository,
SqlSugarRepository<HealthModuleSelection> moduleRepository,
SqlSugarRepository<HealthPlan> planRepository,
EnvironmentAdviceService environmentAdvice,
IHttpContextAccessor httpContextAccessor)
{
_userRepository = userRepository;
_profileRepository = profileRepository;
_moduleRepository = moduleRepository;
_planRepository = planRepository;
_environmentAdvice = environmentAdvice;
_httpContextAccessor = httpContextAccessor;
}
[AllowAnonymous]
[HttpPost("/api/health/auth/register")]
[UnitOfWork]
public async Task<HealthAuthOutput> 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<HealthModuleSelection>
{
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<HealthAuthOutput> 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<HealthDashboardOutput> 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);
EnvironmentAdviceOutput? environmentAdvice = null;
if (tcmResult.HasCompleted)
{
environmentAdvice = await _environmentAdvice.GetAsync(profile);
EnvironmentAdviceService.ApplyTo(tcmResult.SceneConfig, environmentAdvice);
}
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,
EnvironmentAdvice = environmentAdvice
};
}
[Authorize]
[HttpGet("/api/health/assessment")]
public async Task<HealthAssessmentOutput> 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<HealthAssessmentAnswers>(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<HealthAssessmentOutput> 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<string> { "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<SaveHealthProfileInput> 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<List<string>> 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<HealthPlanOutput> 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<HealthPlan>()
.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<HealthUser> 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<HealthPlanItemOutput> BuildPlanItems(HealthProfile? profile, IEnumerable<string> modules)
{
var items = new List<HealthPlanItemOutput>();
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<string> 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<List<HealthPlanItemOutput>>(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<string, object>
{
[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}结果用于了解身体基本情况,不参与智能家居启动参数计算。";
}
}