diff --git a/HikvisionAttendanceManager.cs b/HikvisionAttendanceManager.cs index 9c180b6..7ca230c 100644 --- a/HikvisionAttendanceManager.cs +++ b/HikvisionAttendanceManager.cs @@ -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 _sessions = new List(); + private readonly List _runtimeDevices = new List(); + 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 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()) + .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(); + return _runtimeDevices; + } + /// Main service loop: SDK init, ACS alarm deploy, optional scheduled historical fetch. 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,95 +1337,158 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable if (intervalMinutes <= 0) intervalMinutes = 5; + bool firstRun = true; while (!token.IsCancellationRequested) { - try - { - await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - - LogJobCycleStart("attendance", "ATTENDANCE", "--Attendance job started at "); - 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()) + bool isImmediateStartupRun = firstRun; + if (!firstRun) { try { - var lastSync = ReadLastSyncTimestamp(s.Device.DeviceId, out var lastSyncReadReason); - - DateTime from; - if (lastSync.HasValue) - from = lastSync.Value.AddSeconds(1); - else - from = fallbackFrom; - - if (from > to) - { - _logger.Info("Scheduled historical ACS fetch skipped (from > to): device=" + s.Device.DeviceId + - ", from=" + from.ToString("yyyy-MM-dd HH:mm:ss") + ", to=" + to.ToString("yyyy-MM-dd HH:mm:ss") + - (string.IsNullOrEmpty(lastSyncReadReason) ? "" : ", lastSyncReason=" + lastSyncReadReason)); - continue; - } - - DateTime? lastEventTimestamp; - bool dbInsertAllSucceeded; - int n = FetchAttendanceRecordsCore(s.Device.DeviceId, from, to, token, out lastEventTimestamp, out dbInsertAllSucceeded); - _logger.Info("Scheduled historical ACS fetch completed: device=" + s.Device.DeviceId + ", records=" + n + - ", window=" + from.ToString("yyyy-MM-dd HH:mm:ss") + " .. " + 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) + - ", lastEventTimestamp=" + (lastEventTimestamp.HasValue ? lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(null)")); - _logger.JobInfo("attendance", "Scheduled fetch device=" + s.Device.DeviceId + " records=" + n + - " 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)) - { - WriteLastSyncTimestamp(s.Device.DeviceId, lastEventTimestamp.Value); - if (_config.EnableDatabasePersistence && dbInsertAllSucceeded) - { - if (TryCleanupDeviceAttendanceStorage(s, lastEventTimestamp.Value, out var cleanupErr)) - { - _logger.JobInfo("attendance", "Device cleanup OK: machine=" + s.Device.DeviceId + - " ip=" + (s.Device.Ip ?? "") + - " checkTime=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); - } - else - { - _logger.JobWarn("attendance", "Device cleanup FAILED: machine=" + s.Device.DeviceId + - " ip=" + (s.Device.Ip ?? "") + - " checkTime=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + - " err=" + cleanupErr); - } - } - } - else if (n > 0 && lastEventTimestamp.HasValue && _config.EnableDatabasePersistence && !dbInsertAllSucceeded) - { - _logger.JobWarn("attendance", "LastSync NOT updated for machine=" + s.Device.DeviceId + - " because DB insert failed; same data window will be retried."); - _logger.JobWarn("attendance", "Cleanup skipped: DB insert not confirmed; delete not executed. machine=" + s.Device.DeviceId); - } + await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), token).ConfigureAwait(false); } - catch (Exception ex) + catch (OperationCanceledException) { - _logger.Error("Scheduled historical fetch failed for " + s.Device.DeviceId, ex); - _logger.JobError("attendance", "Scheduled fetch FAILED device=" + s.Device.DeviceId + " err=" + ex.Message); + break; } } - LogJobCycleEnd("attendance", "--Attendance job finished at "); + 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(); + 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( + runtimeDevices.Select(d => DeviceIdentity.CanonicalLookupKey(d.DeviceId)).Where(x => !string.IsNullOrWhiteSpace(x)), + StringComparer.Ordinal); + var runtimeIps = new HashSet( + 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; + var fallbackFrom = to.AddMinutes(-_config.HistoricalFetchLookbackMinutes); + if (_config.HistoricalFetchLookbackMinutes <= 0) + fallbackFrom = to.AddDays(-1); + + 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); + 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 + + ", from=" + from.ToString("yyyy-MM-dd HH:mm:ss") + ", to=" + to.ToString("yyyy-MM-dd HH:mm:ss") + + (string.IsNullOrEmpty(lastSyncReadReason) ? "" : ", lastSyncReason=" + lastSyncReadReason)); + continue; + } + + DateTime? lastEventTimestamp; + bool dbInsertAllSucceeded; + int n = FetchAttendanceRecordsCore(s.Device.DeviceId, from, to, token, out lastEventTimestamp, out dbInsertAllSucceeded); + _logger.Info("Scheduled historical ACS fetch completed: device=" + s.Device.DeviceId + ", records=" + n + + ", window=" + from.ToString("yyyy-MM-dd HH:mm:ss") + " .. " + 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) + + ", lastEventTimestamp=" + (lastEventTimestamp.HasValue ? lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss") : "(null)")); + _logger.JobInfo("attendance", "Scheduled fetch device=" + s.Device.DeviceId + " records=" + n + + " 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 !"); + + if (n > 0 && lastEventTimestamp.HasValue && dbInsertAllSucceeded) + { + WriteLastSyncTimestamp(s.Device.DeviceId, lastEventTimestamp.Value); + if (_config.EnableDatabasePersistence && dbInsertAllSucceeded) + { + if (TryCleanupDeviceAttendanceStorage(s, lastEventTimestamp.Value, out var cleanupErr)) + { + _logger.JobInfo("attendance", "Device cleanup OK: machine=" + s.Device.DeviceId + + " ip=" + (s.Device.Ip ?? "") + + " checkTime=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); + } + else + { + _logger.JobWarn("attendance", "Device cleanup FAILED: machine=" + s.Device.DeviceId + + " ip=" + (s.Device.Ip ?? "") + + " checkTime=" + lastEventTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + + " err=" + cleanupErr); + } + } + } + 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."); + _logger.JobWarn("attendance", "Cleanup skipped: DB insert not confirmed; delete not executed. machine=" + s.Device.DeviceId); + } + } + catch (Exception ex) + { + _logger.Error("Scheduled historical fetch failed for " + s.Device.DeviceId, ex); + _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) { bool firstRun = true; @@ -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)); } - AppendAttendanceToTextFileSafely(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(); + stack.Push(root); + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (cur is Dictionary 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 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,8 +5093,12 @@ internal sealed partial class HikvisionAttendanceManager : IDisposable while (_queue.TryDequeue(out ev)) { Interlocked.Decrement(ref _queueSize); - sw.WriteLine(ToCsvLine(ev)); - AppendAttendanceToTextFileSafely(ev); + 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()) + .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;