378 lines
15 KiB
C#
378 lines
15 KiB
C#
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<ExperionDbContext> options) : base(options) { }
|
|
|
|
public DbSet<ExperionRecord> ExperionRecords => Set<ExperionRecord>();
|
|
public DbSet<RawNodeMap> RawNodeMaps => Set<RawNodeMap>();
|
|
public DbSet<NodeMapMaster> NodeMapMasters => Set<NodeMapMaster>();
|
|
public DbSet<RealtimePoint> RealtimePoints => Set<RealtimePoint>();
|
|
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.Entity<ExperionRecord>(e =>
|
|
{
|
|
e.HasKey(x => x.Id);
|
|
e.HasIndex(x => x.CollectedAt);
|
|
e.HasIndex(x => x.NodeId);
|
|
e.HasIndex(x => x.SessionId);
|
|
});
|
|
|
|
modelBuilder.Entity<RawNodeMap>(e =>
|
|
{
|
|
e.HasKey(x => x.Id);
|
|
e.HasIndex(x => x.NodeId);
|
|
});
|
|
|
|
modelBuilder.Entity<NodeMapMaster>(e =>
|
|
{
|
|
e.HasKey(x => x.Id);
|
|
e.HasIndex(x => x.NodeId);
|
|
e.HasIndex(x => x.Level);
|
|
});
|
|
|
|
modelBuilder.Entity<RealtimePoint>(e =>
|
|
{
|
|
e.HasKey(x => x.Id);
|
|
e.HasIndex(x => x.NodeId).IsUnique();
|
|
e.HasIndex(x => x.TagName);
|
|
});
|
|
|
|
modelBuilder.Entity<HistoryRecord>(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<ExperionDbService> _logger;
|
|
|
|
public ExperionDbService(ExperionDbContext ctx, ILogger<ExperionDbService> logger)
|
|
{
|
|
_ctx = ctx;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<bool> 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<int> SaveRecordsAsync(IEnumerable<ExperionRecord> 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<int> ClearRecordsAsync()
|
|
{
|
|
var deleted = await _ctx.ExperionRecords.ExecuteDeleteAsync();
|
|
_logger.LogInformation("[ExperionDb] {Count}건 삭제 (초기화)", deleted);
|
|
return deleted;
|
|
}
|
|
|
|
public async Task<int> 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<IEnumerable<ExperionRecord>> 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<int> GetTotalCountAsync()
|
|
=> await _ctx.ExperionRecords.CountAsync();
|
|
|
|
public async Task<IEnumerable<string>> GetNameListAsync()
|
|
{
|
|
return await _ctx.NodeMapMasters
|
|
.Select(x => x.Name).Distinct()
|
|
.OrderBy(x => x).ToListAsync();
|
|
}
|
|
|
|
public async Task<NodeMapStats> GetMasterStatsAsync()
|
|
{
|
|
if (!await _ctx.NodeMapMasters.AnyAsync())
|
|
return new NodeMapStats(0, 0, 0, 0, Enumerable.Empty<string>());
|
|
|
|
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<int> BuildRealtimeTableAsync(
|
|
IEnumerable<string> names, IEnumerable<string> 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<IEnumerable<RealtimePoint>> GetRealtimePointsAsync()
|
|
=> await _ctx.RealtimePoints.OrderBy(x => x.TagName).ToListAsync();
|
|
|
|
public async Task<RealtimePoint> 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<bool> 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<int> 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<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> 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<int> 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<IEnumerable<string>> GetTagNamesAsync()
|
|
=> await _ctx.RealtimePoints.Select(x => x.TagName).OrderBy(x => x).ToListAsync();
|
|
|
|
public async Task<HistoryQueryResult> QueryHistoryAsync(
|
|
IEnumerable<string> 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<string, string?>))
|
|
.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<NodeMapQueryResult> QueryMasterAsync(
|
|
int? minLevel, int? maxLevel, string? nodeClass,
|
|
IEnumerable<string>? 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);
|
|
}
|
|
}
|