feat(backend): 워크플로우 연결 Step 1 — 백엔드 기반 확장

- V018 마이그레이션: prediction_events.features JSONB 컬럼 추가
- VesselAnalysis 직접 조회 API 5개 신설 (/api/analysis/*)
  - vessels 목록 (필터: mmsi, zone, riskLevel, isDark)
  - vessels/{mmsi} 최신 분석 (features 포함)
  - vessels/{mmsi}/history 분석 이력
  - dark 베셀 목록 (MMSI 중복 제거)
  - transship 의심 목록
- PredictionEvent entity에 features JSONB 필드 추가
- EnforcementController vesselMmsi 필터 파라미터 추가
- event_generator.py INSERT에 features 컬럼 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-09 10:43:53 +09:00
부모 13ea475bd7
커밋 5c804aa38f
11개의 변경된 파일491개의 추가작업 그리고 3개의 파일을 삭제

파일 보기

@ -0,0 +1,99 @@
package gc.mda.kcg.domain.analysis;
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.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.time.OffsetDateTime;
import java.util.List;
/**
* vessel_analysis_results 직접 조회 API.
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
*/
@RestController
@RequestMapping("/api/analysis")
@RequiredArgsConstructor
public class VesselAnalysisController {
private final VesselAnalysisService service;
/**
* 분석 결과 목록 조회 (필터 + 페이징).
* 기본: 최근 1시간 결과.
*/
@GetMapping("/vessels")
@RequirePermission(resource = "detection", operation = "READ")
public Page<VesselAnalysisResponse> listVessels(
@RequestParam(required = false) String mmsi,
@RequestParam(required = false) String zoneCode,
@RequestParam(required = false) String riskLevel,
@RequestParam(required = false) Boolean isDark,
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return service.getAnalysisResults(
mmsi, zoneCode, riskLevel, isDark, after,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(VesselAnalysisResponse::from);
}
/**
* 특정 선박 최신 분석 결과 (features 포함).
*/
@GetMapping("/vessels/{mmsi}")
@RequirePermission(resource = "detection", operation = "READ")
public VesselAnalysisResponse getLatest(@PathVariable String mmsi) {
return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi));
}
/**
* 특정 선박 분석 이력 (기본 24시간).
*/
@GetMapping("/vessels/{mmsi}/history")
@RequirePermission(resource = "detection", operation = "READ")
public List<VesselAnalysisResponse> getHistory(
@PathVariable String mmsi,
@RequestParam(defaultValue = "24") int hours
) {
return service.getHistory(mmsi, hours).stream()
.map(VesselAnalysisResponse::from)
.toList();
}
/**
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
*/
@GetMapping("/dark")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listDarkVessels(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getDarkVessels(hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
).map(VesselAnalysisResponse::from);
}
/**
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
*/
@GetMapping("/transship")
@RequirePermission(resource = "detection", operation = "READ")
public Page<VesselAnalysisResponse> listTransshipSuspects(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getTransshipSuspects(hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
).map(VesselAnalysisResponse::from);
}
}

파일 보기

@ -0,0 +1,60 @@
package gc.mda.kcg.domain.analysis;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
/**
* vessel_analysis_results 읽기 전용 Repository.
*/
public interface VesselAnalysisRepository
extends JpaRepository<VesselAnalysisResult, Long>, JpaSpecificationExecutor<VesselAnalysisResult> {
/**
* 특정 선박의 최신 분석 결과.
*/
Optional<VesselAnalysisResult> findTopByMmsiOrderByAnalyzedAtDesc(String mmsi);
/**
* 특정 선박의 분석 이력 (시간 범위).
*/
List<VesselAnalysisResult> findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(
String mmsi, OffsetDateTime after);
/**
* 다크 베셀 목록 (최근 분석 결과, MMSI 중복 제거).
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.isDark = true AND v.analyzedAt > :after
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.riskScore DESC
""")
Page<VesselAnalysisResult> findLatestDarkVessels(
@Param("after") OffsetDateTime after, Pageable pageable);
/**
* 환적 의심 목록 (최근 분석 결과, MMSI 중복 제거).
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.transshipSuspect = true AND v.analyzedAt > :after
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.riskScore DESC
""")
Page<VesselAnalysisResult> findLatestTransshipSuspects(
@Param("after") OffsetDateTime after, Pageable pageable);
}

파일 보기

@ -0,0 +1,84 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Map;
/**
* vessel_analysis_results 응답 DTO.
* 프론트엔드에서 필요한 핵심 필드만 포함.
*/
public record VesselAnalysisResponse(
Long id,
String mmsi,
OffsetDateTime analyzedAt,
// 분류
String vesselType,
BigDecimal confidence,
BigDecimal fishingPct,
String season,
// 위치
Double lat,
Double lon,
String zoneCode,
BigDecimal distToBaselineNm,
// 행동
String activityState,
// 위협
Boolean isDark,
Integer gapDurationMin,
String darkPattern,
BigDecimal spoofingScore,
Integer speedJumpCount,
// 환적
Boolean transshipSuspect,
String transshipPairMmsi,
Integer transshipDurationMin,
// 선단
Integer fleetClusterId,
String fleetRole,
Boolean fleetIsLeader,
// 위험도
Integer riskScore,
String riskLevel,
// 확장
String gearCode,
String gearJudgment,
String permitStatus,
// features
Map<String, Object> features
) {
public static VesselAnalysisResponse from(VesselAnalysisResult e) {
return new VesselAnalysisResponse(
e.getId(),
e.getMmsi(),
e.getAnalyzedAt(),
e.getVesselType(),
e.getConfidence(),
e.getFishingPct(),
e.getSeason(),
e.getLat(),
e.getLon(),
e.getZoneCode(),
e.getDistToBaselineNm(),
e.getActivityState(),
e.getIsDark(),
e.getGapDurationMin(),
e.getDarkPattern(),
e.getSpoofingScore(),
e.getSpeedJumpCount(),
e.getTransshipSuspect(),
e.getTransshipPairMmsi(),
e.getTransshipDurationMin(),
e.getFleetClusterId(),
e.getFleetRole(),
e.getFleetIsLeader(),
e.getRiskScore(),
e.getRiskLevel(),
e.getGearCode(),
e.getGearJudgment(),
e.getPermitStatus(),
e.getFeatures()
);
}
}

파일 보기

@ -0,0 +1,135 @@
package gc.mda.kcg.domain.analysis;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Map;
/**
* vessel_analysis_results 읽기 전용 Entity.
* prediction 엔진이 5분 주기로 INSERT, 백엔드는 READ만 수행.
*
* DB PK는 (id, analyzed_at) 복합키(파티션)이지만,
* BIGSERIAL id가 전역 유니크이므로 JPA에서는 id만 @Id로 매핑.
*/
@Entity
@Table(name = "vessel_analysis_results", schema = "kcg")
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class VesselAnalysisResult {
@Id
private Long id;
@Column(name = "mmsi", nullable = false, length = 20)
private String mmsi;
@Column(name = "analyzed_at", nullable = false)
private OffsetDateTime analyzedAt;
// 분류
@Column(name = "vessel_type", length = 30)
private String vesselType;
@Column(name = "confidence", precision = 5, scale = 4)
private BigDecimal confidence;
@Column(name = "fishing_pct", precision = 5, scale = 4)
private BigDecimal fishingPct;
@Column(name = "cluster_id")
private Integer clusterId;
@Column(name = "season", length = 20)
private String season;
// 위치
@Column(name = "lat")
private Double lat;
@Column(name = "lon")
private Double lon;
@Column(name = "zone_code", length = 30)
private String zoneCode;
@Column(name = "dist_to_baseline_nm", precision = 8, scale = 2)
private BigDecimal distToBaselineNm;
// 행동 분석
@Column(name = "activity_state", length = 20)
private String activityState;
@Column(name = "ucaf_score", precision = 5, scale = 4)
private BigDecimal ucafScore;
@Column(name = "ucft_score", precision = 5, scale = 4)
private BigDecimal ucftScore;
// 위협 탐지
@Column(name = "is_dark")
private Boolean isDark;
@Column(name = "gap_duration_min")
private Integer gapDurationMin;
@Column(name = "dark_pattern", length = 30)
private String darkPattern;
@Column(name = "spoofing_score", precision = 5, scale = 4)
private BigDecimal spoofingScore;
@Column(name = "bd09_offset_m", precision = 8, scale = 2)
private BigDecimal bd09OffsetM;
@Column(name = "speed_jump_count")
private Integer speedJumpCount;
// 환적
@Column(name = "transship_suspect")
private Boolean transshipSuspect;
@Column(name = "transship_pair_mmsi", length = 20)
private String transshipPairMmsi;
@Column(name = "transship_duration_min")
private Integer transshipDurationMin;
// 선단
@Column(name = "fleet_cluster_id")
private Integer fleetClusterId;
@Column(name = "fleet_role", length = 20)
private String fleetRole;
@Column(name = "fleet_is_leader")
private Boolean fleetIsLeader;
// 위험도
@Column(name = "risk_score")
private Integer riskScore;
@Column(name = "risk_level", length = 20)
private String riskLevel;
// 확장
@Column(name = "gear_code", length = 20)
private String gearCode;
@Column(name = "gear_judgment", length = 30)
private String gearJudgment;
@Column(name = "permit_status", length = 20)
private String permitStatus;
// features JSONB
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb")
private Map<String, Object> features;
@Column(name = "created_at")
private OffsetDateTime createdAt;
}

파일 보기

@ -0,0 +1,83 @@
package gc.mda.kcg.domain.analysis;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
/**
* vessel_analysis_results 직접 조회 서비스.
* prediction이 write한 분석 결과를 프론트엔드에 제공.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VesselAnalysisService {
private final VesselAnalysisRepository repository;
/**
* 분석 결과 목록 조회 (동적 필터).
*/
public Page<VesselAnalysisResult> getAnalysisResults(
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
OffsetDateTime after, Pageable pageable
) {
Specification<VesselAnalysisResult> spec = Specification.where(null);
if (after != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
}
if (mmsi != null && !mmsi.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
}
if (zoneCode != null && !zoneCode.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
}
if (riskLevel != null && !riskLevel.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("riskLevel"), riskLevel));
}
if (isDark != null && isDark) {
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
}
return repository.findAll(spec, pageable);
}
/**
* 특정 선박 최신 분석 결과.
*/
public VesselAnalysisResult getLatestByMmsi(String mmsi) {
return repository.findTopByMmsiOrderByAnalyzedAtDesc(mmsi)
.orElseThrow(() -> new IllegalArgumentException("ANALYSIS_NOT_FOUND: " + mmsi));
}
/**
* 특정 선박 분석 이력 (시간 범위).
*/
public List<VesselAnalysisResult> getHistory(String mmsi, int hours) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(mmsi, after);
}
/**
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
*/
public Page<VesselAnalysisResult> getDarkVessels(int hours, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findLatestDarkVessels(after, pageable);
}
/**
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
*/
public Page<VesselAnalysisResult> getTransshipSuspects(int hours, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findLatestTransshipSuspects(after, pageable);
}
}

파일 보기

@ -32,9 +32,10 @@ public class EnforcementController {
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
public Page<EnforcementRecord> listRecords(
@RequestParam(required = false) String violationType,
@RequestParam(required = false) String vesselMmsi,
Pageable pageable
) {
return service.listRecords(violationType, pageable);
return service.listRecords(violationType, vesselMmsi, pageable);
}
/**

파일 보기

@ -32,7 +32,10 @@ public class EnforcementService {
// 단속 이력
// ========================================================================
public Page<EnforcementRecord> listRecords(String violationType, Pageable pageable) {
public Page<EnforcementRecord> listRecords(String violationType, String vesselMmsi, Pageable pageable) {
if (vesselMmsi != null && !vesselMmsi.isBlank()) {
return recordRepository.findByVesselMmsiOrderByEnforcedAtDesc(vesselMmsi, pageable);
}
if (violationType != null && !violationType.isBlank()) {
return recordRepository.findByViolationType(violationType, pageable);
}

파일 보기

@ -8,4 +8,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface EnforcementRecordRepository extends JpaRepository<EnforcementRecord, Long> {
Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable);
Page<EnforcementRecord> findByViolationType(String violationType, Pageable pageable);
Page<EnforcementRecord> findByVesselMmsiOrderByEnforcedAtDesc(String vesselMmsi, Pageable pageable);
}

파일 보기

@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
/**
@ -93,6 +94,10 @@ public class PredictionEvent {
@Column(name = "dedup_key", length = 200)
private String dedupKey;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb")
private Map<String, Object> features;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;

파일 보기

@ -0,0 +1,11 @@
-- ============================================================
-- V018: prediction_events에 features JSONB 컬럼 추가
-- event_generator가 분석 결과의 핵심 특성(dark_tier, transship_score 등)을
-- 이벤트와 함께 저장하여 프론트엔드에서 직접 활용할 수 있도록 한다.
-- ============================================================
ALTER TABLE kcg.prediction_events
ADD COLUMN IF NOT EXISTS features JSONB;
COMMENT ON COLUMN kcg.prediction_events.features IS
'분석 결과 핵심 특성 (dark_tier, dark_suspicion_score, transship_tier, transship_score 등)';

파일 보기

@ -6,6 +6,7 @@
dedup: 동일 mmsi + category + 윈도우 중복 방지.
"""
import hashlib
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
@ -214,6 +215,10 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
event_uid = _make_event_uid(now, seq)
seq += 1
# features 추출: 이벤트에 연관된 핵심 특성만 저장
raw_features = result.get('features')
features_json = json.dumps(raw_features, ensure_ascii=False) if raw_features else None
events_to_insert.append((
event_uid,
now, # occurred_at
@ -233,6 +238,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
result.get('confidence') or result.get('risk_score', 0) / 100.0,
'NEW', # status
dedup_key,
features_json,
))
generated += 1
# break 제거: 한 분석결과가 여러 룰에 매칭되면 모두 생성
@ -244,7 +250,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
f"""INSERT INTO {EVENTS_TABLE}
(event_uid, occurred_at, level, category, title, detail,
vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn,
source_type, source_ref_id, ai_confidence, status, dedup_key)
source_type, source_ref_id, ai_confidence, status, dedup_key, features)
VALUES %s
ON CONFLICT (event_uid) DO NOTHING""",
events_to_insert,