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
SYED MUSTUFA AHMED NAQVI 2026-08-15 15:50:58 +05:00
parent 7b66f9319a
commit 9d1e361099
5 changed files with 828 additions and 65 deletions

View File

@ -1,20 +1,24 @@
{
"MachineScopeMode": "SITE",
"ScopedMachineIps": [
"192.168.90.226"
"192.168.91.80"
],
"EnableTemplateDeviceToDbSync": false,
"EnableTemplateDbToDeviceSync": false,
"SourceMachineIp": "192.168.90.226",
"SourceMachineIp": "",
"TargetMachineIps": [
"192.168.90.226"
"192.168.91.80"
],
"SyncEmployeeIds": [
"15399",
"17003",
"15111",
]
"SyncEmployeeIds": [],
"SyncDepartmentIds": [],
"EnableInitialDepartmentSync": true,
"InitialSyncDepartmentIds": [
"357"
],
"EnableEmployeePhotoSource": true,
"EmployeePhotoBaseUrl": "https://portal.utopiaindustries.pk/uind/employee-photo/"
}

View File

@ -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; } = "";
}
}

View File

@ -11,12 +11,16 @@ using System.Threading;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using HikvisionAttendanceService.Data;
using MySql.Data.MySqlClient;
namespace HikvisionAttendanceService;
/// <summary>Multi-device user + face sync via ISAPI HTTP (Digest). Orchestration and focused helpers.</summary>
internal sealed partial class HikvisionAttendanceManager
{
private readonly Dictionary<string, List<FaceLibCandidate>> _faceLibCacheByDeviceIp =
new Dictionary<string, List<FaceLibCandidate>>(StringComparer.OrdinalIgnoreCase);
private HikvisionAttendanceWindowsService.UserSyncPoliciesConfig SyncPol =>
_config.SyncPolicies ?? new HikvisionAttendanceWindowsService.UserSyncPoliciesConfig();
@ -62,10 +66,23 @@ internal sealed partial class HikvisionAttendanceManager
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 ?? "");
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=\"" +
(_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)))
continue;
// Same IP allowed for DB->device restore (user deleted on device, restore from DB).
if (string.Equals((t.Ip ?? "").Trim(), (source.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) &&
if (hasSource &&
string.Equals((t.Ip ?? "").Trim(), (source!.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) &&
!_config.EnableTemplateDbToDeviceSync)
{
_logger.Diag("user_sync", "target skipped — same as source IP and DB->device restore disabled. TargetMachineIp=\"" + targetIp + "\".");
@ -101,7 +119,8 @@ internal sealed partial class HikvisionAttendanceManager
continue;
}
if (DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source.DeviceId))
if (hasSource &&
DeviceIdentity.CanonicalLookupKey(t.DeviceId) == DeviceIdentity.CanonicalLookupKey(source!.DeviceId))
{
if (!_config.EnableTemplateDbToDeviceSync)
{
@ -123,21 +142,24 @@ internal sealed partial class HikvisionAttendanceManager
}
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=" + source.DeviceId + " sourceIp=" + (source.Ip ?? "") +
" 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=" + source.DeviceId +
" sourceIp=" + (source.Ip ?? "") + " targets=" + targets.Count);
". Meaning: push users/faces to targets. source=" + sourceDeviceLabel +
" sourceIp=" + sourceIpLabel + " targets=" + targets.Count);
var sourceUsers = LoadSourceUsersForSync(source, maxRetries, ct);
if ((_config.SyncEmployeeIds ?? new List<string>()).Count > 0)
var sourceUsers = hasSource
? 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();
// Same-machine restore: employee may already be gone from device/user list;
// 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 +
" 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.");
_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);
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);
continue;
}
if (string.IsNullOrWhiteSpace(u.FaceUrl))
if (!hasSource || string.IsNullOrWhiteSpace(u.FaceUrl))
{
faceBytesByEmployee[u.EmployeeNo] = null;
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;
faceDlOk++;
@ -275,6 +298,7 @@ internal sealed partial class HikvisionAttendanceManager
int created = 0, faceUp = 0, skipped = 0, failed = 0, updated = 0;
var statusCounts = new Dictionary<UserSyncStatus, int>();
var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>();
void Bump(UserSyncStatus s)
{
@ -282,8 +306,8 @@ internal sealed partial class HikvisionAttendanceManager
statusCounts[s] = n + 1;
}
// Explicit SyncEmployeeIds = restore/push list; do not filter by DB assignment rows.
var explicitEmployeeFilter = (_config.SyncEmployeeIds ?? new List<string>()).Count > 0;
// Explicit SyncEmployeeIds / SyncDepartmentIds = restore/push list; do not filter by DB assignment rows.
var explicitEmployeeFilter = hasExplicitEmployeeFilter;
foreach (var srcUser in sourceUsers)
{
@ -292,7 +316,12 @@ internal sealed partial class HikvisionAttendanceManager
continue;
targetMap.TryGetValue(srcUser.EmployeeNo, out var tgtRow);
bool createdNew = false;
bool createUserLogged = false;
bool faceUploadedLogged = false;
bool templateFoundLogged = false;
try
{
if (tgtRow == null)
{
if (!CreateUserOnTarget(target, srcUser, maxRetries, ct, out var createErr))
@ -305,6 +334,7 @@ internal sealed partial class HikvisionAttendanceManager
}
created++;
createUserLogged = true;
_logger.Totals.UsersAdded++;
_logger.Biz(BizChannel.UserSync,
"Machine ID : " + (target.DeviceId ?? ""),
@ -393,6 +423,7 @@ internal sealed partial class HikvisionAttendanceManager
{
_logger.Warn("UserSync template lookup failed employeeNo=" + srcUser.EmployeeNo + " err=" + tplErr);
}
templateFoundLogged = hasFaceBytes;
if (!hasFaceBytes)
{
skipped++;
@ -444,6 +475,7 @@ internal sealed partial class HikvisionAttendanceManager
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,
srcUser.EmployeeNo + " -> Device " + target.DeviceId + " = FACE TEMPLATE FAILED reason=\"" + upErr + "\"");
_logger.Totals.TemplatesFailed++;
@ -468,6 +500,7 @@ internal sealed partial class HikvisionAttendanceManager
}
faceUp++;
faceUploadedLogged = true;
_logger.Totals.TemplatesSaved++;
tgtRow.NumOfFace = Math.Max(tgtRow.NumOfFace, 1);
_logger.Ops(OpsMarkers.TemplateDbToDevice,
@ -485,6 +518,15 @@ internal sealed partial class HikvisionAttendanceManager
}
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)
{
@ -520,6 +562,7 @@ internal sealed partial class HikvisionAttendanceManager
var statusLine = string.Join(", ", statusCounts.OrderBy(kv => kv.Key.ToString())
.Select(kv => kv.Key + "=" + kv.Value));
WriteFaceRejectedEmployeeSummary("TEMPLATE_DB_TO_DEVICE", target, faceRejectedEmployees);
_logger.Ops(OpsMarkers.TemplateDbToDevice,
"device=" + target.DeviceId + " summary created=" + created +
" faceUploaded=" + faceUp + " updated=" + updated +
@ -531,6 +574,114 @@ internal sealed partial class HikvisionAttendanceManager
_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)
{
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
@ -686,6 +837,13 @@ internal sealed partial class HikvisionAttendanceManager
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)
@ -767,7 +925,6 @@ internal sealed partial class HikvisionAttendanceManager
byte[] faceImage, int maxRetries, CancellationToken ct, out string error)
{
error = "";
var pol = SyncPol;
if (faceImage == null || faceImage.Length == 0)
{
error = "empty faceImage";
@ -775,8 +932,11 @@ internal sealed partial class HikvisionAttendanceManager
}
var emp = (employeeNo ?? "").Trim();
var faceLibType = string.IsNullOrWhiteSpace(pol.FaceLibType) ? "blackFD" : pol.FaceLibType.Trim();
var fdId = string.IsNullOrWhiteSpace(pol.FaceLibraryFdId) ? "1" : pol.FaceLibraryFdId.Trim();
if (emp.Length == 0)
{
error = "empty employeeNo";
return false;
}
if (!TryNormalizeFaceImageForUpload(faceImage, out var jpegBytes, out var imageFormat, out var normalizeNote))
{
@ -790,15 +950,33 @@ internal sealed partial class HikvisionAttendanceManager
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) + "\"}";
const string relativeUri = "/ISAPI/Intelligent/FDLib/FaceDataRecord?format=json";
string url = "http://" + target.Ip + ":" + IsapiPort + relativeUri;
_logger.Diag("TEMPLATE_DB_TO_DEVICE",
"face upload prepare employee=" + emp +
"face upload prepare employeeNo=" + emp +
" device=" + (target.DeviceId ?? "") +
" ip=" + (target.Ip ?? "") +
" url=" + url +
@ -808,32 +986,206 @@ internal sealed partial class HikvisionAttendanceManager
" blobSize=" + faceImage.Length +
" jpegSize=" + jpegBytes.Length +
" sourceFormat=" + imageFormat +
" base64Length=" + (4 * ((jpegBytes.Length + 2) / 3)) +
" meta=" + metaJson +
(string.IsNullOrWhiteSpace(normalizeNote) ? "" : (" note=\"" + normalizeNote + "\"")));
if (!TryIsapiPostMultipartFaceWithRetry(target, relativeUri, metaJson, jpegBytes, emp, maxRetries, ct,
out var body, out var status, out error))
out var body, out var status, out var uploadErr))
{
// Keep Ops/summary concise; full HTTP body already written to Diag inside the sender.
error = ConciseFaceUploadFailureReason(status, body, error);
return false;
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 employee=" + emp +
"face upload rejected by device employeeNo=" + emp +
" faceLibType=" + faceLibType +
" FDID=" + fdId +
" httpStatus=" + status +
" statusCode=" + sc +
" statusString=\"" + ss + "\"" +
" subStatusCode=\"" + sub + "\"" +
" response=" + (body ?? ""));
error = ConciseFaceUploadFailureReason(status, body, "device rejected face upload");
" 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;
}
/// <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;
}

View File

@ -342,19 +342,23 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
var hasTemplateSyncRoute = hasUserSyncSource && userSyncTargetCount > 0;
var hasDatabaseDeletionRoute = _config.EnableDbIntegration && _attendanceMachineRepository != 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 &&
(hasTemplateSyncRoute || hasDatabaseDeletionRoute))
(hasTemplateSyncRoute || hasDatabaseDeletionRoute || hasInitialDepartmentSyncRoute))
{
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 +
" deletionByMachineId=" + hasDatabaseDeletionRoute);
" deletionByMachineId=" + hasDatabaseDeletionRoute +
" initialDepartmentSync=" + hasInitialDepartmentSyncRoute);
_ = Task.Run(() => UserSyncSchedulerLoop(_cts.Token), _cts.Token);
}
else
{
_logger.Ops(OpsMarkers.Service, "JOB DISABLED [TEMPLATE_DB_TO_DEVICE] EnableUserSync=" + _config.EnableUserSync +
" EnableInitialDepartmentSync=" + _config.EnableInitialDepartmentSync +
" hasSource=" + hasUserSyncSource + " targets=" + userSyncTargetCount +
" deletionByMachineId=" + hasDatabaseDeletionRoute);
}

View File

@ -103,6 +103,24 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
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]
public bool KeepAttendanceFileExport { get; set; } = true;
@ -223,6 +241,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
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>
[DataMember]
public string UserSyncFaceCacheDirectory { get; set; } = "";
@ -248,6 +270,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
EnableTemplateDbPersistence = false,
EnableTemplateDeviceToDbSync = false,
EnableTemplateDbToDeviceSync = false,
EnableInitialDepartmentSync = false,
InitialSyncDepartmentIds = new List<string>(),
EnableEmployeePhotoSource = false,
EmployeePhotoBaseUrl = "",
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
DbHost = "",
@ -281,6 +307,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
TargetDeviceIds = new List<string>(),
TargetMachineIps = new List<string>(),
SyncEmployeeIds = new List<string>(),
SyncDepartmentIds = new List<string>(),
UserSyncFaceCacheDirectory = "",
SyncPolicies = new UserSyncPoliciesConfig()
};
@ -331,6 +358,10 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
EnableTemplateDbPersistence = false,
EnableTemplateDeviceToDbSync = false,
EnableTemplateDbToDeviceSync = false,
EnableInitialDepartmentSync = false,
InitialSyncDepartmentIds = new List<string>(),
EnableEmployeePhotoSource = false,
EmployeePhotoBaseUrl = "",
KeepAttendanceFileExport = true,
KeepTemplateFiles = true,
DbHost = "",
@ -364,6 +395,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
TargetDeviceIds = new List<string>(),
TargetMachineIps = new List<string>(),
SyncEmployeeIds = new List<string>(),
SyncDepartmentIds = new List<string>(),
UserSyncFaceCacheDirectory = "",
SyncPolicies = new UserSyncPoliciesConfig()
};
@ -536,6 +568,14 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
cfg.TargetMachineIps = new List<string>(overlay.TargetMachineIps);
if (overlay.SyncEmployeeIds != null)
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
{
@ -573,6 +613,15 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
cfg.SyncEmployeeIds ??= new List<string>();
for (int i = 0; i < cfg.SyncEmployeeIds.Count; i++)
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)
return;
@ -610,6 +659,21 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase
[DataMember]
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>