feat: .gitignore 추가 및 빌드 출력 제거, 소스 코드 업데이트

This commit is contained in:
windpacer
2026-04-23 09:30:08 +09:00
parent d9f5bfd6f6
commit 4d46df1b4c
162 changed files with 2891 additions and 6306 deletions

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Build outputs
bin/
obj/
# IDE
.vs/
.vscode/
*.suo
*.user
# Dependencies
node_modules/
# OS files
.DS_Store
Thumbs.db
# Certificates (sensitive)
*.pfx
*.p12
# Data files
data/
# Temporary files
*.tmp
*.log

94
.roo.md Normal file
View File

@@ -0,0 +1,94 @@
[CONTENT_MANAGEMENT_RULES]
1. 작업 시작 시 반드시 Todo List를 작성하세요. 각 항목은 독립 실행 가능해야 합니다.
2. 단일 응답에서 다음 중 하나라도 만족하면 작업을 중단하고 이관 신호를 생성하세요:
- 처리 파일 수 ≥ 5개
- 코드 변경/생성 라인 수 ≥ 200줄
- 논리적 모듈 단위 완료
3. 이관 시 반드시 아래 형식으로 응답을 종료하세요:
[TASK_MIGRATION]
✅ 완료: [목록]
📦 현재 상태: [요약]
🎯 다음 하위작업 지시문: [명확한 프롬프트]
[/TASK_MIGRATION]
4. 이관 신호 이후에는 추가 코딩/분석을 중단하고 사용자의 계속 지시를 기다리세요.
[/CONTENT_MANAGEMENT_RULES]
# Roo 작업 시작 가이드
## 작업 시작 시 필수 절차
1. **`CLAUDE.md` 파일 반드시 읽기**
- 프로젝트 루트의 `CLAUDE.md` 파일을 먼저 읽어서 이전 작업 이력 확인
- 완료된 작업, 현재 진행 중인 작업, 알려진 문제점 파악
- 최근 작업 내용을 바탕으로 현재 작업과 충돌하지 않도록 주의
2. **todo 목록 생성**
- 복잡한 작업은 반드시 `update_todo_list` 도구로 todo 목록 생성
- 각 단계별로 명확한 목표 설정
3. **파일 수정 시 주의**
- `apply_diff` 도구는 정확한 검색/교체 블록 사용
- `read_file` 도구로 정확한 내용 확인 후 수정
## 데이터베이스 연결 정보
### docker container : iiot-timescaledb (주요 목적 DB)
- **Host**: localhost
- **Port**: 5432
- **Database**: iiot_platform
- **Username**: postgres
- **Password**: postgres
- **용도**: TimescaleDB 확장, 시계열 데이터 저장, Text-to-SQL 기능
### Experion DB (데이터 소스)
- **Host**: localhost
- **Port**: 5432
- **Database**: postgres
- **Username**: postgres
- **Password**: postgres
- **용도**: Experion HS R530 메타데이터 조회
## 프로젝트 개요
- **이름**: ExperionCrawler
- **기술 스택**: .NET 8 (C#), PostgreSQL, OPC UA
- **주요 기능**:
- Experion HS R530 DCS 시스템에서 실시간 데이터 크롤링
- OPC UA Client로 Experion HS R530 연결
- OPC UA Server로 외부 시스템 연결 지원
- 데이터 PostgreSQL DB 저장
- CSV 내보내기 기능
- Text-to-SQL 기능 (TimescaleDB)
## 디렉토리 구조
```
src/
├── Core/
│ ├── Application/ (DTOs, Services, Interfaces)
│ └── Domain/ (Entities)
├── Infrastructure/ (Certificates, Csv, Database, OpcUa)
└── Web/ (Controllers, Program.cs, wwwroot)
```
## 작업 규칙
- 복잡한 작업은 항상 todo 목록 먼저 생성
- 각 단계 시작 전 todo 목록 확인
- 단계 완료 후 즉시 completed 표시
- 코드 수정 전 반드시 `read_file`으로 현재 내용 확인
## 컨텍스트 관리
### 컨텍스트 캐시 최적화
- 컨텍스트 캐시가 70%를 넘으면 작업중이던 정보를 새로운 하위작업에게 넘기고 시작
- 정보의 질 저하를 방지하기 위해 컨텍스트 압축 수행
- 단일 항목 작업 중에도 컨텍스트가 가득차면 하위작업으로 분할
### 작업 분할 원칙
1. 모든 새로운 작업을 시작하기 전 todo list 생성 (`update_todo_list` 도구 사용)
2. 단일 항목 작업 중 컨텍스트 캐시 70% 초과 시:
- 현재 작업 상태를 하위작업에게 명확히 전달
- 하위작업에서 이어받아 작업 진행
- 완료 후 상위 작업에 결과 보고
3. 하위작업은 `new_task` 도구로 생성하고 mode은 현재 모드 유지 또는 적절히 선택

View File

@@ -0,0 +1,52 @@
# 이력 조회 상태 표시기 구현
## 개요
이력 조회 페이지의 '▼ 옵션 불러오기' 버튼 오른쪽에 상태 표시기를 추가하여, 태그 목록 조회 상태를 시각적으로 표시합니다.
## 구현 내용
### 1. HTML 구조 ([`index.html`](src/Web/wwwroot/index.html:503))
```html
<div class="fg">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>태그 선택 <em>(최대 8개, OR 조건)</em></span>
<button class="btn-b btn-sm" onclick="histLoad()">▼ 옵션 불러오기</button>
<span id="hist-load-status" class="hist-status">대기 중</span>
</div>
<div class="pb-name-grid">
<!-- select 8개 -->
</div>
</div>
```
### 2. CSS 스타일 ([`style.css`](src/Web/wwwroot/css/style.css:815))
- `.hist-status`: 상태 표시기 컨테이너
- `.hist-status.loading`: 조회 중 (파란색)
- `.hist-status.success`: 조회 완료 (초록색)
- `.hist-status.error`: 조회 실패 (빨간색)
- 성능 최적화: `contain: layout style`, `transform: translateZ(0)`
### 3. JavaScript 로직 ([`app.js`](src/Web/wwwroot/js/app.js:688))
- `histUpdateStatus(state, message)`: 상태 표시기 업데이트
- `histLoad()`: 태그 목록 조회 및 상태 업데이트
- 조회 시작: `⏳ 조회 중...`
- 조회 완료: `✅ 조회 완료 (개수, 초)`
- 데이터 없음: `❌ 조회 데이터 없음 (0개)`
- 오류: `❌ 조회 실패: 메시지`
## 수정된 문제들
1. 초기 상태 "준비됨" → "대기 중"으로 변경
2. 레이아웃 구조 개선 (flex 컨테이너 사용)
3. CSS 애니메이션 제거 및 성능 최적화
4. `requestAnimationFrame`으로 DOM 업데이트 지연
## 남은 문제 (추가 작업 필요)
- 태그 선택 시 페이지 hang 현상 원인 파악 필요
- `pb-name-grid`의 CSS grid 레이아웃과 select 요소 8개의 상호작용
- 브라우저 호환성 문제 가능성
- 메모리 누락 가능성
## 테스트 방법
1. 이력 조회 탭 진입 → "대기 중" 표시 확인
2. '▼ 옵션 불러오기' 클릭 → 상태 변화 확인
3. 태그 선택 시 페이지 반응성 확인

View File

@@ -0,0 +1,416 @@
# Text-to-SQL (PostgreSQL + TimeScaleDB) 추가 계획
## 1. 개요
ExperionCrawler 프로젝트에 **TimeScaleDB 하이퍼테이블**을 적용하여 시계열 데이터(`realtime_table`, `history_table`)의 성능과 확장성을 대폭 개선합니다. 또한 **Text-to-SQL 기능**을 추가하여 API를 통해 자연어/쿼리 입력으로 시계열 데이터를 조회·분석할 수 있도록 합니다.
---
## 2. 현재 아키텍처 분석
```mermaid
graph TB
subgraph WebLayer
Controller[Controllers]
Program[Program.cs]
end
subgraph Infrastructure
DbContext[ExperionDbContext]
DbService[ExperionDbService]
end
subgraph Core
CrawlService[ExperionCrawlService]
RealtimeSvc[ExperionRealtimeService]
HistorySvc[ExperionHistoryService]
end
subgraph Data
PostgreSQL[(PostgreSQL)]
CSV[CSV Files]
end
Controller --> DbContext
Controller --> DbService
DbContext --> PostgreSQL
RealtimeSvc --> DbService
HistorySvc --> DbService
DbService --> CSV
```
### 현재 사용 테이블
| 테이블명 | 용도 | 현재 방식 |
|----------|------|-----------|
| `realtime_table` | 실시간 모니터링 포인트 | 일반 PostgreSQL 테이블 |
| `history_table` | 시계열 이력 스냅샷 | 일반 PostgreSQL 테이블 |
| `raw_node_map` | OPC UA 노드맵 원시 데이터 | 일반 PostgreSQL 테이블 |
| `node_map_master` | 빌드된 노드맵 | 일반 PostgreSQL 테이블 |
| `experion_records` | 크롤링 기록 | EF Core DbSet |
---
## 3. 목표 아키텍처
```mermaid
graph TB
subgraph WebLayer
Controller[Controllers]
TextToSql[Text-to-SQL Service]
Program[Program.cs]
end
subgraph Infrastructure
DbContext[ExperionDbContext]
DbService[ExperionDbService]
TimeSeriesSvc[TimeSeries Service]
end
subgraph Core
CrawlService[ExperionCrawlService]
RealtimeSvc[ExperionRealtimeService]
HistorySvc[ExperionHistoryService]
end
subgraph Data
TimeScaleDB[(TimeScaleDB<br/>HyperTables)]
CSV[CSV Files]
end
Controller --> TextToSql
TextToSql --> DbService
Controller --> DbContext
Controller --> DbService
DbContext --> TimeScaleDB
RealtimeSvc --> TimeSeriesSvc
HistorySvc --> TimeSeriesSvc
TimeSeriesSvc --> DbService
DbService --> CSV
```
---
## 4. 구현 단계별 계획
### 단계 1: TimeScaleDB 패키지 추가 및 초기화
**목표**: TimeScaleDB 확장 활성화 및 하이퍼테이블 생성
#### 4.1.1 NuGet 패키지 추가
- `Npgsql.EntityFrameworkCore.PostgreSQL` (이미 존재)
- `Npgsql.TimeScaleDB` (새로 추가)
#### 4.1.2 데이터베이스 초기화 수정
- [`ExperionDbContext`](src/Infrastructure/Database/ExperionDbContext.cs:1)에 TimeScaleDB 확장 활성화 코드 추가
- `history_table``history_hypertable` (하이퍼테이블)로 마이그레이션
- `realtime_table``realtime_hypertable` (하이퍼테이블)로 마이그레이션
#### 4.1.3 마이그레이션 스크립트
```sql
-- TimeScaleDB 확장 활성화
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- 기존 history_table 데이터를 백업
CREATE TABLE history_table_backup AS SELECT * FROM history_table;
-- 기존 테이블 삭제 후 하이퍼테이블 생성
DROP TABLE history_table;
SELECT create_hypertable('history_hypertable', 'recorded_at',
chunk_time_interval => INTERVAL '1 day',
create_default_indexes => true);
-- 데이터 복원
INSERT INTO history_hypertable (id, tagname, node_id, value, recorded_at)
SELECT id, tagname, node_id, value, recorded_at FROM history_table_backup;
DROP TABLE history_table_backup;
```
---
### 단계 2: Text-to-SQL 서비스 구현
**목표**: 자연어/쿼리 입력으로 시계열 데이터를 조회하는 서비스 계층 구현
#### 4.2.1 인터페이스 정의 (`ITextToSqlService`)
```
namespace ExperionCrawler.Core.Application.Interfaces;
public interface ITextToSqlService
{
/// <summary>자연어 질의를 SQL로 변환</summary>
Task<string> ParseNaturalLanguageAsync(string input);
/// <summary>SQL 쿼리 실행 및 결과 반환</summary>
Task<SqlQueryResult> ExecuteQueryAsync(string sql);
/// <summary>쿼리 제안 (자동 완성)</summary>
Task<IEnumerable<string>> SuggestQueriesAsync(string partialInput);
/// <summary>시계열 분석 (평균, 최대, 최소, 추세)</summary>
Task<TimeSeriesAnalysisResult> AnalyzeAsync(string tagName, DateTime? from, DateTime? to);
}
```
#### 4.2.2 구현체 (`TextToSqlService`)
- **자연어 파싱**: 키워드 기반 파서 (tag name, 시간 범위, 집계 함수)
- **SQL 생성**: TimeScaleDB 함수 (`time_bucket`, `avg`, `max`, `min`, `last`) 활용
- **결과 매핑**: 동적 SQL → DTO 매핑
#### 4.2.3 자연어 파싱 예시
| 입력 | 생성된 SQL |
|------|-----------|
| "PV001 온도 최근 1시간 평균" | `SELECT time_bucket('5 min', recorded_at) AS bucket, AVG(value::float) FROM history_hypertable WHERE tagname = 'PV001' AND recorded_at > NOW() - INTERVAL '1 hour' GROUP BY bucket ORDER BY bucket` |
| "전체 태그 현재 값" | `SELECT DISTINCT ON (tagname) tagname, livevalue, timestamp FROM realtime_hypertable ORDER BY tagname, timestamp DESC` |
| "PV001, PV002 최근 24시간 최대값" | `SELECT time_bucket('1 hour', recorded_at) AS bucket, tagname, MAX(value::float) FROM history_hypertable WHERE tagname IN ('PV001', 'PV002') AND recorded_at > NOW() - INTERVAL '24 hour' GROUP BY bucket, tagname ORDER BY bucket, tagname` |
---
### 단계 3: API 엔드포인트 추가
**목표**: Text-to-SQL 기능을 REST API로 노출
#### 4.3.1 컨트롤러 (`TextToSqlController`)
```
[ApiController]
[Route("api/text-to-sql")]
public class TextToSqlController : ControllerBase
{
private readonly ITextToSqlService _service;
[HttpPost("parse")]
public Task<IActionResult> Parse([FromBody] NaturalLanguageQueryDto dto);
[HttpPost("execute")]
public Task<IActionResult> Execute([FromBody] SqlQueryDto dto);
[HttpGet("suggest")]
public Task<IActionResult> Suggest([FromQuery] string input);
[HttpPost("analyze")]
public Task<IActionResult> Analyze([FromBody] AnalysisRequestDto dto);
}
```
#### 4.3.2 DTO 정의
```
public class NaturalLanguageQueryDto
{
public string Query { get; set; } = string.Empty;
public string Language { get; set; } = "ko"; // "ko" or "en"
}
public class SqlQueryDto
{
public string Sql { get; set; } = string.Empty;
public int? Limit { get; set; } = 1000;
}
public class AnalysisRequestDto
{
public List<string> TagNames { get; set; } = new();
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public string Interval { get; set; } = "5 min"; // time_bucket interval
}
```
---
### 단계 4: TimeSeries 서비스 개선
**목표**: TimeScaleDB 특화 함수 활용하여 성능 최적화
#### 4.4.1 기존 `ExperionDbService` 수정
- `history_table``history_hypertable` 참조로 변경
- `realtime_table``realtime_hypertable` 참조로 변경
- TimeScaleDB 함수 (`time_bucket`, `continuous aggregates`) 활용
#### 4.4.2 연속 집계 (Continuous Aggregates) 생성
```sql
-- 5분 단위 집계 뷰 생성
CREATE MATERIALIZED VIEW history_5min_agg
WITH (timescaledb.continuous) AS
SELECT
time_bucket('5 min', recorded_at) AS bucket,
tagname,
AVG(value::float) AS avg_value,
MIN(value::float) AS min_value,
MAX(value::float) AS max_value,
FIRST(value::float, recorded_at) AS open_value,
LAST(value::float, recorded_at) AS close_value,
COUNT(*) AS point_count
FROM history_hypertable
GROUP BY bucket, tagname;
-- 기존 데이터 리프레시
REFRESH MATERIALIZED VIEW history_5min_agg;
```
---
### 단계 5: Program.cs 설정 업데이트
**목표**: 서비스 등록 및 초기화 플로우 수정
#### 4.5.1 서비스 등록
```csharp
// Text-to-SQL 서비스
builder.Services.AddScoped<ITextToSqlService, TextToSqlService>();
// TimeSeries 서비스
builder.Services.AddScoped<ITimeSeriesService, TimeSeriesService>();
```
#### 4.5.2 연결 문자열 확인
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=experion_crawler;Username=postgres;Password=postgres"
}
}
```
---
## 5. 파일 구조 변경
```
src/
├── Core/
│ ├── Application/
│ │ ├── DTOs/
│ │ │ ├── ExperionDtos.cs (수정: Text-to-SQL DTO 추가)
│ │ │ └── TextToSqlDtos.cs (신규)
│ │ ├── Interfaces/
│ │ │ ├── IExperionServices.cs (수정: ITextToSqlService, ITimeSeriesService 추가)
│ │ │ └── ITextToSqlService.cs (신규)
│ │ └── Services/
│ │ ├── ExperionCrawlService.cs (변경 없음)
│ │ └── TextToSqlService.cs (신규)
│ └── Domain/
│ └── Entities/
│ ├── ExperionEntities.cs (수정: TimeScaleDB 엔티티 추가)
│ └── TimeSeriesEntities.cs (신규)
├── Infrastructure/
│ ├── Database/
│ │ ├── ExperionDbContext.cs (수정: 하이퍼테이블 설정)
│ │ ├── TimeSeriesService.cs (신규)
│ │ └── Migrations/ (신규: TimeScaleDB 마이그레이션)
│ └── ... (기존 파일 유지)
└── Web/
├── Controllers/
│ ├── ExperionControllers.cs (수정: Text-to-SQL 엔드포인트 추가)
│ └── TextToSqlController.cs (신규)
├── appsettings.json (수정: 연결 문자열)
└── ExperionCrawler.csproj (수정: NuGet 패키지 추가)
```
---
## 6. TimeScaleDB 특화 기능
### 6.1 체킹 간격 설정
| 테이블 | chunk_time_interval | 설명 |
|--------|-------------------|------|
| `history_hypertable` | 1일 | 이력 데이터는 1일 단위 청크 |
| `realtime_hypertable` | 1시간 | 실시간 데이터는 1시간 단위 청크 |
### 6.2 데이터 보존 정책
```sql
-- 90일 이전 데이터 자동 삭제
SELECT add_drop_chunks_policy('history_hypertable', INTERVAL '90 days');
```
### 6.3 압축 설정
```sql
-- 1일 이전 데이터 자동 압축
SELECT add_compression_policy('history_hypertable', INTERVAL '1 day');
```
---
## 7. NuGet 패키지 변경
### 추가 패키지
| 패키지 | 버전 | 용도 |
|--------|------|------|
| `Npgsql.TimeScaleDB` | 최신 | TimeScaleDB 전용 확장 |
### 기존 패키지 (변경 없음)
| 패키지 | 현재 버전 |
|--------|----------|
| `Npgsql.EntityFrameworkCore.PostgreSQL` | 8.0.11 |
| `Microsoft.EntityFrameworkCore.Design` | 8.0.13 |
---
## 8. 구현 체크리스트
- [ ] **단계 1**: TimeScaleDB 패키지 추가 및 초기화
- [ ] `Npgsql.TimeScaleDB` NuGet 패키지 추가
- [ ] `ExperionDbContext`에 TimeScaleDB 확장 코드 추가
- [ ] `history_table``history_hypertable` 마이그레이션
- [ ] `realtime_table``realtime_hypertable` 마이그레이션
- [ ] **단계 2**: Text-to-SQL 서비스 구현
- [ ] `ITextToSqlService` 인터페이스 정의
- [ ] `TextToSqlService` 구현체 작성
- [ ] 자연어 파서 (한국어/영어 지원)
- [ ] SQL 생성 로직 (TimeScaleDB 함수 활용)
- [ ] 시계열 분석 기능 (평균, 최대, 최소, 추세)
- [ ] **단계 3**: API 엔드포인트 추가
- [ ] `TextToSqlController` 생성
- [ ] `/api/text-to-sql/parse` 엔드포인트
- [ ] `/api/text-to-sql/execute` 엔드포인트
- [ ] `/api/text-to-sql/suggest` 엔드포인트
- [ ] `/api/text-to-sql/analyze` 엔드포인트
- [ ] DTO 정의
- [ ] **단계 4**: TimeSeries 서비스 개선
- [ ] `ExperionDbService` 수정 (하이퍼테이블 참조)
- [ ] 연속 집계 (Continuous Aggregates) 생성
- [ ] TimeScaleDB 함수 활용 쿼리 최적화
- [ ] **단계 5**: Program.cs 설정 업데이트
- [ ] 서비스 등록
- [ ] 연결 문자열 확인
- [ ] 초기화 플로우 수정
- [ ] **단계 6**: 테스트 및 문서화
- [ ] API 테스트 (Swagger)
- [ ] 자연어 파싱 테스트
- [ ] TimeScaleDB 성능 벤치마크
- [ ] README.md 업데이트
---
## 9. 예상 효과
| 항목 | 기존 | 개선 후 |
|------|------|--------|
| 시계열 데이터 삽입 속도 | 일반 테이블 | TimeScaleDB 청킹으로 10x 이상 향상 |
| 시간 범위 쿼리 | 전체 테이블 스캔 | 청크 제거로 빠른 응답 |
| 데이터 압축 | 없음 | 자동 압축 (5-10x 저장공간 절약) |
| 자동 보존 | 수동 삭제 | 정책 기반 자동 삭제 |
| Text-to-SQL | 없음 | 자연어 기반 시계열 쿼리 |
---
## 10. 주의사항
1. **TimeScaleDB 설치 prerequisite**: 대상 서버에 TimeScaleDB가 설치되어 있어야 함
```bash
# Ubuntu/Debian
sudo apt-get install timescaledb-postgresql-16
```
2. **기존 데이터 마이그레이션**: `history_table`과 `realtime_table`의 기존 데이터를 새 하이퍼테이블로 이동해야 함
3. **연결 문자열**: `Trust Server Certificate=true`는 TimeScaleDB에서 필요하지 않을 수 있음
4. **EF Core 제한**: EF Core는 TimeScaleDB 하이퍼테이블을 직접 지원하지 않음
- Raw SQL 쿼리 활용 필요
- `ExecuteSqlRawAsync`, `FromSqlRaw` 사용
5. **백호환성**: 기존 API 엔드포인트는 변경되지 않도록 유지

View File

@@ -59,3 +59,29 @@ public class PointBuilderAddDto
{
public string NodeId { get; set; } = string.Empty;
}
// ── TimeScaleDB 하이퍼테이블 ────────────────────────────────────────────────────
public class HypertableStatusDto
{
public bool IsHypertable { get; set; }
public string? TableName { get; set; }
public string? StatusMessage { get; set; }
public int RecordCount { get; set; }
public bool HasRetentionPolicy { get; set; }
public bool HasCompression { get; set; }
public bool HasContinuousAggregate { get; set; }
}
public class HypertableCreateDto
{
public string TableName { get; set; } = "history_table";
public string TimeColumn { get; set; } = "recorded_at";
public string TimeInterval { get; set; } = "1 day";
public bool MigrateData { get; set; } = true;
public bool SetRetentionPolicy { get; set; } = true;
public string RetentionPeriod { get; set; } = "90 days";
public bool EnableCompression { get; set; } = true;
public string CompressionPeriod { get; set; } = "1 day";
public bool CreateContinuousAggregate { get; set; } = true;
}

View File

@@ -0,0 +1,51 @@
namespace ExperionCrawler.Core.Application.DTOs;
// ── Text-to-SQL DTOs ─────────────────────────────────────────────────────────
public class NaturalLanguageQueryDto
{
public string Query { get; set; } = string.Empty;
}
public class SqlQueryDto
{
public string Sql { get; set; } = string.Empty;
public int? Limit { get; set; } = 1000;
}
public class AnalyzeRequestDto
{
public List<string> TagNames { get; set; } = new();
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public string Interval { get; set; } = "5 min"; // time_bucket interval
}
public class SqlQueryResultDto
{
public bool Success { get; set; }
public string? Error { get; set; }
public List<string> Columns { get; set; } = new();
public List<Dictionary<string, object?>> Rows { get; set; } = new();
public int TotalCount { get; set; }
}
public class TimeSeriesAnalysisDto
{
public bool Success { get; set; }
public string? Error { get; set; }
public List<AnalysisTagResult> Tags { get; set; } = new();
}
public class AnalysisTagResult
{
public string TagName { get; set; } = string.Empty;
public double? Avg { get; set; }
public double? Min { get; set; }
public double? Max { get; set; }
public double? First { get; set; }
public double? Last { get; set; }
public long PointCount { get; set; }
public DateTime? From { get; set; }
public DateTime? To { get; set; }
}

View File

@@ -1,4 +1,6 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using System.Collections.Generic;
namespace ExperionCrawler.Core.Application.Interfaces;
@@ -44,6 +46,11 @@ public interface IExperionCsvService
public interface IExperionDbService
{
Task<bool> InitializeAsync();
// ── TimeScaleDB 하이퍼테이블 ──────────────────────────────────────────────────
Task<HypertableStatusInfo> GetHypertableStatusAsync();
Task<HypertableCreateResult> CreateHypertableAsync(HypertableCreateRequest request);
Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records);
Task<int> ClearRecordsAsync();
Task<int> BuildMasterFromRawAsync(bool truncate = false);

View File

@@ -0,0 +1,19 @@
using ExperionCrawler.Core.Application.DTOs;
namespace ExperionCrawler.Core.Application.Interfaces;
/// <summary>Text-to-SQL 시계열 쿼리 서비스</summary>
public interface ITextToSqlService
{
/// <summary>자연어 질의를 SQL로 변환</summary>
Task<string> ParseNaturalLanguageAsync(string input);
/// <summary>SQL 쿼리 실행 및 결과 반환</summary>
Task<SqlQueryResultDto> ExecuteQueryAsync(string sql, int? limit = 1000);
/// <summary>쿼리 제안 (자동 완성)</summary>
Task<IEnumerable<string>> SuggestQueriesAsync(string partialInput);
/// <summary>시계열 분석 (평균, 최대, 최소, 추세)</summary>
Task<TimeSeriesAnalysisDto> AnalyzeAsync(AnalyzeRequestDto dto);
}

View File

@@ -0,0 +1,394 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace ExperionCrawler.Core.Application.Services;
/// <summary>
/// Text-to-SQL 시계열 쿼리 서비스
/// 자연어 질의를 파싱하여 iiot-timescaledb(IIoT 플랫폼) SQL 쿼리를 생성하고 실행합니다.
/// measurements (TimeScaleDB 하이퍼테이블) + opc_nodes 테이블을 참조합니다.
/// </summary>
public class TextToSqlService : ITextToSqlService
{
private readonly ILogger<TextToSqlService> _logger;
private readonly IConfiguration _configuration;
private readonly string _connectionString;
public TextToSqlService(ILogger<TextToSqlService> logger, IConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
_connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
}
// ── 테이블명 설정 (iiot-timescaledb 매핑) ─────────────────────────────────
// iiot-timescaledb: measurements (node_id, time, value, quality)
// ExperionCrawler: history_hypertable (tagname, recorded_at, value)
private string HistoryTable => "measurements";
private string NodesTable => "opc_nodes";
// ── 자연어 파싱 ─────────────────────────────────────────────────────────────
public Task<string> ParseNaturalLanguageAsync(string input)
{
if (string.IsNullOrWhiteSpace(input))
throw new ArgumentException("쿼리 입력이 필요합니다.", nameof(input));
var sql = BuildSqlFromNaturalLanguage(input);
_logger.LogInformation("[TextToSql] 자연어 파싱: \"{Input}\" → {Sql}", input, sql);
return Task.FromResult(sql);
}
/// <summary>
/// 자연어 입력에서 태그명, 시간 범위, 집계 함수를 추출하여 SQL 생성
/// </summary>
private string BuildSqlFromNaturalLanguage(string input)
{
var lower = input.ToLower();
// 태그명 추출 (한글/영문 태그명 지원)
var tagName = ExtractTagName(input);
// 시간 범위 추출
var (fromClause, timeBucket) = ExtractTimeRange(lower);
// 집계 함수 추출
var aggregate = ExtractAggregate(lower);
// measurements (iiot-timescaledb 하이퍼테이블) 조회 SQL 생성
// 컬럼: node_id (TEXT), time (TIMESTAMPTZ), value (DOUBLE PRECISION)
if (!string.IsNullOrEmpty(tagName))
{
// SQL 인젝션 방지를 위해 태그명 이스케이프
var escapedTagName = tagName.Replace("'", "''");
var whereTag = $"WHERE node_id = '{escapedTagName}'";
if (!string.IsNullOrEmpty(fromClause))
whereTag += $" AND {fromClause}";
if (aggregate != "last" && aggregate != "first")
{
// 집계 쿼리 (value는 이미 double precision)
return $"SELECT time_bucket('{timeBucket}', time) AS bucket, " +
$"{aggregate}(value) AS result " +
$"FROM {HistoryTable} " +
$"{whereTag} " +
$"GROUP BY bucket ORDER BY bucket";
}
else
{
// first/last 쿼리 (TimeScaleDB 함수)
var func = aggregate == "first" ? "first" : "last";
return $"SELECT time_bucket('{timeBucket}', time) AS bucket, " +
$"{func}(value, time) AS result " +
$"FROM {HistoryTable} " +
$"{whereTag} " +
$"GROUP BY bucket ORDER BY bucket";
}
}
// 태그명 없이 전체 조회
var whereAll = fromClause ?? "1=1";
return $"SELECT time_bucket('{timeBucket}', time) AS bucket, " +
$"node_id, {aggregate}(value) AS result " +
$"FROM {HistoryTable} " +
$"WHERE {whereAll} " +
$"GROUP BY bucket, node_id ORDER BY bucket, node_id";
}
/// <summary>
/// 자연어에서 태그명/node_id 추출
/// 예: "Reactor.Temperature 온도 최근 1시간 평균" → "Reactor.Temperature"
/// "ns=2;s=Reactor.Temperature 온도 최근 1시간 평균" → "ns=2;s=Reactor.Temperature"
/// </summary>
private string ExtractTagName(string input)
{
var lower = input.ToLower();
// 키워드 목록 (시간/집계 관련 단어)
var keywords = new[]
{
"최근", "last", "latest",
"평균", "average", "avg", "평균값",
"최대", "maximum", "max", "최댓값",
"최소", "minimum", "min", "최솟값",
"첫번째", "first", "초기값",
"마지막", "last", "종료값",
"추세", "trend",
"데이터", "조회", "quer", "query",
"시간", "hour", "hr",
"분", "minute", "min", "m",
"초", "second", "sec", "s",
"일", "day", "d",
"주", "week", "w",
"전체", "all",
"온도", "temperature",
"압력", "pressure",
"유량", "flow", "유량", "flow rate",
"수위", "level", "수위",
"rpm", "전류", "current"
};
foreach (var kw in keywords)
{
var idx = lower.IndexOf(kw);
if (idx > 0)
{
var candidate = input.Substring(0, idx).Trim();
// OPC UA node_id 형식(ns=...;s=...) 또는 일반 태그명 반환
return candidate;
}
}
// OPC UA node_id 형식 확인 (ns=X;s=...)
var nsMatch = System.Text.RegularExpressions.Regex.Match(input, @"ns=\d+;s=[^ ]+");
if (nsMatch.Success)
return nsMatch.Value;
// 키워드가 없으면 첫 단어(공백 이전)를 태그명으로 간주
var firstSpace = input.IndexOf(' ');
if (firstSpace > 0)
return input.Substring(0, firstSpace).Trim();
return input.Trim();
}
/// <summary>
/// 자연어에서 시간 범위 추출
/// </summary>
private (string? whereClause, string timeBucket) ExtractTimeRange(string lower)
{
// 시간 단위 매핑 (measurements 테이블의 time 컬럼 사용)
var timePatterns = new (string pattern, string interval, string bucket)[]
{
("최근 1시간", "1 hour", "5 min"),
("최근 2시간", "2 hours", "5 min"),
("최근 3시간", "3 hours", "10 min"),
("최근 6시간", "6 hours", "10 min"),
("최근 12시간", "12 hours", "30 min"),
("최근 24시간", "24 hours", "1 hour"),
("최근 하루", "24 hours", "1 hour"),
("최근 1일", "1 day", "1 hour"),
("최근 3일", "3 days", "6 hours"),
("최근 7일", "7 days", "12 hours"),
("최근 1주", "7 days", "12 hours"),
("최근 1개월", "30 days", "1 day"),
("최근 한달", "30 days", "1 day"),
("오늘", "1 day", "1 hour"),
("어제", "1 day", "1 hour"),
("최근 5분", "5 minutes", "1 min"),
("최근 10분", "10 minutes", "1 min"),
("최근 30분", "30 minutes", "1 min"),
};
foreach (var (pattern, interval, bucket) in timePatterns)
{
if (lower.Contains(pattern))
{
return ($"time > NOW() - INTERVAL '{interval}'", bucket);
}
}
// "from ~ to" 패턴 (ISO 8601 형식 또는 한국어)
var fromMatch = System.Text.RegularExpressions.Regex.Match(lower, @"from\s+(\d{4}-\d{2}-\d{2})");
var toMatch = System.Text.RegularExpressions.Regex.Match(lower, @"to\s+(\d{4}-\d{2}-\d{2})");
if (fromMatch.Success)
{
var from = fromMatch.Groups[1].Value;
var to = toMatch.Success ? toMatch.Groups[1].Value : DateTime.Now.ToString("yyyy-MM-dd");
return ($"time >= '{from} 00:00:00' AND time <= '{to} 23:59:59'", "1 hour");
}
return (null, "5 min");
}
/// <summary>
/// 자연어에서 집계 함수 추출
/// </summary>
private string ExtractAggregate(string lower)
{
if (lower.Contains("최대") || lower.Contains("max") || lower.Contains("최댓값"))
return "max";
if (lower.Contains("최소") || lower.Contains("min") || lower.Contains("최솟값"))
return "min";
if (lower.Contains("첫") || lower.Contains("first") || lower.Contains("초기"))
return "first";
if (lower.Contains("마지막") || lower.Contains("last") || lower.Contains("종료"))
return "last";
// 기본값: 평균
return "avg";
}
// ── SQL 실행 ────────────────────────────────────────────────────────────────
public async Task<SqlQueryResultDto> ExecuteQueryAsync(string sql, int? limit = 1000)
{
try
{
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync();
var fullSql = sql.TrimEnd();
if (limit.HasValue && !fullSql.EndsWith(";", StringComparison.OrdinalIgnoreCase)
&& !fullSql.EndsWith(" limit ", StringComparison.OrdinalIgnoreCase))
{
fullSql += $" LIMIT {limit.Value}";
}
using var cmd = new NpgsqlCommand(fullSql, conn);
using var reader = await cmd.ExecuteReaderAsync();
var columns = new List<string>();
for (int i = 0; i < reader.FieldCount; i++)
{
columns.Add(reader.GetName(i));
}
var rows = new List<Dictionary<string, object?>>();
while (await reader.ReadAsync())
{
var row = new Dictionary<string, object?>();
for (int i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
}
rows.Add(row);
}
_logger.LogInformation("[TextToSql] 쿼리 실행 성공: {RowCount}행", rows.Count);
return new SqlQueryResultDto
{
Success = true,
Columns = columns,
Rows = rows,
TotalCount = rows.Count
};
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] 쿼리 실행 실패: {Sql}", sql);
return new SqlQueryResultDto
{
Success = false,
Error = ex.Message
};
}
}
// ── 쿼리 제안 ───────────────────────────────────────────────────────────────
public Task<IEnumerable<string>> SuggestQueriesAsync(string partialInput)
{
var suggestions = new List<string>
{
"최근 1시간 평균",
"최근 24시간 최대값",
"최근 7일 최소값",
"전체 태그 현재 값",
"오늘의 데이터 추세"
};
if (string.IsNullOrWhiteSpace(partialInput))
return Task.FromResult<IEnumerable<string>>(suggestions);
var lower = partialInput.ToLower();
return Task.FromResult<IEnumerable<string>>(suggestions.Where(s => s.Contains(lower)));
}
// ── 시계열 분석 ─────────────────────────────────────────────────────────────
public async Task<TimeSeriesAnalysisDto> AnalyzeAsync(AnalyzeRequestDto dto)
{
var result = new TimeSeriesAnalysisDto();
try
{
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync();
var tagNames = dto.TagNames.Count > 0
? dto.TagNames
: await GetAllTagNamesAsync(conn);
var from = dto.From?.ToString("yyyy-MM-dd HH:mm:ss") ?? DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd HH:mm:ss");
var to = dto.To?.ToString("yyyy-MM-dd HH:mm:ss") ?? DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
var tagResults = new List<AnalysisTagResult>();
foreach (var tagName in tagNames)
{
// SQL 인젝션 방지를 위해 태그명 이스케이프
var escapedTagName = tagName.Replace("'", "''");
// measurements 테이블: node_id (TEXT), time (TIMESTAMPTZ), value (DOUBLE PRECISION)
var sql = $@"
SELECT
AVG(value) AS avg_val,
MIN(value) AS min_val,
MAX(value) AS max_val,
first(value, time) AS first_val,
last(value, time) AS last_val,
COUNT(*) AS point_count
FROM measurements
WHERE node_id = '{escapedTagName}'
AND time BETWEEN '{from}' AND '{to}'";
using var cmd = new NpgsqlCommand(sql, conn);
using var reader = await cmd.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
tagResults.Add(new AnalysisTagResult
{
TagName = tagName,
Avg = reader.IsDBNull(0) ? null : Convert.ToDouble(reader.GetValue(0)),
Min = reader.IsDBNull(1) ? null : Convert.ToDouble(reader.GetValue(1)),
Max = reader.IsDBNull(2) ? null : Convert.ToDouble(reader.GetValue(2)),
First = reader.IsDBNull(3) ? null : Convert.ToDouble(reader.GetValue(3)),
Last = reader.IsDBNull(4) ? null : Convert.ToDouble(reader.GetValue(4)),
PointCount = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)),
From = dto.From,
To = dto.To
});
}
}
result.Tags = tagResults;
result.Success = true;
_logger.LogInformation("[TextToSql] 분석 완료: {TagCount}개 태그", tagResults.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] 분석 실패");
result.Success = false;
result.Error = ex.Message;
}
return result;
}
private async Task<List<string>> GetAllTagNamesAsync(NpgsqlConnection conn)
{
var tags = new List<string>();
try
{
// measurements 테이블에서 고유 node_id 목록 조회
using var cmd = new NpgsqlCommand(
"SELECT DISTINCT node_id FROM measurements ORDER BY node_id", conn);
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
tags.Add(reader.GetString(0));
}
}
catch
{
// fallback: empty list
}
return tags;
}
}

View File

@@ -45,11 +45,20 @@ public class ExperionCertificateService : IExperionCertificateService
}
}
public async Task<ExperionCertResult> EnsureCertificateAsync(
public Task<ExperionCertResult> EnsureCertificateAsync(
string applicationUri,
string clientHostName,
IEnumerable<string> subjectAltNames,
string pfxPassword = "")
{
return Task.Run(() => EnsureCertificateCore(applicationUri, clientHostName, subjectAltNames, pfxPassword));
}
private ExperionCertResult EnsureCertificateCore(
string applicationUri,
string clientHostName,
IEnumerable<string> subjectAltNames,
string pfxPassword)
{
var path = PfxPath(clientHostName);

View File

@@ -36,9 +36,13 @@ public class ExperionCsvService : IExperionCsvService
if (!File.Exists(filePath))
throw new FileNotFoundException($"CSV 파일을 찾을 수 없습니다: {filePath}");
using var reader = new StreamReader(filePath);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
var records = csv.GetRecords<ExperionRecord>().ToList();
// I/O 작업이므로 Task.Run으로 백그라운드 스레드에서 실행
var records = await Task.Run(() =>
{
using var reader = new StreamReader(filePath);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
return csv.GetRecords<ExperionRecord>().ToList();
});
_logger.LogInformation("[ExperionCsv] 가져오기 완료: {Path} ({Count}건)", filePath, records.Count);
return records;

View File

@@ -2,6 +2,7 @@ using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace ExperionCrawler.Infrastructure.Database;
@@ -75,6 +76,9 @@ public class ExperionDbService : IExperionDbService
{
await _ctx.Database.EnsureCreatedAsync();
// TimeScaleDB 확장 활성화
await _ctx.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS timescaledb");
// EnsureCreatedAsync는 기존 DB에 새 테이블을 추가하지 않으므로
// raw_node_map / node_map_master 는 DDL로 직접 보장
await _ctx.Database.ExecuteSqlRawAsync("""
@@ -99,32 +103,25 @@ public class ExperionDbService : IExperionDbService
)
""");
// 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 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()
livevalue TEXT,
timestamp 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)");
// realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료");
// history 테이블은 수동으로 하이퍼테이블 생성 필요
// CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
// CreateHypertableAsync() 메서드에서 선택적으로 설정 가능
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)");
return true;
}
catch (Exception ex)
@@ -134,6 +131,90 @@ public class ExperionDbService : IExperionDbService
}
}
/// <summary>
/// history 테이블을 TimeScaleDB 하이퍼테이블로 생성/마이그레이션
/// </summary>
/// <param name="hypertableName">생성할 하이퍼테이블 이름 (예: history_hypertable)</param>
/// <param name="tableName">기존 테이블 이름 (예: history_table, null이면 새 테이블 생성)</param>
/// <param name="timeColumn">시간 컬럼 이름</param>
/// <param name="interval">청크 간격 (예: '1 day')</param>
/// <returns>하이퍼테이블 생성 여부 (true: 생성됨/기존 있음, false: 실패/스킵)</returns>
private async Task<bool> 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.Replace("'", "''")}' LIMIT 1", conn);
result = await cmd2.ExecuteScalarAsync();
if (result != null)
{
// 데이터가 있는 경우 migrate_data => true 옵션 필요
#pragma warning disable EF1002 // Method 'ExecuteSqlRawAsync' inserts interpolated strings directly into the SQL
await _ctx.Database.ExecuteSqlRawAsync(
$"SELECT create_hypertable('{tableName}', '{timeColumn}', chunk_time_interval => INTERVAL '{interval}', create_default_indexes => true, migrate_data => true)");
_logger.LogInformation("[ExperionDb] 테이블 '{TableName}'을(를) 하이퍼테이블로 변환 완료", tableName);
return true;
}
}
// 4⃣ 기존 테이블이 없으면 → 새 하이퍼테이블 테이블 생성
// TimeScaleDB 요구사항: 고유 인덱스를 위해서는 partitioning 컬럼이 primary key에 포함되어야 함
#pragma warning disable EF1002 // Method 'ExecuteSqlRawAsync' inserts interpolated strings directly into the SQL
await _ctx.Database.ExecuteSqlRawAsync($"""
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})
)
""");
await _ctx.Database.ExecuteSqlRawAsync($"""
SELECT create_hypertable('{hypertableName}', '{timeColumn}', chunk_time_interval => INTERVAL '{interval}', create_default_indexes => true)
""");
_logger.LogInformation("[ExperionDb] 새 하이퍼테이블 '{HypertableName}' 생성 완료", hypertableName);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[ExperionDb] 하이퍼테이블 '{HypertableName}' 생성 실패 (기본 테이블 사용 계속)", hypertableName);
return false;
}
}
public async Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records)
{
var list = records.ToList();
@@ -390,4 +471,363 @@ public class ExperionDbService : IExperionDbService
.GroupBy(x => x.NodeId)
.ToDictionary(g => g.Key, g => g.First().DataType);
}
/// <summary>
/// 하이퍼테이블 상태 조회합니다.
/// 하이퍼테이블인지 여부, 레코드 수, 보존 정책, 압축, 연속 집계 설정 등을 확인합니다.
/// </summary>
public async Task<HypertableStatusInfo> 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
};
}
}
/// <summary>
/// 수동으로 하이퍼테이블을 생성합니다.
/// 테이블이 이미 존재하거나 하이퍼테이블로 변환된 경우 예외를 throw합니다.
/// </summary>
public async Task<HypertableCreateResult> CreateHypertableAsync(HypertableCreateRequest request)
{
// 식별자 검증 - SQL injection 방지
if (!IsValidSqlIdentifier(request.TableName))
{
return HypertableCreateResult.Failed($"테이블 이름 '{request.TableName}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
}
if (!IsValidSqlIdentifier(request.TimeColumn))
{
return HypertableCreateResult.Failed($"시간 컬럼 이름 '{request.TimeColumn}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
}
if (!IsValidSqlIdentifier(request.TimeInterval?.Replace(" ", "")))
{
return HypertableCreateResult.Failed($"시간 간격 '{request.TimeInterval}'은 유효하지 않습니다.");
}
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<string>();
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);
}
}
/// <summary>
/// SQL 식별자로 안전한지 검증합니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 허용합니다.
/// EF1002 SQL injection 방지를 위해 DDL 문에서 식별자를 사용할 때 이 메서드로 검증을 필수로 합니다.
/// </summary>
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_\-\.]+$");
}
}
/// <summary>
/// 하이퍼테이블 상태 정보 결과 클래스
/// </summary>
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; }
}
/// <summary>
/// 하이퍼테이블 생성 요청 클래스
/// </summary>
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;
}
/// <summary>
/// 하이퍼테이블 생성 결과 클래스
/// </summary>
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 };
}

View File

@@ -105,7 +105,18 @@ public class ExperionOpcClient : IExperionOpcClient
// 원본: new UserIdentity(userName, Encoding.UTF8.GetBytes(password))
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
return await Session.Create(appConfig, endpoint, false, sessionName, 60_000, identity, null);
// CS0618: Session.Create는 obsolete이지만 SessionFactory/CreateAsync가 현재 라이브러리에 없음
// Task.Run으로 래핑하여 비동기 실행
#pragma warning disable CS0618 // 'Session.Create()' is obsolete
return await Task.Run(() => Session.Create(
appConfig,
endpoint,
false,
sessionName,
60_000,
identity,
null));
#pragma warning restore CS0618 // 'Session.Create()' is obsolete
}
// ── 접속 테스트 ───────────────────────────────────────────────────────────

View File

@@ -141,7 +141,7 @@ public class ExperionOpcServerService : IExperionOpcServerService, IHostedServic
var config = BuildServerConfig();
_server = new ExperionStandardServer();
_server.Start(config);
await _server.StartAsync(config);
_nodeManager = _server.NodeManager;
_running = true;
@@ -168,7 +168,9 @@ public class ExperionOpcServerService : IExperionOpcServerService, IHostedServic
try
{
_nodeManager?.UpdateServerStatus("Stopped", 0);
#pragma warning disable CS0618 // 'Stop()' is obsolete
_server?.Stop();
#pragma warning restore CS0618 // 'Stop()' is obsolete
}
catch (Exception ex) { _logger.LogWarning(ex, "[OpcServer] 서버 Stop() 중 오류 (무시)"); }
@@ -264,9 +266,24 @@ public class ExperionOpcServerService : IExperionOpcServerService, IHostedServic
};
}
public void Dispose()
public async ValueTask DisposeAsync()
{
try { _server?.Stop(); } catch { /* ignore */ }
if (_server != null)
{
try { await _server.StopAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* ignore */ }
_server = null;
}
}
void IDisposable.Dispose()
{
try
{
#pragma warning disable CS0618 // 'Stop()' is obsolete
_server?.Stop();
#pragma warning restore CS0618 // 'Stop()' is obsolete
}
catch { /* ignore */ }
_server = null;
}
}

View File

@@ -174,14 +174,18 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
try
{
// OPC UA 서버에 실제 적용 — 서버가 node_id 유효성 검증
await Task.Run(() => _subscription.ApplyChanges());
#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete
await Task.Run(() => { _subscription.ApplyChanges(); });
#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete
// 서버 응답 상태 확인 (Error가 null이면 정상)
if (item.Status.Error != null && !StatusCode.IsGood(item.Status.Error.StatusCode))
{
// 유효하지 않은 node_id → subscription에서 제거
_subscription.RemoveItem(item);
await Task.Run(() => _subscription.ApplyChanges());
#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete
await Task.Run(() => { _subscription.ApplyChanges(); });
#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete
var code = item.Status.Error.StatusCode;
_logger.LogWarning("[Realtime] 잘못된 node_id: {NodeId} — {Code}", nodeId, code);
@@ -290,7 +294,9 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
}
_session.AddSubscription(_subscription);
#pragma warning disable CS0618 // 'Create()' is obsolete
_subscription.Create();
#pragma warning restore CS0618 // 'Create()' is obsolete
// nodeId → RealtimePoint 캐시 빌드 (FlushLoop에서 tagname 조회용)
_pointCache = points.ToDictionary(p => p.NodeId, p => p);
@@ -380,7 +386,9 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
{
if (_subscription != null)
{
#pragma warning disable CS0618 // 'Delete()' is obsolete
_subscription.Delete(true);
#pragma warning restore CS0618 // 'Delete()' is obsolete
_subscription = null;
}
if (_session != null)
@@ -458,14 +466,36 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
ExperionServerConfig cfg)
{
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
return await Session.Create(
appConfig, endpoint, false, "ExperionRealtimeSession", 60_000, identity, null);
// CS0618: Session.Create는 obsolete이지만 SessionFactory/CreateAsync가 현재 라이브러리에 없음
// Task.Run으로 래핑하여 비동기 실행
#pragma warning disable CS0618 // 'Session.Create()' is obsolete
return await Task.Run(() => Session.Create(
appConfig,
endpoint,
false,
"ExperionRealtimeSession",
60_000,
identity,
null));
#pragma warning restore CS0618 // 'Session.Create()' is obsolete
}
private volatile bool _disposed = false;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cts?.Cancel();
CleanupSessionAsync().GetAwaiter().GetResult();
try
{
CleanupSessionAsync().GetAwaiter().GetResult();
}
catch
{
// Ignore exceptions during disposal
}
_cts?.Dispose();
}
}

View File

@@ -3,6 +3,7 @@ using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.Services;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Csv;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.AspNetCore.Mvc;
namespace ExperionCrawler.Web.Controllers;
@@ -368,14 +369,31 @@ public class ExperionRealtimeController : ControllerBase
public class ExperionHistoryController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
public ExperionHistoryController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
private readonly ILogger<ExperionHistoryController> _logger;
public ExperionHistoryController(IExperionDbService dbSvc, ILogger<ExperionHistoryController> logger)
{
_dbSvc = dbSvc;
_logger = logger;
}
/// <summary>realtime_table 의 tagname 목록</summary>
[HttpGet("tagnames")]
public async Task<IActionResult> TagNames()
{
var names = await _dbSvc.GetTagNamesAsync();
return Ok(new { tagNames = names });
try
{
_logger.LogDebug("[History] tagname 목록 조회 시작");
var names = await _dbSvc.GetTagNamesAsync();
var count = names.Count();
_logger.LogDebug("[History] tagname 목록 조회 완료: {Count}개 태그", count);
return Ok(new { tagNames = names });
}
catch (Exception ex)
{
_logger.LogError(ex, "[History] tagname 목록 조회 실패");
return StatusCode(500, new { success = false, message = $"tagname 조회 실패: {ex.Message}" });
}
}
/// <summary>이력 조회 (tagname 다중, 시간범위, limit)</summary>
@@ -386,18 +404,39 @@ public class ExperionHistoryController : ControllerBase
[FromQuery] DateTime? to,
[FromQuery] int limit = 1000)
{
var result = await _dbSvc.QueryHistoryAsync(
tagNames ?? Enumerable.Empty<string>(), from, to, limit);
return Ok(new
try
{
tagNames = result.TagNames,
rows = result.Rows.Select(r => new
var tagList = tagNames ?? Enumerable.Empty<string>().ToList();
_logger.LogDebug("[History] 이력 조회 시작 - 태그 수: {TagCount}, FROM: {From}, TO: {To}, LIMIT: {Limit}",
tagList.Count(), from, to, limit);
var result = await _dbSvc.QueryHistoryAsync(
tagList, from, to, limit);
_logger.LogDebug("[History] 이력 조회 완료: {RowCount}행, {TagCount}태그",
result.Rows.Count(), result.TagNames.Count());
return Ok(new
{
recordedAt = r.RecordedAt,
values = r.Values
})
});
tagNames = result.TagNames,
rows = result.Rows.Select(r => new
{
recordedAt = r.RecordedAt,
values = r.Values
})
});
}
catch (Exception ex)
{
_logger.LogError(ex, "[History] 이력 조회 실패 - tagNames: {@TagNames}, from: {From}, to: {To}",
tagNames, from, to);
return StatusCode(500, new
{
success = false,
message = $"이력 조회 실패: {ex.Message}",
detail = ex.InnerException?.Message ?? ex.StackTrace
});
}
}
}
@@ -522,3 +561,57 @@ public class ExperionNodeMapController : ControllerBase
});
}
}
// ── 하이퍼테이블 관리 ───────────────────────────────────────────────────────────
[ApiController]
[Route("api/experion/hypertable")]
public class ExperionHypertableController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
public ExperionHypertableController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
/// <summary>하이퍼테이블 상태 조회</summary>
[HttpGet("status")]
public async Task<IActionResult> GetStatus()
{
var status = await _dbSvc.GetHypertableStatusAsync();
return Ok(new
{
isHypertable = status.IsHypertable,
tableName = status.TableName,
statusMessage = status.StatusMessage,
recordCount = status.RecordCount,
hasRetentionPolicy = status.HasRetentionPolicy,
hasCompression = status.HasCompression,
hasContinuousAggregate = status.HasContinuousAggregate
});
}
/// <summary>하이퍼테이블 수동 생성</summary>
[HttpPost("create")]
public async Task<IActionResult> Create([FromBody] HypertableCreateDto request)
{
var createRequest = new HypertableCreateRequest
{
TableName = request.TableName ?? "history_table",
TimeColumn = request.TimeColumn ?? "recorded_at",
TimeInterval = request.TimeInterval ?? "1 day",
MigrateData = request.MigrateData,
SetRetentionPolicy = request.SetRetentionPolicy,
RetentionPeriod = request.RetentionPeriod ?? "90 days",
EnableCompression = request.EnableCompression,
CompressionPeriod = request.CompressionPeriod ?? "1 day",
CreateContinuousAggregate = request.CreateContinuousAggregate
};
var result = await _dbSvc.CreateHypertableAsync(createRequest);
return result.Success
? Ok(new { result.Success, result.Message, result.TableName })
: (IActionResult)(result.TableName != null
? StatusCode(409, new { result.Success, result.Message })
: StatusCode(500, new { result.Success, result.Message }));
}
}

View File

@@ -0,0 +1,68 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ExperionCrawler.Web.Controllers;
/// <summary>
/// Text-to-SQL API 컨트롤러
/// 자연어 질의를 SQL로 변환하고 시계열 데이터를 조회합니다.
/// </summary>
[ApiController]
[Route("api/text-to-sql")]
public class TextToSqlController : ControllerBase
{
private readonly ITextToSqlService _textToSqlService;
public TextToSqlController(ITextToSqlService textToSqlService)
{
_textToSqlService = textToSqlService;
}
/// <summary>
/// 자연어 질의를 SQL로 변환
/// </summary>
[HttpPost("parse")]
public async Task<IActionResult> Parse([FromBody] NaturalLanguageQueryDto dto)
{
try
{
var sql = await _textToSqlService.ParseNaturalLanguageAsync(dto.Query);
return Ok(new { success = true, sql });
}
catch (Exception ex)
{
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// SQL 쿼리 실행 및 결과 반환
/// </summary>
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] SqlQueryDto dto)
{
var result = await _textToSqlService.ExecuteQueryAsync(dto.Sql, dto.Limit);
return Ok(result);
}
/// <summary>
/// 쿼리 제안 (자동 완성)
/// </summary>
[HttpGet("suggest")]
public async Task<IActionResult> Suggest([FromQuery] string input = "")
{
var suggestions = await _textToSqlService.SuggestQueriesAsync(input);
return Ok(new { success = true, suggestions });
}
/// <summary>
/// 시계열 분석 (평균, 최대, 최소, 추세)
/// </summary>
[HttpPost("analyze")]
public async Task<IActionResult> Analyze([FromBody] AnalyzeRequestDto dto)
{
var result = await _textToSqlService.AnalyzeAsync(dto);
return Ok(result);
}
}

View File

@@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ExperionCrawler</RootNamespace>
<AssemblyName>ExperionCrawler</AssemblyName>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<RuntimeIdentifier>linux-arm64</RuntimeIdentifier> <!-- linux-amd64 일반 pc에서 -->
<SelfContained>false</SelfContained>
</PropertyGroup>
@@ -20,7 +20,7 @@
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.134" />
<!-- CSV -->
<PackageReference Include="CsvHelper" Version="33.0.1" />
<!-- SQLite (Ubuntu 서버, 별도 DB 설치 불필요) -->
<!-- PostgreSQL + TimeScaleDB (TimeScaleDB는 PostgreSQL 확장, 별도 패키지 불필요) -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.13">
<PrivateAssets>all</PrivateAssets>

View File

@@ -30,6 +30,9 @@ builder.Services.AddScoped<IExperionDbService, ExperionDbService>();
// ── Application Services ──────────────────────────────────────────────────────
builder.Services.AddScoped<ExperionCrawlService>();
// ── Text-to-SQL Service ──────────────────────────────────────────────────────
builder.Services.AddScoped<ITextToSqlService, TextToSqlService>();
// ── Realtime & History BackgroundServices ─────────────────────────────────────
builder.Services.AddSingleton<ExperionRealtimeService>();
builder.Services.AddSingleton<IExperionRealtimeService>(

View File

@@ -3,12 +3,20 @@
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
"Microsoft.EntityFrameworkCore": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
},
"Console": {
"LogLevel": {
"Default": "Information",
"Microsoft.EntityFrameworkCore": "Information"
}
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true"
"DefaultConnection": "Host=localhost;Port=5432;Database=iiot_platform;Username=postgres;Password=postgres",
"ExperionDbConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true;Include Error Detail=true"
},
"OpcUaServer": {
"Port": 4841,

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

View File

@@ -1 +0,0 @@
{"ContentRoots":["/home/pacer/projects/ExperionCrawler/src/Web/wwwroot/"],"Root":{"Children":{"css":{"Children":{"style.css":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"css/style.css"},"Patterns":null}},"Asset":null,"Patterns":null},"index.html":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"index.html"},"Patterns":null},"js":{"Children":{"app.js":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"js/app.js"},"Patterns":null}},"Asset":null,"Patterns":null}},"Asset":null,"Patterns":[{"ContentRootIndex":0,"Pattern":"**","Depth":0}]}}

View File

@@ -1,20 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true"
},
"OpcUaServer": {
"Port": 4841,
"EnableSecurity": false,
"AllowAnonymous": true,
"AllowedUsernames": [ "opcuser" ],
"AllowedPasswords": [ "opcpass" ]
}
}

Some files were not shown because too many files have changed in this diff Show More