diff --git a/HikvisionAttendanceWindowsService.cs b/HikvisionAttendanceWindowsService.cs index b5e4ce6..f4aebfe 100644 --- a/HikvisionAttendanceWindowsService.cs +++ b/HikvisionAttendanceWindowsService.cs @@ -39,6 +39,8 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase var config = HikvisionServiceConfig.Load(configPath); var logger = new FileLogger(config.LogDirectory); + logger.Ops(OpsMarkers.Service, "WINDOWS SERVICE OnStart time=" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + + " config=\"" + configPath + "\""); _manager = new HikvisionAttendanceManager(config, logger); _mainTask = _manager.RunAsync(_cts.Token); @@ -93,6 +95,11 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase [DataMember] public bool EnableTemplateDbPersistence { get; set; } + /// When true, pull face templates from device and save to DB (DEVICE -> DATABASE). Prefer only one template direction enabled at a time. + [DataMember] + public bool EnableTemplateDeviceToDbSync { get; set; } + + /// When true, push face templates from DB to device (DATABASE -> DEVICE). Prefer only one template direction enabled at a time. [DataMember] public bool EnableTemplateDbToDeviceSync { get; set; } @@ -193,14 +200,29 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase [DataMember] public int TemplateFetchIntervalHours { get; set; } = 4; + [DataMember] + public string MachineScopeMode { get; set; } = "CENTRAL"; + + [DataMember] + public List ScopedMachineIps { get; set; } = new List(); + /// DeviceId (from ) to treat as the authoritative user source. [DataMember] public string SourceDeviceId { get; set; } = ""; + [DataMember] + public string SourceMachineIp { get; set; } = ""; + /// DeviceIds to push missing users/faces onto. [DataMember] public List TargetDeviceIds { get; set; } = new List(); + [DataMember] + public List TargetMachineIps { get; set; } = new List(); + + [DataMember] + public List SyncEmployeeIds { get; set; } = new List(); + /// Optional directory to persist downloaded source face images for auditing or re-upload. Empty = skip file save. [DataMember] public string UserSyncFaceCacheDirectory { get; set; } = ""; @@ -224,6 +246,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase PreferDbMachinesOverConfig = false, EnableAttendanceDbPersistence = false, EnableTemplateDbPersistence = false, + EnableTemplateDeviceToDbSync = false, EnableTemplateDbToDeviceSync = false, KeepAttendanceFileExport = true, KeepTemplateFiles = true, @@ -249,10 +272,15 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase IsapiHttpPort = 8000, EnableTemplateFetch = false, TemplateFetchIntervalHours = 4, + MachineScopeMode = "CENTRAL", + ScopedMachineIps = new List(), EnableUserSync = false, SyncIntervalMinutes = 60, SourceDeviceId = "", + SourceMachineIp = "", TargetDeviceIds = new List(), + TargetMachineIps = new List(), + SyncEmployeeIds = new List(), UserSyncFaceCacheDirectory = "", SyncPolicies = new UserSyncPoliciesConfig() }; @@ -296,6 +324,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase PreferDbMachinesOverConfig = false, EnableAttendanceDbPersistence = false, EnableTemplateDbPersistence = false, + EnableTemplateDeviceToDbSync = false, EnableTemplateDbToDeviceSync = false, KeepAttendanceFileExport = true, KeepTemplateFiles = true, @@ -321,10 +350,15 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase IsapiHttpPort = 80, EnableTemplateFetch = false, TemplateFetchIntervalHours = 4, + MachineScopeMode = "CENTRAL", + ScopedMachineIps = new List(), EnableUserSync = false, SyncIntervalMinutes = 60, SourceDeviceId = "", + SourceMachineIp = "", TargetDeviceIds = new List(), + TargetMachineIps = new List(), + SyncEmployeeIds = new List(), UserSyncFaceCacheDirectory = "", SyncPolicies = new UserSyncPoliciesConfig() }; @@ -452,11 +486,18 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase if (cfg == null) return; + cfg.MachineScopeMode = string.IsNullOrWhiteSpace(cfg.MachineScopeMode) + ? "CENTRAL" + : cfg.MachineScopeMode.Trim().ToUpperInvariant(); cfg.SourceDeviceId = DeviceIdentity.NormalizeConfigured(cfg.SourceDeviceId); + cfg.SourceMachineIp = string.IsNullOrWhiteSpace(cfg.SourceMachineIp) ? "" : cfg.SourceMachineIp.Trim(); cfg.UserSyncFaceCacheDirectory = string.IsNullOrWhiteSpace(cfg.UserSyncFaceCacheDirectory) ? "" : cfg.UserSyncFaceCacheDirectory.Trim(); cfg.SyncPolicies ??= new UserSyncPoliciesConfig(); + cfg.ScopedMachineIps ??= new List(); + for (int i = 0; i < cfg.ScopedMachineIps.Count; i++) + cfg.ScopedMachineIps[i] = (cfg.ScopedMachineIps[i] ?? "").Trim(); if (cfg.TargetDeviceIds == null) cfg.TargetDeviceIds = new List(); else @@ -464,6 +505,12 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase for (int i = 0; i < cfg.TargetDeviceIds.Count; i++) cfg.TargetDeviceIds[i] = DeviceIdentity.NormalizeConfigured(cfg.TargetDeviceIds[i]); } + cfg.TargetMachineIps ??= new List(); + for (int i = 0; i < cfg.TargetMachineIps.Count; i++) + cfg.TargetMachineIps[i] = (cfg.TargetMachineIps[i] ?? "").Trim(); + cfg.SyncEmployeeIds ??= new List(); + for (int i = 0; i < cfg.SyncEmployeeIds.Count; i++) + cfg.SyncEmployeeIds[i] = (cfg.SyncEmployeeIds[i] ?? "").Trim(); if (cfg.Devices == null) return; @@ -556,10 +603,12 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase { private readonly string _directory; private readonly string _internalLogsDirectory; - private readonly string _attendanceJobLogsDirectory; - private readonly string _templateJobLogsDirectory; - private readonly string _userSyncJobLogsDirectory; + private readonly string _attendanceBizDirectory; + private readonly string _templateBizDirectory; + private readonly string _userSyncBizDirectory; + private readonly string _unreachableBizDirectory; private readonly object _sync = new(); + public readonly BizSessionTotals Totals = new BizSessionTotals(); public FileLogger(string directory) { @@ -567,69 +616,163 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase Directory.CreateDirectory(_directory); var logsRoot = Path.Combine(_directory, "logs"); _internalLogsDirectory = Path.Combine(_directory, "internal_logs"); - _attendanceJobLogsDirectory = Path.Combine(logsRoot, "attendance_logs"); - _templateJobLogsDirectory = Path.Combine(logsRoot, "template_fetching_logs"); - _userSyncJobLogsDirectory = Path.Combine(logsRoot, "user_sync_logs"); + _attendanceBizDirectory = Path.Combine(logsRoot, "attendance_logs"); + _templateBizDirectory = Path.Combine(logsRoot, "template_fetching_logs"); + _userSyncBizDirectory = Path.Combine(logsRoot, "user_sync_logs"); + _unreachableBizDirectory = Path.Combine(_internalLogsDirectory, "unreachable_devices"); Directory.CreateDirectory(_internalLogsDirectory); - Directory.CreateDirectory(_attendanceJobLogsDirectory); - Directory.CreateDirectory(_templateJobLogsDirectory); - Directory.CreateDirectory(_userSyncJobLogsDirectory); + Directory.CreateDirectory(_attendanceBizDirectory); + Directory.CreateDirectory(_templateBizDirectory); + Directory.CreateDirectory(_userSyncBizDirectory); + Directory.CreateDirectory(_unreachableBizDirectory); } - public void Info(string message) => Write("INFO", message); - public void Warn(string message) => Write("WARN", message); - public void Error(string message, Exception? ex = null) => Write("ERROR", message + (ex is null ? "" : $" | {ex}")); - public void JobInfo(string jobName, string message) => WriteJob(jobName, "INFO", message); - public void JobWarn(string jobName, string message) => WriteJob(jobName, "WARN", message); - public void JobError(string jobName, string message) => WriteJob(jobName, "ERROR", message); + public string LogDirectory => _directory; - private void Write(string level, string message) + public void Info(string message) => WriteInternal("INFO", message); + public void Warn(string message) => WriteInternal("WARN", message); + public void Error(string message, Exception? ex = null) => + WriteInternal("ERROR", message + (ex is null ? "" : $" | {ex}")); + + /// Technical diagnostics only — never written to business logs/. + public void JobInfo(string jobName, string message) => WriteInternalDiag(jobName, "INFO", message); + public void JobWarn(string jobName, string message) => WriteInternalDiag(jobName, "WARN", message); + public void JobError(string jobName, string message) => WriteInternalDiag(jobName, "ERROR", message); + public void Diag(string jobName, string message) => WriteInternalDiag(jobName, "INFO", message); + + public void Ops(string marker, string message) => + WriteInternal("INFO", "[" + (marker ?? "OPS") + "] " + (message ?? "")); + public void OpsWarn(string marker, string message) => + WriteInternal("WARN", "[" + (marker ?? "OPS") + "] " + (message ?? "")); + public void OpsError(string marker, string message) => + WriteInternal("ERROR", "[" + (marker ?? "OPS") + "] " + (message ?? "")); + + public void Banner(params string[] lines) { - var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}"; - var logName = "internal_logs_" + DateTime.Now.ToString("yyyy-MM-dd") + ".txt"; - var path = Path.Combine(_internalLogsDirectory, logName); + if (lines == null || lines.Length == 0) + return; lock (_sync) { - File.AppendAllText(path, line); + var sb = new StringBuilder(); + var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); + sb.Append(stamp).Append(" [INFO] ============================================================").Append(Environment.NewLine); + foreach (var line in lines) + sb.Append(stamp).Append(" [INFO] ").Append(line ?? "").Append(Environment.NewLine); + sb.Append(stamp).Append(" [INFO] ============================================================").Append(Environment.NewLine); + File.AppendAllText(InternalLogPath(), sb.ToString()); } } - private void WriteJob(string jobName, string level, string message) + // ---- Business / operations logs (human-readable, no SDK) ---- + + public void Biz(BizChannel channel, params string[] lines) { - string dir; - string prefix; - switch ((jobName ?? "").Trim().ToLowerInvariant()) + if (lines == null || lines.Length == 0) + return; + var sb = new StringBuilder(); + foreach (var line in lines) + sb.AppendLine(line ?? ""); + AppendBiz(channel, sb.ToString()); + } + + public void BizBlank(BizChannel channel) => AppendBiz(channel, Environment.NewLine); + + public void BizSeparator(BizChannel channel) => + AppendBiz(channel, "------------------------------------------------------------" + Environment.NewLine + Environment.NewLine); + + public void WriteBizServiceStarted() + { + Totals.StartedAt = DateTime.Now; + var block = + "------------------------------------------------------------" + Environment.NewLine + + "Service started at " + BizFriendlyReasons.FormatTime(Totals.StartedAt) + Environment.NewLine + + "------------------------------------------------------------" + Environment.NewLine + + Environment.NewLine; + AppendBiz(BizChannel.Attendance, block); + AppendBiz(BizChannel.UserSync, block); + AppendBiz(BizChannel.Template, block); + AppendBiz(BizChannel.Unreachable, block); + } + + public void WriteBizServiceStopped() + { + var stopped = + Environment.NewLine + + "------------------------------------------------------------" + Environment.NewLine + + "Service stopped at " + BizFriendlyReasons.FormatTime(DateTime.Now) + Environment.NewLine + + Environment.NewLine + + Totals.FormatSummaryBlock() + + Environment.NewLine; + AppendBiz(BizChannel.Attendance, stopped); + AppendBiz(BizChannel.UserSync, stopped); + AppendBiz(BizChannel.Template, stopped); + AppendBiz(BizChannel.Unreachable, stopped); + } + + public void WriteBizDatabaseFailed(string mysqlErrorCode, string friendlyReason) + { + Biz(BizChannel.Attendance, + "Database connection failed.", + "", + "Reason :", + "", + string.IsNullOrWhiteSpace(mysqlErrorCode) ? "[MySQL Error]" : "[MySQL Error " + mysqlErrorCode + "]", + "", + string.IsNullOrWhiteSpace(friendlyReason) ? "Unable to connect to MySQL server." : friendlyReason, + "", + "Attendance synchronization cannot continue.", + ""); + } + + private void AppendBiz(BizChannel channel, string text) + { + string path; + switch (channel) { - case "attendance": - case "attendance_logs": - dir = _attendanceJobLogsDirectory; - prefix = "attendance_logs_"; + case BizChannel.Attendance: + path = Path.Combine(_attendanceBizDirectory, "attendance_logs_" + DateStamp() + ".txt"); break; - case "template": - case "template_fetch": - case "template_fetching_logs": - dir = _templateJobLogsDirectory; - prefix = "template_fetching_logs_"; + case BizChannel.UserSync: + path = Path.Combine(_userSyncBizDirectory, "user_sync_logs_" + DateStamp() + ".txt"); break; - case "user_sync": - case "user_sync_logs": - dir = _userSyncJobLogsDirectory; - prefix = "user_sync_logs_"; + case BizChannel.Template: + path = Path.Combine(_templateBizDirectory, "template_fetching_logs_" + DateStamp() + ".txt"); + break; + case BizChannel.Unreachable: + path = Path.Combine(_unreachableBizDirectory, "unreachable_devices_" + DateStamp() + ".txt"); break; default: - dir = _internalLogsDirectory; - prefix = "internal_logs_"; + path = InternalLogPath(); break; } - var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}"; - var path = Path.Combine(dir, prefix + DateTime.Now.ToString("yyyy-MM-dd") + ".txt"); lock (_sync) { - File.AppendAllText(path, line); + File.AppendAllText(path, text, Encoding.UTF8); } } + private static string DateStamp() => DateTime.Now.ToString("yyyy-MM-dd"); + + private string InternalLogPath() => + Path.Combine(_internalLogsDirectory, "internal_logs_" + DateStamp() + ".txt"); + + private void WriteInternal(string level, string message) + { + var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}"; + lock (_sync) + { + File.AppendAllText(InternalLogPath(), line); + } + } + + private void WriteInternalDiag(string jobName, string level, string message) + { + var tag = (jobName ?? "diag").Trim().ToUpperInvariant(); + if (tag.Length == 0) tag = "DIAG"; + WriteInternal(level, "[DIAG][" + tag + "] " + message); + } + public void Dispose() { } }