refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 + 카탈로그 #68
12
CLAUDE.md
12
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 를 통해서만 연동
|
||||
|
||||
## 명령어
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ Phase 2에서 초기화 예정.
|
||||
## 책임
|
||||
- 자체 인증/권한/감사로그
|
||||
- 운영자 의사결정 (모선 확정/제외/학습)
|
||||
- iran 백엔드 분석 데이터 프록시
|
||||
- prediction 분석 결과 조회 API (`/api/analysis/*`)
|
||||
- 관리자 화면 API
|
||||
|
||||
상세 설계: `.claude/plans/vast-tinkering-knuth.md`
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
package gc.mda.kcg.admin;
|
||||
|
||||
import gc.mda.kcg.audit.AccessLogRepository;
|
||||
import gc.mda.kcg.audit.AuditLogRepository;
|
||||
import gc.mda.kcg.auth.LoginHistoryRepository;
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@ -28,127 +22,23 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class AdminStatsController {
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
private final AccessLogRepository accessLogRepository;
|
||||
private final LoginHistoryRepository loginHistoryRepository;
|
||||
private final JdbcTemplate jdbc;
|
||||
private final AdminStatsService adminStatsService;
|
||||
|
||||
/**
|
||||
* 감사 로그 통계.
|
||||
* - total: 전체 건수
|
||||
* - last24h: 24시간 내 건수
|
||||
* - failed24h: 24시간 내 FAILED 건수
|
||||
* - byAction: 액션별 카운트 (top 10)
|
||||
* - hourly24: 시간별 24시간 추세
|
||||
*/
|
||||
@GetMapping("/audit")
|
||||
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
|
||||
public Map<String, Object> auditStats() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", auditLogRepository.count());
|
||||
result.put("last24h", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
||||
result.put("failed24h", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
|
||||
|
||||
List<Map<String, Object>> byAction = jdbc.queryForList(
|
||||
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
|
||||
"WHERE created_at > now() - interval '7 days' " +
|
||||
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
|
||||
result.put("byAction", byAction);
|
||||
|
||||
List<Map<String, Object>> hourly = jdbc.queryForList(
|
||||
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
|
||||
"FROM kcg.auth_audit_log " +
|
||||
"WHERE created_at > now() - interval '24 hours' " +
|
||||
"GROUP BY hour ORDER BY hour");
|
||||
result.put("hourly24", hourly);
|
||||
|
||||
return result;
|
||||
return adminStatsService.auditStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 로그 통계.
|
||||
* - total: 전체 건수
|
||||
* - last24h: 24시간 내
|
||||
* - error4xx, error5xx: 24시간 내 에러
|
||||
* - avgDurationMs: 24시간 내 평균 응답 시간
|
||||
* - topPaths: 24시간 내 호출 많은 경로
|
||||
*/
|
||||
@GetMapping("/access")
|
||||
@RequirePermission(resource = "admin:access-logs", operation = "READ")
|
||||
public Map<String, Object> accessStats() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", accessLogRepository.count());
|
||||
result.put("last24h", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
||||
result.put("error4xx", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
|
||||
result.put("error5xx", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
|
||||
|
||||
Double avg = jdbc.queryForObject(
|
||||
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
|
||||
Double.class);
|
||||
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
|
||||
|
||||
List<Map<String, Object>> topPaths = jdbc.queryForList(
|
||||
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
|
||||
"FROM kcg.auth_access_log " +
|
||||
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
|
||||
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
|
||||
result.put("topPaths", topPaths);
|
||||
|
||||
return result;
|
||||
return adminStatsService.accessStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 통계.
|
||||
* - total: 전체 건수
|
||||
* - success24h: 24시간 내 성공
|
||||
* - failed24h: 24시간 내 실패
|
||||
* - locked24h: 24시간 내 잠금
|
||||
* - successRate: 성공률 (24시간 내, %)
|
||||
* - byUser: 사용자별 성공 카운트 (top 10)
|
||||
* - daily7d: 7일 일별 추세
|
||||
*/
|
||||
@GetMapping("/login")
|
||||
@RequirePermission(resource = "admin:login-history", operation = "READ")
|
||||
public Map<String, Object> loginStats() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", loginHistoryRepository.count());
|
||||
|
||||
Long success24h = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
|
||||
Long failed24h = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
|
||||
Long locked24h = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
|
||||
|
||||
result.put("success24h", success24h);
|
||||
result.put("failed24h", failed24h);
|
||||
result.put("locked24h", locked24h);
|
||||
|
||||
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
|
||||
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
|
||||
result.put("successRate", Math.round(rate * 10) / 10.0);
|
||||
|
||||
List<Map<String, Object>> byUser = jdbc.queryForList(
|
||||
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
|
||||
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
|
||||
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
|
||||
result.put("byUser", byUser);
|
||||
|
||||
List<Map<String, Object>> daily = jdbc.queryForList(
|
||||
"SELECT date_trunc('day', login_dtm) AS day, " +
|
||||
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
|
||||
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
|
||||
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
|
||||
"FROM kcg.auth_login_hist " +
|
||||
"WHERE login_dtm > now() - interval '7 days' " +
|
||||
"GROUP BY day ORDER BY day");
|
||||
result.put("daily7d", daily);
|
||||
|
||||
return result;
|
||||
return adminStatsService.loginStats();
|
||||
}
|
||||
}
|
||||
|
||||
124
backend/src/main/java/gc/mda/kcg/admin/AdminStatsService.java
Normal file
124
backend/src/main/java/gc/mda/kcg/admin/AdminStatsService.java
Normal file
@ -0,0 +1,124 @@
|
||||
package gc.mda.kcg.admin;
|
||||
|
||||
import gc.mda.kcg.audit.AccessLogRepository;
|
||||
import gc.mda.kcg.audit.AuditLogRepository;
|
||||
import gc.mda.kcg.auth.LoginHistoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 시스템 관리 대시보드 메트릭 서비스.
|
||||
* 감사 로그 / 접근 로그 / 로그인 이력의 집계 쿼리를 담당한다.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class AdminStatsService {
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
private final AccessLogRepository accessLogRepository;
|
||||
private final LoginHistoryRepository loginHistoryRepository;
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
/**
|
||||
* 감사 로그 통계.
|
||||
*/
|
||||
public Map<String, Object> auditStats() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", auditLogRepository.count());
|
||||
result.put("last24h", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
||||
result.put("failed24h", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
|
||||
|
||||
List<Map<String, Object>> byAction = jdbc.queryForList(
|
||||
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
|
||||
"WHERE created_at > now() - interval '7 days' " +
|
||||
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
|
||||
result.put("byAction", byAction);
|
||||
|
||||
List<Map<String, Object>> hourly = jdbc.queryForList(
|
||||
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
|
||||
"FROM kcg.auth_audit_log " +
|
||||
"WHERE created_at > now() - interval '24 hours' " +
|
||||
"GROUP BY hour ORDER BY hour");
|
||||
result.put("hourly24", hourly);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 로그 통계.
|
||||
*/
|
||||
public Map<String, Object> accessStats() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", accessLogRepository.count());
|
||||
result.put("last24h", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
||||
result.put("error4xx", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
|
||||
result.put("error5xx", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
|
||||
|
||||
Double avg = jdbc.queryForObject(
|
||||
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
|
||||
Double.class);
|
||||
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
|
||||
|
||||
List<Map<String, Object>> topPaths = jdbc.queryForList(
|
||||
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
|
||||
"FROM kcg.auth_access_log " +
|
||||
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
|
||||
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
|
||||
result.put("topPaths", topPaths);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 통계.
|
||||
*/
|
||||
public Map<String, Object> loginStats() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", loginHistoryRepository.count());
|
||||
|
||||
Long success24h = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
|
||||
Long failed24h = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
|
||||
Long locked24h = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
|
||||
|
||||
result.put("success24h", success24h);
|
||||
result.put("failed24h", failed24h);
|
||||
result.put("locked24h", locked24h);
|
||||
|
||||
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
|
||||
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
|
||||
result.put("successRate", Math.round(rate * 10) / 10.0);
|
||||
|
||||
List<Map<String, Object>> byUser = jdbc.queryForList(
|
||||
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
|
||||
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
|
||||
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
|
||||
result.put("byUser", byUser);
|
||||
|
||||
List<Map<String, Object>> daily = jdbc.queryForList(
|
||||
"SELECT date_trunc('day', login_dtm) AS day, " +
|
||||
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
|
||||
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
|
||||
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
|
||||
"FROM kcg.auth_login_hist " +
|
||||
"WHERE login_dtm > now() - interval '7 days' " +
|
||||
"GROUP BY day ORDER BY day");
|
||||
result.put("daily7d", daily);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,6 @@ public class AppProperties {
|
||||
|
||||
private Prediction prediction = new Prediction();
|
||||
private SignalBatch signalBatch = new SignalBatch();
|
||||
private IranBackend iranBackend = new IranBackend();
|
||||
private Cors cors = new Cors();
|
||||
private Jwt jwt = new Jwt();
|
||||
|
||||
@ -27,11 +26,6 @@ public class AppProperties {
|
||||
private String baseUrl;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class IranBackend {
|
||||
private String baseUrl;
|
||||
}
|
||||
|
||||
@Getter @Setter
|
||||
public static class Cors {
|
||||
private String allowedOrigins;
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package gc.mda.kcg.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* 외부 서비스용 RestClient Bean 정의.
|
||||
* Proxy controller 들이 @PostConstruct 에서 ad-hoc 생성하던 RestClient 를 일원화한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class RestClientConfig {
|
||||
|
||||
private final AppProperties appProperties;
|
||||
|
||||
/**
|
||||
* prediction FastAPI 서비스 호출용.
|
||||
* base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
|
||||
*/
|
||||
@Bean
|
||||
public RestClient predictionRestClient(RestClient.Builder builder) {
|
||||
String baseUrl = appProperties.getPrediction().getBaseUrl();
|
||||
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://localhost:8001";
|
||||
log.info("predictionRestClient initialized: baseUrl={}", resolved);
|
||||
return builder
|
||||
.baseUrl(resolved)
|
||||
.defaultHeader("Accept", "application/json")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* signal-batch 선박 항적 서비스 호출용.
|
||||
* base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
|
||||
*/
|
||||
@Bean
|
||||
public RestClient signalBatchRestClient(RestClient.Builder builder) {
|
||||
String baseUrl = appProperties.getSignalBatch().getBaseUrl();
|
||||
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://192.168.1.18:18090/signal-batch";
|
||||
log.info("signalBatchRestClient initialized: baseUrl={}", resolved);
|
||||
return builder
|
||||
.baseUrl(resolved)
|
||||
.defaultHeader("Accept", "application/json")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* iran 백엔드 REST 클라이언트.
|
||||
*
|
||||
* 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
|
||||
* 호출 실패 시 graceful degradation: null 반환 → 프론트에 빈 응답.
|
||||
*
|
||||
* 향후 prediction 이관 시 IranBackendClient를 PredictionDirectClient로 교체하면 됨.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class IranBackendClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final boolean enabled;
|
||||
|
||||
public IranBackendClient(AppProperties appProperties) {
|
||||
String baseUrl = appProperties.getIranBackend().getBaseUrl();
|
||||
this.enabled = baseUrl != null && !baseUrl.isBlank();
|
||||
this.restClient = enabled
|
||||
? RestClient.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.defaultHeader("Accept", "application/json")
|
||||
.build()
|
||||
: RestClient.create();
|
||||
log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl);
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 호출 (Map 반환). 실패 시 null 반환.
|
||||
*/
|
||||
public Map<String, Object> getJson(String path) {
|
||||
if (!enabled) return null;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> body = restClient.get().uri(path).retrieve().body(Map.class);
|
||||
return body;
|
||||
} catch (RestClientException e) {
|
||||
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임의 타입 GET 호출.
|
||||
*/
|
||||
public <T> T getAs(String path, Class<T> responseType) {
|
||||
if (!enabled) return null;
|
||||
try {
|
||||
return restClient.get().uri(path).retrieve().body(responseType);
|
||||
} catch (RestClientException e) {
|
||||
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,9 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.client.RestClient;
|
||||
@ -14,7 +13,7 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* Prediction FastAPI 서비스 프록시.
|
||||
* app.prediction.base-url (기본: http://localhost:8001, 운영: http://192.168.1.19:18092)
|
||||
* 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
|
||||
*
|
||||
* 엔드포인트:
|
||||
* GET /api/prediction/health → FastAPI /health
|
||||
@ -30,20 +29,8 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class PredictionProxyController {
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private RestClient predictionClient;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
String baseUrl = appProperties.getPrediction().getBaseUrl();
|
||||
predictionClient = restClientBuilder
|
||||
.baseUrl(baseUrl != null && !baseUrl.isBlank() ? baseUrl : "http://localhost:8001")
|
||||
.defaultHeader("Accept", "application/json")
|
||||
.build();
|
||||
log.info("PredictionProxyController initialized: baseUrl={}", baseUrl);
|
||||
}
|
||||
@Qualifier("predictionRestClient")
|
||||
private final RestClient predictionClient;
|
||||
|
||||
@GetMapping("/health")
|
||||
public ResponseEntity<?> health() {
|
||||
|
||||
@ -13,8 +13,7 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 직접 조회 API.
|
||||
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
|
||||
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
|
||||
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/analysis")
|
||||
|
||||
@ -2,6 +2,7 @@ package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import gc.mda.kcg.audit.annotation.Auditable;
|
||||
import gc.mda.kcg.domain.fleet.ParentResolution;
|
||||
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -290,6 +291,7 @@ public class VesselAnalysisGroupService {
|
||||
/**
|
||||
* 모선 확정/제외 처리.
|
||||
*/
|
||||
@Auditable(action = "PARENT_RESOLVE", resourceType = "GEAR_GROUP")
|
||||
public Map<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
|
||||
try {
|
||||
// 먼저 resolution 존재 확인
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.client.RestClient;
|
||||
@ -16,10 +15,6 @@ import java.util.Map;
|
||||
/**
|
||||
* 분석 데이터 API — group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
|
||||
* + signal-batch 선박 항적 프록시.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 분석 데이터 API — group_polygon_snapshots / gear_correlation_scores 직접 DB 조회.
|
||||
*
|
||||
* 라우팅:
|
||||
* GET /api/vessel-analysis/groups → 어구/선단 그룹 + parentResolution 합성
|
||||
@ -35,19 +30,9 @@ import java.util.Map;
|
||||
public class VesselAnalysisProxyController {
|
||||
|
||||
private final VesselAnalysisGroupService groupService;
|
||||
private final AppProperties appProperties;
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private RestClient signalBatchClient;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
String sbUrl = appProperties.getSignalBatch().getBaseUrl();
|
||||
signalBatchClient = restClientBuilder
|
||||
.baseUrl(sbUrl != null && !sbUrl.isBlank() ? sbUrl : "http://192.168.1.18:18090/signal-batch")
|
||||
.defaultHeader("Accept", "application/json")
|
||||
.build();
|
||||
}
|
||||
@Qualifier("signalBatchRestClient")
|
||||
private final RestClient signalBatchClient;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
@ -69,7 +54,7 @@ public class VesselAnalysisProxyController {
|
||||
@GetMapping("/groups")
|
||||
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||
public ResponseEntity<?> getGroups(
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String groupType
|
||||
@RequestParam(required = false) String groupType
|
||||
) {
|
||||
Map<String, Object> result = groupService.getGroups(groupType);
|
||||
return ResponseEntity.ok(result);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package gc.mda.kcg.domain.enforcement;
|
||||
|
||||
import gc.mda.kcg.audit.annotation.Auditable;
|
||||
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
|
||||
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
|
||||
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
|
||||
@ -48,6 +49,7 @@ public class EnforcementService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
|
||||
public EnforcementRecord createRecord(CreateRecordRequest req) {
|
||||
EnforcementRecord record = EnforcementRecord.builder()
|
||||
.enfUid(generateEnfUid())
|
||||
@ -87,6 +89,7 @@ public class EnforcementService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
|
||||
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
|
||||
EnforcementRecord record = getRecord(id);
|
||||
if (req.result() != null) record.setResult(req.result());
|
||||
@ -107,6 +110,7 @@ public class EnforcementService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
|
||||
public EnforcementPlan createPlan(CreatePlanRequest req) {
|
||||
EnforcementPlan plan = EnforcementPlan.builder()
|
||||
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())
|
||||
|
||||
@ -2,12 +2,9 @@ package gc.mda.kcg.domain.event;
|
||||
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 알림 조회 API.
|
||||
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 등) 이력을 제공.
|
||||
@ -17,7 +14,7 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class AlertController {
|
||||
|
||||
private final PredictionAlertRepository alertRepository;
|
||||
private final AlertService alertService;
|
||||
|
||||
/**
|
||||
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
|
||||
@ -30,10 +27,8 @@ public class AlertController {
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
if (eventId != null) {
|
||||
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
|
||||
return alertService.findByEventId(eventId);
|
||||
}
|
||||
return alertRepository.findAllByOrderBySentAtDesc(
|
||||
PageRequest.of(page, size)
|
||||
);
|
||||
return alertService.findAll(PageRequest.of(page, size));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
package gc.mda.kcg.domain.event;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 예측 알림 조회 서비스.
|
||||
* 이벤트에 대해 발송된 알림(SMS/푸시 등) 이력을 조회한다.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class AlertService {
|
||||
|
||||
private final PredictionAlertRepository alertRepository;
|
||||
|
||||
public List<PredictionAlert> findByEventId(Long eventId) {
|
||||
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
|
||||
}
|
||||
|
||||
public Page<PredictionAlert> findAll(Pageable pageable) {
|
||||
return alertRepository.findAllByOrderBySentAtDesc(pageable);
|
||||
}
|
||||
}
|
||||
@ -16,8 +16,8 @@ import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 모선 워크플로우 핵심 서비스 (HYBRID).
|
||||
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
|
||||
* 모선 워크플로우 핵심 서비스.
|
||||
* - 후보 데이터: prediction이 kcgaidb 에 저장한 분석 결과를 참조
|
||||
* - 운영자 결정: 자체 DB (gear_group_parent_resolution 등)
|
||||
*
|
||||
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.
|
||||
|
||||
@ -10,7 +10,7 @@ import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 모선 확정 결과 (운영자 의사결정).
|
||||
* iran 백엔드의 후보 데이터(prediction이 생성)와 별도로 운영자 결정만 자체 DB에 저장.
|
||||
* prediction이 생성한 후보 데이터와 별도로 운영자 결정만 자체 DB에 저장.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "gear_group_parent_resolution", schema = "kcg",
|
||||
|
||||
@ -4,9 +4,7 @@ import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -18,10 +16,7 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class MasterDataController {
|
||||
|
||||
private final CodeMasterRepository codeMasterRepository;
|
||||
private final GearTypeRepository gearTypeRepository;
|
||||
private final PatrolShipRepository patrolShipRepository;
|
||||
private final VesselPermitRepository vesselPermitRepository;
|
||||
private final MasterDataService masterDataService;
|
||||
|
||||
// ========================================================================
|
||||
// 코드 마스터 (인증만, 권한 불필요)
|
||||
@ -29,12 +24,12 @@ public class MasterDataController {
|
||||
|
||||
@GetMapping("/api/codes")
|
||||
public List<CodeMaster> listCodes(@RequestParam String group) {
|
||||
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group);
|
||||
return masterDataService.listCodes(group);
|
||||
}
|
||||
|
||||
@GetMapping("/api/codes/{codeId}/children")
|
||||
public List<CodeMaster> listChildren(@PathVariable String codeId) {
|
||||
return codeMasterRepository.findByParentIdOrderBySortOrder(codeId);
|
||||
return masterDataService.listChildren(codeId);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@ -43,35 +38,24 @@ public class MasterDataController {
|
||||
|
||||
@GetMapping("/api/gear-types")
|
||||
public List<GearType> listGearTypes() {
|
||||
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
|
||||
return masterDataService.listGearTypes();
|
||||
}
|
||||
|
||||
@GetMapping("/api/gear-types/{gearCode}")
|
||||
public GearType getGearType(@PathVariable String gearCode) {
|
||||
return gearTypeRepository.findById(gearCode)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"어구 유형을 찾을 수 없습니다: " + gearCode));
|
||||
return masterDataService.getGearType(gearCode);
|
||||
}
|
||||
|
||||
@PostMapping("/api/gear-types")
|
||||
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
|
||||
public GearType createGearType(@RequestBody GearType gearType) {
|
||||
if (gearTypeRepository.existsById(gearType.getGearCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
|
||||
}
|
||||
return gearTypeRepository.save(gearType);
|
||||
return masterDataService.createGearType(gearType);
|
||||
}
|
||||
|
||||
@PutMapping("/api/gear-types/{gearCode}")
|
||||
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
|
||||
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
|
||||
if (!gearTypeRepository.existsById(gearCode)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"어구 유형을 찾을 수 없습니다: " + gearCode);
|
||||
}
|
||||
gearType.setGearCode(gearCode);
|
||||
return gearTypeRepository.save(gearType);
|
||||
return masterDataService.updateGearType(gearCode, gearType);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@ -81,7 +65,7 @@ public class MasterDataController {
|
||||
@GetMapping("/api/patrol-ships")
|
||||
@RequirePermission(resource = "patrol:patrol-route", operation = "READ")
|
||||
public List<PatrolShip> listPatrolShips() {
|
||||
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
|
||||
return masterDataService.listPatrolShips();
|
||||
}
|
||||
|
||||
@PatchMapping("/api/patrol-ships/{id}/status")
|
||||
@ -90,47 +74,28 @@ public class MasterDataController {
|
||||
@PathVariable Long id,
|
||||
@RequestBody PatrolShipStatusRequest request
|
||||
) {
|
||||
PatrolShip ship = patrolShipRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"함정을 찾을 수 없습니다: " + id));
|
||||
|
||||
if (request.status() != null) ship.setCurrentStatus(request.status());
|
||||
if (request.lat() != null) ship.setCurrentLat(request.lat());
|
||||
if (request.lon() != null) ship.setCurrentLon(request.lon());
|
||||
if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode());
|
||||
if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct());
|
||||
|
||||
return patrolShipRepository.save(ship);
|
||||
return masterDataService.updatePatrolShipStatus(id, new MasterDataService.PatrolShipStatusCommand(
|
||||
request.status(), request.lat(), request.lon(), request.zoneCode(), request.fuelPct()
|
||||
));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 선박 허가 (vessel 권한)
|
||||
// 선박 허가 (인증만, 공통 마스터 데이터)
|
||||
// ========================================================================
|
||||
|
||||
@GetMapping("/api/vessel-permits")
|
||||
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
|
||||
public Page<VesselPermit> listVesselPermits(
|
||||
@RequestParam(required = false) String flag,
|
||||
@RequestParam(required = false) String permitStatus,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
PageRequest pageable = PageRequest.of(page, size);
|
||||
if (flag != null) {
|
||||
return vesselPermitRepository.findByFlagCountry(flag, pageable);
|
||||
}
|
||||
if (permitStatus != null) {
|
||||
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
|
||||
}
|
||||
return vesselPermitRepository.findAll(pageable);
|
||||
return masterDataService.listVesselPermits(flag, permitStatus, PageRequest.of(page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/api/vessel-permits/{mmsi}")
|
||||
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
|
||||
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
|
||||
return vesselPermitRepository.findByMmsi(mmsi)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
|
||||
return masterDataService.getVesselPermit(mmsi);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
||||
115
backend/src/main/java/gc/mda/kcg/master/MasterDataService.java
Normal file
115
backend/src/main/java/gc/mda/kcg/master/MasterDataService.java
Normal file
@ -0,0 +1,115 @@
|
||||
package gc.mda.kcg.master;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 마스터 데이터(코드/어구/함정/선박허가) 조회 및 관리 서비스.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class MasterDataService {
|
||||
|
||||
private final CodeMasterRepository codeMasterRepository;
|
||||
private final GearTypeRepository gearTypeRepository;
|
||||
private final PatrolShipRepository patrolShipRepository;
|
||||
private final VesselPermitRepository vesselPermitRepository;
|
||||
|
||||
// ── 코드 마스터 ─────────────────────────────────────────────────────────
|
||||
|
||||
public List<CodeMaster> listCodes(String groupCode) {
|
||||
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(groupCode);
|
||||
}
|
||||
|
||||
public List<CodeMaster> listChildren(String parentId) {
|
||||
return codeMasterRepository.findByParentIdOrderBySortOrder(parentId);
|
||||
}
|
||||
|
||||
// ── 어구 유형 ──────────────────────────────────────────────────────────
|
||||
|
||||
public List<GearType> listGearTypes() {
|
||||
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
|
||||
}
|
||||
|
||||
public GearType getGearType(String gearCode) {
|
||||
return gearTypeRepository.findById(gearCode)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"어구 유형을 찾을 수 없습니다: " + gearCode));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GearType createGearType(GearType gearType) {
|
||||
if (gearTypeRepository.existsById(gearType.getGearCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
|
||||
}
|
||||
return gearTypeRepository.save(gearType);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public GearType updateGearType(String gearCode, GearType gearType) {
|
||||
if (!gearTypeRepository.existsById(gearCode)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"어구 유형을 찾을 수 없습니다: " + gearCode);
|
||||
}
|
||||
gearType.setGearCode(gearCode);
|
||||
return gearTypeRepository.save(gearType);
|
||||
}
|
||||
|
||||
// ── 함정 ───────────────────────────────────────────────────────────────
|
||||
|
||||
public List<PatrolShip> listPatrolShips() {
|
||||
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PatrolShip updatePatrolShipStatus(Long id, PatrolShipStatusCommand command) {
|
||||
PatrolShip ship = patrolShipRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"함정을 찾을 수 없습니다: " + id));
|
||||
|
||||
if (command.status() != null) ship.setCurrentStatus(command.status());
|
||||
if (command.lat() != null) ship.setCurrentLat(command.lat());
|
||||
if (command.lon() != null) ship.setCurrentLon(command.lon());
|
||||
if (command.zoneCode() != null) ship.setCurrentZoneCode(command.zoneCode());
|
||||
if (command.fuelPct() != null) ship.setFuelPct(command.fuelPct());
|
||||
|
||||
return patrolShipRepository.save(ship);
|
||||
}
|
||||
|
||||
// ── 선박 허가 ─────────────────────────────────────────────────────────
|
||||
|
||||
public Page<VesselPermit> listVesselPermits(String flag, String permitStatus, Pageable pageable) {
|
||||
if (flag != null) {
|
||||
return vesselPermitRepository.findByFlagCountry(flag, pageable);
|
||||
}
|
||||
if (permitStatus != null) {
|
||||
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
|
||||
}
|
||||
return vesselPermitRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
public VesselPermit getVesselPermit(String mmsi) {
|
||||
return vesselPermitRepository.findByMmsi(mmsi)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
|
||||
}
|
||||
|
||||
// ── Command DTO ───────────────────────────────────────────────────────
|
||||
|
||||
public record PatrolShipStatusCommand(
|
||||
String status,
|
||||
Double lat,
|
||||
Double lon,
|
||||
String zoneCode,
|
||||
Integer fuelPct
|
||||
) {}
|
||||
}
|
||||
@ -66,9 +66,6 @@ app:
|
||||
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
|
||||
signal-batch:
|
||||
base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
|
||||
iran-backend:
|
||||
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
|
||||
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
|
||||
jwt:
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
| 서비스 | systemd | 포트 | 로그 |
|
||||
|---|---|---|---|
|
||||
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
|
||||
| kcg-prediction (기존 iran) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
|
||||
| kcg-prediction (레거시) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
|
||||
| kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` |
|
||||
|
||||
## 디렉토리 구조
|
||||
@ -166,7 +166,7 @@ PGPASSWORD='Kcg2026ai' psql -h 211.208.115.83 -U kcg-app -d kcgaidb
|
||||
| 443 | nginx (HTTPS) | rocky-211 |
|
||||
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
|
||||
| 18092 | kcg-ai-prediction (FastAPI) | redis-211 |
|
||||
| 8001 | kcg-prediction (기존 iran) | redis-211 |
|
||||
| 8001 | kcg-prediction (레거시) | redis-211 |
|
||||
| 18091 | kcg-prediction-lab | redis-211 |
|
||||
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
|
||||
| 6379 | Redis | redis-211 |
|
||||
@ -226,5 +226,5 @@ ssh redis-211 "systemctl restart kcg-ai-prediction"
|
||||
| `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 |
|
||||
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
|
||||
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
|
||||
| `/home/apps/kcg-prediction/` | 기존 iran prediction (포트 8001) |
|
||||
| `/home/apps/kcg-prediction/` | 레거시 prediction (포트 8001) |
|
||||
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |
|
||||
|
||||
@ -4,6 +4,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 변경
|
||||
- **iran 백엔드 프록시 잔재 제거** — `IranBackendClient` dead class 삭제, `application.yml` 의 `iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)` → `AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹(노드 ID 안정성 원칙 준수, 1~2 릴리즈 후 삭제 예정)
|
||||
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
|
||||
- **감사 로그 보강** — `EnforcementService` 의 createRecord / updateRecord / createPlan 에 `@Auditable` 추가 (ENFORCEMENT_CREATE/UPDATE/PLAN_CREATE). `VesselAnalysisGroupService.resolveParent` 에 `PARENT_RESOLVE` 액션 기록. 모든 쓰기 액션이 `auth_audit_log` 에 자동 수집
|
||||
- **alertLevels 카탈로그 확장** — 8개 화면의 `level === 'CRITICAL' ? ... : 'HIGH' ? ...` 식 직접 분기를 제거하기 위해 `isValidAlertLevel` (타입 가드) / `isHighSeverity` / `getAlertLevelOrder` / `ALERT_LEVEL_MARKER_OPACITY` / `ALERT_LEVEL_MARKER_RADIUS` / `ALERT_LEVEL_TIER_SCORE` 헬퍼·상수 신설. LiveMapView 마커 시각 매핑, DarkVesselDetection tier→점수, GearIdentification 타입 가드, vesselAnomaly 패널 severity 할당 헬퍼로 치환
|
||||
|
||||
### 추가
|
||||
- **performanceStatus 카탈로그 등록** — 이미 존재하던 `shared/constants/performanceStatus.ts` (good/normal/warning/critical/running/passed/failed/active/scheduled/archived 10종) 를 `catalogRegistry` 에 등록. design-system 쇼케이스 자동 노출 + admin 성능/보관/검증 페이지 SSOT 일원화
|
||||
|
||||
## [2026-04-16.7]
|
||||
|
||||
### 변경
|
||||
|
||||
@ -380,7 +380,7 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* iran 백엔드 실시간 분석 결과 */}
|
||||
{/* 중국 선박 실시간 분석 결과 */}
|
||||
<RealAllVessels />
|
||||
|
||||
{/* ── 상단 바: 기준일 + 검색 ── */}
|
||||
|
||||
@ -12,6 +12,7 @@ import type { MarkerData } from '@lib/map';
|
||||
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getRiskIntent } from '@shared/constants/statusIntent';
|
||||
import { getAlertLevelTierScore } from '@shared/constants/alertLevels';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { DarkDetailPanel } from './components/DarkDetailPanel';
|
||||
|
||||
@ -87,7 +88,7 @@ export function DarkVesselDetection() {
|
||||
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
|
||||
render: (v) => {
|
||||
const tier = v as string;
|
||||
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
|
||||
return <Badge intent={getRiskIntent(tier === 'WATCH' ? 40 : getAlertLevelTierScore(tier))} size="sm">{tier}</Badge>;
|
||||
} },
|
||||
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
|
||||
render: (v) => {
|
||||
|
||||
@ -320,7 +320,7 @@ export function GearDetection() {
|
||||
const mapRef = useRef<MapHandle>(null);
|
||||
|
||||
// overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조
|
||||
// iran 패턴: 리플레이 훅이 overlay.setProps() 직접 호출
|
||||
// 리플레이 훅이 overlay.setProps() 직접 호출 (단일 렌더링 경로)
|
||||
const overlayRef = useMemo<React.RefObject<MapboxOverlay | null>>(() => ({
|
||||
get current() { return mapRef.current?.overlay ?? null; },
|
||||
}), []);
|
||||
@ -424,7 +424,7 @@ export function GearDetection() {
|
||||
}, [DATA, selectedId, isReplayActive, replayGroupKey]);
|
||||
|
||||
// 리플레이 비활성 시만 useMapLayers가 overlay 제어
|
||||
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (iran 패턴: 단일 렌더링 경로)
|
||||
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (단일 렌더링 경로)
|
||||
useEffect(() => {
|
||||
if (isReplayActive) return; // replay hook이 overlay 독점
|
||||
const raf = requestAnimationFrame(() => {
|
||||
@ -456,7 +456,7 @@ export function GearDetection() {
|
||||
{!serviceAvailable && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>iran 분석 서비스 미연결 - 실시간 어구 데이터를 불러올 수 없습니다</span>
|
||||
<span>AI 분석 엔진 미연결 - 실시간 어구 데이터를 불러올 수 없습니다</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getAlertLevelIntent, isValidAlertLevel } from '@shared/constants/alertLevels';
|
||||
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
|
||||
@ -687,9 +687,7 @@ export function GearIdentification() {
|
||||
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
|
||||
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
|
||||
|
||||
const alertLevel = (v.riskLevel === 'CRITICAL' || v.riskLevel === 'HIGH' || v.riskLevel === 'MEDIUM' || v.riskLevel === 'LOW')
|
||||
? v.riskLevel
|
||||
: 'LOW';
|
||||
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
|
||||
|
||||
setResult({
|
||||
origin: 'china',
|
||||
|
||||
@ -9,9 +9,9 @@ import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* iran 백엔드의 실시간 어구/선단 그룹을 표시.
|
||||
* prediction 분석 엔진이 산출한 실시간 어구/선단 그룹을 표시.
|
||||
* - GET /api/vessel-analysis/groups
|
||||
* - 자체 DB의 ParentResolution이 합성되어 있음
|
||||
* - 자체 DB의 ParentResolution 운영자 결정이 합성되어 있음
|
||||
*/
|
||||
|
||||
export function RealGearGroups() {
|
||||
@ -54,7 +54,7 @@ export function RealGearGroups() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-orange-400" /> 실시간 어구/선단 그룹 (iran 백엔드)
|
||||
<MapPin className="w-4 h-4 text-orange-400" /> 실시간 어구/선단 그룹
|
||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
|
||||
@ -12,6 +12,14 @@
|
||||
* 좌표가 없어도 이상 신호가 있으면 패널에는 표시하고, 미니맵 포인트만 생략한다.
|
||||
*/
|
||||
import type { VesselAnalysis } from '@/services/analysisApi';
|
||||
import type { AlertLevel } from '@shared/constants/alertLevels';
|
||||
|
||||
/** riskLevel → 특이운항 패널의 severity/한글 레이블 매핑. */
|
||||
const HIGH_RISK_LEVEL_META: Partial<Record<AlertLevel, { severity: AnomalyPoint['severity']; label: string }>> = {
|
||||
CRITICAL: { severity: 'critical', label: '고위험 CRITICAL' },
|
||||
HIGH: { severity: 'warning', label: '위험 HIGH' },
|
||||
MEDIUM: { severity: 'info', label: '주의 MEDIUM' },
|
||||
};
|
||||
|
||||
export type AnomalyCategory =
|
||||
| 'DARK'
|
||||
@ -113,18 +121,15 @@ export function classifyAnomaly(v: VesselAnalysis): AnomalyPoint | null {
|
||||
descs.push(`어구 판정 ${v.gearJudgment}${v.gearCode ? ` (${v.gearCode})` : ''}`);
|
||||
severity = bumpSeverity(severity, 'warning');
|
||||
}
|
||||
if (v.riskLevel === 'CRITICAL') {
|
||||
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(`고위험 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');
|
||||
descs.push(`${levelMeta.label} (score ${score})`);
|
||||
severity = bumpSeverity(severity, levelMeta.severity);
|
||||
}
|
||||
}
|
||||
|
||||
if (cats.length === 0) return null;
|
||||
|
||||
@ -88,7 +88,7 @@ export function MonitoringDashboard() {
|
||||
title={t('monitoring.title')}
|
||||
description={t('monitoring.desc')}
|
||||
/>
|
||||
{/* iran 백엔드 + Prediction 시스템 상태 (실시간) */}
|
||||
{/* 백엔드 + prediction 분석 엔진 시스템 상태 (실시간) */}
|
||||
<SystemStatusPanel />
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
||||
@ -29,7 +29,7 @@ interface AnalysisStatus {
|
||||
*
|
||||
* 표시:
|
||||
* 1. 우리 백엔드 (kcg-ai-backend) 상태
|
||||
* 2. iran 백엔드 + Prediction (분석 사이클)
|
||||
* 2. 분석 엔진 (prediction) + 분석 사이클
|
||||
* 3. 분석 결과 통계 (현재 시점)
|
||||
*/
|
||||
export function SystemStatusPanel() {
|
||||
@ -94,10 +94,10 @@ export function SystemStatusPanel() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* iran 백엔드 */}
|
||||
{/* 분석 엔진 */}
|
||||
<ServiceCard
|
||||
icon={<Wifi className="w-4 h-4" />}
|
||||
title="iran 백엔드 (분석)"
|
||||
title="AI 분석 엔진"
|
||||
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
|
||||
statusIntent={stats ? 'success' : 'critical'}
|
||||
details={[
|
||||
|
||||
@ -15,7 +15,11 @@ import {
|
||||
type PredictionEvent,
|
||||
} from '@/services/event';
|
||||
|
||||
import { getAlertLevelHex } from '@shared/constants/alertLevels';
|
||||
import {
|
||||
getAlertLevelHex,
|
||||
getAlertLevelMarkerOpacity,
|
||||
getAlertLevelMarkerRadius,
|
||||
} from '@shared/constants/alertLevels';
|
||||
|
||||
interface MapEvent {
|
||||
id: string;
|
||||
@ -113,7 +117,7 @@ export function LiveMapView() {
|
||||
nationality: e.vesselMmsi?.startsWith('412') ? 'CN' : e.vesselMmsi?.startsWith('440') ? 'KR' : '미상',
|
||||
time: e.occurredAt.includes(' ') ? e.occurredAt.split(' ')[1]?.slice(0, 5) ?? e.occurredAt : e.occurredAt,
|
||||
vesselName: e.vesselName ?? '미상',
|
||||
risk: e.aiConfidence ?? (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88),
|
||||
risk: e.aiConfidence ?? getAlertLevelMarkerOpacity(e.level),
|
||||
lat: e.lat!,
|
||||
lng: e.lon!,
|
||||
level: e.level,
|
||||
@ -171,7 +175,7 @@ export function LiveMapView() {
|
||||
lat: v.lat,
|
||||
lng: v.lng,
|
||||
color,
|
||||
radius: level === 'CRITICAL' ? 900 : level === 'HIGH' ? 750 : 600,
|
||||
radius: getAlertLevelMarkerRadius(level),
|
||||
label: v.item.mmsi,
|
||||
};
|
||||
}),
|
||||
|
||||
@ -161,7 +161,7 @@
|
||||
{
|
||||
"id": "api.vessel_analysis",
|
||||
"label": "GET /api/vessel-analysis",
|
||||
"shortDescription": "선박 분석 결과 (iran 프록시)",
|
||||
"shortDescription": "선박 분석 결과 (레거시 경로, 신규는 /api/analysis/*)",
|
||||
"stage": "API",
|
||||
"kind": "api",
|
||||
"trigger": "on_demand",
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
[
|
||||
{
|
||||
"id": "external.iran_backend",
|
||||
"label": "Iran 백엔드 (레거시)",
|
||||
"shortDescription": "어구 그룹 read-only 프록시",
|
||||
"label": "Iran 백엔드 (레거시·미사용)",
|
||||
"shortDescription": "prediction 직접 연동으로 대체됨",
|
||||
"stage": "외부",
|
||||
"kind": "external",
|
||||
"trigger": "on_demand",
|
||||
"status": "partial",
|
||||
"notes": "어구 그룹 read-only proxy (선택적, 향후 자체 prediction으로 대체 예정)"
|
||||
"status": "deprecated",
|
||||
"notes": "prediction 이 kcgaidb 에 직접 write 하므로 더 이상 호출하지 않는다. 1~2 릴리즈 후 노드 삭제 예정."
|
||||
},
|
||||
{
|
||||
"id": "external.redis",
|
||||
|
||||
@ -30,7 +30,7 @@ export type NodeKind =
|
||||
| 'api' // 백엔드 API 엔드포인트
|
||||
| 'ui' // 프론트 화면
|
||||
| 'decision' // 운영자 의사결정 액션
|
||||
| 'external'; // 외부 시스템 (iran, GPKI 등)
|
||||
| 'external'; // 외부 시스템 (GPKI 등)
|
||||
|
||||
export type NodeTrigger =
|
||||
| 'scheduled' // 5분 주기 등 자동
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* useGearReplayLayers — 어구 궤적 리플레이 레이어 빌더 훅
|
||||
*
|
||||
* iran 프로젝트 useReplayLayer.ts 패턴 그대로 적용:
|
||||
* 검증된 리플레이 렌더링 패턴 (단일 렌더링 경로):
|
||||
*
|
||||
* 1. animationStore rAF 루프 → set({ currentTime }) 매 프레임 (gearReplayStore)
|
||||
* 2. zustand.subscribe(currentTime) → renderFrame()
|
||||
@ -9,10 +9,10 @@
|
||||
* - seek/정지: 즉시 렌더
|
||||
* 3. renderFrame() → 레이어 빌드 → overlay.setProps({ layers }) 직접 호출
|
||||
*
|
||||
* 레이어 구성 (iran 대비 KCG 적용):
|
||||
* - PathLayer: 중심 궤적 (gold) — iran의 정적 PathLayer에 대응
|
||||
* - TripsLayer: 멤버 궤적 fade trail — iran과 동일
|
||||
* - IconLayer: 멤버 현재 위치 (보간) — iran의 가상 선박 레이어에 대응
|
||||
* 레이어 구성:
|
||||
* - PathLayer: 중심 궤적 (gold)
|
||||
* - TripsLayer: 멤버 궤적 fade trail
|
||||
* - IconLayer: 멤버 현재 위치 (보간)
|
||||
* - PolygonLayer: 현재 폴리곤 (보간으로 확장/축소 애니메이션)
|
||||
* - TextLayer: MMSI 라벨
|
||||
*
|
||||
@ -55,7 +55,7 @@ const SLATE: [number, number, number, number] = [148, 163, 184, 120];
|
||||
const POLYGON_FILL: [number, number, number, number] = [245, 158, 11, 30];
|
||||
const POLYGON_STROKE: [number, number, number, number] = [245, 158, 11, 120];
|
||||
|
||||
const RENDER_INTERVAL_MS = 100; // iran과 동일: ~10fps 쓰로틀
|
||||
const RENDER_INTERVAL_MS = 100; // ~10fps 쓰로틀
|
||||
|
||||
function memberIconColor(m: MemberPosition): [number, number, number, number] {
|
||||
if (m.stale) return SLATE;
|
||||
@ -71,7 +71,7 @@ export function useGearReplayLayers(
|
||||
buildBaseLayers: () => Layer[],
|
||||
) {
|
||||
const frameCursorRef = useRef(0);
|
||||
// iran의 positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
|
||||
// positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
|
||||
const memberCursorsRef = useRef(new Map<string, number>());
|
||||
|
||||
// buildBaseLayers를 최신 참조로 유지
|
||||
@ -79,7 +79,7 @@ export function useGearReplayLayers(
|
||||
baseLayersRef.current = buildBaseLayers;
|
||||
|
||||
/**
|
||||
* renderFrame — iran의 renderFrame과 동일 구조:
|
||||
* renderFrame — 보간 + 레이어 빌드 + overlay.setProps 직접 호출:
|
||||
* 1. 현재 위치 계산 (보간)
|
||||
* 2. 레이어 빌드
|
||||
* 3. overlay.setProps({ layers }) 직접 호출
|
||||
@ -106,7 +106,7 @@ export function useGearReplayLayers(
|
||||
);
|
||||
frameCursorRef.current = newCursor;
|
||||
|
||||
// 멤버 보간 — iran의 getCurrentVesselPositions 패턴:
|
||||
// 멤버 보간 — getCurrentVesselPositions 패턴:
|
||||
// 프레임 기반이 아닌 멤버별 개별 타임라인에서 보간 → 빈 구간도 연속 보간
|
||||
const relativeTime = currentTime - startTime;
|
||||
const members = interpolateFromTripsData(
|
||||
@ -121,7 +121,7 @@ export function useGearReplayLayers(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const replayLayers: any[] = [];
|
||||
|
||||
// 1. TripsLayer — 멤버 궤적 fade trail (iran과 동일 패턴)
|
||||
// 1. TripsLayer — 멤버 궤적 fade trail
|
||||
// TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리
|
||||
if (memberTripsData.length > 0) {
|
||||
replayLayers.push(createTripsLayer(
|
||||
@ -132,7 +132,7 @@ export function useGearReplayLayers(
|
||||
));
|
||||
}
|
||||
|
||||
// 4. 멤버 현재 위치 IconLayer (iran의 createVirtualShipLayers에 대응)
|
||||
// 4. 멤버 현재 위치 IconLayer
|
||||
const ships = members.filter(m => m.isParent);
|
||||
const gears = members.filter(m => m.isGear);
|
||||
const others = members.filter(m => !m.isParent && !m.isGear);
|
||||
@ -330,13 +330,13 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
// iran 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
|
||||
// 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
|
||||
const baseLayers = baseLayersRef.current();
|
||||
overlay.setProps({ layers: [...baseLayers, ...replayLayers] });
|
||||
}, [overlayRef]);
|
||||
|
||||
/**
|
||||
* currentTime 구독 — iran useReplayLayer.ts:425~458 그대로 적용
|
||||
* currentTime 구독 — 재생 시 쓰로틀 + seek 시 즉시 렌더
|
||||
*
|
||||
* 핵심: 재생 중 쓰로틀에 걸려도 pendingRafId로 다음 rAF에 반드시 렌더 예약
|
||||
* → 프레임 드롭 없이 부드러운 애니메이션
|
||||
@ -354,12 +354,12 @@ export function useGearReplayLayers(
|
||||
if (!useGearReplayStore.getState().groupKey) return;
|
||||
|
||||
const isPlaying = useGearReplayStore.getState().isPlaying;
|
||||
// seek/정지: 즉시 렌더 (iran:437~439)
|
||||
// seek/정지: 즉시 렌더
|
||||
if (!isPlaying) {
|
||||
renderFrame();
|
||||
return;
|
||||
}
|
||||
// 재생 중: 쓰로틀 + pending rAF (iran:441~451)
|
||||
// 재생 중: 쓰로틀 + pending rAF
|
||||
const now = performance.now();
|
||||
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
|
||||
lastRenderTime = now;
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
/**
|
||||
* 모선 워크플로우 API 클라이언트.
|
||||
* - 후보/리뷰: 자체 백엔드 (자체 DB의 운영자 결정)
|
||||
* - 향후: iran 백엔드의 후보 데이터와 조합 (HYBRID)
|
||||
* - 후보/리뷰/운영자 결정 모두 자체 백엔드 + 자체 DB(gear_group_parent_resolution) 경유.
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* iran 백엔드의 분석 데이터 프록시 API.
|
||||
* - 백엔드(우리)가 iran 백엔드를 호출 + HYBRID 합성하여 응답.
|
||||
* prediction 분석 결과 조회 API (레거시 proxy 경로).
|
||||
* 새 화면은 @/services/analysisApi 의 /api/analysis/* 를 직접 사용한다.
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
@ -123,3 +123,54 @@ export function getAlertLevelHex(level: string): string {
|
||||
export function getAlertLevelIntent(level: string): BadgeIntent {
|
||||
return getAlertLevelMeta(level)?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
/** 타입 가드 — 외부 문자열이 유효한 AlertLevel 코드인지 확인 */
|
||||
export function isValidAlertLevel(value: unknown): value is AlertLevel {
|
||||
return typeof value === 'string' && value in ALERT_LEVELS;
|
||||
}
|
||||
|
||||
/** CRITICAL / HIGH 필터용 (상위 위험만 잡는 KPI·경보 집계에서 사용) */
|
||||
export function isHighSeverity(level: string): boolean {
|
||||
return level === 'CRITICAL' || level === 'HIGH';
|
||||
}
|
||||
|
||||
/** 정렬용 우선순위 (CRITICAL 이 가장 앞, UNKNOWN 은 마지막) */
|
||||
export function getAlertLevelOrder(level: string): number {
|
||||
return getAlertLevelMeta(level)?.order ?? 999;
|
||||
}
|
||||
|
||||
/** 지도 마커 불투명도 (CRITICAL 이 가장 선명) */
|
||||
export const ALERT_LEVEL_MARKER_OPACITY: Record<AlertLevel, number> = {
|
||||
CRITICAL: 0.94,
|
||||
HIGH: 0.91,
|
||||
MEDIUM: 0.88,
|
||||
LOW: 0.85,
|
||||
};
|
||||
|
||||
export function getAlertLevelMarkerOpacity(level: string): number {
|
||||
return ALERT_LEVEL_MARKER_OPACITY[level as AlertLevel] ?? 0.85;
|
||||
}
|
||||
|
||||
/** 지도 마커 반경 (미터) — 위험도가 높을수록 크게 */
|
||||
export const ALERT_LEVEL_MARKER_RADIUS: Record<AlertLevel, number> = {
|
||||
CRITICAL: 900,
|
||||
HIGH: 750,
|
||||
MEDIUM: 600,
|
||||
LOW: 500,
|
||||
};
|
||||
|
||||
export function getAlertLevelMarkerRadius(level: string): number {
|
||||
return ALERT_LEVEL_MARKER_RADIUS[level as AlertLevel] ?? 500;
|
||||
}
|
||||
|
||||
/** Tier(다크베셀 등) 점수 매핑 — 시각화용 숫자 가중치 */
|
||||
export const ALERT_LEVEL_TIER_SCORE: Record<AlertLevel, number> = {
|
||||
CRITICAL: 90,
|
||||
HIGH: 60,
|
||||
MEDIUM: 30,
|
||||
LOW: 10,
|
||||
};
|
||||
|
||||
export function getAlertLevelTierScore(level: string): number {
|
||||
return ALERT_LEVEL_TIER_SCORE[level as AlertLevel] ?? 0;
|
||||
}
|
||||
|
||||
@ -44,6 +44,7 @@ import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurit
|
||||
import { ZONE_CODES } from './zoneCodes';
|
||||
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
||||
import { VESSEL_TYPES } from './vesselTypes';
|
||||
import { PERFORMANCE_STATUS_META } from './performanceStatus';
|
||||
|
||||
/**
|
||||
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
||||
@ -327,6 +328,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
|
||||
source: 'prediction/algorithms/gear_violation.py',
|
||||
items: GEAR_VIOLATION_CODES,
|
||||
},
|
||||
{
|
||||
id: 'performance-status',
|
||||
showcaseId: 'TRK-CAT-performance-status',
|
||||
titleKo: '성능/시스템 상태',
|
||||
titleEn: 'Performance / System Status',
|
||||
description: 'good / normal / warning / critical / running / passed / failed / active / scheduled / archived',
|
||||
source: 'admin 성능·데이터 보관·모델 검증 공통',
|
||||
items: PERFORMANCE_STATUS_META,
|
||||
},
|
||||
];
|
||||
|
||||
/** ID로 특정 카탈로그 조회 */
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
* 어구 그룹 궤적 리플레이 전처리 모듈
|
||||
*
|
||||
* API 데이터(history frames) → deck.gl TripsLayer 포맷으로 변환.
|
||||
* iran 프로젝트의 검증된 패턴을 KCG에 맞게 재구현.
|
||||
*/
|
||||
|
||||
// ── 타입 ──────────────────────────────────────────────────────────────
|
||||
@ -317,7 +316,6 @@ export function buildMemberMetadata(
|
||||
|
||||
/**
|
||||
* 두 좌표 사이의 heading(침로) 계산.
|
||||
* iran animationStore.ts의 calculateHeading과 동일.
|
||||
*/
|
||||
function calcHeading(p1: [number, number], p2: [number, number]): number {
|
||||
const dx = p2[0] - p1[0];
|
||||
@ -328,11 +326,11 @@ function calcHeading(p1: [number, number], p2: [number, number]): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* iran의 getCurrentVesselPositions 패턴 — 멤버별 개별 타임라인에서 보간.
|
||||
* 멤버별 개별 타임라인에서 보간하여 현재 위치를 반환.
|
||||
*
|
||||
* 프레임 기반(frameA/frameB) 대신 멤버별 경로(memberTripsData)를 사용하여
|
||||
* 각 멤버가 24시간 내내 연속 경로로 유지.
|
||||
* 커서 기반 전진 스캔(O(1)) + 이진탐색 fallback (iran과 동일).
|
||||
* 커서 기반 전진 스캔(O(1)) + 이진탐색 fallback (seek 대비).
|
||||
*
|
||||
* @param memberTripsData — 전처리된 멤버별 경로 (timestamps는 startTime 기준 상대값)
|
||||
* @param memberMeta — 멤버 메타 정보 (name, role, isParent)
|
||||
@ -375,7 +373,7 @@ export function interpolateFromTripsData(
|
||||
continue;
|
||||
}
|
||||
|
||||
// 커서 기반 탐색 (iran positionCursors 패턴)
|
||||
// 커서 기반 탐색 (positionCursors 패턴)
|
||||
let cursor = cursors.get(mmsi) ?? 0;
|
||||
|
||||
if (
|
||||
@ -406,7 +404,7 @@ export function interpolateFromTripsData(
|
||||
const cog = idx1 > 0 ? calcHeading(path[idx1 - 1], path[idx1]) : 0;
|
||||
positions.push({ ...base, lon: path[idx1][0], lat: path[idx1][1], cog });
|
||||
} else {
|
||||
// 선형 보간 (iran의 interpolatePosition과 동일)
|
||||
// 선형 보간
|
||||
const ratio = (relativeTimeMs - timestamps[idx1]) / (timestamps[idx2] - timestamps[idx1]);
|
||||
const lon = path[idx1][0] + (path[idx2][0] - path[idx1][0]) * ratio;
|
||||
const lat = path[idx1][1] + (path[idx2][1] - path[idx1][1]) * ratio;
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user