Refactor attendance scheduler and move cursor/state handling to DB-driven flow.

1) Immediate startup attendance run
2) Shared real fetch cycle path with skip-reason logs
3) last_sync_date read/write from DB
4) Preserve cursor on status updates
5) IP-based machine matching
6) zero-record cursor advance to window end
7) CSV write lock/file-sharing fix
main
SYED MUSTUFA AHMED NAQVI 2026-04-14 17:17:05 +05:00
parent 93b2bf9bd7
commit 33c2dfabb3
1 changed files with 466 additions and 92 deletions

View File

@ -15,6 +15,7 @@ using System.Web.Script.Serialization;
using EventByDeploy;
using Common;
using HikvisionAttendanceService.Interop;
using HikvisionAttendanceService.Data;
using CHCNetSDK = EventByDeploy.CHCNetSDK;
namespace HikvisionAttendanceService;
@ -38,6 +39,12 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
private readonly string _attendanceTextPath;
private readonly List<DeviceSession> _sessions = new List<DeviceSession>();
private readonly List<HikvisionAttendanceWindowsService.DeviceConfig> _runtimeDevices = new List<HikvisionAttendanceWindowsService.DeviceConfig>();
private readonly IHrmsDbConnectionFactory? _dbConnectionFactory;
private readonly IAttendanceMachineRepository? _attendanceMachineRepository;
private readonly IAttendanceLogRepository? _attendanceLogRepository;
private readonly IAttendanceMachineUserRepository? _attendanceMachineUserRepository;
private readonly IAttendanceMachineFaceTemplateRepository? _attendanceMachineFaceTemplateRepository;
private CancellationTokenSource _cts;
private Task _queueWriterTask;
@ -69,6 +76,16 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
if (!string.IsNullOrEmpty(textDir))
Directory.CreateDirectory(textDir);
if (_config.EnableDbIntegration)
{
_dbConnectionFactory = new MySqlHrmsDbConnectionFactory(_config);
_attendanceMachineRepository = new MySqlAttendanceMachineRepository(_dbConnectionFactory);
_attendanceLogRepository = new MySqlAttendanceLogRepository(_dbConnectionFactory);
_attendanceMachineUserRepository = new MySqlAttendanceMachineUserRepository(_dbConnectionFactory);
_attendanceMachineFaceTemplateRepository = new MySqlAttendanceMachineFaceTemplateRepository(_dbConnectionFactory);
_logger.Info("DB integration enabled; connection=" + _dbConnectionFactory.BuildConnectionStringMasked());
}
EnsureCsvSchema();
}
@ -101,6 +118,52 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
}
}
private List<HikvisionAttendanceWindowsService.DeviceConfig> ResolveRuntimeDevices()
{
_runtimeDevices.Clear();
bool loadedFromDb = false;
if (_config.EnableDbIntegration && _config.EnableDbMachineLoading && _attendanceMachineRepository != null)
{
var dbMachines = _attendanceMachineRepository.GetActiveMachines("HIKVISION", out var dbErr);
if (!string.IsNullOrWhiteSpace(dbErr))
_logger.Warn("DB machine loading failed; fallback to config. err=" + dbErr);
else
{
foreach (var m in dbMachines)
{
var matchingCfg = (_config.Devices ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>())
.FirstOrDefault(x =>
string.Equals(x.Ip, m.MachineIp, StringComparison.OrdinalIgnoreCase) ||
string.Equals(DeviceIdentity.CanonicalLookupKey(x.DeviceId), DeviceIdentity.CanonicalLookupKey(m.MachineId), StringComparison.Ordinal));
if (matchingCfg == null)
continue;
_runtimeDevices.Add(new HikvisionAttendanceWindowsService.DeviceConfig
{
DeviceId = string.IsNullOrWhiteSpace(m.MachineId) ? matchingCfg.DeviceId : m.MachineId,
Ip = m.MachineIp,
Port = m.PortNumber > 0 ? m.PortNumber : matchingCfg.Port,
Username = matchingCfg.Username,
Password = matchingCfg.Password,
FingerPrintReaderNo = matchingCfg.FingerPrintReaderNo,
FaceReaderNo = matchingCfg.FaceReaderNo,
GatewayDoorIndex = matchingCfg.GatewayDoorIndex,
Model = matchingCfg.Model,
SerialNumber = matchingCfg.SerialNumber,
FirmwareVersion = matchingCfg.FirmwareVersion,
SubnetMask = matchingCfg.SubnetMask,
DefaultGateway = matchingCfg.DefaultGateway
});
}
loadedFromDb = _runtimeDevices.Count > 0;
_logger.Info("DB machine loading: loaded " + _runtimeDevices.Count + " active Hikvision machines.");
}
}
if (!_config.PreferDbMachinesOverConfig || !loadedFromDb)
return _config.Devices ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>();
return _runtimeDevices;
}
/// <summary>Main service loop: SDK init, ACS alarm deploy, optional scheduled historical fetch.</summary>
public async Task RunAsync(CancellationToken token)
{
@ -200,19 +263,26 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
// CLI/manual fetches should also advance the last-sync cursor,
// otherwise incremental sync won't work until the scheduled loop runs.
string filePath = GetLastSyncFilePath(deviceId);
var lastSyncStore = (_config.EnableDbIntegration && _attendanceMachineRepository != null)
? "db.attendance_machine.last_sync_date"
: "file." + GetLastSyncFilePath(deviceId);
_logger.Info("LastSync write check (CLI/manual fetch): device=" + deviceId + ", n=" + n +
", lastEventTimestamp=" + (lastEventTimestamp.HasValue ? lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(null)") +
", filePath=" + filePath);
", store=" + lastSyncStore);
if (n > 0 && lastEventTimestamp.HasValue &&
(!_config.EnableDatabasePersistence || dbInsertAllSucceeded))
if (n > 0 && lastEventTimestamp.HasValue && dbInsertAllSucceeded)
{
WriteLastSyncTimestamp(deviceId, lastEventTimestamp.Value);
_logger.Info("LastSync updated (CLI/manual fetch): device=" + deviceId +
", lastEventTimestamp=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss"));
}
else if (n > 0 && lastEventTimestamp.HasValue && _config.EnableDatabasePersistence && !dbInsertAllSucceeded)
else if (n == 0 && dbInsertAllSucceeded)
{
WriteLastSyncTimestamp(deviceId, toLocal);
_logger.Info("No records found; advancing last_sync_date to window end. machine=" + deviceId +
" timestamp=" + toLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
}
else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded)
{
_logger.Warn("LastSync NOT updated (CLI/manual fetch): DB insert did not fully succeed; will retry same window.");
_logger.JobWarn("attendance", "Cleanup skipped: DB insert not confirmed; delete not executed.");
@ -1123,28 +1193,30 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
}
}
var devicesToLogin = ResolveRuntimeDevices();
// Startup diagnostics: prove whether we have devices to login.
if (_config.Devices == null)
if (devicesToLogin == null)
{
_logger.Warn("StartDevices: config.Devices is NULL; skipping all device logins (active sessions will remain 0).");
return;
}
_logger.Info("StartDevices: devicesToLoginCount=" + _config.Devices.Count);
if (_config.Devices.Count == 0)
_logger.Info("StartDevices: devicesToLoginCount=" + devicesToLogin.Count);
if (devicesToLogin.Count == 0)
{
_logger.Warn("StartDevices: Devices is empty; skipping all device logins (active sessions will remain 0).");
return;
}
for (int i = 0; i < _config.Devices.Count; i++)
for (int i = 0; i < devicesToLogin.Count; i++)
{
var d = _config.Devices[i];
var d = devicesToLogin[i];
_logger.Info("StartDevices: loadedDevice[" + i + "]: DeviceId=\"" + (d.DeviceId ?? "") + "\" Ip=\"" + (d.Ip ?? "") + "\" Port=" + d.Port +
" Username=\"" + (d.Username ?? "") + "\"");
}
foreach (var device in _config.Devices)
foreach (var device in devicesToLogin)
{
_logger.Info("NET_DVR_Login_V30: connecting " + device.Ip + ":" + device.Port + " user=" + device.Username + " (" + device.DeviceId + ")...");
@ -1161,6 +1233,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
var err = Common.CHCNetSDK.NET_DVR_GetLastError();
LogSdkFailure("NET_DVR_Login_V30", err, device.DeviceId, device.Ip,
"Device login failed on port " + device.Port + ". Check device IP/network/credentials.", isWarning: false);
// Status update must NOT touch last_sync_date (cursor).
TryUpdateMachineRuntimeState(device.Ip, "NOT CONNECTED", null, null);
continue;
}
@ -1208,6 +1282,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
_sessions.Add(new DeviceSession(device, userId, alarmHandle));
_logger.Info("Session REGISTERED: deviceId=" + device.DeviceId + " userId=" + userId + " alarmHandle=" + alarmHandle +
(alarmHandle < 0 ? " (no live alarm; fetch still allowed)" : ""));
// Status update must NOT touch last_sync_date (cursor).
TryUpdateMachineRuntimeState(device.Ip, "IDLE", null, null);
}
}
@ -1241,6 +1317,8 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
{
Common.CHCNetSDK.NET_DVR_Logout_V30(s.UserId);
}
// Status update must NOT touch last_sync_date (cursor).
TryUpdateMachineRuntimeState(s.Device.Ip, "NOT CONNECTED", null, null);
}
catch (Exception ex)
{
@ -1259,7 +1337,11 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
if (intervalMinutes <= 0)
intervalMinutes = 5;
bool firstRun = true;
while (!token.IsCancellationRequested)
{
bool isImmediateStartupRun = firstRun;
if (!firstRun)
{
try
{
@ -1269,26 +1351,79 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
{
break;
}
}
firstRun = false;
ExecuteAttendanceFetchCycle(token, isImmediateStartupRun);
}
}
private void ExecuteAttendanceFetchCycle(CancellationToken token, bool isImmediateStartupRun)
{
LogJobCycleStart("attendance", "ATTENDANCE", "--Attendance job started at ");
if (isImmediateStartupRun)
_logger.JobInfo("attendance", "Attendance immediate startup fetch triggered.");
_logger.JobInfo("attendance", "Entering real attendance fetch loop.");
var runtimeDevices = ResolveRuntimeDevices() ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>();
var sessionsSnapshot = _sessions.ToArray();
_logger.JobInfo("attendance", "Device/session count being processed: runtimeDevices=" + runtimeDevices.Count + ", activeSessions=" + sessionsSnapshot.Length);
if (runtimeDevices.Count == 0)
_logger.JobWarn("attendance", "Attendance fetch skipped reason=no runtime devices resolved.");
if (sessionsSnapshot.Length == 0)
_logger.JobWarn("attendance", "Attendance fetch skipped reason=no active device sessions.");
if (runtimeDevices.Count == 0 || sessionsSnapshot.Length == 0)
{
LogJobCycleEnd("attendance", "--Attendance job finished at ");
return;
}
var runtimeKeys = new HashSet<string>(
runtimeDevices.Select(d => DeviceIdentity.CanonicalLookupKey(d.DeviceId)).Where(x => !string.IsNullOrWhiteSpace(x)),
StringComparer.Ordinal);
var runtimeIps = new HashSet<string>(
runtimeDevices.Select(d => (d.Ip ?? "").Trim()).Where(x => x.Length > 0),
StringComparer.OrdinalIgnoreCase);
foreach (var d in runtimeDevices)
{
var key = DeviceIdentity.CanonicalLookupKey(d.DeviceId);
var ip = (d.Ip ?? "").Trim();
bool hasSessionById = key.Length > 0 && sessionsSnapshot.Any(s => DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == key);
bool hasSessionByIp = ip.Length > 0 && sessionsSnapshot.Any(s => string.Equals((s.Device.Ip ?? "").Trim(), ip, StringComparison.OrdinalIgnoreCase));
if (!hasSessionById && !hasSessionByIp)
_logger.JobWarn("attendance", "Attendance fetch skipped reason=DB machine loaded but session missing machine=" + d.DeviceId + " ip=" + (d.Ip ?? ""));
}
var to = DateTime.Now;
// Fallback window used only for the first sync (when last-sync file is missing) or if last-sync is unreadable.
var fallbackFrom = to.AddMinutes(-_config.HistoricalFetchLookbackMinutes);
if (_config.HistoricalFetchLookbackMinutes <= 0)
fallbackFrom = to.AddDays(-1);
foreach (var s in _sessions.ToArray())
int processedSessions = 0;
foreach (var s in sessionsSnapshot)
{
try
{
var sessionKey = DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId);
var sessionIp = (s.Device.Ip ?? "").Trim();
bool eligibleById = sessionKey.Length > 0 && runtimeKeys.Contains(sessionKey);
bool eligibleByIp = sessionIp.Length > 0 && runtimeIps.Contains(sessionIp);
if ((runtimeKeys.Count > 0 || runtimeIps.Count > 0) && !eligibleById && !eligibleByIp)
{
_logger.JobWarn("attendance", "Attendance fetch skipped reason=no matching machine/session found for session device=" + s.Device.DeviceId +
" sessionIp=" + sessionIp);
continue;
}
processedSessions++;
var lastSync = ReadLastSyncTimestamp(s.Device.DeviceId, out var lastSyncReadReason);
DateTime from;
if (lastSync.HasValue)
from = lastSync.Value.AddSeconds(1);
else
from = fallbackFrom;
var from = lastSync.HasValue ? lastSync.Value.AddSeconds(1) : fallbackFrom;
_logger.JobInfo("attendance", "Attendance fetch window: device=" + s.Device.DeviceId +
" from=" + from.ToString("yyyy-MM-dd HH:mm:ss") +
" to=" + to.ToString("yyyy-MM-dd HH:mm:ss") +
" lastSync=" + (lastSync.HasValue ? lastSync.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(none)") +
(string.IsNullOrEmpty(lastSyncReadReason) ? "" : " lastSyncReason=" + lastSyncReadReason));
if (from > to)
{
_logger.Info("Scheduled historical ACS fetch skipped (from > to): device=" + s.Device.DeviceId +
@ -1309,9 +1444,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
" window=" + from.ToString("yyyy-MM-dd HH:mm:ss") + ".." + to.ToString("yyyy-MM-dd HH:mm:ss"));
_logger.JobInfo("attendance", "MACHINE " + s.Device.DeviceId + " has " + n + " records !");
// Update last sync only when we actually parsed at least one event and we have a usable event timestamp.
if (n > 0 && lastEventTimestamp.HasValue &&
(!_config.EnableDatabasePersistence || dbInsertAllSucceeded))
if (n > 0 && lastEventTimestamp.HasValue && dbInsertAllSucceeded)
{
WriteLastSyncTimestamp(s.Device.DeviceId, lastEventTimestamp.Value);
if (_config.EnableDatabasePersistence && dbInsertAllSucceeded)
@ -1331,7 +1464,13 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
}
}
}
else if (n > 0 && lastEventTimestamp.HasValue && _config.EnableDatabasePersistence && !dbInsertAllSucceeded)
else if (n == 0 && dbInsertAllSucceeded)
{
WriteLastSyncTimestamp(s.Device.DeviceId, to);
_logger.JobInfo("attendance", "No records found; advancing last_sync_date to window end. machine=" + s.Device.DeviceId +
" timestamp=" + to.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
}
else if ((n > 0 && lastEventTimestamp.HasValue && !dbInsertAllSucceeded) || !dbInsertAllSucceeded)
{
_logger.JobWarn("attendance", "LastSync NOT updated for machine=" + s.Device.DeviceId +
" because DB insert failed; same data window will be retried.");
@ -1344,9 +1483,11 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
_logger.JobError("attendance", "Scheduled fetch FAILED device=" + s.Device.DeviceId + " err=" + ex.Message);
}
}
if (processedSessions == 0)
_logger.JobWarn("attendance", "Attendance fetch method returned early due to guard condition: no eligible sessions to process.");
LogJobCycleEnd("attendance", "--Attendance job finished at ");
}
}
private async Task TemplateFetchSchedulerLoop(CancellationToken token)
{
@ -1378,6 +1519,7 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
_logger.JobInfo("template_fetch", "Start device=" + s.Device.DeviceId + " outDir=\"" + outDir + "\"");
string jsonPath;
int discoveredUsers;
string err;
bool ok = TryExportAllUsersTemplatesToIsapiFile(
s.Device.DeviceId,
@ -1386,10 +1528,16 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
5000,
token,
out jsonPath,
out discoveredUsers,
out err);
if (ok)
{
_logger.JobInfo("template_fetch", "DONE device=" + s.Device.DeviceId + " jsonPath=\"" + jsonPath + "\"");
TryUpdateMachineRuntimeState(s.Device.Ip, "IDLE", null, discoveredUsers);
_logger.JobInfo("template_fetch", "DB attendance_machine.total_users updated: machine_ip=" + (s.Device.Ip ?? "") +
" total_users=" + discoveredUsers);
}
else
_logger.JobError("template_fetch", "FAILED device=" + s.Device.DeviceId + " err=" + err);
}
@ -1439,11 +1587,17 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
lock (_csvWriteLock)
{
File.AppendAllText(_csvPath, ToCsvLine(ev) + Environment.NewLine, Encoding.UTF8);
AppendAttendanceCsvLineSafely(ToCsvLine(ev));
}
if (_config.KeepAttendanceFileExport || !_config.EnableAttendanceDbPersistence)
AppendAttendanceToTextFileSafely(ev);
dbInserted = WriteAttendanceToDatabase(ev);
if (dbInserted)
{
// Keep status updated but do not overwrite last_sync_date cursor (that is handled by WriteLastSyncTimestamp).
TryUpdateMachineRuntimeState(ev.DeviceIp ?? "", "synced", null, null);
}
return true;
}
@ -1489,6 +1643,30 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
private DateTime? ReadLastSyncTimestamp(string deviceId, out string reason)
{
reason = "";
if (_config.EnableDbIntegration && _attendanceMachineRepository != null)
{
var machineIp = ResolveMachineIpForDbLookup(deviceId);
if (string.IsNullOrWhiteSpace(machineIp))
{
reason = "db lookup skipped: machine_ip unresolved for deviceId=" + deviceId;
return null;
}
if (_attendanceMachineRepository.TryGetMachineLastSyncDate(machineIp, out var dbLastSync, out var dbErr))
{
if (dbLastSync.HasValue)
{
reason = "db.last_sync_date(machine_ip=" + machineIp + ")";
return dbLastSync.Value;
}
reason = "db.last_sync_date is null (machine_ip=" + machineIp + ")";
return null;
}
reason = "db last_sync_date read failed (machine_ip=" + machineIp + "): " + dbErr;
return null;
}
try
{
var path = GetLastSyncFilePath(deviceId);
@ -1525,6 +1703,20 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
private void WriteLastSyncTimestamp(string deviceId, DateTime timestampLocal)
{
if (_config.EnableDbIntegration && _attendanceMachineRepository != null)
{
var localValue = NormalizeForDbLocalTime(timestampLocal);
var machineIp = ResolveMachineIpForDbLookup(deviceId);
if (string.IsNullOrWhiteSpace(machineIp))
{
_logger.Warn("last_sync_date DB update skipped: machine_ip unresolved for deviceId=" + deviceId);
return;
}
if (!_attendanceMachineRepository.UpdateMachineLastSyncDate(machineIp, localValue, out var dbErr))
_logger.Warn("last_sync_date DB update failed machine_ip=" + machineIp + " err=" + dbErr);
return;
}
var path = GetLastSyncFilePath(deviceId);
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? _config.LogDirectory);
var raw = timestampLocal.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
@ -2680,6 +2872,65 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
return false;
}
private static bool TryExtractNameFromUserInfoSearch(string userInfoJson, string employeeNo, out string name)
{
name = "";
if (string.IsNullOrWhiteSpace(userInfoJson))
return false;
try
{
var ser = new JavaScriptSerializer();
object? root = ser.DeserializeObject(userInfoJson);
if (root == null)
return false;
var stack = new Stack<object>();
stack.Push(root);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (cur is Dictionary<string, object> d)
{
bool employeeMatches = false;
if (d.TryGetValue("employeeNo", out var enoObj) && enoObj != null)
{
var eno = enoObj.ToString() ?? "";
employeeMatches = string.Equals(eno.Trim(), employeeNo?.Trim(), StringComparison.OrdinalIgnoreCase);
}
if (employeeMatches && d.TryGetValue("name", out var nameObj) && nameObj is string s && !string.IsNullOrWhiteSpace(s))
{
name = s.Trim();
return true;
}
foreach (var kv in d)
{
if (kv.Value is Dictionary<string, object> nd)
stack.Push(nd);
else if (kv.Value is object[] arr)
foreach (var it in arr)
if (it != null)
stack.Push(it);
}
}
else if (cur is object[] arr2)
{
foreach (var it in arr2)
if (it != null)
stack.Push(it);
}
}
}
catch
{
// best effort only
}
return false;
}
private string CallCardInfoApi(int userId, string uri, string jsonBody, out string sdkError)
{
return StdXmlCall(userId, "POST", uri, jsonBody, out sdkError);
@ -3689,9 +3940,11 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
int maxUsers,
CancellationToken cancellationToken,
out string writtenJsonPath,
out int discoveredUsers,
out string error)
{
writtenJsonPath = "";
discoveredUsers = 0;
error = "";
try
@ -3740,6 +3993,26 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
var cardNos = FetchAllUserCardNosStdXml(session.UserId, pageSize, maxUsers, out var listErr, cancellationToken);
root.listFetchError = listErr;
discoveredUsers = cardNos.Count;
// DB population: attendance_machine_user (device → DB users list)
int upsertedUsers = 0;
int upsertFailed = 0;
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
{
foreach (var eno in cardNos)
{
var serial = (eno ?? "").Trim();
if (serial.Length == 0)
continue;
if (_attendanceMachineUserRepository.UpsertMachineUser(deviceId.Trim(), serial, "", out var upErr))
upsertedUsers++;
else
upsertFailed++;
}
_logger.JobInfo("template_fetch", "DB attendance_machine_user upsert: machine_id=" + deviceId.Trim() +
" discoveredUsers=" + discoveredUsers + " upserted=" + upsertedUsers + " failed=" + upsertFailed);
}
using var faceSw = new StreamWriter(faceLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
using var fpSw = new StreamWriter(fingerprintLogPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
@ -3850,6 +4123,13 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
TrySearchUserInfoByEmployeeNo(session, cardNo.Trim(), out var userInfoRaw, out var userInfoErr) &&
TryExtractFaceUrlFromUserInfoSearch(userInfoRaw, cardNo.Trim(), out var userFaceUrl))
{
// Best-effort: enrich DB user name if present in UserInfo/Search payload.
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null &&
TryExtractNameFromUserInfoSearch(userInfoRaw, cardNo.Trim(), out var userName))
{
_attendanceMachineUserRepository.UpsertMachineUser(deviceId.Trim(), cardNo.Trim(), userName ?? "", out _);
}
var facePath = userFaceUrl;
if (facePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
facePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
@ -3900,6 +4180,23 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
" machine_ip=" + (session.Device.Ip ?? "") +
" emp_no=" + cardNo.Trim() +
" template=" + (userPayload.face.present ? "fetched" : "not_fetched"));
if (_config.EnableDbIntegration &&
_config.EnableTemplateDbPersistence &&
userPayload.face.present &&
!string.IsNullOrWhiteSpace(userPayload.face.dataBase64) &&
_attendanceMachineFaceTemplateRepository != null)
{
try
{
var bytes = Convert.FromBase64String(userPayload.face.dataBase64);
if (!_attendanceMachineFaceTemplateRepository.UpsertFaceTemplate(cardNo.Trim(), bytes, DateTime.UtcNow, true, out var dbErr))
_logger.Warn("Template DB upsert failed emp_no=" + cardNo.Trim() + " err=" + dbErr);
}
catch (Exception ex)
{
_logger.Warn("Template DB upsert decode failed emp_no=" + cardNo.Trim() + " err=" + ex.Message);
}
}
if (userPayload.face.present) faceFetched.Add(cardNo.Trim()); else faceNotFetched.Add(cardNo.Trim());
// Fingerprint flow: capability-first, then FingerPrintDownload family.
@ -4796,7 +5093,11 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
while (_queue.TryDequeue(out ev))
{
Interlocked.Decrement(ref _queueSize);
lock (_csvWriteLock)
{
sw.WriteLine(ToCsvLine(ev));
}
if (_config.KeepAttendanceFileExport || !_config.EnableAttendanceDbPersistence)
AppendAttendanceToTextFileSafely(ev);
WriteAttendanceToDatabase(ev);
}
@ -4875,6 +5176,15 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
ev.RawMinor.ToString());
}
private void AppendAttendanceCsvLineSafely(string csvLine)
{
using (var fs = new FileStream(_csvPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
using (var writer = new StreamWriter(fs, Encoding.UTF8))
{
writer.WriteLine(csvLine);
}
}
public void Dispose()
{
try { if (_cts != null) _cts.Cancel(); } catch { /* ignore */ }
@ -4929,8 +5239,72 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable
return value;
}
private void TryUpdateMachineRuntimeState(string machineId, string status, DateTime? lastSyncDateUtc, int? totalUsers)
{
if (!_config.EnableDbIntegration || _attendanceMachineRepository == null || string.IsNullOrWhiteSpace(machineId))
return;
DateTime? lastSyncLocal = null;
if (lastSyncDateUtc.HasValue)
lastSyncLocal = NormalizeForDbLocalTime(lastSyncDateUtc.Value);
_attendanceMachineRepository.UpdateMachineSyncState(machineId, status, lastSyncLocal, totalUsers, out var err);
if (!string.IsNullOrWhiteSpace(err))
_logger.Warn("Machine state update failed machine_ip=" + machineId + " err=" + err);
}
private string ResolveMachineIpForDbLookup(string deviceId)
{
var bySession = _sessions.FirstOrDefault(s =>
string.Equals(DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId), DeviceIdentity.CanonicalLookupKey(deviceId), StringComparison.Ordinal));
if (bySession != null && !string.IsNullOrWhiteSpace(bySession.Device.Ip))
return bySession.Device.Ip.Trim();
var byRuntime = (ResolveRuntimeDevices() ?? new List<HikvisionAttendanceWindowsService.DeviceConfig>())
.FirstOrDefault(d => string.Equals(DeviceIdentity.CanonicalLookupKey(d.DeviceId), DeviceIdentity.CanonicalLookupKey(deviceId), StringComparison.Ordinal));
if (byRuntime != null && !string.IsNullOrWhiteSpace(byRuntime.Ip))
return byRuntime.Ip.Trim();
return "";
}
private static DateTime NormalizeForDbLocalTime(DateTime value)
{
if (value.Kind == DateTimeKind.Utc)
return value.ToLocalTime();
return value;
}
private bool WriteAttendanceToDatabase(AttendanceEvent ev)
{
if (_config.EnableDbIntegration && _config.EnableAttendanceDbPersistence && _attendanceLogRepository != null)
{
var acNo = ev.EmployeeNo.HasValue
? ev.EmployeeNo.Value.ToString(CultureInfo.InvariantCulture)
: (ev.UserIdentifier ?? "");
var inOutTypeId = ev.RawMinor > 0 ? (int)ev.RawMinor : 0;
var ok = _attendanceLogRepository.UpsertAttendance(
acNo,
ev.Timestamp,
0,
ev.DeviceId ?? "",
inOutTypeId,
ev.DeviceIp ?? "",
ev.Timestamp.Date,
out var err);
if (!ok)
{
_logger.Error("WriteAttendanceToDatabase MySQL(REPLACE) failed: " + err);
return false;
}
_logger.Info(
"[AttendanceInsert] machine_id=" + (ev.DeviceId ?? "") +
" ip=" + (ev.DeviceIp ?? "") +
" emp_no=" + (ev.EmployeeNo.HasValue ? ev.EmployeeNo.Value.ToString(CultureInfo.InvariantCulture) : "") +
" checktime=" + ev.Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) +
" rows=1");
return true;
}
if (!_config.EnableDatabasePersistence)
return true;