From 9605c370ae46829e033177137b99a5c4f7731850 Mon Sep 17 00:00:00 2001 From: "mustafa.ahmed" Date: Fri, 11 Sep 2026 15:03:55 +0500 Subject: [PATCH] feat: resolve sync targets from DB and harden ISAPI connectivity probes Add TargetDevice helpers to look up sync machines by IP/machine_id without site-scope filtering, and probe multiple ISAPI paths so 404 on one endpoint is not treated as offline. Include repository lookups used by sync targets. --- Data/Interfaces.cs | 30 ++ Data/MySqlRepositories.cs | 224 +++++++++- HikvisionAttendanceManager.TargetDevice.cs | 457 +++++++++++++++++++++ 3 files changed, 709 insertions(+), 2 deletions(-) create mode 100644 HikvisionAttendanceManager.TargetDevice.cs diff --git a/Data/Interfaces.cs b/Data/Interfaces.cs index 482beee..8f353a6 100644 --- a/Data/Interfaces.cs +++ b/Data/Interfaces.cs @@ -12,6 +12,10 @@ internal interface IHrmsDbConnectionFactory internal interface IAttendanceMachineRepository { List GetActiveMachines(string? machineTypeFilter, out string error); + /// Lookup by IP without site-scope filtering (used for sync targets). + bool TryGetMachineByIp(string machineIp, out AttendanceMachineRow? row, out string error); + /// Lookup by machine_id without site-scope filtering (used for sync targets). + bool TryGetMachineByMachineId(string machineId, out AttendanceMachineRow? row, out string error); void UpdateMachineSyncState(string machineIp, string status, DateTime? lastSyncDate, int? totalUsers, out string error); bool TryGetMachineLastSyncDate(string machineIp, out DateTime? lastSyncDate, out string error); bool UpdateMachineLastSyncDate(string machineIp, DateTime lastSyncDate, out string error); @@ -34,6 +38,32 @@ internal interface IAttendanceLogRepository DateTime dateOnly, Action? log, out string error); + + /// + /// Inserts one terry_attendance_log row for CONTRACTOR WORKER punches. + /// Returns false with error when a same punch already exists (duplicate protection). + /// + bool InsertTerryAttendance( + string acNo, + DateTime checkTime, + int processed, + string machineId, + int inOutTypeId, + string machineIp, + DateTime dateOnly, + DateTime forDate, + Action? log, + out bool wasDuplicate, + out string error); +} + +internal interface IEmployeeLookupRepository +{ + /// + /// Looks up employee.worker_type by employee.serial_number_int (= Hikvision employeeNo). + /// Returns false when no matching employee row exists. + /// + bool TryGetWorkerTypeBySerialNumberInt(int serialNumberInt, out string? workerType, out string error); } internal interface IAttendanceMachineUserRepository diff --git a/Data/MySqlRepositories.cs b/Data/MySqlRepositories.cs index 8555d8b..884f4d5 100644 --- a/Data/MySqlRepositories.cs +++ b/Data/MySqlRepositories.cs @@ -48,6 +48,93 @@ internal sealed class MySqlAttendanceMachineRepository : IAttendanceMachineRepos return rows; } + public bool TryGetMachineByIp(string machineIp, out AttendanceMachineRow? row, out string error) + { + row = null; + error = ""; + var wanted = (machineIp ?? "").Trim(); + if (wanted.Length == 0) + { + error = "empty machine_ip"; + return false; + } + + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "SELECT machine_id,machine_ip,port_number,machine_name,site_id,machine_status,machine_type,status,last_sync_date,total_users " + + "FROM attendance_machine WHERE TRIM(machine_ip)=@machine_ip LIMIT 1", conn); + cmd.Parameters.AddWithValue("@machine_ip", wanted); + using var rd = cmd.ExecuteReader(); + if (!rd.Read()) + return false; + + row = ReadAttendanceMachineRow(rd); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + public bool TryGetMachineByMachineId(string machineId, out AttendanceMachineRow? row, out string error) + { + row = null; + error = ""; + var wanted = (machineId ?? "").Trim(); + if (wanted.Length == 0) + { + error = "empty machine_id"; + return false; + } + + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "SELECT machine_id,machine_ip,port_number,machine_name,site_id,machine_status,machine_type,status,last_sync_date,total_users " + + "FROM attendance_machine WHERE TRIM(machine_id)=@machine_id LIMIT 1", conn); + cmd.Parameters.AddWithValue("@machine_id", wanted); + using var rd = cmd.ExecuteReader(); + if (!rd.Read()) + return false; + + row = ReadAttendanceMachineRow(rd); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + private static AttendanceMachineRow ReadAttendanceMachineRow(MySqlDataReader rd) => + new AttendanceMachineRow + { + MachineId = rd["machine_id"]?.ToString() ?? "", + MachineIp = rd["machine_ip"]?.ToString() ?? "", + PortNumber = rd["port_number"] == DBNull.Value ? 8000 : Convert.ToInt32(rd["port_number"]), + MachineName = rd["machine_name"]?.ToString() ?? "", + SiteId = rd["site_id"] == DBNull.Value ? (int?)null : Convert.ToInt32(rd["site_id"]), + MachineStatus = rd["machine_status"]?.ToString() ?? "", + MachineType = rd["machine_type"]?.ToString() ?? "", + Status = rd["status"]?.ToString() ?? "", + LastSyncDate = rd["last_sync_date"] == DBNull.Value ? (DateTime?)null : Convert.ToDateTime(rd["last_sync_date"]), + TotalUsers = rd["total_users"] == DBNull.Value ? 0 : Convert.ToInt32(rd["total_users"]) + }; + public void UpdateMachineSyncState(string machineIp, string status, DateTime? lastSyncDate, int? totalUsers, out string error) { error = ""; @@ -86,7 +173,9 @@ internal sealed class MySqlAttendanceMachineRepository : IAttendanceMachineRepos var obj = cmd.ExecuteScalar(); if (obj == null || obj == DBNull.Value) return true; - lastSyncDate = Convert.ToDateTime(obj); + // MySQL DATETIME has no timezone — keep clock components as site wall-clock. + var dt = Convert.ToDateTime(obj); + lastSyncDate = new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, DateTimeKind.Unspecified); return true; } catch (Exception ex) @@ -108,7 +197,9 @@ internal sealed class MySqlAttendanceMachineRepository : IAttendanceMachineRepos using var cmd = new MySqlCommand( "UPDATE attendance_machine SET last_sync_date=@last_sync_date WHERE machine_ip=@machine_ip", conn); - cmd.Parameters.AddWithValue("@last_sync_date", lastSyncDate); + cmd.Parameters.AddWithValue("@last_sync_date", + new DateTime(lastSyncDate.Year, lastSyncDate.Month, lastSyncDate.Day, + lastSyncDate.Hour, lastSyncDate.Minute, lastSyncDate.Second, DateTimeKind.Unspecified)); cmd.Parameters.AddWithValue("@machine_ip", machineIp ?? ""); cmd.ExecuteNonQuery(); return true; @@ -215,6 +306,135 @@ internal sealed class MySqlAttendanceLogRepository : IAttendanceLogRepository return false; } } + + public bool InsertTerryAttendance( + string acNo, + DateTime checkTime, + int processed, + string machineId, + int inOutTypeId, + string machineIp, + DateTime dateOnly, + DateTime forDate, + Action? log, + out bool wasDuplicate, + out string error) + { + wasDuplicate = false; + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + + if (string.IsNullOrWhiteSpace(acNo)) + { + error = "refused insert: ac_no is empty"; + log?.Invoke("[TerryAttendanceInsert] REFUSED: " + error); + return false; + } + + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + + // Same punch identity as attendance_log conceptual duplicate protection (ac_no + checktime + machine), + // persisted so contractor punches are not re-inserted across polling cycles / restarts. + const string existsSql = + "SELECT 1 FROM terry_attendance_log " + + "WHERE ac_no=@ac_no AND checktime=@checktime " + + "AND ((machine_id IS NULL AND @machine_id IS NULL) OR machine_id=@machine_id) " + + "LIMIT 1"; + using (var existsCmd = new MySqlCommand(existsSql, conn)) + { + existsCmd.Parameters.Add("@ac_no", MySqlDbType.VarChar, 24).Value = acNo; + existsCmd.Parameters.Add("@checktime", MySqlDbType.DateTime).Value = checkTime; + existsCmd.Parameters.Add("@machine_id", MySqlDbType.VarChar, 45).Value = + string.IsNullOrWhiteSpace(machineId) ? (object)DBNull.Value : machineId; + var exists = existsCmd.ExecuteScalar(); + if (exists != null && exists != DBNull.Value) + { + wasDuplicate = true; + error = "duplicate punch already in terry_attendance_log"; + log?.Invoke("[TerryAttendanceInsert] DUPLICATE ac_no=\"" + acNo + + "\" checktime=" + checkTime.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + + " machine_id=\"" + (machineId ?? "") + "\""); + return false; + } + } + + const string sql = + "INSERT INTO terry_attendance_log " + + "(checktime,ac_no,processed,machine_id,in_out_type_id,machine_ip,date,for_date) " + + "VALUES (@checktime,@ac_no,@processed,@machine_id,@in_out_type_id,@machine_ip,@date,@for_date)"; + + using var cmd = new MySqlCommand(sql, conn); + cmd.Parameters.Add("@checktime", MySqlDbType.DateTime).Value = checkTime; + cmd.Parameters.Add("@ac_no", MySqlDbType.VarChar, 24).Value = acNo; + cmd.Parameters.Add("@processed", MySqlDbType.Int32).Value = processed; + cmd.Parameters.Add("@machine_id", MySqlDbType.VarChar, 45).Value = + string.IsNullOrWhiteSpace(machineId) ? (object)DBNull.Value : machineId; + cmd.Parameters.Add("@in_out_type_id", MySqlDbType.Int32).Value = inOutTypeId; + cmd.Parameters.Add("@machine_ip", MySqlDbType.VarChar, 50).Value = + string.IsNullOrWhiteSpace(machineIp) ? (object)DBNull.Value : machineIp; + cmd.Parameters.Add("@date", MySqlDbType.DateTime).Value = dateOnly.Date; + cmd.Parameters.Add("@for_date", MySqlDbType.Date).Value = forDate.Date; + + log?.Invoke("[TerryAttendanceInsert] SQL=" + sql); + log?.Invoke("[TerryAttendanceInsert] ac_no=\"" + acNo + + "\" checktime=" + checkTime.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + + " machine_id=\"" + (machineId ?? "") + "\" machine_ip=\"" + (machineIp ?? "") + "\"" + + " in_out_type_id=" + inOutTypeId + + " date=" + dateOnly.Date.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) + + " for_date=" + forDate.Date.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)); + + cmd.ExecuteNonQuery(); + return true; + } + catch (Exception ex) + { + error = ex.Message; + log?.Invoke("[TerryAttendanceInsert] FAILED err=" + ex.Message + + " ac_no=\"" + (acNo ?? "") + "\"" + + " machine_id=\"" + (machineId ?? "") + "\" machine_ip=\"" + (machineIp ?? "") + "\"" + + " checktime=" + checkTime.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture)); + return false; + } + } +} + +internal sealed class MySqlEmployeeLookupRepository : IEmployeeLookupRepository +{ + private readonly IHrmsDbConnectionFactory _factory; + public MySqlEmployeeLookupRepository(IHrmsDbConnectionFactory factory) { _factory = factory; } + + public bool TryGetWorkerTypeBySerialNumberInt(int serialNumberInt, out string? workerType, out string error) + { + workerType = null; + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "SELECT worker_type FROM employee WHERE serial_number_int=@serial_number_int LIMIT 1", + conn); + cmd.Parameters.AddWithValue("@serial_number_int", serialNumberInt); + var result = cmd.ExecuteScalar(); + if (result == null || result == DBNull.Value) + return false; + + workerType = Convert.ToString(result, System.Globalization.CultureInfo.InvariantCulture)?.Trim(); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } } internal sealed class MySqlAttendanceMachineUserRepository : IAttendanceMachineUserRepository diff --git a/HikvisionAttendanceManager.TargetDevice.cs b/HikvisionAttendanceManager.TargetDevice.cs new file mode 100644 index 0000000..2cff0f0 --- /dev/null +++ b/HikvisionAttendanceManager.TargetDevice.cs @@ -0,0 +1,457 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using HikvisionAttendanceService.Data; + +namespace HikvisionAttendanceService; + +/// +/// Resolves sync targets from the attendance_machine repository without site-scope filtering. +/// Source-device selection via is unchanged. +/// +internal sealed partial class HikvisionAttendanceManager +{ + // Prefer universal paths — AccessControl/capabilities returns 404 on many terminals. + private static readonly string[] TargetIsapiProbePaths = + { + "/ISAPI/System/deviceInfo", + "/ISAPI/AccessControl/UserInfo/capabilities?format=json", + "/ISAPI/Intelligent/FDLib?format=json", + "/ISAPI/AccessControl/capabilities?format=json" + }; + + private bool TryResolveSyncTargetByIp(string requestedIp, out HikvisionAttendanceWindowsService.DeviceConfig? device) + { + device = null; + var wantedIp = (requestedIp ?? "").Trim(); + if (wantedIp.Length == 0) + { + LogTargetResolve(wantedIp, found: false, machineId: "", port: 0, machineType: "", note: "empty IP"); + return false; + } + + if (_attendanceMachineRepository == null || !_config.EnableDbIntegration) + { + device = ResolveDeviceConfigByIp(wantedIp); + var found = device != null && !string.IsNullOrWhiteSpace(device.Ip); + LogTargetResolve( + wantedIp, + found, + device?.DeviceId ?? "", + device?.Port ?? 0, + "CONFIG_FALLBACK", + found ? "resolved from runtime config (DB integration disabled)" : "Target Device Not Found"); + return found; + } + + if (!_attendanceMachineRepository.TryGetMachineByIp(wantedIp, out var row, out var dbErr)) + { + LogTargetResolve(wantedIp, found: false, machineId: "", port: 0, machineType: "", + note: string.IsNullOrWhiteSpace(dbErr) ? "Target Device Not Found" : "Target lookup failed: " + dbErr); + _logger.Warn("UserSync: Target Device Not Found. TargetMachineIp=\"" + wantedIp + "\"."); + return false; + } + + if (!IsAttendanceMachineActive(row!)) + { + LogTargetResolve( + wantedIp, + found: false, + row!.MachineId, + row.PortNumber, + row.MachineType, + "Target device exists but machine_status is not active"); + _logger.Warn("UserSync: target inactive in DB. TargetMachineIp=\"" + wantedIp + + "\" machine_id=" + row.MachineId + " machine_status=\"" + row.MachineStatus + "\"."); + return false; + } + + if (!TryBuildDeviceConfigFromAttendanceMachine(row!, out device, out var buildErr)) + { + LogTargetResolve(wantedIp, found: false, row!.MachineId, row.PortNumber, row.MachineType, buildErr); + _logger.Warn("UserSync: target found but could not be built. TargetMachineIp=\"" + wantedIp + + "\" reason=\"" + buildErr + "\"."); + return false; + } + + LogTargetResolve( + wantedIp, + found: true, + device!.DeviceId, + device.Port, + row!.MachineType, + "resolved from attendance_machine"); + return true; + } + + private bool TryResolveSyncTargetByMachineId(string requestedMachineId, out HikvisionAttendanceWindowsService.DeviceConfig? device) + { + device = null; + var wantedId = (requestedMachineId ?? "").Trim(); + if (wantedId.Length == 0) + { + LogTargetResolve("", found: false, machineId: wantedId, port: 0, machineType: "", note: "empty machine_id"); + return false; + } + + if (_attendanceMachineRepository == null || !_config.EnableDbIntegration) + { + device = ResolveDeviceConfig(wantedId); + var found = device != null && !string.IsNullOrWhiteSpace(device.Ip); + LogTargetResolve( + device?.Ip ?? "", + found, + wantedId, + device?.Port ?? 0, + "CONFIG_FALLBACK", + found ? "resolved from runtime config (DB integration disabled)" : "Target Device Not Found"); + return found; + } + + if (!_attendanceMachineRepository.TryGetMachineByMachineId(wantedId, out var row, out var dbErr)) + { + LogTargetResolve("", found: false, machineId: wantedId, port: 0, machineType: "", + note: string.IsNullOrWhiteSpace(dbErr) ? "Target Device Not Found" : "Target lookup failed: " + dbErr); + _logger.Warn("UserSync: Target Device Not Found. TargetDeviceId=\"" + wantedId + "\"."); + return false; + } + + if (!IsAttendanceMachineActive(row!)) + { + LogTargetResolve( + row!.MachineIp, + found: false, + row.MachineId, + row.PortNumber, + row.MachineType, + "Target device exists but machine_status is not active"); + _logger.Warn("UserSync: target inactive in DB. TargetDeviceId=\"" + wantedId + + "\" machine_status=\"" + row.MachineStatus + "\"."); + return false; + } + + if (!TryBuildDeviceConfigFromAttendanceMachine(row!, out device, out var buildErr)) + { + LogTargetResolve(row!.MachineIp, found: false, row.MachineId, row.PortNumber, row.MachineType, buildErr); + return false; + } + + LogTargetResolve(device!.Ip, found: true, device.DeviceId, device.Port, row!.MachineType, + "resolved from attendance_machine"); + return true; + } + + private bool TryBuildDeviceConfigFromAttendanceMachine( + AttendanceMachineRow machine, + out HikvisionAttendanceWindowsService.DeviceConfig? device, + out string error) + { + device = null; + error = ""; + if (string.IsNullOrWhiteSpace(machine.MachineIp)) + { + error = "machine_ip empty in attendance_machine"; + return false; + } + + var configDevices = _config.Devices ?? new List(); + var credentialFallback = configDevices.FirstOrDefault(d => + !string.IsNullOrWhiteSpace(d.Username) && !string.IsNullOrWhiteSpace(d.Password)); + var matchingCfg = configDevices.FirstOrDefault(x => + string.Equals((x.Ip ?? "").Trim(), machine.MachineIp.Trim(), StringComparison.OrdinalIgnoreCase)); + var creds = matchingCfg ?? credentialFallback; + if (creds == null || string.IsNullOrWhiteSpace(creds.Username) || string.IsNullOrWhiteSpace(creds.Password)) + { + error = "no username/password in serviceconfig Devices for target ip=" + machine.MachineIp; + return false; + } + + var deviceId = !string.IsNullOrWhiteSpace(machine.MachineId) + ? machine.MachineId.Trim() + : (!string.IsNullOrWhiteSpace(machine.MachineName) ? machine.MachineName.Trim() : machine.MachineIp.Trim()); + var port = machine.PortNumber > 0 ? machine.PortNumber : (creds.Port > 0 ? creds.Port : 8000); + + device = new HikvisionAttendanceWindowsService.DeviceConfig + { + DeviceId = deviceId, + Ip = machine.MachineIp.Trim(), + Port = port, + Username = creds.Username, + Password = creds.Password, + FingerPrintReaderNo = creds.FingerPrintReaderNo, + FaceReaderNo = creds.FaceReaderNo, + GatewayDoorIndex = creds.GatewayDoorIndex, + Model = string.IsNullOrWhiteSpace(machine.MachineName) ? (creds.Model ?? "") : machine.MachineName, + SerialNumber = creds.SerialNumber, + FirmwareVersion = creds.FirmwareVersion, + SubnetMask = creds.SubnetMask, + DefaultGateway = creds.DefaultGateway + }; + return true; + } + + private static bool IsAttendanceMachineActive(AttendanceMachineRow machine) + { + var status = (machine.MachineStatus ?? "").Trim(); + return string.Equals(status, "active", StringComparison.OrdinalIgnoreCase) || status == "1"; + } + + private void LogTargetResolve(string requestedIp, bool found, string machineId, int port, string machineType, string note) + { + _logger.Ops("[TARGET_RESOLVE]", + "requestedIp=" + (requestedIp ?? "") + + " found=" + (found ? "true" : "false") + + " machineId=" + (machineId ?? "") + + " port=" + port + + " type=" + (machineType ?? "") + + (string.IsNullOrWhiteSpace(note) ? "" : (" note=\"" + note + "\""))); + } + + /// + /// ISAPI HTTP connectivity probe (Digest) on the HTTP port — not ICMP ping. + /// Tries several endpoints because firmware/models omit some capabilities URLs (404 is not offline). + /// + private bool TryTestTargetIsapiConnectivity( + HikvisionAttendanceWindowsService.DeviceConfig device, + out string outcome, + out string detail) + { + outcome = ""; + detail = ""; + int isapiPort = IsapiPort; + var ip = (device.Ip ?? "").Trim(); + var requestLabel = "GET ISAPI probe (" + TargetIsapiProbePaths.Length + " paths)"; + + var tcpProbe = ConnectivityDiagnostics.ProbeBeforeLogin(ip, isapiPort, tcpTimeoutMs: 5000, tryPing: false); + if (tcpProbe.TcpChecked && !tcpProbe.TcpOk) + { + outcome = "Connection Failed"; + detail = tcpProbe.FriendlyReason; + LogTargetTest(requestLabel, outcome, detail); + return false; + } + + try + { + using var handler = BuildDigestHandler(device); + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }; + + string lastNonAuthFailure = ""; + var sawHttpResponse = false; + + foreach (var path in TargetIsapiProbePaths) + { + var url = "http://" + ip + ":" + isapiPort + path; + using var response = client.GetAsync(url).GetAwaiter().GetResult(); + sawHttpResponse = true; + + var bodySnippet = ""; + try + { + var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + if (!string.IsNullOrWhiteSpace(body)) + bodySnippet = body.Length > 120 ? body.Substring(0, 120) : body; + } + catch + { + // ignore body read errors + } + + var pathLabel = "GET " + path; + if (response.StatusCode == HttpStatusCode.Unauthorized || + response.StatusCode == HttpStatusCode.Forbidden) + { + outcome = "Authentication Failed"; + detail = pathLabel + " → HTTP " + (int)response.StatusCode + " " + response.ReasonPhrase; + LogTargetTest(pathLabel, outcome, detail); + return false; + } + + if (response.IsSuccessStatusCode) + { + outcome = "OK"; + detail = pathLabel + " → HTTP " + (int)response.StatusCode; + LogTargetTest(pathLabel, outcome, detail); + return true; + } + + lastNonAuthFailure = pathLabel + " → HTTP " + (int)response.StatusCode + " " + response.ReasonPhrase + + (string.IsNullOrWhiteSpace(bodySnippet) ? "" : (" body=\"" + bodySnippet + "\"")); + LogTargetTest(pathLabel, "probe_miss", lastNonAuthFailure); + } + + // Device answered HTTP but probe URLs are unsupported on this firmware. + // Allow sync to proceed; real UserInfo / FaceDataRecord calls will report real errors. + if (sawHttpResponse) + { + outcome = "OK"; + detail = "device reachable on HTTP " + isapiPort + + "; probe URLs unsupported (last: " + lastNonAuthFailure + ")"; + LogTargetTest(requestLabel, outcome, detail); + return true; + } + + outcome = "Connection Failed"; + detail = string.IsNullOrWhiteSpace(lastNonAuthFailure) + ? "No HTTP response from ISAPI probes." + : lastNonAuthFailure; + LogTargetTest(requestLabel, outcome, detail); + return false; + } + catch (TaskCanceledException ex) + { + outcome = "Connection Failed"; + detail = "ISAPI request timed out: " + ex.Message; + LogTargetTest(requestLabel, outcome, detail); + return false; + } + catch (HttpRequestException ex) + { + outcome = "Connection Failed"; + detail = ex.Message; + LogTargetTest(requestLabel, outcome, detail); + return false; + } + catch (Exception ex) + { + outcome = "Connection Failed"; + detail = ex.GetType().Name + ": " + ex.Message; + LogTargetTest(requestLabel, outcome, detail); + return false; + } + } + + private void LogTargetTest(string request, string outcome, string detail) + { + _logger.Ops("[TARGET_TEST]", + "request=" + request + + " outcome=" + outcome + + (string.IsNullOrWhiteSpace(detail) ? "" : (" detail=\"" + detail + "\""))); + } + + private void LogDbToDeviceResult( + string employeeNo, + string createUser, + string faceUpload, + string verification, + string finalResult) + { + _logger.Ops("[DB_TO_DEVICE]", + "employee=" + employeeNo + + " createUser=" + createUser + + " faceUpload=" + faceUpload + + " verification=" + verification + + " finalResult=" + finalResult); + } + + /// + /// Resolves configured sync targets from DB (no scope filter), tests ISAPI connectivity, returns ready targets. + /// + private List CollectTestedSyncTargets( + HikvisionAttendanceWindowsService.DeviceConfig? source, + bool hasSource, + CancellationToken ct) + { + var targets = new List(); + foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty()) + { + ct.ThrowIfCancellationRequested(); + if (!TryResolveSyncTargetByIp(targetIp, out var device) || device == null) + continue; + + if (targets.Any(x => string.Equals(x.Ip, device.Ip, StringComparison.OrdinalIgnoreCase))) + continue; + + if (hasSource && + string.Equals((device.Ip ?? "").Trim(), (source!.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) && + !_config.EnableTemplateDbToDeviceSync) + { + _logger.Diag("user_sync", + "target skipped — same as source IP and DB->device restore disabled. TargetMachineIp=\"" + targetIp + "\"."); + continue; + } + + if (!TryTestTargetIsapiConnectivity(device, out var testOutcome, out var testDetail)) + { + _logger.Warn("UserSync: target connectivity failed. TargetMachineIp=\"" + targetIp + + "\" machine_id=" + device.DeviceId + " outcome=\"" + testOutcome + + "\" detail=\"" + testDetail + "\"."); + var failTitle = testOutcome == "Authentication Failed" + ? "Authentication Failed." + : "Connection Failed."; + var failLines = new[] + { + "MACHINE " + (device.DeviceId ?? ""), + "", + failTitle, + "", + "Machine IP : " + (device.Ip ?? ""), + "ISAPI HTTP Port : " + IsapiPort, + "Detail : " + (string.IsNullOrWhiteSpace(testDetail) ? "(none)" : testDetail), + "" + }; + _logger.Biz(BizChannel.UserSync, failLines); + _logger.BizSeparator(BizChannel.UserSync); + if (_config.EnableInitialDepartmentSync) + { + _logger.Biz(BizChannel.DepartmentalSync, failLines); + _logger.BizSeparator(BizChannel.DepartmentalSync); + } + if (_config.EnableTemplateDbToDeviceSync) + { + _logger.Biz(BizChannel.Template, + "Target Device :", + "", + (device.Ip ?? ""), + "", + failTitle, + "", + "Detail : " + (string.IsNullOrWhiteSpace(testDetail) ? "(none)" : testDetail), + ""); + _logger.BizSeparator(BizChannel.Template); + } + + continue; + } + + targets.Add(device); + } + + foreach (var tid in _config.TargetDeviceIds ?? Enumerable.Empty()) + { + ct.ThrowIfCancellationRequested(); + if (!TryResolveSyncTargetByMachineId(tid, out var device) || device == null) + continue; + + if (targets.Any(x => + string.Equals(x.Ip, device.Ip, StringComparison.OrdinalIgnoreCase) || + DeviceIdentity.CanonicalLookupKey(x.DeviceId) == DeviceIdentity.CanonicalLookupKey(device.DeviceId))) + continue; + + if (hasSource && + DeviceIdentity.CanonicalLookupKey(device.DeviceId) == DeviceIdentity.CanonicalLookupKey(source!.DeviceId)) + { + if (!_config.EnableTemplateDbToDeviceSync) + { + _logger.Warn("UserSync: target skipped — same as source. deviceId=\"" + device.DeviceId + "\"."); + continue; + } + } + + if (!TryTestTargetIsapiConnectivity(device, out var testOutcome, out var testDetail)) + { + _logger.Warn("UserSync: target connectivity failed. TargetDeviceId=\"" + tid + + "\" machine_id=" + device.DeviceId + " outcome=\"" + testOutcome + + "\" detail=\"" + testDetail + "\"."); + continue; + } + + targets.Add(device); + } + + return targets; + } +}