From feabf16114526c18f9f6ee371987bd58d9b9a327 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Mar 2026 11:00:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A4=91=EA=B5=AD=EC=96=B4=EC=84=A0=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=E2=80=94=20?= =?UTF-8?q?=ED=97=88=EA=B0=80=EC=96=B4=EC=84=A0=20API=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20+=20vessel-analysis=20=EB=B0=B1=EC=97=94=EB=93=9C=20+=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=ED=8F=AC=EB=A7=B7=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frontend: ChnPrmShipInfo 타입 + chnPrmShip.ts 서비스 (signal-batch 허가어선 API) - Frontend: FieldAnalysisModal fetchVesselPermit → lookupPermittedShip 교체 - Frontend: 더미 라벨 정리 (LightGBM → 규칙기반, BD-09/레이더 → STANDBY/미연동) - Frontend: VesselAnalysisResult 인터페이스 정의 (Python 분석 결과 수신용) - Backend: vessel-analysis REST API (Entity/Repository/Service/Controller) - Backend: DB 마이그레이션 005 (kcg.vessel_analysis_results 테이블) - Backend: AuthFilter 인증 예외 + CacheConfig 캐시 등록 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/gc/mda/kcg/auth/AuthFilter.java | 4 +- .../java/gc/mda/kcg/config/CacheConfig.java | 4 +- .../analysis/VesselAnalysisController.java | 35 +++++ .../domain/analysis/VesselAnalysisDto.java | 148 ++++++++++++++++++ .../domain/analysis/VesselAnalysisResult.java | 97 ++++++++++++ .../VesselAnalysisResultRepository.java | 11 ++ .../analysis/VesselAnalysisService.java | 47 ++++++ database/migration/005_vessel_analysis.sql | 30 ++++ .../components/korea/FieldAnalysisModal.tsx | 68 +++----- frontend/src/services/chnPrmShip.ts | 54 +++++++ frontend/src/types.ts | 57 +++++++ 11 files changed, 506 insertions(+), 49 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java create mode 100644 database/migration/005_vessel_analysis.sql create mode 100644 frontend/src/services/chnPrmShip.ts diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java index de1aa74..9394f43 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java @@ -23,6 +23,7 @@ public class AuthFilter extends OncePerRequestFilter { private static final String AUTH_PATH_PREFIX = "/api/auth/"; private static final String SENSOR_PATH_PREFIX = "/api/sensor/"; private static final String CCTV_PATH_PREFIX = "/api/cctv/"; + private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis"; private final JwtProvider jwtProvider; @@ -31,7 +32,8 @@ public class AuthFilter extends OncePerRequestFilter { String path = request.getRequestURI(); return path.startsWith(AUTH_PATH_PREFIX) || path.startsWith(SENSOR_PATH_PREFIX) - || path.startsWith(CCTV_PATH_PREFIX); + || path.startsWith(CCTV_PATH_PREFIX) + || path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX); } @Override diff --git a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java index 1d216f7..f2274a0 100644 --- a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java +++ b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java @@ -20,6 +20,7 @@ public class CacheConfig { public static final String SATELLITES = "satellites"; public static final String SEISMIC = "seismic"; public static final String PRESSURE = "pressure"; + public static final String VESSEL_ANALYSIS = "vessel-analysis"; @Bean public CacheManager cacheManager() { @@ -27,7 +28,8 @@ public class CacheConfig { AIRCRAFT_IRAN, AIRCRAFT_KOREA, OSINT_IRAN, OSINT_KOREA, SATELLITES, - SEISMIC, PRESSURE + SEISMIC, PRESSURE, + VESSEL_ANALYSIS ); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(2, TimeUnit.DAYS) 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 new file mode 100644 index 0000000..b6fd0a7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -0,0 +1,35 @@ +package gc.mda.kcg.domain.analysis; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/vessel-analysis") +@RequiredArgsConstructor +public class VesselAnalysisController { + + private final VesselAnalysisService vesselAnalysisService; + + /** + * 최근 선박 분석 결과 조회 + * @param region 지역 필터 (향후 확장용, 현재 미사용) + */ + @GetMapping + public ResponseEntity> getVesselAnalysis( + @RequestParam(required = false) String region) { + + List results = vesselAnalysisService.getLatestResults(); + + return ResponseEntity.ok(Map.of( + "count", results.size(), + "items", results + )); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java new file mode 100644 index 0000000..a8d2e52 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java @@ -0,0 +1,148 @@ +package gc.mda.kcg.domain.analysis; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class VesselAnalysisDto { + + private String mmsi; + private String timestamp; + private Classification classification; + private Algorithms algorithms; + private Map features; + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Classification { + private String vesselType; + private Double confidence; + private Double fishingPct; + private Integer clusterId; + private String season; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Algorithms { + private LocationInfo location; + private ActivityInfo activity; + private DarkVesselInfo darkVessel; + private GpsSpoofingInfo gpsSpoofing; + private ClusterInfo cluster; + private FleetRoleInfo fleetRole; + private RiskScoreInfo riskScore; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class LocationInfo { + private String zone; + private Double distToBaselineNm; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ActivityInfo { + private String state; + private Double ucafScore; + private Double ucftScore; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class DarkVesselInfo { + private Boolean isDark; + private Integer gapDurationMin; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class GpsSpoofingInfo { + private Double spoofingScore; + private Double bd09OffsetM; + private Integer speedJumpCount; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ClusterInfo { + private Integer clusterId; + private Integer clusterSize; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class FleetRoleInfo { + private Boolean isLeader; + private String role; + } + + @Getter + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class RiskScoreInfo { + private Integer score; + private String level; + } + + public static VesselAnalysisDto from(VesselAnalysisResult r) { + return VesselAnalysisDto.builder() + .mmsi(r.getMmsi()) + .timestamp(r.getTimestamp().toString()) + .classification(Classification.builder() + .vesselType(r.getVesselType()) + .confidence(r.getConfidence()) + .fishingPct(r.getFishingPct()) + .clusterId(r.getClusterId()) + .season(r.getSeason()) + .build()) + .algorithms(Algorithms.builder() + .location(LocationInfo.builder() + .zone(r.getZone()) + .distToBaselineNm(r.getDistToBaselineNm()) + .build()) + .activity(ActivityInfo.builder() + .state(r.getActivityState()) + .ucafScore(r.getUcafScore()) + .ucftScore(r.getUcftScore()) + .build()) + .darkVessel(DarkVesselInfo.builder() + .isDark(r.getIsDark()) + .gapDurationMin(r.getGapDurationMin()) + .build()) + .gpsSpoofing(GpsSpoofingInfo.builder() + .spoofingScore(r.getSpoofingScore()) + .bd09OffsetM(r.getBd09OffsetM()) + .speedJumpCount(r.getSpeedJumpCount()) + .build()) + .cluster(ClusterInfo.builder() + .clusterId(r.getClusterId()) + .clusterSize(r.getClusterSize()) + .build()) + .fleetRole(FleetRoleInfo.builder() + .isLeader(r.getIsLeader()) + .role(r.getFleetRole()) + .build()) + .riskScore(RiskScoreInfo.builder() + .score(r.getRiskScore()) + .level(r.getRiskLevel()) + .build()) + .build()) + .features(r.getFeatures()) + .build(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java new file mode 100644 index 0000000..6306d8f --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -0,0 +1,97 @@ +package gc.mda.kcg.domain.analysis; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; + +@Entity +@Table(name = "vessel_analysis_results", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VesselAnalysisResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 15) + private String mmsi; + + @Column(nullable = false) + private Instant timestamp; + + @Column(length = 20) + private String vesselType; + + private Double confidence; + + private Double fishingPct; + + private Integer clusterId; + + @Column(length = 10) + private String season; + + @Column(length = 20) + private String zone; + + private Double distToBaselineNm; + + @Column(length = 20) + private String activityState; + + private Double ucafScore; + + private Double ucftScore; + + @Column(nullable = false) + private Boolean isDark; + + private Integer gapDurationMin; + + private Double spoofingScore; + + private Double bd09OffsetM; + + private Integer speedJumpCount; + + private Integer clusterSize; + + @Column(nullable = false) + private Boolean isLeader; + + @Column(length = 20) + private String fleetRole; + + private Integer riskScore; + + @Column(length = 20) + private String riskLevel; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map features; + + @Column(nullable = false) + private Instant analyzedAt; + + @PrePersist + protected void onCreate() { + if (analyzedAt == null) { + analyzedAt = Instant.now(); + } + if (isDark == null) { + isDark = false; + } + if (isLeader == null) { + isLeader = false; + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java new file mode 100644 index 0000000..17f117b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResultRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.analysis; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface VesselAnalysisResultRepository extends JpaRepository { + + List findByTimestampAfter(Instant since); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java new file mode 100644 index 0000000..9e78162 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -0,0 +1,47 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.config.CacheConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class VesselAnalysisService { + + private static final int RECENT_MINUTES = 10; + + private final VesselAnalysisResultRepository repository; + private final CacheManager cacheManager; + + /** + * 최근 10분 내 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용. + */ + @SuppressWarnings("unchecked") + public List getLatestResults() { + Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS); + if (cache != null) { + Cache.ValueWrapper wrapper = cache.get("data"); + if (wrapper != null) { + return (List) wrapper.get(); + } + } + + Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES); + List results = repository.findByTimestampAfter(since) + .stream() + .map(VesselAnalysisDto::from) + .toList(); + + if (cache != null) { + cache.put("data", results); + } + + return results; + } +} diff --git a/database/migration/005_vessel_analysis.sql b/database/migration/005_vessel_analysis.sql new file mode 100644 index 0000000..82650f3 --- /dev/null +++ b/database/migration/005_vessel_analysis.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS kcg.vessel_analysis_results ( + id BIGSERIAL PRIMARY KEY, + mmsi VARCHAR(15) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + vessel_type VARCHAR(20), + confidence DOUBLE PRECISION, + fishing_pct DOUBLE PRECISION, + cluster_id INTEGER, + season VARCHAR(10), + zone VARCHAR(20), + dist_to_baseline_nm DOUBLE PRECISION, + activity_state VARCHAR(20), + ucaf_score DOUBLE PRECISION, + ucft_score DOUBLE PRECISION, + is_dark BOOLEAN DEFAULT FALSE, + gap_duration_min INTEGER, + spoofing_score DOUBLE PRECISION, + bd09_offset_m DOUBLE PRECISION, + speed_jump_count INTEGER, + cluster_size INTEGER, + is_leader BOOLEAN DEFAULT FALSE, + fleet_role VARCHAR(20), + risk_score INTEGER, + risk_level VARCHAR(20), + features JSONB, + analyzed_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_vessel_analysis_mmsi ON kcg.vessel_analysis_results(mmsi); +CREATE INDEX IF NOT EXISTS idx_vessel_analysis_timestamp ON kcg.vessel_analysis_results(timestamp DESC); diff --git a/frontend/src/components/korea/FieldAnalysisModal.tsx b/frontend/src/components/korea/FieldAnalysisModal.tsx index 05926cf..ae1ef89 100644 --- a/frontend/src/components/korea/FieldAnalysisModal.tsx +++ b/frontend/src/components/korea/FieldAnalysisModal.tsx @@ -1,36 +1,8 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; -import type { Ship } from '../../types'; +import type { Ship, ChnPrmShipInfo } from '../../types'; import { analyzeFishing } from '../../utils/fishingAnalysis'; import { getMarineTrafficCategory } from '../../utils/marineTraffic'; - -// ── 선박 허가 정보 타입 -interface PermitRecord { - permitNumber: string; - permitType: string; - issuedBy: string; - validFrom: string; - validTo: string; - authorizedZones: string[]; - vesselName: string; - grossTonnage?: number; -} - -// MMSI → 허가 정보 캐시 (null = 미등록, undefined = 미조회) -const permitCache = new Map(); - -async function fetchVesselPermit(mmsi: string): Promise { - if (permitCache.has(mmsi)) return permitCache.get(mmsi) ?? null; - try { - const res = await fetch(`/api/kcg/vessel-permit/${mmsi}`); - if (res.status === 404) { permitCache.set(mmsi, null); return null; } - if (!res.ok) throw new Error(`${res.status}`); - const data: PermitRecord = await res.json(); - permitCache.set(mmsi, data); - return data; - } catch { - return null; - } -} +import { lookupPermittedShip } from '../../services/chnPrmShip'; // MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회) const mtPhotoCache = new Map(); @@ -173,7 +145,7 @@ const PIPE_STEPS = [ { num: '02', name: '행동 상태 탐지' }, { num: '03', name: '궤적 리샘플링' }, { num: '04', name: '특징 벡터 추출' }, - { num: '05', name: 'LightGBM 분류' }, + { num: '05', name: '규칙 기반 분류' }, { num: '06', name: 'BIRCH 군집화' }, { num: '07', name: '계절 활동 분석' }, ]; @@ -294,7 +266,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) { // 허가 정보 const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle'); - const [permitData, setPermitData] = useState(null); + const [permitData, setPermitData] = useState(null); // 선박 사진 const [photoUrl, setPhotoUrl] = useState(undefined); // undefined=로딩, null=없음 @@ -306,7 +278,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) { // 허가 조회 setPermitStatus('loading'); setPermitData(null); - fetchVesselPermit(ship.mmsi).then(data => { + lookupPermittedShip(ship.mmsi).then(data => { setPermitData(data); setPermitStatus(data ? 'found' : 'not-found'); }); @@ -378,7 +350,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) { }}> ▶ FIELD ANALYSIS 중국 불법어업 현장분석 대시보드 - AIS · LightGBM · BIRCH · Shepperson(2017) · Yan et al.(2022) + AIS · 규칙분류 · BIRCH · Shepperson(2017) · Yan et al.(2022)
@@ -412,8 +384,8 @@ export function FieldAnalysisModal({ ships, onClose }: Props) { { label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' }, { label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 의심' }, { label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'BIRCH 군집' }, - { label: '트롤어선', val: stats.trawl, color: C.purple, sub: 'LightGBM 분류' }, - { label: '선망어선', val: stats.purse, color: C.cyan, sub: 'LightGBM 분류' }, + { label: '트롤어선', val: stats.trawl, color: C.purple, sub: '규칙 기반 분류' }, + { label: '선망어선', val: stats.purse, color: C.cyan, sub: '규칙 기반 분류' }, ].map(({ label, val, color, sub }) => (
(
{step.num} @@ -514,7 +486,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) { { label: 'AIS 소실', val: '>20분 미수신', color: C.amber }, { label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple }, { label: '클러스터', val: 'BIRCH 5NM', color: C.ink2 }, - { label: '선종 분류', val: 'LightGBM 95.7%', color: C.green }, + { label: '선종 분류', val: '규칙 기반 (Python 연동 예정)', color: C.ink2 }, ].map(({ label, val, color }) => (
{label} @@ -708,7 +680,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) { ))} - AIS 4분 갱신 | Shepperson(2017) 기준 | LightGBM 95.68% | BIRCH 5NM + AIS 4분 갱신 | Shepperson(2017) 기준 | 규칙 기반 분류 | 근접 클러스터 5NM
@@ -737,7 +709,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) { { label: '위치', val: `${selectedVessel.ship.lat.toFixed(4)}°N ${selectedVessel.ship.lng.toFixed(4)}°E`, color: C.ink }, { label: '속도 / 침로', val: `${selectedVessel.ship.speed.toFixed(1)}kt ${selectedVessel.ship.course}°`, color: C.amber }, { label: '행동 상태', val: stateLabel(selectedVessel.state), color: stateColor(selectedVessel.state) }, - { label: 'LightGBM 선종', val: selectedVessel.vtype, color: C.ink }, + { label: '추정 선종', val: selectedVessel.vtype, color: C.ink }, { label: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) }, { label: 'BIRCH 클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 }, { label: '경보 등급', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) }, @@ -787,12 +759,14 @@ export function FieldAnalysisModal({ ships, onClose }: Props) { {permitStatus === 'found' && permitData && (
{[ - { label: '허가번호', val: permitData.permitNumber }, - { label: '허가종류', val: permitData.permitType }, - { label: '발급기관', val: permitData.issuedBy }, - { label: '유효기간', val: `${permitData.validFrom} ~ ${permitData.validTo}` }, - { label: '허가수역', val: permitData.authorizedZones.join(', ') }, - ...(permitData.grossTonnage ? [{ label: '총톤수', val: `${permitData.grossTonnage}GT` }] : []), + { label: '선명', val: permitData.name }, + { label: '선종', val: permitData.vesselType }, + { label: 'IMO', val: String(permitData.imo || '—') }, + { label: '호출부호', val: permitData.callsign || '—' }, + { label: '길이/폭', val: `${permitData.length ?? 0}m / ${permitData.width ?? 0}m` }, + { label: '흘수', val: permitData.draught ? `${permitData.draught}m` : '—' }, + { label: '목적지', val: permitData.destination || '—' }, + { label: '상태', val: permitData.status || '—' }, ].map(({ label, val }) => (
{label} diff --git a/frontend/src/services/chnPrmShip.ts b/frontend/src/services/chnPrmShip.ts new file mode 100644 index 0000000..1a4c890 --- /dev/null +++ b/frontend/src/services/chnPrmShip.ts @@ -0,0 +1,54 @@ +import type { ChnPrmShipInfo } from '../types'; + +const SIGNAL_BATCH_BASE = '/signal-batch'; +const CACHE_TTL_MS = 5 * 60_000; // 5분 + +let cachedList: ChnPrmShipInfo[] = []; +let cacheTime = 0; +let fetchPromise: Promise | null = null; + +async function fetchList(): Promise { + const now = Date.now(); + if (cachedList.length > 0 && now - cacheTime < CACHE_TTL_MS) { + return cachedList; + } + + if (fetchPromise) return fetchPromise; + + fetchPromise = (async () => { + try { + const res = await fetch( + `${SIGNAL_BATCH_BASE}/api/v2/vessels/chnprmship/recent-positions?minutes=60`, + { headers: { accept: 'application/json' } }, + ); + if (!res.ok) return cachedList; + const json: unknown = await res.json(); + cachedList = Array.isArray(json) ? (json as ChnPrmShipInfo[]) : []; + cacheTime = Date.now(); + return cachedList; + } catch { + return cachedList; + } finally { + fetchPromise = null; + } + })(); + + return fetchPromise; +} + +/** mmsi로 허가어선 정보 조회 — 목록을 캐시하고 lookup */ +export async function lookupPermittedShip(mmsi: string): Promise { + const list = await fetchList(); + return list.find((s) => s.mmsi === mmsi) ?? null; +} + +/** 허가어선 mmsi Set (빠른 조회용) */ +export async function getPermittedMmsiSet(): Promise> { + const list = await fetchList(); + return new Set(list.map((s) => s.mmsi)); +} + +/** 캐시 강제 갱신 */ +export function invalidateCache(): void { + cacheTime = 0; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 458e7fe..76f6149 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -147,3 +147,60 @@ export interface LayerVisibility { } export type AppMode = 'replay' | 'live'; + +// ── 중국어선 분석 결과 (Python 분류기 → REST API → Frontend) ── + +export type VesselType = 'TRAWL' | 'PURSE' | 'LONGLINE' | 'TRAP'; +export type RiskLevel = 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL'; +export type ActivityState = 'FISHING' | 'SAILING' | 'STATIONARY' | 'AIS_LOSS'; +export type ZoneType = 'TERRITORIAL' | 'CONTIGUOUS' | 'EEZ' | 'BEYOND'; +export type FleetRole = 'MOTHER' | 'SUB' | 'TRANSPORT' | 'INDEPENDENT'; + +export interface VesselClassification { + vesselType: VesselType; + confidence: number; // 0~1 + fishingPct: number; // 조업 비율 % + clusterId: number; // BIRCH 군집 ID (-1=노이즈) + season: string; // SPRING/SUMMER/FALL/WINTER +} + +export interface VesselAlgorithms { + location: { zone: ZoneType; distToBaselineNm: number }; + activity: { state: ActivityState; ucafScore: number; ucftScore: number }; + darkVessel: { isDark: boolean; gapDurationMin: number }; + gpsSpoofing: { spoofingScore: number; bd09OffsetM: number; speedJumpCount: number }; + cluster: { clusterId: number; clusterSize: number; centroid?: [number, number] }; + fleetRole: { isLeader: boolean; role: FleetRole }; + riskScore: { score: number; level: RiskLevel }; +} + +export interface VesselAnalysisResult { + mmsi: string; + timestamp: string; // ISO 분석 시점 + classification: VesselClassification; + algorithms: VesselAlgorithms; + features: Record; +} + +// 허가어선 정보 (signal-batch /api/v2/vessels/chnprmship) +export interface ChnPrmShipInfo { + mmsi: string; + imo: number; + name: string; + callsign: string; + vesselType: string; + lat: number; + lon: number; + sog: number; + cog: number; + heading: number; + length: number; + width: number; + draught: number; + destination: string; + status: string; + signalKindCode: string; + messageTimestamp: string; + shipImagePath?: string | null; + shipImageCount?: number; +}