From 93b2bf9bd78863610e891220460f99e5916febc8 Mon Sep 17 00:00:00 2001 From: "mustafa.ahmed" Date: Tue, 14 Apr 2026 17:12:10 +0500 Subject: [PATCH] Add MySQL-backed HRMS repositories and DB integration configuration. 1) Introduces DB connectivity, repository abstractions, and new config flags/env overrides. 2) Keeps architecture clean and isolated. --- Data/DbModels.cs | 34 +++ Data/Interfaces.cs | 45 ++++ Data/MySqlHrmsDbConnectionFactory.cs | 55 +++++ Data/MySqlRepositories.cs | 298 +++++++++++++++++++++++++++ HikvisionAttendanceService.csproj | 15 +- HikvisionAttendanceWindowsService.cs | 100 +++++++++ 6 files changed, 541 insertions(+), 6 deletions(-) create mode 100644 Data/DbModels.cs create mode 100644 Data/Interfaces.cs create mode 100644 Data/MySqlHrmsDbConnectionFactory.cs create mode 100644 Data/MySqlRepositories.cs diff --git a/Data/DbModels.cs b/Data/DbModels.cs new file mode 100644 index 0000000..3ab38a3 --- /dev/null +++ b/Data/DbModels.cs @@ -0,0 +1,34 @@ +using System; + +namespace HikvisionAttendanceService.Data; + +internal sealed class AttendanceMachineRow +{ + public string MachineId { get; set; } = ""; + public string MachineIp { get; set; } = ""; + public int PortNumber { get; set; } = 8000; + public string MachineName { get; set; } = ""; + public int? SiteId { get; set; } + public string MachineStatus { get; set; } = ""; + public string MachineType { get; set; } = ""; + public string Status { get; set; } = ""; + public DateTime? LastSyncDate { get; set; } + public int TotalUsers { get; set; } +} + +internal sealed class AttendanceMachineUserRow +{ + public string MachineId { get; set; } = ""; + public string SerialNumber { get; set; } = ""; + public string EmployeeName { get; set; } = ""; + public bool IsDeleted { get; set; } + public bool IsDeletionRequested { get; set; } +} + +internal sealed class AttendanceMachineFaceTemplateRow +{ + public string SerialNo { get; set; } = ""; + public byte[] Template { get; set; } = Array.Empty(); + public DateTime CreatedDate { get; set; } + public bool IsActive { get; set; } = true; +} diff --git a/Data/Interfaces.cs b/Data/Interfaces.cs new file mode 100644 index 0000000..e4f0380 --- /dev/null +++ b/Data/Interfaces.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace HikvisionAttendanceService.Data; + +internal interface IHrmsDbConnectionFactory +{ + string BuildConnectionStringMasked(); + bool TryBuildConnectionString(out string connectionString, out string error); +} + +internal interface IAttendanceMachineRepository +{ + List GetActiveMachines(string? machineTypeFilter, out string error); + void UpdateMachineSyncState(string machineIp, string status, DateTime? lastSyncDate, int? totalUsers, out string error); + bool TryGetMachineLastSyncDate(string machineIp, out DateTime? lastSyncDate, out string error); + bool UpdateMachineLastSyncDate(string machineIp, DateTime lastSyncDate, out string error); +} + +internal interface IAttendanceLogRepository +{ + bool UpsertAttendance( + string acNo, + DateTime checkTime, + int processed, + string machineId, + int inOutTypeId, + string machineIp, + DateTime dateOnly, + out string error); +} + +internal interface IAttendanceMachineUserRepository +{ + List GetActiveUsersByMachine(string machineId, out string error); + List GetPendingDeletionUsersByMachine(string machineId, out string error); + bool UpsertMachineUser(string machineId, string serialNumber, string employeeName, out string error); + bool MarkDeleted(string machineId, string serialNumber, string updatedBy, out string error); +} + +internal interface IAttendanceMachineFaceTemplateRepository +{ + bool UpsertFaceTemplate(string serialNo, byte[] template, DateTime createdDate, bool isActive, out string error); + bool TryGetActiveFaceTemplateBySerial(string serialNo, out AttendanceMachineFaceTemplateRow? row, out string error); +} diff --git a/Data/MySqlHrmsDbConnectionFactory.cs b/Data/MySqlHrmsDbConnectionFactory.cs new file mode 100644 index 0000000..757a1f1 --- /dev/null +++ b/Data/MySqlHrmsDbConnectionFactory.cs @@ -0,0 +1,55 @@ +using MySql.Data.MySqlClient; + +namespace HikvisionAttendanceService.Data; + +internal sealed class MySqlHrmsDbConnectionFactory : IHrmsDbConnectionFactory +{ + private readonly HikvisionAttendanceWindowsService.HikvisionServiceConfig _config; + + public MySqlHrmsDbConnectionFactory(HikvisionAttendanceWindowsService.HikvisionServiceConfig config) + { + _config = config; + } + + public string BuildConnectionStringMasked() + { + if (TryBuildConnectionString(out var cs, out _)) + { + var b = new MySqlConnectionStringBuilder(cs); + b.Password = "***"; + return b.ConnectionString; + } + return "(invalid)"; + } + + public bool TryBuildConnectionString(out string connectionString, out string error) + { + connectionString = ""; + error = ""; + if (!string.IsNullOrWhiteSpace(_config.DbConnectionString)) + { + connectionString = _config.DbConnectionString.Trim(); + return true; + } + + if (string.IsNullOrWhiteSpace(_config.DbHost) || + string.IsNullOrWhiteSpace(_config.DbUsername) || + string.IsNullOrWhiteSpace(_config.DbName)) + { + error = "DbHost/DbUsername/DbName are required when DbConnectionString is empty."; + return false; + } + + var b = new MySqlConnectionStringBuilder + { + Server = _config.DbHost.Trim(), + Port = (uint)(_config.DbPort > 0 ? _config.DbPort : 3306), + Database = _config.DbName.Trim(), + UserID = _config.DbUsername.Trim(), + Password = _config.DbPassword ?? "", + DefaultCommandTimeout = 30 + }; + connectionString = b.ConnectionString; + return true; + } +} diff --git a/Data/MySqlRepositories.cs b/Data/MySqlRepositories.cs new file mode 100644 index 0000000..53414a4 --- /dev/null +++ b/Data/MySqlRepositories.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using MySql.Data.MySqlClient; + +namespace HikvisionAttendanceService.Data; + +internal sealed class MySqlAttendanceMachineRepository : IAttendanceMachineRepository +{ + private readonly IHrmsDbConnectionFactory _factory; + public MySqlAttendanceMachineRepository(IHrmsDbConnectionFactory factory) { _factory = factory; } + + public List GetActiveMachines(string? machineTypeFilter, out string error) + { + error = ""; + var rows = new List(); + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return rows; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + string sql = "SELECT machine_id,machine_ip,port_number,machine_name,site_id,machine_status,machine_type,status,last_sync_date,total_users " + + "FROM attendance_machine WHERE (machine_status='active' OR machine_status='1' OR machine_status=1)"; + if (!string.IsNullOrWhiteSpace(machineTypeFilter)) + sql += " AND machine_type=@machineType"; + using var cmd = new MySqlCommand(sql, conn); + if (!string.IsNullOrWhiteSpace(machineTypeFilter)) + cmd.Parameters.AddWithValue("@machineType", machineTypeFilter); + using var rd = cmd.ExecuteReader(); + while (rd.Read()) + { + rows.Add(new AttendanceMachineRow + { + MachineId = rd["machine_id"]?.ToString() ?? "", + MachineIp = rd["machine_ip"]?.ToString() ?? "", + PortNumber = rd["port_number"] == DBNull.Value ? 8000 : Convert.ToInt32(rd["port_number"]), + MachineName = rd["machine_name"]?.ToString() ?? "", + SiteId = rd["site_id"] == DBNull.Value ? (int?)null : Convert.ToInt32(rd["site_id"]), + MachineStatus = rd["machine_status"]?.ToString() ?? "", + MachineType = rd["machine_type"]?.ToString() ?? "", + Status = rd["status"]?.ToString() ?? "", + LastSyncDate = rd["last_sync_date"] == DBNull.Value ? (DateTime?)null : Convert.ToDateTime(rd["last_sync_date"]), + TotalUsers = rd["total_users"] == DBNull.Value ? 0 : Convert.ToInt32(rd["total_users"]) + }); + } + } + catch (Exception ex) { error = ex.Message; } + return rows; + } + + public void UpdateMachineSyncState(string machineIp, string status, DateTime? lastSyncDate, int? totalUsers, out string error) + { + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "UPDATE attendance_machine SET status=@status,last_sync_date=COALESCE(@last_sync_date,last_sync_date),total_users=COALESCE(@total_users,total_users) WHERE machine_ip=@machine_ip", + conn); + cmd.Parameters.AddWithValue("@status", status ?? ""); + cmd.Parameters.AddWithValue("@last_sync_date", (object?)lastSyncDate ?? DBNull.Value); + cmd.Parameters.AddWithValue("@total_users", (object?)totalUsers ?? DBNull.Value); + cmd.Parameters.AddWithValue("@machine_ip", machineIp ?? ""); + cmd.ExecuteNonQuery(); + } + catch (Exception ex) { error = ex.Message; } + } + + public bool TryGetMachineLastSyncDate(string machineIp, out DateTime? lastSyncDate, out string error) + { + lastSyncDate = null; + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "SELECT last_sync_date FROM attendance_machine WHERE machine_ip=@machine_ip LIMIT 1", + conn); + cmd.Parameters.AddWithValue("@machine_ip", machineIp ?? ""); + var obj = cmd.ExecuteScalar(); + if (obj == null || obj == DBNull.Value) + return true; + lastSyncDate = Convert.ToDateTime(obj); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + public bool UpdateMachineLastSyncDate(string machineIp, DateTime lastSyncDate, out string error) + { + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "UPDATE attendance_machine SET last_sync_date=@last_sync_date WHERE machine_ip=@machine_ip", + conn); + cmd.Parameters.AddWithValue("@last_sync_date", lastSyncDate); + cmd.Parameters.AddWithValue("@machine_ip", machineIp ?? ""); + cmd.ExecuteNonQuery(); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } +} + +internal sealed class MySqlAttendanceLogRepository : IAttendanceLogRepository +{ + private readonly IHrmsDbConnectionFactory _factory; + public MySqlAttendanceLogRepository(IHrmsDbConnectionFactory factory) { _factory = factory; } + + public bool UpsertAttendance(string acNo, DateTime checkTime, int processed, string machineId, int inOutTypeId, string machineIp, DateTime dateOnly, out string error) + { + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "REPLACE INTO attendance_log (ac_no,checktime,processed,machine_id,in_out_type_id,machine_ip,date) " + + "VALUES (@ac_no,@checktime,@processed,@machine_id,@in_out_type_id,@machine_ip,@date)", + conn); + cmd.Parameters.AddWithValue("@ac_no", acNo ?? ""); + cmd.Parameters.AddWithValue("@checktime", checkTime); + cmd.Parameters.AddWithValue("@processed", processed); + cmd.Parameters.AddWithValue("@machine_id", machineId ?? ""); + cmd.Parameters.AddWithValue("@in_out_type_id", inOutTypeId); + cmd.Parameters.AddWithValue("@machine_ip", machineIp ?? ""); + cmd.Parameters.AddWithValue("@date", dateOnly.Date); + cmd.ExecuteNonQuery(); + return true; + } + catch (Exception ex) { error = ex.Message; return false; } + } +} + +internal sealed class MySqlAttendanceMachineUserRepository : IAttendanceMachineUserRepository +{ + private readonly IHrmsDbConnectionFactory _factory; + public MySqlAttendanceMachineUserRepository(IHrmsDbConnectionFactory factory) { _factory = factory; } + + public List GetActiveUsersByMachine(string machineId, out string error) => + QueryUsers(machineId, false, out error); + + public List GetPendingDeletionUsersByMachine(string machineId, out string error) => + QueryUsers(machineId, true, out error); + + private List QueryUsers(string machineId, bool deletionRequested, out string error) + { + error = ""; + var rows = new List(); + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return rows; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "SELECT machine_id,serial_number,employee_name,is_deleted,is_deletion_requested FROM attendance_machine_user " + + "WHERE machine_id=@machine_id AND is_deleted=0 AND is_deletion_requested=@is_deletion_requested", + conn); + cmd.Parameters.AddWithValue("@machine_id", machineId ?? ""); + cmd.Parameters.AddWithValue("@is_deletion_requested", deletionRequested ? 1 : 0); + using var rd = cmd.ExecuteReader(); + while (rd.Read()) + { + rows.Add(new AttendanceMachineUserRow + { + MachineId = rd["machine_id"]?.ToString() ?? "", + SerialNumber = rd["serial_number"]?.ToString() ?? "", + EmployeeName = rd["employee_name"]?.ToString() ?? "", + IsDeleted = Convert.ToInt32(rd["is_deleted"]) != 0, + IsDeletionRequested = Convert.ToInt32(rd["is_deletion_requested"]) != 0 + }); + } + } + catch (Exception ex) { error = ex.Message; } + return rows; + } + + public bool UpsertMachineUser(string machineId, string serialNumber, string employeeName, out string error) + { + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "REPLACE INTO attendance_machine_user (machine_id,serial_number,employee_name,is_deleted,is_deletion_requested,updated_at) " + + "VALUES (@machine_id,@serial_number,@employee_name,0,0,NOW())", conn); + cmd.Parameters.AddWithValue("@machine_id", machineId ?? ""); + cmd.Parameters.AddWithValue("@serial_number", serialNumber ?? ""); + cmd.Parameters.AddWithValue("@employee_name", employeeName ?? ""); + cmd.ExecuteNonQuery(); + return true; + } + catch (Exception ex) { error = ex.Message; return false; } + } + + public bool MarkDeleted(string machineId, string serialNumber, string updatedBy, out string error) + { + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "UPDATE attendance_machine_user SET is_deleted=1,is_deletion_requested=0,updated_by=@updated_by,updated_at=NOW() " + + "WHERE machine_id=@machine_id AND serial_number=@serial_number", conn); + cmd.Parameters.AddWithValue("@updated_by", updatedBy ?? "hikvision-service"); + cmd.Parameters.AddWithValue("@machine_id", machineId ?? ""); + cmd.Parameters.AddWithValue("@serial_number", serialNumber ?? ""); + cmd.ExecuteNonQuery(); + return true; + } + catch (Exception ex) { error = ex.Message; return false; } + } +} + +internal sealed class MySqlAttendanceMachineFaceTemplateRepository : IAttendanceMachineFaceTemplateRepository +{ + private readonly IHrmsDbConnectionFactory _factory; + public MySqlAttendanceMachineFaceTemplateRepository(IHrmsDbConnectionFactory factory) { _factory = factory; } + + public bool UpsertFaceTemplate(string serialNo, byte[] template, DateTime createdDate, bool isActive, out string error) + { + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "REPLACE INTO attendance_machine_face_templates (serial_no,template,created_date,is_active) " + + "VALUES (@serial_no,@template,@created_date,@is_active)", conn); + cmd.Parameters.AddWithValue("@serial_no", serialNo ?? ""); + cmd.Parameters.Add("@template", MySqlDbType.Blob).Value = (object?)template ?? Array.Empty(); + cmd.Parameters.AddWithValue("@created_date", createdDate); + cmd.Parameters.AddWithValue("@is_active", isActive ? 1 : 0); + cmd.ExecuteNonQuery(); + return true; + } + catch (Exception ex) { error = ex.Message; return false; } + } + + public bool TryGetActiveFaceTemplateBySerial(string serialNo, out AttendanceMachineFaceTemplateRow? row, out string error) + { + row = null; + error = ""; + if (!_factory.TryBuildConnectionString(out var cs, out error)) + return false; + try + { + using var conn = new MySqlConnection(cs); + conn.Open(); + using var cmd = new MySqlCommand( + "SELECT serial_no,template,created_date,is_active FROM attendance_machine_face_templates " + + "WHERE serial_no=@serial_no AND is_active=1 ORDER BY created_date DESC LIMIT 1", conn); + cmd.Parameters.AddWithValue("@serial_no", serialNo ?? ""); + using var rd = cmd.ExecuteReader(); + if (!rd.Read()) + return false; + row = new AttendanceMachineFaceTemplateRow + { + SerialNo = rd["serial_no"]?.ToString() ?? "", + Template = rd["template"] == DBNull.Value ? Array.Empty() : (byte[])rd["template"], + CreatedDate = rd["created_date"] == DBNull.Value ? DateTime.MinValue : Convert.ToDateTime(rd["created_date"]), + IsActive = rd["is_active"] != DBNull.Value && Convert.ToInt32(rd["is_active"]) != 0 + }; + return true; + } + catch (Exception ex) { error = ex.Message; return false; } + } +} diff --git a/HikvisionAttendanceService.csproj b/HikvisionAttendanceService.csproj index 85a8c79..c9790fa 100644 --- a/HikvisionAttendanceService.csproj +++ b/HikvisionAttendanceService.csproj @@ -22,13 +22,10 @@ - - + + - + @@ -51,4 +48,10 @@ + + + 9.6.0 + + + diff --git a/HikvisionAttendanceWindowsService.cs b/HikvisionAttendanceWindowsService.cs index d68000b..b5e4ce6 100644 --- a/HikvisionAttendanceWindowsService.cs +++ b/HikvisionAttendanceWindowsService.cs @@ -78,6 +78,48 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase [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; } + + [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; } @@ -177,6 +219,20 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase { LogDirectory = @"C:\Users\Public\HikvisionAttendanceService", SqlConnectionString = "", + EnableDbIntegration = false, + EnableDbMachineLoading = false, + PreferDbMachinesOverConfig = false, + EnableAttendanceDbPersistence = false, + EnableTemplateDbPersistence = false, + EnableTemplateDbToDeviceSync = false, + KeepAttendanceFileExport = true, + KeepTemplateFiles = true, + DbHost = "", + DbPort = 3306, + DbName = "hrms", + DbUsername = "", + DbPassword = "", + DbConnectionString = "", EnableDatabasePersistence = false, AttendanceTextFilePath = "", AttendanceTableName = "dbo.HikvisionAttendanceEvents", @@ -211,6 +267,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(raw))) { var cfg = (HikvisionServiceConfig)serializer.ReadObject(ms); + ApplyEnvironmentOverrides(cfg); NormalizeLoadedDeviceConfig(cfg); return cfg; } @@ -222,6 +279,7 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(sanitized))) { var cfg = (HikvisionServiceConfig)serializer.ReadObject(ms); + ApplyEnvironmentOverrides(cfg); NormalizeLoadedDeviceConfig(cfg); return cfg; } @@ -233,6 +291,20 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase { LogDirectory = @"C:\Users\Public\HikvisionAttendanceService", SqlConnectionString = "", + EnableDbIntegration = false, + EnableDbMachineLoading = false, + PreferDbMachinesOverConfig = false, + EnableAttendanceDbPersistence = false, + EnableTemplateDbPersistence = false, + EnableTemplateDbToDeviceSync = false, + KeepAttendanceFileExport = true, + KeepTemplateFiles = true, + DbHost = "", + DbPort = 3306, + DbName = "hrms", + DbUsername = "", + DbPassword = "", + DbConnectionString = "", EnableDatabasePersistence = false, AttendanceTextFilePath = "", AttendanceTableName = "dbo.HikvisionAttendanceEvents", @@ -347,6 +419,34 @@ public sealed class HikvisionAttendanceWindowsService : ServiceBase 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; + } + private static void NormalizeLoadedDeviceConfig(HikvisionServiceConfig? cfg) { if (cfg == null)