JJServiceManagerRelay/server/Db.cs

697 lines
33 KiB
C#

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
};
}
/// <summary>PostgreSQL 접근</summary>
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<NpgsqlConnection> 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<string> 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<string> GetSettingAsync(string key)
{
await using var c = await OpenAsync();
return await GetSetting(c, key);
}
/// <summary>없을 때만 저장하고, 실제 저장된 값을 돌려줌</summary>
public async Task<string> GetOrCreateSettingAsync(string key, Func<string> 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<bool> 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<long> 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<UserRow> 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<UserRow> 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<List<UserRow>> ListUsersAsync()
{
var list = new List<UserRow>();
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<long> 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<long> 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();
}
/// <summary>유효한 토큰이면 사용자 ID (만료 시간 연장)</summary>
public async Task<long?> 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();
}
/// <summary>만료된 로그인 토큰 정리 (로그가 아니라 인증 정보이므로 정리함)</summary>
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<DeviceRow> 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<List<DeviceRow>> ListDevicesAsync()
{
var list = new List<DeviceRow>();
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;
}
/// <summary>등록 코드를 쓰고 기기를 만든다 (한 트랜잭션). 실패 사유 문자열 또는 null</summary>
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<Perm> 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<Dictionary<Guid, Perm>> GetPermsForUserAsync(long userId)
{
var map = new Dictionary<Guid, Perm>();
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<long> 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<JsonArray> 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<bool> 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<JsonArray> 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();
}
/// <summary>서버가 재시작되어 끝을 못 적은 연결 정리</summary>
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<bool> 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<JsonArray> 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];
}