using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Runtime.Serialization; using System.Runtime.Serialization.Json; using System.Threading; using System.Threading.Tasks; using System.ServiceProcess; namespace HikvisionAttendanceService; public sealed class HikvisionAttendanceWindowsService : ServiceBase { private CancellationTokenSource? _cts; private Task? _mainTask; private HikvisionAttendanceManager? _manager; public HikvisionAttendanceWindowsService() { ServiceName = "HikvisionAttendanceService"; CanStop = true; CanPauseAndContinue = false; AutoLog = false; // we use our own file logger } protected override void OnStart(string[] args) { _cts = new CancellationTokenSource(); Environment.CurrentDirectory = AppContext.BaseDirectory; var baseDir = AppContext.BaseDirectory; var configPath = Path.Combine(baseDir, "serviceconfig.json"); if (!File.Exists(configPath)) { // Fallback to appsettings.json (developer convenience). configPath = Path.Combine(baseDir, "appsettings.json"); } 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); } protected override void OnStop() { if (_cts is null) { return; } try { _cts.Cancel(); } catch { // ignore } if (_mainTask is not null) { try { _mainTask.Wait(TimeSpan.FromSeconds(20)); } catch { /* ignore */ } } _manager?.Dispose(); _manager = null; } [DataContract] internal sealed class HikvisionServiceConfig { [DataMember] public string LogDirectory { get; set; } = @"C:\Users\Public\HikvisionAttendanceService"; [DataMember] public string SqlConnectionString { get; set; } = ""; [DataMember] public bool EnableDbIntegration { get; set; } [DataMember] public bool EnableDbMachineLoading { get; set; } [DataMember] public bool PreferDbMachinesOverConfig { get; set; } [DataMember] public bool EnableAttendanceDbPersistence { get; set; } [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; } [DataMember] public bool KeepAttendanceFileExport { get; set; } = true; [DataMember] public bool KeepTemplateFiles { get; set; } = true; [DataMember] public string DbHost { get; set; } = ""; [DataMember] public int DbPort { get; set; } = 3306; [DataMember] public string DbName { get; set; } = "hrms"; [DataMember] public string DbUsername { get; set; } = ""; [DataMember] public string DbPassword { get; set; } = ""; [DataMember] public string DbConnectionString { get; set; } = ""; /// When true and SqlConnectionString is set, attendance rows are INSERTed. Default false until schema is finalized. [DataMember] public bool EnableDatabasePersistence { get; set; } /// Plain-text attendance log. Empty = LogDirectory\attendance_records.txt [DataMember] public string AttendanceTextFilePath { get; set; } = ""; [DataMember] public string AttendanceTableName { get; set; } = "dbo.HikvisionAttendanceEvents"; [DataMember] public string HrExportPath { get; set; } = ""; [DataMember] public bool AutoDoorControlOnSuccess { get; set; } = true; [DataMember] public int AutoDoorCloseDelaySeconds { get; set; } = 3; [DataMember] public List Devices { get; set; } = new List(); /// 0 = all majors (per GetACSEvent demo). [DataMember] public uint AcsHistoryMajor { get; set; } /// 0 = all minors. [DataMember] public uint AcsHistoryMinor { get; set; } /// 0 = disabled. Otherwise interval in minutes for NET_DVR_GET_ACS_EVENT sync. [DataMember] public int HistoricalFetchIntervalMinutes { get; set; } [DataMember] public int HistoricalFetchLookbackMinutes { get; set; } = 1440; /// Enable/disable scheduled attendance historical sync job. [DataMember] public bool EnableAttendanceSync { get; set; } = true; /// Scheduled attendance sync interval in minutes (default 5). [DataMember] public int AttendanceSyncIntervalMinutes { get; set; } = 5; /// /// When true, user-info/faceURL lookups use direct ISAPI HTTP (Digest auth) instead of SDK STDXML wrappers. /// Default true because some terminals return richer UserInfo fields (e.g. faceURL) over HTTP. /// [DataMember] public bool UseIsapiHttpForUserInfo { get; set; } = true; /// HTTP port for direct ISAPI calls. 80 by default. [DataMember] public int IsapiHttpPort { get; set; } = 80; /// When true, periodically syncs users and face images from to via ISAPI HTTP (Digest). [DataMember] public bool EnableUserSync { get; set; } /// Interval between user/face sync cycles. Ignored if <= 0 or sync is disabled. [DataMember] public int SyncIntervalMinutes { get; set; } = 60; /// Enable/disable scheduled face/fingerprint template export/fetch job. [DataMember] public bool EnableTemplateFetch { get; set; } /// Template fetch interval in hours (default 4). [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; } = ""; [DataMember] public UserSyncPoliciesConfig SyncPolicies { get; set; } = new UserSyncPoliciesConfig(); public static HikvisionServiceConfig Load(string configPath) { // Minimal JSON config loader (no external packages). try { if (!File.Exists(configPath)) { var missingCfg = new HikvisionServiceConfig { LogDirectory = @"C:\Users\Public\HikvisionAttendanceService", SqlConnectionString = "", EnableDbIntegration = false, EnableDbMachineLoading = false, PreferDbMachinesOverConfig = false, EnableAttendanceDbPersistence = false, EnableTemplateDbPersistence = false, EnableTemplateDeviceToDbSync = false, EnableTemplateDbToDeviceSync = false, KeepAttendanceFileExport = true, KeepTemplateFiles = true, DbHost = "", DbPort = 3306, DbName = "hrms", DbUsername = "", DbPassword = "", DbConnectionString = "", EnableDatabasePersistence = false, AttendanceTextFilePath = "", AttendanceTableName = "dbo.HikvisionAttendanceEvents", HrExportPath = "", AutoDoorControlOnSuccess = true, AutoDoorCloseDelaySeconds = 3, AcsHistoryMajor = 0, AcsHistoryMinor = 0, HistoricalFetchIntervalMinutes = 0, HistoricalFetchLookbackMinutes = 1440, EnableAttendanceSync = true, AttendanceSyncIntervalMinutes = 5, UseIsapiHttpForUserInfo = true, 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() }; ApplyDeviceSettingsOverlay(missingCfg, configPath); NormalizeLoadedDeviceConfig(missingCfg); return missingCfg; } var raw = File.ReadAllText(configPath); var serializer = new DataContractJsonSerializer(typeof(HikvisionServiceConfig)); // First attempt: strict JSON. try { using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(raw))) { var cfg = (HikvisionServiceConfig)serializer.ReadObject(ms); ApplyEnvironmentOverrides(cfg); ApplyDeviceSettingsOverlay(cfg, configPath); NormalizeLoadedDeviceConfig(cfg); return cfg; } } catch { // Second attempt: tolerate // and /* */ comments (your serviceconfig.json includes them). var sanitized = StripJsonComments(raw); using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(sanitized))) { var cfg = (HikvisionServiceConfig)serializer.ReadObject(ms); ApplyEnvironmentOverrides(cfg); ApplyDeviceSettingsOverlay(cfg, configPath); NormalizeLoadedDeviceConfig(cfg); return cfg; } } } catch { var fallbackCfg = new HikvisionServiceConfig { LogDirectory = @"C:\Users\Public\HikvisionAttendanceService", SqlConnectionString = "", EnableDbIntegration = false, EnableDbMachineLoading = false, PreferDbMachinesOverConfig = false, EnableAttendanceDbPersistence = false, EnableTemplateDbPersistence = false, EnableTemplateDeviceToDbSync = false, EnableTemplateDbToDeviceSync = false, KeepAttendanceFileExport = true, KeepTemplateFiles = true, DbHost = "", DbPort = 3306, DbName = "hrms", DbUsername = "", DbPassword = "", DbConnectionString = "", EnableDatabasePersistence = false, AttendanceTextFilePath = "", AttendanceTableName = "dbo.HikvisionAttendanceEvents", HrExportPath = "", AutoDoorControlOnSuccess = true, AutoDoorCloseDelaySeconds = 3, AcsHistoryMajor = 0, AcsHistoryMinor = 0, HistoricalFetchIntervalMinutes = 0, HistoricalFetchLookbackMinutes = 1440, EnableAttendanceSync = true, AttendanceSyncIntervalMinutes = 5, UseIsapiHttpForUserInfo = true, 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() }; ApplyDeviceSettingsOverlay(fallbackCfg, configPath); NormalizeLoadedDeviceConfig(fallbackCfg); return fallbackCfg; } } private static string StripJsonComments(string input) { if (string.IsNullOrEmpty(input)) return input; var sb = new StringBuilder(input.Length); bool inString = false; char stringQuote = '\0'; bool escape = false; for (int i = 0; i < input.Length; i++) { char c = input[i]; if (inString) { sb.Append(c); if (escape) { escape = false; continue; } if (c == '\\') { escape = true; continue; } if (c == stringQuote) { inString = false; stringQuote = '\0'; } continue; } // Not in a string if (c == '"' || c == '\'') { // JSON strings are double-quoted; single-quote is not valid JSON, // but supporting it here doesn't hurt for comment stripping. inString = true; stringQuote = c; sb.Append(c); continue; } // Line comment // if (c == '/' && i + 1 < input.Length && input[i + 1] == '/') { i += 1; // consume second '/' // skip until newline or end while (i + 1 < input.Length) { i += 1; if (input[i] == '\r' || input[i] == '\n') { sb.Append(input[i]); break; } } continue; } // Block comment /* ... */ if (c == '/' && i + 1 < input.Length && input[i + 1] == '*') { i += 1; // consume '*' while (i + 1 < input.Length) { i += 1; if (input[i] == '*' && i + 1 < input.Length && input[i + 1] == '/') { i += 1; // consume '/' break; } } continue; } sb.Append(c); } return sb.ToString(); } private static void ApplyEnvironmentOverrides(HikvisionServiceConfig cfg) { if (cfg == null) return; string ReadEnv(string key) => Environment.GetEnvironmentVariable(key) ?? ""; var cs = ReadEnv("HIKVISION_DB_CONNECTION_STRING"); var host = ReadEnv("HIKVISION_DB_HOST"); var user = ReadEnv("HIKVISION_DB_USERNAME"); var pass = ReadEnv("HIKVISION_DB_PASSWORD"); var dbName = ReadEnv("HIKVISION_DB_NAME"); var dbPort = ReadEnv("HIKVISION_DB_PORT"); if (!string.IsNullOrWhiteSpace(cs)) cfg.DbConnectionString = cs.Trim(); if (!string.IsNullOrWhiteSpace(host)) cfg.DbHost = host.Trim(); if (!string.IsNullOrWhiteSpace(user)) cfg.DbUsername = user.Trim(); if (!string.IsNullOrWhiteSpace(pass)) cfg.DbPassword = pass; if (!string.IsNullOrWhiteSpace(dbName)) cfg.DbName = dbName.Trim(); if (int.TryParse(dbPort, out var parsedPort) && parsedPort > 0) cfg.DbPort = parsedPort; } /// /// Overlays device-scope / template-direction settings from DeviceSettings.config /// (same folder as serviceconfig.json). Property names and behavior are unchanged. /// private static void ApplyDeviceSettingsOverlay(HikvisionServiceConfig cfg, string serviceConfigPath) { if (cfg == null) return; string dir; try { dir = Path.GetDirectoryName(Path.GetFullPath(serviceConfigPath ?? "")) ?? ""; } catch { dir = ""; } if (string.IsNullOrWhiteSpace(dir)) dir = AppContext.BaseDirectory; var path = Path.Combine(dir, "DeviceSettings.config"); if (!File.Exists(path)) return; try { var raw = File.ReadAllText(path); var serializer = new DataContractJsonSerializer(typeof(DeviceSettingsFile)); DeviceSettingsFile? overlay; using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(raw))) overlay = serializer.ReadObject(ms) as DeviceSettingsFile; if (overlay == null) return; if (!string.IsNullOrWhiteSpace(overlay.MachineScopeMode)) cfg.MachineScopeMode = overlay.MachineScopeMode; if (overlay.ScopedMachineIps != null) cfg.ScopedMachineIps = new List(overlay.ScopedMachineIps); cfg.EnableTemplateDeviceToDbSync = overlay.EnableTemplateDeviceToDbSync; cfg.EnableTemplateDbToDeviceSync = overlay.EnableTemplateDbToDeviceSync; if (overlay.SourceMachineIp != null) cfg.SourceMachineIp = overlay.SourceMachineIp; if (overlay.TargetMachineIps != null) cfg.TargetMachineIps = new List(overlay.TargetMachineIps); if (overlay.SyncEmployeeIds != null) cfg.SyncEmployeeIds = new List(overlay.SyncEmployeeIds); } catch { // Keep serviceconfig / defaults if DeviceSettings.config cannot be parsed. } } private static void NormalizeLoadedDeviceConfig(HikvisionServiceConfig? cfg) { 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 { 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; foreach (var d in cfg.Devices) { d.DeviceId = DeviceIdentity.NormalizeConfigured(d.DeviceId); if (!string.IsNullOrWhiteSpace(d.Ip)) d.Ip = d.Ip.Trim(); } } } /// Device-scope and template-direction settings loaded from DeviceSettings.config. [DataContract] internal sealed class DeviceSettingsFile { [DataMember] public string MachineScopeMode { get; set; } = "CENTRAL"; [DataMember] public List ScopedMachineIps { get; set; } = new List(); [DataMember] public bool EnableTemplateDeviceToDbSync { get; set; } [DataMember] public bool EnableTemplateDbToDeviceSync { get; set; } [DataMember] public string SourceMachineIp { get; set; } = ""; [DataMember] public List TargetMachineIps { get; set; } = new List(); [DataMember] public List SyncEmployeeIds { get; set; } = new List(); } /// Policies for multi-device user/face sync (ISAPI). Loaded from service JSON only. [DataContract] internal sealed class UserSyncPoliciesConfig { /// When true, attempts UserInfo/Modify on targets when name or validity differs from source. [DataMember] public bool UpdateExistingUserFields { get; set; } /// When true (default), face upload runs only if target has no enrolled face (numOfFace == 0). [DataMember] public bool UploadFaceIfMissingOnly { get; set; } = true; /// When true, deletes users on targets that are not present on the source (destructive). [DataMember] public bool DeleteOnTargetIfMissingInSource { get; set; } /// Face library type for FaceDataRecord upload (device-specific; Pro Series often uses blackFD). [DataMember] public string FaceLibType { get; set; } = "blackFD"; /// Face library ID for FaceDataRecord upload. [DataMember] public string FaceLibraryFdId { get; set; } = "1"; /// Max retries per HTTP call (exponential backoff between attempts). [DataMember] public int HttpMaxRetries { get; set; } = 3; } [DataContract] internal sealed class DeviceConfig { [DataMember] public string DeviceId { get; set; } = ""; [DataMember] public string Ip { get; set; } = ""; [DataMember] public int Port { get; set; } = 8000; [DataMember] public string Username { get; set; } = ""; [DataMember] public string Password { get; set; } = ""; // Door index used by NET_DVR_ControlGateway for manual open/close. [DataMember] public int GatewayDoorIndex { get; set; } = 1; // For template enrollment/sync (optional). [DataMember] public int FingerPrintReaderNo { get; set; } = 1; [DataMember] public int FaceReaderNo { get; set; } = 1; /// Optional: documented in JSON for support logs (not sent to SDK). [DataMember] public string Model { get; set; } = ""; [DataMember] public string SerialNumber { get; set; } = ""; [DataMember] public string FirmwareVersion { get; set; } = ""; [DataMember] public string SubnetMask { get; set; } = ""; [DataMember] public string DefaultGateway { get; set; } = ""; } internal sealed class FileLogger : IDisposable { private readonly string _directory; private readonly string _internalLogsDirectory; 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) { _directory = directory; Directory.CreateDirectory(_directory); var logsRoot = Path.Combine(_directory, "logs"); _internalLogsDirectory = Path.Combine(_directory, "internal_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(_attendanceBizDirectory); Directory.CreateDirectory(_templateBizDirectory); Directory.CreateDirectory(_userSyncBizDirectory); Directory.CreateDirectory(_unreachableBizDirectory); } public string LogDirectory => _directory; 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) { if (lines == null || lines.Length == 0) return; lock (_sync) { 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()); } } // ---- Business / operations logs (human-readable, no SDK) ---- public void Biz(BizChannel channel, params string[] lines) { 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 BizChannel.Attendance: path = Path.Combine(_attendanceBizDirectory, "attendance_logs_" + DateStamp() + ".txt"); break; case BizChannel.UserSync: path = Path.Combine(_userSyncBizDirectory, "user_sync_logs_" + DateStamp() + ".txt"); break; 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: path = InternalLogPath(); break; } lock (_sync) { 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() { } } // Manager implementation is in a separate file below for readability. }