using ExperionCrawler.Core.Application.Interfaces; using ExperionCrawler.Core.Domain.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Npgsql; using System.Text.Json; using System.Globalization; namespace ExperionCrawler.Infrastructure.Database; // ── DbContext ──────────────────────────────────────────────────────────────── public class ExperionDbContext : DbContext { public ExperionDbContext(DbContextOptions options) : base(options) { } public DbSet ExperionRecords => Set(); public DbSet RawNodeMaps => Set(); public DbSet NodeMapMasters => Set(); public DbSet RealtimePoints => Set(); public DbSet HistoryRecords => Set(); public DbSet FastSessions => Set(); public DbSet FastRecords => Set(); // P&ID 데이터베이스용 DbSet public DbSet PidEquipment => Set(); public DbSet PidAuditLog => Set(); public DbSet PidGraphStatuses => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(e => { e.HasKey(x => x.Id); e.HasIndex(x => x.CollectedAt); e.HasIndex(x => x.NodeId); e.HasIndex(x => x.SessionId); }); modelBuilder.Entity(e => { e.HasKey(x => x.Id); e.HasIndex(x => x.NodeId); }); modelBuilder.Entity(e => { e.HasKey(x => x.Id); e.HasIndex(x => x.NodeId); e.HasIndex(x => x.Level); }); modelBuilder.Entity(e => { e.HasKey(x => x.Id); e.HasIndex(x => x.NodeId).IsUnique(); e.HasIndex(x => x.TagName); }); modelBuilder.Entity(e => { e.HasKey(x => x.Id); e.HasIndex(x => x.TagName); e.HasIndex(x => x.RecordedAt); }); modelBuilder.Entity(e => { e.HasKey(x => x.Id); e.HasIndex(x => x.Status); e.HasIndex(x => x.StartedAt); e.Property(x => x.TagList).HasColumnType("jsonb"); }); modelBuilder.Entity(e => { e.HasKey(x => new { x.SessionId, x.RecordedAt, x.TagName }); e.HasIndex(x => x.SessionId); }); // P&ID 엔티티 설정 modelBuilder.Entity(entity => { entity.ToTable("pid_equipment"); entity.HasKey(e => e.Id); entity.Property(e => e.TagNo) .IsRequired() .HasMaxLength(50); entity.Property(e => e.EquipmentName) .HasMaxLength(200); entity.Property(e => e.InstrumentType) .HasMaxLength(10); entity.Property(e => e.LineNumber) .HasMaxLength(100); entity.Property(e => e.PidDrawingNo) .HasMaxLength(50); entity.Property(e => e.Confidence) .HasPrecision(4, 3); entity.Property(e => e.IsActive) .HasDefaultValue(true); entity.Property(e => e.ExtractedAt) .HasDefaultValueSql("NOW()"); entity.Property(e => e.UpdatedAt) .ValueGeneratedOnAddOrUpdate() .HasDefaultValueSql("NOW()"); entity.HasIndex(e => e.TagNo); entity.HasIndex(e => e.InstrumentType); entity.HasIndex(e => e.ExtractedAt); entity.HasOne(e => e.ExperionTag) .WithMany() .HasForeignKey(e => e.ExperionTagId) .OnDelete(DeleteBehavior.SetNull); }); modelBuilder.Entity(entity => { entity.ToTable("pid_audit_log"); entity.HasKey(e => e.Id); entity.Property(e => e.Source) .HasMaxLength(50) .HasDefaultValue("WebUI"); entity.Property(e => e.Action) .HasMaxLength(50); entity.Property(e => e.TargetTagNo) .HasMaxLength(50); entity.Property(e => e.LoggedAt) .HasDefaultValueSql("NOW()"); entity.HasIndex(e => e.LoggedAt); }); modelBuilder.Entity(entity => { entity.ToTable("pid_graph_status"); entity.HasKey(e => e.TaskId); entity.Property(e => e.Status) .HasMaxLength(20); entity.Property(e => e.Message) .HasMaxLength(500); entity.HasIndex(e => e.UpdatedAt); }); } } // ── Service ────────────────────────────────────────────────────────────────── public class ExperionDbService : IExperionDbService { private readonly ExperionDbContext _ctx; private readonly ILogger _logger; public ExperionDbService(ExperionDbContext ctx, ILogger logger) { _ctx = ctx; _logger = logger; } public async Task InitializeAsync() { try { await _ctx.Database.EnsureCreatedAsync(); // ── fast_session / fast_record 테이블 생성 ──────────────────────────────── await _ctx.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS fast_session ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, started_at TIMESTAMPTZ NOT NULL, ended_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'Pending', sampling_ms INTEGER NOT NULL, duration_sec INTEGER NOT NULL, tag_list JSONB NOT NULL DEFAULT '[]', row_count INTEGER NOT NULL DEFAULT 0, retention_days INTEGER, pinned BOOLEAN NOT NULL DEFAULT FALSE ) """); await _ctx.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS fast_record ( session_id INTEGER NOT NULL REFERENCES fast_session(id) ON DELETE CASCADE, recorded_at TIMESTAMPTZ NOT NULL, tagname TEXT NOT NULL, value TEXT, PRIMARY KEY (session_id, recorded_at, tagname) ) """); // PK 마이그레이션: 기존 테이블 PK에 recorded_at 없으면 수정 (TimescaleDB hypertable 요건) await _ctx.Database.ExecuteSqlRawAsync(""" DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'fast_record' AND schemaname = 'public') AND NOT EXISTS ( SELECT 1 FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey) WHERE t.relname = 'fast_record' AND c.contype = 'p' AND a.attname = 'recorded_at' ) THEN ALTER TABLE fast_record DROP CONSTRAINT IF EXISTS fast_record_pkey; ALTER TABLE fast_record ADD PRIMARY KEY (session_id, recorded_at, tagname); END IF; END $$; """); // TimescaleDB hypertable 생성 (recorded_at 기준, chunk_interval = 1 day) // create_default_indexes => FALSE: PK 인덱스를 TimescaleDB가 중복 생성하지 않도록 함 await _ctx.Database.ExecuteSqlRawAsync(""" SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE, migrate_data => TRUE, create_default_indexes => FALSE) """); await _ctx.Database.ExecuteSqlRawAsync(""" SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day') """); // TimeScaleDB 확장 활성화 await _ctx.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS timescaledb"); // EnsureCreatedAsync는 기존 DB에 새 테이블을 추가하지 않으므로 // raw_node_map / node_map_master 는 DDL로 직접 보장 await _ctx.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS raw_node_map ( id SERIAL PRIMARY KEY, level INTEGER NOT NULL, class TEXT NOT NULL, name TEXT NOT NULL, node_id TEXT NOT NULL, data_type TEXT NOT NULL ) """); await _ctx.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS node_map_master ( id SERIAL PRIMARY KEY, level INTEGER NOT NULL, class TEXT NOT NULL, name TEXT NOT NULL, node_id TEXT NOT NULL, data_type TEXT NOT NULL ) """); // realtime_table 생성 (실시간 모니터링 포인트) await _ctx.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS realtime_table ( id SERIAL PRIMARY KEY, tagname TEXT NOT NULL, node_id TEXT NOT NULL, livevalue TEXT, timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """); // realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음 // history 테이블은 수동으로 하이퍼테이블 생성 필요 // CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능 // 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은 // CreateHypertableAsync() 메서드에서 선택적으로 설정 가능 _logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)"); return true; } catch (Exception ex) { _logger.LogError(ex, "[ExperionDb] 초기화 실패"); return false; } } /// /// history 테이블을 TimeScaleDB 하이퍼테이블로 생성/마이그레이션 /// /// 생성할 하이퍼테이블 이름 (예: history_hypertable) /// 기존 테이블 이름 (예: history_table, null이면 새 테이블 생성) /// 시간 컬럼 이름 /// 청크 간격 (예: '1 day') /// 하이퍼테이블 생성 여부 (true: 생성됨/기존 있음, false: 실패/스킵) private async Task CreateHistoryHypertableIfNotExistsAsync( string hypertableName, string? tableName, string timeColumn, string interval) { try { // SQL injection 방지를 위해 식별자 검증 if (!IsValidSqlIdentifier(hypertableName)) { _logger.LogWarning("[ExperionDb] 하이퍼테이블 이름 '{HypertableName}'이(가) 유효하지 않음", hypertableName); return false; } // 1️⃣ 하이퍼테이블이 이미 존재하면 스킵 await using var conn = new NpgsqlConnection(_ctx.Database.GetConnectionString()); await conn.OpenAsync(); await using var cmd1 = new NpgsqlCommand( $"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = '{hypertableName.Replace("'", "''")}' LIMIT 1", conn); var result = await cmd1.ExecuteScalarAsync(); if (result != null) { _logger.LogInformation("[ExperionDb] 하이퍼테이블 '{HypertableName}' 이미 존재함", hypertableName); return true; } // 2️⃣ 기존 테이블 이름이 제공되면 검증 if (tableName != null && !IsValidSqlIdentifier(tableName)) { _logger.LogWarning("[ExperionDb] 테이블 이름 '{TableName}'이(가) 유효하지 않음", tableName); return false; } // 3️⃣ 기존 테이블이 존재하면 → 하이퍼테이블로 마이그레이션 if (tableName != null) { await using var cmd2 = new NpgsqlCommand( "SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = @tableName LIMIT 1", conn); cmd2.Parameters.AddWithValue("@tableName", tableName.Replace("'", "''")); result = await cmd2.ExecuteScalarAsync(); if (result != null) { // 데이터가 있는 경우 migrate_data => true 옵션 필요 await using var cmd3 = new NpgsqlCommand( "SELECT create_hypertable(@tableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true, migrate_data => true)", conn); cmd3.Parameters.AddWithValue("@tableName", tableName); cmd3.Parameters.AddWithValue("@timeColumn", timeColumn); cmd3.Parameters.AddWithValue("@interval", interval); await cmd3.ExecuteNonQueryAsync(); _logger.LogInformation("[ExperionDb] 테이블 '{TableName}'을(를) 하이퍼테이블로 변환 완료", tableName); return true; } } // 4️⃣ 기존 테이블이 없으면 → 새 하이퍼테이블 테이블 생성 // TimeScaleDB 요구사항: 고유 인덱스를 위해서는 partitioning 컬럼이 primary key에 포함되어야 함 await using var cmd4 = new NpgsqlCommand( @" CREATE TABLE IF NOT EXISTS @hypertableName ( id SERIAL, tagname TEXT NOT NULL, node_id TEXT, value TEXT, livevalue TEXT, @timeColumn TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (id, @timeColumn) )", conn); cmd4.Parameters.AddWithValue("@hypertableName", hypertableName); cmd4.Parameters.AddWithValue("@timeColumn", timeColumn); await cmd4.ExecuteNonQueryAsync(); await using var cmd5 = new NpgsqlCommand( "SELECT create_hypertable(@hypertableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true)", conn); cmd5.Parameters.AddWithValue("@hypertableName", hypertableName); cmd5.Parameters.AddWithValue("@timeColumn", timeColumn); cmd5.Parameters.AddWithValue("@interval", interval); await cmd5.ExecuteNonQueryAsync(); _logger.LogInformation("[ExperionDb] 새 하이퍼테이블 '{HypertableName}' 생성 완료", hypertableName); return true; } catch (Exception ex) { _logger.LogWarning(ex, "[ExperionDb] 하이퍼테이블 '{HypertableName}' 생성 실패 (기본 테이블 사용 계속)", hypertableName); return false; } } public async Task SaveRecordsAsync(IEnumerable records) { var list = records.ToList(); await _ctx.ExperionRecords.AddRangeAsync(list); var saved = await _ctx.SaveChangesAsync(); _logger.LogInformation("[ExperionDb] {Count}건 저장", saved); return saved; } public async Task ClearRecordsAsync() { var deleted = await _ctx.ExperionRecords.ExecuteDeleteAsync(); _logger.LogInformation("[ExperionDb] {Count}건 삭제 (초기화)", deleted); return deleted; } public async Task BuildMasterFromRawAsync(bool truncate = false) { if (truncate) { await _ctx.Database.ExecuteSqlRawAsync( "TRUNCATE TABLE node_map_master RESTART IDENTITY"); _logger.LogInformation("[ExperionDb] node_map_master 초기화 완료"); } var inserted = await _ctx.Database.ExecuteSqlRawAsync( "INSERT INTO node_map_master (level, class, name, node_id, data_type) " + "SELECT level, class, name, node_id, data_type FROM raw_node_map"); _logger.LogInformation("[ExperionDb] node_map_master 빌드 완료: {Count}건", inserted); return inserted; } public async Task> GetRecordsAsync( DateTime? from = null, DateTime? to = null, int limit = 1000) { var q = _ctx.ExperionRecords.AsQueryable(); if (from.HasValue) q = q.Where(r => r.CollectedAt >= from.Value); if (to.HasValue) q = q.Where(r => r.CollectedAt <= to.Value); return await q.OrderByDescending(r => r.CollectedAt).Take(limit).ToListAsync(); } public async Task GetTotalCountAsync() => await _ctx.ExperionRecords.CountAsync(); public async Task> GetNameListAsync() { return await _ctx.NodeMapMasters .Select(x => x.Name).Distinct() .OrderBy(x => x).ToListAsync(); } public async Task GetMasterStatsAsync() { if (!await _ctx.NodeMapMasters.AnyAsync()) return new NodeMapStats(0, 0, 0, 0, Enumerable.Empty()); var total = await _ctx.NodeMapMasters.CountAsync(); var objectCount = await _ctx.NodeMapMasters.CountAsync(x => x.Class == "Object"); var variableCount = await _ctx.NodeMapMasters.CountAsync(x => x.Class == "Variable"); var maxLevel = await _ctx.NodeMapMasters.MaxAsync(x => (int?)x.Level) ?? 0; var dataTypes = await _ctx.NodeMapMasters .Select(x => x.DataType).Distinct() .OrderBy(x => x).ToListAsync(); _logger.LogInformation("[ExperionDb] 노드맵 통계: total={Total}", total); return new NodeMapStats(total, objectCount, variableCount, maxLevel, dataTypes); } // ── RealtimeTable ───────────────────────────────────────────────────────── private static string ExtractTagName(string nodeId) { var idx = nodeId.LastIndexOf(':'); return idx >= 0 ? nodeId[(idx + 1)..] : nodeId; } public async Task BuildRealtimeTableAsync( IEnumerable names, IEnumerable dataTypes) { var nameList = names.Where(n => !string.IsNullOrEmpty(n)).ToList(); var dtList = dataTypes.Where(d => !string.IsNullOrEmpty(d)).ToList(); var q = _ctx.NodeMapMasters.AsQueryable(); if (nameList.Count > 0) q = q.Where(x => nameList.Contains(x.Name)); if (dtList.Count > 0) q = q.Where(x => dtList.Contains(x.DataType)); var sources = await q.ToListAsync(); await _ctx.Database.ExecuteSqlRawAsync( "TRUNCATE TABLE realtime_table RESTART IDENTITY"); var points = sources.Select(s => new RealtimePoint { TagName = ExtractTagName(s.NodeId), NodeId = s.NodeId, LiveValue = null, Timestamp = DateTime.UtcNow }).ToList(); await _ctx.RealtimePoints.AddRangeAsync(points); var saved = await _ctx.SaveChangesAsync(); _logger.LogInformation("[ExperionDb] realtime_table 빌드: {Count}건", saved); return saved; } public async Task> GetRealtimePointsAsync() { try { var points = await _ctx.RealtimePoints .OrderBy(x => x.TagName) .ToListAsync(); _logger.LogInformation("[Realtime] 포인트 조회 완료: {Count}건", points.Count); return points; } catch (Exception ex) { _logger.LogError(ex, "[Realtime] 포인트 조회 실패"); return Enumerable.Empty(); } } public async Task AddRealtimePointAsync(string nodeId) { var existing = await _ctx.RealtimePoints.FirstOrDefaultAsync(x => x.NodeId == nodeId); if (existing != null) return existing; var point = new RealtimePoint { TagName = ExtractTagName(nodeId), NodeId = nodeId, LiveValue = null, Timestamp = DateTime.UtcNow }; _ctx.RealtimePoints.Add(point); await _ctx.SaveChangesAsync(); _logger.LogInformation("[ExperionDb] 수동 추가: {NodeId}", nodeId); return point; } public async Task DeleteRealtimePointAsync(int id) { var point = await _ctx.RealtimePoints.FindAsync(id); if (point == null) return false; _ctx.RealtimePoints.Remove(point); await _ctx.SaveChangesAsync(); return true; } public async Task UpdateLiveValueAsync(string nodeId, string? value, DateTime timestamp) { return await _ctx.RealtimePoints .Where(x => x.NodeId == nodeId) .ExecuteUpdateAsync(s => s .SetProperty(x => x.LiveValue, value) .SetProperty(x => x.Timestamp, timestamp)); } public async Task BatchUpdateLiveValuesAsync(IEnumerable updates) { var list = updates.ToList(); if (list.Count == 0) return 0; // 단일 DbContext(단일 DB 커넥션)에서 순차 업데이트 — 커넥션 폭발 방지 int total = 0; foreach (var u in list) { total += await _ctx.RealtimePoints .Where(x => x.NodeId == u.NodeId) .ExecuteUpdateAsync(s => s .SetProperty(x => x.LiveValue, u.Value) .SetProperty(x => x.Timestamp, u.Timestamp)); } return total; } // ── HistoryTable ────────────────────────────────────────────────────────── public async Task SnapshotToHistoryAsync() { var now = DateTime.UtcNow; var points = await _ctx.RealtimePoints.ToListAsync(); if (points.Count == 0) return 0; var rows = points.Select(p => new HistoryRecord { TagName = p.TagName, NodeId = p.NodeId, Value = p.LiveValue, RecordedAt = now }).ToList(); await _ctx.HistoryRecords.AddRangeAsync(rows); var saved = await _ctx.SaveChangesAsync(); _logger.LogDebug("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now); return saved; } public async Task> GetTagNamesAsync() => await _ctx.RealtimePoints.Select(x => x.TagName).OrderBy(x => x).ToListAsync(); public async Task QueryHistoryAsync( IEnumerable tagNames, DateTime? from, DateTime? to, int limit) { var tags = tagNames.Where(t => !string.IsNullOrEmpty(t)).ToList(); var q = _ctx.HistoryRecords.AsQueryable(); if (tags.Count > 0) q = q.Where(x => tags.Contains(x.TagName)); if (from.HasValue) q = q.Where(x => x.RecordedAt >= from.Value); if (to.HasValue) q = q.Where(x => x.RecordedAt <= to.Value); var rows = await q.OrderBy(x => x.RecordedAt) .Take(Math.Min(limit, 5000)) .ToListAsync(); // recorded_at 기준으로 행을 묶어서 pivot 구성 var grouped = rows .GroupBy(x => x.RecordedAt) .Select(g => new HistoryRow( g.Key, g.GroupBy(r => r.TagName) .ToDictionary(tg => tg.Key, tg => tg.Last().Value) as IReadOnlyDictionary)) .ToList(); var usedTags = tags.Count > 0 ? tags : rows.Select(x => x.TagName).Distinct().OrderBy(x => x).ToList(); return new HistoryQueryResult(usedTags, grouped); } // ── History Interval Query ────────────────────────────────────────────────── public async Task QueryHistoryWithIntervalAsync( HistoryIntervalQueryRequest request) { const int BaseIntervalSeconds = 60; // history_table 기본 저장 간격 (60초) var tags = request.TagNames.Where(t => !string.IsNullOrEmpty(t)).ToList(); var limit = Math.Min(request.Limit, 5000); // SQL 인젝션 방지를 위해 식별자 검증 if (!IsValidSqlIdentifier("history_table")) { throw new ArgumentException("Invalid table name"); } // 간격 파싱 (예: "1 minute", "5 minutes", "1 hour", "10 seconds") var intervalStr = ParseIntervalToPostgresInterval(request.Interval); await _ctx.Database.GetDbConnection().OpenAsync(); try { // TimeScaleDB time_bucket 함수를 사용한 간격별 집계 쿼리 var sql = BuildHistoryIntervalQuerySql(tags, request.From, request.To, intervalStr, limit); using var cmd = new NpgsqlCommand(sql, _ctx.Database.GetDbConnection() as NpgsqlConnection); // 파라미터 바인딩 (Npgsql는 @paramName 형식 지원) if (tags.Count > 0) { var tagParam = cmd.CreateParameter(); tagParam.ParameterName = "tagNames"; tagParam.Value = tags.ToArray(); cmd.Parameters.Add(tagParam); } if (request.From.HasValue) { var fromParam = cmd.CreateParameter(); fromParam.ParameterName = "fromTime"; fromParam.Value = request.From.Value; cmd.Parameters.Add(fromParam); } if (request.To.HasValue) { var toParam = cmd.CreateParameter(); toParam.ParameterName = "toTime"; toParam.Value = request.To.Value; cmd.Parameters.Add(toParam); } using var reader = await cmd.ExecuteReaderAsync(); var rows = new List(); var allTagNames = new HashSet(); while (await reader.ReadAsync()) { var timeBucket = reader.GetDateTime(0); var values = new Dictionary(); for (int i = 1; i < reader.FieldCount; i++) { var tagName = reader.GetName(i); allTagNames.Add(tagName); values[tagName] = reader.IsDBNull(i) ? null : reader.GetString(i); } rows.Add(new HistoryIntervalRow(timeBucket, values)); } var usedTags = tags.Count > 0 ? tags : allTagNames.OrderBy(x => x).ToList(); return new HistoryIntervalQueryResult( usedTags, rows, BaseIntervalSeconds, request.Interval); } finally { await _ctx.Database.GetDbConnection().CloseAsync(); } } /// /// 사용자 지정 간격으로 history 이력 조회용 SQL 생성 /// TimeScaleDB time_bucket 함수를 사용하여 간격별 집계 수행 /// private string BuildHistoryIntervalQuerySql( List tags, DateTime? from, DateTime? to, string intervalStr, int limit) { var selectParts = new List(); selectParts.Add($"time_bucket('{intervalStr}', recorded_at) AS time_bucket"); // 태그명별로 동적으로 컬럼 생성 (PIVOT) // TimeScaleDB에서는 crosstab 함수를 사용하거나, 동적 SQL로 처리 // 여기서는 간단하게 tagname GROUP BY로 조회 후 앱에서 PIVOT selectParts.Add("tagname"); selectParts.Add("last(value, recorded_at) AS value"); var sql = $"SELECT {string.Join(", ", selectParts)} FROM history_table WHERE 1=1"; if (tags.Count > 0) { sql += $" AND tagname = ANY(ARRAY[{string.Join(", ", tags.Select(t => $"'{t.Replace("'", "''")}'"))}])"; } if (from.HasValue) { sql += $" AND recorded_at >= @fromTime"; } if (to.HasValue) { sql += $" AND recorded_at <= @toTime"; } sql += $" GROUP BY time_bucket, tagname ORDER BY time_bucket, tagname LIMIT {limit}"; return sql; } /// /// 사용자 입력 간격을 PostgreSQL INTERVAL 형식으로 변환 /// 예: "1 minute" → "1 minute", "5 minutes" → "5 minutes", "1 hour" → "1 hour" /// private string ParseIntervalToPostgresInterval(string interval) { if (string.IsNullOrWhiteSpace(interval)) return "1 minute"; var lower = interval.ToLower().Trim(); // 이미 PostgreSQL 형식인 경우 (예: "1 minute", "5 minutes", "1 hour") if (System.Text.RegularExpressions.Regex.IsMatch(lower, @"^\d+\s+(second|minute|hour|day|week)s?$")) { return lower; } // 숫자만 있는 경우 (초 단위) if (int.TryParse(lower, out var seconds)) { if (seconds >= 3600 && seconds % 3600 == 0) return $"{seconds / 3600} hour{(seconds / 3600 > 1 ? "s" : "")}"; if (seconds >= 60 && seconds % 60 == 0) return $"{seconds / 60} minute{(seconds / 60 > 1 ? "s" : "")}"; return $"{seconds} second{(seconds > 1 ? "s" : "")}"; } // 기본값 return "1 minute"; } public async Task QueryMasterAsync( int? minLevel, int? maxLevel, string? nodeClass, IEnumerable? names, string? nodeId, string? dataType, int limit, int offset) { var q = _ctx.NodeMapMasters.AsQueryable(); if (minLevel.HasValue) q = q.Where(x => x.Level >= minLevel.Value); if (maxLevel.HasValue) q = q.Where(x => x.Level <= maxLevel.Value); if (!string.IsNullOrEmpty(nodeClass)) q = q.Where(x => x.Class == nodeClass); var nameList = names?.Where(n => !string.IsNullOrEmpty(n)).ToList(); if (nameList?.Count > 0) q = q.Where(x => nameList.Contains(x.Name)); if (!string.IsNullOrEmpty(nodeId)) q = q.Where(x => x.NodeId.Contains(nodeId)); if (!string.IsNullOrEmpty(dataType)) q = q.Where(x => x.DataType == dataType); var total = await q.CountAsync(); var items = await q.OrderBy(x => x.Level).ThenBy(x => x.Name) .Skip(offset).Take(Math.Min(limit, 500)) .ToListAsync(); return new NodeMapQueryResult(total, items); } /// realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환 public async Task> GetRealtimeNodeDataTypesAsync() { var result = await ( from rt in _ctx.RealtimePoints join nm in _ctx.NodeMapMasters on rt.NodeId equals nm.NodeId into joined from nm in joined.DefaultIfEmpty() select new { rt.NodeId, DataType = nm == null ? "String" : nm.DataType } ).ToListAsync(); return result .GroupBy(x => x.NodeId) .ToDictionary(g => g.Key, g => g.First().DataType); } public async Task> GetRealtimeRecordsByTagNamesAsync(IEnumerable tagNames) { try { var tags = tagNames.ToList(); if (tags.Count == 0) return Enumerable.Empty(); var records = await _ctx.RealtimePoints .Where(x => tags.Contains(x.TagName)) .ToListAsync(); _logger.LogInformation("[Realtime] 태그 {Count}개의 라이브 데이터 조회 완료", tags.Count); return records; } catch (Exception ex) { _logger.LogError(ex, "[Realtime] 태그 라이브 데이터 조회 실패"); return Enumerable.Empty(); } } // ── FastSession / FastRecord ───────────────────────────────────────────────── public async Task CreateFastSessionAsync(FastSessionCreateRequest request) { var session = new FastSession { Name = request.Name, SamplingMs = request.SamplingMs, DurationSec = request.DurationSec, TagList = JsonSerializer.Serialize(request.TagList), // string[] → JSONB StartedAt = DateTime.UtcNow, Status = "Running", RowCount = 0, RetentionDays = request.RetentionDays, Pinned = false }; _ctx.FastSessions.Add(session); await _ctx.SaveChangesAsync(); return session; } public async Task UpdateFastSessionStatusAsync(int sessionId, string status) { var session = await _ctx.FastSessions.FindAsync(sessionId); if (session == null) return; session.Status = status; if (status is "Completed" or "Cancelled" or "Failed" or "RowLimitReached") session.EndedAt = DateTime.UtcNow; await _ctx.SaveChangesAsync(); } public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount) { var session = await _ctx.FastSessions.FindAsync(sessionId); if (session == null) return; session.RowCount = rowCount; await _ctx.SaveChangesAsync(); } public async Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned) { var session = await _ctx.FastSessions.FindAsync(sessionId); if (session == null) return; session.Pinned = pinned; await _ctx.SaveChangesAsync(); } public async Task GetFastSessionAsync(int sessionId) => await _ctx.FastSessions.FindAsync(sessionId); public async Task> GetFastSessionsAsync() => await _ctx.FastSessions.OrderByDescending(x => x.StartedAt).ToListAsync(); public async Task DeleteFastSessionAsync(int sessionId) { var session = await _ctx.FastSessions.FindAsync(sessionId); if (session == null) return; _ctx.FastSessions.Remove(session); await _ctx.SaveChangesAsync(); } public async Task> GetExpiredFastSessionsAsync() { var now = DateTime.UtcNow; return await _ctx.FastSessions .Where(x => x.EndedAt != null && !x.Pinned && x.RetentionDays.HasValue && x.EndedAt.Value.AddDays(x.RetentionDays.Value) < now) .OrderBy(x => x.EndedAt) .ToListAsync(); } public async Task GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to) { var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId); if (from.HasValue) query = query.Where(x => x.RecordedAt >= from.Value); if (to.HasValue) query = query.Where(x => x.RecordedAt <= to.Value); var records = await query.OrderBy(x => x.RecordedAt).ToListAsync(); var tagNames = records.Select(x => x.TagName).Distinct().ToArray(); var items = records.Select(r => new FastRecord { Id = r.Id, SessionId = r.SessionId, RecordedAt = r.RecordedAt, TagName = r.TagName, Value = r.Value }); return new FastQueryResult( SessionId: sessionId, From: from ?? records.MinBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow, To: to ?? records.MaxBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow, TagNames: tagNames, Items: items, TotalCount: records.Count ); } public async Task BatchInsertFastRecordsAsync(IEnumerable records) { await _ctx.FastRecords.AddRangeAsync(records); await _ctx.SaveChangesAsync(); } public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to) { var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId); if (from.HasValue) query = query.Where(x => x.RecordedAt >= from.Value); if (to.HasValue) query = query.Where(x => x.RecordedAt <= to.Value); var records = await query.OrderBy(x => x.RecordedAt).ThenBy(x => x.TagName).ToListAsync(); var tagNames = records.Select(x => x.TagName).Distinct().OrderBy(x => x).ToArray(); using var writer = new StreamWriter(stream, leaveOpen: true); await writer.WriteLineAsync("recorded_at," + string.Join(",", tagNames)); foreach (var g in records.GroupBy(x => x.RecordedAt).OrderBy(g => g.Key)) { var values = g.ToDictionary(r => r.TagName, r => r.Value); var row = g.Key.ToString("o") + "," + string.Join(",", tagNames.Select(t => values.TryGetValue(t, out var v) ? $"\"{v}\"" : "")); await writer.WriteLineAsync(row); } await writer.FlushAsync(); } public async Task GetNodeIdByTagNameAsync(string tagName) { return await _ctx.RealtimePoints .Where(x => x.TagName == tagName) .Select(x => x.NodeId) .FirstOrDefaultAsync(); } /// /// 하이퍼테이블 상태 조회합니다. /// 하이퍼테이블인지 여부, 레코드 수, 보존 정책, 압축, 연속 집계 설정 등을 확인합니다. /// public async Task GetHypertableStatusAsync() { try { await _ctx.Database.GetDbConnection().OpenAsync(); // 하이퍼테이블 존재 여부 확인 await using var hypertableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand(); hypertableCheckCmd.CommandText = @" SELECT EXISTS ( SELECT 1 FROM timescaledb_information.hypertables WHERE hypertable_name = 'history_table' )"; var isHypertableResult = Convert.ToBoolean(await hypertableCheckCmd.ExecuteScalarAsync()); // 레코드 수 조회 var recordCount = 0; if (isHypertableResult) { await using var countCmd = _ctx.Database.GetDbConnection().CreateCommand(); countCmd.CommandText = "SELECT COUNT(*) FROM history_table"; recordCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync()); } // 보존 정책 확인 (pg_extension으로 TimeScaleDB 활성화 여부만 확인) var hasRetentionPolicy = false; try { await using var retentionCmd = _ctx.Database.GetDbConnection().CreateCommand(); retentionCmd.CommandText = @" SELECT EXISTS ( SELECT 1 FROM timescaledb_information.policies WHERE policy_type = 'data_retention' AND hypertable_name = 'history_table' )"; hasRetentionPolicy = Convert.ToBoolean(await retentionCmd.ExecuteScalarAsync()); } catch { // policies 뷰가 없는 경우 false 유지 hasRetentionPolicy = false; } // 압축 확인 var hasCompression = false; try { await using var compressionCmd = _ctx.Database.GetDbConnection().CreateCommand(); compressionCmd.CommandText = @" SELECT is_compressed = 't' FROM timescaledb_information.hypertables WHERE hypertable_name = 'history_table'"; var compressionResult = await compressionCmd.ExecuteScalarAsync(); if (compressionResult != null) { hasCompression = compressionResult.ToString() == "t"; } } catch { // 압축 정보 조회 실패 시 false 유지 hasCompression = false; } // 연속 집계 확인 var hasContinuousAggregate = false; try { await using var aggregateCmd = _ctx.Database.GetDbConnection().CreateCommand(); aggregateCmd.CommandText = @" SELECT EXISTS ( SELECT 1 FROM timescaledb_information.continuous_aggregates WHERE hypertable_name = 'history_table' )"; hasContinuousAggregate = Convert.ToBoolean(await aggregateCmd.ExecuteScalarAsync()); } catch { // continuous_aggregates 뷰가 없는 경우 false 유지 hasContinuousAggregate = false; } return new HypertableStatusInfo { IsHypertable = isHypertableResult, TableName = "history_table", StatusMessage = isHypertableResult ? "하이퍼테이블이 활성화되어 있습니다." : "일반 테이블입니다. CreateHypertableAsync()를 사용하여 하이퍼테이블로 변환할 수 있습니다.", RecordCount = recordCount, HasRetentionPolicy = hasRetentionPolicy, HasCompression = hasCompression, HasContinuousAggregate = hasContinuousAggregate }; } catch (Exception ex) { return new HypertableStatusInfo { IsHypertable = false, TableName = "history_table", StatusMessage = $"상태 확인 중 오류 발생: {ex.Message}", RecordCount = 0, HasRetentionPolicy = false, HasCompression = false, HasContinuousAggregate = false }; } } /// /// 수동으로 하이퍼테이블을 생성합니다. /// 테이블이 이미 존재하거나 하이퍼테이블로 변환된 경우 예외를 throw합니다. /// public async Task CreateHypertableAsync(HypertableCreateRequest request) { // 식별자 검증 - SQL injection 방지 if (!IsValidSqlIdentifier(request.TableName)) { return HypertableCreateResult.Failed($"테이블 이름 '{request.TableName}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다."); } if (!IsValidSqlIdentifier(request.TimeColumn)) { return HypertableCreateResult.Failed($"시간 컬럼 이름 '{request.TimeColumn}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다."); } var timeInterval = request.TimeInterval ?? ""; if (!IsValidSqlIdentifier(timeInterval.Replace(" ", ""))) { return HypertableCreateResult.Failed($"시간 간격 '{request.TimeInterval ?? "(null)"}'은 유효하지 않습니다."); } try { _ctx.Database.GetDbConnection().Open(); // 1. 테이블 존재 여부 확인 await using var tableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand(); tableCheckCmd.CommandText = @" SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = @tableName)"; tableCheckCmd.Parameters.Add(new NpgsqlParameter("@tableName", request.TableName)); var tableExists = Convert.ToBoolean(await tableCheckCmd.ExecuteScalarAsync()); if (!tableExists) { return HypertableCreateResult.Failed($"테이블 '{request.TableName}'이 존재하지 않습니다."); } // 2. 이미 하이퍼테이블인지 확인 await using var hypertableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand(); hypertableCheckCmd.CommandText = @" SELECT EXISTS ( SELECT 1 FROM timescaledb_information.hypertables WHERE hypertable_name = @tableName)"; hypertableCheckCmd.Parameters.Add(new NpgsqlParameter("@tableName", request.TableName)); var isHypertable = Convert.ToBoolean(await hypertableCheckCmd.ExecuteScalarAsync()); if (isHypertable) { return HypertableCreateResult.AlreadyExists($"테이블 '{request.TableName}'은 이미 하이퍼테이블입니다."); } // 3. TimescaleDB 확장 활성화 await _ctx.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS timescaledb"); // 3-1. 기존 SERIAL PRIMARY KEY 제약사항 제거 (TimeScaleDB 호환성) var dropPrimaryKeySql = $"ALTER TABLE {request.TableName} DROP CONSTRAINT IF EXISTS {request.TableName}_pkey"; Console.WriteLine($"[DEBUG] 기본키 제약사항 제거 SQL: {dropPrimaryKeySql}"); #pragma warning disable EF1002 await _ctx.Database.ExecuteSqlRawAsync(dropPrimaryKeySql); #pragma warning restore EF1002 // 4. 하이퍼테이블 생성 (기존 데이터 마이그레이션 포함) var createHypertableSql = $"SELECT create_hypertable('{request.TableName}'::regclass, '{request.TimeColumn}'::text, if_not_exists => TRUE, migrate_data => TRUE)"; Console.WriteLine($"[DEBUG] 하이퍼테이블 생성 SQL: {createHypertableSql}"); #pragma warning disable EF1002 await _ctx.Database.ExecuteSqlRawAsync(createHypertableSql); #pragma warning restore EF1002 // 4-1. TimeScaleDB 하이퍼테이블에 적합한 새로운 기본키 생성 var addPrimaryKeySql = $"ALTER TABLE {request.TableName} ADD CONSTRAINT {request.TableName}_pkey PRIMARY KEY ({request.TimeColumn}, id)"; Console.WriteLine($"[DEBUG] 새 기본키 생성 SQL: {addPrimaryKeySql}"); #pragma warning disable EF1002 await _ctx.Database.ExecuteSqlRawAsync(addPrimaryKeySql); #pragma warning restore EF1002 // 6. 보존 정책 설정 (요청된 경우) if (request.SetRetentionPolicy && !string.IsNullOrEmpty(request.RetentionPeriod)) { var retentionSql = $"SELECT add_retention_policy('{request.TableName}'::regclass, INTERVAL '{request.RetentionPeriod}')"; Console.WriteLine($"[DEBUG] 보존 정책 SQL: {retentionSql}"); #pragma warning disable EF1002 await _ctx.Database.ExecuteSqlRawAsync(retentionSql); #pragma warning restore EF1002 } // 7. 압축 정책 설정 (요청된 경우) if (request.EnableCompression && !string.IsNullOrEmpty(request.CompressionPeriod)) { var compressionSql = $"SELECT add_compression_policy('{request.TableName}'::regclass, INTERVAL '{request.CompressionPeriod}')"; Console.WriteLine($"[DEBUG] 압축 정책 SQL: {compressionSql}"); #pragma warning disable EF1002 await _ctx.Database.ExecuteSqlRawAsync(compressionSql); #pragma warning restore EF1002 } // 7-1. history_table 컬럼명 검증 (tag_name vs tagname) if (request.TableName == "history_table") { await using var columnCheckCmd = _ctx.Database.GetDbConnection().CreateCommand(); columnCheckCmd.CommandText = @" SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'history_table' AND column_name IN ('tag_name', 'tagname') ORDER BY column_name;"; var columns = new List(); await using var reader = await columnCheckCmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { columns.Add(reader.GetString(0)); } Console.WriteLine($"[DEBUG] history_table 컬럼명 검증: {string.Join(", ", columns)}"); } // 8. 연속 집계 생성 (요청된 경우) if (request.CreateContinuousAggregate) { // 8-1. 기존 MATERIALIZED VIEW 삭제 (별도 실행) var dropViewSql = "DROP MATERIALIZED VIEW IF EXISTS history_5min_agg"; Console.WriteLine($"[DEBUG] 연속 집계 DROP VIEW SQL: {dropViewSql}"); #pragma warning disable EF1002 await _ctx.Database.ExecuteSqlRawAsync(dropViewSql); #pragma warning restore EF1002 // 8-2. 연속 집계 MATERIALIZED VIEW 생성 (별도 실행) var createViewSql = $@" CREATE MATERIALIZED VIEW history_5min_agg WITH (timescaledb.continuous) AS SELECT time_bucket(INTERVAL '5 minutes', {request.TimeColumn}) AS time_bucket, tagname, AVG(value) AS avg_value, first(value, {request.TimeColumn}) AS min_value, last(value, {request.TimeColumn}) AS max_value FROM {request.TableName} GROUP BY time_bucket, tagname;"; Console.WriteLine($"[DEBUG] 연속 집계 CREATE VIEW SQL: {createViewSql}"); #pragma warning disable EF1002 Console.WriteLine($"[DEBUG] CREATE VIEW SQL 실행 전: {createViewSql}"); await _ctx.Database.ExecuteSqlRawAsync(createViewSql); Console.WriteLine($"[DEBUG] CREATE VIEW SQL 실행 성공"); #pragma warning restore EF1002 // 8-3. 연속 집계 정책 추가 var aggregatePolicySql = $"SELECT add_continuous_aggregate_policy('history_5min_agg', '10 minutes', '1 minute', '5 minutes')"; Console.WriteLine($"[DEBUG] 연속 집계 정책 SQL: {aggregatePolicySql}"); #pragma warning disable EF1002 await _ctx.Database.ExecuteSqlRawAsync(aggregatePolicySql); #pragma warning restore EF1002 } return HypertableCreateResult.Ok(); } catch (Exception ex) { var errorDetails = $"하이퍼테이블 생성 실패: {ex.Message}"; Console.WriteLine($"[ERROR] {errorDetails}"); if (ex.InnerException != null) { Console.WriteLine($"[ERROR] InnerException: {ex.InnerException.Message}"); Console.WriteLine($"[ERROR] InnerException StackTrace: {ex.InnerException.StackTrace}"); } Console.WriteLine($"[ERROR] Full StackTrace: {ex.StackTrace}"); return HypertableCreateResult.Failed(errorDetails); } } /// /// SQL 식별자로 안전한지 검증합니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 허용합니다. /// EF1002 SQL injection 방지를 위해 DDL 문에서 식별자를 사용할 때 이 메서드로 검증을 필수로 합니다. /// private static bool IsValidSqlIdentifier(string identifier) { if (string.IsNullOrEmpty(identifier)) return false; if (identifier.Length > 63) // PostgreSQL 식별자 최대 길이 return false; // 영문, 숫자, 언더스코어, 하이픈, 마침표만 허용 return System.Text.RegularExpressions.Regex.IsMatch( identifier, @"^[a-zA-Z0-9_\-\.]+$"); } } /// /// 하이퍼테이블 상태 정보 결과 클래스 /// public record HypertableStatusInfo { public bool IsHypertable { get; init; } public string? TableName { get; init; } public string? StatusMessage { get; init; } public int RecordCount { get; init; } public bool HasRetentionPolicy { get; init; } public bool HasCompression { get; init; } public bool HasContinuousAggregate { get; init; } } /// /// 하이퍼테이블 생성 요청 클래스 /// public record HypertableCreateRequest { public string TableName { get; init; } = "history_table"; public string TimeColumn { get; init; } = "recorded_at"; public string TimeInterval { get; init; } = "1 day"; public bool MigrateData { get; init; } = true; public bool SetRetentionPolicy { get; init; } = true; public string RetentionPeriod { get; init; } = "90 days"; public bool EnableCompression { get; init; } = true; public string CompressionPeriod { get; init; } = "1 day"; public bool CreateContinuousAggregate { get; init; } = true; } /// /// 하이퍼테이블 생성 결과 클래스 /// public record HypertableCreateResult { public bool Success { get; init; } public string Message { get; init; } = string.Empty; public string? TableName { get; init; } public static HypertableCreateResult Ok(string? tableName = null, string? message = null) => new() { Success = true, TableName = tableName, Message = message ?? "하이퍼테이블이 성공적으로 생성되었습니다." }; public static HypertableCreateResult Failed(string message) => new() { Success = false, Message = message }; public static HypertableCreateResult AlreadyExists(string message) => new() { Success = false, Message = message }; }