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.
main
SYED MUSTUFA AHMED NAQVI 2026-09-11 15:03:55 +05:00
parent ec6aaac0fa
commit 9605c370ae
3 changed files with 709 additions and 2 deletions

View File

@ -12,6 +12,10 @@ internal interface IHrmsDbConnectionFactory
internal interface IAttendanceMachineRepository
{
List<AttendanceMachineRow> GetActiveMachines(string? machineTypeFilter, out string error);
/// <summary>Lookup by IP without site-scope filtering (used for sync targets).</summary>
bool TryGetMachineByIp(string machineIp, out AttendanceMachineRow? row, out string error);
/// <summary>Lookup by machine_id without site-scope filtering (used for sync targets).</summary>
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<string>? log,
out string error);
/// <summary>
/// Inserts one terry_attendance_log row for CONTRACTOR WORKER punches.
/// Returns false with error when a same punch already exists (duplicate protection).
/// </summary>
bool InsertTerryAttendance(
string acNo,
DateTime checkTime,
int processed,
string machineId,
int inOutTypeId,
string machineIp,
DateTime dateOnly,
DateTime forDate,
Action<string>? log,
out bool wasDuplicate,
out string error);
}
internal interface IEmployeeLookupRepository
{
/// <summary>
/// Looks up employee.worker_type by employee.serial_number_int (= Hikvision employeeNo).
/// Returns false when no matching employee row exists.
/// </summary>
bool TryGetWorkerTypeBySerialNumberInt(int serialNumberInt, out string? workerType, out string error);
}
internal interface IAttendanceMachineUserRepository

View File

@ -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<string>? 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

View File

@ -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;
/// <summary>
/// Resolves sync targets from the attendance_machine repository without site-scope filtering.
/// Source-device selection via <see cref="ResolveRuntimeDevices"/> is unchanged.
/// </summary>
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<HikvisionAttendanceWindowsService.DeviceConfig>();
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 + "\"")));
}
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// Resolves configured sync targets from DB (no scope filter), tests ISAPI connectivity, returns ready targets.
/// </summary>
private List<HikvisionAttendanceWindowsService.DeviceConfig> CollectTestedSyncTargets(
HikvisionAttendanceWindowsService.DeviceConfig? source,
bool hasSource,
CancellationToken ct)
{
var targets = new List<HikvisionAttendanceWindowsService.DeviceConfig>();
foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty<string>())
{
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<string>())
{
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;
}
}