Fix DB_TO_DEVICE HTTP restore so deleted users get face templates on device.

Use EmpIds from config when restoring from DB, and push templates via a two-step setuserinfo (create user, then backupnum/record or face) matching the F710X web API.

Co-authored-by: Cursor <cursoragent@cursor.com>
main
SYED MUSTUFA AHMED NAQVI 2026-07-24 09:44:29 +05:00
parent 6867cb90e7
commit 5424d0abb8
5 changed files with 351 additions and 62 deletions

View File

@ -6,12 +6,13 @@
providerName="MySql.Data.MySqlClient" />
</connectionStrings>
<appSettings>
<add key="ATTENDANCE_SCOPE" value="CENTRAL" />
<!--<add key="ATTENDANCE_SCOPE" value="SITE" />-->
<!-- <add key="ATTENDANCE_SCOPE" value="CENTRAL" /> -->
<add key="ATTENDANCE_SCOPE" value="SITE" />
<add key="ATTENDANCE_JOB_INTERVAL_MINUTES" value="20" />
<add key="TEMPLATE_JOB_INTERVAL_MINUTES" value="240" />
<add key="TEMPLATE_SYNC_ENABLED" value="true" />
<add key="TRANSFER_MODE" value="DEVICE_TO_DB" />
<!-- <add key="TRANSFER_MODE" value="DB_TO_DEVICE" /> -->
<add key="DEVICE_SETTINGS_PATH" value="DeviceSettings.xml" />
<add key="TEMPLATE_VERIFY_AFTER_PUSH" value="true" />
<add key="TEMPLATE_DEVICE_DELAY_MS" value="0" />

View File

@ -104,6 +104,24 @@ namespace HanvonF710XAttendanceService
}
}
public string GetEmployeeNameForMachine(MySqlConnection connection, string machineId, string serialNumber)
{
if (string.IsNullOrWhiteSpace(machineId) || string.IsNullOrWhiteSpace(serialNumber)) return null;
const string query =
"SELECT employee_name FROM hrms.attendance_machine_user " +
"WHERE machine_id = @MachineId AND serial_number = @SerialNumber AND is_deleted = 0 LIMIT 1";
using (var cmd = new MySqlCommand(query, connection))
{
cmd.CommandTimeout = 60;
cmd.Parameters.AddWithValue("@MachineId", machineId);
cmd.Parameters.AddWithValue("@SerialNumber", serialNumber.Trim());
var result = cmd.ExecuteScalar();
if (result == null || result == DBNull.Value) return null;
var name = result.ToString()?.Trim();
return string.IsNullOrWhiteSpace(name) ? null : name;
}
}
private List<AttendanceMachineUser> getDBUsers(MySqlDataReader reader)
{
List<AttendanceMachineUser> DbUsers = new List<AttendanceMachineUser>();

View File

@ -1,13 +1,13 @@
<DeviceSettings Mode="Multi" SiteId="2">
<!--<Device IpAddress="192.168.70.14" Enabled="true" />-->
<Device IpAddress="192.168.86.2" Enabled="true" />
<Device IpAddress="192.168.90.89" Enabled="true" />
<TemplateTransfer Enabled="false">
<TemplateTransfer Enabled="true">
<Job Id="1"
Enabled="true"
SourceIp="192.168.86.7"
EmpIds="7044">
<Target Ip="192.168.86.4" />
SourceIp="192.168.90.89"
EmpIds="15399,17003">
<Target Ip="192.168.90.89" />
</Job>
</TemplateTransfer>

View File

@ -196,6 +196,121 @@ namespace HanvonF710XAttendanceService
}
}
public static string NormalizeFaceTemplatePayload(byte[] blob, out string pushMode)
{
pushMode = "record";
if (blob == null || blob.Length == 0) return null;
if (blob.Length >= 3 && blob[0] == 0xFF && blob[1] == 0xD8 && blob[2] == 0xFF)
{
pushMode = "face";
return Convert.ToBase64String(blob);
}
string text = Encoding.UTF8.GetString(blob).Trim();
if (string.IsNullOrWhiteSpace(text)) return null;
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
int comma = text.IndexOf(',');
if (comma >= 0) text = text.Substring(comma + 1);
}
text = text.Replace("\r", "").Replace("\n", "").Trim();
if (string.IsNullOrWhiteSpace(text)) return null;
try
{
byte[] decoded = Convert.FromBase64String(text);
if (decoded.Length >= 3 && decoded[0] == 0xFF && decoded[1] == 0xD8 && decoded[2] == 0xFF)
{
pushMode = "face";
}
}
catch
{
// Biometric templates from getuserinfo may not decode as standard base64.
}
return text;
}
public bool TryEnsureUser(int enrollId, string name, out string error)
{
error = null;
try
{
var resp = Post(new Dictionary<string, object>
{
{ "cmd", "setuserinfo" },
{ "password", _password },
{ "enrollid", enrollId },
{ "name", name ?? enrollId.ToString() },
{ "department", "" },
{ "shiftid", 1 },
{ "admin", 0 },
{ "pwd", 0 },
{ "card", 0 },
{ "zoneid", 0 },
{ "groupid", 0 },
{ "access_times", 0 },
{ "verifymode", 0 },
{ "birthday", "" },
{ "starttime", "" },
{ "endtime", "" },
{ "userprofile", "" }
});
if (IsSuccess(resp)) return true;
error = GetMsg(resp) ?? "setuserinfo failed";
return false;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
public bool TrySetFaceTemplateRecord(int enrollId, string templatePayload, string pushMode, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(templatePayload))
{
error = "template record is empty";
return false;
}
try
{
var payload = new Dictionary<string, object>
{
{ "cmd", "setuserinfo" },
{ "password", _password },
{ "enrollid", enrollId }
};
if (string.Equals(pushMode, "face", StringComparison.OrdinalIgnoreCase))
{
payload["face"] = templatePayload;
}
else
{
payload["backupnum"] = GetIntSetting("DEVICE_FACE_BACKUPNUM", 50);
payload["record"] = templatePayload;
}
var resp = Post(payload);
if (IsSuccess(resp)) return true;
error = GetMsg(resp) ?? "setuserinfo template push failed";
return false;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
public bool TryGetFaceRecord(int enrollId, out string recordBase64, out string name, out string error)
{
recordBase64 = null;
@ -231,6 +346,43 @@ namespace HanvonF710XAttendanceService
}
}
public bool TryRestoreUserWithFaceTemplate(int enrollId, string name, string templatePayload, string pushMode, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(templatePayload))
{
error = "template record is empty";
return false;
}
if (!TryEnsureUser(enrollId, name, out error))
{
return false;
}
int delayMs = GetIntSetting("TEMPLATE_DEVICE_DELAY_MS", 300);
if (delayMs > 0)
{
System.Threading.Thread.Sleep(delayMs);
}
if (!TrySetFaceTemplateRecord(enrollId, templatePayload, pushMode, out error))
{
string altMode = string.Equals(pushMode, "face", StringComparison.OrdinalIgnoreCase) ? "record" : "face";
if (!TrySetFaceTemplateRecord(enrollId, templatePayload, altMode, out error))
{
return false;
}
}
return true;
}
public bool UserHasFaceTemplate(int enrollId, out string error)
{
return TryGetFaceRecord(enrollId, out _, out _, out error);
}
private Dictionary<string, object> Post(Dictionary<string, object> payload)
{
string body = _json.Serialize(payload);

View File

@ -128,24 +128,50 @@ namespace HanvonF710XAttendanceService
continue;
}
if (mode == TemplateTransferMode.DB_TO_DEVICE)
{
List<string> idsForTransfer;
if (job.EmpIds != null && job.EmpIds.Count > 0)
{
// Restore from DB even when user was deleted from source device.
idsForTransfer = job.EmpIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
logs.Add($"[TemplateJob {job.Id}] DB_TO_DEVICE using EmpIds from config (source device list skipped): {string.Join(",", idsForTransfer)}");
}
else
{
if (!TryGetEmployeeIdsFromDevice(sourceMachine, out var employeeIds, logs, job.Id))
{
continue;
}
if (employeeIds.Count == 0)
{
logs.Add($"[TemplateJob {job.Id}] Source device returned 0 employee IDs.");
continue;
}
if (mode == TemplateTransferMode.DB_TO_DEVICE)
{
TransferDbToDevice(job, employeeIds, targetMachines, connection, logs);
idsForTransfer = employeeIds;
}
else if (mode == TemplateTransferMode.DEVICE_TO_DEVICE)
TransferDbToDevice(job, idsForTransfer, targetMachines, connection, logs);
continue;
}
if (!TryGetEmployeeIdsFromDevice(sourceMachine, out var deviceEmployeeIds, logs, job.Id))
{
TransferDeviceToDevice(job.Id, sourceMachine, employeeIds, targetMachines, logs);
continue;
}
if (deviceEmployeeIds.Count == 0)
{
logs.Add($"[TemplateJob {job.Id}] Source device returned 0 employee IDs.");
continue;
}
if (mode == TemplateTransferMode.DEVICE_TO_DEVICE)
{
TransferDeviceToDevice(job.Id, sourceMachine, deviceEmployeeIds, targetMachines, logs);
}
}
}
@ -678,9 +704,6 @@ namespace HanvonF710XAttendanceService
foreach (var target in targets)
{
string targetDevInfo = target.Machine.GetDeviceInfo();
var callback = new CallBack(Program.BeCalled);
int ok = 0;
int fail = 0;
int verifyOk = 0;
@ -688,6 +711,100 @@ namespace HanvonF710XAttendanceService
logs.Add($"[TemplateTransfer] job={job.Id} target_ip={target.Machine.MachineIp} total_templates={totalTemplates} batch_size={batchSize} total_batches={batches.Count}");
if (DeviceProtocol.UseHttp())
{
var httpClient = HanvonHttpApiClient.ForMachine(target.Machine);
if (!httpClient.TryLogin(out string loginErr))
{
logs.Add($"[TemplateJob {job.Id}] HTTP login failed for target. ip={target.Machine.MachineIp} err={loginErr}");
Program.RecordUnreachableMachine(target.Machine, "DB_TO_DEVICE HTTP login: " + loginErr);
continue;
}
var machineUserDao = new AttendanceMachineUserDAO();
for (int bi = 0; bi < batches.Count; bi++)
{
var swBatch = Stopwatch.StartNew();
int bAttempted = 0;
int bOk = 0;
int bFail = 0;
int bVerifyOk = 0;
int bVerifyFail = 0;
foreach (var kvp in batches[bi])
{
bAttempted++;
string empId = kvp.Key;
byte[] templateBlob = kvp.Value ?? Array.Empty<byte>();
string recordBase64 = HanvonHttpApiClient.NormalizeFaceTemplatePayload(templateBlob, out string pushMode);
if (string.IsNullOrWhiteSpace(recordBase64))
{
fail++;
bFail++;
logs.Add($"[TemplateJob {job.Id}] Empty template payload. emp={empId} blob_bytes={templateBlob.Length} target={target.Machine.MachineIp}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL reason=EmptyTemplate blob_bytes={templateBlob.Length}");
continue;
}
if (!int.TryParse(empId, out int enrollId))
{
fail++;
bFail++;
logs.Add($"[TemplateJob {job.Id}] Invalid enroll id for HTTP setuserinfo. emp={empId} target={target.Machine.MachineIp}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL reason=InvalidEnrollId");
continue;
}
string displayName = machineUserDao.GetEmployeeNameForMachine(connection, target.Machine.MachineId, empId)
?? empId;
if (!httpClient.TryRestoreUserWithFaceTemplate(enrollId, displayName, recordBase64, pushMode, out string pushErr))
{
fail++;
bFail++;
logs.Add($"[TemplateJob {job.Id}] HTTP template push failed. emp={empId} mode={pushMode} payload_len={recordBase64.Length} target={target.Machine.MachineIp} err={pushErr}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} mode={pushMode} payload_len={recordBase64.Length} send=FAIL err={pushErr}");
continue;
}
ok++;
bOk++;
logs.Add($"[TemplateJob {job.Id}] HTTP template push ok. emp={empId} name={displayName} mode={pushMode} payload_len={recordBase64.Length} target={target.Machine.MachineIp}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=OK");
if (verifyAfterPush)
{
if (httpClient.UserHasFaceTemplate(enrollId, out string verifyErr))
{
verifyOk++;
bVerifyOk++;
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=OK");
}
else
{
verifyFail++;
bVerifyFail++;
logs.Add($"[TemplateJob {job.Id}] HTTP verify failed after push. emp={empId} target={target.Machine.MachineIp} err={verifyErr}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=FAIL err={verifyErr}");
}
}
}
if (delayMs > 0)
{
Thread.Sleep(delayMs);
}
swBatch.Stop();
logs.Add($"[TemplateTransferBatch] job={job.Id} target_ip={target.Machine.MachineIp} batch={bi + 1}/{batches.Count} attempted={bAttempted} ok={bOk} fail={bFail} verify_ok={bVerifyOk} verify_fail={bVerifyFail} duration_sec={Math.Round(swBatch.Elapsed.TotalSeconds, 1)}");
}
}
else
{
string targetDevInfo = target.Machine.GetDeviceInfo();
var callback = new CallBack(Program.BeCalled);
for (int bi = 0; bi < batches.Count; bi++)
{
var swBatch = Stopwatch.StartNew();
@ -752,12 +869,13 @@ namespace HanvonF710XAttendanceService
if (delayMs > 0)
{
System.Threading.Thread.Sleep(delayMs);
Thread.Sleep(delayMs);
}
swBatch.Stop();
logs.Add($"[TemplateTransferBatch] job={job.Id} target_ip={target.Machine.MachineIp} batch={bi + 1}/{batches.Count} attempted={bAttempted} ok={bOk} fail={bFail} verify_ok={bVerifyOk} verify_fail={bVerifyFail} duration_sec={Math.Round(swBatch.Elapsed.TotalSeconds, 1)}");
}
}
if (verifyAfterPush)
{