first commit
This commit is contained in:
@@ -0,0 +1,731 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user