using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using HikvisionAttendanceService.Data;
namespace HikvisionAttendanceService;
/// Multi-device user + face sync via ISAPI HTTP (Digest). Orchestration and focused helpers.
internal sealed partial class HikvisionAttendanceManager
{
private HikvisionAttendanceWindowsService.UserSyncPoliciesConfig SyncPol =>
_config.SyncPolicies ?? new HikvisionAttendanceWindowsService.UserSyncPoliciesConfig();
private int IsapiPort => _config.IsapiHttpPort > 0 ? _config.IsapiHttpPort : 80;
private HikvisionAttendanceWindowsService.DeviceConfig? ResolveDeviceConfig(string deviceId)
{
var key = DeviceIdentity.CanonicalLookupKey(deviceId);
if (key.Length == 0)
return null;
foreach (var d in ResolveRuntimeDevices() ?? Enumerable.Empty())
{
if (DeviceIdentity.CanonicalLookupKey(d.DeviceId) == key)
return d;
}
return null;
}
private HikvisionAttendanceWindowsService.DeviceConfig? ResolveDeviceConfigByIp(string ip)
{
var wanted = (ip ?? "").Trim();
if (wanted.Length == 0)
return null;
foreach (var d in ResolveRuntimeDevices() ?? Enumerable.Empty())
{
if (string.Equals((d.Ip ?? "").Trim(), wanted, StringComparison.OrdinalIgnoreCase))
return d;
}
return null;
}
/// Runs one full sync cycle: source UserInfo/Search, per-target reconcile, optional deletes.
public void RunUserFaceSyncCycle(CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// Deletion requests belong to the machine recorded in attendance_machine_user.
// They must not depend on the source/target list, which is only for copying
// users and templates between devices.
RunPendingUserDeletionCycle(ct);
var pol = SyncPol;
int maxRetries = pol.HttpMaxRetries < 1 ? 1 : pol.HttpMaxRetries;
var source = !string.IsNullOrWhiteSpace(_config.SourceMachineIp)
? ResolveDeviceConfigByIp(_config.SourceMachineIp)
: ResolveDeviceConfig(_config.SourceDeviceId ?? "");
if (source == null || string.IsNullOrWhiteSpace(source.Ip))
{
_logger.Warn("UserSync: skipped — source device not found or IP empty. SourceDeviceId=\"" +
(_config.SourceDeviceId ?? "") + "\" SourceMachineIp=\"" + (_config.SourceMachineIp ?? "") + "\".");
return;
}
var targets = new List();
foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty())
{
var t = ResolveDeviceConfigByIp(targetIp);
if (t == null || string.IsNullOrWhiteSpace(t.Ip))
{
_logger.Warn("UserSync: target skipped — not found or IP empty. TargetMachineIp=\"" + targetIp + "\".");
continue;
}
if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase)))
continue;
// Same IP allowed for DB->device restore (user deleted on device, restore from DB).
if (string.Equals((t.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;
}
targets.Add(t);
}
foreach (var tid in _config.TargetDeviceIds ?? Enumerable.Empty())
{
var t = ResolveDeviceConfig(tid);
if (t == null || string.IsNullOrWhiteSpace(t.Ip))
{
_logger.Warn("UserSync: target skipped — not found or IP empty. TargetDeviceId=\"" + tid + "\".");
continue;
}
if (DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source.DeviceId))
{
if (!_config.EnableTemplateDbToDeviceSync)
{
_logger.Warn("UserSync: target skipped — same as source. deviceId=\"" + t.DeviceId + "\".");
continue;
}
if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase)))
continue;
}
targets.Add(t);
}
if (targets.Count == 0)
{
// Deletion may have already run above; copy/template sync simply has nothing to do.
_logger.Diag("user_sync", "Copy/template sync skipped — no valid target devices.");
return;
}
var cycleId = DateTime.UtcNow.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture);
_logger.Info("USER_SYNC cycle begin id=" + cycleId +
". Meaning: one full restore/sync pass from source to target(s)." +
" sourceDevice=" + source.DeviceId + " sourceIp=" + (source.Ip ?? "") +
" targetCount=" + targets.Count +
" UpdateExistingUserFields=" + pol.UpdateExistingUserFields +
" UploadFaceIfMissingOnly=" + pol.UploadFaceIfMissingOnly +
" DeleteOnTargetIfMissingInSource=" + pol.DeleteOnTargetIfMissingInSource);
_logger.JobInfo("user_sync", "Cycle begin id=" + cycleId +
". Meaning: push users/faces to targets. source=" + source.DeviceId +
" sourceIp=" + (source.Ip ?? "") + " targets=" + targets.Count);
var sourceUsers = LoadSourceUsersForSync(source, maxRetries, ct);
if ((_config.SyncEmployeeIds ?? new List()).Count > 0)
{
var requestedEmployeeIds = new HashSet(_config.SyncEmployeeIds.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
sourceUsers = sourceUsers.Where(x => requestedEmployeeIds.Contains(x.EmployeeNo)).ToList();
// Same-machine restore: employee may already be gone from device/user list;
// synthesize missing IDs and restore from DB face templates.
if (_config.EnableTemplateDbToDeviceSync)
{
var existing = new HashSet(sourceUsers.Select(x => x.EmployeeNo), StringComparer.OrdinalIgnoreCase);
foreach (var emp in requestedEmployeeIds)
{
if (existing.Contains(emp))
continue;
sourceUsers.Add(new UserDto
{
EmployeeNo = emp,
Name = "",
NumOfFace = 0,
NumOfFp = 0
});
}
}
_logger.JobInfo("user_sync", "Employee filter: requested=" + requestedEmployeeIds.Count +
" willSync=" + sourceUsers.Count +
". Meaning: only these employee IDs from SyncEmployeeIds are restored/synced this cycle.");
}
_logger.Info("USER_SYNC source users ready count=" + sourceUsers.Count + " device=" + source.DeviceId +
". Meaning: users we will try to create/update on the target.");
_logger.JobInfo("user_sync", "Source users ready count=" + sourceUsers.Count + " device=" + source.DeviceId);
var faceBytesByEmployee = new Dictionary(StringComparer.OrdinalIgnoreCase);
int faceDlOk = 0, faceDlFail = 0;
foreach (var u in sourceUsers)
{
ct.ThrowIfCancellationRequested();
// Prefer DB template first for restore / DB->device sync.
if (_config.EnableTemplateDbToDeviceSync &&
_attendanceMachineFaceTemplateRepository != null &&
_attendanceMachineFaceTemplateRepository.TryGetActiveFaceTemplateBySerial(u.EmployeeNo, out var dbTpl, out _) &&
dbTpl != null &&
dbTpl.Template != null &&
dbTpl.Template.Length > 0)
{
faceBytesByEmployee[u.EmployeeNo] = dbTpl.Template;
faceDlOk++;
_logger.Diag("user_sync", "face loaded from DB employeeNo=" + u.EmployeeNo);
continue;
}
if (string.IsNullOrWhiteSpace(u.FaceUrl))
{
faceBytesByEmployee[u.EmployeeNo] = null;
continue;
}
if (DownloadFaceByUrl(source, u.FaceUrl, maxRetries, ct, out var bytes) && bytes.Length > 0)
{
faceBytesByEmployee[u.EmployeeNo] = bytes;
faceDlOk++;
TrySaveFaceCacheFile(u.EmployeeNo, bytes);
}
else
{
faceBytesByEmployee[u.EmployeeNo] = null;
faceDlFail++;
}
}
_logger.Info("USER_SYNC faces prepared ok=" + faceDlOk + " missing=" + faceDlFail +
". Meaning: ok=face image/template available for upload; missing=no face found in DB/device URL.");
var sourceEmp = new HashSet(sourceUsers.Select(s => s.EmployeeNo), StringComparer.OrdinalIgnoreCase);
foreach (var target in targets)
{
ct.ThrowIfCancellationRequested();
var hasSession = _sessions.Any(s =>
string.Equals((s.Device.Ip ?? "").Trim(), (target.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) ||
DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == DeviceIdentity.CanonicalLookupKey(target.DeviceId));
if (!hasSession)
{
_logger.OpsWarn(OpsMarkers.TemplateDbToDevice,
"target=" + target.DeviceId + " ip=" + (target.Ip ?? "") +
" SKIPPED reason=\"target offline\"");
_logger.Biz(BizChannel.UserSync,
"MACHINE " + (target.DeviceId ?? ""),
"",
"Machine is not connected.",
"");
_logger.BizSeparator(BizChannel.UserSync);
if (_config.EnableTemplateDbToDeviceSync)
{
_logger.Biz(BizChannel.Template,
"Target Device :",
"",
(target.Ip ?? ""),
"",
"Machine is not connected.",
"");
_logger.BizSeparator(BizChannel.Template);
}
foreach (var u in sourceUsers)
_logger.Ops(OpsMarkers.TemplateDbToDevice,
u.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE SKIPPED reason=\"target offline\"");
continue;
}
_logger.Biz(BizChannel.UserSync,
"MACHINE " + (target.DeviceId ?? "") + " -> Total Users : " + sourceUsers.Count,
"");
if (_config.EnableTemplateDbToDeviceSync)
{
_logger.Biz(BizChannel.Template,
"Target Device :",
"",
(target.Ip ?? ""),
"");
_logger.BizSeparator(BizChannel.Template);
}
var targetUsers = FetchAllUsersIsapiForSync(target, maxRetries, ct);
var targetMap = targetUsers.ToDictionary(x => x.EmployeeNo, x => x, StringComparer.OrdinalIgnoreCase);
var initialTargetEmployeeIds = new HashSet(targetUsers.Select(u => u.EmployeeNo),
StringComparer.OrdinalIgnoreCase);
HashSet? assignedUsers = null;
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
{
var dbAssignments = _attendanceMachineUserRepository.GetActiveUsersByMachine(target.DeviceId ?? "", out var dbAssignErr);
if (!string.IsNullOrWhiteSpace(dbAssignErr))
_logger.Warn("UserSync DB assignment fetch failed target=" + target.DeviceId + " err=" + dbAssignErr);
else if (dbAssignments.Count > 0)
{
assignedUsers = new HashSet(dbAssignments.Select(x => x.SerialNumber), StringComparer.OrdinalIgnoreCase);
_logger.JobInfo("user_sync", "Target " + target.DeviceId + " DB-assigned active users=" + assignedUsers.Count);
}
}
_logger.Info("UserSync: cycle=" + cycleId + " target=" + target.DeviceId + " existingUsers=" + targetUsers.Count);
int created = 0, faceUp = 0, skipped = 0, failed = 0, updated = 0;
var statusCounts = new Dictionary();
void Bump(UserSyncStatus s)
{
statusCounts.TryGetValue(s, out var n);
statusCounts[s] = n + 1;
}
// Explicit SyncEmployeeIds = restore/push list; do not filter by DB assignment rows.
var explicitEmployeeFilter = (_config.SyncEmployeeIds ?? new List()).Count > 0;
foreach (var srcUser in sourceUsers)
{
ct.ThrowIfCancellationRequested();
if (!explicitEmployeeFilter && assignedUsers != null && !assignedUsers.Contains(srcUser.EmployeeNo))
continue;
targetMap.TryGetValue(srcUser.EmployeeNo, out var tgtRow);
bool createdNew = false;
if (tgtRow == null)
{
if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr))
{
_logger.Warn("UserSync: FailedCreate employeeNo=" + srcUser.EmployeeNo + " target=" + target.DeviceId +
" err=" + createErr);
failed++;
Bump(UserSyncStatus.FailedCreate);
continue;
}
created++;
_logger.Totals.UsersAdded++;
_logger.Biz(BizChannel.UserSync,
"Machine ID : " + (target.DeviceId ?? ""),
"Machine IP : " + (target.Ip ?? ""),
"",
srcUser.EmployeeNo + " added successfully.",
"");
// Clear is_deleted / is_deletion_requested so DB matches the live device user.
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
{
if (!_attendanceMachineUserRepository.UpsertMachineUser(
target.DeviceId ?? "",
srcUser.EmployeeNo,
srcUser.Name ?? "",
"hikvision-service",
out var upsertErr))
{
_logger.Warn("UserSync: device add OK but DB is_deleted reset failed employeeNo=" +
srcUser.EmployeeNo + " machine_id=" + (target.DeviceId ?? "") +
" err=" + upsertErr);
}
else
{
_logger.Diag("user_sync",
"DB attendance_machine_user reactivated is_deleted=0 employeeNo=" +
srcUser.EmployeeNo + " machine_id=" + (target.DeviceId ?? ""));
}
}
createdNew = true;
tgtRow = new UserDto
{
EmployeeNo = srcUser.EmployeeNo,
Name = srcUser.Name,
NumOfFace = 0,
NumOfFp = 0
};
targetMap[srcUser.EmployeeNo] = tgtRow;
}
else if (pol.UpdateExistingUserFields && UserFieldsDiffer(srcUser, tgtRow))
{
if (TryModifyUserOnTarget(target, srcUser, maxRetries, ct, out var modErr))
{
updated++;
_logger.Totals.UsersUpdated++;
_logger.Biz(BizChannel.UserSync,
"Machine ID : " + (target.DeviceId ?? ""),
"Machine IP : " + (target.Ip ?? ""),
"",
srcUser.EmployeeNo + " updated successfully.",
"");
Bump(UserSyncStatus.ExistsUserFieldsUpdated);
tgtRow.Name = srcUser.Name;
tgtRow.ValidBeginTime = srcUser.ValidBeginTime;
tgtRow.ValidEndTime = srcUser.ValidEndTime;
tgtRow.ValidEnable = srcUser.ValidEnable;
tgtRow.UserType = srcUser.UserType;
tgtRow.DoorRight = srcUser.DoorRight;
tgtRow.RightPlanDoorNo = srcUser.RightPlanDoorNo;
tgtRow.RightPlanTemplateNo = srcUser.RightPlanTemplateNo;
}
else
{
_logger.Warn("UserSync: FailedUserModify employeeNo=" + srcUser.EmployeeNo + " target=" +
target.DeviceId + " err=" + modErr);
failed++;
Bump(UserSyncStatus.FailedUserModify);
}
}
bool hasFaceBytes = faceBytesByEmployee.TryGetValue(srcUser.EmployeeNo, out var fb) && fb != null &&
fb.Length > 0;
string tplErr = "";
if (!hasFaceBytes &&
_config.EnableDbIntegration &&
_config.EnableTemplateDbToDeviceSync &&
_attendanceMachineFaceTemplateRepository != null &&
_attendanceMachineFaceTemplateRepository.TryGetActiveFaceTemplateBySerial(srcUser.EmployeeNo, out var tplRow, out tplErr) &&
tplRow != null &&
tplRow.Template != null &&
tplRow.Template.Length > 0)
{
fb = tplRow.Template;
hasFaceBytes = true;
}
else if (!hasFaceBytes && !string.IsNullOrWhiteSpace(tplErr))
{
_logger.Warn("UserSync template lookup failed employeeNo=" + srcUser.EmployeeNo + " err=" + tplErr);
}
if (!hasFaceBytes)
{
skipped++;
_logger.Ops(OpsMarkers.TemplateDbToDevice,
srcUser.EmployeeNo + " -> Device " + target.DeviceId +
" = FACE TEMPLATE SKIPPED reason=\"template missing\"");
if (!createdNew && tgtRow.NumOfFace > 0)
{
_logger.Biz(BizChannel.UserSync, srcUser.EmployeeNo + " already exists.", "");
if (_config.EnableTemplateDbToDeviceSync)
{
_logger.Biz(BizChannel.Template,
"Employee " + srcUser.EmployeeNo,
"",
"Face Template",
"",
"Already exists.",
"");
_logger.BizSeparator(BizChannel.Template);
}
Bump(UserSyncStatus.AlreadySynced);
}
else
Bump(createdNew ? UserSyncStatus.CreatedNoFaceAvailable : UserSyncStatus.ExistsNoFaceOnSourceSkipped);
continue;
}
if (pol.UploadFaceIfMissingOnly && tgtRow.NumOfFace > 0)
{
skipped++;
_logger.Ops(OpsMarkers.TemplateDbToDevice,
srcUser.EmployeeNo + " -> Device " + target.DeviceId +
" = FACE TEMPLATE SKIPPED reason=\"already exists and overwrite disabled\"");
_logger.Biz(BizChannel.UserSync, srcUser.EmployeeNo + " already exists.", "");
if (_config.EnableTemplateDbToDeviceSync)
{
_logger.Biz(BizChannel.Template,
"Employee " + srcUser.EmployeeNo,
"",
"Face Template",
"",
"Already exists.",
"");
_logger.BizSeparator(BizChannel.Template);
}
Bump(UserSyncStatus.SkippedFaceUploadPolicy);
continue;
}
if (!UploadFaceOnTarget(target, srcUser.EmployeeNo, fb!, maxRetries, ct, out var upErr))
{
_logger.OpsError(OpsMarkers.TemplateDbToDevice,
srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\"");
_logger.Totals.TemplatesFailed++;
if (_config.EnableTemplateDbToDeviceSync)
{
_logger.Biz(BizChannel.Template,
"Employee " + srcUser.EmployeeNo,
"",
"Face Template",
"",
"Upload failed.",
"",
"Reason :",
"",
string.IsNullOrWhiteSpace(upErr) ? "Unknown error." : upErr,
"");
_logger.BizSeparator(BizChannel.Template);
}
failed++;
Bump(createdNew ? UserSyncStatus.CreatedFaceUploadFailed : UserSyncStatus.FailedFaceUpload);
continue;
}
faceUp++;
_logger.Totals.TemplatesSaved++;
tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1);
_logger.Ops(OpsMarkers.TemplateDbToDevice,
srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE INSERTED SUCCESSFULLY");
if (_config.EnableTemplateDbToDeviceSync)
{
_logger.Biz(BizChannel.Template,
"Employee " + srcUser.EmployeeNo,
"",
"Face Template",
"",
"Uploaded successfully.",
"");
_logger.BizSeparator(BizChannel.Template);
}
Bump(createdNew ? UserSyncStatus.CreatedAndFaceUploaded : UserSyncStatus.ExistsFaceUploaded);
}
if (pol.DeleteOnTargetIfMissingInSource)
{
var toDelete = initialTargetEmployeeIds.Where(k => !sourceEmp.Contains(k)).ToList();
if (toDelete.Count > 0)
{
_logger.Diag("user_sync", "policy delete candidates=" + toDelete.Count + " target=" + target.DeviceId);
if (!TryDeleteUsersOnTarget(target, toDelete, maxRetries, ct, target.DeviceId ?? "", out var delErr))
{
foreach (var emp in toDelete)
_logger.OpsError(OpsMarkers.UserDelete, emp + " -> Device " + target.DeviceId +
" = DELETED FAILED reason=\"" + ConciseDeleteFailureReason(delErr) + "\"");
for (var di = 0; di < toDelete.Count; di++)
Bump(UserSyncStatus.FailedDeleteOnTarget);
}
else
{
foreach (var emp in toDelete)
{
_logger.Ops(OpsMarkers.UserDelete, emp + " -> Device " + target.DeviceId + " = DELETED SUCCESSFULLY");
_logger.Totals.UsersRemoved++;
_logger.Biz(BizChannel.UserSync,
"Machine ID : " + (target.DeviceId ?? ""),
"Machine IP : " + (target.Ip ?? ""),
"",
emp + " removed successfully.",
"");
Bump(UserSyncStatus.DeletedOnTargetNotInSource);
}
}
}
}
var statusLine = string.Join(", ", statusCounts.OrderBy(kv => kv.Key.ToString())
.Select(kv => kv.Key + "=" + kv.Value));
_logger.Ops(OpsMarkers.TemplateDbToDevice,
"device=" + target.DeviceId + " summary created=" + created +
" faceUploaded=" + faceUp + " updated=" + updated +
" skipped=" + skipped + " failed=" + failed);
_logger.Diag("user_sync", "statusCounts[" + statusLine + "]");
_logger.BizSeparator(BizChannel.UserSync);
}
_logger.Ops(OpsMarkers.TemplateDbToDevice, "Cycle completed id=" + cycleId);
}
private List LoadSourceUsersForSync(HikvisionAttendanceWindowsService.DeviceConfig source, int maxRetries, CancellationToken ct)
{
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
{
var rows = _attendanceMachineUserRepository.GetActiveUsersByMachine(source.DeviceId ?? "", out var dbErr);
if (string.IsNullOrWhiteSpace(dbErr) && rows.Count > 0)
{
_logger.JobInfo("user_sync", "Source users loaded from DB machine_id=" + source.DeviceId + " count=" + rows.Count);
return rows
.Where(x => !string.IsNullOrWhiteSpace(x.SerialNumber))
.Select(x => new UserDto
{
EmployeeNo = x.SerialNumber,
Name = x.EmployeeName ?? "",
NumOfFace = 0,
NumOfFp = 0
})
.ToList();
}
if (!string.IsNullOrWhiteSpace(dbErr))
_logger.Warn("UserSync DB source-user load failed machine_id=" + source.DeviceId + " err=" + dbErr);
}
return FetchAllUsersIsapiForSync(source, maxRetries, ct);
}
///
/// Deletes users explicitly requested in attendance_machine_user. The
/// request's machine_id selects the device; it is intentionally independent
/// of SourceDeviceId/TargetDeviceIds, which configure copy/template sync.
///
private void RunPendingUserDeletionCycle(CancellationToken ct)
{
if (!_config.EnableDbIntegration || _attendanceMachineRepository == null || _attendanceMachineUserRepository == null)
return;
var machines = _attendanceMachineRepository.GetActiveMachines("HIKVISION", out var machinesError);
if (!string.IsNullOrWhiteSpace(machinesError))
{
_logger.OpsWarn(OpsMarkers.UserDelete, "Could not load active Hikvision machines for deletion requests reason=\"" + machinesError + "\"");
return;
}
var maxRetries = Math.Max(1, SyncPol.HttpMaxRetries);
foreach (var machine in machines)
{
ct.ThrowIfCancellationRequested();
var machineId = (machine.MachineId ?? "").Trim();
var machineIp = (machine.MachineIp ?? "").Trim();
if (machineId.Length == 0 || machineIp.Length == 0)
continue;
var session = _sessions.FirstOrDefault(s =>
string.Equals((s.Device.Ip ?? "").Trim(), machineIp, StringComparison.OrdinalIgnoreCase) ||
DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == DeviceIdentity.CanonicalLookupKey(machineId));
if (session == null)
{
_logger.OpsWarn(OpsMarkers.UserDelete, "machine_id=" + machineId + " ip=" + machineIp +
" SKIPPED reason=\"machine is not connected\"");
continue;
}
var pending = _attendanceMachineUserRepository.GetPendingDeletionUsersByMachine(machineId, out var pendingError);
if (!string.IsNullOrWhiteSpace(pendingError))
{
_logger.OpsWarn(OpsMarkers.UserDelete, "Could not load delete_request users for machine_id=" + machineId +
" reason=\"" + pendingError + "\"");
continue;
}
var employeeNumbers = pending.Select(x => x.SerialNumber)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (employeeNumbers.Count == 0)
continue;
int deletedOk = 0, deletedFail = 0;
_logger.Ops(OpsMarkers.UserDelete, "Processing delete_request count=" + employeeNumbers.Count +
" machine_id=" + machineId + " ip=" + machineIp);
foreach (var employeeNo in employeeNumbers)
{
if (!TryDeleteUsersOnTarget(session.Device, new List { employeeNo }, maxRetries, ct, machineId,
out var deleteError))
{
deletedFail++;
_logger.OpsError(OpsMarkers.UserDelete, employeeNo + " -> Device " + machineId +
" = DELETED FAILED reason=\"" + ConciseDeleteFailureReason(deleteError) + "\"");
continue;
}
if (_attendanceMachineUserRepository.MarkDeleted(machineId, employeeNo, "hikvision-service", out var markError))
{
deletedOk++;
_logger.Totals.UsersRemoved++;
_logger.Ops(OpsMarkers.UserDelete, employeeNo + " -> Device " + machineId + " = DELETED SUCCESSFULLY");
_logger.Biz(BizChannel.UserSync,
"Machine ID : " + machineId,
"Machine IP : " + machineIp,
"",
employeeNo + " removed successfully.",
"");
}
else
{
deletedFail++;
_logger.OpsError(OpsMarkers.UserDelete, employeeNo + " -> Device " + machineId +
" = DELETED FAILED reason=\"device deleted but DB mark failed: " + ConciseDeleteFailureReason(markError) + "\"");
}
}
_logger.Ops(OpsMarkers.UserDelete, "SUMMARY machine_id=" + machineId +
" requested=" + employeeNumbers.Count + " deleted=" + deletedOk + " failed=" + deletedFail);
}
}
private async Task UserSyncSchedulerLoop(CancellationToken token)
{
bool firstRun = true;
while (!token.IsCancellationRequested)
{
if (!firstRun)
{
try
{
await Task.Delay(TimeSpan.FromMinutes(_config.SyncIntervalMinutes), token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
firstRun = false;
try
{
var cycleStarted = DateTime.Now;
_logger.Ops(OpsMarkers.UserDelete, "JOB CYCLE START (pending deletes + optional copy/template sync)");
if (_config.EnableTemplateDbToDeviceSync)
{
_logger.Ops(OpsMarkers.TemplateDbToDevice, "JOB CYCLE START direction=DB -> DEVICE");
_logger.Biz(BizChannel.UserSync, "USER SYNCHRONIZATION", "");
_logger.Biz(BizChannel.Template,
"TEMPLATE SYNC",
"",
"Direction :",
"",
"DATABASE -> DEVICE",
"");
_logger.BizSeparator(BizChannel.Template);
}
RunUserFaceSyncCycle(token);
if (_config.EnableTemplateDbToDeviceSync)
_logger.Ops(OpsMarkers.TemplateDbToDevice, "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - cycleStarted));
_logger.Ops(OpsMarkers.UserDelete, "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - cycleStarted));
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.Error("UserSync: cycle exception", ex);
_logger.OpsError(OpsMarkers.TemplateDbToDevice, "JOB CYCLE FAILED reason=\"" + ex.Message + "\"");
}
}
}
/// One page of UserInfo/Search (searchResultPosition = start, maxResults = max).
public bool SearchUsersIsapiHttp(HikvisionAttendanceWindowsService.DeviceConfig device, int start, int max,
out List users)
{
return TrySearchUsersIsapiPage(device, start, max, filterEmployeeNo: null, SyncPol.HttpMaxRetries,
CancellationToken.None, out users, out _);
}
public bool DownloadFaceByUrl(HikvisionAttendanceWindowsService.DeviceConfig source, string faceUrl, out byte[] bytes)
{
return DownloadFaceByUrl(source, faceUrl, SyncPol.HttpMaxRetries, CancellationToken.None, out bytes);
}
public bool DownloadFaceByUrl(HikvisionAttendanceWindowsService.DeviceConfig source, string faceUrl, int maxRetries,
CancellationToken ct, out byte[] bytes)
{
bytes = Array.Empty();
if (string.IsNullOrWhiteSpace(faceUrl))
return false;
var cleaned = StripFaceUrlSuffix(faceUrl.Trim());
int httpPort = IsapiPort;
string pathAndQuery = cleaned;
if (Uri.TryCreate(cleaned, UriKind.Absolute, out var abs))
pathAndQuery = abs.PathAndQuery;
if (!pathAndQuery.StartsWith("/", StringComparison.Ordinal))
pathAndQuery = "/" + pathAndQuery.TrimStart('/');
string url = "http://" + source.Ip + ":" + httpPort + pathAndQuery;
return TryHttpGetBytesWithRetry(source, url, maxRetries, ct, out bytes, out _);
}
public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user)
{
return CreateUserOnTarget(target, user, SyncPol.HttpMaxRetries, CancellationToken.None, out _);
}
public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user, int maxRetries,
CancellationToken ct, out string error)
{
error = "";
var ser = new JavaScriptSerializer();
var root = BuildUserInfoRecordPayload(user);
string json = ser.Serialize(root);
if (!TryIsapiPostJsonWithRetry(target, "/ISAPI/AccessControl/UserInfo/Record?format=json", json, maxRetries, ct,
out var body, out var status, out error))
return false;
if (!IsLikelyIsapiSuccess(body, status))
{
error = "ISAPI error status=" + status + " bodySnip=" + ToOneLineSnippet(body);
return false;
}
return true;
}
public bool UploadFaceOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo,
byte[] faceImage)
{
return UploadFaceOnTarget(target, employeeNo, faceImage, SyncPol.HttpMaxRetries, CancellationToken.None, out _);
}
public bool UploadFaceOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo,
byte[] faceImage, int maxRetries, CancellationToken ct, out string error)
{
error = "";
var pol = SyncPol;
if (faceImage == null || faceImage.Length == 0)
{
error = "empty faceImage";
return false;
}
var emp = (employeeNo ?? "").Trim();
var faceLibType = string.IsNullOrWhiteSpace(pol.FaceLibType) ? "blackFD" : pol.FaceLibType.Trim();
var fdId = string.IsNullOrWhiteSpace(pol.FaceLibraryFdId) ? "1" : pol.FaceLibraryFdId.Trim();
if (!TryNormalizeFaceImageForUpload(faceImage, out var jpegBytes, out var imageFormat, out var normalizeNote))
{
error = "unsupported face image format=" + imageFormat +
" (Hikvision FaceDataRecord requires JPEG). " + normalizeNote;
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
"face upload aborted employee=" + emp +
" format=" + imageFormat +
" blobSize=" + faceImage.Length +
" note=\"" + normalizeNote + "\"");
return false;
}
string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(faceLibType) +
"\",\"FDID\":\"" + EscapeJsonStatic(fdId) +
"\",\"FPID\":\"" + EscapeJsonStatic(emp) + "\"}";
const string relativeUri = "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json";
string url = "http://" + target.Ip + ":" + IsapiPort + relativeUri;
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
"face upload prepare employee=" + emp +
" device=" + (target.DeviceId ?? "") +
" ip=" + (target.Ip ?? "") +
" url=" + url +
" faceLibType=" + faceLibType +
" FDID=" + fdId +
" FPID=" + emp +
" blobSize=" + faceImage.Length +
" jpegSize=" + jpegBytes.Length +
" sourceFormat=" + imageFormat +
" base64Length=" + (4 * ((jpegBytes.Length + 2) / 3)) +
" meta=" + metaJson +
(string.IsNullOrWhiteSpace(normalizeNote) ? "" : (" note=\"" + normalizeNote + "\"")));
if (!TryIsapiPostMultipartFaceWithRetry(target, relativeUri, metaJson, jpegBytes, emp, maxRetries, ct,
out var body, out var status, out error))
{
// Keep Ops/summary concise; full HTTP body already written to Diag inside the sender.
error = ConciseFaceUploadFailureReason(status, body, error);
return false;
}
if (!IsLikelyIsapiSuccess(body, status))
{
TryParseIsapiResponseFields(body, out var sc, out var ss, out var sub);
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
"face upload rejected by device employee=" + emp +
" httpStatus=" + status +
" statusCode=" + sc +
" statusString=\"" + ss + "\"" +
" subStatusCode=\"" + sub + "\"" +
" response=" + (body ?? ""));
error = ConciseFaceUploadFailureReason(status, body, "device rejected face upload");
return false;
}
return true;
}
///
/// Hikvision FaceDataRecord accepts JPEG only. Detect format; convert PNG/BMP when possible.
/// Does not touch DB retrieval — operates on already-loaded bytes.
///
private static bool TryNormalizeFaceImageForUpload(byte[] raw, out byte[] jpegBytes, out string format,
out string note)
{
jpegBytes = Array.Empty();
format = DetectImageFormat(raw);
note = "";
if (format == "JPEG")
{
jpegBytes = raw;
if (raw.Length > 200 * 1024)
note = "image larger than 200KB (device limit); upload may fail";
return true;
}
// Some firmwares store JPEG as base64 text bytes — try decode once.
if (format == "unknown" || format == "text")
{
try
{
var asText = Encoding.ASCII.GetString(raw).Trim();
if (asText.Length > 32 && LooksLikeBase64(asText))
{
var decoded = Convert.FromBase64String(asText);
var decodedFmt = DetectImageFormat(decoded);
if (decodedFmt == "JPEG")
{
jpegBytes = decoded;
format = "JPEG(from-base64-text)";
note = "decoded base64 text blob to JPEG";
return true;
}
raw = decoded;
format = decodedFmt;
}
}
catch
{
// ignore — fall through
}
}
if (format == "PNG" || format == "BMP" || format == "GIF")
{
try
{
using var msIn = new MemoryStream(raw);
using var img = System.Drawing.Image.FromStream(msIn);
using var msOut = new MemoryStream();
img.Save(msOut, System.Drawing.Imaging.ImageFormat.Jpeg);
jpegBytes = msOut.ToArray();
note = "converted " + format + " -> JPEG size=" + jpegBytes.Length;
format = format + "->JPEG";
return jpegBytes.Length > 0;
}
catch (Exception ex)
{
note = "conversion failed: " + ex.Message;
return false;
}
}
note = "expected JPEG (FF D8 FF); got " + format;
return false;
}
private static string DetectImageFormat(byte[] bytes)
{
if (bytes == null || bytes.Length < 3)
return "empty";
if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
return "JPEG";
if (bytes.Length >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
return "PNG";
if (bytes[0] == (byte)'B' && bytes[1] == (byte)'M')
return "BMP";
if (bytes.Length >= 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')
return "GIF";
// Printable ASCII hint (base64 text stored as blob)
int printable = 0;
int n = Math.Min(bytes.Length, 64);
for (int i = 0; i < n; i++)
{
var b = bytes[i];
if (b == 9 || b == 10 || b == 13 || (b >= 32 && b < 127)) printable++;
}
if (printable >= n * 0.9)
return "text";
return "unknown";
}
private static string ConciseFaceUploadFailureReason(int httpStatus, string? body, string? fallback)
{
TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode);
if (!string.IsNullOrWhiteSpace(statusString) &&
!string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase))
{
if (!string.IsNullOrWhiteSpace(subStatusCode))
return statusString.Trim() + " (" + subStatusCode.Trim() + ")";
return statusString.Trim();
}
if (!string.IsNullOrWhiteSpace(subStatusCode) &&
!string.Equals(subStatusCode, "ok", StringComparison.OrdinalIgnoreCase))
return subStatusCode.Trim();
if (httpStatus > 0 && (httpStatus < 200 || httpStatus >= 300))
return "HTTP " + httpStatus;
if (statusCode != 0 && statusCode != 1)
return "Hikvision statusCode=" + statusCode;
if (!string.IsNullOrWhiteSpace(fallback))
{
var f = fallback.Trim();
var brace = f.IndexOf('{');
if (brace > 0) f = f.Substring(0, brace).Trim();
if (f.Length > 160) f = f.Substring(0, 160).TrimEnd() + "...";
return f;
}
return "face upload rejected";
}
public bool VerifyUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo,
out UserDto? user)
{
user = null;
if (string.IsNullOrWhiteSpace(employeeNo))
return false;
if (!TrySearchUsersIsapiPage(target, 0, 10, employeeNo.Trim(), SyncPol.HttpMaxRetries, CancellationToken.None,
out var list,
out _))
return false;
user = list.FirstOrDefault(u =>
string.Equals(u.EmployeeNo, employeeNo.Trim(), StringComparison.OrdinalIgnoreCase));
return user != null;
}
private List FetchAllUsersIsapiForSync(HikvisionAttendanceWindowsService.DeviceConfig device,
int maxRetries, CancellationToken ct)
{
var all = new List();
int pos = 0;
const int page = 100;
while (!ct.IsCancellationRequested)
{
if (!TrySearchUsersIsapiPage(device, pos, page, null, maxRetries, ct, out var chunk, out var err))
{
_logger.Warn("UserSync: UserInfo/Search page failed device=" + device.DeviceId + " pos=" + pos +
" err=" + err);
break;
}
if (chunk == null || chunk.Count == 0)
break;
all.AddRange(chunk);
if (chunk.Count < page)
break;
pos += chunk.Count;
}
return all;
}
private bool TrySearchUsersIsapiPage(HikvisionAttendanceWindowsService.DeviceConfig device, int start, int max,
string? filterEmployeeNo, int maxRetries, CancellationToken ct, out List users, out string error)
{
users = new List();
error = "";
string body;
if (string.IsNullOrEmpty(filterEmployeeNo))
{
body = "{\"UserInfoSearchCond\":{\"searchID\":\"1\",\"searchResultPosition\":" + start +
",\"maxResults\":" + max + "}}";
}
else
{
body = "{\"UserInfoSearchCond\":{\"searchID\":\"1\",\"searchResultPosition\":" + start +
",\"maxResults\":" + max + ",\"EmployeeNoList\":[{\"employeeNo\":\"" +
EscapeJsonStatic(filterEmployeeNo) + "\"}]}}";
}
if (!TryIsapiPostJsonWithRetry(device, "/ISAPI/AccessControl/UserInfo/Search?format=json", body, maxRetries, ct,
out var resp, out var status, out error))
return false;
if (status != (int)HttpStatusCode.OK)
{
error = "HTTP " + status;
return false;
}
users = UserSyncJsonParser.ParseUserInfoSearchPage(resp);
return true;
}
private bool TryModifyUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user,
int maxRetries, CancellationToken ct, out string error)
{
error = "";
var ser = new JavaScriptSerializer();
string json = ser.Serialize(BuildUserInfoRecordPayload(user));
if (!TryIsapiPostJsonWithRetry(target, "/ISAPI/AccessControl/UserInfo/Modify?format=json", json, maxRetries, ct,
out var body, out var status, out error))
return false;
return IsLikelyIsapiSuccess(body, status);
}
private static object BuildUserInfoRecordPayload(UserDto user)
{
return new Dictionary
{
["UserInfo"] = new Dictionary
{
["employeeNo"] = user.EmployeeNo,
["name"] = string.IsNullOrWhiteSpace(user.Name) ? user.EmployeeNo : user.Name,
["userType"] = string.IsNullOrWhiteSpace(user.UserType) ? "normal" : user.UserType,
["Valid"] = new Dictionary
{
["enable"] = user.ValidEnable,
["beginTime"] = user.ValidBeginTime,
["endTime"] = user.ValidEndTime
},
["doorRight"] = user.DoorRight,
["RightPlan"] = new object[]
{
new Dictionary
{
["doorNo"] = user.RightPlanDoorNo,
["planTemplateNo"] = user.RightPlanTemplateNo
}
}
}
};
}
private static bool UserFieldsDiffer(UserDto src, UserDto tgt)
{
return !string.Equals(src.Name, tgt.Name, StringComparison.Ordinal) ||
!string.Equals(src.ValidBeginTime, tgt.ValidBeginTime, StringComparison.Ordinal) ||
!string.Equals(src.ValidEndTime, tgt.ValidEndTime, StringComparison.Ordinal) ||
src.ValidEnable != tgt.ValidEnable ||
!string.Equals(src.UserType, tgt.UserType, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(src.DoorRight, tgt.DoorRight, StringComparison.Ordinal);
}
private bool TryDeleteUsersOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target,
List employeeNumbers, int maxRetries, CancellationToken ct, string machineId, out string error)
{
error = "";
const int batch = 30;
var ser = new JavaScriptSerializer();
for (int i = 0; i < employeeNumbers.Count; i += batch)
{
var slice = employeeNumbers.Skip(i).Take(batch).ToList();
var listObj = slice.Select(e => new Dictionary
{ ["employeeNo"] = e }).ToList();
var employeeLabel = slice.Count == 1 ? slice[0] : ("batch[" + slice.Count + "]");
// Primary: PUT /UserInfo/Delete (Hikvision requires PUT, not POST).
var delCondJson = ser.Serialize(new Dictionary
{
["UserInfoDelCond"] = new Dictionary { ["EmployeeNoList"] = listObj }
});
if (TryExecuteUserDeleteRequest(
target, machineId, employeeLabel, "primary",
"/ISAPI/AccessControl/UserInfo/Delete?format=json",
delCondJson, maxRetries, ct, out error))
continue; // confirmed success — do not call fallback
var primaryError = error;
// Fallback only when primary was not a confirmed success.
var detailJson = ser.Serialize(new Dictionary
{
["UserInfoDetail"] = new Dictionary
{
["mode"] = "byEmployeeNo",
["EmployeeNoList"] = listObj
}
});
if (TryExecuteUserDeleteRequest(
target, machineId, employeeLabel, "fallback",
"/ISAPI/AccessControl/UserInfoDetail/Delete?format=json",
detailJson, maxRetries, ct, out error))
continue;
error = string.IsNullOrWhiteSpace(primaryError) ? error : primaryError;
return false;
}
return true;
}
///
/// Sends one delete attempt, logs full HTTP diagnostics, and returns true only when the
/// Hikvision response is a confirmed success (HTTP 2xx + statusCode 1 + OK/ok).
///
private bool TryExecuteUserDeleteRequest(
HikvisionAttendanceWindowsService.DeviceConfig target,
string machineId,
string employeeLabel,
string attemptName,
string relativeUri,
string requestJson,
int maxRetries,
CancellationToken ct,
out string conciseError)
{
conciseError = "";
var httpOk = TryIsapiSendJsonWithRetry(target, HttpMethod.Put, relativeUri, requestJson, maxRetries,
ct, out var body, out var httpStatus, out var transportError);
_logger.Diag("USER_DELETE",
"attempt=" + attemptName +
" employee=" + employeeLabel +
" machine_id=" + machineId +
" httpStatus=" + httpStatus +
" transportOk=" + httpOk +
" uri=" + relativeUri +
" request=" + ToOneLineSnippet(requestJson, 400) +
" response=" + (body ?? ""));
int statusCode;
string statusString;
string subStatusCode;
bool confirmed = IsConfirmedIsapiDeleteSuccess(body, httpStatus, out statusCode, out statusString, out subStatusCode);
_logger.Diag("USER_DELETE",
"employee=" + employeeLabel +
" machine_id=" + machineId +
" deleteConfirmed=" + confirmed +
" httpStatus=" + httpStatus +
" statusCode=" + statusCode +
" statusString=\"" + statusString + "\"" +
" subStatusCode=\"" + subStatusCode + "\"");
if (confirmed)
{
conciseError = "";
return true;
}
conciseError = DescribeIsapiDeleteFailure(httpStatus, statusCode, statusString, subStatusCode, transportError, body);
return false;
}
private static bool IsConfirmedIsapiDeleteSuccess(
string? body,
int httpStatus,
out int statusCode,
out string statusString,
out string subStatusCode)
{
statusCode = 0;
statusString = "";
subStatusCode = "";
// Requirement: HTTP 200 (accept any 2xx as transport success) + Hikvision OK payload.
if (httpStatus < 200 || httpStatus >= 300)
return false;
if (!TryParseIsapiResponseFields(body, out statusCode, out statusString, out subStatusCode))
return false;
if (statusCode != 1)
return false;
return string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase)
|| string.Equals(subStatusCode, "ok", StringComparison.OrdinalIgnoreCase);
}
private static bool TryParseIsapiResponseFields(
string? body,
out int statusCode,
out string statusString,
out string subStatusCode)
{
statusCode = 0;
statusString = "";
subStatusCode = "";
if (string.IsNullOrWhiteSpace(body))
return false;
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(body);
if (root != null)
{
TryFindInt(root, new[] { "statusCode", "responseStatusCode" }, out statusCode);
TryFindString(root, new[] { "statusString", "responseStatusStrg", "responseStatusStr", "responseStatusString" }, out statusString);
TryFindString(root, new[] { "subStatusCode" }, out subStatusCode);
}
}
catch
{
// fall through to regex
}
if (statusCode == 0)
{
var m = System.Text.RegularExpressions.Regex.Match(body,
"\"statusCode\"\\s*:\\s*\"?(?-?\\d+)\"?",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (m.Success)
int.TryParse(m.Groups["v"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out statusCode);
}
if (string.IsNullOrEmpty(statusString))
{
var m = System.Text.RegularExpressions.Regex.Match(body,
"\"statusString\"\\s*:\\s*\"(?[^\"]*)\"",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (m.Success) statusString = m.Groups["v"].Value;
}
if (string.IsNullOrEmpty(subStatusCode))
{
var m = System.Text.RegularExpressions.Regex.Match(body,
"\"subStatusCode\"\\s*:\\s*\"(?[^\"]*)\"",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (m.Success) subStatusCode = m.Groups["v"].Value;
}
return statusCode != 0
|| !string.IsNullOrWhiteSpace(statusString)
|| !string.IsNullOrWhiteSpace(subStatusCode);
}
private static string DescribeIsapiDeleteFailure(
int httpStatus,
int statusCode,
string statusString,
string subStatusCode,
string? transportError,
string? body)
{
if (httpStatus < 200 || httpStatus >= 300)
{
if (!string.IsNullOrWhiteSpace(transportError))
return ConciseDeleteFailureReason(transportError);
return "HTTP " + httpStatus;
}
var sub = (subStatusCode ?? "").Trim();
if (sub.Length > 0)
{
if (sub.IndexOf("notExist", StringComparison.OrdinalIgnoreCase) >= 0 ||
sub.IndexOf("notFound", StringComparison.OrdinalIgnoreCase) >= 0 ||
sub.Equals("invalidEmployeeNo", StringComparison.OrdinalIgnoreCase) ||
sub.Equals("employeeNoNotExist", StringComparison.OrdinalIgnoreCase))
return "Employee was not found on the device";
return sub;
}
if (!string.IsNullOrWhiteSpace(statusString) &&
!string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase))
return statusString.Trim();
if (statusCode != 0 && statusCode != 1)
return "Hikvision statusCode=" + statusCode;
if (!string.IsNullOrWhiteSpace(transportError))
return ConciseDeleteFailureReason(transportError);
return "device delete rejected";
}
/// Keeps Ops summary lines short — never dump full JSON into the failure reason.
private static string ConciseDeleteFailureReason(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return "device delete rejected";
var s = raw.Trim();
// Strip any embedded JSON / response dumps from older error paths.
var snipIdx = s.IndexOf(" snip=", StringComparison.OrdinalIgnoreCase);
if (snipIdx > 0)
s = s.Substring(0, snipIdx).Trim();
var brace = s.IndexOf('{');
if (brace > 0)
s = s.Substring(0, brace).Trim().TrimEnd('|', ' ');
if (s.Length > 180)
s = s.Substring(0, 180).TrimEnd() + "...";
return string.IsNullOrWhiteSpace(s) ? "device delete rejected" : s;
}
private void TrySaveFaceCacheFile(string employeeNo, byte[] bytes)
{
var dir = _config.UserSyncFaceCacheDirectory?.Trim() ?? "";
if (dir.Length == 0 || bytes == null || bytes.Length == 0)
return;
try
{
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, SanitizeFileName(employeeNo) + ".jpg");
File.WriteAllBytes(path, bytes);
}
catch (Exception ex)
{
_logger.Warn("UserSync: face cache write failed employeeNo=" + employeeNo + " err=" + ex.Message);
}
}
private static string SanitizeFileName(string s)
{
foreach (var c in Path.GetInvalidFileNameChars())
s = s.Replace(c, '_');
return string.IsNullOrWhiteSpace(s) ? "unknown" : s.Trim();
}
private static string StripFaceUrlSuffix(string raw)
{
int at = raw.IndexOf('@');
if (at > 0)
return raw.Substring(0, at);
return raw;
}
private bool TryIsapiPostJsonWithRetry(HikvisionAttendanceWindowsService.DeviceConfig device, string relativeUri,
string jsonBody, int maxRetries, CancellationToken ct, out string responseBody, out int statusCode, out string error)
{
return TryIsapiSendJsonWithRetry(device, HttpMethod.Post, relativeUri, jsonBody, maxRetries, ct,
out responseBody, out statusCode, out error);
}
private bool TryIsapiSendJsonWithRetry(HikvisionAttendanceWindowsService.DeviceConfig device, HttpMethod method,
string relativeUri, string jsonBody, int maxRetries, CancellationToken ct, out string responseBody,
out int statusCode, out string error)
{
responseBody = "";
statusCode = 0;
error = "";
string url = "http://" + device.Ip + ":" + IsapiPort + relativeUri;
int attempts = Math.Max(1, maxRetries);
for (int attempt = 0; attempt < attempts; attempt++)
{
try
{
using var handler = BuildDigestHandler(device);
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) };
using var content = new StringContent(jsonBody ?? "", Encoding.UTF8, "application/json");
using var req = new HttpRequestMessage(method, url) { Content = content };
using var res = client.SendAsync(req).GetAwaiter().GetResult();
statusCode = (int)res.StatusCode;
responseBody = res.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (res.IsSuccessStatusCode)
return true;
error = "HTTP " + statusCode + " " + res.ReasonPhrase;
if (attempt + 1 < attempts && UserSyncRetryHelper.IsTransientHttpStatus(res.StatusCode))
{
UserSyncRetryHelper.SleepBackoff(attempt, ct);
continue;
}
return false;
}
catch (Exception ex)
{
error = ex.Message;
if (attempt + 1 < attempts && UserSyncRetryHelper.ShouldRetryException(ex))
{
UserSyncRetryHelper.SleepBackoff(attempt, ct);
continue;
}
return false;
}
}
return false;
}
private bool TryHttpGetBytesWithRetry(HikvisionAttendanceWindowsService.DeviceConfig device, string fullUrl,
int maxRetries, CancellationToken ct, out byte[] bytes, out string error)
{
bytes = Array.Empty();
error = "";
int attempts = Math.Max(1, maxRetries);
for (int attempt = 0; attempt < attempts; attempt++)
{
try
{
using var handler = BuildDigestHandler(device);
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) };
using var res = client.GetAsync(fullUrl).GetAwaiter().GetResult();
bytes = res.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
if (res.IsSuccessStatusCode && bytes.Length > 0)
return true;
error = "HTTP " + (int)res.StatusCode;
if (attempt + 1 < attempts && UserSyncRetryHelper.IsTransientHttpStatus(res.StatusCode))
{
UserSyncRetryHelper.SleepBackoff(attempt, ct);
continue;
}
return false;
}
catch (Exception ex)
{
error = ex.Message;
if (attempt + 1 < attempts && UserSyncRetryHelper.ShouldRetryException(ex))
{
UserSyncRetryHelper.SleepBackoff(attempt, ct);
continue;
}
return false;
}
}
return false;
}
private bool TryIsapiPostMultipartFaceWithRetry(HikvisionAttendanceWindowsService.DeviceConfig device,
string relativeUri, string faceDataRecordJson, byte[] image, string employeeNo, int maxRetries,
CancellationToken ct, out string responseBody, out int statusCode, out string error)
{
responseBody = "";
statusCode = 0;
error = "";
string url = "http://" + device.Ip + ":" + IsapiPort + relativeUri;
int attempts = Math.Max(1, maxRetries);
for (int attempt = 0; attempt < attempts; attempt++)
{
try
{
// Hikvision docs require a hand-built multipart body with parts named
// "FaceDataRecord" (JSON) and "FaceImage" (JPEG). HttpClient's
// MultipartFormDataContent often adds filename= on the JSON part and
// historically we used name="img", which the device rejects with HTTP 400.
var boundary = "---------------" + DateTime.Now.Ticks.ToString("x", CultureInfo.InvariantCulture);
var bodyBytes = BuildHikvisionFaceMultipartBody(boundary, faceDataRecordJson, image);
using var handler = BuildDigestHandler(device);
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(120) };
using var content = new ByteArrayContent(bodyBytes);
content.Headers.TryAddWithoutValidation("Content-Type", "multipart/form-data; boundary=" + boundary);
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
req.Headers.TryAddWithoutValidation("Accept", "application/json, text/html, application/xhtml+xml");
using var res = client.SendAsync(req).GetAwaiter().GetResult();
statusCode = (int)res.StatusCode;
responseBody = res.Content.ReadAsStringAsync().GetAwaiter().GetResult() ?? "";
TryParseIsapiResponseFields(responseBody, out var sc, out var ss, out var sub);
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
"face upload attempt=" + (attempt + 1) +
" employee=" + employeeNo +
" httpStatus=" + statusCode +
" statusCode=" + sc +
" statusString=\"" + ss + "\"" +
" subStatusCode=\"" + sub + "\"" +
" response=" + responseBody);
if (res.IsSuccessStatusCode)
return true;
error = "HTTP " + statusCode + " " + (res.ReasonPhrase ?? "");
if (!string.IsNullOrWhiteSpace(ss) || !string.IsNullOrWhiteSpace(sub))
error = ConciseFaceUploadFailureReason(statusCode, responseBody, error);
if (attempt + 1 < attempts && UserSyncRetryHelper.IsTransientHttpStatus(res.StatusCode))
{
UserSyncRetryHelper.SleepBackoff(attempt, ct);
continue;
}
return false;
}
catch (Exception ex)
{
error = ex.Message;
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
"face upload exception attempt=" + (attempt + 1) +
" employee=" + employeeNo +
" err=\"" + ex.Message + "\"");
if (attempt + 1 < attempts && UserSyncRetryHelper.ShouldRetryException(ex))
{
UserSyncRetryHelper.SleepBackoff(attempt, ct);
continue;
}
return false;
}
}
return false;
}
///
/// Builds the exact multipart layout documented by Hikvision for FaceDataRecord uploads.
///
private static byte[] BuildHikvisionFaceMultipartBody(string boundary, string faceDataRecordJson, byte[] jpegBytes)
{
using var ms = new MemoryStream();
void WriteAscii(string s)
{
var b = Encoding.ASCII.GetBytes(s);
ms.Write(b, 0, b.Length);
}
var jsonBytes = Encoding.UTF8.GetBytes(faceDataRecordJson ?? "");
WriteAscii("--" + boundary + "\r\n");
WriteAscii("Content-Disposition: form-data; name=\"FaceDataRecord\";\r\n");
WriteAscii("Content-Type: application/json\r\n");
WriteAscii("Content-Length: " + jsonBytes.Length.ToString(CultureInfo.InvariantCulture) + "\r\n\r\n");
ms.Write(jsonBytes, 0, jsonBytes.Length);
WriteAscii("\r\n--" + boundary + "\r\n");
WriteAscii("Content-Disposition: form-data; name=\"FaceImage\";\r\n");
WriteAscii("Content-Type: image/jpeg\r\n");
WriteAscii("Content-Length: " + jpegBytes.Length.ToString(CultureInfo.InvariantCulture) + "\r\n\r\n");
ms.Write(jpegBytes, 0, jpegBytes.Length);
WriteAscii("\r\n--" + boundary + "--\r\n");
return ms.ToArray();
}
private static HttpClientHandler BuildDigestHandler(HikvisionAttendanceWindowsService.DeviceConfig device)
{
return new HttpClientHandler
{
Credentials = new NetworkCredential(device.Username ?? "", device.Password ?? ""),
PreAuthenticate = false,
UseDefaultCredentials = false
};
}
private static bool IsLikelyIsapiSuccess(string? body, int httpStatus)
{
if (httpStatus < 200 || httpStatus >= 300)
return false;
if (string.IsNullOrWhiteSpace(body))
return true;
if (!TryParseIsapiResponseFields(body, out var statusCode, out var statusString, out var subStatusCode))
return true; // no status fields — treat 2xx as OK for create/modify
if (statusCode == 1)
return true;
if (string.Equals(statusString, "OK", StringComparison.OrdinalIgnoreCase) ||
string.Equals(subStatusCode, "ok", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}