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 fixmain
parent
93b2bf9bd7
commit
33c2dfabb3
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue