From 9251d7593cb18e14102fa9fb72bf09d21dff3092 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 16 Apr 2026 16:18:18 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=BC=88=EB=8C=80=20=EC=A0=95=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20iran=20=EC=9E=94=EC=9E=AC=20=EC=A0=9C=EA=B1=B0=20+=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EA=B3=84=EC=B8=B5=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20+=20=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iran 백엔드 프록시 잔재 제거: - IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거 - Frontend UI 라벨/주석/system-flow manifest deprecated 마킹 - CLAUDE.md 시스템 구성 다이어그램 최신화 백엔드 계층 분리: - AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거 - AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true) - Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합 감사 로그 보강: - EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가 - VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록 카탈로그 정합성: - performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출) - alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder - LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출 - GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환 --- CLAUDE.md | 12 +- backend/README.md | 2 +- .../mda/kcg/admin/AdminStatsController.java | 118 +---------------- .../gc/mda/kcg/admin/AdminStatsService.java | 124 ++++++++++++++++++ .../java/gc/mda/kcg/config/AppProperties.java | 6 - .../gc/mda/kcg/config/RestClientConfig.java | 49 +++++++ .../domain/analysis/IranBackendClient.java | 70 ---------- .../analysis/PredictionProxyController.java | 21 +-- .../analysis/VesselAnalysisController.java | 3 +- .../analysis/VesselAnalysisGroupService.java | 2 + .../VesselAnalysisProxyController.java | 23 +--- .../enforcement/EnforcementService.java | 4 + .../mda/kcg/domain/event/AlertController.java | 11 +- .../gc/mda/kcg/domain/event/AlertService.java | 29 ++++ .../fleet/ParentInferenceWorkflowService.java | 4 +- .../kcg/domain/fleet/ParentResolution.java | 2 +- .../mda/kcg/master/MasterDataController.java | 63 ++------- .../gc/mda/kcg/master/MasterDataService.java | 115 ++++++++++++++++ backend/src/main/resources/application.yml | 3 - deploy/README.md | 6 +- .../src/features/detection/ChinaFishing.tsx | 2 +- .../detection/DarkVesselDetection.tsx | 3 +- .../src/features/detection/GearDetection.tsx | 6 +- .../features/detection/GearIdentification.tsx | 6 +- .../src/features/detection/RealGearGroups.tsx | 6 +- .../detection/components/vesselAnomaly.ts | 29 ++-- .../monitoring/MonitoringDashboard.tsx | 2 +- .../features/monitoring/SystemStatusPanel.tsx | 6 +- .../src/features/surveillance/LiveMapView.tsx | 10 +- frontend/src/flow/manifest/07-backend.json | 2 +- frontend/src/flow/manifest/10-external.json | 8 +- frontend/src/flow/manifest/index.ts | 2 +- frontend/src/hooks/useGearReplayLayers.ts | 30 ++--- frontend/src/services/parentInferenceApi.ts | 3 +- frontend/src/services/vesselAnalysisApi.ts | 4 +- frontend/src/shared/constants/alertLevels.ts | 51 +++++++ .../src/shared/constants/catalogRegistry.ts | 10 ++ frontend/src/stores/gearReplayPreprocess.ts | 10 +- 38 files changed, 494 insertions(+), 363 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/admin/AdminStatsService.java create mode 100644 backend/src/main/java/gc/mda/kcg/config/RestClientConfig.java delete mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/AlertService.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/MasterDataService.java diff --git a/CLAUDE.md b/CLAUDE.md index b119896..e07c332 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 를 통해서만 연동 ## 명령어 diff --git a/backend/README.md b/backend/README.md index f4428c9..a306471 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,7 +12,7 @@ Phase 2에서 초기화 예정. ## 책임 - 자체 인증/권한/감사로그 - 운영자 의사결정 (모선 확정/제외/학습) -- iran 백엔드 분석 데이터 프록시 +- prediction 분석 결과 조회 API (`/api/analysis/*`) - 관리자 화면 API 상세 설계: `.claude/plans/vast-tinkering-knuth.md` diff --git a/backend/src/main/java/gc/mda/kcg/admin/AdminStatsController.java b/backend/src/main/java/gc/mda/kcg/admin/AdminStatsController.java index c3b6f2a..87cef00 100644 --- a/backend/src/main/java/gc/mda/kcg/admin/AdminStatsController.java +++ b/backend/src/main/java/gc/mda/kcg/admin/AdminStatsController.java @@ -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 auditStats() { - Map 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> 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> 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 accessStats() { - Map 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> 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 loginStats() { - Map 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> 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> 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(); } } diff --git a/backend/src/main/java/gc/mda/kcg/admin/AdminStatsService.java b/backend/src/main/java/gc/mda/kcg/admin/AdminStatsService.java new file mode 100644 index 0000000..2fdebec --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/admin/AdminStatsService.java @@ -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 auditStats() { + Map 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> 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> 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 accessStats() { + Map 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> 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 loginStats() { + Map 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> 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> 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; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java index 8410d99..1f11f67 100644 --- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -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; diff --git a/backend/src/main/java/gc/mda/kcg/config/RestClientConfig.java b/backend/src/main/java/gc/mda/kcg/config/RestClientConfig.java new file mode 100644 index 0000000..dd3b053 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/RestClientConfig.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java deleted file mode 100644 index cc04c3e..0000000 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java +++ /dev/null @@ -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 getJson(String path) { - if (!enabled) return null; - try { - @SuppressWarnings("unchecked") - Map 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 getAs(String path, Class 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; - } - } -} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java index 34a97ff..2b5d8e3 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java @@ -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() { diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java index 71e245f..fe96926 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -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") diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisGroupService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisGroupService.java index f2a50f2..af1d55e 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisGroupService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisGroupService.java @@ -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 resolveParent(String groupKey, String action, String targetMmsi, String comment) { try { // 먼저 resolution 존재 확인 diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java index 4c00d34..a92023a 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java @@ -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 result = groupService.getGroups(groupType); return ResponseEntity.ok(result); diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java index cf40432..cd8cc85 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java @@ -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()) diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/AlertController.java b/backend/src/main/java/gc/mda/kcg/domain/event/AlertController.java index 98fc2ff..3d7e233 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/event/AlertController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/event/AlertController.java @@ -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)); } } diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/AlertService.java b/backend/src/main/java/gc/mda/kcg/domain/event/AlertService.java new file mode 100644 index 0000000..2a4bbb8 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/AlertService.java @@ -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 findByEventId(Long eventId) { + return alertRepository.findByEventIdOrderBySentAtDesc(eventId); + } + + public Page findAll(Pageable pageable) { + return alertRepository.findAllByOrderBySentAtDesc(pageable); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowService.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowService.java index 7d160f1..1ef620a 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowService.java @@ -16,8 +16,8 @@ import java.time.OffsetDateTime; import java.util.List; /** - * 모선 워크플로우 핵심 서비스 (HYBRID). - * - 후보 데이터: iran 백엔드 API 호출 (현재 stub) + * 모선 워크플로우 핵심 서비스. + * - 후보 데이터: prediction이 kcgaidb 에 저장한 분석 결과를 참조 * - 운영자 결정: 자체 DB (gear_group_parent_resolution 등) * * 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록. diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java index 5396b81..d1f4653 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java @@ -10,7 +10,7 @@ import java.util.UUID; /** * 모선 확정 결과 (운영자 의사결정). - * iran 백엔드의 후보 데이터(prediction이 생성)와 별도로 운영자 결정만 자체 DB에 저장. + * prediction이 생성한 후보 데이터와 별도로 운영자 결정만 자체 DB에 저장. */ @Entity @Table(name = "gear_group_parent_resolution", schema = "kcg", diff --git a/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java b/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java index 87279ff..197b9a0 100644 --- a/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java +++ b/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java @@ -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 listCodes(@RequestParam String group) { - return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group); + return masterDataService.listCodes(group); } @GetMapping("/api/codes/{codeId}/children") public List 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 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 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 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); } // ======================================================================== diff --git a/backend/src/main/java/gc/mda/kcg/master/MasterDataService.java b/backend/src/main/java/gc/mda/kcg/master/MasterDataService.java new file mode 100644 index 0000000..7fdb3d8 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/MasterDataService.java @@ -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 listCodes(String groupCode) { + return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(groupCode); + } + + public List listChildren(String parentId) { + return codeMasterRepository.findByParentIdOrderBySortOrder(parentId); + } + + // ── 어구 유형 ────────────────────────────────────────────────────────── + + public List 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 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 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 + ) {} +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ba553f7..8bbbfb8 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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: diff --git a/deploy/README.md b/deploy/README.md index 3464433..19dd165 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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) | diff --git a/frontend/src/features/detection/ChinaFishing.tsx b/frontend/src/features/detection/ChinaFishing.tsx index 4f0ee86..e5ed01b 100644 --- a/frontend/src/features/detection/ChinaFishing.tsx +++ b/frontend/src/features/detection/ChinaFishing.tsx @@ -380,7 +380,7 @@ export function ChinaFishing() { )} - {/* iran 백엔드 실시간 분석 결과 */} + {/* 중국 선박 실시간 분석 결과 */} {/* ── 상단 바: 기준일 + 검색 ── */} diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 83efa26..6d7c179 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -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 {tier}; + return {tier}; } }, { key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true, render: (v) => { diff --git a/frontend/src/features/detection/GearDetection.tsx b/frontend/src/features/detection/GearDetection.tsx index 7c3a32c..43c7483 100644 --- a/frontend/src/features/detection/GearDetection.tsx +++ b/frontend/src/features/detection/GearDetection.tsx @@ -320,7 +320,7 @@ export function GearDetection() { const mapRef = useRef(null); // overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조 - // iran 패턴: 리플레이 훅이 overlay.setProps() 직접 호출 + // 리플레이 훅이 overlay.setProps() 직접 호출 (단일 렌더링 경로) const overlayRef = useMemo>(() => ({ 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 && (
- iran 분석 서비스 미연결 - 실시간 어구 데이터를 불러올 수 없습니다 + AI 분석 엔진 미연결 - 실시간 어구 데이터를 불러올 수 없습니다
)} diff --git a/frontend/src/features/detection/GearIdentification.tsx b/frontend/src/features/detection/GearIdentification.tsx index 754b5b1..575a430 100644 --- a/frontend/src/features/detection/GearIdentification.tsx +++ b/frontend/src/features/detection/GearIdentification.tsx @@ -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', diff --git a/frontend/src/features/detection/RealGearGroups.tsx b/frontend/src/features/detection/RealGearGroups.tsx index b1fb42d..2e3113c 100644 --- a/frontend/src/features/detection/RealGearGroups.tsx +++ b/frontend/src/features/detection/RealGearGroups.tsx @@ -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() {
- 실시간 어구/선단 그룹 (iran 백엔드) + 실시간 어구/선단 그룹 {!available && 미연결}
diff --git a/frontend/src/features/detection/components/vesselAnomaly.ts b/frontend/src/features/detection/components/vesselAnomaly.ts index 26ffb09..625bf2a 100644 --- a/frontend/src/features/detection/components/vesselAnomaly.ts +++ b/frontend/src/features/detection/components/vesselAnomaly.ts @@ -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> = { + 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; diff --git a/frontend/src/features/monitoring/MonitoringDashboard.tsx b/frontend/src/features/monitoring/MonitoringDashboard.tsx index 9532769..440793d 100644 --- a/frontend/src/features/monitoring/MonitoringDashboard.tsx +++ b/frontend/src/features/monitoring/MonitoringDashboard.tsx @@ -88,7 +88,7 @@ export function MonitoringDashboard() { title={t('monitoring.title')} description={t('monitoring.desc')} /> - {/* iran 백엔드 + Prediction 시스템 상태 (실시간) */} + {/* 백엔드 + prediction 분석 엔진 시스템 상태 (실시간) */}
diff --git a/frontend/src/features/monitoring/SystemStatusPanel.tsx b/frontend/src/features/monitoring/SystemStatusPanel.tsx index 99bc29e..b90222e 100644 --- a/frontend/src/features/monitoring/SystemStatusPanel.tsx +++ b/frontend/src/features/monitoring/SystemStatusPanel.tsx @@ -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 백엔드 */} + {/* 분석 엔진 */} } - title="iran 백엔드 (분석)" + title="AI 분석 엔진" status={stats ? 'CONNECTED' : 'DISCONNECTED'} statusIntent={stats ? 'success' : 'critical'} details={[ diff --git a/frontend/src/features/surveillance/LiveMapView.tsx b/frontend/src/features/surveillance/LiveMapView.tsx index ba828af..2b8f162 100644 --- a/frontend/src/features/surveillance/LiveMapView.tsx +++ b/frontend/src/features/surveillance/LiveMapView.tsx @@ -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, }; }), diff --git a/frontend/src/flow/manifest/07-backend.json b/frontend/src/flow/manifest/07-backend.json index ee0cac2..e869626 100644 --- a/frontend/src/flow/manifest/07-backend.json +++ b/frontend/src/flow/manifest/07-backend.json @@ -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", diff --git a/frontend/src/flow/manifest/10-external.json b/frontend/src/flow/manifest/10-external.json index b9afa75..a9fdd30 100644 --- a/frontend/src/flow/manifest/10-external.json +++ b/frontend/src/flow/manifest/10-external.json @@ -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", diff --git a/frontend/src/flow/manifest/index.ts b/frontend/src/flow/manifest/index.ts index f69a547..7408255 100644 --- a/frontend/src/flow/manifest/index.ts +++ b/frontend/src/flow/manifest/index.ts @@ -30,7 +30,7 @@ export type NodeKind = | 'api' // 백엔드 API 엔드포인트 | 'ui' // 프론트 화면 | 'decision' // 운영자 의사결정 액션 - | 'external'; // 외부 시스템 (iran, GPKI 등) + | 'external'; // 외부 시스템 (GPKI 등) export type NodeTrigger = | 'scheduled' // 5분 주기 등 자동 diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index 0f14b6f..d28abf4 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -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()); // 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; diff --git a/frontend/src/services/parentInferenceApi.ts b/frontend/src/services/parentInferenceApi.ts index d5affdc..4880277 100644 --- a/frontend/src/services/parentInferenceApi.ts +++ b/frontend/src/services/parentInferenceApi.ts @@ -1,7 +1,6 @@ /** * 모선 워크플로우 API 클라이언트. - * - 후보/리뷰: 자체 백엔드 (자체 DB의 운영자 결정) - * - 향후: iran 백엔드의 후보 데이터와 조합 (HYBRID) + * - 후보/리뷰/운영자 결정 모두 자체 백엔드 + 자체 DB(gear_group_parent_resolution) 경유. */ const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; diff --git a/frontend/src/services/vesselAnalysisApi.ts b/frontend/src/services/vesselAnalysisApi.ts index 7092cfb..482b848 100644 --- a/frontend/src/services/vesselAnalysisApi.ts +++ b/frontend/src/services/vesselAnalysisApi.ts @@ -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'; diff --git a/frontend/src/shared/constants/alertLevels.ts b/frontend/src/shared/constants/alertLevels.ts index c80a8e4..cdbaf30 100644 --- a/frontend/src/shared/constants/alertLevels.ts +++ b/frontend/src/shared/constants/alertLevels.ts @@ -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 = { + 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 = { + 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 = { + CRITICAL: 90, + HIGH: 60, + MEDIUM: 30, + LOW: 10, +}; + +export function getAlertLevelTierScore(level: string): number { + return ALERT_LEVEL_TIER_SCORE[level as AlertLevel] ?? 0; +} diff --git a/frontend/src/shared/constants/catalogRegistry.ts b/frontend/src/shared/constants/catalogRegistry.ts index 2fa9e7b..df1fc7b 100644 --- a/frontend/src/shared/constants/catalogRegistry.ts +++ b/frontend/src/shared/constants/catalogRegistry.ts @@ -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로 특정 카탈로그 조회 */ diff --git a/frontend/src/stores/gearReplayPreprocess.ts b/frontend/src/stores/gearReplayPreprocess.ts index 3979f69..1e84e99 100644 --- a/frontend/src/stores/gearReplayPreprocess.ts +++ b/frontend/src/stores/gearReplayPreprocess.ts @@ -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; -- 2.45.2 From bb409588588c42412b58e2f5d3fbd8766e1c3f9b Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 16 Apr 2026 16:19:02 +0900 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(PR?= =?UTF-8?q?=20#A=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=B9=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index bbfbed8..66ae927 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -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] ### 변경 -- 2.45.2 From 8af693a2dfebaed21723091551408f72e7db882e Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 16 Apr 2026 16:32:37 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor(i18n):=20alert/confirm/aria-label?= =?UTF-8?q?=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=ED=95=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공통 번역 리소스 확장: - common.json 에 aria / error / dialog / success / message 네임스페이스 추가 - ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키) alert/confirm 11건 → t() 치환: - parent-inference: ParentReview / LabelSession / ParentExclusion - admin: PermissionsPanel / UserRoleAssignDialog / AccessControl aria-label 한글 40+건 → t() 치환: - parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등) - admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일) - detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기) - enforcement (확인/선박 상세/단속 등록/오탐 처리) - vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사) - 공통 컴포넌트 (SearchInput, NotificationBanner) MainLayout 언어 토글: - title 삼항분기 → t('message.switchToEnglish'/'switchToKorean') - aria-label="페이지 내 검색" → t('aria.searchInPage') - 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가 --- frontend/src/app/layout/MainLayout.tsx | 5 +- frontend/src/features/admin/AccessControl.tsx | 4 +- frontend/src/features/admin/DataHub.tsx | 3 +- .../src/features/admin/NoticeManagement.tsx | 11 +-- .../src/features/admin/PermissionsPanel.tsx | 22 +++--- frontend/src/features/admin/SystemConfig.tsx | 5 +- .../features/admin/UserRoleAssignDialog.tsx | 6 +- .../features/ai-operations/AIAssistant.tsx | 3 +- .../ai-operations/AIModelManagement.tsx | 2 +- .../src/features/ai-operations/MLOpsPage.tsx | 3 +- .../src/features/detection/ChinaFishing.tsx | 8 +- .../src/features/detection/GearDetection.tsx | 10 +-- .../src/features/detection/RealGearGroups.tsx | 2 +- .../features/detection/RealVesselAnalysis.tsx | 2 +- .../detection/components/DarkDetailPanel.tsx | 4 +- .../detection/components/GearDetailPanel.tsx | 3 +- .../components/GearReplayController.tsx | 6 +- .../detection/components/VesselMiniMap.tsx | 4 +- .../src/features/enforcement/EventList.tsx | 8 +- .../parent-inference/LabelSession.tsx | 16 ++-- .../parent-inference/ParentExclusion.tsx | 24 +++--- .../parent-inference/ParentReview.tsx | 10 +-- .../features/statistics/ReportManagement.tsx | 3 +- frontend/src/features/vessel/VesselDetail.tsx | 4 +- frontend/src/lib/i18n/locales/en/common.json | 79 +++++++++++++++++++ frontend/src/lib/i18n/locales/ko/common.json | 79 +++++++++++++++++++ .../components/common/NotificationBanner.tsx | 4 +- .../shared/components/common/SearchInput.tsx | 11 ++- 28 files changed, 262 insertions(+), 79 deletions(-) diff --git a/frontend/src/app/layout/MainLayout.tsx b/frontend/src/app/layout/MainLayout.tsx index 115a90e..2ce6dcb 100644 --- a/frontend/src/app/layout/MainLayout.tsx +++ b/frontend/src/app/layout/MainLayout.tsx @@ -282,8 +282,9 @@ export function MainLayout() { {/* 언어 토글 */} @@ -338,7 +339,7 @@ export function MainLayout() {
setPageSearch(e.target.value)} onKeyDown={(e) => { diff --git a/frontend/src/features/admin/AccessControl.tsx b/frontend/src/features/admin/AccessControl.tsx index a8308dc..c9fd72e 100644 --- a/frontend/src/features/admin/AccessControl.tsx +++ b/frontend/src/features/admin/AccessControl.tsx @@ -94,12 +94,12 @@ export function AccessControl() { }, [tab, loadUsers, loadAudit]); const handleUnlock = async (userId: string, acnt: string) => { - if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return; + if (!confirm(`${acnt} ${tc('dialog.genericRemove')}`)) return; try { await unlockUser(userId); await loadUsers(); } catch (e: unknown) { - alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); + alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' })); } }; diff --git a/frontend/src/features/admin/DataHub.tsx b/frontend/src/features/admin/DataHub.tsx index 38a57a4..5b3ba0b 100644 --- a/frontend/src/features/admin/DataHub.tsx +++ b/frontend/src/features/admin/DataHub.tsx @@ -341,6 +341,7 @@ type Tab = 'signal' | 'monitor' | 'collect' | 'load' | 'agents'; export function DataHub() { const { t } = useTranslation('admin'); + const { t: tc } = useTranslation('common'); const [tab, setTab] = useState('signal'); const [selectedDate, setSelectedDate] = useState('2026-04-02'); const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>(''); @@ -442,7 +443,7 @@ export function DataHub() {
setSelectedDate(e.target.value)} diff --git a/frontend/src/features/admin/NoticeManagement.tsx b/frontend/src/features/admin/NoticeManagement.tsx index 8d1a5b0..3017241 100644 --- a/frontend/src/features/admin/NoticeManagement.tsx +++ b/frontend/src/features/admin/NoticeManagement.tsx @@ -74,6 +74,7 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER']; export function NoticeManagement() { const { t } = useTranslation('admin'); + const { t: tc } = useTranslation('common'); const { hasPermission } = useAuth(); const canCreate = hasPermission('admin:notices', 'CREATE'); const canUpdate = hasPermission('admin:notices', 'UPDATE'); @@ -265,7 +266,7 @@ export function NoticeManagement() { {editingId ? '알림 수정' : '새 알림 등록'} -
@@ -275,7 +276,7 @@ export function NoticeManagement() {
setForm({ ...form, title: e.target.value })} className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50" @@ -287,7 +288,7 @@ export function NoticeManagement() {