using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Threading; using HikvisionAttendanceService.Data; using MySql.Data.MySqlClient; namespace HikvisionAttendanceService; /// /// Separate onboarding path: HRMS department employees + portal JPEG photos → new Hikvision device. /// Does not read or write attendance_machine_face_templates (existing DB↔device flows stay unchanged). /// internal sealed partial class HikvisionAttendanceManager { /// /// Provisions users/faces onto TargetMachineIps from InitialSyncDepartmentIds + employee-photo URL. /// Gated by EnableInitialDepartmentSync only. /// public void RunInitialDepartmentSyncCycle(CancellationToken ct) { if (!_config.EnableInitialDepartmentSync) return; ct.ThrowIfCancellationRequested(); var deptIds = (_config.InitialSyncDepartmentIds ?? new List()) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (deptIds.Count == 0) { _logger.OpsWarn("INITIAL_SYNC", "skipped — InitialSyncDepartmentIds is empty"); return; } var targets = ResolveInitialSyncTargets(); if (targets.Count == 0) { _logger.OpsWarn("INITIAL_SYNC", "skipped — no valid TargetMachineIps"); return; } var employees = LoadEmployeesForInitialDepartmentSync(deptIds, out var loadErr); if (!string.IsNullOrWhiteSpace(loadErr)) _logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr); _logger.Ops("INITIAL_SYNC", "departmentIds=" + string.Join(",", deptIds) + " employeesFound=" + employees.Count); if (employees.Count == 0) return; var maxRetries = Math.Max(1, SyncPol.HttpMaxRetries); var photoBase = (_config.EmployeePhotoBaseUrl ?? "").Trim(); if (!string.IsNullOrWhiteSpace(photoBase) && !photoBase.EndsWith("/", StringComparison.Ordinal)) photoBase += "/"; 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("INITIAL_SYNC", "target=" + (target.DeviceId ?? "") + " ip=" + (target.Ip ?? "") + " SKIPPED reason=\"target offline\""); continue; } var existing = FetchAllUsersIsapiForSync(target, maxRetries, ct); var existingNos = new HashSet(existing.Select(u => u.EmployeeNo), StringComparer.OrdinalIgnoreCase); var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>(); foreach (var emp in employees) { ct.ThrowIfCancellationRequested(); var employeeNo = emp.SerialNumber; var employeeId = emp.Id; var photoUrl = ""; var photoDownloaded = false; var userCreated = false; var faceUploaded = false; var reason = ""; try { if (!existingNos.Contains(employeeNo)) { var dto = new UserDto { EmployeeNo = employeeNo, Name = emp.Name ?? "", NumOfFace = 0, NumOfFp = 0 }; if (!CreateUserOnTarget(target, dto, maxRetries, ct, out var createErr)) { reason = string.IsNullOrWhiteSpace(createErr) ? "user_create_failed" : createErr; continue; } userCreated = true; existingNos.Add(employeeNo); if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null) { _attendanceMachineUserRepository.UpsertMachineUser( target.DeviceId ?? "", employeeNo, emp.Name ?? "", "hikvision-service-initial", out _); } } if (!_config.EnableEmployeePhotoSource || string.IsNullOrWhiteSpace(photoBase)) { reason = "photo_source_disabled"; continue; } if (string.IsNullOrWhiteSpace(employeeId)) { reason = "missing_employee_id"; continue; } photoUrl = photoBase + employeeId.Trim() + ".jpeg"; if (!TryDownloadEmployeePortalPhoto(photoUrl, out var jpegBytes, out var photoErr)) { reason = string.IsNullOrWhiteSpace(photoErr) ? "photo_not_found" : photoErr; continue; } photoDownloaded = true; if (!IsJpegMagic(jpegBytes)) { reason = "invalid_jpeg"; continue; } if (!UploadFaceOnTarget(target, employeeNo, jpegBytes, maxRetries, ct, out var upErr)) { reason = string.IsNullOrWhiteSpace(upErr) ? "face_upload_failed" : upErr; faceRejectedEmployees.Add((employeeNo, reason)); continue; } faceUploaded = true; } finally { if (photoDownloaded && faceUploaded) { _logger.Ops("INITIAL_SYNC", "employeeNo=" + employeeNo + " employeeId=" + employeeId + " photoUrl=" + photoUrl + " photoDownloaded=true" + " userCreated=" + (userCreated ? "true" : "false") + " faceUploaded=true"); } else if (!photoDownloaded) { _logger.Ops("INITIAL_SYNC", "employeeNo=" + employeeNo + (string.IsNullOrWhiteSpace(employeeId) ? "" : (" employeeId=" + employeeId)) + (string.IsNullOrWhiteSpace(photoUrl) ? "" : (" photoUrl=" + photoUrl)) + " photoDownloaded=false" + " reason=" + (string.IsNullOrWhiteSpace(reason) ? "photo_not_found" : reason)); } else { _logger.Ops("INITIAL_SYNC", "employeeNo=" + employeeNo + " employeeId=" + employeeId + " photoUrl=" + photoUrl + " photoDownloaded=true" + " userCreated=" + (userCreated ? "true" : "false") + " faceUploaded=false" + " reason=" + (string.IsNullOrWhiteSpace(reason) ? "face_upload_failed" : reason)); } } } WriteFaceRejectedEmployeeSummary("INITIAL_SYNC", target, faceRejectedEmployees); } } private List ResolveInitialSyncTargets() { var targets = new List(); foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty()) { var t = ResolveDeviceConfigByIp(targetIp); if (t == null || string.IsNullOrWhiteSpace(t.Ip)) { _logger.OpsWarn("INITIAL_SYNC", "target skipped — not found or IP empty. TargetMachineIp=\"" + targetIp + "\""); continue; } if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase))) continue; targets.Add(t); } foreach (var tid in _config.TargetDeviceIds ?? Enumerable.Empty()) { var t = ResolveDeviceConfig(tid); if (t == null || string.IsNullOrWhiteSpace(t.Ip)) continue; if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase))) continue; targets.Add(t); } return targets; } private List LoadEmployeesForInitialDepartmentSync(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 initial department sync."; 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 id, 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 seenSerial = new HashSet(StringComparer.OrdinalIgnoreCase); while (rd.Read()) { var serial = rd["serial_number"]?.ToString()?.Trim() ?? ""; if (serial.Length == 0 || !seenSerial.Add(serial)) continue; result.Add(new InitialSyncEmployee { Id = rd["id"]?.ToString()?.Trim() ?? "", SerialNumber = serial, Name = "" }); } } catch (Exception ex) { error = ex.Message; } return result; } private static bool TryDownloadEmployeePortalPhoto(string photoUrl, out byte[] bytes, out string error) { bytes = Array.Empty(); error = ""; if (string.IsNullOrWhiteSpace(photoUrl)) { error = "photo_not_found"; return false; } try { using var handler = new HttpClientHandler { AllowAutoRedirect = true, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }; using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) }; using var response = client.GetAsync(photoUrl).GetAwaiter().GetResult(); if (response.StatusCode == HttpStatusCode.NotFound) { error = "photo_not_found"; return false; } if (!response.IsSuccessStatusCode) { error = "photo_http_" + (int)response.StatusCode; return false; } bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() ?? Array.Empty(); if (bytes.Length == 0) { error = "photo_not_found"; return false; } return true; } catch (Exception ex) { error = "photo_download_failed:" + ex.Message; return false; } } private static bool IsJpegMagic(byte[] bytes) => bytes != null && bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; private sealed class InitialSyncEmployee { public string Id { get; set; } = ""; public string SerialNumber { get; set; } = ""; public string Name { get; set; } = ""; } }