340 lines
13 KiB
C#
340 lines
13 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
internal sealed partial class HikvisionAttendanceManager
|
|
{
|
|
/// <summary>
|
|
/// Provisions users/faces onto TargetMachineIps from InitialSyncDepartmentIds + employee-photo URL.
|
|
/// Gated by EnableInitialDepartmentSync only.
|
|
/// </summary>
|
|
public void RunInitialDepartmentSyncCycle(CancellationToken ct)
|
|
{
|
|
if (!_config.EnableInitialDepartmentSync)
|
|
return;
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var deptIds = (_config.InitialSyncDepartmentIds ?? new List<string>())
|
|
.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<string>(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<HikvisionAttendanceWindowsService.DeviceConfig> ResolveInitialSyncTargets()
|
|
{
|
|
var targets = new List<HikvisionAttendanceWindowsService.DeviceConfig>();
|
|
foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty<string>())
|
|
{
|
|
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<string>())
|
|
{
|
|
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<InitialSyncEmployee> LoadEmployeesForInitialDepartmentSync(IReadOnlyList<string> departmentIds, out string error)
|
|
{
|
|
error = "";
|
|
var result = new List<InitialSyncEmployee>();
|
|
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<string>(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<byte>();
|
|
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<byte>();
|
|
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; } = "";
|
|
}
|
|
}
|