185 lines
11 KiB
C#
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);
|
|
}
|