using System.Data; using System.Security.Cryptography; using Npgsql; namespace AutorentWinForms; public sealed record UserSession(int UserId, string Login, string FullName, string Role); public sealed class Database : IDisposable { private readonly string _connectionString; private UserSession? _session; public Database(string connectionString) { _connectionString = connectionString; } public void Initialize() { ExecuteNonQuery(SchemaSql); Seed(); SyncSequences(); } public void SetSession(UserSession session) { _session = session; ExecuteNonQuery( """ INSERT INTO app_context(key, value) VALUES('current_user', @value) ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value """, new() { ["@value"] = session.Login }); } public UserSession? Authenticate(string login, string password) { return login.Trim() switch { "garanin_gi" when password == "6qw#6xUyG%#" => new UserSession(1, "garanin_gi", "Администратор PostgreSQL", "admin"), "manager" when password == "Manager123!" => new UserSession(2, "manager", "Менеджер проката", "manager"), "shagavaliev_iv" when password == "75&n057$380" => new UserSession(3, "shagavaliev_iv", "Пользователь просмотра", "viewer"), _ => null }; } public DataTable Query(string sql, Dictionary? parameters = null) { using var connection = OpenConnection(); using var command = new NpgsqlCommand(sql, connection); AddParameters(command, parameters); using var reader = command.ExecuteReader(); var table = new DataTable(); table.Load(reader); return table; } public int ExecuteNonQuery(string sql, Dictionary? parameters = null) { using var connection = OpenConnection(); using var command = new NpgsqlCommand(sql, connection); AddParameters(command, parameters); return command.ExecuteNonQuery(); } public int ExecuteScalarInt(string sql, Dictionary? parameters = null) { using var connection = OpenConnection(); using var command = new NpgsqlCommand(sql, connection); AddParameters(command, parameters); var value = command.ExecuteScalar(); return value is null or DBNull ? 0 : Convert.ToInt32(value); } public void Audit(string action, string entity, int? entityId, string details) { ExecuteNonQuery( """ INSERT INTO audit_log(user_login, action, entity, entity_id, details) VALUES(@u, @a, @e, @id, @d) """, new() { ["@u"] = _session?.Login ?? "system", ["@a"] = action, ["@e"] = entity, ["@id"] = entityId, ["@d"] = details }); } public static string HashPassword(string password, string saltHex) { var salt = Convert.FromHexString(saltHex); var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 120_000, HashAlgorithmName.SHA256, 32); return Convert.ToHexString(hash); } public static string NewSalt() { Span salt = stackalloc byte[16]; RandomNumberGenerator.Fill(salt); return Convert.ToHexString(salt); } private NpgsqlConnection OpenConnection() { var connection = new NpgsqlConnection(_connectionString); connection.Open(); return connection; } private static void AddParameters(NpgsqlCommand command, Dictionary? parameters) { if (parameters is null) return; foreach (var (name, value) in parameters) { var parameterName = name.StartsWith('@') ? name : "@" + name; command.Parameters.AddWithValue(parameterName, value ?? DBNull.Value); } } private void Seed() { if (ExecuteScalarInt("SELECT COUNT(*) FROM brand") == 0) { ExecuteNonQuery(""" INSERT INTO brand(name,country) VALUES ('Toyota','Япония'),('BMW','Германия'),('Lada','Россия'),('Hyundai','Корея'), ('Mercedes-Benz','Германия'),('Kia','Корея'),('Ford','США'),('Skoda','Чехия'); INSERT INTO car(license_plate,brand_id,year,daily_rate,status) VALUES ('А123БВ77',1,2021,3500,'Свободен'),('В456ГД77',2,2020,5500,'В аренде'), ('Е789ЖЗ99',3,2019,1800,'Свободен'),('К321ЛМ50',4,2022,2900,'На ТО'), ('М001НО77',5,2023,7200,'Свободен'),('Р555СТ77',6,2021,2600,'Свободен'), ('У888ФХ50',1,2020,3200,'В аренде'),('Х111ЦЧ77',7,2018,3000,'Свободен'), ('Ш222ЩЭ99',8,2022,2400,'На ТО'),('Ю333ЯА77',4,2023,3100,'Свободен'); INSERT INTO client(full_name,phone,address,license_no) VALUES ('Иванов Алексей Петрович','+7(916)123-45-67','Москва, ул. Ленина, 10','77АА123456'), ('Смирнова Елена Игоревна','+7(903)987-65-43','Москва, пр. Мира, 55','77ББ654321'), ('Козлов Дмитрий Владимирович','+7(925)555-11-22','Мытищи, ул. Садовая, 3','50ВВ112233'), ('Новикова Анна Сергеевна','+7(499)321-00-11','Москва, ул. Тверская, 22','77ГГ445566'), ('Фёдоров Игорь Николаевич','+7(926)777-88-99','Люберцы, ул. Парковая, 7','50ДД778899'), ('Белова Марина Андреевна','+7(915)100-20-30','Москва, ул. Арбат, 15','77ЕЕ001122'), ('Тимофеев Сергей Юрьевич','+7(977)333-44-55','Химки, ул. Речная, 21','50ЖЖ334455'), ('Орлова Юлия Викторовна','+7(968)666-77-88','Москва, пр. Вернадского, 88','77ЗЗ667788'); INSERT INTO rental(car_id,client_id,start_date,end_date,actual_return,total_cost,status) VALUES (2,1,'2025-04-01','2025-04-07','2025-04-07',33000,'Завершена'), (1,2,'2025-04-15','2025-04-20','2025-04-20',17500,'Завершена'), (3,3,'2025-04-10','2025-04-15','2025-04-15',9000,'Завершена'), (2,4,'2025-05-01','2025-05-07',NULL,38500,'Активна'), (7,5,'2025-05-10','2025-05-17',NULL,22400,'Активна'), (5,1,'2025-03-20','2025-03-25','2025-03-25',36000,'Завершена'), (6,6,'2025-04-22','2025-04-28','2025-04-28',15600,'Завершена'), (8,7,'2025-05-12','2025-05-15',NULL,9000,'Активна'), (1,8,'2025-02-01','2025-02-05','2025-02-06',17500,'Завершена'), (3,2,'2025-05-18','2025-05-23',NULL,9000,'Активна'); """); } if (ExecuteScalarInt("SELECT COUNT(*) FROM app_user") == 0) { CreateUser("admin", "Администратор БД", "admin", "Admin123!"); CreateUser("manager", "Менеджер проката", "manager", "Manager123!"); CreateUser("viewer", "Аналитик", "viewer", "Viewer123!"); } } private void SyncSequences() { ExecuteNonQuery(""" SELECT setval(pg_get_serial_sequence('brand', 'brand_id'), COALESCE((SELECT MAX(brand_id) FROM brand), 1), true) WHERE pg_get_serial_sequence('brand', 'brand_id') IS NOT NULL; SELECT setval(pg_get_serial_sequence('car', 'car_id'), COALESCE((SELECT MAX(car_id) FROM car), 1), true) WHERE pg_get_serial_sequence('car', 'car_id') IS NOT NULL; SELECT setval(pg_get_serial_sequence('client', 'client_id'), COALESCE((SELECT MAX(client_id) FROM client), 1), true) WHERE pg_get_serial_sequence('client', 'client_id') IS NOT NULL; SELECT setval(pg_get_serial_sequence('rental', 'rental_id'), COALESCE((SELECT MAX(rental_id) FROM rental), 1), true) WHERE pg_get_serial_sequence('rental', 'rental_id') IS NOT NULL; SELECT setval(pg_get_serial_sequence('app_user', 'user_id'), COALESCE((SELECT MAX(user_id) FROM app_user), 1), true) WHERE pg_get_serial_sequence('app_user', 'user_id') IS NOT NULL; """); } private void CreateUser(string login, string fullName, string role, string password) { var salt = NewSalt(); ExecuteNonQuery( """ INSERT INTO app_user(login, full_name, role, password_hash, password_salt) VALUES(@l, @n, @r, @h, @s) """, new() { ["@l"] = login, ["@n"] = fullName, ["@r"] = role, ["@h"] = HashPassword(password, salt), ["@s"] = salt }); } public void Dispose() { } private const string SchemaSql = """ SET search_path TO garanin_gi, public; CREATE TABLE IF NOT EXISTS app_context( key TEXT PRIMARY KEY, value TEXT NOT NULL ); INSERT INTO app_context(key,value) VALUES('current_user','system') ON CONFLICT(key) DO NOTHING; CREATE TABLE IF NOT EXISTS brand( brand_id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, country VARCHAR(50) ); CREATE TABLE IF NOT EXISTS car( car_id SERIAL PRIMARY KEY, license_plate VARCHAR(15) NOT NULL UNIQUE, brand_id INT NOT NULL REFERENCES brand(brand_id) ON UPDATE CASCADE ON DELETE RESTRICT, year SMALLINT CHECK(year BETWEEN 1990 AND 2030), daily_rate NUMERIC(10,2) NOT NULL CHECK(daily_rate > 0), status VARCHAR(20) NOT NULL DEFAULT 'Свободен' CHECK(status IN ('Свободен','В аренде','На ТО')) ); CREATE TABLE IF NOT EXISTS client( client_id SERIAL PRIMARY KEY, full_name VARCHAR(100) NOT NULL, phone VARCHAR(20) UNIQUE, address TEXT, license_no VARCHAR(20) UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS rental( rental_id SERIAL PRIMARY KEY, car_id INT NOT NULL REFERENCES car(car_id) ON UPDATE CASCADE ON DELETE RESTRICT, client_id INT NOT NULL REFERENCES client(client_id) ON UPDATE CASCADE ON DELETE RESTRICT, start_date DATE NOT NULL, end_date DATE NOT NULL CHECK(end_date >= start_date), actual_return DATE, total_cost NUMERIC(10,2) CHECK(total_cost >= 0), status VARCHAR(20) NOT NULL DEFAULT 'Активна' CHECK(status IN ('Активна','Завершена')) ); CREATE INDEX IF NOT EXISTS idx_car_brand_id ON car(brand_id); CREATE INDEX IF NOT EXISTS idx_rental_car_id ON rental(car_id); CREATE INDEX IF NOT EXISTS idx_rental_client_id ON rental(client_id); CREATE INDEX IF NOT EXISTS idx_rental_dates ON rental(start_date, end_date); CREATE INDEX IF NOT EXISTS idx_rental_status ON rental(status); CREATE TABLE IF NOT EXISTS app_user( user_id SERIAL PRIMARY KEY, login VARCHAR(50) NOT NULL UNIQUE, full_name VARCHAR(100) NOT NULL, role VARCHAR(20) NOT NULL CHECK(role IN ('admin','manager','viewer')), password_hash TEXT NOT NULL, password_salt TEXT NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true ); CREATE TABLE IF NOT EXISTS audit_log( audit_id BIGSERIAL PRIMARY KEY, changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), user_login TEXT NOT NULL DEFAULT 'system', action TEXT NOT NULL, entity TEXT NOT NULL, entity_id INT, details TEXT ); CREATE OR REPLACE VIEW v_rentals_full AS SELECT r.rental_id, c.full_name AS client_name, c.phone AS client_phone, ca.license_plate, b.name AS brand_name, r.start_date, r.end_date, r.actual_return, r.total_cost, r.status FROM rental r JOIN client c ON c.client_id = r.client_id JOIN car ca ON ca.car_id = r.car_id JOIN brand b ON b.brand_id = ca.brand_id; CREATE OR REPLACE VIEW v_available_cars AS SELECT ca.car_id, ca.license_plate, b.name AS brand, b.country, ca.year, ca.daily_rate FROM car ca JOIN brand b ON b.brand_id = ca.brand_id WHERE ca.status = 'Свободен'; CREATE OR REPLACE VIEW v_client_stats AS SELECT c.client_id, c.full_name, COUNT(r.rental_id) AS total_rentals, COALESCE(SUM(r.total_cost), 0) AS total_spent FROM client c LEFT JOIN rental r ON r.client_id = c.client_id GROUP BY c.client_id, c.full_name; CREATE OR REPLACE FUNCTION fn_autorent_current_user() RETURNS TEXT LANGUAGE sql AS $$ SELECT COALESCE((SELECT value FROM app_context WHERE key = 'current_user'), current_user::text) $$; CREATE OR REPLACE FUNCTION fn_autorent_audit() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE pk_name TEXT := TG_ARGV[0]; row_id INT; info TEXT; BEGIN IF TG_OP = 'INSERT' THEN row_id := (to_jsonb(NEW)->>pk_name)::INT; info := to_jsonb(NEW)::TEXT; INSERT INTO audit_log(user_login, action, entity, entity_id, details) VALUES(fn_autorent_current_user(), TG_OP, TG_TABLE_NAME, row_id, info); RETURN NEW; ELSIF TG_OP = 'UPDATE' THEN row_id := (to_jsonb(NEW)->>pk_name)::INT; info := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW))::TEXT; INSERT INTO audit_log(user_login, action, entity, entity_id, details) VALUES(fn_autorent_current_user(), TG_OP, TG_TABLE_NAME, row_id, info); RETURN NEW; ELSE row_id := (to_jsonb(OLD)->>pk_name)::INT; info := to_jsonb(OLD)::TEXT; INSERT INTO audit_log(user_login, action, entity, entity_id, details) VALUES(fn_autorent_current_user(), TG_OP, TG_TABLE_NAME, row_id, info); RETURN OLD; END IF; END; $$; DROP TRIGGER IF EXISTS trg_audit_brand ON brand; CREATE TRIGGER trg_audit_brand AFTER INSERT OR UPDATE OR DELETE ON brand FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('brand_id'); DROP TRIGGER IF EXISTS trg_audit_car ON car; CREATE TRIGGER trg_audit_car AFTER INSERT OR UPDATE OR DELETE ON car FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('car_id'); DROP TRIGGER IF EXISTS trg_audit_client ON client; CREATE TRIGGER trg_audit_client AFTER INSERT OR UPDATE OR DELETE ON client FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('client_id'); DROP TRIGGER IF EXISTS trg_audit_rental ON rental; CREATE TRIGGER trg_audit_rental AFTER INSERT OR UPDATE OR DELETE ON rental FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('rental_id'); """; }