using System; using System.Collections.Generic; 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; } /// Runs one full sync cycle: source UserInfo/Search, per-target reconcile, optional deletes. public void RunUserFaceSyncCycle(CancellationToken ct) { ct.ThrowIfCancellationRequested(); var pol = SyncPol; int maxRetries = pol.HttpMaxRetries < 1 ? 1 : pol.HttpMaxRetries; var source = ResolveDeviceConfig(_config.SourceDeviceId ?? ""); if (source == null || string.IsNullOrWhiteSpace(source.Ip)) { _logger.Warn("UserSync: skipped — source device not found or IP empty. SourceDeviceId=\"" + (_config.SourceDeviceId ?? "") + "\"."); return; } var targets = new List(); 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)) { _logger.Warn("UserSync: target skipped — same as source. deviceId=\"" + t.DeviceId + "\"."); continue; } targets.Add(t); } if (targets.Count == 0) { _logger.Warn("UserSync: skipped — no valid target devices."); return; } var cycleId = DateTime.UtcNow.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture); _logger.Info("UserSync: cycle=" + cycleId + " begin source=" + source.DeviceId + " targets=" + targets.Count + " maxRetries=" + maxRetries + " policies: UpdateExistingUserFields=" + pol.UpdateExistingUserFields + " UploadFaceIfMissingOnly=" + pol.UploadFaceIfMissingOnly + " DeleteOnTargetIfMissingInSource=" + pol.DeleteOnTargetIfMissingInSource); _logger.JobInfo("user_sync", "Cycle begin id=" + cycleId + " source=" + source.DeviceId + " targets=" + targets.Count); var sourceUsers = FetchAllUsersIsapiForSync(source, maxRetries, ct); _logger.Info("UserSync: cycle=" + cycleId + " sourceUsers=" + sourceUsers.Count + " device=" + source.DeviceId); _logger.JobInfo("user_sync", "Source users=" + sourceUsers.Count + " device=" + source.DeviceId); var faceBytesByEmployee = new Dictionary(StringComparer.OrdinalIgnoreCase); int faceDlOk = 0, faceDlFail = 0; foreach (var u in sourceUsers) { ct.ThrowIfCancellationRequested(); 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("UserSync: cycle=" + cycleId + " faceDownload ok=" + faceDlOk + " failOrEmpty=" + faceDlFail); var sourceEmp = new HashSet(sourceUsers.Select(s => s.EmployeeNo), StringComparer.OrdinalIgnoreCase); foreach (var target in targets) { ct.ThrowIfCancellationRequested(); 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; } foreach (var srcUser in sourceUsers) { ct.ThrowIfCancellationRequested(); if (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++; 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++; 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++; if (!createdNew && tgtRow.NumOfFace > 0) Bump(UserSyncStatus.AlreadySynced); else Bump(createdNew ? UserSyncStatus.CreatedNoFaceAvailable : UserSyncStatus.ExistsNoFaceOnSourceSkipped); continue; } if (pol.UploadFaceIfMissingOnly && tgtRow.NumOfFace > 0) { skipped++; Bump(UserSyncStatus.SkippedFaceUploadPolicy); continue; } if (!UploadFaceOnTarget(target, srcUser.EmployeeNo, fb!, maxRetries, ct, out var upErr)) { _logger.Warn("UserSync: FailedFaceUpload employeeNo=" + srcUser.EmployeeNo + " target=" + target.DeviceId + " err=" + upErr); failed++; Bump(createdNew ? UserSyncStatus.CreatedFaceUploadFailed : UserSyncStatus.FailedFaceUpload); continue; } faceUp++; tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1); Bump(createdNew ? UserSyncStatus.CreatedAndFaceUploaded : UserSyncStatus.ExistsFaceUploaded); } if (pol.DeleteOnTargetIfMissingInSource) { var toDelete = initialTargetEmployeeIds.Where(k => !sourceEmp.Contains(k)).ToList(); if (toDelete.Count > 0) { _logger.Info("UserSync: cycle=" + cycleId + " target=" + target.DeviceId + " deleteCandidates=" + toDelete.Count); if (!TryDeleteUsersOnTarget(target, toDelete, maxRetries, ct, out var delErr)) { _logger.Warn("UserSync: batch delete failed target=" + target.DeviceId + " err=" + delErr); for (var di = 0; di < toDelete.Count; di++) Bump(UserSyncStatus.FailedDeleteOnTarget); } else { foreach (var _ in toDelete) Bump(UserSyncStatus.DeletedOnTargetNotInSource); } } } if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null) { var pendingDeletionUsers = _attendanceMachineUserRepository.GetPendingDeletionUsersByMachine(target.DeviceId ?? "", out var pendingErr); if (!string.IsNullOrWhiteSpace(pendingErr)) { _logger.Warn("UserSync pending deletion fetch failed target=" + target.DeviceId + " err=" + pendingErr); } else if (pendingDeletionUsers.Count > 0) { var toDelete = pendingDeletionUsers.Select(x => x.SerialNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); string delErr2 = ""; if (toDelete.Count > 0 && TryDeleteUsersOnTarget(target, toDelete, maxRetries, ct, out delErr2)) { foreach (var serial in toDelete) _attendanceMachineUserRepository.MarkDeleted(target.DeviceId ?? "", serial, "hikvision-service", out _); _logger.JobInfo("user_sync", "Target " + target.DeviceId + " DB deletion requests processed=" + toDelete.Count); } else if (toDelete.Count > 0) { _logger.Warn("UserSync DB-requested deletions failed target=" + target.DeviceId + " err=" + delErr2); } } } var statusLine = string.Join(", ", statusCounts.OrderBy(kv => kv.Key.ToString()) .Select(kv => kv.Key + "=" + kv.Value)); _logger.Info("UserSync: cycle=" + cycleId + " target=" + target.DeviceId + " summary created=" + created + " faceUploaded=" + faceUp + " userFieldsUpdated=" + updated + " skippedRows=" + skipped + " failures=" + failed + " statusCounts[" + statusLine + "]"); _logger.JobInfo("user_sync", "Target summary cycle=" + cycleId + " target=" + target.DeviceId + " created=" + created + " faceUploaded=" + faceUp + " updated=" + updated + " skipped=" + skipped + " failed=" + failed + " statusCounts[" + statusLine + "]"); } _logger.Info("UserSync: cycle=" + cycleId + " completed."); _logger.JobInfo("user_sync", "Cycle completed id=" + cycleId); } 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 { LogJobCycleStart("user_sync", "USER SYNC", "--UserSync job started at "); RunUserFaceSyncCycle(token); LogJobCycleEnd("user_sync", "--UserSync job finished at "); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.Error("UserSync: cycle exception", ex); _logger.JobError("user_sync", "Cycle exception err=" + 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; } string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(pol.FaceLibType) + "\",\"FDID\":\"" + EscapeJsonStatic(pol.FaceLibraryFdId) + "\",\"FPID\":\"" + EscapeJsonStatic(employeeNo ?? "") + "\"}"; return TryIsapiPostMultipartFaceWithRetry(target, "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json", metaJson, faceImage, employeeNo, maxRetries, ct, out _, out _, out error); } 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, out string error) { error = ""; const int batch = 30; 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 root = new Dictionary { ["UserInfoDelCond"] = new Dictionary { ["EmployeeNoList"] = listObj } }; var ser = new JavaScriptSerializer(); string json = ser.Serialize(root); if (!TryIsapiPostJsonWithRetry(target, "/ISAPI/AccessControl/UserInfo/Delete?format=json", json, maxRetries, ct, out var body, out var status, out error)) return false; if (!IsLikelyIsapiSuccess(body, status)) { error = "delete ISAPI status=" + status + " snip=" + ToOneLineSnippet(body); return false; } } return true; } 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) { 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(HttpMethod.Post, 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 { using var handler = BuildDigestHandler(device); using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(120) }; using var mp = new MultipartFormDataContent(); mp.Add(new StringContent(faceDataRecordJson, Encoding.UTF8, "application/json"), "FaceDataRecord"); var img = new ByteArrayContent(image); img.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); mp.Add(img, "img", SanitizeFileName(employeeNo) + ".jpg"); using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = mp }; 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 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) { if (string.IsNullOrWhiteSpace(body)) return true; var s = body.Trim(); if (s.IndexOf("\"statusCode\"", StringComparison.OrdinalIgnoreCase) < 0 && s.IndexOf("statusCode", StringComparison.OrdinalIgnoreCase) < 0) return true; if (s.Contains("\"statusCode\":1") || s.Contains("\"statusCode\": 1")) return true; } return false; } }