diff --git a/HikvisionAttendanceManager.UserSync.cs b/HikvisionAttendanceManager.UserSync.cs index 2481fbc..f49c704 100644 --- a/HikvisionAttendanceManager.UserSync.cs +++ b/HikvisionAttendanceManager.UserSync.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -35,22 +36,62 @@ internal sealed partial class HikvisionAttendanceManager 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 = ResolveDeviceConfig(_config.SourceDeviceId ?? ""); + 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 ?? "") + "\"."); + (_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); @@ -62,8 +103,13 @@ internal sealed partial class HikvisionAttendanceManager if (DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source.DeviceId)) { - _logger.Warn("UserSync: target skipped — same as source. deviceId=\"" + t.DeviceId + "\"."); - continue; + 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); @@ -71,26 +117,72 @@ internal sealed partial class HikvisionAttendanceManager if (targets.Count == 0) { - _logger.Warn("UserSync: skipped — no valid target devices."); + // 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("UserSync: cycle=" + cycleId + " begin source=" + source.DeviceId + " targets=" + targets.Count + - " maxRetries=" + maxRetries + " policies: UpdateExistingUserFields=" + pol.UpdateExistingUserFields + + _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 + " source=" + source.DeviceId + " targets=" + targets.Count); + _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 = 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 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; @@ -110,13 +202,58 @@ internal sealed partial class HikvisionAttendanceManager } } - _logger.Info("UserSync: cycle=" + cycleId + " faceDownload ok=" + faceDlOk + " failOrEmpty=" + 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), @@ -145,10 +282,13 @@ internal sealed partial class HikvisionAttendanceManager 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 (assignedUsers != null && !assignedUsers.Contains(srcUser.EmployeeNo)) + if (!explicitEmployeeFilter && assignedUsers != null && !assignedUsers.Contains(srcUser.EmployeeNo)) continue; targetMap.TryGetValue(srcUser.EmployeeNo, out var tgtRow); bool createdNew = false; @@ -165,6 +305,8 @@ internal sealed partial class HikvisionAttendanceManager } created++; + _logger.Totals.UsersAdded++; + _logger.Biz(BizChannel.UserSync, srcUser.EmployeeNo + " added successfully.", ""); createdNew = true; tgtRow = new UserDto { @@ -180,6 +322,8 @@ internal sealed partial class HikvisionAttendanceManager if (TryModifyUserOnTarget(target, srcUser, maxRetries, ct, out var modErr)) { updated++; + _logger.Totals.UsersUpdated++; + _logger.Biz(BizChannel.UserSync, srcUser.EmployeeNo + " updated successfully.", ""); Bump(UserSyncStatus.ExistsUserFieldsUpdated); tgtRow.Name = srcUser.Name; tgtRow.ValidBeginTime = srcUser.ValidBeginTime; @@ -221,8 +365,25 @@ internal sealed partial class HikvisionAttendanceManager 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; @@ -231,21 +392,66 @@ internal sealed partial class HikvisionAttendanceManager 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.Warn("UserSync: FailedFaceUpload employeeNo=" + srcUser.EmployeeNo + " target=" + - target.DeviceId + " err=" + 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); } @@ -254,58 +460,151 @@ internal sealed partial class HikvisionAttendanceManager 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.Diag("user_sync", "policy delete candidates=" + toDelete.Count + " target=" + target.DeviceId); + if (!TryDeleteUsersOnTarget(target, toDelete, maxRetries, ct, target.DeviceId ?? "", out var delErr)) { - _logger.Warn("UserSync: batch delete failed target=" + target.DeviceId + " err=" + 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 _ in toDelete) + foreach (var emp in toDelete) + { + _logger.Ops(OpsMarkers.UserDelete, emp + " -> Device " + target.DeviceId + " = DELETED SUCCESSFULLY"); + _logger.Totals.UsersRemoved++; + _logger.Biz(BizChannel.UserSync, emp + " removed successfully.", ""); 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.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.Info("UserSync: cycle=" + cycleId + " completed."); - _logger.JobInfo("user_sync", "Cycle completed id=" + cycleId); + _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, 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) @@ -328,9 +627,25 @@ internal sealed partial class HikvisionAttendanceManager try { - LogJobCycleStart("user_sync", "USER SYNC", "--UserSync job started at "); + 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); - LogJobCycleEnd("user_sync", "--UserSync job finished at "); + 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) { @@ -339,7 +654,7 @@ internal sealed partial class HikvisionAttendanceManager catch (Exception ex) { _logger.Error("UserSync: cycle exception", ex); - _logger.JobError("user_sync", "Cycle exception err=" + ex.Message); + _logger.OpsError(OpsMarkers.TemplateDbToDevice, "JOB CYCLE FAILED reason=\"" + ex.Message + "\""); } } } @@ -418,13 +733,190 @@ internal sealed partial class HikvisionAttendanceManager return false; } - string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(pol.FaceLibType) + "\",\"FDID\":\"" + - EscapeJsonStatic(pol.FaceLibraryFdId) + "\",\"FPID\":\"" + - EscapeJsonStatic(employeeNo ?? "") + "\"}"; + var emp = (employeeNo ?? "").Trim(); + var faceLibType = string.IsNullOrWhiteSpace(pol.FaceLibType) ? "blackFD" : pol.FaceLibType.Trim(); + var fdId = string.IsNullOrWhiteSpace(pol.FaceLibraryFdId) ? "1" : pol.FaceLibraryFdId.Trim(); - return TryIsapiPostMultipartFaceWithRetry(target, - "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json", metaJson, faceImage, employeeNo, maxRetries, ct, - out _, out _, out error); + 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, @@ -551,34 +1043,244 @@ internal sealed partial class HikvisionAttendanceManager } private bool TryDeleteUsersOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, - List employeeNumbers, int maxRetries, CancellationToken ct, out string error) + 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 root = new Dictionary + 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 } - }; - 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)) + }); + 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 { - error = "delete ISAPI status=" + status + " snip=" + ToOneLineSnippet(body); - return false; - } + ["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() ?? ""; @@ -613,6 +1315,14 @@ internal sealed partial class HikvisionAttendanceManager 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; @@ -626,7 +1336,7 @@ internal sealed partial class HikvisionAttendanceManager 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 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(); @@ -713,21 +1423,40 @@ internal sealed partial class HikvisionAttendanceManager { 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 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 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(); + 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; + 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); @@ -739,6 +1468,10 @@ internal sealed partial class HikvisionAttendanceManager 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); @@ -752,6 +1485,36 @@ internal sealed partial class HikvisionAttendanceManager 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 @@ -764,17 +1527,20 @@ internal sealed partial class HikvisionAttendanceManager 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; - } + 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; }