Files
Evergreen-Health/Evergreen.Health.Application/Health/Services/HealthIb43Service.cs
T
2026-11-12 15:45:52 +08:00

185 lines
11 KiB
C#

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<string, Ib43MetricOutput> Readings);
public sealed record Ib43HistoryItem(long Id, long ReceivedAtMs, IReadOnlyDictionary<string, Ib43Reading> Readings);
public sealed record Ib43HistoryOutput(IReadOnlyList<Ib43HistoryItem> 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<Ib43AutomationRecord> Records, IReadOnlyList<BedPresenceRuleStatus> Rules);
[Authorize]
[ApiDescriptionSettings("健康管理", Name = "HealthIb43", Order = 53)]
public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContextAccessor context,
IOptions<Ib43Options> options, Ib43StateStore states, TimeProvider clock, BedPresenceAutomation automation) : IDynamicApiController, ITransient
{
[HttpGet("/api/health/ib43/admin/bindings")]
public async Task<Ib43AdminBedOutput[]> 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<Ib43BedOutput> 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<Ib43AutomationOutput> 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<Ib43BedOutput[]> Bindings()
{
var user = await RequireUser();
return (await repository.Bindings(user)).Select(Map).ToArray();
}
[HttpGet("/api/health/ib43/bindings/{bindingId}/current")]
public async Task<Ib43CurrentOutput> 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<string, Ib43MetricOutput>();
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<Ib43HistoryOutput> 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<Dictionary<string, Ib43Reading>>(r.ReadingsJson))).ToArray();
return new(items, more ? items[^1].Id : null);
}
private async Task<long> 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<long> 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);
}