Files
Core_Be/Services/AgentService.cs
T
Dhananjaya99 bffc9b1686 first commit
2026-09-28 11:44:26 +05:30

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);
}
}
}
}