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; using MySql.Data.MySqlClient; namespace HikvisionAttendanceService; /// Multi-device user + face sync via ISAPI HTTP (Digest). Orchestration and focused helpers. internal sealed partial class HikvisionAttendanceManager { private readonly Dictionary> _faceLibCacheByDeviceIp = new Dictionary>(StringComparer.OrdinalIgnoreCase); 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; // Merge SyncEmployeeIds + serial_numbers from SyncDepartmentIds (deduped). var requestedEmployeeIds = BuildMergedSyncEmployeeIds(out var departmentIdsUsed, out var departmentEmployeesFound); if (departmentIdsUsed.Count > 0) { _logger.Ops("USER_SYNC", "Department filter: departmentIds=" + string.Join(",", departmentIdsUsed) + " employeesFound=" + departmentEmployeesFound); } var hasExplicitEmployeeFilter = requestedEmployeeIds.Count > 0; // DB templates already in attendance_machine_face_templates do not need a source device. var dbOnlyRestore = _config.EnableTemplateDbToDeviceSync && hasExplicitEmployeeFilter; var source = !string.IsNullOrWhiteSpace(_config.SourceMachineIp) ? ResolveDeviceConfigByIp(_config.SourceMachineIp) : ResolveDeviceConfig(_config.SourceDeviceId ?? ""); var hasSource = source != null && !string.IsNullOrWhiteSpace(source.Ip); if (!hasSource && !dbOnlyRestore) { _logger.Warn("UserSync: skipped — source device not found or IP empty. SourceDeviceId=\"" + (_config.SourceDeviceId ?? "") + "\" SourceMachineIp=\"" + (_config.SourceMachineIp ?? "") + "\"."); return; } var targets = CollectTestedSyncTargets(source, hasSource, ct); 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); var sourceDeviceLabel = hasSource ? (source!.DeviceId ?? "") : "(none-db-restore)"; var sourceIpLabel = hasSource ? (source!.Ip ?? "") : ""; _logger.Info("USER_SYNC cycle begin id=" + cycleId + ". Meaning: one full restore/sync pass from source to target(s)." + " sourceDevice=" + sourceDeviceLabel + " sourceIp=" + sourceIpLabel + " 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=" + sourceDeviceLabel + " sourceIp=" + sourceIpLabel + " targets=" + targets.Count); var sourceUsers = hasSource ? LoadSourceUsersForSync(source!, maxRetries, ct) : new List(); if (hasExplicitEmployeeFilter) { 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.Ops("USER_SYNC", "Final employee sync count=" + sourceUsers.Count); _logger.JobInfo("user_sync", "Employee filter: requested=" + requestedEmployeeIds.Count + " willSync=" + sourceUsers.Count + ". Meaning: SyncEmployeeIds + SyncDepartmentIds (deduped) drive this cycle."); } _logger.Info("USER_SYNC source users ready count=" + sourceUsers.Count + " device=" + sourceDeviceLabel + ". Meaning: users we will try to create/update on the target."); _logger.JobInfo("user_sync", "Source users ready count=" + sourceUsers.Count + " device=" + sourceDeviceLabel); 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 (!hasSource || 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(); _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(); var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>(); var failedEmployees = new List<(string EmployeeNo, string Reason)>(); var skippedEmployees = new List<(string EmployeeNo, string Reason)>(); void Bump(UserSyncStatus s) { statusCounts.TryGetValue(s, out var n); statusCounts[s] = n + 1; } // Explicit SyncEmployeeIds / SyncDepartmentIds = restore/push list; do not filter by DB assignment rows. var explicitEmployeeFilter = hasExplicitEmployeeFilter; 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; bool createUserLogged = false; bool faceUploadedLogged = false; bool templateFoundLogged = false; string dbCreateUserResult = "skipped"; string dbFaceUploadResult = "skipped"; string dbVerificationResult = "skipped"; string dbFinalResult = "skipped"; try { if (tgtRow == null) { if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr, out var alreadyExisted)) { dbCreateUserResult = "failed"; dbFinalResult = string.IsNullOrWhiteSpace(createErr) ? "create_user_failed" : createErr; var createReason = DescribeUserSyncFailure("create", createErr); failedEmployees.Add((srcUser.EmployeeNo, createReason)); _logger.Warn("UserSync: FailedCreate employeeNo=" + srcUser.EmployeeNo + " target=" + target.DeviceId + " err=" + createErr); _logger.Biz(BizChannel.UserSync, "Machine ID : " + (target.DeviceId ?? ""), "Machine IP : " + (target.Ip ?? ""), "", srcUser.EmployeeNo + " user create failed.", "", "Reason :", "", createReason, ""); failed++; Bump(UserSyncStatus.FailedCreate); continue; } if (alreadyExisted) { dbCreateUserResult = "exists"; _logger.Biz(BizChannel.UserSync, srcUser.EmployeeNo + " already present.", ""); tgtRow = new UserDto { EmployeeNo = srcUser.EmployeeNo, Name = srcUser.Name, NumOfFace = 0, NumOfFp = 0 }; targetMap[srcUser.EmployeeNo] = tgtRow; } else { dbCreateUserResult = "ok"; created++; createUserLogged = true; _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 { dbCreateUserResult = "exists"; 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 { var modReason = DescribeUserSyncFailure("modify", modErr); failedEmployees.Add((srcUser.EmployeeNo, modReason)); _logger.Warn("UserSync: FailedUserModify employeeNo=" + srcUser.EmployeeNo + " target=" + target.DeviceId + " err=" + modErr); _logger.Biz(BizChannel.UserSync, "Machine ID : " + (target.DeviceId ?? ""), "Machine IP : " + (target.Ip ?? ""), "", srcUser.EmployeeNo + " user update failed.", "", "Reason :", "", modReason, ""); 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); } templateFoundLogged = hasFaceBytes; if (!hasFaceBytes) { dbFaceUploadResult = "missing"; dbVerificationResult = "skipped"; dbFinalResult = "template_missing"; 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 { skippedEmployees.Add((srcUser.EmployeeNo, "Face template missing (no face in DB/device source).")); _logger.Biz(BizChannel.UserSync, srcUser.EmployeeNo + " face sync skipped.", "", "Reason :", "", "Face template missing (no face in DB/device source).", ""); Bump(createdNew ? UserSyncStatus.CreatedNoFaceAvailable : UserSyncStatus.ExistsNoFaceOnSourceSkipped); } continue; } if (pol.UploadFaceIfMissingOnly && tgtRow.NumOfFace > 0) { dbFaceUploadResult = "skipped_exists"; dbVerificationResult = "ok"; dbFinalResult = "already_synced"; skipped++; skippedEmployees.Add((srcUser.EmployeeNo, "Face already exists on device and overwrite is disabled.")); _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)) { dbFaceUploadResult = "failed"; dbVerificationResult = "failed"; dbFinalResult = string.IsNullOrWhiteSpace(upErr) ? "face_upload_failed" : upErr; var faceReason = DescribeUserSyncFailure("face", upErr); faceRejectedEmployees.Add((srcUser.EmployeeNo, faceReason)); failedEmployees.Add((srcUser.EmployeeNo, faceReason)); _logger.OpsError(OpsMarkers.TemplateDbToDevice, srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\""); _logger.Totals.TemplatesFailed++; _logger.Biz(BizChannel.UserSync, srcUser.EmployeeNo + " face enrollment failed.", "", "Reason :", "", faceReason, ""); if (_config.EnableTemplateDbToDeviceSync) { _logger.Biz(BizChannel.Template, "Employee " + srcUser.EmployeeNo, "", "Face Template", "", "Upload failed.", "", "Reason :", "", faceReason, ""); _logger.BizSeparator(BizChannel.Template); } failed++; Bump(createdNew ? UserSyncStatus.CreatedFaceUploadFailed : UserSyncStatus.FailedFaceUpload); continue; } faceUp++; faceUploadedLogged = true; dbFaceUploadResult = "ok"; dbVerificationResult = "ok"; dbFinalResult = "success"; _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); } finally { _logger.Ops("USER_SYNC", "employeeNo=" + srcUser.EmployeeNo + " templateFound=" + (templateFoundLogged ? "true" : "false") + " createUser=" + (createUserLogged ? "true" : "false") + " faceUploaded=" + (faceUploadedLogged ? "true" : "false")); if (_config.EnableTemplateDbToDeviceSync) { LogDbToDeviceResult( srcUser.EmployeeNo, dbCreateUserResult, dbFaceUploadResult, dbVerificationResult, dbFinalResult); } } } 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)); WriteFaceRejectedEmployeeSummary("TEMPLATE_DB_TO_DEVICE", target, faceRejectedEmployees); WriteUserSyncTargetConclusion(target, created, faceUp, updated, skipped, failed, failedEmployees, skippedEmployees); _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 void WriteUserSyncTargetConclusion( HikvisionAttendanceWindowsService.DeviceConfig target, int created, int faceUploaded, int updated, int skipped, int failed, IReadOnlyList<(string EmployeeNo, string Reason)> failedEmployees, IReadOnlyList<(string EmployeeNo, string Reason)> skippedEmployees) { var lines = new List { "USER SYNC SUMMARY", "", "Machine ID : " + (target.DeviceId ?? ""), "Machine IP : " + (target.Ip ?? ""), "Created Users : " + created, "Updated Users : " + updated, "Face Uploaded : " + faceUploaded, "Skipped Users : " + skipped, "Failed Users : " + failed, "" }; if (failedEmployees != null && failedEmployees.Count > 0) { lines.Add("CONCLUSION — FAILED USERS:"); foreach (var item in failedEmployees .GroupBy(x => x.EmployeeNo, StringComparer.OrdinalIgnoreCase) .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)) { var reasons = string.Join(" | ", item .Select(x => ToOneLineSnippet(x.Reason, 240)) .Distinct(StringComparer.OrdinalIgnoreCase)); lines.Add(" " + item.Key + " : " + reasons); } lines.Add(""); } if (skippedEmployees != null && skippedEmployees.Count > 0) { lines.Add("SKIPPED USERS:"); foreach (var item in skippedEmployees .OrderBy(x => x.EmployeeNo, StringComparer.OrdinalIgnoreCase)) { lines.Add(" " + item.EmployeeNo + " : " + ToOneLineSnippet(item.Reason, 240)); } lines.Add(""); } if (failed == 0 && (failedEmployees == null || failedEmployees.Count == 0)) lines.Add("CONCLUSION : All processed users succeeded (or were intentionally skipped)."); _logger.Biz(BizChannel.UserSync, lines.ToArray()); } private static string DescribeUserSyncFailure(string stage, string? rawError) { var detail = string.IsNullOrWhiteSpace(rawError) ? "Unknown error." : rawError.Trim(); switch ((stage ?? "").Trim().ToLowerInvariant()) { case "create": return "User create failed on device. " + detail; case "modify": return "User update failed on device. " + detail; case "face": return "Face enrollment failed on device. " + detail; default: return detail; } } private void WriteFaceRejectedEmployeeSummary( string diagTag, HikvisionAttendanceWindowsService.DeviceConfig target, IReadOnlyList<(string EmployeeNo, string Reason)> rejected) { if (rejected == null || rejected.Count == 0) return; _logger.Diag(diagTag, "FACE REJECTED SUMMARY device=" + (target.DeviceId ?? "") + " ip=" + (target.Ip ?? "") + " count=" + rejected.Count); foreach (var item in rejected.OrderBy(x => x.EmployeeNo, StringComparer.OrdinalIgnoreCase)) { _logger.Diag(diagTag, "employeeNo=" + item.EmployeeNo + " reason=\"" + ToOneLineSnippet(item.Reason, 240) + "\""); } } /// /// Merges SyncEmployeeIds with serial_numbers from hrms.employee for SyncDepartmentIds. /// private HashSet BuildMergedSyncEmployeeIds(out List departmentIdsUsed, out int departmentEmployeesFound) { departmentIdsUsed = new List(); departmentEmployeesFound = 0; var merged = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var id in _config.SyncEmployeeIds ?? Enumerable.Empty()) { var t = (id ?? "").Trim(); if (t.Length > 0) merged.Add(t); } var deptIds = (_config.SyncDepartmentIds ?? new List()) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (deptIds.Count == 0) return merged; departmentIdsUsed = deptIds; var fromDept = ResolveSerialNumbersByDepartmentIds(deptIds, out var err); if (!string.IsNullOrWhiteSpace(err)) _logger.Warn("UserSync: department employee lookup failed err=" + err); departmentEmployeesFound = fromDept.Count; foreach (var sn in fromDept) merged.Add(sn); return merged; } /// /// SELECT serial_number FROM employee WHERE department_id IN (...). Uses existing DB factory only. /// private List ResolveSerialNumbersByDepartmentIds(IReadOnlyList departmentIds, out string error) { error = ""; var result = new List(); if (departmentIds == null || departmentIds.Count == 0) return result; if (!_config.EnableDbIntegration || _dbConnectionFactory == null) { error = "DB integration not available for department filter."; return result; } if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error)) return result; try { using var conn = new MySqlConnection(cs); conn.Open(); var sql = new StringBuilder("SELECT serial_number FROM employee WHERE department_id IN ("); for (int i = 0; i < departmentIds.Count; i++) { if (i > 0) sql.Append(','); sql.Append("@d").Append(i); } sql.Append(')'); using var cmd = new MySqlCommand(sql.ToString(), conn); for (int i = 0; i < departmentIds.Count; i++) cmd.Parameters.AddWithValue("@d" + i, departmentIds[i]); using var rd = cmd.ExecuteReader(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); while (rd.Read()) { var sn = rd["serial_number"]?.ToString()?.Trim() ?? ""; if (sn.Length == 0 || !seen.Add(sn)) continue; result.Add(sn); } } catch (Exception ex) { error = ex.Message; } return result; } 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)); if (_config.EnableInitialDepartmentSync) { var initialStarted = DateTime.Now; _logger.Ops("INITIAL_SYNC", "JOB CYCLE START"); RunInitialDepartmentSyncCycle(token); _logger.Ops("INITIAL_SYNC", "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - initialStarted)); } _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 _, out _); } public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user, int maxRetries, CancellationToken ct, out string error) { return CreateUserOnTarget(target, user, maxRetries, ct, out error, out _); } public bool CreateUserOnTarget(HikvisionAttendanceWindowsService.DeviceConfig target, UserDto user, int maxRetries, CancellationToken ct, out string error, out bool alreadyExisted) { error = ""; alreadyExisted = false; 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)) { // Only treat as "already present" when the device says so or the user is confirmed on-device. if (IsUserAlreadyExistsOnDeviceResponse(status, body, error) || (status == 400 && VerifyUserOnTarget(target, user.EmployeeNo ?? "", out _))) { alreadyExisted = true; error = ""; return true; } return false; } if (!IsLikelyIsapiSuccess(body, status)) { if (IsUserAlreadyExistsOnDeviceResponse(status, body, error) || (status == 400 && VerifyUserOnTarget(target, user.EmployeeNo ?? "", out _))) { alreadyExisted = true; error = ""; return true; } error = "ISAPI error status=" + status + " bodySnip=" + ToOneLineSnippet(body); return false; } return true; } /// /// True only when Hikvision explicitly reports the employeeNo already exists. /// private static bool IsUserAlreadyExistsOnDeviceResponse(int httpStatus, string? body, string? transportError) { TryParseIsapiResponseFields(body, out _, out var statusString, out var subStatusCode); var sub = (subStatusCode ?? "").Trim(); if (sub.Equals("employeeNoAlreadyExist", StringComparison.OrdinalIgnoreCase) || sub.Equals("employeeNoAlreadyExisted", StringComparison.OrdinalIgnoreCase) || sub.Equals("employeeAlreadyExist", StringComparison.OrdinalIgnoreCase) || sub.Equals("userAlreadyExist", StringComparison.OrdinalIgnoreCase)) return true; var statusText = (statusString ?? "").Trim(); if (statusText.IndexOf("already exist", StringComparison.OrdinalIgnoreCase) >= 0) return true; var blob = ((body ?? "") + " " + (transportError ?? "")).Trim(); if (blob.IndexOf("employeeNoAlreadyExist", StringComparison.OrdinalIgnoreCase) >= 0 || blob.IndexOf("employeeNoAlreadyExisted", StringComparison.OrdinalIgnoreCase) >= 0) return true; return false; } 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 = ""; if (faceImage == null || faceImage.Length == 0) { error = "empty faceImage"; return false; } var emp = (employeeNo ?? "").Trim(); if (emp.Length == 0) { error = "empty employeeNo"; return false; } 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; } if (!TryDiscoverFaceLibCandidates(target, maxRetries, ct, out var faceLibs, out var discoverErr)) { error = "FDLib discovery failed: " + (string.IsNullOrWhiteSpace(discoverErr) ? "no libraries returned" : discoverErr); _logger.Diag("TEMPLATE_DB_TO_DEVICE", "face upload aborted employee=" + emp + " reason=\"" + error + "\""); return false; } const string relativeUri = "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json"; string url = "http://" + target.Ip + ":" + IsapiPort + relativeUri; string lastError = ""; string lastFaceDataRecordResponse = ""; foreach (var lib in faceLibs) { ct.ThrowIfCancellationRequested(); var faceLibType = (lib.faceLibType ?? "").Trim(); var fdId = lib.fdId.ToString(CultureInfo.InvariantCulture); if (faceLibType.Length == 0) continue; string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(faceLibType) + "\",\"FDID\":\"" + EscapeJsonStatic(fdId) + "\",\"FPID\":\"" + EscapeJsonStatic(emp) + "\"}"; _logger.Diag("TEMPLATE_DB_TO_DEVICE", "face upload prepare employeeNo=" + emp + " device=" + (target.DeviceId ?? "") + " ip=" + (target.Ip ?? "") + " url=" + url + " faceLibType=" + faceLibType + " FDID=" + fdId + " FPID=" + emp + " blobSize=" + faceImage.Length + " jpegSize=" + jpegBytes.Length + " sourceFormat=" + imageFormat + " meta=" + metaJson + (string.IsNullOrWhiteSpace(normalizeNote) ? "" : (" note=\"" + normalizeNote + "\""))); if (!TryIsapiPostMultipartFaceWithRetry(target, relativeUri, metaJson, jpegBytes, emp, maxRetries, ct, out var body, out var status, out var uploadErr)) { lastError = ConciseFaceUploadFailureReason(status, body, uploadErr); lastFaceDataRecordResponse = body ?? ""; _logger.Diag("TEMPLATE_DB_TO_DEVICE", "FaceDataRecord failed employeeNo=" + emp + " faceLibType=" + faceLibType + " FDID=" + fdId + " response=" + lastFaceDataRecordResponse + " err=\"" + lastError + "\""); continue; } if (!IsLikelyIsapiSuccess(body, status)) { TryParseIsapiResponseFields(body, out var sc, out var ss, out var sub); lastError = ConciseFaceUploadFailureReason(status, body, "device rejected face upload"); lastFaceDataRecordResponse = body ?? ""; _logger.Diag("TEMPLATE_DB_TO_DEVICE", "face upload rejected by device employeeNo=" + emp + " faceLibType=" + faceLibType + " FDID=" + fdId + " httpStatus=" + status + " statusCode=" + sc + " statusString=\"" + ss + "\"" + " subStatusCode=\"" + sub + "\"" + " response=" + lastFaceDataRecordResponse); continue; } lastFaceDataRecordResponse = body ?? ""; _logger.Diag("TEMPLATE_DB_TO_DEVICE", "FaceDataRecord accepted employeeNo=" + emp + " faceLibType=" + faceLibType + " FDID=" + fdId + " response=" + lastFaceDataRecordResponse); if (TryVerifyPersonFaceEnrolled(target, emp, maxRetries, ct, out var numOfFace, out var verifyErr) && numOfFace >= 1) { _logger.Diag("TEMPLATE_DB_TO_DEVICE", "face enrollment verified employeeNo=" + emp + " selectedFaceLibType=" + faceLibType + " selectedFDID=" + fdId + " FaceDataRecordResponse=" + lastFaceDataRecordResponse + " UserInfoNumOfFace=" + numOfFace); return true; } lastError = string.IsNullOrWhiteSpace(verifyErr) ? "FaceDataRecord OK but UserInfo numOfFace=" + numOfFace : verifyErr; _logger.Diag("TEMPLATE_DB_TO_DEVICE", "face enrollment not visible employeeNo=" + emp + " faceLibType=" + faceLibType + " FDID=" + fdId + " FaceDataRecordResponse=" + lastFaceDataRecordResponse + " UserInfoNumOfFace=" + numOfFace + " note=\"" + lastError + "\""); } error = string.IsNullOrWhiteSpace(lastError) ? "FaceDataRecord did not produce enrolled face (numOfFace=0) on any discovered FDLib" : lastError; if (!string.IsNullOrWhiteSpace(lastFaceDataRecordResponse)) error += " lastFaceDataRecordResponse=" + ToOneLineSnippet(lastFaceDataRecordResponse, 240); return false; } /// /// GET /ISAPI/Intelligent/FDLib and cache per device IP for the process lifetime. /// private bool TryDiscoverFaceLibCandidates( HikvisionAttendanceWindowsService.DeviceConfig device, int maxRetries, CancellationToken ct, out List candidates, out string error) { candidates = new List(); error = ""; var ip = (device.Ip ?? "").Trim(); if (ip.Length == 0) { error = "target IP empty"; return false; } if (_faceLibCacheByDeviceIp.TryGetValue(ip, out var cached) && cached.Count > 0) { candidates = cached; return true; } string fdLibUrl = "http://" + ip + ":" + IsapiPort + "/ISAPI/Intelligent/FDLib?format=json"; if (!TryHttpGetBytesWithRetry(device, fdLibUrl, maxRetries, ct, out var bytes, out error)) return false; if (!TryParseFaceLibCandidatesFromFdLibResponse(bytes, out candidates, out var parseErr)) { error = string.IsNullOrWhiteSpace(parseErr) ? "FDLib response parse failed" : parseErr; candidates = new List(); return false; } candidates = OrderFaceLibCandidates(DedupeFaceLibCandidates(candidates)); if (candidates.Count == 0) { error = "FDLib discovery returned no faceLibType/FDID pairs"; return false; } _faceLibCacheByDeviceIp[ip] = candidates; _logger.Diag("TEMPLATE_DB_TO_DEVICE", "FDLib discovered device=" + (device.DeviceId ?? "") + " ip=" + ip + " count=" + candidates.Count + " libs=" + string.Join(",", candidates.Select(c => c.faceLibType + ":" + c.fdId))); return true; } private static List DedupeFaceLibCandidates(List input) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var result = new List(); foreach (var c in input) { var key = (c.faceLibType ?? "").Trim() + "|" + c.fdId; if (!seen.Add(key)) continue; result.Add(c); } return result; } private static List OrderFaceLibCandidates(List input) => input .OrderBy(c => string.Equals(c.faceLibType, "blackFD", StringComparison.OrdinalIgnoreCase) ? 0 : 1) .ThenBy(c => c.faceLibType, StringComparer.OrdinalIgnoreCase) .ThenBy(c => c.fdId) .ToList(); /// /// Confirms Person Management enrollment via UserInfo/Search numOfFace. /// Retries once briefly because some firmware updates numOfFace asynchronously. /// private bool TryVerifyPersonFaceEnrolled( HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo, int maxRetries, CancellationToken ct, out int numOfFace, out string error) { numOfFace = 0; error = ""; for (int attempt = 0; attempt < 2; attempt++) { if (attempt > 0) Thread.Sleep(300); if (!TryGetUserNumOfFaceOnTarget(target, employeeNo, maxRetries, ct, out numOfFace, out error)) continue; if (numOfFace >= 1) return true; } if (string.IsNullOrWhiteSpace(error)) error = "UserInfo numOfFace=" + numOfFace; return false; } private bool TryGetUserNumOfFaceOnTarget( HikvisionAttendanceWindowsService.DeviceConfig target, string employeeNo, int maxRetries, CancellationToken ct, out int numOfFace, out string error) { numOfFace = 0; error = ""; if (!TrySearchUsersIsapiPage(target, 0, 10, employeeNo.Trim(), maxRetries, ct, out var users, out error)) return false; var user = users.FirstOrDefault(u => string.Equals(u.EmployeeNo, employeeNo.Trim(), StringComparison.OrdinalIgnoreCase)); if (user == null) { error = "employee not found in UserInfo/Search"; return false; } numOfFace = user.NumOfFace; 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; } }