using Evergreen.Health.Application.Health.Entities; using Evergreen.Health.Application.Health.Integrations.Ib43; using Evergreen.Health.Application.Health.Sleep; using Furion.FriendlyException; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using System.ComponentModel.DataAnnotations; using System.Security.Claims; using System.Text.Json; namespace Evergreen.Health.Application.Health.Services; public sealed class AssignIb43BedInput { [Required, MaxLength(128)] public string ProductKey { get; set; } [Required, MaxLength(128)] public string SerialNumber { get; set; } [Required, RegularExpression("^(left|right)$")] public string Side { get; set; } [Range(1, long.MaxValue)] public long UserId { get; set; } [Required, MaxLength(80)] public string BedName { get; set; } } public sealed record Ib43BedOutput(string Id, string ProductKey, string SerialNumber, string Side, string BedName, bool Simulation, long StartedAtMs, long? EndedAtMs); public sealed record Ib43AdminBedOutput(Ib43BedOutput Bed, long UserId); public sealed record Ib43MetricOutput(double? Value, DateTimeOffset? ReceivedAt, string Source, string Freshness); public sealed record Ib43CurrentOutput(Ib43BedOutput Bed, string Transport, bool Active, IReadOnlyDictionary Readings); public sealed record Ib43HistoryItem(long Id, long ReceivedAtMs, IReadOnlyDictionary Readings); public sealed record Ib43HistoryOutput(IReadOnlyList Items, long? NextBeforeId); public sealed record Ib43AutomationRecord(string Id, string ActionKey, string TargetLabel, long TriggeredAtMs, long? CompletedAtMs, string Result); public sealed record Ib43AutomationOutput(bool Available, bool Enabled, string ActionLabel, string TargetLabel, IReadOnlyList Records, IReadOnlyList Rules); [Authorize] [ApiDescriptionSettings("健康管理", Name = "HealthIb43", Order = 53)] public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContextAccessor context, IOptions options, Ib43StateStore states, TimeProvider clock, BedPresenceAutomation automation) : IDynamicApiController, ITransient { [HttpGet("/api/health/ib43/admin/bindings")] public async Task ActiveBindings(string productKey, string serialNumber) { await RequireAdmin(); if (string.IsNullOrWhiteSpace(productKey) || string.IsNullOrWhiteSpace(serialNumber) || productKey.Length > 128 || serialNumber.Length > 128) throw new AppFriendlyException("请指定产品和设备编号", null) { StatusCode = 400 }; return (await repository.ActiveBindings(productKey, serialNumber)).Select(b => new Ib43AdminBedOutput(Map(b), b.UserId)).ToArray(); } [HttpPost("/api/health/ib43/admin/bindings")] public async Task Assign(AssignIb43BedInput input) { var admin = await RequireAdmin(); if (input == null || input.Side is not ("left" or "right") || string.IsNullOrWhiteSpace(input.BedName) || input.BedName.Length > 80 || input.UserId <= 0) throw new AppFriendlyException("床位参数不合法", null) { StatusCode = 400 }; var device = options.Value.Devices.SingleOrDefault(d => d.ProductKey == input.ProductKey && d.SerialNumber == input.SerialNumber); if (device == null || input.ProductKey.Length > 128 || input.SerialNumber.Length > 128) throw new AppFriendlyException("设备不在后台接入白名单中", null) { StatusCode = 400 }; if (!await repository.UserEnabled(input.UserId)) throw new AppFriendlyException("目标健康用户不存在或已停用", null) { StatusCode = 400 }; var key = Ib43DataRepository.DeviceKey(device.ProductKey, device.SerialNumber); var binding = new HealthIb43Binding { DeviceKey = key, ProductKey = device.ProductKey, SerialNumber = device.SerialNumber, Side = input.Side, ActiveSlot = key + "." + input.Side, UserId = input.UserId, BedName = input.BedName.Trim(), Simulation = device.Simulation, StartedAtMs = clock.GetUtcNow().ToUnixTimeMilliseconds(), AssignedBy = admin }; await repository.Assign(binding); return Map(binding); } [HttpDelete("/api/health/ib43/admin/bindings/{bindingId}")] public async Task Release(string bindingId) { var admin = await RequireAdmin(); await repository.Release(bindingId, admin, clock.GetUtcNow().ToUnixTimeMilliseconds()); automation.RevokeBinding(bindingId); } [HttpGet("/api/health/ib43/bindings/{bindingId}/automation")] public async Task AutomationStatus(string bindingId) { var user = await RequireUser(); var binding = await repository.OwnedBinding(user, bindingId); var records = await repository.AutomationHistory(user, binding.Id); var bed = AutomationBinding(binding); var canManage = CanManageAutomation(binding); var primary = canManage ? automation.PrimaryRuleStatus(bed) : null; return new(primary is not null, primary?.Enabled == true, primary?.ActionLabel ?? "", primary?.TargetLabel ?? "", records.Select(row => new Ib43AutomationRecord(row.Id, row.ActionKey, row.TargetLabel, row.TriggeredAtMs, row.CompletedAtMs, row.Result)).ToArray(), canManage ? automation.RuleStatuses(bed) : []); } [HttpPost("/api/health/ib43/bindings/{bindingId}/automation/start")] public async Task StartAutomation(string bindingId, [FromQuery] string? actionKey = null) { var user = await RequireUser(); var binding = await repository.OwnedBinding(user, bindingId); if (!CanManageAutomation(binding) || !automation.TryEnable(AutomationBinding(binding), actionKey)) throw new AppFriendlyException("该床位当前无法开启家具联动", null) { StatusCode = 409 }; } [HttpPost("/api/health/ib43/bindings/{bindingId}/automation/stop")] public async Task StopAutomation(string bindingId, [FromQuery] string? actionKey = null) { var user = await RequireUser(); var binding = await repository.OwnedBinding(user, bindingId); if (!automation.Disable(binding.Id, user, actionKey)) throw new AppFriendlyException("家具联动规则不存在", null) { StatusCode = 404 }; } private bool CanManageAutomation(HealthIb43Binding binding) => binding.EndedAtMs is null && binding.Side is "left" or "right" && options.Value.Enabled && options.Value.Devices.Any(device => device.Simulation && device.ProductKey == binding.ProductKey && device.SerialNumber == binding.SerialNumber); private static BedAutomationBinding AutomationBinding(HealthIb43Binding binding) => new(binding.Id, binding.UserId, binding.ProductKey, binding.SerialNumber, binding.Side == "left" ? BedSide.Left : BedSide.Right, binding.Simulation); [HttpGet("/api/health/ib43/bindings")] public async Task Bindings() { var user = await RequireUser(); return (await repository.Bindings(user)).Select(Map).ToArray(); } [HttpGet("/api/health/ib43/bindings/{bindingId}/current")] public async Task Current(string bindingId) { var user = await RequireUser(); var binding = await repository.OwnedBinding(user, bindingId); var readings = await repository.Latest(user, bindingId); var now = clock.GetUtcNow(); var projected = new Dictionary(); foreach (var key in new[] { "heartRate", "respiration", "presence", "snoring" }) { readings.TryGetValue(key, out var reading); projected[key] = reading == null ? new(null, null, null, "unknown") : new(reading.Value, reading.ReceivedAt, reading.Source, now - reading.ReceivedAt > TimeSpan.FromSeconds(options.Value.StaleAfterSeconds) ? "stale" : "recent"); } var transport = states.Snapshot().FirstOrDefault(s => s.ProductKey == binding.ProductKey && s.SerialNumber == binding.SerialNumber && s.Simulation == binding.Simulation)?.Transport ?? "unconfigured"; return new(Map(binding), transport, binding.EndedAtMs == null, projected); } [HttpGet("/api/health/ib43/bindings/{bindingId}/history")] public async Task History(string bindingId, long fromMs, long toMs, long? beforeId = null, int take = 100) { var user = await RequireUser(); await repository.OwnedBinding(user, bindingId); // 固定半开 UTC 毫秒区间;游标按数据库接收记录Id倒序,避免同毫秒漏页。 if (fromMs < 0 || toMs <= fromMs || toMs - fromMs > 7L * 86400000 || take is < 1 or > 200 || beforeId is <= 0) throw new AppFriendlyException("查询范围最多7天,每页1至200条", null) { StatusCode = 400 }; var rows = await repository.History(user, bindingId, fromMs, toMs, beforeId, take + 1); var more = rows.Count > take; var items = rows.Take(take).Select(r => new Ib43HistoryItem(r.Id, r.ReceivedAtMs, JsonSerializer.Deserialize>(r.ReadingsJson))).ToArray(); return new(items, more ? items[^1].Id : null); } private async Task RequireUser() { var principal = context.HttpContext?.User; if (principal?.Identity?.IsAuthenticated != true || !long.TryParse(principal.FindFirstValue("health_user_id"), out var user) || user <= 0) throw new AppFriendlyException("请先登录健康端账号", null) { StatusCode = 401 }; if (!await repository.UserEnabled(user)) throw new AppFriendlyException("健康用户已停用或不存在", null) { StatusCode = 403 }; return user; } private async Task RequireAdmin() { var principal = context.HttpContext?.User; if (principal?.Identity?.IsAuthenticated != true || principal.HasClaim(c => c.Type == "health_user_id") || principal.FindFirstValue(ClaimConst.AccountType) != ((int)AccountTypeEnum.SuperAdmin).ToString() || !long.TryParse(principal.FindFirstValue(ClaimConst.UserId), out var admin) || admin <= 0) throw new AppFriendlyException("只有后台超级管理员可以分配床位", null) { StatusCode = 403 }; if (!await repository.AdminEnabled(admin)) throw new AppFriendlyException("后台管理员已失效", null) { StatusCode = 403 }; return admin; } private static Ib43BedOutput Map(HealthIb43Binding b) => new(b.Id, b.ProductKey, b.SerialNumber, b.Side, b.BedName, b.Simulation, b.StartedAtMs, b.EndedAtMs); }