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/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index bbfbed8..ada9e8f 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,19 @@ ## [Unreleased] +## [2026-04-17] + +### 변경 +- **디자인 시스템 SSOT 일괄 준수 (30파일)** — `frontend/design-system.html` 쇼케이스의 공통 컴포넌트와 `shared/constants/` 카탈로그를 우회하던 하드코딩 UI 를 전영역 치환. raw ` @@ -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..2028d31 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' })); } }; @@ -146,15 +146,23 @@ export function AccessControl() { { key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false, render: (_v, row) => (
- + +
), 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/DataModelVerification.tsx b/frontend/src/features/admin/DataModelVerification.tsx index a627667..7b13047 100644 --- a/frontend/src/features/admin/DataModelVerification.tsx +++ b/frontend/src/features/admin/DataModelVerification.tsx @@ -124,7 +124,7 @@ export function DataModelVerification() {
- + 검증 절차 (4단계)
@@ -168,14 +168,14 @@ export function DataModelVerification() { )}
- - {s.phase} + + {s.phase}
-
{s.responsible}
+
{s.responsible}
    {s.actions.map(a => (
  • - + {a}
  • ))} @@ -190,7 +190,7 @@ export function DataModelVerification() {
    - + 검증 참여자
    @@ -207,7 +207,7 @@ export function DataModelVerification() {
    - + 데이터 주제영역 ({SUBJECT_AREAS.reduce((s, a) => s + a.count, 0)}개 테이블)
    @@ -229,7 +229,7 @@ export function DataModelVerification() {
    - + 논리 데이터 모델 검증 기준 및 결과 {LOGICAL_CHECKS.filter(c => c.status === '통과').length}/{LOGICAL_CHECKS.length} 통과
    @@ -249,7 +249,7 @@ export function DataModelVerification() { {c.category} {c.item} {c.desc} - {c.result} + {c.result} {c.status} ))} @@ -279,7 +279,7 @@ export function DataModelVerification() { {tab === 'physical' && (
    - + 물리 데이터 모델 검증 기준 및 결과 {PHYSICAL_CHECKS.filter(c => c.status === '통과').length}/{PHYSICAL_CHECKS.length} 통과 {PHYSICAL_CHECKS.some(c => c.status === '주의') && ( @@ -302,7 +302,7 @@ export function DataModelVerification() { {c.category} {c.item} {c.desc} - {c.result} + {c.result} {c.status} ))} @@ -315,7 +315,7 @@ export function DataModelVerification() { {tab === 'duplication' && (
    - + 중복 테이블·컬럼 및 데이터 정합성 점검 {DUPLICATION_CHECKS.filter(c => c.status === '통과').length}/{DUPLICATION_CHECKS.length} 통과
    @@ -335,7 +335,7 @@ export function DataModelVerification() { {c.target} {c.desc} {c.scope} - {c.result} + {c.result} {c.status} ))} @@ -348,7 +348,7 @@ export function DataModelVerification() { {tab === 'history' && (
    - + 검증 결과 이력 {VERIFICATION_HISTORY.length}건
    @@ -372,7 +372,7 @@ export function DataModelVerification() { {h.phase} {h.reviewer} {h.target} - {h.issues > 0 ? {h.issues}건 : 0건} + {h.issues > 0 ? {h.issues}건 : 0건} {h.result} ))} diff --git a/frontend/src/features/admin/DataRetentionPolicy.tsx b/frontend/src/features/admin/DataRetentionPolicy.tsx index e0df961..e44dfdf 100644 --- a/frontend/src/features/admin/DataRetentionPolicy.tsx +++ b/frontend/src/features/admin/DataRetentionPolicy.tsx @@ -108,7 +108,7 @@ export function DataRetentionPolicy() {
    - + 전체 보관 구조 (4-Tier)
    {STORAGE_ARCHITECTURE.map(s => (
    - + {s.tier}

    {s.desc}

    @@ -184,7 +184,7 @@ export function DataRetentionPolicy() { ['CCTV 30일 보관 준수', '미채택 영상 30일 초과 1건', '주의'], ].map(([k, v, s]) => (
    - {s === '완료' || s === '정상' ? : } + {s === '완료' || s === '정상' ? : } {k} {v}
    @@ -213,7 +213,7 @@ export function DataRetentionPolicy() {
    - + 데이터 유형별 보관기간 기준표 {RETENTION_TABLE.length}종 관리
    @@ -235,7 +235,7 @@ export function DataRetentionPolicy() { {r.type} {r.category} {r.basis} - {r.period} + {r.period} {r.format} {r.volume} {r.status} @@ -273,7 +273,7 @@ export function DataRetentionPolicy() { {/* 파기 승인 워크플로우 */}
    - + 파기 승인 절차 (4단계)
    @@ -284,14 +284,14 @@ export function DataRetentionPolicy() { )}
    - - {s.phase} + + {s.phase}
    -
    {s.responsible}
    +
    {s.responsible}
      {s.actions.map(a => (
    • - + {a}
    • ))} @@ -305,7 +305,7 @@ export function DataRetentionPolicy() { {/* 파기 방식 */}
      - + 파기 방식 정의
      @@ -326,7 +326,7 @@ export function DataRetentionPolicy() { - + ))} @@ -341,7 +341,7 @@ export function DataRetentionPolicy() {
      - + 보존 연장 예외 현황 {EXCEPTIONS.filter(e => e.status === '연장 중').length}건 연장 중
      @@ -364,7 +364,7 @@ export function DataRetentionPolicy() {
      - + @@ -375,13 +375,13 @@ export function DataRetentionPolicy() {
      - + 보존 연장 사유 유형
      {EXCEPTION_RULES.map(r => (
      - +
      {r.rule}
      {r.desc}
      @@ -398,7 +398,7 @@ export function DataRetentionPolicy() { {tab === 'audit' && (
      - + 파기 감사 대장 {DISPOSAL_AUDIT_LOG.length}건
      @@ -424,7 +424,7 @@ export function DataRetentionPolicy() {
      - + 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() {
      {m.desc} {m.target} {m.encryption}{m.recovery}{m.recovery} {m.status}
      {e.dataType} {e.reason} {e.originalExpiry}{e.extendedTo}{e.extendedTo} {e.approver} {e.status}
      {d.target} {d.type} {d.method}{d.volume}{d.volume} {d.operator} {d.approver} {d.result}