feat: add department-based initial user and face provisioning
Add a configurable initial-sync flow that creates department employees on target Hikvision devices and uploads portal JPEG photos. Support department-based employee filtering for existing user/template sync. Discover and retry available Hikvision face libraries, then verify face enrollment after upload. Extend service configuration and scheduled sync handling for the new onboarding flow.main
parent
7b66f9319a
commit
9d1e361099
|
|
@ -1,20 +1,24 @@
|
||||||
{
|
{
|
||||||
"MachineScopeMode": "SITE",
|
"MachineScopeMode": "SITE",
|
||||||
"ScopedMachineIps": [
|
"ScopedMachineIps": [
|
||||||
"192.168.90.226"
|
"192.168.91.80"
|
||||||
],
|
],
|
||||||
|
|
||||||
"EnableTemplateDeviceToDbSync": false,
|
"EnableTemplateDeviceToDbSync": false,
|
||||||
"EnableTemplateDbToDeviceSync": false,
|
"EnableTemplateDbToDeviceSync": false,
|
||||||
|
|
||||||
"SourceMachineIp": "192.168.90.226",
|
"SourceMachineIp": "",
|
||||||
"TargetMachineIps": [
|
"TargetMachineIps": [
|
||||||
"192.168.90.226"
|
"192.168.91.80"
|
||||||
],
|
],
|
||||||
|
|
||||||
"SyncEmployeeIds": [
|
"SyncEmployeeIds": [],
|
||||||
"15399",
|
"SyncDepartmentIds": [],
|
||||||
"17003",
|
|
||||||
"15111",
|
"EnableInitialDepartmentSync": true,
|
||||||
]
|
"InitialSyncDepartmentIds": [
|
||||||
|
"357"
|
||||||
|
],
|
||||||
|
"EnableEmployeePhotoSource": true,
|
||||||
|
"EmployeePhotoBaseUrl": "https://portal.utopiaindustries.pk/uind/employee-photo/"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,12 +11,16 @@ using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web.Script.Serialization;
|
using System.Web.Script.Serialization;
|
||||||
using HikvisionAttendanceService.Data;
|
using HikvisionAttendanceService.Data;
|
||||||
|
using MySql.Data.MySqlClient;
|
||||||
|
|
||||||
namespace HikvisionAttendanceService;
|
namespace HikvisionAttendanceService;
|
||||||
|
|
||||||
/// <summary>Multi-device user + face sync via ISAPI HTTP (Digest). Orchestration and focused helpers.</summary>
|
/// <summary>Multi-device user + face sync via ISAPI HTTP (Digest). Orchestration and focused helpers.</summary>
|
||||||
internal sealed partial class HikvisionAttendanceManager
|
internal sealed partial class HikvisionAttendanceManager
|
||||||
{
|
{
|
||||||
|
private readonly Dictionary<string, List<FaceLibCandidate>> _faceLibCacheByDeviceIp =
|
||||||
|
new Dictionary<string, List<FaceLibCandidate>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private HikvisionAttendanceWindowsService.UserSyncPoliciesConfig SyncPol =>
|
private HikvisionAttendanceWindowsService.UserSyncPoliciesConfig SyncPol =>
|
||||||
_config.SyncPolicies ?? new HikvisionAttendanceWindowsService.UserSyncPoliciesConfig();
|
_config.SyncPolicies ?? new HikvisionAttendanceWindowsService.UserSyncPoliciesConfig();
|
||||||
|
|
||||||
|
|
@ -62,10 +66,23 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
var pol = SyncPol;
|
var pol = SyncPol;
|
||||||
int maxRetries = pol.HttpMaxRetries < 1 ? 1 : pol.HttpMaxRetries;
|
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)
|
var source = !string.IsNullOrWhiteSpace(_config.SourceMachineIp)
|
||||||
? ResolveDeviceConfigByIp(_config.SourceMachineIp)
|
? ResolveDeviceConfigByIp(_config.SourceMachineIp)
|
||||||
: ResolveDeviceConfig(_config.SourceDeviceId ?? "");
|
: ResolveDeviceConfig(_config.SourceDeviceId ?? "");
|
||||||
if (source == null || string.IsNullOrWhiteSpace(source.Ip))
|
var hasSource = source != null && !string.IsNullOrWhiteSpace(source.Ip);
|
||||||
|
if (!hasSource && !dbOnlyRestore)
|
||||||
{
|
{
|
||||||
_logger.Warn("UserSync: skipped — source device not found or IP empty. SourceDeviceId=\"" +
|
_logger.Warn("UserSync: skipped — source device not found or IP empty. SourceDeviceId=\"" +
|
||||||
(_config.SourceDeviceId ?? "") + "\" SourceMachineIp=\"" + (_config.SourceMachineIp ?? "") + "\".");
|
(_config.SourceDeviceId ?? "") + "\" SourceMachineIp=\"" + (_config.SourceMachineIp ?? "") + "\".");
|
||||||
|
|
@ -84,7 +101,8 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase)))
|
if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase)))
|
||||||
continue;
|
continue;
|
||||||
// Same IP allowed for DB->device restore (user deleted on device, restore from DB).
|
// 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) &&
|
if (hasSource &&
|
||||||
|
string.Equals((t.Ip ?? "").Trim(), (source!.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||||
!_config.EnableTemplateDbToDeviceSync)
|
!_config.EnableTemplateDbToDeviceSync)
|
||||||
{
|
{
|
||||||
_logger.Diag("user_sync", "target skipped — same as source IP and DB->device restore disabled. TargetMachineIp=\"" + targetIp + "\".");
|
_logger.Diag("user_sync", "target skipped — same as source IP and DB->device restore disabled. TargetMachineIp=\"" + targetIp + "\".");
|
||||||
|
|
@ -101,7 +119,8 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source.DeviceId))
|
if (hasSource &&
|
||||||
|
DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source!.DeviceId))
|
||||||
{
|
{
|
||||||
if (!_config.EnableTemplateDbToDeviceSync)
|
if (!_config.EnableTemplateDbToDeviceSync)
|
||||||
{
|
{
|
||||||
|
|
@ -123,21 +142,24 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
}
|
}
|
||||||
|
|
||||||
var cycleId = DateTime.UtcNow.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture);
|
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 +
|
_logger.Info("USER_SYNC cycle begin id=" + cycleId +
|
||||||
". Meaning: one full restore/sync pass from source to target(s)." +
|
". Meaning: one full restore/sync pass from source to target(s)." +
|
||||||
" sourceDevice=" + source.DeviceId + " sourceIp=" + (source.Ip ?? "") +
|
" sourceDevice=" + sourceDeviceLabel + " sourceIp=" + sourceIpLabel +
|
||||||
" targetCount=" + targets.Count +
|
" targetCount=" + targets.Count +
|
||||||
" UpdateExistingUserFields=" + pol.UpdateExistingUserFields +
|
" UpdateExistingUserFields=" + pol.UpdateExistingUserFields +
|
||||||
" UploadFaceIfMissingOnly=" + pol.UploadFaceIfMissingOnly +
|
" UploadFaceIfMissingOnly=" + pol.UploadFaceIfMissingOnly +
|
||||||
" DeleteOnTargetIfMissingInSource=" + pol.DeleteOnTargetIfMissingInSource);
|
" DeleteOnTargetIfMissingInSource=" + pol.DeleteOnTargetIfMissingInSource);
|
||||||
_logger.JobInfo("user_sync", "Cycle begin id=" + cycleId +
|
_logger.JobInfo("user_sync", "Cycle begin id=" + cycleId +
|
||||||
". Meaning: push users/faces to targets. source=" + source.DeviceId +
|
". Meaning: push users/faces to targets. source=" + sourceDeviceLabel +
|
||||||
" sourceIp=" + (source.Ip ?? "") + " targets=" + targets.Count);
|
" sourceIp=" + sourceIpLabel + " targets=" + targets.Count);
|
||||||
|
|
||||||
var sourceUsers = LoadSourceUsersForSync(source, maxRetries, ct);
|
var sourceUsers = hasSource
|
||||||
if ((_config.SyncEmployeeIds ?? new List<string>()).Count > 0)
|
? LoadSourceUsersForSync(source!, maxRetries, ct)
|
||||||
|
: new List<UserDto>();
|
||||||
|
if (hasExplicitEmployeeFilter)
|
||||||
{
|
{
|
||||||
var requestedEmployeeIds = new HashSet<string>(_config.SyncEmployeeIds.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase);
|
|
||||||
sourceUsers = sourceUsers.Where(x => requestedEmployeeIds.Contains(x.EmployeeNo)).ToList();
|
sourceUsers = sourceUsers.Where(x => requestedEmployeeIds.Contains(x.EmployeeNo)).ToList();
|
||||||
// Same-machine restore: employee may already be gone from device/user list;
|
// Same-machine restore: employee may already be gone from device/user list;
|
||||||
// synthesize missing IDs and restore from DB face templates.
|
// synthesize missing IDs and restore from DB face templates.
|
||||||
|
|
@ -157,13 +179,14 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_logger.Ops("USER_SYNC", "Final employee sync count=" + sourceUsers.Count);
|
||||||
_logger.JobInfo("user_sync", "Employee filter: requested=" + requestedEmployeeIds.Count +
|
_logger.JobInfo("user_sync", "Employee filter: requested=" + requestedEmployeeIds.Count +
|
||||||
" willSync=" + sourceUsers.Count +
|
" willSync=" + sourceUsers.Count +
|
||||||
". Meaning: only these employee IDs from SyncEmployeeIds are restored/synced this cycle.");
|
". Meaning: SyncEmployeeIds + SyncDepartmentIds (deduped) drive this cycle.");
|
||||||
}
|
}
|
||||||
_logger.Info("USER_SYNC source users ready count=" + sourceUsers.Count + " device=" + source.DeviceId +
|
_logger.Info("USER_SYNC source users ready count=" + sourceUsers.Count + " device=" + sourceDeviceLabel +
|
||||||
". Meaning: users we will try to create/update on the target.");
|
". Meaning: users we will try to create/update on the target.");
|
||||||
_logger.JobInfo("user_sync", "Source users ready count=" + sourceUsers.Count + " device=" + source.DeviceId);
|
_logger.JobInfo("user_sync", "Source users ready count=" + sourceUsers.Count + " device=" + sourceDeviceLabel);
|
||||||
|
|
||||||
var faceBytesByEmployee = new Dictionary<string, byte[]?>(StringComparer.OrdinalIgnoreCase);
|
var faceBytesByEmployee = new Dictionary<string, byte[]?>(StringComparer.OrdinalIgnoreCase);
|
||||||
int faceDlOk = 0, faceDlFail = 0;
|
int faceDlOk = 0, faceDlFail = 0;
|
||||||
|
|
@ -183,13 +206,13 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
_logger.Diag("user_sync", "face loaded from DB employeeNo=" + u.EmployeeNo);
|
_logger.Diag("user_sync", "face loaded from DB employeeNo=" + u.EmployeeNo);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (string.IsNullOrWhiteSpace(u.FaceUrl))
|
if (!hasSource || string.IsNullOrWhiteSpace(u.FaceUrl))
|
||||||
{
|
{
|
||||||
faceBytesByEmployee[u.EmployeeNo] = null;
|
faceBytesByEmployee[u.EmployeeNo] = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DownloadFaceByUrl(source, u.FaceUrl, maxRetries, ct, out var bytes) && bytes.Length > 0)
|
if (DownloadFaceByUrl(source!, u.FaceUrl, maxRetries, ct, out var bytes) && bytes.Length > 0)
|
||||||
{
|
{
|
||||||
faceBytesByEmployee[u.EmployeeNo] = bytes;
|
faceBytesByEmployee[u.EmployeeNo] = bytes;
|
||||||
faceDlOk++;
|
faceDlOk++;
|
||||||
|
|
@ -275,6 +298,7 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
|
|
||||||
int created = 0, faceUp = 0, skipped = 0, failed = 0, updated = 0;
|
int created = 0, faceUp = 0, skipped = 0, failed = 0, updated = 0;
|
||||||
var statusCounts = new Dictionary<UserSyncStatus, int>();
|
var statusCounts = new Dictionary<UserSyncStatus, int>();
|
||||||
|
var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>();
|
||||||
|
|
||||||
void Bump(UserSyncStatus s)
|
void Bump(UserSyncStatus s)
|
||||||
{
|
{
|
||||||
|
|
@ -282,8 +306,8 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
statusCounts[s] = n + 1;
|
statusCounts[s] = n + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit SyncEmployeeIds = restore/push list; do not filter by DB assignment rows.
|
// Explicit SyncEmployeeIds / SyncDepartmentIds = restore/push list; do not filter by DB assignment rows.
|
||||||
var explicitEmployeeFilter = (_config.SyncEmployeeIds ?? new List<string>()).Count > 0;
|
var explicitEmployeeFilter = hasExplicitEmployeeFilter;
|
||||||
|
|
||||||
foreach (var srcUser in sourceUsers)
|
foreach (var srcUser in sourceUsers)
|
||||||
{
|
{
|
||||||
|
|
@ -292,7 +316,12 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
continue;
|
continue;
|
||||||
targetMap.TryGetValue(srcUser.EmployeeNo, out var tgtRow);
|
targetMap.TryGetValue(srcUser.EmployeeNo, out var tgtRow);
|
||||||
bool createdNew = false;
|
bool createdNew = false;
|
||||||
|
bool createUserLogged = false;
|
||||||
|
bool faceUploadedLogged = false;
|
||||||
|
bool templateFoundLogged = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
if (tgtRow == null)
|
if (tgtRow == null)
|
||||||
{
|
{
|
||||||
if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr))
|
if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr))
|
||||||
|
|
@ -305,6 +334,7 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
}
|
}
|
||||||
|
|
||||||
created++;
|
created++;
|
||||||
|
createUserLogged = true;
|
||||||
_logger.Totals.UsersAdded++;
|
_logger.Totals.UsersAdded++;
|
||||||
_logger.Biz(BizChannel.UserSync,
|
_logger.Biz(BizChannel.UserSync,
|
||||||
"Machine ID : " + (target.DeviceId ?? ""),
|
"Machine ID : " + (target.DeviceId ?? ""),
|
||||||
|
|
@ -393,6 +423,7 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
{
|
{
|
||||||
_logger.Warn("UserSync template lookup failed employeeNo=" + srcUser.EmployeeNo + " err=" + tplErr);
|
_logger.Warn("UserSync template lookup failed employeeNo=" + srcUser.EmployeeNo + " err=" + tplErr);
|
||||||
}
|
}
|
||||||
|
templateFoundLogged = hasFaceBytes;
|
||||||
if (!hasFaceBytes)
|
if (!hasFaceBytes)
|
||||||
{
|
{
|
||||||
skipped++;
|
skipped++;
|
||||||
|
|
@ -444,6 +475,7 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
|
|
||||||
if (!UploadFaceOnTarget(target, srcUser.EmployeeNo, fb!, maxRetries, ct, out var upErr))
|
if (!UploadFaceOnTarget(target, srcUser.EmployeeNo, fb!, maxRetries, ct, out var upErr))
|
||||||
{
|
{
|
||||||
|
faceRejectedEmployees.Add((srcUser.EmployeeNo, string.IsNullOrWhiteSpace(upErr) ? "face upload rejected" : upErr));
|
||||||
_logger.OpsError(OpsMarkers.TemplateDbToDevice,
|
_logger.OpsError(OpsMarkers.TemplateDbToDevice,
|
||||||
srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\"");
|
srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\"");
|
||||||
_logger.Totals.TemplatesFailed++;
|
_logger.Totals.TemplatesFailed++;
|
||||||
|
|
@ -468,6 +500,7 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
}
|
}
|
||||||
|
|
||||||
faceUp++;
|
faceUp++;
|
||||||
|
faceUploadedLogged = true;
|
||||||
_logger.Totals.TemplatesSaved++;
|
_logger.Totals.TemplatesSaved++;
|
||||||
tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1);
|
tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1);
|
||||||
_logger.Ops(OpsMarkers.TemplateDbToDevice,
|
_logger.Ops(OpsMarkers.TemplateDbToDevice,
|
||||||
|
|
@ -485,6 +518,15 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
}
|
}
|
||||||
Bump(createdNew ? UserSyncStatus.CreatedAndFaceUploaded : UserSyncStatus.ExistsFaceUploaded);
|
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 (pol.DeleteOnTargetIfMissingInSource)
|
if (pol.DeleteOnTargetIfMissingInSource)
|
||||||
{
|
{
|
||||||
|
|
@ -520,6 +562,7 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
|
|
||||||
var statusLine = string.Join(", ", statusCounts.OrderBy(kv => kv.Key.ToString())
|
var statusLine = string.Join(", ", statusCounts.OrderBy(kv => kv.Key.ToString())
|
||||||
.Select(kv => kv.Key + "=" + kv.Value));
|
.Select(kv => kv.Key + "=" + kv.Value));
|
||||||
|
WriteFaceRejectedEmployeeSummary("TEMPLATE_DB_TO_DEVICE", target, faceRejectedEmployees);
|
||||||
_logger.Ops(OpsMarkers.TemplateDbToDevice,
|
_logger.Ops(OpsMarkers.TemplateDbToDevice,
|
||||||
"device=" + target.DeviceId + " summary created=" + created +
|
"device=" + target.DeviceId + " summary created=" + created +
|
||||||
" faceUploaded=" + faceUp + " updated=" + updated +
|
" faceUploaded=" + faceUp + " updated=" + updated +
|
||||||
|
|
@ -531,6 +574,114 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
_logger.Ops(OpsMarkers.TemplateDbToDevice, "Cycle completed id=" + cycleId);
|
_logger.Ops(OpsMarkers.TemplateDbToDevice, "Cycle completed id=" + cycleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges SyncEmployeeIds with serial_numbers from hrms.employee for SyncDepartmentIds.
|
||||||
|
/// </summary>
|
||||||
|
private HashSet<string> BuildMergedSyncEmployeeIds(out List<string> departmentIdsUsed, out int departmentEmployeesFound)
|
||||||
|
{
|
||||||
|
departmentIdsUsed = new List<string>();
|
||||||
|
departmentEmployeesFound = 0;
|
||||||
|
var merged = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var id in _config.SyncEmployeeIds ?? Enumerable.Empty<string>())
|
||||||
|
{
|
||||||
|
var t = (id ?? "").Trim();
|
||||||
|
if (t.Length > 0)
|
||||||
|
merged.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
var deptIds = (_config.SyncDepartmentIds ?? new List<string>())
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SELECT serial_number FROM employee WHERE department_id IN (...). Uses existing DB factory only.
|
||||||
|
/// </summary>
|
||||||
|
private List<string> ResolveSerialNumbersByDepartmentIds(IReadOnlyList<string> departmentIds, out string error)
|
||||||
|
{
|
||||||
|
error = "";
|
||||||
|
var result = new List<string>();
|
||||||
|
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<string>(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<UserDto> LoadSourceUsersForSync(HikvisionAttendanceWindowsService.DeviceConfig source, int maxRetries, CancellationToken ct)
|
private List<UserDto> LoadSourceUsersForSync(HikvisionAttendanceWindowsService.DeviceConfig source, int maxRetries, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
|
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
|
||||||
|
|
@ -686,6 +837,13 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
RunUserFaceSyncCycle(token);
|
RunUserFaceSyncCycle(token);
|
||||||
if (_config.EnableTemplateDbToDeviceSync)
|
if (_config.EnableTemplateDbToDeviceSync)
|
||||||
_logger.Ops(OpsMarkers.TemplateDbToDevice, "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - cycleStarted));
|
_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));
|
_logger.Ops(OpsMarkers.UserDelete, "JOB CYCLE END duration=" + FormatDuration(DateTime.Now - cycleStarted));
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
|
|
@ -767,7 +925,6 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
byte[] faceImage, int maxRetries, CancellationToken ct, out string error)
|
byte[] faceImage, int maxRetries, CancellationToken ct, out string error)
|
||||||
{
|
{
|
||||||
error = "";
|
error = "";
|
||||||
var pol = SyncPol;
|
|
||||||
if (faceImage == null || faceImage.Length == 0)
|
if (faceImage == null || faceImage.Length == 0)
|
||||||
{
|
{
|
||||||
error = "empty faceImage";
|
error = "empty faceImage";
|
||||||
|
|
@ -775,8 +932,11 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
}
|
}
|
||||||
|
|
||||||
var emp = (employeeNo ?? "").Trim();
|
var emp = (employeeNo ?? "").Trim();
|
||||||
var faceLibType = string.IsNullOrWhiteSpace(pol.FaceLibType) ? "blackFD" : pol.FaceLibType.Trim();
|
if (emp.Length == 0)
|
||||||
var fdId = string.IsNullOrWhiteSpace(pol.FaceLibraryFdId) ? "1" : pol.FaceLibraryFdId.Trim();
|
{
|
||||||
|
error = "empty employeeNo";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryNormalizeFaceImageForUpload(faceImage, out var jpegBytes, out var imageFormat, out var normalizeNote))
|
if (!TryNormalizeFaceImageForUpload(faceImage, out var jpegBytes, out var imageFormat, out var normalizeNote))
|
||||||
{
|
{
|
||||||
|
|
@ -790,15 +950,33 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
return false;
|
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) +
|
string metaJson = "{\"faceLibType\":\"" + EscapeJsonStatic(faceLibType) +
|
||||||
"\",\"FDID\":\"" + EscapeJsonStatic(fdId) +
|
"\",\"FDID\":\"" + EscapeJsonStatic(fdId) +
|
||||||
"\",\"FPID\":\"" + EscapeJsonStatic(emp) + "\"}";
|
"\",\"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",
|
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
|
||||||
"face upload prepare employee=" + emp +
|
"face upload prepare employeeNo=" + emp +
|
||||||
" device=" + (target.DeviceId ?? "") +
|
" device=" + (target.DeviceId ?? "") +
|
||||||
" ip=" + (target.Ip ?? "") +
|
" ip=" + (target.Ip ?? "") +
|
||||||
" url=" + url +
|
" url=" + url +
|
||||||
|
|
@ -808,32 +986,206 @@ internal sealed partial class HikvisionAttendanceManager
|
||||||
" blobSize=" + faceImage.Length +
|
" blobSize=" + faceImage.Length +
|
||||||
" jpegSize=" + jpegBytes.Length +
|
" jpegSize=" + jpegBytes.Length +
|
||||||
" sourceFormat=" + imageFormat +
|
" sourceFormat=" + imageFormat +
|
||||||
" base64Length=" + (4 * ((jpegBytes.Length + 2) / 3)) +
|
|
||||||
" meta=" + metaJson +
|
" meta=" + metaJson +
|
||||||
(string.IsNullOrWhiteSpace(normalizeNote) ? "" : (" note=\"" + normalizeNote + "\"")));
|
(string.IsNullOrWhiteSpace(normalizeNote) ? "" : (" note=\"" + normalizeNote + "\"")));
|
||||||
|
|
||||||
if (!TryIsapiPostMultipartFaceWithRetry(target, relativeUri, metaJson, jpegBytes, emp, maxRetries, ct,
|
if (!TryIsapiPostMultipartFaceWithRetry(target, relativeUri, metaJson, jpegBytes, emp, maxRetries, ct,
|
||||||
out var body, out var status, out error))
|
out var body, out var status, out var uploadErr))
|
||||||
{
|
{
|
||||||
// Keep Ops/summary concise; full HTTP body already written to Diag inside the sender.
|
lastError = ConciseFaceUploadFailureReason(status, body, uploadErr);
|
||||||
error = ConciseFaceUploadFailureReason(status, body, error);
|
lastFaceDataRecordResponse = body ?? "";
|
||||||
return false;
|
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
|
||||||
|
"FaceDataRecord failed employeeNo=" + emp +
|
||||||
|
" faceLibType=" + faceLibType +
|
||||||
|
" FDID=" + fdId +
|
||||||
|
" response=" + lastFaceDataRecordResponse +
|
||||||
|
" err=\"" + lastError + "\"");
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsLikelyIsapiSuccess(body, status))
|
if (!IsLikelyIsapiSuccess(body, status))
|
||||||
{
|
{
|
||||||
TryParseIsapiResponseFields(body, out var sc, out var ss, out var sub);
|
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",
|
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
|
||||||
"face upload rejected by device employee=" + emp +
|
"face upload rejected by device employeeNo=" + emp +
|
||||||
|
" faceLibType=" + faceLibType +
|
||||||
|
" FDID=" + fdId +
|
||||||
" httpStatus=" + status +
|
" httpStatus=" + status +
|
||||||
" statusCode=" + sc +
|
" statusCode=" + sc +
|
||||||
" statusString=\"" + ss + "\"" +
|
" statusString=\"" + ss + "\"" +
|
||||||
" subStatusCode=\"" + sub + "\"" +
|
" subStatusCode=\"" + sub + "\"" +
|
||||||
" response=" + (body ?? ""));
|
" response=" + lastFaceDataRecordResponse);
|
||||||
error = ConciseFaceUploadFailureReason(status, body, "device rejected face upload");
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GET /ISAPI/Intelligent/FDLib and cache per device IP for the process lifetime.
|
||||||
|
/// </summary>
|
||||||
|
private bool TryDiscoverFaceLibCandidates(
|
||||||
|
HikvisionAttendanceWindowsService.DeviceConfig device,
|
||||||
|
int maxRetries,
|
||||||
|
CancellationToken ct,
|
||||||
|
out List<FaceLibCandidate> candidates,
|
||||||
|
out string error)
|
||||||
|
{
|
||||||
|
candidates = new List<FaceLibCandidate>();
|
||||||
|
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<FaceLibCandidate>();
|
||||||
|
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<FaceLibCandidate> DedupeFaceLibCandidates(List<FaceLibCandidate> input)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<FaceLibCandidate>();
|
||||||
|
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<FaceLibCandidate> OrderFaceLibCandidates(List<FaceLibCandidate> input) =>
|
||||||
|
input
|
||||||
|
.OrderBy(c => string.Equals(c.faceLibType, "blackFD", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||||
|
.ThenBy(c => c.faceLibType, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(c => c.fdId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirms Person Management enrollment via UserInfo/Search numOfFace.
|
||||||
|
/// Retries once briefly because some firmware updates numOfFace asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -342,19 +342,23 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
|
||||||
var hasTemplateSyncRoute = hasUserSyncSource && userSyncTargetCount > 0;
|
var hasTemplateSyncRoute = hasUserSyncSource && userSyncTargetCount > 0;
|
||||||
var hasDatabaseDeletionRoute = _config.EnableDbIntegration && _attendanceMachineRepository != null &&
|
var hasDatabaseDeletionRoute = _config.EnableDbIntegration && _attendanceMachineRepository != null &&
|
||||||
_attendanceMachineUserRepository != null;
|
_attendanceMachineUserRepository != null;
|
||||||
if (_config.EnableUserSync &&
|
var hasInitialDepartmentSyncRoute = _config.EnableInitialDepartmentSync && userSyncTargetCount > 0 &&
|
||||||
|
(_config.InitialSyncDepartmentIds?.Count ?? 0) > 0;
|
||||||
|
if ((_config.EnableUserSync || _config.EnableInitialDepartmentSync) &&
|
||||||
_config.SyncIntervalMinutes > 0 &&
|
_config.SyncIntervalMinutes > 0 &&
|
||||||
(hasTemplateSyncRoute || hasDatabaseDeletionRoute))
|
(hasTemplateSyncRoute || hasDatabaseDeletionRoute || hasInitialDepartmentSyncRoute))
|
||||||
{
|
{
|
||||||
var sourceLabel = !string.IsNullOrWhiteSpace(_config.SourceMachineIp) ? _config.SourceMachineIp : _config.SourceDeviceId;
|
var sourceLabel = !string.IsNullOrWhiteSpace(_config.SourceMachineIp) ? _config.SourceMachineIp : _config.SourceDeviceId;
|
||||||
_logger.Ops(OpsMarkers.Service, "JOB ENABLED [TEMPLATE_DB_TO_DEVICE]/[USER_DELETE] intervalMinutes=" + _config.SyncIntervalMinutes +
|
_logger.Ops(OpsMarkers.Service, "JOB ENABLED [TEMPLATE_DB_TO_DEVICE]/[USER_DELETE]/[INITIAL_SYNC] intervalMinutes=" + _config.SyncIntervalMinutes +
|
||||||
" source=" + (hasUserSyncSource ? sourceLabel : "(not configured)") + " targets=" + userSyncTargetCount +
|
" source=" + (hasUserSyncSource ? sourceLabel : "(not configured)") + " targets=" + userSyncTargetCount +
|
||||||
" deletionByMachineId=" + hasDatabaseDeletionRoute);
|
" deletionByMachineId=" + hasDatabaseDeletionRoute +
|
||||||
|
" initialDepartmentSync=" + hasInitialDepartmentSyncRoute);
|
||||||
_ = Task.Run(() => UserSyncSchedulerLoop(_cts.Token), _cts.Token);
|
_ = Task.Run(() => UserSyncSchedulerLoop(_cts.Token), _cts.Token);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.Ops(OpsMarkers.Service, "JOB DISABLED [TEMPLATE_DB_TO_DEVICE] EnableUserSync=" + _config.EnableUserSync +
|
_logger.Ops(OpsMarkers.Service, "JOB DISABLED [TEMPLATE_DB_TO_DEVICE] EnableUserSync=" + _config.EnableUserSync +
|
||||||
|
" EnableInitialDepartmentSync=" + _config.EnableInitialDepartmentSync +
|
||||||
" hasSource=" + hasUserSyncSource + " targets=" + userSyncTargetCount +
|
" hasSource=" + hasUserSyncSource + " targets=" + userSyncTargetCount +
|
||||||
" deletionByMachineId=" + hasDatabaseDeletionRoute);
|
" deletionByMachineId=" + hasDatabaseDeletionRoute);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,24 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public bool EnableTemplateDbToDeviceSync { get; set; }
|
public bool EnableTemplateDbToDeviceSync { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Separate onboarding flow: provision users+faces to a new device from HRMS department
|
||||||
|
/// + employee portal photos. Does not use attendance_machine_face_templates.
|
||||||
|
/// </summary>
|
||||||
|
[DataMember]
|
||||||
|
public bool EnableInitialDepartmentSync { get; set; }
|
||||||
|
|
||||||
|
[DataMember]
|
||||||
|
public List<string> InitialSyncDepartmentIds { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
/// <summary>When true, initial sync downloads JPEG photos from <see cref="EmployeePhotoBaseUrl"/>.</summary>
|
||||||
|
[DataMember]
|
||||||
|
public bool EnableEmployeePhotoSource { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base URL ending with /; photo path is {base}{employee.id}.jpeg</summary>
|
||||||
|
[DataMember]
|
||||||
|
public string EmployeePhotoBaseUrl { get; set; } = "";
|
||||||
|
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public bool KeepAttendanceFileExport { get; set; } = true;
|
public bool KeepAttendanceFileExport { get; set; } = true;
|
||||||
|
|
||||||
|
|
@ -223,6 +241,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public List<string> SyncEmployeeIds { get; set; } = new List<string>();
|
public List<string> SyncEmployeeIds { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
/// <summary>Optional HRMS department_id values; serial_numbers from hrms.employee are merged with SyncEmployeeIds.</summary>
|
||||||
|
[DataMember]
|
||||||
|
public List<string> SyncDepartmentIds { get; set; } = new List<string>();
|
||||||
|
|
||||||
/// <summary>Optional directory to persist downloaded source face images for auditing or re-upload. Empty = skip file save.</summary>
|
/// <summary>Optional directory to persist downloaded source face images for auditing or re-upload. Empty = skip file save.</summary>
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public string UserSyncFaceCacheDirectory { get; set; } = "";
|
public string UserSyncFaceCacheDirectory { get; set; } = "";
|
||||||
|
|
@ -248,6 +270,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
EnableTemplateDbPersistence = false,
|
EnableTemplateDbPersistence = false,
|
||||||
EnableTemplateDeviceToDbSync = false,
|
EnableTemplateDeviceToDbSync = false,
|
||||||
EnableTemplateDbToDeviceSync = false,
|
EnableTemplateDbToDeviceSync = false,
|
||||||
|
EnableInitialDepartmentSync = false,
|
||||||
|
InitialSyncDepartmentIds = new List<string>(),
|
||||||
|
EnableEmployeePhotoSource = false,
|
||||||
|
EmployeePhotoBaseUrl = "",
|
||||||
KeepAttendanceFileExport = true,
|
KeepAttendanceFileExport = true,
|
||||||
KeepTemplateFiles = true,
|
KeepTemplateFiles = true,
|
||||||
DbHost = "",
|
DbHost = "",
|
||||||
|
|
@ -281,6 +307,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
TargetDeviceIds = new List<string>(),
|
TargetDeviceIds = new List<string>(),
|
||||||
TargetMachineIps = new List<string>(),
|
TargetMachineIps = new List<string>(),
|
||||||
SyncEmployeeIds = new List<string>(),
|
SyncEmployeeIds = new List<string>(),
|
||||||
|
SyncDepartmentIds = new List<string>(),
|
||||||
UserSyncFaceCacheDirectory = "",
|
UserSyncFaceCacheDirectory = "",
|
||||||
SyncPolicies = new UserSyncPoliciesConfig()
|
SyncPolicies = new UserSyncPoliciesConfig()
|
||||||
};
|
};
|
||||||
|
|
@ -331,6 +358,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
EnableTemplateDbPersistence = false,
|
EnableTemplateDbPersistence = false,
|
||||||
EnableTemplateDeviceToDbSync = false,
|
EnableTemplateDeviceToDbSync = false,
|
||||||
EnableTemplateDbToDeviceSync = false,
|
EnableTemplateDbToDeviceSync = false,
|
||||||
|
EnableInitialDepartmentSync = false,
|
||||||
|
InitialSyncDepartmentIds = new List<string>(),
|
||||||
|
EnableEmployeePhotoSource = false,
|
||||||
|
EmployeePhotoBaseUrl = "",
|
||||||
KeepAttendanceFileExport = true,
|
KeepAttendanceFileExport = true,
|
||||||
KeepTemplateFiles = true,
|
KeepTemplateFiles = true,
|
||||||
DbHost = "",
|
DbHost = "",
|
||||||
|
|
@ -364,6 +395,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
TargetDeviceIds = new List<string>(),
|
TargetDeviceIds = new List<string>(),
|
||||||
TargetMachineIps = new List<string>(),
|
TargetMachineIps = new List<string>(),
|
||||||
SyncEmployeeIds = new List<string>(),
|
SyncEmployeeIds = new List<string>(),
|
||||||
|
SyncDepartmentIds = new List<string>(),
|
||||||
UserSyncFaceCacheDirectory = "",
|
UserSyncFaceCacheDirectory = "",
|
||||||
SyncPolicies = new UserSyncPoliciesConfig()
|
SyncPolicies = new UserSyncPoliciesConfig()
|
||||||
};
|
};
|
||||||
|
|
@ -536,6 +568,14 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
cfg.TargetMachineIps = new List<string>(overlay.TargetMachineIps);
|
cfg.TargetMachineIps = new List<string>(overlay.TargetMachineIps);
|
||||||
if (overlay.SyncEmployeeIds != null)
|
if (overlay.SyncEmployeeIds != null)
|
||||||
cfg.SyncEmployeeIds = new List<string>(overlay.SyncEmployeeIds);
|
cfg.SyncEmployeeIds = new List<string>(overlay.SyncEmployeeIds);
|
||||||
|
if (overlay.SyncDepartmentIds != null)
|
||||||
|
cfg.SyncDepartmentIds = new List<string>(overlay.SyncDepartmentIds);
|
||||||
|
cfg.EnableInitialDepartmentSync = overlay.EnableInitialDepartmentSync;
|
||||||
|
if (overlay.InitialSyncDepartmentIds != null)
|
||||||
|
cfg.InitialSyncDepartmentIds = new List<string>(overlay.InitialSyncDepartmentIds);
|
||||||
|
cfg.EnableEmployeePhotoSource = overlay.EnableEmployeePhotoSource;
|
||||||
|
if (overlay.EmployeePhotoBaseUrl != null)
|
||||||
|
cfg.EmployeePhotoBaseUrl = overlay.EmployeePhotoBaseUrl;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
@ -573,6 +613,15 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
cfg.SyncEmployeeIds ??= new List<string>();
|
cfg.SyncEmployeeIds ??= new List<string>();
|
||||||
for (int i = 0; i < cfg.SyncEmployeeIds.Count; i++)
|
for (int i = 0; i < cfg.SyncEmployeeIds.Count; i++)
|
||||||
cfg.SyncEmployeeIds[i] = (cfg.SyncEmployeeIds[i] ?? "").Trim();
|
cfg.SyncEmployeeIds[i] = (cfg.SyncEmployeeIds[i] ?? "").Trim();
|
||||||
|
cfg.SyncDepartmentIds ??= new List<string>();
|
||||||
|
for (int i = 0; i < cfg.SyncDepartmentIds.Count; i++)
|
||||||
|
cfg.SyncDepartmentIds[i] = (cfg.SyncDepartmentIds[i] ?? "").Trim();
|
||||||
|
cfg.InitialSyncDepartmentIds ??= new List<string>();
|
||||||
|
for (int i = 0; i < cfg.InitialSyncDepartmentIds.Count; i++)
|
||||||
|
cfg.InitialSyncDepartmentIds[i] = (cfg.InitialSyncDepartmentIds[i] ?? "").Trim();
|
||||||
|
cfg.EmployeePhotoBaseUrl = string.IsNullOrWhiteSpace(cfg.EmployeePhotoBaseUrl)
|
||||||
|
? ""
|
||||||
|
: cfg.EmployeePhotoBaseUrl.Trim();
|
||||||
|
|
||||||
if (cfg.Devices == null)
|
if (cfg.Devices == null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -610,6 +659,21 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
|
||||||
|
|
||||||
[DataMember]
|
[DataMember]
|
||||||
public List<string> SyncEmployeeIds { get; set; } = new List<string>();
|
public List<string> SyncEmployeeIds { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
[DataMember]
|
||||||
|
public List<string> SyncDepartmentIds { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
[DataMember]
|
||||||
|
public bool EnableInitialDepartmentSync { get; set; }
|
||||||
|
|
||||||
|
[DataMember]
|
||||||
|
public List<string> InitialSyncDepartmentIds { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
[DataMember]
|
||||||
|
public bool EnableEmployeePhotoSource { get; set; }
|
||||||
|
|
||||||
|
[DataMember]
|
||||||
|
public string EmployeePhotoBaseUrl { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Policies for multi-device user/face sync (ISAPI). Loaded from service JSON only.</summary>
|
/// <summary>Policies for multi-device user/face sync (ISAPI). Loaded from service JSON only.</summary>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue