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" /> providerName="MySql.Data.MySqlClient" />
</connectionStrings> </connectionStrings>
<appSettings> <appSettings>
<add key="ATTENDANCE_SCOPE" value="CENTRAL" /> <!-- <add key="ATTENDANCE_SCOPE" value="CENTRAL" /> -->
<!--<add key="ATTENDANCE_SCOPE" value="SITE" />--> <add key="ATTENDANCE_SCOPE" value="SITE" />
<add key="ATTENDANCE_JOB_INTERVAL_MINUTES" value="20" /> <add key="ATTENDANCE_JOB_INTERVAL_MINUTES" value="20" />
<add key="TEMPLATE_JOB_INTERVAL_MINUTES" value="240" /> <add key="TEMPLATE_JOB_INTERVAL_MINUTES" value="240" />
<add key="TEMPLATE_SYNC_ENABLED" value="true" /> <add key="TEMPLATE_SYNC_ENABLED" value="true" />
<add key="TRANSFER_MODE" value="DEVICE_TO_DB" /> <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="DEVICE_SETTINGS_PATH" value="DeviceSettings.xml" />
<add key="TEMPLATE_VERIFY_AFTER_PUSH" value="true" /> <add key="TEMPLATE_VERIFY_AFTER_PUSH" value="true" />
<add key="TEMPLATE_DEVICE_DELAY_MS" value="0" /> <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) private List<AttendanceMachineUser> getDBUsers(MySqlDataReader reader)
{ {
List<AttendanceMachineUser> DbUsers = new List<AttendanceMachineUser>(); List<AttendanceMachineUser> DbUsers = new List<AttendanceMachineUser>();

View File

@ -1,13 +1,13 @@
<DeviceSettings Mode="Multi" SiteId="2"> <DeviceSettings Mode="Multi" SiteId="2">
<!--<Device IpAddress="192.168.70.14" Enabled="true" />--> <!--<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" <Job Id="1"
Enabled="true" Enabled="true"
SourceIp="192.168.86.7" SourceIp="192.168.90.89"
EmpIds="7044"> EmpIds="15399,17003">
<Target Ip="192.168.86.4" /> <Target Ip="192.168.90.89" />
</Job> </Job>
</TemplateTransfer> </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) public bool TryGetFaceRecord(int enrollId, out string recordBase64, out string name, out string error)
{ {
recordBase64 = null; 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) private Dictionary<string, object> Post(Dictionary<string, object> payload)
{ {
string body = _json.Serialize(payload); string body = _json.Serialize(payload);

View File

@ -128,24 +128,50 @@ namespace HanvonF710XAttendanceService
continue; continue;
} }
if (!TryGetEmployeeIdsFromDevice(sourceMachine, out var employeeIds, logs, job.Id)) 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;
}
idsForTransfer = employeeIds;
}
TransferDbToDevice(job, idsForTransfer, targetMachines, connection, logs);
continue;
}
if (!TryGetEmployeeIdsFromDevice(sourceMachine, out var deviceEmployeeIds, logs, job.Id))
{ {
continue; continue;
} }
if (employeeIds.Count == 0) if (deviceEmployeeIds.Count == 0)
{ {
logs.Add($"[TemplateJob {job.Id}] Source device returned 0 employee IDs."); logs.Add($"[TemplateJob {job.Id}] Source device returned 0 employee IDs.");
continue; continue;
} }
if (mode == TemplateTransferMode.DB_TO_DEVICE) if (mode == TemplateTransferMode.DEVICE_TO_DEVICE)
{ {
TransferDbToDevice(job, employeeIds, targetMachines, connection, logs); TransferDeviceToDevice(job.Id, sourceMachine, deviceEmployeeIds, targetMachines, logs);
}
else if (mode == TemplateTransferMode.DEVICE_TO_DEVICE)
{
TransferDeviceToDevice(job.Id, sourceMachine, employeeIds, targetMachines, logs);
} }
} }
} }
@ -678,9 +704,6 @@ namespace HanvonF710XAttendanceService
foreach (var target in targets) foreach (var target in targets)
{ {
string targetDevInfo = target.Machine.GetDeviceInfo();
var callback = new CallBack(Program.BeCalled);
int ok = 0; int ok = 0;
int fail = 0; int fail = 0;
int verifyOk = 0; int verifyOk = 0;
@ -688,53 +711,71 @@ namespace HanvonF710XAttendanceService
logs.Add($"[TemplateTransfer] job={job.Id} target_ip={target.Machine.MachineIp} total_templates={totalTemplates} batch_size={batchSize} total_batches={batches.Count}"); logs.Add($"[TemplateTransfer] job={job.Id} target_ip={target.Machine.MachineIp} total_templates={totalTemplates} batch_size={batchSize} total_batches={batches.Count}");
for (int bi = 0; bi < batches.Count; bi++) if (DeviceProtocol.UseHttp())
{ {
var swBatch = Stopwatch.StartNew(); var httpClient = HanvonHttpApiClient.ForMachine(target.Machine);
int bAttempted = 0; if (!httpClient.TryLogin(out string loginErr))
int bOk = 0;
int bFail = 0;
int bVerifyOk = 0;
int bVerifyFail = 0;
foreach (var kvp in batches[bi])
{ {
bAttempted++; logs.Add($"[TemplateJob {job.Id}] HTTP login failed for target. ip={target.Machine.MachineIp} err={loginErr}");
string empId = kvp.Key; Program.RecordUnreachableMachine(target.Machine, "DB_TO_DEVICE HTTP login: " + loginErr);
string templateStr = Encoding.UTF8.GetString(kvp.Value ?? Array.Empty<byte>()); continue;
if (!TemplateParser.TryBuildSetEmployeeCommand(templateStr, out var setCmd)) }
{
fail++;
bFail++;
logs.Add($"[TemplateJob {job.Id}] Build SetEmployee failed. emp={empId} target={target.Machine.MachineIp}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL reason=BuildSetEmployee");
continue;
}
string response = ""; var machineUserDao = new AttendanceMachineUserDAO();
uint recvLen = 0;
int rc = Program.test(targetDevInfo, targetDevInfo.Length, setCmd, setCmd.Length, ref response, ref recvLen, callback); for (int bi = 0; bi < batches.Count; bi++)
if (rc != 0 || (response != null && response.IndexOf("fail", StringComparison.OrdinalIgnoreCase) >= 0)) {
{ var swBatch = Stopwatch.StartNew();
fail++; int bAttempted = 0;
bFail++; int bOk = 0;
logs.Add($"[TemplateJob {job.Id}] SetEmployee failed. emp={empId} target={target.Machine.MachineIp}"); int bFail = 0;
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL rc={rc}"); int bVerifyOk = 0;
} int bVerifyFail = 0;
else
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++; ok++;
bOk++; 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"); Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=OK");
if (verifyAfterPush) if (verifyAfterPush)
{ {
string verifyCmd = "GetEmployee(id=\"" + empId + "\")"; if (httpClient.UserHasFaceTemplate(enrollId, out string verifyErr))
string verifyResp = "";
uint verifyRecvLen = 0;
int vrc = Program.test(targetDevInfo, targetDevInfo.Length, verifyCmd, verifyCmd.Length, ref verifyResp, ref verifyRecvLen, callback);
bool success = vrc == 0 && verifyResp != null && verifyResp.IndexOf("success", StringComparison.OrdinalIgnoreCase) >= 0;
if (success)
{ {
verifyOk++; verifyOk++;
bVerifyOk++; bVerifyOk++;
@ -744,19 +785,96 @@ namespace HanvonF710XAttendanceService
{ {
verifyFail++; verifyFail++;
bVerifyFail++; bVerifyFail++;
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=FAIL rc={vrc}"); 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) 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++)
{ {
System.Threading.Thread.Sleep(delayMs); var swBatch = Stopwatch.StartNew();
} int bAttempted = 0;
int bOk = 0;
int bFail = 0;
int bVerifyOk = 0;
int bVerifyFail = 0;
swBatch.Stop(); foreach (var kvp in batches[bi])
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)}"); {
bAttempted++;
string empId = kvp.Key;
string templateStr = Encoding.UTF8.GetString(kvp.Value ?? Array.Empty<byte>());
if (!TemplateParser.TryBuildSetEmployeeCommand(templateStr, out var setCmd))
{
fail++;
bFail++;
logs.Add($"[TemplateJob {job.Id}] Build SetEmployee failed. emp={empId} target={target.Machine.MachineIp}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL reason=BuildSetEmployee");
continue;
}
string response = "";
uint recvLen = 0;
int rc = Program.test(targetDevInfo, targetDevInfo.Length, setCmd, setCmd.Length, ref response, ref recvLen, callback);
if (rc != 0 || (response != null && response.IndexOf("fail", StringComparison.OrdinalIgnoreCase) >= 0))
{
fail++;
bFail++;
logs.Add($"[TemplateJob {job.Id}] SetEmployee failed. emp={empId} target={target.Machine.MachineIp}");
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=FAIL rc={rc}");
}
else
{
ok++;
bOk++;
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} send=OK");
if (verifyAfterPush)
{
string verifyCmd = "GetEmployee(id=\"" + empId + "\")";
string verifyResp = "";
uint verifyRecvLen = 0;
int vrc = Program.test(targetDevInfo, targetDevInfo.Length, verifyCmd, verifyCmd.Length, ref verifyResp, ref verifyRecvLen, callback);
bool success = vrc == 0 && verifyResp != null && verifyResp.IndexOf("success", StringComparison.OrdinalIgnoreCase) >= 0;
if (success)
{
verifyOk++;
bVerifyOk++;
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=OK");
}
else
{
verifyFail++;
bVerifyFail++;
Program.WriteInternalLog($"[DB_TO_DEVICE] job={job.Id} target={target.Machine.MachineIp} emp={empId} verify=FAIL rc={vrc}");
}
}
}
}
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)}");
}
} }
if (verifyAfterPush) if (verifyAfterPush)