using ExperionCrawler.Core.Application.Interfaces; using ExperionCrawler.Core.Domain.Entities; using ExperionCrawler.Infrastructure.Database; using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore; using Npgsql; namespace ExperionCrawler.Infrastructure.OpcUa; /// /// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신 /// public class MetadataLoaderService : IMetadataLoaderService { private readonly IExperionOpcClient _opcClient; private readonly ExperionDbContext _ctx; private readonly ILogger _logger; // 로드할 메타데이터 속성 목록 private static readonly string[] MetaAttributes = { "desc", "area", "state0descriptor", "state1descriptor", "state2descriptor", "state3descriptor", "state4descriptor", "state5descriptor", "state6descriptor", "state7descriptor" }; public MetadataLoaderService( IExperionOpcClient opcClient, ExperionDbContext ctx, ILogger logger) { _opcClient = opcClient; _ctx = ctx; _logger = logger; } public async Task LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable baseTags) { var baseTagList = baseTags.ToList(); // 이중 열거 방지 // ── Step 1: 모든 노드 ID 수집 ────────────────────────────────────── var nodeMap = new Dictionary(); foreach (var baseTag in baseTagList) { foreach (var attr in MetaAttributes) { var nodeId = $"{cfg.ServerHostName}:{baseTag}.{attr}"; var fullNodeId = $"ns=1;s={nodeId}"; nodeMap[fullNodeId] = (baseTag.ToLowerInvariant(), attr); } } // ── Step 2: 배치 읽기 (ReadTagsAsync 사용) ──────────────────────── var results = await _opcClient.ReadTagsAsync(cfg, nodeMap.Keys); var entries = new List<(string baseTag, string attr, string? value, string nodeId)>(); foreach (var result in results) { if (result.Success && result.Value != null && nodeMap.TryGetValue(result.NodeId, out var meta)) { entries.Add((meta.baseTag, meta.attr, result.Value?.ToString(), result.NodeId)); } } // ── Step 3: 단일 배치 UPSERT ────────────────────────────────────── if (entries.Count > 0) { // VALUES 절을 동적으로 생성하여 한 번에 INSERT // CTE 컬럼(5개)과 VALUES 값(5개) 일치: base_tag, attribute, value, node_id, loaded_at var valuesSql = string.Join(", ", entries.Select((e, i) => $"(@bt{i}, @attr{i}, @val{i}, @nid{i}, NOW())")); await _ctx.Database.ExecuteSqlRawAsync(@" WITH new_data (base_tag, attribute, value, node_id, loaded_at) AS ( VALUES " + valuesSql + @" ) INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at) SELECT base_tag, attribute, value, node_id, loaded_at FROM new_data ON CONFLICT (base_tag, attribute) DO UPDATE SET value = excluded.value, node_id = excluded.node_id, loaded_at = NOW()", entries.SelectMany((e, i) => new object[] { new NpgsqlParameter($"@bt{i}", e.baseTag), new NpgsqlParameter($"@attr{i}", e.attr), new NpgsqlParameter($"@val{i}", (object?)e.value ?? DBNull.Value), new NpgsqlParameter($"@nid{i}", e.nodeId) }).ToArray()); } // v_tag_summary는 일반 VIEW이므로 REFRESH 불필요 (조회 시 실시간 JOIN) _logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({TagCount}개 태그)", entries.Count, baseTagList.Count); return entries.Count; } public async Task ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable? baseTags = null) { // baseTags가 null이면 tag_metadata에서 전체 base_tag 조회 var tags = baseTags?.ToList() ?? await _ctx.TagMetadata.Select(t => t.BaseTag).Distinct().ToListAsync(); return await LoadMetadataAsync(cfg, tags); } }