using ExperionCrawler.Core.Application.Interfaces; using ExperionCrawler.Core.Domain.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; 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(); 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); }); } } // ── 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(); // 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 ) """); await _ctx.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS realtime_table ( id SERIAL PRIMARY KEY, tagname TEXT NOT NULL, node_id TEXT NOT NULL UNIQUE, livevalue TEXT, timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """); await _ctx.Database.ExecuteSqlRawAsync(""" CREATE TABLE IF NOT EXISTS history_table ( id SERIAL PRIMARY KEY, tagname TEXT NOT NULL, node_id TEXT NOT NULL, value TEXT, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """); await _ctx.Database.ExecuteSqlRawAsync( "CREATE INDEX IF NOT EXISTS idx_history_tagname ON history_table(tagname)"); await _ctx.Database.ExecuteSqlRawAsync( "CREATE INDEX IF NOT EXISTS idx_history_recorded_at ON history_table(recorded_at)"); _logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료"); return true; } catch (Exception ex) { _logger.LogError(ex, "[ExperionDb] 초기화 실패"); 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() => await _ctx.RealtimePoints.OrderBy(x => x.TagName).ToListAsync(); 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.LogInformation("[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.ToDictionary(r => r.TagName, r => r.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); } 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); } }