Files
Evergreen-Health/Evergreen.Health.Application/Health/Services/HealthIb43Service.cs
T
2026-11-11 16:11:41 +08:00

140 lines
7.8 KiB
C#

using Evergreen.Health.Application.Health.Entities;
using Evergreen.Health.Application.Health.Integrations.Ib43;
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);
[Authorize]
[ApiDescriptionSettings("健康管理", Name = "HealthIb43", Order = 53)]
public sealed class HealthIb43Service(Ib43DataRepository repository, IHttpContextAccessor context,
IOptions<Ib43Options> options, Ib43StateStore states, TimeProvider clock) : 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());
}
[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);
}