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