feat: 중국어선 분석 인프라 — 허가어선 API 연동 + vessel-analysis 백엔드 + 결과 포맷 확정

- 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) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-20 11:00:16 +09:00
부모 5cf69a1d22
커밋 feabf16114
11개의 변경된 파일506개의 추가작업 그리고 49개의 파일을 삭제

파일 보기

@ -23,6 +23,7 @@ public class AuthFilter extends OncePerRequestFilter {
private static final String AUTH_PATH_PREFIX = "/api/auth/"; private static final String AUTH_PATH_PREFIX = "/api/auth/";
private static final String SENSOR_PATH_PREFIX = "/api/sensor/"; private static final String SENSOR_PATH_PREFIX = "/api/sensor/";
private static final String CCTV_PATH_PREFIX = "/api/cctv/"; private static final String CCTV_PATH_PREFIX = "/api/cctv/";
private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis";
private final JwtProvider jwtProvider; private final JwtProvider jwtProvider;
@ -31,7 +32,8 @@ public class AuthFilter extends OncePerRequestFilter {
String path = request.getRequestURI(); String path = request.getRequestURI();
return path.startsWith(AUTH_PATH_PREFIX) return path.startsWith(AUTH_PATH_PREFIX)
|| path.startsWith(SENSOR_PATH_PREFIX) || path.startsWith(SENSOR_PATH_PREFIX)
|| path.startsWith(CCTV_PATH_PREFIX); || path.startsWith(CCTV_PATH_PREFIX)
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX);
} }
@Override @Override

파일 보기

@ -20,6 +20,7 @@ public class CacheConfig {
public static final String SATELLITES = "satellites"; public static final String SATELLITES = "satellites";
public static final String SEISMIC = "seismic"; public static final String SEISMIC = "seismic";
public static final String PRESSURE = "pressure"; public static final String PRESSURE = "pressure";
public static final String VESSEL_ANALYSIS = "vessel-analysis";
@Bean @Bean
public CacheManager cacheManager() { public CacheManager cacheManager() {
@ -27,7 +28,8 @@ public class CacheConfig {
AIRCRAFT_IRAN, AIRCRAFT_KOREA, AIRCRAFT_IRAN, AIRCRAFT_KOREA,
OSINT_IRAN, OSINT_KOREA, OSINT_IRAN, OSINT_KOREA,
SATELLITES, SATELLITES,
SEISMIC, PRESSURE SEISMIC, PRESSURE,
VESSEL_ANALYSIS
); );
manager.setCaffeine(Caffeine.newBuilder() manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(2, TimeUnit.DAYS) .expireAfterWrite(2, TimeUnit.DAYS)

파일 보기

@ -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<Map<String, Object>> getVesselAnalysis(
@RequestParam(required = false) String region) {
List<VesselAnalysisDto> results = vesselAnalysisService.getLatestResults();
return ResponseEntity.ok(Map.of(
"count", results.size(),
"items", results
));
}
}

파일 보기

@ -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<String, Double> 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();
}
}

파일 보기

@ -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<String, Double> 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;
}
}
}

파일 보기

@ -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<VesselAnalysisResult, Long> {
List<VesselAnalysisResult> findByTimestampAfter(Instant since);
}

파일 보기

@ -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<VesselAnalysisDto> getLatestResults() {
Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS);
if (cache != null) {
Cache.ValueWrapper wrapper = cache.get("data");
if (wrapper != null) {
return (List<VesselAnalysisDto>) wrapper.get();
}
}
Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES);
List<VesselAnalysisDto> results = repository.findByTimestampAfter(since)
.stream()
.map(VesselAnalysisDto::from)
.toList();
if (cache != null) {
cache.put("data", results);
}
return results;
}
}

파일 보기

@ -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);

파일 보기

@ -1,36 +1,8 @@
import { useState, useMemo, useEffect, useCallback } from 'react'; import { useState, useMemo, useEffect, useCallback } from 'react';
import type { Ship } from '../../types'; import type { Ship, ChnPrmShipInfo } from '../../types';
import { analyzeFishing } from '../../utils/fishingAnalysis'; import { analyzeFishing } from '../../utils/fishingAnalysis';
import { getMarineTrafficCategory } from '../../utils/marineTraffic'; import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { lookupPermittedShip } from '../../services/chnPrmShip';
// ── 선박 허가 정보 타입
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<string, PermitRecord | null>();
async function fetchVesselPermit(mmsi: string): Promise<PermitRecord | null> {
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;
}
}
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회) // MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
const mtPhotoCache = new Map<string, string | null>(); const mtPhotoCache = new Map<string, string | null>();
@ -173,7 +145,7 @@ const PIPE_STEPS = [
{ num: '02', name: '행동 상태 탐지' }, { num: '02', name: '행동 상태 탐지' },
{ num: '03', name: '궤적 리샘플링' }, { num: '03', name: '궤적 리샘플링' },
{ num: '04', name: '특징 벡터 추출' }, { num: '04', name: '특징 벡터 추출' },
{ num: '05', name: 'LightGBM 분류' }, { num: '05', name: '규칙 기반 분류' },
{ num: '06', name: 'BIRCH 군집화' }, { num: '06', name: 'BIRCH 군집화' },
{ num: '07', name: '계절 활동 분석' }, { num: '07', name: '계절 활동 분석' },
]; ];
@ -294,7 +266,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
// 허가 정보 // 허가 정보
const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle'); const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle');
const [permitData, setPermitData] = useState<PermitRecord | null>(null); const [permitData, setPermitData] = useState<ChnPrmShipInfo | null>(null);
// 선박 사진 // 선박 사진
const [photoUrl, setPhotoUrl] = useState<string | null | undefined>(undefined); // undefined=로딩, null=없음 const [photoUrl, setPhotoUrl] = useState<string | null | undefined>(undefined); // undefined=로딩, null=없음
@ -306,7 +278,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
// 허가 조회 // 허가 조회
setPermitStatus('loading'); setPermitStatus('loading');
setPermitData(null); setPermitData(null);
fetchVesselPermit(ship.mmsi).then(data => { lookupPermittedShip(ship.mmsi).then(data => {
setPermitData(data); setPermitData(data);
setPermitStatus(data ? 'found' : 'not-found'); setPermitStatus(data ? 'found' : 'not-found');
}); });
@ -378,7 +350,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
}}> }}>
<span style={{ color: C.green, fontSize: 9, letterSpacing: 3 }}> FIELD ANALYSIS</span> <span style={{ color: C.green, fontSize: 9, letterSpacing: 3 }}> FIELD ANALYSIS</span>
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700, letterSpacing: 1 }}> </span> <span style={{ color: '#fff', fontSize: 14, fontWeight: 700, letterSpacing: 1 }}> </span>
<span style={{ color: C.ink3, fontSize: 10 }}>AIS · LightGBM · BIRCH · Shepperson(2017) · Yan et al.(2022)</span> <span style={{ color: C.ink3, fontSize: 10 }}>AIS · · BIRCH · Shepperson(2017) · Yan et al.(2022)</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 16, alignItems: 'center' }}> <div style={{ marginLeft: 'auto', display: 'flex', gap: 16, alignItems: 'center' }}>
<span style={{ color: C.green, fontSize: 10, display: 'flex', alignItems: 'center', gap: 4 }}> <span style={{ color: C.green, fontSize: 10, display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: C.green, display: 'inline-block', animation: 'pulse 1.5s ease-in-out infinite' }} /> <span style={{ width: 6, height: 6, borderRadius: '50%', background: C.green, display: 'inline-block', animation: 'pulse 1.5s ease-in-out infinite' }} />
@ -412,8 +384,8 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
{ label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' }, { label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' },
{ label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 의심' }, { label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 의심' },
{ label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'BIRCH 군집' }, { label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'BIRCH 군집' },
{ label: '트롤어선', val: stats.trawl, color: C.purple, sub: 'LightGBM 분류' }, { label: '트롤어선', val: stats.trawl, color: C.purple, sub: '규칙 기반 분류' },
{ label: '선망어선', val: stats.purse, color: C.cyan, sub: 'LightGBM 분류' }, { label: '선망어선', val: stats.purse, color: C.cyan, sub: '규칙 기반 분류' },
].map(({ label, val, color, sub }) => ( ].map(({ label, val, color, sub }) => (
<div key={label} style={{ <div key={label} style={{
flex: 1, background: C.bg2, border: `1px solid ${C.border}`, flex: 1, background: C.bg2, border: `1px solid ${C.border}`,
@ -489,8 +461,8 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
})} })}
{[ {[
{ num: 'GPS', name: 'BD-09 변환', status: 'ACTIVE', color: C.amber }, { num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
{ num: 'NRD', name: '레이더 교차검증', status: 'LINKED', color: C.cyan }, { num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
].map(step => ( ].map(step => (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}> <div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span> <span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
@ -514,7 +486,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
{ label: 'AIS 소실', val: '>20분 미수신', color: C.amber }, { label: 'AIS 소실', val: '>20분 미수신', color: C.amber },
{ label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple }, { label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple },
{ label: '클러스터', val: 'BIRCH 5NM', color: C.ink2 }, { 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 }) => ( ].map(({ label, val, color }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> <div key={label} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span> <span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
@ -708,7 +680,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
</span> </span>
))} ))}
<span style={{ marginLeft: 'auto', color: C.ink3, fontSize: 9 }}> <span style={{ marginLeft: 'auto', color: C.ink3, fontSize: 9 }}>
AIS 4 | Shepperson(2017) | LightGBM 95.68% | BIRCH 5NM AIS 4 | Shepperson(2017) | | 5NM
</span> </span>
</div> </div>
</div> </div>
@ -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.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: `${selectedVessel.ship.speed.toFixed(1)}kt ${selectedVessel.ship.course}°`, color: C.amber },
{ label: '행동 상태', val: stateLabel(selectedVessel.state), color: stateColor(selectedVessel.state) }, { 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: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) },
{ label: 'BIRCH 클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 }, { label: 'BIRCH 클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 },
{ label: '경보 등급', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) }, { label: '경보 등급', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) },
@ -787,12 +759,14 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
{permitStatus === 'found' && permitData && ( {permitStatus === 'found' && permitData && (
<div style={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 3, padding: '7px 10px' }}> <div style={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 3, padding: '7px 10px' }}>
{[ {[
{ label: '허가번호', val: permitData.permitNumber }, { label: '선명', val: permitData.name },
{ label: '허가종류', val: permitData.permitType }, { label: '선종', val: permitData.vesselType },
{ label: '발급기관', val: permitData.issuedBy }, { label: 'IMO', val: String(permitData.imo || '—') },
{ label: '유효기간', val: `${permitData.validFrom} ~ ${permitData.validTo}` }, { label: '호출부호', val: permitData.callsign || '—' },
{ label: '허가수역', val: permitData.authorizedZones.join(', ') }, { label: '길이/폭', val: `${permitData.length ?? 0}m / ${permitData.width ?? 0}m` },
...(permitData.grossTonnage ? [{ label: '총톤수', val: `${permitData.grossTonnage}GT` }] : []), { label: '흘수', val: permitData.draught ? `${permitData.draught}m` : '—' },
{ label: '목적지', val: permitData.destination || '—' },
{ label: '상태', val: permitData.status || '—' },
].map(({ label, val }) => ( ].map(({ label, val }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: `1px solid ${C.border2}` }}> <div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: `1px solid ${C.border2}` }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span> <span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>

파일 보기

@ -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<ChnPrmShipInfo[]> | null = null;
async function fetchList(): Promise<ChnPrmShipInfo[]> {
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<ChnPrmShipInfo | null> {
const list = await fetchList();
return list.find((s) => s.mmsi === mmsi) ?? null;
}
/** 허가어선 mmsi Set (빠른 조회용) */
export async function getPermittedMmsiSet(): Promise<Set<string>> {
const list = await fetchList();
return new Set(list.map((s) => s.mmsi));
}
/** 캐시 강제 갱신 */
export function invalidateCache(): void {
cacheTime = 0;
}

파일 보기

@ -147,3 +147,60 @@ export interface LayerVisibility {
} }
export type AppMode = 'replay' | 'live'; 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<string, number>;
}
// 허가어선 정보 (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;
}