Merge pull request 'refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 + 카탈로그' (#68) from refactor/cleanup-iran-backend-catalog into develop

This commit is contained in:
htlee 2026-04-16 16:20:05 +09:00
커밋 5a57959bd5
39개의 변경된 파일503개의 추가작업 그리고 363개의 파일을 삭제

파일 보기

@ -24,14 +24,14 @@ kcg-ai-monitoring/
```
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
↑ write
[Prediction FastAPI :8001] ──────┘ (5분 주기 분석 결과 저장)
↑ read ↑ read
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택)
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
↑ read
[SNPDB PostgreSQL] (AIS 원본)
```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습)
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations)
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
## 명령어

파일 보기

@ -12,7 +12,7 @@ Phase 2에서 초기화 예정.
## 책임
- 자체 인증/권한/감사로그
- 운영자 의사결정 (모선 확정/제외/학습)
- iran 백엔드 분석 데이터 프록시
- prediction 분석 결과 조회 API (`/api/analysis/*`)
- 관리자 화면 API
상세 설계: `.claude/plans/vast-tinkering-knuth.md`

파일 보기

@ -1,17 +1,11 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@ -28,127 +22,23 @@ import java.util.Map;
@RequiredArgsConstructor
public class AdminStatsController {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
private final AdminStatsService adminStatsService;
/**
* 감사 로그 통계.
* - total: 전체 건수
* - last24h: 24시간 건수
* - failed24h: 24시간 FAILED 건수
* - byAction: 액션별 카운트 (top 10)
* - hourly24: 시간별 24시간 추세
*/
@GetMapping("/audit")
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
public Map<String, Object> auditStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
return adminStatsService.auditStats();
}
/**
* 접근 로그 통계.
* - total: 전체 건수
* - last24h: 24시간
* - error4xx, error5xx: 24시간 에러
* - avgDurationMs: 24시간 평균 응답 시간
* - topPaths: 24시간 호출 많은 경로
*/
@GetMapping("/access")
@RequirePermission(resource = "admin:access-logs", operation = "READ")
public Map<String, Object> accessStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
return adminStatsService.accessStats();
}
/**
* 로그인 통계.
* - total: 전체 건수
* - success24h: 24시간 성공
* - failed24h: 24시간 실패
* - locked24h: 24시간 잠금
* - successRate: 성공률 (24시간 , %)
* - byUser: 사용자별 성공 카운트 (top 10)
* - daily7d: 7일 일별 추세
*/
@GetMapping("/login")
@RequirePermission(resource = "admin:login-history", operation = "READ")
public Map<String, Object> loginStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
return adminStatsService.loginStats();
}
}

파일 보기

@ -0,0 +1,124 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 시스템 관리 대시보드 메트릭 서비스.
* 감사 로그 / 접근 로그 / 로그인 이력의 집계 쿼리를 담당한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdminStatsService {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
/**
* 감사 로그 통계.
*/
public Map<String, Object> auditStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
}
/**
* 접근 로그 통계.
*/
public Map<String, Object> accessStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
}
/**
* 로그인 통계.
*/
public Map<String, Object> loginStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
}
}

파일 보기

@ -13,7 +13,6 @@ public class AppProperties {
private Prediction prediction = new Prediction();
private SignalBatch signalBatch = new SignalBatch();
private IranBackend iranBackend = new IranBackend();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@ -27,11 +26,6 @@ public class AppProperties {
private String baseUrl;
}
@Getter @Setter
public static class IranBackend {
private String baseUrl;
}
@Getter @Setter
public static class Cors {
private String allowedOrigins;

파일 보기

@ -0,0 +1,49 @@
package gc.mda.kcg.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
/**
* 외부 서비스용 RestClient Bean 정의.
* Proxy controller 들이 @PostConstruct 에서 ad-hoc 생성하던 RestClient 일원화한다.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RestClientConfig {
private final AppProperties appProperties;
/**
* prediction FastAPI 서비스 호출용.
* base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
*/
@Bean
public RestClient predictionRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getPrediction().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://localhost:8001";
log.info("predictionRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
/**
* signal-batch 선박 항적 서비스 호출용.
* base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
*/
@Bean
public RestClient signalBatchRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getSignalBatch().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://192.168.1.18:18090/signal-batch";
log.info("signalBatchRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
}

파일 보기

@ -1,70 +0,0 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.Map;
/**
* iran 백엔드 REST 클라이언트.
*
* 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
* 호출 실패 graceful degradation: null 반환 프론트에 응답.
*
* 향후 prediction 이관 IranBackendClient를 PredictionDirectClient로 교체하면 .
*/
@Slf4j
@Component
public class IranBackendClient {
private final RestClient restClient;
private final boolean enabled;
public IranBackendClient(AppProperties appProperties) {
String baseUrl = appProperties.getIranBackend().getBaseUrl();
this.enabled = baseUrl != null && !baseUrl.isBlank();
this.restClient = enabled
? RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Accept", "application/json")
.build()
: RestClient.create();
log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl);
}
public boolean isEnabled() {
return enabled;
}
/**
* GET 호출 (Map 반환). 실패 null 반환.
*/
public Map<String, Object> getJson(String path) {
if (!enabled) return null;
try {
@SuppressWarnings("unchecked")
Map<String, Object> body = restClient.get().uri(path).retrieve().body(Map.class);
return body;
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
/**
* 임의 타입 GET 호출.
*/
public <T> T getAs(String path, Class<T> responseType) {
if (!enabled) return null;
try {
return restClient.get().uri(path).retrieve().body(responseType);
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
}

파일 보기

@ -1,10 +1,9 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
@ -14,7 +13,7 @@ import java.util.Map;
/**
* Prediction FastAPI 서비스 프록시.
* app.prediction.base-url (기본: http://localhost:8001, 운영: http://192.168.1.19:18092)
* 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
*
* 엔드포인트:
* GET /api/prediction/health FastAPI /health
@ -30,20 +29,8 @@ import java.util.Map;
@RequiredArgsConstructor
public class PredictionProxyController {
private final AppProperties appProperties;
private final RestClient.Builder restClientBuilder;
private RestClient predictionClient;
@PostConstruct
void init() {
String baseUrl = appProperties.getPrediction().getBaseUrl();
predictionClient = restClientBuilder
.baseUrl(baseUrl != null && !baseUrl.isBlank() ? baseUrl : "http://localhost:8001")
.defaultHeader("Accept", "application/json")
.build();
log.info("PredictionProxyController initialized: baseUrl={}", baseUrl);
}
@Qualifier("predictionRestClient")
private final RestClient predictionClient;
@GetMapping("/health")
public ResponseEntity<?> health() {

파일 보기

@ -13,8 +13,7 @@ import java.util.List;
/**
* vessel_analysis_results 직접 조회 API.
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
*/
@RestController
@RequestMapping("/api/analysis")

파일 보기

@ -2,6 +2,7 @@ package gc.mda.kcg.domain.analysis;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.fleet.ParentResolution;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import lombok.RequiredArgsConstructor;
@ -290,6 +291,7 @@ public class VesselAnalysisGroupService {
/**
* 모선 확정/제외 처리.
*/
@Auditable(action = "PARENT_RESOLVE", resourceType = "GEAR_GROUP")
public Map<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
try {
// 먼저 resolution 존재 확인

파일 보기

@ -1,10 +1,9 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
@ -16,10 +15,6 @@ import java.util.Map;
/**
* 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
* + signal-batch 선박 항적 프록시.
*/
/**
* 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회.
*
* 라우팅:
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
@ -35,19 +30,9 @@ import java.util.Map;
public class VesselAnalysisProxyController {
private final VesselAnalysisGroupService groupService;
private final AppProperties appProperties;
private final RestClient.Builder restClientBuilder;
private RestClient signalBatchClient;
@PostConstruct
void init() {
String sbUrl = appProperties.getSignalBatch().getBaseUrl();
signalBatchClient = restClientBuilder
.baseUrl(sbUrl != null && !sbUrl.isBlank() ? sbUrl : "http://192.168.1.18:18090/signal-batch")
.defaultHeader("Accept", "application/json")
.build();
}
@Qualifier("signalBatchRestClient")
private final RestClient signalBatchClient;
@GetMapping
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
@ -69,7 +54,7 @@ public class VesselAnalysisProxyController {
@GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups(
@org.springframework.web.bind.annotation.RequestParam(required = false) String groupType
@RequestParam(required = false) String groupType
) {
Map<String, Object> result = groupService.getGroups(groupType);
return ResponseEntity.ok(result);

파일 보기

@ -1,5 +1,6 @@
package gc.mda.kcg.domain.enforcement;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
@ -48,6 +49,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
public EnforcementRecord createRecord(CreateRecordRequest req) {
EnforcementRecord record = EnforcementRecord.builder()
.enfUid(generateEnfUid())
@ -87,6 +89,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
EnforcementRecord record = getRecord(id);
if (req.result() != null) record.setResult(req.result());
@ -107,6 +110,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
public EnforcementPlan createPlan(CreatePlanRequest req) {
EnforcementPlan plan = EnforcementPlan.builder()
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())

파일 보기

@ -2,12 +2,9 @@ package gc.mda.kcg.domain.event;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 알림 조회 API.
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 ) 이력을 제공.
@ -17,7 +14,7 @@ import java.util.List;
@RequiredArgsConstructor
public class AlertController {
private final PredictionAlertRepository alertRepository;
private final AlertService alertService;
/**
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
@ -30,10 +27,8 @@ public class AlertController {
@RequestParam(defaultValue = "20") int size
) {
if (eventId != null) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
return alertService.findByEventId(eventId);
}
return alertRepository.findAllByOrderBySentAtDesc(
PageRequest.of(page, size)
);
return alertService.findAll(PageRequest.of(page, size));
}
}

파일 보기

@ -0,0 +1,29 @@
package gc.mda.kcg.domain.event;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 예측 알림 조회 서비스.
* 이벤트에 대해 발송된 알림(SMS/푸시 ) 이력을 조회한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AlertService {
private final PredictionAlertRepository alertRepository;
public List<PredictionAlert> findByEventId(Long eventId) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
}
public Page<PredictionAlert> findAll(Pageable pageable) {
return alertRepository.findAllByOrderBySentAtDesc(pageable);
}
}

파일 보기

@ -16,8 +16,8 @@ import java.time.OffsetDateTime;
import java.util.List;
/**
* 모선 워크플로우 핵심 서비스 (HYBRID).
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
* 모선 워크플로우 핵심 서비스.
* - 후보 데이터: prediction이 kcgaidb 저장한 분석 결과를 참조
* - 운영자 결정: 자체 DB (gear_group_parent_resolution )
*
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.

파일 보기

@ -10,7 +10,7 @@ import java.util.UUID;
/**
* 모선 확정 결과 (운영자 의사결정).
* iran 백엔드의 후보 데이터(prediction이 생성) 별도로 운영자 결정만 자체 DB에 저장.
* prediction이 생성한 후보 데이터 별도로 운영자 결정만 자체 DB에 저장.
*/
@Entity
@Table(name = "gear_group_parent_resolution", schema = "kcg",

파일 보기

@ -4,9 +4,7 @@ import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@ -18,10 +16,7 @@ import java.util.List;
@RequiredArgsConstructor
public class MasterDataController {
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
private final MasterDataService masterDataService;
// ========================================================================
// 코드 마스터 (인증만, 권한 불필요)
@ -29,12 +24,12 @@ public class MasterDataController {
@GetMapping("/api/codes")
public List<CodeMaster> listCodes(@RequestParam String group) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group);
return masterDataService.listCodes(group);
}
@GetMapping("/api/codes/{codeId}/children")
public List<CodeMaster> listChildren(@PathVariable String codeId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(codeId);
return masterDataService.listChildren(codeId);
}
// ========================================================================
@ -43,35 +38,24 @@ public class MasterDataController {
@GetMapping("/api/gear-types")
public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
return masterDataService.listGearTypes();
}
@GetMapping("/api/gear-types/{gearCode}")
public GearType getGearType(@PathVariable String gearCode) {
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
return masterDataService.getGearType(gearCode);
}
@PostMapping("/api/gear-types")
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
public GearType createGearType(@RequestBody GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
return masterDataService.createGearType(gearType);
}
@PutMapping("/api/gear-types/{gearCode}")
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
return masterDataService.updateGearType(gearCode, gearType);
}
// ========================================================================
@ -81,7 +65,7 @@ public class MasterDataController {
@GetMapping("/api/patrol-ships")
@RequirePermission(resource = "patrol:patrol-route", operation = "READ")
public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
return masterDataService.listPatrolShips();
}
@PatchMapping("/api/patrol-ships/{id}/status")
@ -90,47 +74,28 @@ public class MasterDataController {
@PathVariable Long id,
@RequestBody PatrolShipStatusRequest request
) {
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (request.status() != null) ship.setCurrentStatus(request.status());
if (request.lat() != null) ship.setCurrentLat(request.lat());
if (request.lon() != null) ship.setCurrentLon(request.lon());
if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode());
if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct());
return patrolShipRepository.save(ship);
return masterDataService.updatePatrolShipStatus(id, new MasterDataService.PatrolShipStatusCommand(
request.status(), request.lat(), request.lon(), request.zoneCode(), request.fuelPct()
));
}
// ========================================================================
// 선박 허가 (vessel 권한)
// 선박 허가 (인증만, 공통 마스터 데이터)
// ========================================================================
@GetMapping("/api/vessel-permits")
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public Page<VesselPermit> listVesselPermits(
@RequestParam(required = false) String flag,
@RequestParam(required = false) String permitStatus,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
PageRequest pageable = PageRequest.of(page, size);
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
return masterDataService.listVesselPermits(flag, permitStatus, PageRequest.of(page, size));
}
@GetMapping("/api/vessel-permits/{mmsi}")
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
return masterDataService.getVesselPermit(mmsi);
}
// ========================================================================

파일 보기

@ -0,0 +1,115 @@
package gc.mda.kcg.master;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/**
* 마스터 데이터(코드/어구/함정/선박허가) 조회 관리 서비스.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MasterDataService {
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// 코드 마스터
public List<CodeMaster> listCodes(String groupCode) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(groupCode);
}
public List<CodeMaster> listChildren(String parentId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(parentId);
}
// 어구 유형
public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
}
public GearType getGearType(String gearCode) {
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
}
@Transactional
public GearType createGearType(GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
}
@Transactional
public GearType updateGearType(String gearCode, GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
}
// 함정
public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
}
@Transactional
public PatrolShip updatePatrolShipStatus(Long id, PatrolShipStatusCommand command) {
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (command.status() != null) ship.setCurrentStatus(command.status());
if (command.lat() != null) ship.setCurrentLat(command.lat());
if (command.lon() != null) ship.setCurrentLon(command.lon());
if (command.zoneCode() != null) ship.setCurrentZoneCode(command.zoneCode());
if (command.fuelPct() != null) ship.setFuelPct(command.fuelPct());
return patrolShipRepository.save(ship);
}
// 선박 허가
public Page<VesselPermit> listVesselPermits(String flag, String permitStatus, Pageable pageable) {
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
}
public VesselPermit getVesselPermit(String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
}
// Command DTO
public record PatrolShipStatusCommand(
String status,
Double lat,
Double lon,
String zoneCode,
Integer fuelPct
) {}
}

파일 보기

@ -66,9 +66,6 @@ app:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
signal-batch:
base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
iran-backend:
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
jwt:

파일 보기

@ -31,7 +31,7 @@
| 서비스 | systemd | 포트 | 로그 |
|---|---|---|---|
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
| kcg-prediction (기존 iran) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction (레거시) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` |
## 디렉토리 구조
@ -166,7 +166,7 @@ PGPASSWORD='Kcg2026ai' psql -h 211.208.115.83 -U kcg-app -d kcgaidb
| 443 | nginx (HTTPS) | rocky-211 |
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
| 18092 | kcg-ai-prediction (FastAPI) | redis-211 |
| 8001 | kcg-prediction (기존 iran) | redis-211 |
| 8001 | kcg-prediction (레거시) | redis-211 |
| 18091 | kcg-prediction-lab | redis-211 |
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
| 6379 | Redis | redis-211 |
@ -226,5 +226,5 @@ ssh redis-211 "systemctl restart kcg-ai-prediction"
| `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 |
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
| `/home/apps/kcg-prediction/` | 기존 iran prediction (포트 8001) |
| `/home/apps/kcg-prediction/` | 레거시 prediction (포트 8001) |
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |

파일 보기

@ -4,6 +4,15 @@
## [Unreleased]
### 변경
- **iran 백엔드 프록시 잔재 제거**`IranBackendClient` dead class 삭제, `application.yml``iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)``AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹(노드 ID 안정성 원칙 준수, 1~2 릴리즈 후 삭제 예정)
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
- **감사 로그 보강**`EnforcementService` 의 createRecord / updateRecord / createPlan 에 `@Auditable` 추가 (ENFORCEMENT_CREATE/UPDATE/PLAN_CREATE). `VesselAnalysisGroupService.resolveParent``PARENT_RESOLVE` 액션 기록. 모든 쓰기 액션이 `auth_audit_log` 에 자동 수집
- **alertLevels 카탈로그 확장** — 8개 화면의 `level === 'CRITICAL' ? ... : 'HIGH' ? ...` 식 직접 분기를 제거하기 위해 `isValidAlertLevel` (타입 가드) / `isHighSeverity` / `getAlertLevelOrder` / `ALERT_LEVEL_MARKER_OPACITY` / `ALERT_LEVEL_MARKER_RADIUS` / `ALERT_LEVEL_TIER_SCORE` 헬퍼·상수 신설. LiveMapView 마커 시각 매핑, DarkVesselDetection tier→점수, GearIdentification 타입 가드, vesselAnomaly 패널 severity 할당 헬퍼로 치환
### 추가
- **performanceStatus 카탈로그 등록** — 이미 존재하던 `shared/constants/performanceStatus.ts` (good/normal/warning/critical/running/passed/failed/active/scheduled/archived 10종) 를 `catalogRegistry` 에 등록. design-system 쇼케이스 자동 노출 + admin 성능/보관/검증 페이지 SSOT 일원화
## [2026-04-16.7]
### 변경

파일 보기

@ -380,7 +380,7 @@ export function ChinaFishing() {
</div>
)}
{/* iran 백엔드 실시간 분석 결과 */}
{/* 중국 선박 실시간 분석 결과 */}
<RealAllVessels />
{/* ── 상단 바: 기준일 + 검색 ── */}

파일 보기

@ -12,6 +12,7 @@ import type { MarkerData } from '@lib/map';
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelTierScore } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
import { DarkDetailPanel } from './components/DarkDetailPanel';
@ -87,7 +88,7 @@ export function DarkVesselDetection() {
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
render: (v) => {
const tier = v as string;
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
return <Badge intent={getRiskIntent(tier === 'WATCH' ? 40 : getAlertLevelTierScore(tier))} size="sm">{tier}</Badge>;
} },
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
render: (v) => {

파일 보기

@ -320,7 +320,7 @@ export function GearDetection() {
const mapRef = useRef<MapHandle>(null);
// overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조
// iran 패턴: 리플레이 훅이 overlay.setProps() 직접 호출
// 리플레이 훅이 overlay.setProps() 직접 호출 (단일 렌더링 경로)
const overlayRef = useMemo<React.RefObject<MapboxOverlay | null>>(() => ({
get current() { return mapRef.current?.overlay ?? null; },
}), []);
@ -424,7 +424,7 @@ export function GearDetection() {
}, [DATA, selectedId, isReplayActive, replayGroupKey]);
// 리플레이 비활성 시만 useMapLayers가 overlay 제어
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (iran 패턴: 단일 렌더링 경로)
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (단일 렌더링 경로)
useEffect(() => {
if (isReplayActive) return; // replay hook이 overlay 독점
const raf = requestAnimationFrame(() => {
@ -456,7 +456,7 @@ export function GearDetection() {
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - </span>
<span>AI - </span>
</div>
)}

파일 보기

@ -7,7 +7,7 @@ import {
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getAlertLevelIntent, isValidAlertLevel } from '@shared/constants/alertLevels';
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
@ -687,9 +687,7 @@ export function GearIdentification() {
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
const alertLevel = (v.riskLevel === 'CRITICAL' || v.riskLevel === 'HIGH' || v.riskLevel === 'MEDIUM' || v.riskLevel === 'LOW')
? v.riskLevel
: 'LOW';
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
setResult({
origin: 'china',

파일 보기

@ -9,9 +9,9 @@ import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* iran / .
* prediction / .
* - GET /api/vessel-analysis/groups
* - DB의 ParentResolution
* - DB의 ParentResolution
*/
export function RealGearGroups() {
@ -54,7 +54,7 @@ export function RealGearGroups() {
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
<MapPin className="w-4 h-4 text-orange-400" /> / (iran )
<MapPin className="w-4 h-4 text-orange-400" /> /
{!available && <Badge intent="critical" size="sm"></Badge>}
</div>
<div className="text-[10px] text-hint mt-0.5">

파일 보기

@ -12,6 +12,14 @@
* , .
*/
import type { VesselAnalysis } from '@/services/analysisApi';
import type { AlertLevel } from '@shared/constants/alertLevels';
/** riskLevel → 특이운항 패널의 severity/한글 레이블 매핑. */
const HIGH_RISK_LEVEL_META: Partial<Record<AlertLevel, { severity: AnomalyPoint['severity']; label: string }>> = {
CRITICAL: { severity: 'critical', label: '고위험 CRITICAL' },
HIGH: { severity: 'warning', label: '위험 HIGH' },
MEDIUM: { severity: 'info', label: '주의 MEDIUM' },
};
export type AnomalyCategory =
| 'DARK'
@ -113,18 +121,15 @@ export function classifyAnomaly(v: VesselAnalysis): AnomalyPoint | null {
descs.push(`어구 판정 ${v.gearJudgment}${v.gearCode ? ` (${v.gearCode})` : ''}`);
severity = bumpSeverity(severity, 'warning');
}
if (v.riskLevel === 'CRITICAL') {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`고위험 CRITICAL (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'critical');
} else if (v.riskLevel === 'HIGH') {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`위험 HIGH (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'warning');
} else if (v.riskLevel === 'MEDIUM' && (v.riskScore ?? 0) >= 40) {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`주의 MEDIUM (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'info');
const levelMeta = HIGH_RISK_LEVEL_META[v.riskLevel as AlertLevel];
if (levelMeta) {
const score = v.riskScore ?? 0;
const include = v.riskLevel === 'MEDIUM' ? score >= 40 : true;
if (include) {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`${levelMeta.label} (score ${score})`);
severity = bumpSeverity(severity, levelMeta.severity);
}
}
if (cats.length === 0) return null;

파일 보기

@ -88,7 +88,7 @@ export function MonitoringDashboard() {
title={t('monitoring.title')}
description={t('monitoring.desc')}
/>
{/* iran 백엔드 + Prediction 시스템 상태 (실시간) */}
{/* 백엔드 + prediction 분석 엔진 시스템 상태 (실시간) */}
<SystemStatusPanel />
<div className="flex gap-2">

파일 보기

@ -29,7 +29,7 @@ interface AnalysisStatus {
*
* :
* 1. (kcg-ai-backend)
* 2. iran + Prediction ( )
* 2. (prediction) +
* 3. ( )
*/
export function SystemStatusPanel() {
@ -94,10 +94,10 @@ export function SystemStatusPanel() {
]}
/>
{/* iran 백엔드 */}
{/* 분석 엔진 */}
<ServiceCard
icon={<Wifi className="w-4 h-4" />}
title="iran 백엔드 (분석)"
title="AI 분석 엔진"
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
statusIntent={stats ? 'success' : 'critical'}
details={[

파일 보기

@ -15,7 +15,11 @@ import {
type PredictionEvent,
} from '@/services/event';
import { getAlertLevelHex } from '@shared/constants/alertLevels';
import {
getAlertLevelHex,
getAlertLevelMarkerOpacity,
getAlertLevelMarkerRadius,
} from '@shared/constants/alertLevels';
interface MapEvent {
id: string;
@ -113,7 +117,7 @@ export function LiveMapView() {
nationality: e.vesselMmsi?.startsWith('412') ? 'CN' : e.vesselMmsi?.startsWith('440') ? 'KR' : '미상',
time: e.occurredAt.includes(' ') ? e.occurredAt.split(' ')[1]?.slice(0, 5) ?? e.occurredAt : e.occurredAt,
vesselName: e.vesselName ?? '미상',
risk: e.aiConfidence ?? (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88),
risk: e.aiConfidence ?? getAlertLevelMarkerOpacity(e.level),
lat: e.lat!,
lng: e.lon!,
level: e.level,
@ -171,7 +175,7 @@ export function LiveMapView() {
lat: v.lat,
lng: v.lng,
color,
radius: level === 'CRITICAL' ? 900 : level === 'HIGH' ? 750 : 600,
radius: getAlertLevelMarkerRadius(level),
label: v.item.mmsi,
};
}),

파일 보기

@ -161,7 +161,7 @@
{
"id": "api.vessel_analysis",
"label": "GET /api/vessel-analysis",
"shortDescription": "선박 분석 결과 (iran 프록시)",
"shortDescription": "선박 분석 결과 (레거시 경로, 신규는 /api/analysis/*)",
"stage": "API",
"kind": "api",
"trigger": "on_demand",

파일 보기

@ -1,13 +1,13 @@
[
{
"id": "external.iran_backend",
"label": "Iran 백엔드 (레거시)",
"shortDescription": "어구 그룹 read-only 프록시",
"label": "Iran 백엔드 (레거시·미사용)",
"shortDescription": "prediction 직접 연동으로 대체됨",
"stage": "외부",
"kind": "external",
"trigger": "on_demand",
"status": "partial",
"notes": "어구 그룹 read-only proxy (선택적, 향후 자체 prediction으로 대체 예정)"
"status": "deprecated",
"notes": "prediction 이 kcgaidb 에 직접 write 하므로 더 이상 호출하지 않는다. 1~2 릴리즈 후 노드 삭제 예정."
},
{
"id": "external.redis",

파일 보기

@ -30,7 +30,7 @@ export type NodeKind =
| 'api' // 백엔드 API 엔드포인트
| 'ui' // 프론트 화면
| 'decision' // 운영자 의사결정 액션
| 'external'; // 외부 시스템 (iran, GPKI 등)
| 'external'; // 외부 시스템 (GPKI 등)
export type NodeTrigger =
| 'scheduled' // 5분 주기 등 자동

파일 보기

@ -1,7 +1,7 @@
/**
* useGearReplayLayers
*
* iran useReplayLayer.ts :
* ( ):
*
* 1. animationStore rAF set({ currentTime }) (gearReplayStore)
* 2. zustand.subscribe(currentTime) renderFrame()
@ -9,10 +9,10 @@
* - seek/정지: 즉시
* 3. renderFrame() overlay.setProps({ layers })
*
* (iran KCG ):
* - PathLayer: 중심 (gold) iran의 PathLayer에
* - TripsLayer: 멤버 fade trail iran과
* - IconLayer: 멤버 () iran의
* :
* - PathLayer: 중심 (gold)
* - TripsLayer: 멤버 fade trail
* - IconLayer: 멤버 ()
* - PolygonLayer: 현재 ( / )
* - TextLayer: MMSI
*
@ -55,7 +55,7 @@ const SLATE: [number, number, number, number] = [148, 163, 184, 120];
const POLYGON_FILL: [number, number, number, number] = [245, 158, 11, 30];
const POLYGON_STROKE: [number, number, number, number] = [245, 158, 11, 120];
const RENDER_INTERVAL_MS = 100; // iran과 동일: ~10fps 쓰로틀
const RENDER_INTERVAL_MS = 100; // ~10fps 쓰로틀
function memberIconColor(m: MemberPosition): [number, number, number, number] {
if (m.stale) return SLATE;
@ -71,7 +71,7 @@ export function useGearReplayLayers(
buildBaseLayers: () => Layer[],
) {
const frameCursorRef = useRef(0);
// iran의 positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
// positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
const memberCursorsRef = useRef(new Map<string, number>());
// buildBaseLayers를 최신 참조로 유지
@ -79,7 +79,7 @@ export function useGearReplayLayers(
baseLayersRef.current = buildBaseLayers;
/**
* renderFrame iran의 renderFrame과 :
* renderFrame + + overlay.setProps :
* 1. ()
* 2.
* 3. overlay.setProps({ layers })
@ -106,7 +106,7 @@ export function useGearReplayLayers(
);
frameCursorRef.current = newCursor;
// 멤버 보간 — iran의 getCurrentVesselPositions 패턴:
// 멤버 보간 — getCurrentVesselPositions 패턴:
// 프레임 기반이 아닌 멤버별 개별 타임라인에서 보간 → 빈 구간도 연속 보간
const relativeTime = currentTime - startTime;
const members = interpolateFromTripsData(
@ -121,7 +121,7 @@ export function useGearReplayLayers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const replayLayers: any[] = [];
// 1. TripsLayer — 멤버 궤적 fade trail (iran과 동일 패턴)
// 1. TripsLayer — 멤버 궤적 fade trail
// TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리
if (memberTripsData.length > 0) {
replayLayers.push(createTripsLayer(
@ -132,7 +132,7 @@ export function useGearReplayLayers(
));
}
// 4. 멤버 현재 위치 IconLayer (iran의 createVirtualShipLayers에 대응)
// 4. 멤버 현재 위치 IconLayer
const ships = members.filter(m => m.isParent);
const gears = members.filter(m => m.isGear);
const others = members.filter(m => !m.isParent && !m.isGear);
@ -330,13 +330,13 @@ export function useGearReplayLayers(
}
}
// iran 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
// 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
const baseLayers = baseLayersRef.current();
overlay.setProps({ layers: [...baseLayers, ...replayLayers] });
}, [overlayRef]);
/**
* currentTime iran useReplayLayer.ts:425~458
* currentTime + seek
*
* 핵심: 재생 pendingRafId로 rAF에
*
@ -354,12 +354,12 @@ export function useGearReplayLayers(
if (!useGearReplayStore.getState().groupKey) return;
const isPlaying = useGearReplayStore.getState().isPlaying;
// seek/정지: 즉시 렌더 (iran:437~439)
// seek/정지: 즉시 렌더
if (!isPlaying) {
renderFrame();
return;
}
// 재생 중: 쓰로틀 + pending rAF (iran:441~451)
// 재생 중: 쓰로틀 + pending rAF
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;

파일 보기

@ -1,7 +1,6 @@
/**
* API .
* - /리뷰: 자체 ( DB의 )
* - 향후: iran (HYBRID)
* - // + DB(gear_group_parent_resolution) .
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';

파일 보기

@ -1,6 +1,6 @@
/**
* iran API.
* - () iran + HYBRID .
* prediction API ( proxy ).
* @/services/analysisApi /api/analysis/* .
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';

파일 보기

@ -123,3 +123,54 @@ export function getAlertLevelHex(level: string): string {
export function getAlertLevelIntent(level: string): BadgeIntent {
return getAlertLevelMeta(level)?.intent ?? 'muted';
}
/** 타입 가드 — 외부 문자열이 유효한 AlertLevel 코드인지 확인 */
export function isValidAlertLevel(value: unknown): value is AlertLevel {
return typeof value === 'string' && value in ALERT_LEVELS;
}
/** CRITICAL / HIGH 필터용 (상위 위험만 잡는 KPI·경보 집계에서 사용) */
export function isHighSeverity(level: string): boolean {
return level === 'CRITICAL' || level === 'HIGH';
}
/** 정렬용 우선순위 (CRITICAL 이 가장 앞, UNKNOWN 은 마지막) */
export function getAlertLevelOrder(level: string): number {
return getAlertLevelMeta(level)?.order ?? 999;
}
/** 지도 마커 불투명도 (CRITICAL 이 가장 선명) */
export const ALERT_LEVEL_MARKER_OPACITY: Record<AlertLevel, number> = {
CRITICAL: 0.94,
HIGH: 0.91,
MEDIUM: 0.88,
LOW: 0.85,
};
export function getAlertLevelMarkerOpacity(level: string): number {
return ALERT_LEVEL_MARKER_OPACITY[level as AlertLevel] ?? 0.85;
}
/** 지도 마커 반경 (미터) — 위험도가 높을수록 크게 */
export const ALERT_LEVEL_MARKER_RADIUS: Record<AlertLevel, number> = {
CRITICAL: 900,
HIGH: 750,
MEDIUM: 600,
LOW: 500,
};
export function getAlertLevelMarkerRadius(level: string): number {
return ALERT_LEVEL_MARKER_RADIUS[level as AlertLevel] ?? 500;
}
/** Tier(다크베셀 등) 점수 매핑 — 시각화용 숫자 가중치 */
export const ALERT_LEVEL_TIER_SCORE: Record<AlertLevel, number> = {
CRITICAL: 90,
HIGH: 60,
MEDIUM: 30,
LOW: 10,
};
export function getAlertLevelTierScore(level: string): number {
return ALERT_LEVEL_TIER_SCORE[level as AlertLevel] ?? 0;
}

파일 보기

@ -44,6 +44,7 @@ import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurit
import { ZONE_CODES } from './zoneCodes';
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
import { VESSEL_TYPES } from './vesselTypes';
import { PERFORMANCE_STATUS_META } from './performanceStatus';
/**
* UI
@ -327,6 +328,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
source: 'prediction/algorithms/gear_violation.py',
items: GEAR_VIOLATION_CODES,
},
{
id: 'performance-status',
showcaseId: 'TRK-CAT-performance-status',
titleKo: '성능/시스템 상태',
titleEn: 'Performance / System Status',
description: 'good / normal / warning / critical / running / passed / failed / active / scheduled / archived',
source: 'admin 성능·데이터 보관·모델 검증 공통',
items: PERFORMANCE_STATUS_META,
},
];
/** ID로 특정 카탈로그 조회 */

파일 보기

@ -2,7 +2,6 @@
*
*
* API (history frames) deck.gl TripsLayer .
* iran KCG에 .
*/
// ── 타입 ──────────────────────────────────────────────────────────────
@ -317,7 +316,6 @@ export function buildMemberMetadata(
/**
* heading() .
* iran animationStore.ts의 calculateHeading과 .
*/
function calcHeading(p1: [number, number], p2: [number, number]): number {
const dx = p2[0] - p1[0];
@ -328,11 +326,11 @@ function calcHeading(p1: [number, number], p2: [number, number]): number {
}
/**
* iran의 getCurrentVesselPositions .
* .
*
* (frameA/frameB) (memberTripsData)
* 24 .
* (O(1)) + fallback (iran과 ).
* (O(1)) + fallback (seek ).
*
* @param memberTripsData (timestamps는 startTime )
* @param memberMeta (name, role, isParent)
@ -375,7 +373,7 @@ export function interpolateFromTripsData(
continue;
}
// 커서 기반 탐색 (iran positionCursors 패턴)
// 커서 기반 탐색 (positionCursors 패턴)
let cursor = cursors.get(mmsi) ?? 0;
if (
@ -406,7 +404,7 @@ export function interpolateFromTripsData(
const cog = idx1 > 0 ? calcHeading(path[idx1 - 1], path[idx1]) : 0;
positions.push({ ...base, lon: path[idx1][0], lat: path[idx1][1], cog });
} else {
// 선형 보간 (iran의 interpolatePosition과 동일)
// 선형 보간
const ratio = (relativeTimeMs - timestamps[idx1]) / (timestamps[idx2] - timestamps[idx1]);
const lon = path[idx1][0] + (path[idx2][0] - path[idx1][0]) * ratio;
const lat = path[idx1][1] + (path[idx2][1] - path[idx1][1]) * ratio;