using System.Reflection; using System.Text.Json.Nodes; using Npgsql; using NpgsqlTypes; namespace JJServiceManagerRelay; public sealed record UserRow(long Id, string Username, string PasswordHash, string Role, bool MustChangePassword, bool Enabled, string Note, DateTime CreatedAt, DateTime? LastLoginAt) { public bool IsAdmin => Role == "admin"; } public sealed record DeviceRow(Guid Id, string Name, string Hostname, byte[] KeyHash, bool Enabled, string Note, DateTime EnrolledAt, DateTime? LastSeenAt, string LastPublicIp, string LanIpsJson, string ActiveListenJson, string AppVersion, string OsVersion); public sealed record Perm(bool View, bool Control, bool ScreenView, bool ScreenControl) { public static readonly Perm All = new(true, true, true, true); public static readonly Perm None = new(false, false, false, false); public bool Any => View || Control || ScreenView || ScreenControl; public JsonObject ToJson() => new() { ["view"] = View, ["control"] = Control, ["screenView"] = ScreenView, ["screenControl"] = ScreenControl }; } /// PostgreSQL 접근 public sealed class Db { private readonly NpgsqlDataSource _ds; private readonly ServerLog _log; public Db(string connectionString, ServerLog log) { _ds = NpgsqlDataSource.Create(connectionString); _log = log; } public async Task OpenAsync() => await _ds.OpenConnectionAsync(); // ───────────────────────── 시작: DB 기다리기 + 스키마 ───────────────────────── public async Task InitializeAsync(CancellationToken ct) { // PostgreSQL 컨테이너가 늦게 뜰 수 있으므로 최대 2분 기다림 for (int i = 1; ; i++) { try { await using var c = await _ds.OpenConnectionAsync(ct); break; } catch (Exception ex) when (i < 60 && !ct.IsCancellationRequested) { _log.Warn($"DB 연결 대기 중 ({i}/60): {ex.Message}"); await Task.Delay(2000, ct); } } await using var conn = await _ds.OpenConnectionAsync(ct); // 여러 서버가 동시에 올라와도 한 번만 적용되도록 잠금 await Exec(conn, "SELECT pg_advisory_lock(7200421)"); try { await Exec(conn, "CREATE TABLE IF NOT EXISTS server_settings (key text PRIMARY KEY, value text NOT NULL)"); int version = int.TryParse(await GetSetting(conn, "schema_version"), out var v) ? v : 0; var asm = Assembly.GetExecutingAssembly(); var scripts = asm.GetManifestResourceNames() .Where(n => n.Contains(".Schema.") && n.EndsWith(".sql")) .OrderBy(n => n, StringComparer.Ordinal) .ToList(); foreach (var name in scripts) { var file = name.Substring(name.IndexOf(".Schema.", StringComparison.Ordinal) + 8); int num = int.Parse(file.Split('_')[0]); if (num <= version) continue; using var sr = new StreamReader(asm.GetManifestResourceStream(name)!); var sql = await sr.ReadToEndAsync(ct); await using var tx = await conn.BeginTransactionAsync(ct); await using (var cmd = new NpgsqlCommand(sql, conn, tx)) await cmd.ExecuteNonQueryAsync(ct); await using (var cmd = new NpgsqlCommand( "INSERT INTO server_settings(key, value) VALUES ('schema_version', @v) ON CONFLICT (key) DO UPDATE SET value = excluded.value", conn, tx)) { cmd.Parameters.AddWithValue("v", num.ToString()); await cmd.ExecuteNonQueryAsync(ct); } await tx.CommitAsync(ct); _log.Info($"DB 스키마 적용: {file}"); version = num; } } finally { await Exec(conn, "SELECT pg_advisory_unlock(7200421)"); } } private static async Task Exec(NpgsqlConnection c, string sql) { await using var cmd = new NpgsqlCommand(sql, c); await cmd.ExecuteNonQueryAsync(); } private static async Task GetSetting(NpgsqlConnection c, string key) { await using var cmd = new NpgsqlCommand("SELECT value FROM server_settings WHERE key = @k", c); cmd.Parameters.AddWithValue("k", key); return (string)await cmd.ExecuteScalarAsync(); } public async Task GetSettingAsync(string key) { await using var c = await OpenAsync(); return await GetSetting(c, key); } /// 없을 때만 저장하고, 실제 저장된 값을 돌려줌 public async Task GetOrCreateSettingAsync(string key, Func create) { await using var c = await OpenAsync(); await using (var cmd = new NpgsqlCommand("INSERT INTO server_settings(key, value) VALUES (@k, @v) ON CONFLICT (key) DO NOTHING", c)) { cmd.Parameters.AddWithValue("k", key); cmd.Parameters.AddWithValue("v", create()); await cmd.ExecuteNonQueryAsync(); } return await GetSetting(c, key); } public async Task PingAsync() { try { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("SELECT 1", c); await cmd.ExecuteScalarAsync(); return true; } catch { return false; } } // ───────────────────────── 사용자 ───────────────────────── private const string UserCols = "id, username, password_hash, role, must_change_password, enabled, note, created_at, last_login_at"; private static UserRow ReadUser(NpgsqlDataReader r) => new( r.GetInt64(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetBoolean(4), r.GetBoolean(5), r.GetString(6), r.GetDateTime(7), r.IsDBNull(8) ? null : r.GetDateTime(8)); public async Task CountUsersAsync() { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("SELECT count(*) FROM users", c); return (long)await cmd.ExecuteScalarAsync(); } public async Task FindUserAsync(string username) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand($"SELECT {UserCols} FROM users WHERE lower(username) = lower(@u)", c); cmd.Parameters.AddWithValue("u", username ?? ""); await using var r = await cmd.ExecuteReaderAsync(); return await r.ReadAsync() ? ReadUser(r) : null; } public async Task GetUserAsync(long id) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand($"SELECT {UserCols} FROM users WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); await using var r = await cmd.ExecuteReaderAsync(); return await r.ReadAsync() ? ReadUser(r) : null; } public async Task> ListUsersAsync() { var list = new List(); await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand($"SELECT {UserCols} FROM users ORDER BY lower(username)", c); await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) list.Add(ReadUser(r)); return list; } public async Task CreateUserAsync(string username, string passwordHash, string role, bool mustChange, string note) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "INSERT INTO users(username, password_hash, role, must_change_password, note) VALUES (@u, @p, @r, @m, @n) RETURNING id", c); cmd.Parameters.AddWithValue("u", username); cmd.Parameters.AddWithValue("p", passwordHash); cmd.Parameters.AddWithValue("r", role); cmd.Parameters.AddWithValue("m", mustChange); cmd.Parameters.AddWithValue("n", note ?? ""); return (long)await cmd.ExecuteScalarAsync(); } public async Task UpdateUserAsync(long id, string role, bool? enabled, string note) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "UPDATE users SET role = coalesce(@r, role), enabled = coalesce(@e, enabled), note = coalesce(@n, note) WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); cmd.Parameters.Add(new NpgsqlParameter("r", NpgsqlDbType.Text) { Value = (object)role ?? DBNull.Value }); cmd.Parameters.Add(new NpgsqlParameter("e", NpgsqlDbType.Boolean) { Value = (object)enabled ?? DBNull.Value }); cmd.Parameters.Add(new NpgsqlParameter("n", NpgsqlDbType.Text) { Value = (object)note ?? DBNull.Value }); await cmd.ExecuteNonQueryAsync(); } public async Task SetPasswordAsync(long id, string passwordHash, bool mustChange) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("UPDATE users SET password_hash = @p, must_change_password = @m WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); cmd.Parameters.AddWithValue("p", passwordHash); cmd.Parameters.AddWithValue("m", mustChange); await cmd.ExecuteNonQueryAsync(); } public async Task TouchLoginAsync(long id) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("UPDATE users SET last_login_at = now() WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); await cmd.ExecuteNonQueryAsync(); } public async Task CountEnabledAdminsAsync() { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("SELECT count(*) FROM users WHERE role = 'admin' AND enabled", c); return (long)await cmd.ExecuteScalarAsync(); } // ───────────────────────── 로그인 토큰 ───────────────────────── public async Task CreateSessionAsync(byte[] tokenHash, long userId, TimeSpan life, string ip, string host) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "INSERT INTO user_sessions(token_hash, user_id, expires_at, client_ip, client_host) VALUES (@t, @u, now() + @l, @ip, @h)", c); cmd.Parameters.AddWithValue("t", tokenHash); cmd.Parameters.AddWithValue("u", userId); cmd.Parameters.AddWithValue("l", life); cmd.Parameters.AddWithValue("ip", ip ?? ""); cmd.Parameters.AddWithValue("h", host ?? ""); await cmd.ExecuteNonQueryAsync(); } /// 유효한 토큰이면 사용자 ID (만료 시간 연장) public async Task UseSessionAsync(byte[] tokenHash, TimeSpan life) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "UPDATE user_sessions SET expires_at = now() + @l WHERE token_hash = @t AND expires_at > now() RETURNING user_id", c); cmd.Parameters.AddWithValue("t", tokenHash); cmd.Parameters.AddWithValue("l", life); var v = await cmd.ExecuteScalarAsync(); return v == null ? null : (long)v; } public async Task DeleteSessionAsync(byte[] tokenHash) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("DELETE FROM user_sessions WHERE token_hash = @t", c); cmd.Parameters.AddWithValue("t", tokenHash); await cmd.ExecuteNonQueryAsync(); } public async Task DeleteUserSessionsAsync(long userId, byte[] exceptTokenHash = null) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "DELETE FROM user_sessions WHERE user_id = @u AND (@e::bytea IS NULL OR token_hash <> @e)", c); cmd.Parameters.AddWithValue("u", userId); cmd.Parameters.Add(new NpgsqlParameter("e", NpgsqlDbType.Bytea) { Value = (object)exceptTokenHash ?? DBNull.Value }); await cmd.ExecuteNonQueryAsync(); } /// 만료된 로그인 토큰 정리 (로그가 아니라 인증 정보이므로 정리함) public async Task PurgeExpiredSessionsAsync() { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("DELETE FROM user_sessions WHERE expires_at < now() - interval '1 day'", c); await cmd.ExecuteNonQueryAsync(); } // ───────────────────────── 기기 ───────────────────────── private const string DeviceCols = "id, name, hostname, key_hash, enabled, note, enrolled_at, last_seen_at, coalesce(last_public_ip, ''), lan_ips::text, coalesce(active_listen::text, ''), coalesce(app_version, ''), coalesce(os_version, '')"; private static DeviceRow ReadDevice(NpgsqlDataReader r) => new( r.GetGuid(0), r.GetString(1), r.GetString(2), (byte[])r[3], r.GetBoolean(4), r.GetString(5), r.GetDateTime(6), r.IsDBNull(7) ? null : r.GetDateTime(7), r.GetString(8), r.GetString(9), r.GetString(10), r.GetString(11), r.GetString(12)); public async Task GetDeviceAsync(Guid id) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand($"SELECT {DeviceCols} FROM devices WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); await using var r = await cmd.ExecuteReaderAsync(); return await r.ReadAsync() ? ReadDevice(r) : null; } public async Task> ListDevicesAsync() { var list = new List(); await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand($"SELECT {DeviceCols} FROM devices ORDER BY lower(name)", c); await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) list.Add(ReadDevice(r)); return list; } /// 등록 코드를 쓰고 기기를 만든다 (한 트랜잭션). 실패 사유 문자열 또는 null public async Task<(Guid id, long? createdBy, string error)> EnrollAsync(byte[] codeHash, Guid newId, string hostname, byte[] keyHash, string os, string appVersion) { await using var c = await OpenAsync(); await using var tx = await c.BeginTransactionAsync(); long codeId; long? createdBy; await using (var cmd = new NpgsqlCommand( "SELECT id, created_by, expires_at > now(), used_at IS NULL, revoked FROM enrollment_codes WHERE code_hash = @h FOR UPDATE", c, tx)) { cmd.Parameters.AddWithValue("h", codeHash); await using var r = await cmd.ExecuteReaderAsync(); if (!await r.ReadAsync()) return (Guid.Empty, null, "등록 코드가 올바르지 않습니다."); codeId = r.GetInt64(0); createdBy = r.IsDBNull(1) ? null : r.GetInt64(1); bool valid = r.GetBoolean(2), unused = r.GetBoolean(3), revoked = r.GetBoolean(4); if (revoked) return (Guid.Empty, null, "취소된 등록 코드입니다."); if (!unused) return (Guid.Empty, null, "이미 사용한 등록 코드입니다."); if (!valid) return (Guid.Empty, null, "유효 시간이 지난 등록 코드입니다."); } await using (var cmd = new NpgsqlCommand( "INSERT INTO devices(id, name, hostname, key_hash, enrolled_by, os_version, app_version) VALUES (@id, @n, @n, @k, @b, @os, @av)", c, tx)) { cmd.Parameters.AddWithValue("id", newId); cmd.Parameters.AddWithValue("n", hostname); cmd.Parameters.AddWithValue("k", keyHash); cmd.Parameters.Add(new NpgsqlParameter("b", NpgsqlDbType.Bigint) { Value = (object)createdBy ?? DBNull.Value }); cmd.Parameters.AddWithValue("os", os ?? ""); cmd.Parameters.AddWithValue("av", appVersion ?? ""); await cmd.ExecuteNonQueryAsync(); } await using (var cmd = new NpgsqlCommand("UPDATE enrollment_codes SET used_at = now(), used_device = @d WHERE id = @id", c, tx)) { cmd.Parameters.AddWithValue("id", codeId); cmd.Parameters.AddWithValue("d", newId); await cmd.ExecuteNonQueryAsync(); } await tx.CommitAsync(); return (newId, createdBy, null); } public async Task UpdateDeviceStateAsync(Guid id, string hostname, string publicIp, string lanIpsJson, string activeListenJson, string appVersion, string os) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( @"UPDATE devices SET hostname = coalesce(nullif(@h, ''), hostname), last_seen_at = now(), last_public_ip = @ip, lan_ips = @lan::jsonb, active_listen = @al::jsonb, app_version = coalesce(nullif(@av, ''), app_version), os_version = coalesce(nullif(@os, ''), os_version) WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); cmd.Parameters.AddWithValue("h", hostname ?? ""); cmd.Parameters.AddWithValue("ip", publicIp ?? ""); cmd.Parameters.AddWithValue("lan", lanIpsJson ?? "[]"); cmd.Parameters.Add(new NpgsqlParameter("al", NpgsqlDbType.Text) { Value = (object)activeListenJson ?? DBNull.Value }); cmd.Parameters.AddWithValue("av", appVersion ?? ""); cmd.Parameters.AddWithValue("os", os ?? ""); await cmd.ExecuteNonQueryAsync(); } public async Task TouchDeviceAsync(Guid id) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("UPDATE devices SET last_seen_at = now() WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); await cmd.ExecuteNonQueryAsync(); } public async Task RenameDeviceAsync(Guid id, string name, string note) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "UPDATE devices SET name = coalesce(@n, name), note = coalesce(@note, note) WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); cmd.Parameters.Add(new NpgsqlParameter("n", NpgsqlDbType.Text) { Value = (object)name ?? DBNull.Value }); cmd.Parameters.Add(new NpgsqlParameter("note", NpgsqlDbType.Text) { Value = (object)note ?? DBNull.Value }); await cmd.ExecuteNonQueryAsync(); } public async Task SetDeviceEnabledAsync(Guid id, bool enabled) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("UPDATE devices SET enabled = @e WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); cmd.Parameters.AddWithValue("e", enabled); await cmd.ExecuteNonQueryAsync(); } public async Task DeleteDeviceAsync(Guid id) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("DELETE FROM devices WHERE id = @id", c); cmd.Parameters.AddWithValue("id", id); await cmd.ExecuteNonQueryAsync(); } // ───────────────────────── 권한 ───────────────────────── public async Task GetPermAsync(long userId, Guid deviceId) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "SELECT can_view, can_control, can_screen_view, can_screen_control FROM user_device_permissions WHERE user_id = @u AND device_id = @d", c); cmd.Parameters.AddWithValue("u", userId); cmd.Parameters.AddWithValue("d", deviceId); await using var r = await cmd.ExecuteReaderAsync(); return await r.ReadAsync() ? new Perm(r.GetBoolean(0), r.GetBoolean(1), r.GetBoolean(2), r.GetBoolean(3)) : Perm.None; } public async Task> GetPermsForUserAsync(long userId) { var map = new Dictionary(); await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "SELECT device_id, can_view, can_control, can_screen_view, can_screen_control FROM user_device_permissions WHERE user_id = @u", c); cmd.Parameters.AddWithValue("u", userId); await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) map[r.GetGuid(0)] = new Perm(r.GetBoolean(1), r.GetBoolean(2), r.GetBoolean(3), r.GetBoolean(4)); return map; } public async Task SetPermAsync(long userId, Guid deviceId, Perm p) { await using var c = await OpenAsync(); if (!p.Any) { await using var del = new NpgsqlCommand("DELETE FROM user_device_permissions WHERE user_id = @u AND device_id = @d", c); del.Parameters.AddWithValue("u", userId); del.Parameters.AddWithValue("d", deviceId); await del.ExecuteNonQueryAsync(); return; } await using var cmd = new NpgsqlCommand( @"INSERT INTO user_device_permissions(user_id, device_id, can_view, can_control, can_screen_view, can_screen_control) VALUES (@u, @d, @v, @c, @sv, @sc) ON CONFLICT (user_id, device_id) DO UPDATE SET can_view = excluded.can_view, can_control = excluded.can_control, can_screen_view = excluded.can_screen_view, can_screen_control = excluded.can_screen_control", c); cmd.Parameters.AddWithValue("u", userId); cmd.Parameters.AddWithValue("d", deviceId); cmd.Parameters.AddWithValue("v", p.View); cmd.Parameters.AddWithValue("c", p.Control); cmd.Parameters.AddWithValue("sv", p.ScreenView); cmd.Parameters.AddWithValue("sc", p.ScreenControl); await cmd.ExecuteNonQueryAsync(); } // ───────────────────────── 등록 코드 ───────────────────────── public async Task CreateCodeAsync(byte[] codeHash, long createdBy, TimeSpan valid, string note) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "INSERT INTO enrollment_codes(code_hash, created_by, expires_at, note) VALUES (@h, @b, now() + @v, @n) RETURNING id", c); cmd.Parameters.AddWithValue("h", codeHash); cmd.Parameters.AddWithValue("b", createdBy); cmd.Parameters.AddWithValue("v", valid); cmd.Parameters.AddWithValue("n", note ?? ""); return (long)await cmd.ExecuteScalarAsync(); } public async Task ListCodesAsync() { var arr = new JsonArray(); await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( @"SELECT e.id, coalesce(u.username, ''), e.created_at, e.expires_at, e.used_at, coalesce(d.name, ''), e.revoked, e.note, e.expires_at > now() FROM enrollment_codes e LEFT JOIN users u ON u.id = e.created_by LEFT JOIN devices d ON d.id = e.used_device ORDER BY e.created_at DESC LIMIT 200", c); await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) { bool used = !r.IsDBNull(4), revoked = r.GetBoolean(6), valid = r.GetBoolean(8); arr.Add(new JsonObject { ["id"] = r.GetInt64(0), ["createdBy"] = r.GetString(1), ["createdAt"] = Iso(r.GetDateTime(2)), ["expiresAt"] = Iso(r.GetDateTime(3)), ["usedAt"] = used ? Iso(r.GetDateTime(4)) : null, ["usedDevice"] = r.GetString(5), ["note"] = r.GetString(7), ["status"] = used ? "used" : revoked ? "revoked" : valid ? "valid" : "expired" }); } return arr; } public async Task RevokeCodeAsync(long id) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("UPDATE enrollment_codes SET revoked = true WHERE id = @id AND used_at IS NULL", c); cmd.Parameters.AddWithValue("id", id); return await cmd.ExecuteNonQueryAsync() > 0; } // ───────────────────────── 감사 로그 ───────────────────────── public async Task AuditAsync(long? userId, string username, Guid? deviceId, string deviceName, string action, string target, string result, string detail, string ip) { try { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( @"INSERT INTO audit_logs(user_id, username, device_id, device_name, action, target, result, detail, source_ip) VALUES (@u, @un, @d, @dn, @a, @t, @r, @de, @ip)", c); cmd.Parameters.Add(new NpgsqlParameter("u", NpgsqlDbType.Bigint) { Value = (object)userId ?? DBNull.Value }); cmd.Parameters.AddWithValue("un", username ?? ""); cmd.Parameters.Add(new NpgsqlParameter("d", NpgsqlDbType.Uuid) { Value = (object)deviceId ?? DBNull.Value }); cmd.Parameters.AddWithValue("dn", deviceName ?? ""); cmd.Parameters.AddWithValue("a", action ?? ""); cmd.Parameters.AddWithValue("t", Cut(target, 500)); cmd.Parameters.AddWithValue("r", result ?? ""); cmd.Parameters.AddWithValue("de", Cut(detail, 2000)); cmd.Parameters.AddWithValue("ip", ip ?? ""); await cmd.ExecuteNonQueryAsync(); } catch (Exception ex) { _log.Error("감사 로그 저장 실패: " + ex.Message); } } public async Task QueryAuditAsync(DateTime? from, DateTime? to, long? userId, Guid? deviceId, string action, long? beforeId, int limit) { var arr = new JsonArray(); await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( @"SELECT id, at, coalesce(user_id, 0), username, device_id, device_name, action, target, result, detail, source_ip FROM audit_logs WHERE (@f::timestamptz IS NULL OR at >= @f) AND (@t::timestamptz IS NULL OR at < @t) AND (@u::bigint IS NULL OR user_id = @u) AND (@d::uuid IS NULL OR device_id = @d) AND (@a::text IS NULL OR action = @a) AND (@b::bigint IS NULL OR id < @b) ORDER BY id DESC LIMIT @l", c); AddFilters(cmd, from, to, userId, deviceId, action, beforeId, limit); await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) { arr.Add(new JsonObject { ["id"] = r.GetInt64(0), ["at"] = Iso(r.GetDateTime(1)), ["userId"] = r.GetInt64(2), ["username"] = r.GetString(3), ["deviceId"] = r.IsDBNull(4) ? null : r.GetGuid(4).ToString(), ["deviceName"] = r.GetString(5), ["action"] = r.GetString(6), ["target"] = r.GetString(7), ["result"] = r.GetString(8), ["detail"] = r.GetString(9), ["ip"] = r.GetString(10) }); } return arr; } private static void AddFilters(NpgsqlCommand cmd, DateTime? from, DateTime? to, long? userId, Guid? deviceId, string action, long? beforeId, int limit) { cmd.Parameters.Add(new NpgsqlParameter("f", NpgsqlDbType.TimestampTz) { Value = (object)from ?? DBNull.Value }); cmd.Parameters.Add(new NpgsqlParameter("t", NpgsqlDbType.TimestampTz) { Value = (object)to ?? DBNull.Value }); cmd.Parameters.Add(new NpgsqlParameter("u", NpgsqlDbType.Bigint) { Value = (object)userId ?? DBNull.Value }); cmd.Parameters.Add(new NpgsqlParameter("d", NpgsqlDbType.Uuid) { Value = (object)deviceId ?? DBNull.Value }); cmd.Parameters.Add(new NpgsqlParameter("a", NpgsqlDbType.Text) { Value = string.IsNullOrEmpty(action) ? DBNull.Value : action }); cmd.Parameters.Add(new NpgsqlParameter("b", NpgsqlDbType.Bigint) { Value = (object)beforeId ?? DBNull.Value }); cmd.Parameters.AddWithValue("l", Math.Clamp(limit, 1, 500)); } // ───────────────────────── 연결 기록 ───────────────────────── public async Task ConnectionStartAsync(Guid sessionId, long userId, string username, Guid deviceId, string deviceName, string managerIp) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( @"INSERT INTO connections(session_id, user_id, username, device_id, device_name, manager_ip) VALUES (@s, @u, @un, @d, @dn, @ip)", c); cmd.Parameters.AddWithValue("s", sessionId); cmd.Parameters.AddWithValue("u", userId); cmd.Parameters.AddWithValue("un", username ?? ""); cmd.Parameters.AddWithValue("d", deviceId); cmd.Parameters.AddWithValue("dn", deviceName ?? ""); cmd.Parameters.AddWithValue("ip", managerIp ?? ""); await cmd.ExecuteNonQueryAsync(); } public async Task ConnectionModeAsync(Guid sessionId, string mode) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("UPDATE connections SET mode = @m WHERE session_id = @s AND ended_at IS NULL", c); cmd.Parameters.AddWithValue("s", sessionId); cmd.Parameters.AddWithValue("m", mode); await cmd.ExecuteNonQueryAsync(); } public async Task ConnectionEndAsync(Guid sessionId, string mode, long toDevice, long fromDevice, string reason) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( @"UPDATE connections SET ended_at = now(), mode = coalesce(@m, mode), bytes_to_device = @td, bytes_from_device = @fd, end_reason = @r WHERE session_id = @s AND ended_at IS NULL", c); cmd.Parameters.AddWithValue("s", sessionId); cmd.Parameters.Add(new NpgsqlParameter("m", NpgsqlDbType.Text) { Value = (object)mode ?? DBNull.Value }); cmd.Parameters.AddWithValue("td", toDevice); cmd.Parameters.AddWithValue("fd", fromDevice); cmd.Parameters.AddWithValue("r", Cut(reason, 500)); await cmd.ExecuteNonQueryAsync(); } /// 서버가 재시작되어 끝을 못 적은 연결 정리 public async Task CloseDanglingConnectionsAsync() { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( "UPDATE connections SET ended_at = now(), end_reason = '서버 재시작' WHERE ended_at IS NULL", c); await cmd.ExecuteNonQueryAsync(); } public async Task ConnectionBelongsToAsync(Guid sessionId, long userId) { await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand("SELECT 1 FROM connections WHERE session_id = @s AND user_id = @u", c); cmd.Parameters.AddWithValue("s", sessionId); cmd.Parameters.AddWithValue("u", userId); return await cmd.ExecuteScalarAsync() != null; } public async Task QueryConnectionsAsync(DateTime? from, DateTime? to, long? userId, Guid? deviceId, long? beforeId, int limit) { var arr = new JsonArray(); await using var c = await OpenAsync(); await using var cmd = new NpgsqlCommand( @"SELECT id, session_id, coalesce(user_id, 0), username, device_id, device_name, started_at, ended_at, coalesce(mode, ''), bytes_to_device, bytes_from_device, end_reason, manager_ip FROM connections WHERE (@f::timestamptz IS NULL OR started_at >= @f) AND (@t::timestamptz IS NULL OR started_at < @t) AND (@u::bigint IS NULL OR user_id = @u) AND (@d::uuid IS NULL OR device_id = @d) AND (@a::text IS NULL OR mode = @a) AND (@b::bigint IS NULL OR id < @b) ORDER BY id DESC LIMIT @l", c); AddFilters(cmd, from, to, userId, deviceId, null, beforeId, limit); await using var r = await cmd.ExecuteReaderAsync(); while (await r.ReadAsync()) { arr.Add(new JsonObject { ["id"] = r.GetInt64(0), ["sessionId"] = r.GetGuid(1).ToString(), ["userId"] = r.GetInt64(2), ["username"] = r.GetString(3), ["deviceId"] = r.IsDBNull(4) ? null : r.GetGuid(4).ToString(), ["deviceName"] = r.GetString(5), ["startedAt"] = Iso(r.GetDateTime(6)), ["endedAt"] = r.IsDBNull(7) ? null : Iso(r.GetDateTime(7)), ["mode"] = r.GetString(8), ["bytesToDevice"] = r.GetInt64(9), ["bytesFromDevice"] = r.GetInt64(10), ["endReason"] = r.GetString(11), ["managerIp"] = r.GetString(12) }); } return arr; } // ───────────────────────── 공통 ───────────────────────── public static string Iso(DateTime t) => DateTime.SpecifyKind(t, DateTimeKind.Utc).ToString("o"); private static string Cut(string s, int max) => s == null ? "" : s.Length <= max ? s : s[..max]; }