Database.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. using System.Data;
  2. using System.Security.Cryptography;
  3. using Npgsql;
  4. namespace AutorentWinForms;
  5. public sealed record UserSession(int UserId, string Login, string FullName, string Role);
  6. public sealed class Database : IDisposable
  7. {
  8. private readonly string _connectionString;
  9. private UserSession? _session;
  10. public Database(string connectionString)
  11. {
  12. _connectionString = connectionString;
  13. }
  14. public void Initialize()
  15. {
  16. ExecuteNonQuery(SchemaSql);
  17. Seed();
  18. SyncSequences();
  19. }
  20. public void SetSession(UserSession session)
  21. {
  22. _session = session;
  23. ExecuteNonQuery(
  24. """
  25. INSERT INTO app_context(key, value) VALUES('current_user', @value)
  26. ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
  27. """,
  28. new() { ["@value"] = session.Login });
  29. }
  30. public UserSession? Authenticate(string login, string password)
  31. {
  32. return login.Trim() switch
  33. {
  34. "garanin_gi" when password == "6qw#6xUyG%#" =>
  35. new UserSession(1, "garanin_gi", "Администратор PostgreSQL", "admin"),
  36. "manager" when password == "Manager123!" =>
  37. new UserSession(2, "manager", "Менеджер проката", "manager"),
  38. "shagavaliev_iv" when password == "75&n057$380" =>
  39. new UserSession(3, "shagavaliev_iv", "Пользователь просмотра", "viewer"),
  40. _ => null
  41. };
  42. }
  43. public DataTable Query(string sql, Dictionary<string, object?>? parameters = null)
  44. {
  45. using var connection = OpenConnection();
  46. using var command = new NpgsqlCommand(sql, connection);
  47. AddParameters(command, parameters);
  48. using var reader = command.ExecuteReader();
  49. var table = new DataTable();
  50. table.Load(reader);
  51. return table;
  52. }
  53. public int ExecuteNonQuery(string sql, Dictionary<string, object?>? parameters = null)
  54. {
  55. using var connection = OpenConnection();
  56. using var command = new NpgsqlCommand(sql, connection);
  57. AddParameters(command, parameters);
  58. return command.ExecuteNonQuery();
  59. }
  60. public int ExecuteScalarInt(string sql, Dictionary<string, object?>? parameters = null)
  61. {
  62. using var connection = OpenConnection();
  63. using var command = new NpgsqlCommand(sql, connection);
  64. AddParameters(command, parameters);
  65. var value = command.ExecuteScalar();
  66. return value is null or DBNull ? 0 : Convert.ToInt32(value);
  67. }
  68. public void Audit(string action, string entity, int? entityId, string details)
  69. {
  70. ExecuteNonQuery(
  71. """
  72. INSERT INTO audit_log(user_login, action, entity, entity_id, details)
  73. VALUES(@u, @a, @e, @id, @d)
  74. """,
  75. new()
  76. {
  77. ["@u"] = _session?.Login ?? "system",
  78. ["@a"] = action,
  79. ["@e"] = entity,
  80. ["@id"] = entityId,
  81. ["@d"] = details
  82. });
  83. }
  84. public static string HashPassword(string password, string saltHex)
  85. {
  86. var salt = Convert.FromHexString(saltHex);
  87. var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 120_000, HashAlgorithmName.SHA256, 32);
  88. return Convert.ToHexString(hash);
  89. }
  90. public static string NewSalt()
  91. {
  92. Span<byte> salt = stackalloc byte[16];
  93. RandomNumberGenerator.Fill(salt);
  94. return Convert.ToHexString(salt);
  95. }
  96. private NpgsqlConnection OpenConnection()
  97. {
  98. var connection = new NpgsqlConnection(_connectionString);
  99. connection.Open();
  100. return connection;
  101. }
  102. private static void AddParameters(NpgsqlCommand command, Dictionary<string, object?>? parameters)
  103. {
  104. if (parameters is null) return;
  105. foreach (var (name, value) in parameters)
  106. {
  107. var parameterName = name.StartsWith('@') ? name : "@" + name;
  108. command.Parameters.AddWithValue(parameterName, value ?? DBNull.Value);
  109. }
  110. }
  111. private void Seed()
  112. {
  113. if (ExecuteScalarInt("SELECT COUNT(*) FROM brand") == 0)
  114. {
  115. ExecuteNonQuery("""
  116. INSERT INTO brand(name,country) VALUES
  117. ('Toyota','Япония'),('BMW','Германия'),('Lada','Россия'),('Hyundai','Корея'),
  118. ('Mercedes-Benz','Германия'),('Kia','Корея'),('Ford','США'),('Skoda','Чехия');
  119. INSERT INTO car(license_plate,brand_id,year,daily_rate,status) VALUES
  120. ('А123БВ77',1,2021,3500,'Свободен'),('В456ГД77',2,2020,5500,'В аренде'),
  121. ('Е789ЖЗ99',3,2019,1800,'Свободен'),('К321ЛМ50',4,2022,2900,'На ТО'),
  122. ('М001НО77',5,2023,7200,'Свободен'),('Р555СТ77',6,2021,2600,'Свободен'),
  123. ('У888ФХ50',1,2020,3200,'В аренде'),('Х111ЦЧ77',7,2018,3000,'Свободен'),
  124. ('Ш222ЩЭ99',8,2022,2400,'На ТО'),('Ю333ЯА77',4,2023,3100,'Свободен');
  125. INSERT INTO client(full_name,phone,address,license_no) VALUES
  126. ('Иванов Алексей Петрович','+7(916)123-45-67','Москва, ул. Ленина, 10','77АА123456'),
  127. ('Смирнова Елена Игоревна','+7(903)987-65-43','Москва, пр. Мира, 55','77ББ654321'),
  128. ('Козлов Дмитрий Владимирович','+7(925)555-11-22','Мытищи, ул. Садовая, 3','50ВВ112233'),
  129. ('Новикова Анна Сергеевна','+7(499)321-00-11','Москва, ул. Тверская, 22','77ГГ445566'),
  130. ('Фёдоров Игорь Николаевич','+7(926)777-88-99','Люберцы, ул. Парковая, 7','50ДД778899'),
  131. ('Белова Марина Андреевна','+7(915)100-20-30','Москва, ул. Арбат, 15','77ЕЕ001122'),
  132. ('Тимофеев Сергей Юрьевич','+7(977)333-44-55','Химки, ул. Речная, 21','50ЖЖ334455'),
  133. ('Орлова Юлия Викторовна','+7(968)666-77-88','Москва, пр. Вернадского, 88','77ЗЗ667788');
  134. INSERT INTO rental(car_id,client_id,start_date,end_date,actual_return,total_cost,status) VALUES
  135. (2,1,'2025-04-01','2025-04-07','2025-04-07',33000,'Завершена'),
  136. (1,2,'2025-04-15','2025-04-20','2025-04-20',17500,'Завершена'),
  137. (3,3,'2025-04-10','2025-04-15','2025-04-15',9000,'Завершена'),
  138. (2,4,'2025-05-01','2025-05-07',NULL,38500,'Активна'),
  139. (7,5,'2025-05-10','2025-05-17',NULL,22400,'Активна'),
  140. (5,1,'2025-03-20','2025-03-25','2025-03-25',36000,'Завершена'),
  141. (6,6,'2025-04-22','2025-04-28','2025-04-28',15600,'Завершена'),
  142. (8,7,'2025-05-12','2025-05-15',NULL,9000,'Активна'),
  143. (1,8,'2025-02-01','2025-02-05','2025-02-06',17500,'Завершена'),
  144. (3,2,'2025-05-18','2025-05-23',NULL,9000,'Активна');
  145. """);
  146. }
  147. if (ExecuteScalarInt("SELECT COUNT(*) FROM app_user") == 0)
  148. {
  149. CreateUser("admin", "Администратор БД", "admin", "Admin123!");
  150. CreateUser("manager", "Менеджер проката", "manager", "Manager123!");
  151. CreateUser("viewer", "Аналитик", "viewer", "Viewer123!");
  152. }
  153. }
  154. private void SyncSequences()
  155. {
  156. ExecuteNonQuery("""
  157. SELECT setval(pg_get_serial_sequence('brand', 'brand_id'), COALESCE((SELECT MAX(brand_id) FROM brand), 1), true)
  158. WHERE pg_get_serial_sequence('brand', 'brand_id') IS NOT NULL;
  159. SELECT setval(pg_get_serial_sequence('car', 'car_id'), COALESCE((SELECT MAX(car_id) FROM car), 1), true)
  160. WHERE pg_get_serial_sequence('car', 'car_id') IS NOT NULL;
  161. SELECT setval(pg_get_serial_sequence('client', 'client_id'), COALESCE((SELECT MAX(client_id) FROM client), 1), true)
  162. WHERE pg_get_serial_sequence('client', 'client_id') IS NOT NULL;
  163. SELECT setval(pg_get_serial_sequence('rental', 'rental_id'), COALESCE((SELECT MAX(rental_id) FROM rental), 1), true)
  164. WHERE pg_get_serial_sequence('rental', 'rental_id') IS NOT NULL;
  165. SELECT setval(pg_get_serial_sequence('app_user', 'user_id'), COALESCE((SELECT MAX(user_id) FROM app_user), 1), true)
  166. WHERE pg_get_serial_sequence('app_user', 'user_id') IS NOT NULL;
  167. """);
  168. }
  169. private void CreateUser(string login, string fullName, string role, string password)
  170. {
  171. var salt = NewSalt();
  172. ExecuteNonQuery(
  173. """
  174. INSERT INTO app_user(login, full_name, role, password_hash, password_salt)
  175. VALUES(@l, @n, @r, @h, @s)
  176. """,
  177. new()
  178. {
  179. ["@l"] = login,
  180. ["@n"] = fullName,
  181. ["@r"] = role,
  182. ["@h"] = HashPassword(password, salt),
  183. ["@s"] = salt
  184. });
  185. }
  186. public void Dispose()
  187. {
  188. }
  189. private const string SchemaSql = """
  190. SET search_path TO garanin_gi, public;
  191. CREATE TABLE IF NOT EXISTS app_context(
  192. key TEXT PRIMARY KEY,
  193. value TEXT NOT NULL
  194. );
  195. INSERT INTO app_context(key,value) VALUES('current_user','system')
  196. ON CONFLICT(key) DO NOTHING;
  197. CREATE TABLE IF NOT EXISTS brand(
  198. brand_id SERIAL PRIMARY KEY,
  199. name VARCHAR(50) NOT NULL UNIQUE,
  200. country VARCHAR(50)
  201. );
  202. CREATE TABLE IF NOT EXISTS car(
  203. car_id SERIAL PRIMARY KEY,
  204. license_plate VARCHAR(15) NOT NULL UNIQUE,
  205. brand_id INT NOT NULL REFERENCES brand(brand_id) ON UPDATE CASCADE ON DELETE RESTRICT,
  206. year SMALLINT CHECK(year BETWEEN 1990 AND 2030),
  207. daily_rate NUMERIC(10,2) NOT NULL CHECK(daily_rate > 0),
  208. status VARCHAR(20) NOT NULL DEFAULT 'Свободен' CHECK(status IN ('Свободен','В аренде','На ТО'))
  209. );
  210. CREATE TABLE IF NOT EXISTS client(
  211. client_id SERIAL PRIMARY KEY,
  212. full_name VARCHAR(100) NOT NULL,
  213. phone VARCHAR(20) UNIQUE,
  214. address TEXT,
  215. license_no VARCHAR(20) UNIQUE,
  216. created_at TIMESTAMPTZ NOT NULL DEFAULT now()
  217. );
  218. CREATE TABLE IF NOT EXISTS rental(
  219. rental_id SERIAL PRIMARY KEY,
  220. car_id INT NOT NULL REFERENCES car(car_id) ON UPDATE CASCADE ON DELETE RESTRICT,
  221. client_id INT NOT NULL REFERENCES client(client_id) ON UPDATE CASCADE ON DELETE RESTRICT,
  222. start_date DATE NOT NULL,
  223. end_date DATE NOT NULL CHECK(end_date >= start_date),
  224. actual_return DATE,
  225. total_cost NUMERIC(10,2) CHECK(total_cost >= 0),
  226. status VARCHAR(20) NOT NULL DEFAULT 'Активна' CHECK(status IN ('Активна','Завершена'))
  227. );
  228. CREATE INDEX IF NOT EXISTS idx_car_brand_id ON car(brand_id);
  229. CREATE INDEX IF NOT EXISTS idx_rental_car_id ON rental(car_id);
  230. CREATE INDEX IF NOT EXISTS idx_rental_client_id ON rental(client_id);
  231. CREATE INDEX IF NOT EXISTS idx_rental_dates ON rental(start_date, end_date);
  232. CREATE INDEX IF NOT EXISTS idx_rental_status ON rental(status);
  233. CREATE TABLE IF NOT EXISTS app_user(
  234. user_id SERIAL PRIMARY KEY,
  235. login VARCHAR(50) NOT NULL UNIQUE,
  236. full_name VARCHAR(100) NOT NULL,
  237. role VARCHAR(20) NOT NULL CHECK(role IN ('admin','manager','viewer')),
  238. password_hash TEXT NOT NULL,
  239. password_salt TEXT NOT NULL,
  240. is_active BOOLEAN NOT NULL DEFAULT true
  241. );
  242. CREATE TABLE IF NOT EXISTS audit_log(
  243. audit_id BIGSERIAL PRIMARY KEY,
  244. changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  245. user_login TEXT NOT NULL DEFAULT 'system',
  246. action TEXT NOT NULL,
  247. entity TEXT NOT NULL,
  248. entity_id INT,
  249. details TEXT
  250. );
  251. CREATE OR REPLACE VIEW v_rentals_full AS
  252. SELECT r.rental_id, c.full_name AS client_name, c.phone AS client_phone,
  253. ca.license_plate, b.name AS brand_name, r.start_date, r.end_date,
  254. r.actual_return, r.total_cost, r.status
  255. FROM rental r
  256. JOIN client c ON c.client_id = r.client_id
  257. JOIN car ca ON ca.car_id = r.car_id
  258. JOIN brand b ON b.brand_id = ca.brand_id;
  259. CREATE OR REPLACE VIEW v_available_cars AS
  260. SELECT ca.car_id, ca.license_plate, b.name AS brand, b.country, ca.year, ca.daily_rate
  261. FROM car ca JOIN brand b ON b.brand_id = ca.brand_id
  262. WHERE ca.status = 'Свободен';
  263. CREATE OR REPLACE VIEW v_client_stats AS
  264. SELECT c.client_id, c.full_name, COUNT(r.rental_id) AS total_rentals,
  265. COALESCE(SUM(r.total_cost), 0) AS total_spent
  266. FROM client c LEFT JOIN rental r ON r.client_id = c.client_id
  267. GROUP BY c.client_id, c.full_name;
  268. CREATE OR REPLACE FUNCTION fn_autorent_current_user()
  269. RETURNS TEXT
  270. LANGUAGE sql
  271. AS $$
  272. SELECT COALESCE((SELECT value FROM app_context WHERE key = 'current_user'), current_user::text)
  273. $$;
  274. CREATE OR REPLACE FUNCTION fn_autorent_audit()
  275. RETURNS TRIGGER
  276. LANGUAGE plpgsql
  277. AS $$
  278. DECLARE
  279. pk_name TEXT := TG_ARGV[0];
  280. row_id INT;
  281. info TEXT;
  282. BEGIN
  283. IF TG_OP = 'INSERT' THEN
  284. row_id := (to_jsonb(NEW)->>pk_name)::INT;
  285. info := to_jsonb(NEW)::TEXT;
  286. INSERT INTO audit_log(user_login, action, entity, entity_id, details)
  287. VALUES(fn_autorent_current_user(), TG_OP, TG_TABLE_NAME, row_id, info);
  288. RETURN NEW;
  289. ELSIF TG_OP = 'UPDATE' THEN
  290. row_id := (to_jsonb(NEW)->>pk_name)::INT;
  291. info := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW))::TEXT;
  292. INSERT INTO audit_log(user_login, action, entity, entity_id, details)
  293. VALUES(fn_autorent_current_user(), TG_OP, TG_TABLE_NAME, row_id, info);
  294. RETURN NEW;
  295. ELSE
  296. row_id := (to_jsonb(OLD)->>pk_name)::INT;
  297. info := to_jsonb(OLD)::TEXT;
  298. INSERT INTO audit_log(user_login, action, entity, entity_id, details)
  299. VALUES(fn_autorent_current_user(), TG_OP, TG_TABLE_NAME, row_id, info);
  300. RETURN OLD;
  301. END IF;
  302. END;
  303. $$;
  304. DROP TRIGGER IF EXISTS trg_audit_brand ON brand;
  305. CREATE TRIGGER trg_audit_brand AFTER INSERT OR UPDATE OR DELETE ON brand
  306. FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('brand_id');
  307. DROP TRIGGER IF EXISTS trg_audit_car ON car;
  308. CREATE TRIGGER trg_audit_car AFTER INSERT OR UPDATE OR DELETE ON car
  309. FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('car_id');
  310. DROP TRIGGER IF EXISTS trg_audit_client ON client;
  311. CREATE TRIGGER trg_audit_client AFTER INSERT OR UPDATE OR DELETE ON client
  312. FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('client_id');
  313. DROP TRIGGER IF EXISTS trg_audit_rental ON rental;
  314. CREATE TRIGGER trg_audit_rental AFTER INSERT OR UPDATE OR DELETE ON rental
  315. FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('rental_id');
  316. """;
  317. }