feat: initialize Evergreen Health backend

This commit is contained in:
fuenmao
2026-09-21 14:08:22 +08:00
commit 8553df5dd2
493 changed files with 75649 additions and 0 deletions
@@ -0,0 +1,331 @@
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 IHttpContextAccessor _httpContextAccessor;
public HealthAppService(
SqlSugarRepository<HealthUser> userRepository,
SqlSugarRepository<HealthProfile> profileRepository,
SqlSugarRepository<HealthModuleSelection> moduleRepository,
SqlSugarRepository<HealthPlan> planRepository,
IHttpContextAccessor httpContextAccessor)
{
_userRepository = userRepository;
_profileRepository = profileRepository;
_moduleRepository = moduleRepository;
_planRepository = planRepository;
_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();
return new HealthDashboardOutput
{
User = MapUser(user),
Profile = profile is null ? null : MapProfile(profile),
SelectedModules = modules,
CurrentPlan = plan is null ? null : MapPlan(plan)
};
}
[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,
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)
};
}
}