feat: apply photo preprocessing, employee allow-list, and user-sync logging to initial sync
Wire the initial department sync to the new photo processor: downloaded
JPEGs are face-cropped/normalized before FaceDataRecord, and photos with
no detectable face are skipped and logged as FACE_NOT_DETECTED.
Add an optional InitialSyncEmployeeIds allow-list so only selected
employees within the chosen departments are enrolled (empty = whole
department), filtered directly in the SQL query with parameters.
Emit business entries to logs/user_sync_logs for provisioned users
("added successfully", "face enrolled successfully", readable failure
reasons) so onboarded users appear alongside the existing deletion
entries instead of only in internal_logs.
main
parent
0ef986e71a
commit
3ce91f4d76
|
|
@ -45,11 +45,24 @@ internal sealed partial class HikvisionAttendanceManager
|
|||
return;
|
||||
}
|
||||
|
||||
var employees = LoadEmployeesForInitialDepartmentSync(deptIds, out var loadErr);
|
||||
var employeeIdFilter = (_config.InitialSyncEmployeeIds ?? new List<string>())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var employees = LoadEmployeesForInitialDepartmentSync(deptIds, employeeIdFilter, out var loadErr);
|
||||
if (!string.IsNullOrWhiteSpace(loadErr))
|
||||
_logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr);
|
||||
|
||||
if (employeeIdFilter.Count > 0)
|
||||
_logger.Ops("INITIAL_SYNC", "Employee filter: departmentIds=" + string.Join(",", deptIds) +
|
||||
" allowListCount=" + employeeIdFilter.Count +
|
||||
" allowList=" + string.Join(",", employeeIdFilter) +
|
||||
" matched=" + employees.Count);
|
||||
|
||||
_logger.Ops("INITIAL_SYNC", "departmentIds=" + string.Join(",", deptIds) +
|
||||
" employeeFilter=" + (employeeIdFilter.Count > 0 ? "selective(" + employeeIdFilter.Count + ")" : "whole-department") +
|
||||
" employeesFound=" + employees.Count);
|
||||
|
||||
if (employees.Count == 0)
|
||||
|
|
@ -71,9 +84,19 @@ internal sealed partial class HikvisionAttendanceManager
|
|||
_logger.OpsWarn("INITIAL_SYNC",
|
||||
"target=" + (target.DeviceId ?? "") + " ip=" + (target.Ip ?? "") +
|
||||
" SKIPPED reason=\"target offline\"");
|
||||
_logger.Biz(BizChannel.UserSync,
|
||||
"MACHINE " + (target.DeviceId ?? ""),
|
||||
"",
|
||||
"Machine is not connected.",
|
||||
"");
|
||||
_logger.BizSeparator(BizChannel.UserSync);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Biz(BizChannel.UserSync,
|
||||
"MACHINE " + (target.DeviceId ?? "") + " -> Total Users : " + employees.Count,
|
||||
"");
|
||||
|
||||
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)>();
|
||||
|
|
@ -145,7 +168,14 @@ internal sealed partial class HikvisionAttendanceManager
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!UploadFaceOnTarget(target, employeeNo, jpegBytes, maxRetries, ct, out var upErr))
|
||||
if (!TryPrepareInitialSyncFaceJpeg(employeeNo, jpegBytes, out var uploadBytes))
|
||||
{
|
||||
reason = "FACE_NOT_DETECTED";
|
||||
faceRejectedEmployees.Add((employeeNo, reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!UploadFaceOnTarget(target, employeeNo, uploadBytes, maxRetries, ct, out var upErr))
|
||||
{
|
||||
reason = string.IsNullOrWhiteSpace(upErr) ? "face_upload_failed" : upErr;
|
||||
faceRejectedEmployees.Add((employeeNo, reason));
|
||||
|
|
@ -156,6 +186,8 @@ internal sealed partial class HikvisionAttendanceManager
|
|||
}
|
||||
finally
|
||||
{
|
||||
WriteInitialSyncEmployeeBizLog(target, employeeNo, userCreated, faceUploaded, reason);
|
||||
|
||||
if (photoDownloaded && faceUploaded)
|
||||
{
|
||||
_logger.Ops("INITIAL_SYNC",
|
||||
|
|
@ -190,9 +222,111 @@ internal sealed partial class HikvisionAttendanceManager
|
|||
}
|
||||
|
||||
WriteFaceRejectedEmployeeSummary("INITIAL_SYNC", target, faceRejectedEmployees);
|
||||
_logger.BizSeparator(BizChannel.UserSync);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the DB→device sync wording in logs/user_sync_logs so provisioned users are visible
|
||||
/// alongside the "removed successfully" entries, not only in internal_logs.
|
||||
/// </summary>
|
||||
private void WriteInitialSyncEmployeeBizLog(
|
||||
HikvisionAttendanceWindowsService.DeviceConfig target,
|
||||
string employeeNo,
|
||||
bool userCreated,
|
||||
bool faceUploaded,
|
||||
string reason)
|
||||
{
|
||||
if (userCreated)
|
||||
{
|
||||
_logger.Totals.UsersAdded++;
|
||||
_logger.Biz(BizChannel.UserSync,
|
||||
"Machine ID : " + (target.DeviceId ?? ""),
|
||||
"Machine IP : " + (target.Ip ?? ""),
|
||||
"",
|
||||
employeeNo + " added successfully.",
|
||||
"");
|
||||
}
|
||||
|
||||
if (faceUploaded)
|
||||
{
|
||||
_logger.Totals.TemplatesSaved++;
|
||||
_logger.Biz(BizChannel.UserSync, employeeNo + " face enrolled successfully.", "");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Totals.TemplatesFailed++;
|
||||
_logger.Biz(BizChannel.UserSync,
|
||||
employeeNo + " face enrollment failed.",
|
||||
"",
|
||||
"Reason :",
|
||||
"",
|
||||
DescribeInitialSyncFailure(reason),
|
||||
"");
|
||||
}
|
||||
|
||||
private static string DescribeInitialSyncFailure(string reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
return "Unknown error.";
|
||||
|
||||
switch (reason)
|
||||
{
|
||||
case "FACE_NOT_DETECTED":
|
||||
return "No face detected in the employee photo.";
|
||||
case "photo_not_found":
|
||||
return "Employee photo not found on the HRMS portal.";
|
||||
case "invalid_jpeg":
|
||||
return "Employee photo is not a valid JPEG.";
|
||||
case "photo_source_disabled":
|
||||
return "Employee photo source is disabled.";
|
||||
case "missing_employee_id":
|
||||
return "Employee has no HRMS id, photo URL cannot be built.";
|
||||
case "user_create_failed":
|
||||
return "User could not be created on the device.";
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initial-sync only: crops/normalizes the HRMS photo around the detected face before FaceDataRecord.
|
||||
/// Returns false only when no face is found; decode/encode problems fall back to the raw portal JPEG.
|
||||
/// </summary>
|
||||
private bool TryPrepareInitialSyncFaceJpeg(string employeeNo, byte[] downloadedJpeg, out byte[] uploadBytes)
|
||||
{
|
||||
uploadBytes = downloadedJpeg;
|
||||
var processed = EmployeePhotoFaceProcessor.Process(downloadedJpeg);
|
||||
|
||||
if (processed.Status == EmployeePhotoFaceProcessor.PhotoStatus.NoFace)
|
||||
{
|
||||
_logger.OpsWarn("INITIAL_SYNC",
|
||||
"FACE_NOT_DETECTED employeeNo=" + employeeNo +
|
||||
" original=" + processed.Original.Width + "x" + processed.Original.Height +
|
||||
" uploadSkipped=true");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (processed.Status != EmployeePhotoFaceProcessor.PhotoStatus.Ok)
|
||||
{
|
||||
_logger.OpsWarn("INITIAL_SYNC",
|
||||
"PHOTO_PROCESS_FAILED employeeNo=" + employeeNo +
|
||||
" err=" + processed.Error +
|
||||
" fallback=original-jpeg bytes=" + downloadedJpeg.Length);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.Ops("INITIAL_SYNC", "PHOTO_PROCESSED employeeNo=" + employeeNo + " " + processed.Describe());
|
||||
_logger.Diag("INITIAL_SYNC",
|
||||
"PHOTO_PROCESSED employeeNo=" + employeeNo + " " + processed.Describe() +
|
||||
" jpegQuality=" + processed.JpegQuality +
|
||||
" downloadedBytes=" + downloadedJpeg.Length +
|
||||
" enhance[" + processed.Enhancement + "]");
|
||||
|
||||
uploadBytes = processed.JpegBytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
private List<HikvisionAttendanceWindowsService.DeviceConfig> ResolveInitialSyncTargets()
|
||||
{
|
||||
var targets = new List<HikvisionAttendanceWindowsService.DeviceConfig>();
|
||||
|
|
@ -222,7 +356,10 @@ internal sealed partial class HikvisionAttendanceManager
|
|||
return targets;
|
||||
}
|
||||
|
||||
private List<InitialSyncEmployee> LoadEmployeesForInitialDepartmentSync(IReadOnlyList<string> departmentIds, out string error)
|
||||
private List<InitialSyncEmployee> LoadEmployeesForInitialDepartmentSync(
|
||||
IReadOnlyList<string> departmentIds,
|
||||
IReadOnlyList<string> employeeIdAllowList,
|
||||
out string error)
|
||||
{
|
||||
error = "";
|
||||
var result = new List<InitialSyncEmployee>();
|
||||
|
|
@ -237,6 +374,8 @@ internal sealed partial class HikvisionAttendanceManager
|
|||
if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error))
|
||||
return result;
|
||||
|
||||
var hasAllowList = employeeIdAllowList != null && employeeIdAllowList.Count > 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new MySqlConnection(cs);
|
||||
|
|
@ -249,9 +388,25 @@ internal sealed partial class HikvisionAttendanceManager
|
|||
}
|
||||
sql.Append(')');
|
||||
|
||||
if (hasAllowList)
|
||||
{
|
||||
sql.Append(" AND serial_number IN (");
|
||||
for (int i = 0; i < employeeIdAllowList.Count; i++)
|
||||
{
|
||||
if (i > 0) sql.Append(',');
|
||||
sql.Append("@e").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]);
|
||||
if (hasAllowList)
|
||||
{
|
||||
for (int i = 0; i < employeeIdAllowList.Count; i++)
|
||||
cmd.Parameters.AddWithValue("@e" + i, employeeIdAllowList[i]);
|
||||
}
|
||||
|
||||
using var rd = cmd.ExecuteReader();
|
||||
var seenSerial = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
|
|
|||
Loading…
Reference in New Issue