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
parent
ec6aaac0fa
commit
9605c370ae
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue