Files
ExperionCrawler/src/Web/Program.cs
windpacer 7c26aa7361 feat: Phase II auto-write (WriteGuard, audit, auth) + WO-2~7 완료
Phase II:
- FfOperatorAction entity + ff_operator_action DDL/DbSet
- IFeedforwardWriteGuard + FeedforwardWriteGuard (SP bounds, grade C, transient, NaN)
- IFeedforwardAuditService + FeedforwardAuditService (raw ADO insert/query)
- FeedforwardSupervisor.AutoWriteAsync (per-stream OPC UA after Tick, rate-limited)
- FeedforwardConfigStore: advisory_only now read/writes DB, sp_node_id column
- FeedforwardController: auth (X-Kb-Token) on config/delete/write/audit;
  POST write/{id}/{key} manual SP write; GET audit; write results in MapColumn
- ff.js: token header, auto-write badge, per-stream write result, spNodeId, advisoryOnly
- ff.css: .ff-write-badge, .ff-write, .ff-write-err, .ff-wg-blocked
- Program.cs: register audit (Scoped) + write guard (Singleton)

WO-2~7 (build 0W/0E, test 22/22):
- PCT monitor, θ auto-tune, slow bias, front position indicator,
  total reflux recovery, config form expansion
2026-05-31 20:30:06 +09:00

239 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.Services;
using ExperionCrawler.Infrastructure.Certificates;
using ExperionCrawler.Infrastructure.Csv;
using ExperionCrawler.Infrastructure.Database;
using ExperionCrawler.Infrastructure.Kb;
using ExperionCrawler.Infrastructure.Mcp;
using ExperionCrawler.Infrastructure.OpcUa;
using ExperionCrawler.Infrastructure.Trend;
using ExperionCrawler.Web;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// ── MVC / Swagger ─────────────────────────────────────────────────────────────
var mvcBuilder = builder.Services.AddControllers()
.AddJsonOptions(opt =>
{
// JSON 직렬화 시 대소문자 구분 없이 처리하도록 PascalCase 유지
opt.JsonSerializerOptions.PropertyNamingPolicy = null;
// Deserialize 시 camelCase 키를 C# PascalCase 속성에 매핑 (프론트엔드 호환)
opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
});
builder.Services.AddMemoryCache();
// ── P&ID 컨트롤러 조건부 활성화 (기본: 비활성화) ─────────────────────────────
// PidControllers:Enabled = true 로 설정 시 P&ID 관련 컨트롤러 활성화
bool pidEnabled = builder.Configuration.GetValue<bool>("PidControllers:Enabled");
if (!pidEnabled)
{
var partManager = mvcBuilder.PartManager;
var excludedNames = new[] { "PidController", "ExperionPidController", "PidGraphController" };
var existingProvider = partManager.FeatureProviders
.OfType<Microsoft.AspNetCore.Mvc.Controllers.ControllerFeatureProvider>()
.FirstOrDefault();
if (existingProvider != null)
{
partManager.FeatureProviders.Remove(existingProvider);
partManager.FeatureProviders.Add(new ExcludedControllersFeatureProvider(existingProvider, excludedNames));
}
}
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
c.SwaggerDoc("v1", new() { Title = "ExperionCrawler API", Version = "v1" }));
// ── Infrastructure ────────────────────────────────────────────────────────────
builder.Services.AddSingleton<IExperionCertificateService, ExperionCertificateService>();
builder.Services.AddSingleton<IExperionStatusCodeService, ExperionStatusCodeService>();
builder.Services.AddSingleton<IOpcUaConfigProvider, OpcUaConfigProvider>();
builder.Services.AddScoped<IExperionOpcClient, ExperionOpcClient>();
builder.Services.AddScoped<IExperionOpcWriteClient, ExperionOpcWriteClient>();
builder.Services.AddScoped<IExperionCsvService, ExperionCsvService>();
builder.Services.AddScoped<AssetLoader>();
// PostgreSQL Ubuntu 서버에서 별도 설치 없이 동작
Directory.CreateDirectory("data");
builder.Services.AddDbContext<ExperionDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IExperionDbService, ExperionDbService>();
builder.Services.AddScoped<ITrendService, TrendService>();
// ── Application Services ──────────────────────────────────────────────────────
builder.Services.AddScoped<ExperionCrawlService>();
// ── KST 시간대 관리 서비스 ──────────────────────────────────────────────────
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<KstClock>();
// ── 한글 시간 범위 추출기 ──────────────────────────────────────────────────
builder.Services.AddSingleton<KoreanTimeRangeExtractor>();
// ── Text-to-SQL Service ──────────────────────────────────────────────────────
builder.Services.AddSingleton<SqlValidatorOptions>(_ => new SqlValidatorOptions
{
RequiredTables = ["history_table"],
AllowedTables = ["history_table", "node_map_master", "realtime_table", "tag_metadata", "v_tag_summary"],
MaxSubqueryDepth = 4
});
builder.Services.AddSingleton<SqlValidator>();
builder.Services.AddScoped<ITextToSqlService, TextToSqlService>();
// ── Realtime & History BackgroundServices ─────────────────────────────────────
builder.Services.AddSingleton<ExperionRealtimeService>();
builder.Services.AddSingleton<IExperionRealtimeService>(
sp => sp.GetRequiredService<ExperionRealtimeService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionRealtimeService>());
builder.Services.AddHostedService<ExperionHistoryService>();
builder.Services.AddHostedService<DigitalEventDetectorService>();
// ── MCP Service ───────────────────────────────────────────────────────────────
// Python MCP 서버 (localhost:5001)와 통신
// McpClient: 저수준 HTTP 클라이언트 / McpService: IMcpService 구현 (McpClient 위임)
// IHttpClientFactory 패턴: 핸들러 재사용으로 소켓 고갈 방지, 기본 타임아웃 명시
builder.Services.AddHttpClient(McpClient.HttpClientName, c =>
{
c.BaseAddress = new Uri("http://localhost:5001");
// HttpClient.Timeout = 하드 상한. McpClient 내부에서 소프트(120s)→연장(300s) 2단계 적용.
// ExtendedTimeoutSeconds(300) + 네트워크/직렬화 여유 60s.
c.Timeout = TimeSpan.FromSeconds(McpClient.ExtendedTimeoutSeconds + 60);
}).SetHandlerLifetime(TimeSpan.FromMinutes(5));
builder.Services.AddSingleton<McpClient>();
builder.Services.AddSingleton<IMcpService, McpService>();
builder.Services.AddHostedService<McpServerHostedService>();
// ── OPC UA Server BackgroundService ──────────────────────────────────────────
builder.Services.AddSingleton<ExperionOpcServerService>();
builder.Services.AddSingleton<IExperionOpcServerService>(
sp => sp.GetRequiredService<ExperionOpcServerService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionOpcServerService>());
// ── FastTable Service ─────────────────────────────────────────────────────────
// 중요: Singleton으로 하나만 생성 후 IExperionFastService와 IHostedService 양쪽에 같은 인스턴스 공유
builder.Services.AddSingleton<ExperionFastService>();
builder.Services.AddSingleton<IExperionFastService>(sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastService>());
// ── Metadata Loader Service ───────────────────────────────────────────────────
builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
// ── Feedforward Advisory Engine ───────────────────────────────────────────────
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.FeedforwardEngine>();
builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.IFeedforwardAdvisoryStore, ExperionCrawler.Infrastructure.Control.FeedforwardAdvisoryStore>();
builder.Services.AddScoped<ExperionCrawler.Core.Application.Feedforward.IFeedforwardConfigStore, ExperionCrawler.Infrastructure.Control.FeedforwardConfigStore>();
builder.Services.AddScoped<ExperionCrawler.Core.Application.Feedforward.IFeedforwardAuditService, ExperionCrawler.Infrastructure.Control.FeedforwardAuditService>();
builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.IFeedforwardWriteGuard, ExperionCrawler.Infrastructure.Control.FeedforwardWriteGuard>();
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>());
// ── P&ID Services ───────────────────────────────────────────────────────────────
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();
builder.Services.AddScoped<IPidGraphService, PidGraphService>();
builder.Services.AddDbContextFactory<ExperionDbContext>();
builder.Services.AddScoped<IStatusStore, DbStatusStore>();
builder.Services.AddSingleton<IPidGraphEventBroadcaster, PidGraphEventBroadcaster>();
// ── FastTable Cleanup Service ─────────────────────────────────────────────────
builder.Services.AddHostedService<ExperionFastCleanupService>();
// ── Knowledge Base (RAG) ──────────────────────────────────────────────────────
builder.Services.AddHttpClient("KbQdrant", (sp, c) =>
{
var cfg = sp.GetRequiredService<IConfiguration>();
var baseUrl = cfg["Kb:QdrantUrl"] ?? "http://localhost:6333";
c.BaseAddress = new Uri(baseUrl);
c.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddSingleton<KbQdrantClient>();
builder.Services.AddSingleton<KbStorageService>();
builder.Services.AddSingleton<KbEmbeddingClient>();
builder.Services.AddScoped<IKbAuthService, KbAuthService>();
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Docs.DocBrowserService>();
builder.Services.AddHostedService<KbStartupService>();
builder.Services.AddHostedService<KbIngestWorker>();
// ── Ollama HttpClient ─────────────────────────────────────────────────────────
builder.Services.AddHttpClient("Ollama", c =>
{
c.BaseAddress = new Uri("http://localhost:11434");
c.Timeout = TimeSpan.FromSeconds(1800);
}).SetHandlerLifetime(Timeout.InfiniteTimeSpan);
// ── vLLM HttpClient (OpenAI-compatible) ──────────────────────────────────────
builder.Services.AddHttpClient("Vllm", c =>
{
c.BaseAddress = new Uri("http://localhost:8000");
c.Timeout = TimeSpan.FromSeconds(1800);
}).SetHandlerLifetime(Timeout.InfiniteTimeSpan);
// ── CORS ──────────────────────────────────────────────────────────────────────
// appsettings.json 의 "Cors:AllowedOrigins" 배열을 화이트리스트로 사용.
// 미설정 또는 ["*"] 인 경우에만 AllowAnyOrigin (개발 편의). 운영에서는 명시적 origin 권장.
builder.Services.AddCors(opt =>
opt.AddDefaultPolicy(p =>
{
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
if (origins == null || origins.Length == 0 || (origins.Length == 1 && origins[0] == "*"))
{
// 자격증명 미사용 + 내부망 가정: 와일드카드 허용 (역호환)
p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
}
else
{
p.WithOrigins(origins).AllowAnyMethod().AllowAnyHeader();
}
}));
// ── 포트 설정 (Ubuntu 환경: 기본 5000) ───────────────────────────────────────
builder.WebHost.UseUrls("http://0.0.0.0:5000");
var app = builder.Build();
// ── DB 초기화 ─────────────────────────────────────────────────────────────────
try
{
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.InitializeAsync();
try
{
var kbAuth = scope.ServiceProvider.GetRequiredService<IKbAuthService>();
await kbAuth.EnsureCredentialAsync();
}
catch (Exception kbEx)
{
var lg = app.Services.GetRequiredService<ILogger<Program>>();
lg.LogWarning(kbEx, "[Kb] 관리자 비밀번호 초기화 실패");
}
}
}
catch (Exception ex)
{
// DB 초기화 실패 시 앱 시작 계속 — 기능 사용 시 지연 초기화
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogWarning(ex, "[DB] 초기화 실패 — DB 관련 기능 비활성화 (앱 시작 계속)");
}
// ── Middleware ────────────────────────────────────────────────────────────────
app.UseCors();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseDefaultFiles(); // index.html
app.UseStaticFiles(); // wwwroot/
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();