732 lines
29 KiB
C#
732 lines
29 KiB
C#
using Fitness_core_BE.DTOs;
|
|
using Fitness_core_BE.DTOs.Agent;
|
|
using Fitness_core_BE.Exceptions;
|
|
using Fitness_core_BE.Helpers;
|
|
using Fitness_core_BE.Hubs;
|
|
using Fitness_core_BE.Infra.UoW;
|
|
using Fitness_core_BE.Interfaces;
|
|
using Fitness_core_BE.Interfaces.Services;
|
|
using Fitness_core_BE.Models;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Text.Json;
|
|
|
|
namespace Fitness_core_BE.Services
|
|
{
|
|
public class AgentService : IAgentService
|
|
{
|
|
private static readonly TimeSpan DevicePresenceWindow = TimeSpan.FromSeconds(90);
|
|
|
|
private readonly IUnitOfWork _UoW;
|
|
private readonly IAgentRepo _agentRepo;
|
|
private readonly IPairingCodeRepo _pairingCodeRepo;
|
|
private readonly IDeviceCommandRepo _deviceCommandRepo;
|
|
private readonly IAccessDeviceRepo _accessDeviceRepo;
|
|
private readonly IAccessDeviceService _accessDeviceService;
|
|
private readonly ILogger<AgentService> _logger;
|
|
private readonly IHubContext<AccessHub> _accessHub;
|
|
|
|
public AgentService(
|
|
IUnitOfWork unitOfWork,
|
|
IAgentRepo agentRepo,
|
|
IPairingCodeRepo pairingCodeRepo,
|
|
IDeviceCommandRepo deviceCommandRepo,
|
|
IAccessDeviceRepo accessDeviceRepo,
|
|
IAccessDeviceService accessDeviceService,
|
|
ILogger<AgentService> logger,
|
|
IHubContext<AccessHub> accessHub)
|
|
{
|
|
_UoW = unitOfWork;
|
|
_agentRepo = agentRepo;
|
|
_pairingCodeRepo = pairingCodeRepo;
|
|
_deviceCommandRepo = deviceCommandRepo;
|
|
_accessDeviceRepo = accessDeviceRepo;
|
|
_accessDeviceService = accessDeviceService;
|
|
_logger = logger;
|
|
_accessHub = accessHub;
|
|
}
|
|
|
|
public async Task<object> EnrollAsync(EnrollRequest request)
|
|
{
|
|
if (request == null || string.IsNullOrWhiteSpace(request.Code))
|
|
throw new BadRequestException("Pairing code is required.");
|
|
if (string.IsNullOrWhiteSpace(request.AgentName))
|
|
throw new BadRequestException("Agent name is required.");
|
|
|
|
var pairingCode = await _pairingCodeRepo.GetActiveByCodeAsync(request.Code);
|
|
if (pairingCode == null)
|
|
throw new BadRequestException("Pairing code is invalid, used, or expired.");
|
|
|
|
var apiKey = ApiKeyHelper.GenerateApiKey();
|
|
var agent = new Agent
|
|
{
|
|
AgentId = Guid.NewGuid(),
|
|
FKClientId = pairingCode.FKClientId,
|
|
Name = request.AgentName.Trim(),
|
|
ApiKeyHash = ApiKeyHelper.HashApiKey(apiKey),
|
|
IsActive = true,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
await _UoW.BeginAsync();
|
|
try
|
|
{
|
|
await _agentRepo.AddAsync(agent);
|
|
await _pairingCodeRepo.MarkUsedAsync(pairingCode);
|
|
await _UoW.CommitAsync();
|
|
}
|
|
catch
|
|
{
|
|
await _UoW.RollbackAsync();
|
|
throw;
|
|
}
|
|
return new { agentId = agent.AgentId, apiKey };
|
|
}
|
|
|
|
public async Task<object> RegisterDeviceAsync(
|
|
Guid clientId, Guid agentId, RegisterDeviceRequest request)
|
|
{
|
|
if (request == null || string.IsNullOrWhiteSpace(request.DeviceName))
|
|
throw new BadRequestException("Device name is required.");
|
|
|
|
var hardwareId = string.IsNullOrWhiteSpace(request.HardwareId)
|
|
? null
|
|
: request.HardwareId.Trim();
|
|
AccessDevice? device = null;
|
|
|
|
if (request.DeviceId.HasValue && request.DeviceId.Value != Guid.Empty)
|
|
{
|
|
device = await _accessDeviceRepo.GetDeviceByIdAsync(request.DeviceId.Value);
|
|
if (device != null && device.FKClientId != clientId)
|
|
throw new ForbiddenException("Device belongs to another client.");
|
|
}
|
|
|
|
if (device == null && hardwareId != null)
|
|
{
|
|
device = await _accessDeviceRepo
|
|
.GetDeviceByHardwareIdAsync(clientId, hardwareId);
|
|
}
|
|
|
|
await _UoW.BeginAsync();
|
|
try
|
|
{
|
|
if (device == null)
|
|
{
|
|
device = new AccessDevice
|
|
{
|
|
DeviceId = Guid.NewGuid(),
|
|
FKClientId = clientId,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
await _accessDeviceRepo.AddDeviceAsync(device);
|
|
}
|
|
|
|
device.DeviceName = request.DeviceName.Trim();
|
|
device.HardwareId = hardwareId ?? device.HardwareId;
|
|
device.FKAgentId = agentId;
|
|
device.IsActive = true;
|
|
device.LastSeenAt = DateTime.UtcNow;
|
|
|
|
// The row must exist before bindings/commands reference it.
|
|
await _UoW.SaveChangesAsync();
|
|
await _accessDeviceService.ProvisionProfilesToDeviceAsync(device);
|
|
await _UoW.CommitAsync();
|
|
}
|
|
catch
|
|
{
|
|
await _UoW.RollbackAsync();
|
|
throw;
|
|
}
|
|
|
|
return new { deviceId = device.DeviceId };
|
|
}
|
|
|
|
public async Task<object> ReconcileDeviceInventoryAsync(
|
|
Guid clientId, Guid agentId, DeviceInventoryRequest request)
|
|
{
|
|
if (request == null || request.DeviceId == Guid.Empty)
|
|
throw new BadRequestException("Device id is required.");
|
|
|
|
var device = await _accessDeviceRepo
|
|
.GetActiveDeviceForClientAsync(clientId, request.DeviceId);
|
|
if (device == null)
|
|
throw new ForbiddenException("Device does not belong to this client.");
|
|
if (device.FKAgentId != agentId)
|
|
throw new ForbiddenException("Device is assigned to another agent.");
|
|
|
|
var now = DateTime.UtcNow;
|
|
var discovered = 0;
|
|
var updated = 0;
|
|
var reportedEmployeeNos = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
await _UoW.BeginAsync();
|
|
try
|
|
{
|
|
device.LastSeenAt = now;
|
|
foreach (var person in request.People ?? new List<DeviceInventoryPerson>())
|
|
{
|
|
var employeeNo = person.EmployeeNo?.Trim();
|
|
if (string.IsNullOrWhiteSpace(employeeNo))
|
|
continue;
|
|
reportedEmployeeNos.Add(employeeNo);
|
|
|
|
var binding = await _accessDeviceRepo
|
|
.GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo);
|
|
if (binding != null)
|
|
{
|
|
binding.DeviceReportedName = person.Name;
|
|
binding.FingerprintCount = Math.Max(0, person.FingerprintCount);
|
|
binding.ActualAccessState =
|
|
IsBlocked(person.UserType) ? "Disabled" : "Enabled";
|
|
var matchesDesired =
|
|
binding.ActualAccessState == binding.AccessProfile.DesiredAccessState;
|
|
binding.ProvisionStatus = matchesDesired ? "Synced" : "Pending";
|
|
binding.LastError = matchesDesired
|
|
? null
|
|
: "Terminal access state differs from the platform; repair queued.";
|
|
binding.LastSyncedAt = now;
|
|
binding.UpdatedAt = now;
|
|
if (!matchesDesired)
|
|
await QueueInventoryRepairAsync(binding, "SetAccessState", now);
|
|
updated++;
|
|
continue;
|
|
}
|
|
|
|
var unlinked = await _accessDeviceRepo
|
|
.GetUnlinkedEnrollmentByEmployeeNoAsync(
|
|
request.DeviceId, employeeNo);
|
|
|
|
if (unlinked == null)
|
|
{
|
|
unlinked = new UnlinkedDeviceEnrollment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
FKDeviceId = request.DeviceId,
|
|
DeviceEmployeeNo = employeeNo,
|
|
DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0,
|
|
DiscoveredAt = now
|
|
};
|
|
await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(unlinked);
|
|
discovered++;
|
|
}
|
|
|
|
unlinked.DeviceReportedName = person.Name;
|
|
unlinked.DeviceUserType = person.UserType;
|
|
unlinked.FingerprintCount = Math.Max(0, person.FingerprintCount);
|
|
unlinked.LastSeenAt = now;
|
|
}
|
|
|
|
var missingBindings = await _accessDeviceRepo
|
|
.GetBindingsForDeviceAsync(request.DeviceId);
|
|
foreach (var binding in missingBindings.Where(
|
|
b => !reportedEmployeeNos.Contains(b.DeviceEmployeeNo)))
|
|
{
|
|
binding.ProvisionStatus = "Pending";
|
|
binding.ActualAccessState = "Unknown";
|
|
binding.FingerprintCount = 0;
|
|
binding.LastError = "Terminal person is missing; recreation queued.";
|
|
binding.LastSyncedAt = now;
|
|
binding.UpdatedAt = now;
|
|
await QueueInventoryRepairAsync(binding, "UpsertPerson", now);
|
|
}
|
|
|
|
await _UoW.CommitAsync();
|
|
}
|
|
catch
|
|
{
|
|
await _UoW.RollbackAsync();
|
|
throw;
|
|
}
|
|
|
|
var total = request.People?.Count ?? 0;
|
|
await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new
|
|
{
|
|
deviceId = request.DeviceId,
|
|
discovered,
|
|
updated,
|
|
total,
|
|
occurredAt = now
|
|
});
|
|
return new { discovered, updated, total };
|
|
}
|
|
|
|
public async Task<object> IngestAttendanceAsync(
|
|
Guid clientId, Guid agentId, AgentAttendanceRequest request)
|
|
{
|
|
if (request == null)
|
|
throw new BadRequestException("Request body is required.");
|
|
var device = await _accessDeviceRepo
|
|
.GetActiveDeviceForClientAsync(clientId, request.DeviceId);
|
|
if (device == null)
|
|
throw new ForbiddenException("Device does not belong to this client.");
|
|
if (device.FKAgentId != agentId)
|
|
throw new ForbiddenException("Device is assigned to another agent.");
|
|
|
|
var eventCount = request.Events?.Count ?? 0;
|
|
var attendanceList = new List<AttendanceDTO>();
|
|
var unknown = 0;
|
|
|
|
foreach (var ev in request.Events ?? new List<AgentAttendanceEvent>())
|
|
{
|
|
var employeeNo = ev.EmployeeNoString?.Trim();
|
|
if (string.IsNullOrWhiteSpace(employeeNo))
|
|
continue;
|
|
|
|
var binding = await _accessDeviceRepo
|
|
.GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo);
|
|
if (binding == null)
|
|
{
|
|
await UpsertUnknownFromAttendanceAsync(request.DeviceId, employeeNo);
|
|
unknown++;
|
|
continue;
|
|
}
|
|
|
|
Guid? customerId = null;
|
|
if (string.Equals(
|
|
binding.AccessProfile.PersonType,
|
|
"Customer",
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
customerId = await _accessDeviceRepo
|
|
.GetCustomerIdForUserAtClientAsync(
|
|
binding.AccessProfile.FKUserId, clientId);
|
|
}
|
|
|
|
attendanceList.Add(new AttendanceDTO
|
|
{
|
|
DeviceId = request.DeviceId,
|
|
CustomerId = customerId,
|
|
UserId = binding.AccessProfile.FKUserId,
|
|
AccessProfileId = binding.FKAccessProfileId,
|
|
DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0,
|
|
DeviceEmployeeNo = employeeNo,
|
|
EventTime = ev.Time,
|
|
EventType = string.IsNullOrWhiteSpace(ev.AttendanceStatus) ? "Unknown" : ev.AttendanceStatus
|
|
});
|
|
}
|
|
|
|
device.LastSeenAt = DateTime.UtcNow;
|
|
if (unknown > 0 || eventCount > 0)
|
|
await _UoW.SaveChangesAsync();
|
|
|
|
var inserted = await _accessDeviceService.SaveAttendanceRecords(clientId, attendanceList);
|
|
if (inserted > 0 || unknown > 0)
|
|
{
|
|
await PublishAccessEventAsync(clientId, "AttendanceUpdated", new
|
|
{
|
|
deviceId = request.DeviceId,
|
|
inserted,
|
|
unknown,
|
|
received = eventCount,
|
|
occurredAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
if (unknown > 0)
|
|
{
|
|
await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new
|
|
{
|
|
deviceId = request.DeviceId,
|
|
discovered = unknown,
|
|
updated = 0,
|
|
total = unknown,
|
|
occurredAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
_logger.LogInformation(
|
|
"Attendance ingest: client {ClientId}, device {DeviceId}, {Received} received, " +
|
|
"{Unknown} unknown, {Inserted} inserted.",
|
|
clientId, request.DeviceId, eventCount, unknown, inserted);
|
|
return new { inserted, unknown };
|
|
}
|
|
|
|
public async Task<object> GetPendingCommandsAsync(Guid clientId, Guid agentId)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
|
|
// Close commands whose final lease expired without a successful report.
|
|
var exhausted = await _deviceCommandRepo
|
|
.GetExhaustedLeasesAsync(clientId, now);
|
|
foreach (var command in exhausted)
|
|
{
|
|
command.Status = "Failed";
|
|
command.ProcessedAt = now;
|
|
command.ResultJson = command.ResultJson ?? "{\"detail\":\"Command lease expired.\"}";
|
|
if (command.AccessDeviceBinding != null)
|
|
{
|
|
command.AccessDeviceBinding.ProvisionStatus = "Failed";
|
|
command.AccessDeviceBinding.LastError = "Command lease expired.";
|
|
command.AccessDeviceBinding.UpdatedAt = now;
|
|
}
|
|
}
|
|
|
|
var commands = await _deviceCommandRepo
|
|
.GetLeaseableCommandsAsync(clientId, agentId, now, 100);
|
|
|
|
foreach (var command in commands)
|
|
{
|
|
command.Status = "InProgress";
|
|
command.AttemptCount++;
|
|
command.LeaseExpiresAt = now.AddSeconds(90);
|
|
}
|
|
await _UoW.SaveChangesAsync();
|
|
|
|
return commands.Select(c => new
|
|
{
|
|
id = c.Id,
|
|
commandType = c.CommandType,
|
|
payloadJson = c.PayloadJson,
|
|
fkDeviceId = c.FKDeviceId,
|
|
attempt = c.AttemptCount,
|
|
createdAt = c.CreatedAt
|
|
}).ToList();
|
|
}
|
|
|
|
public async Task<object> SubmitCommandResultAsync(
|
|
Guid clientId, Guid agentId, Guid commandId, CommandResultRequest request)
|
|
{
|
|
if (request == null)
|
|
throw new BadRequestException("Request body is required.");
|
|
|
|
var command = await _deviceCommandRepo.GetForAgentResultAsync(commandId)
|
|
?? throw new NotFoundException($"Command {commandId} not found.");
|
|
if (command.FKClientId != clientId)
|
|
throw new ForbiddenException("Command does not belong to this client.");
|
|
if (command.AccessDevice?.FKAgentId != agentId)
|
|
throw new ForbiddenException("Command belongs to another agent.");
|
|
|
|
var now = DateTime.UtcNow;
|
|
var binding = command.AccessDeviceBinding;
|
|
var versionIsCurrent = binding == null ||
|
|
command.DesiredVersion == binding.AccessProfile.DesiredVersion;
|
|
|
|
// A face photo carries its image in the payload and reports enrolment state on
|
|
// the profile, not the binding's access state -- so it takes its own path.
|
|
var isFacePhoto = command.CommandType == "UpsertFacePhoto";
|
|
|
|
if (isFacePhoto)
|
|
{
|
|
HandleFacePhotoResult(command, binding, request, versionIsCurrent, now);
|
|
}
|
|
else if (request.Success)
|
|
{
|
|
command.Status = "Completed";
|
|
command.ProcessedAt = now;
|
|
command.LeaseExpiresAt = null;
|
|
if (binding != null && versionIsCurrent)
|
|
{
|
|
binding.ProvisionStatus = "Synced";
|
|
binding.ActualAccessState = ReadEnabled(command.PayloadJson) ? "Enabled" : "Disabled";
|
|
binding.LastError = null;
|
|
binding.LastSyncedAt = now;
|
|
binding.UpdatedAt = now;
|
|
}
|
|
}
|
|
else if (command.AttemptCount < command.MaxAttempts && versionIsCurrent)
|
|
{
|
|
command.Status = "Pending";
|
|
command.LeaseExpiresAt = null;
|
|
command.NextAttemptAt = now.AddSeconds(Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300));
|
|
if (binding != null)
|
|
{
|
|
binding.ProvisionStatus = "Pending";
|
|
binding.LastError = ExtractResultDetail(request.ResultJson);
|
|
binding.UpdatedAt = now;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
command.Status = "Failed";
|
|
command.ProcessedAt = now;
|
|
command.LeaseExpiresAt = null;
|
|
if (binding != null && versionIsCurrent)
|
|
{
|
|
binding.ProvisionStatus = "Failed";
|
|
binding.LastError = ExtractResultDetail(request.ResultJson);
|
|
binding.UpdatedAt = now;
|
|
}
|
|
}
|
|
|
|
command.ResultJson = request.ResultJson;
|
|
await _UoW.SaveChangesAsync();
|
|
|
|
if (isFacePhoto)
|
|
{
|
|
var profile = binding?.AccessProfile;
|
|
await PublishAccessEventAsync(clientId, "FacePhotoUpdated", new
|
|
{
|
|
commandId = command.Id,
|
|
bindingId = command.FKAccessDeviceBindingId,
|
|
accessProfileId = profile?.AccessProfileId,
|
|
facePhotoStatus = profile?.FacePhotoStatus,
|
|
hasFacePhoto = profile?.HasFacePhoto,
|
|
error = profile?.FacePhotoError,
|
|
occurredAt = now
|
|
});
|
|
}
|
|
else
|
|
{
|
|
await PublishAccessEventAsync(clientId, "AccessStateUpdated", new
|
|
{
|
|
commandId = command.Id,
|
|
bindingId = command.FKAccessDeviceBindingId,
|
|
status = command.Status,
|
|
success = request.Success,
|
|
occurredAt = now
|
|
});
|
|
}
|
|
|
|
return new { command.Id, command.Status, command.AttemptCount, command.MaxAttempts };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a face-photo command result to the access profile and, on any terminal
|
|
/// state, drops the image from the command payload. The photo is only ever held
|
|
/// while it is in flight to a terminal -- never stored.
|
|
/// </summary>
|
|
private void HandleFacePhotoResult(
|
|
DeviceCommand command,
|
|
AccessDeviceBinding? binding,
|
|
CommandResultRequest request,
|
|
bool versionIsCurrent,
|
|
DateTime now)
|
|
{
|
|
var profile = binding?.AccessProfile;
|
|
|
|
if (request.Success)
|
|
{
|
|
command.Status = "Completed";
|
|
command.ProcessedAt = now;
|
|
command.LeaseExpiresAt = null;
|
|
command.PayloadJson = string.Empty; // purge the image
|
|
|
|
if (profile != null && versionIsCurrent)
|
|
{
|
|
profile.HasFacePhoto = true;
|
|
profile.FacePhotoStatus = "Synced";
|
|
profile.FacePhotoError = null;
|
|
// The agent enrols the face under the person's employeeNo, so that is the
|
|
// FPID. It is read from the result when the agent reports one, and falls
|
|
// back to the binding otherwise -- either way this must be populated, as
|
|
// it is the only handle a later replace/delete has.
|
|
profile.FacePhotoFPID =
|
|
ExtractFacePhotoFpid(request.ResultJson) ?? binding?.DeviceEmployeeNo;
|
|
profile.FacePhotoUpdatedAt = now;
|
|
profile.UpdatedAt = now;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// A rejected face (not frontal, eyes closed, poor lighting) will be rejected the
|
|
// same way on every retry, so the payload is kept only while retries remain.
|
|
if (command.AttemptCount < command.MaxAttempts && versionIsCurrent)
|
|
{
|
|
command.Status = "Pending";
|
|
command.LeaseExpiresAt = null;
|
|
command.NextAttemptAt = now.AddSeconds(
|
|
Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300));
|
|
|
|
if (profile != null)
|
|
{
|
|
profile.FacePhotoStatus = "Pending";
|
|
profile.FacePhotoError = ExtractResultDetail(request.ResultJson);
|
|
profile.FacePhotoUpdatedAt = now;
|
|
}
|
|
return;
|
|
}
|
|
|
|
command.Status = "Failed";
|
|
command.ProcessedAt = now;
|
|
command.LeaseExpiresAt = null;
|
|
command.PayloadJson = string.Empty; // purge the image
|
|
|
|
if (profile != null && versionIsCurrent)
|
|
{
|
|
profile.FacePhotoStatus = "Failed";
|
|
profile.FacePhotoError = ExtractResultDetail(request.ResultJson);
|
|
profile.FacePhotoUpdatedAt = now;
|
|
profile.UpdatedAt = now;
|
|
}
|
|
}
|
|
|
|
/// <summary>Pulls the Hikvision face-record id out of the agent's result JSON.</summary>
|
|
private static string? ExtractFacePhotoFpid(string? resultJson)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(resultJson))
|
|
return null;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(resultJson);
|
|
foreach (var name in new[] { "fpid", "FPID", "faceId", "faceURL" })
|
|
{
|
|
if (doc.RootElement.TryGetProperty(name, out var value) &&
|
|
value.ValueKind == JsonValueKind.String)
|
|
{
|
|
var id = value.GetString();
|
|
if (!string.IsNullOrWhiteSpace(id))
|
|
return id.Length <= 64 ? id : id[..64];
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
return null;
|
|
}
|
|
|
|
public async Task<object> HeartbeatAsync(
|
|
Guid clientId, Guid agentId, AgentHeartbeatRequest request)
|
|
{
|
|
var serverTime = DateTime.UtcNow;
|
|
await _agentRepo.UpdateLastSeenAsync(agentId, serverTime);
|
|
|
|
var reports = (request.Devices ?? new List<AgentDeviceHealthReport>())
|
|
.Where(report => report.DeviceId != Guid.Empty)
|
|
.GroupBy(report => report.DeviceId)
|
|
.Select(group => group.Last())
|
|
.ToList();
|
|
var reportedIds = reports.Select(report => report.DeviceId).ToList();
|
|
var devices = reportedIds.Count == 0
|
|
? new List<AccessDevice>()
|
|
: await _accessDeviceRepo.GetAgentDevicesAsync(
|
|
clientId, agentId, reportedIds);
|
|
|
|
foreach (var device in devices)
|
|
{
|
|
var report = reports.First(item => item.DeviceId == device.DeviceId);
|
|
device.IsReachable = report.IsOnline;
|
|
device.LastHealthReportedAt = serverTime;
|
|
if (report.IsOnline)
|
|
device.LastSeenAt = serverTime;
|
|
}
|
|
await _UoW.SaveChangesAsync();
|
|
|
|
var presence = devices.Select(device => new
|
|
{
|
|
deviceId = device.DeviceId,
|
|
isOnline = device.IsActive && device.IsReachable,
|
|
lastSeenAt = device.LastSeenAt,
|
|
presenceExpiresAt = device.IsActive && device.IsReachable
|
|
? serverTime.Add(DevicePresenceWindow)
|
|
: (DateTime?)null
|
|
}).ToList();
|
|
if (presence.Count > 0)
|
|
{
|
|
await PublishAccessEventAsync(clientId, "DevicePresenceUpdated", new
|
|
{
|
|
agentId,
|
|
devices = presence,
|
|
occurredAt = serverTime
|
|
});
|
|
}
|
|
|
|
return new { serverTime, devices = presence };
|
|
}
|
|
|
|
private async Task UpsertUnknownFromAttendanceAsync(Guid deviceId, string employeeNo)
|
|
{
|
|
var existing = await _accessDeviceRepo
|
|
.GetUnlinkedEnrollmentByEmployeeNoAsync(deviceId, employeeNo);
|
|
if (existing != null)
|
|
{
|
|
existing.LastSeenAt = DateTime.UtcNow;
|
|
return;
|
|
}
|
|
|
|
await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(new UnlinkedDeviceEnrollment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
FKDeviceId = deviceId,
|
|
DeviceEmployeeNo = employeeNo,
|
|
DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0,
|
|
DiscoveredAt = DateTime.UtcNow,
|
|
LastSeenAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
|
|
private async Task QueueInventoryRepairAsync(
|
|
AccessDeviceBinding binding, string commandType, DateTime now)
|
|
{
|
|
var profile = binding.AccessProfile;
|
|
var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync(
|
|
binding.AccessDeviceBindingId,
|
|
commandType,
|
|
profile.DesiredVersion);
|
|
if (alreadyQueued)
|
|
return;
|
|
|
|
var payloadJson = JsonSerializer.Serialize(new
|
|
{
|
|
bindingId = binding.AccessDeviceBindingId,
|
|
accessProfileId = profile.AccessProfileId,
|
|
employeeNo = binding.DeviceEmployeeNo,
|
|
name = profile.DisplayName,
|
|
enabled = profile.DesiredAccessState == "Enabled",
|
|
desiredVersion = profile.DesiredVersion
|
|
});
|
|
await _deviceCommandRepo.AddAsync(new DeviceCommand
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
FKClientId = profile.FKClientId,
|
|
FKDeviceId = binding.FKDeviceId,
|
|
FKAccessDeviceBindingId = binding.AccessDeviceBindingId,
|
|
CommandType = commandType,
|
|
PayloadJson = payloadJson,
|
|
DesiredVersion = profile.DesiredVersion,
|
|
Status = "Pending",
|
|
MaxAttempts = 5,
|
|
NextAttemptAt = now,
|
|
CreatedAt = now
|
|
});
|
|
}
|
|
|
|
private static bool IsBlocked(string? userType) =>
|
|
string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static bool ReadEnabled(string payloadJson)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(payloadJson);
|
|
return !doc.RootElement.TryGetProperty("enabled", out var enabled) || enabled.GetBoolean();
|
|
}
|
|
catch
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private static string ExtractResultDetail(string? resultJson)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(resultJson))
|
|
return "Device command failed.";
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(resultJson);
|
|
if (doc.RootElement.TryGetProperty("detail", out var detail))
|
|
return detail.GetString() ?? resultJson;
|
|
}
|
|
catch { }
|
|
return resultJson.Length <= 1000 ? resultJson : resultJson[..1000];
|
|
}
|
|
|
|
private async Task PublishAccessEventAsync(
|
|
Guid clientId, string eventName, object payload)
|
|
{
|
|
try
|
|
{
|
|
await _accessHub.Clients
|
|
.Group(AccessHub.ClientGroup(clientId))
|
|
.SendAsync(eventName, payload);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Device ingestion is authoritative; a transient socket/backplane
|
|
// failure must never make the agent retry a committed database write.
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Could not publish {EventName} for client {ClientId}.",
|
|
eventName,
|
|
clientId);
|
|
}
|
|
}
|
|
}
|
|
}
|