release: 2026-03-25 (8건 커밋) #104
@ -62,6 +62,8 @@
|
||||
- 실패 건 수동 재수집 시 414 Request-URI Too Long 오류 수정 (#71)
|
||||
|
||||
### 변경
|
||||
- AIS 수집 및 서비스 API 제거 (배치 분리에 따른 코드 정리, ~7,000 LOC 삭제) (#99)
|
||||
- API URL 환경별 중복 제거 (application.yml 공통 관리)
|
||||
- RiskRangeImportJob API URL 변경 및 저장 테이블 통합 (#86)
|
||||
- RiskDetailImportJob IMO 조회 대상을 tb_ship_default_info로 변경 (#81)
|
||||
- 파티션 스텝 프로세스 공통 모듈화 (StringListPartitioner, BasePartitionedJobConfig, LastExecutionUpdateTasklet) (#73)
|
||||
|
||||
@ -93,9 +93,6 @@ export default function Executions() {
|
||||
return map;
|
||||
}, [displayNames]);
|
||||
|
||||
const aisJobs = useMemo(() => jobs.filter(j => j.toLowerCase().startsWith('ais')), [jobs]);
|
||||
const nonAisJobs = useMemo(() => jobs.filter(j => !j.toLowerCase().startsWith('ais')), [jobs]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
try {
|
||||
const data = await batchApi.getJobs();
|
||||
@ -330,26 +327,6 @@ export default function Executions() {
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedJobs(nonAisJobs)}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
selectedJobs.length > 0 && selectedJobs.length === nonAisJobs.length && selectedJobs.every(j => nonAisJobs.includes(j))
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||
}`}
|
||||
>
|
||||
AIS 제외
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedJobs([...aisJobs])}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
selectedJobs.length > 0 && selectedJobs.length === aisJobs.length && selectedJobs.every(j => aisJobs.includes(j))
|
||||
? 'bg-wing-accent text-white'
|
||||
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
|
||||
}`}
|
||||
>
|
||||
AIS만
|
||||
</button>
|
||||
</div>
|
||||
{selectedJobs.length > 0 && (
|
||||
<button
|
||||
|
||||
11
pom.xml
11
pom.xml
@ -111,11 +111,6 @@
|
||||
<version>2.3.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Kafka -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.kafka</groupId>
|
||||
<artifactId>spring-kafka</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine Cache -->
|
||||
<dependency>
|
||||
@ -124,12 +119,6 @@
|
||||
<version>3.1.8</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JTS (Java Topology Suite) - 공간 연산 라이브러리 -->
|
||||
<dependency>
|
||||
<groupId>org.locationtech.jts</groupId>
|
||||
<artifactId>jts-core</artifactId>
|
||||
<version>1.19.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Test Dependencies -->
|
||||
<dependency>
|
||||
|
||||
@ -1,149 +0,0 @@
|
||||
-- ============================================================
|
||||
-- ChnPrmShip 캐시 검증 진단 쿼리
|
||||
-- 대상: std_snp_data.ais_target (일별 파티션)
|
||||
-- 목적: 최근 2일 내 대상 MMSI별 최종위치 캐싱 검증
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 0. 대상 MMSI 임시 테이블 생성
|
||||
-- ============================================================
|
||||
CREATE TEMP TABLE tmp_chn_mmsi (mmsi BIGINT PRIMARY KEY);
|
||||
|
||||
-- psql에서 실행:
|
||||
-- \copy tmp_chn_mmsi(mmsi) FROM 'chnprmship-mmsi.txt'
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 기본 현황: 대상 MMSI 중 최근 2일 내 데이터 존재 여부
|
||||
-- ============================================================
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM tmp_chn_mmsi) AS total_target_mmsi,
|
||||
COUNT(DISTINCT a.mmsi) AS mmsi_with_data_2d,
|
||||
(SELECT COUNT(*) FROM tmp_chn_mmsi) - COUNT(DISTINCT a.mmsi) AS mmsi_without_data_2d,
|
||||
ROUND(COUNT(DISTINCT a.mmsi) * 100.0
|
||||
/ NULLIF((SELECT COUNT(*) FROM tmp_chn_mmsi), 0), 1) AS hit_rate_pct
|
||||
FROM std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 워밍업 시뮬레이션: 최근 2일 내 MMSI별 최종위치
|
||||
-- (수정 후 findLatestByMmsiIn 쿼리와 동일하게 동작)
|
||||
-- ============================================================
|
||||
SELECT COUNT(*) AS cached_count,
|
||||
MIN(message_timestamp) AS oldest_cached,
|
||||
MAX(message_timestamp) AS newest_cached,
|
||||
NOW() - MAX(message_timestamp) AS newest_age
|
||||
FROM (
|
||||
SELECT DISTINCT ON (a.mmsi) a.mmsi, a.message_timestamp
|
||||
FROM std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||
ORDER BY a.mmsi, a.message_timestamp DESC
|
||||
) latest;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. MMSI별 최종위치 상세 (최근 2일 내, 최신순 상위 30건)
|
||||
-- ============================================================
|
||||
SELECT DISTINCT ON (a.mmsi)
|
||||
a.mmsi,
|
||||
a.message_timestamp,
|
||||
a.name,
|
||||
a.vessel_type,
|
||||
a.lat,
|
||||
a.lon,
|
||||
a.sog,
|
||||
a.cog,
|
||||
a.heading,
|
||||
NOW() - a.message_timestamp AS data_age
|
||||
FROM std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||
ORDER BY a.mmsi, a.message_timestamp DESC
|
||||
LIMIT 30;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 데이터 없는 대상 MMSI (최근 2일 내 DB에 없는 선박)
|
||||
-- ============================================================
|
||||
SELECT t.mmsi AS missing_mmsi
|
||||
FROM tmp_chn_mmsi t
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT mmsi
|
||||
FROM std_snp_data.ais_target
|
||||
WHERE mmsi IN (SELECT mmsi FROM tmp_chn_mmsi)
|
||||
AND message_timestamp >= NOW() - INTERVAL '2 days'
|
||||
) a ON t.mmsi = a.mmsi
|
||||
WHERE a.mmsi IS NULL
|
||||
ORDER BY t.mmsi;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 시간대별 분포 (2일 기준 세부 확인)
|
||||
-- ============================================================
|
||||
SELECT
|
||||
'6시간 이내' AS time_range,
|
||||
COUNT(DISTINCT mmsi) AS distinct_mmsi
|
||||
FROM std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '6 hours'
|
||||
|
||||
UNION ALL
|
||||
SELECT '12시간 이내', COUNT(DISTINCT mmsi)
|
||||
FROM std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '12 hours'
|
||||
|
||||
UNION ALL
|
||||
SELECT '1일 이내', COUNT(DISTINCT mmsi)
|
||||
FROM std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '1 day'
|
||||
|
||||
UNION ALL
|
||||
SELECT '2일 이내', COUNT(DISTINCT mmsi)
|
||||
FROM std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
|
||||
|
||||
UNION ALL
|
||||
SELECT '전체(무제한)', COUNT(DISTINCT mmsi)
|
||||
FROM std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 6. 파티션별 대상 데이터 분포
|
||||
-- ============================================================
|
||||
SELECT
|
||||
tableoid::regclass AS partition_name,
|
||||
COUNT(*) AS row_count,
|
||||
COUNT(DISTINCT mmsi) AS distinct_mmsi,
|
||||
MIN(message_timestamp) AS min_ts,
|
||||
MAX(message_timestamp) AS max_ts
|
||||
FROM std_snp_data.ais_target a
|
||||
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
|
||||
GROUP BY tableoid::regclass
|
||||
ORDER BY max_ts DESC;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 7. 전체 ais_target 파티션 현황
|
||||
-- ============================================================
|
||||
SELECT
|
||||
c.relname AS partition_name,
|
||||
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
|
||||
s.n_live_tup AS estimated_rows
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_stat_user_tables s ON s.relid = c.oid
|
||||
WHERE i.inhparent = 'std_snp_data.ais_target'::regclass
|
||||
ORDER BY c.relname DESC;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 정리
|
||||
-- ============================================================
|
||||
DROP TABLE IF EXISTS tmp_chn_mmsi;
|
||||
@ -2,11 +2,10 @@ package com.snp.batch;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication(exclude = KafkaAutoConfiguration.class)
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@ConfigurationPropertiesScan
|
||||
public class SnpBatchApplication {
|
||||
|
||||
@ -1,143 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.snp.batch.common.batch.config.BaseJobConfig;
|
||||
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.processor.AisTargetDataProcessor;
|
||||
import com.snp.batch.jobs.aistarget.batch.reader.AisTargetDataReader;
|
||||
import com.snp.batch.jobs.aistarget.batch.writer.AisTargetDataWriter;
|
||||
import com.snp.batch.jobs.aistarget.classifier.Core20CacheManager;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.JobExecution;
|
||||
import org.springframework.batch.core.JobExecutionListener;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
import org.springframework.batch.item.ItemReader;
|
||||
import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* AIS Target Import Job Config
|
||||
*
|
||||
* 스케줄: 매 분 15초 (0 15 * * * * ?)
|
||||
* API: POST /AisSvc.svc/AIS/GetTargets
|
||||
* 파라미터: {"sinceSeconds": "60"}
|
||||
*
|
||||
* 동작:
|
||||
* - 최근 60초 동안의 전체 선박 위치 정보 수집
|
||||
* - 약 33,000건/분 처리
|
||||
* - UPSERT 방식으로 DB 저장
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTargetEntity> {
|
||||
|
||||
private final AisTargetDataProcessor aisTargetDataProcessor;
|
||||
private final AisTargetDataWriter aisTargetDataWriter;
|
||||
private final WebClient maritimeAisApiWebClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Core20CacheManager core20CacheManager;
|
||||
|
||||
@Value("${app.batch.ais-target.since-seconds:60}")
|
||||
private int sinceSeconds;
|
||||
|
||||
@Value("${app.batch.ais-target.chunk-size:5000}")
|
||||
private int chunkSize;
|
||||
|
||||
public AisTargetImportJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
AisTargetDataProcessor aisTargetDataProcessor,
|
||||
AisTargetDataWriter aisTargetDataWriter,
|
||||
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient,
|
||||
ObjectMapper objectMapper,
|
||||
Core20CacheManager core20CacheManager) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.aisTargetDataProcessor = aisTargetDataProcessor;
|
||||
this.aisTargetDataWriter = aisTargetDataWriter;
|
||||
this.maritimeAisApiWebClient = maritimeAisApiWebClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.core20CacheManager = core20CacheManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJobName() {
|
||||
return "aisTargetImportJob";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getStepName() {
|
||||
return "aisTargetImportStep";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemReader<AisTargetDto> createReader() {
|
||||
return new AisTargetDataReader(maritimeAisApiWebClient, objectMapper, sinceSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemProcessor<AisTargetDto, AisTargetEntity> createProcessor() {
|
||||
return aisTargetDataProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemWriter<AisTargetEntity> createWriter() {
|
||||
return aisTargetDataWriter;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getChunkSize() {
|
||||
return chunkSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void configureJob(JobBuilder jobBuilder) {
|
||||
jobBuilder.listener(new JobExecutionListener() {
|
||||
@Override
|
||||
public void beforeJob(JobExecution jobExecution) {
|
||||
// 배치 수집 시점 설정
|
||||
OffsetDateTime collectedAt = OffsetDateTime.now();
|
||||
aisTargetDataProcessor.setCollectedAt(collectedAt);
|
||||
log.info("[{}] Job 시작 (API → 캐시) - 수집 시점: {}", getJobName(), collectedAt);
|
||||
|
||||
// Core20 캐시 관리
|
||||
// 1. 캐시가 비어있으면 즉시 로딩 (첫 실행 또는 재시작 시)
|
||||
// 2. 지정된 시간대(기본 04:00)이면 일일 갱신 수행
|
||||
if (!core20CacheManager.isLoaded()) {
|
||||
log.info("[{}] Core20 캐시 초기 로딩 시작", getJobName());
|
||||
core20CacheManager.refresh();
|
||||
} else if (core20CacheManager.shouldRefresh()) {
|
||||
log.info("[{}] Core20 캐시 일일 갱신 시작 (스케줄: {}시)",
|
||||
getJobName(), core20CacheManager.getLastRefreshTime());
|
||||
core20CacheManager.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterJob(JobExecution jobExecution) {
|
||||
log.info("[{}] Job 완료 (API → 캐시) - 상태: {}, 캐시 적재 건수: {}, Core20 캐시 크기: {}",
|
||||
getJobName(),
|
||||
jobExecution.getStatus(),
|
||||
jobExecution.getStepExecutions().stream()
|
||||
.mapToLong(se -> se.getWriteCount())
|
||||
.sum(),
|
||||
core20CacheManager.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Bean(name = "aisTargetImportJob")
|
||||
public Job aisTargetImportJob() {
|
||||
return job();
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AIS GetTargets API 응답 래퍼
|
||||
*
|
||||
* API 응답 구조:
|
||||
* {
|
||||
* "targetArr": [...]
|
||||
* }
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AisTargetApiResponse {
|
||||
|
||||
@JsonProperty("targetEnhancedArr")
|
||||
private List<AisTargetDto> targetArr;
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* AIS Target API 응답 DTO
|
||||
*
|
||||
* API: POST /AisSvc.svc/AIS/GetTargets
|
||||
* Request: {"sinceSeconds": "60"}
|
||||
* Response: {"targetArr": [...]}
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AisTargetDto {
|
||||
|
||||
@JsonProperty("MMSI")
|
||||
private Long mmsi;
|
||||
|
||||
@JsonProperty("IMO")
|
||||
private Long imo;
|
||||
|
||||
@JsonProperty("AgeMinutes")
|
||||
private Double ageMinutes;
|
||||
|
||||
@JsonProperty("Lat")
|
||||
private Double lat;
|
||||
|
||||
@JsonProperty("Lon")
|
||||
private Double lon;
|
||||
|
||||
@JsonProperty("Heading")
|
||||
private Double heading;
|
||||
|
||||
@JsonProperty("SoG")
|
||||
private Double sog; // Speed over Ground
|
||||
|
||||
@JsonProperty("CoG")
|
||||
private Double cog; // Course over Ground (if available)
|
||||
|
||||
@JsonProperty("Width")
|
||||
private Integer width;
|
||||
|
||||
@JsonProperty("Length")
|
||||
private Integer length;
|
||||
|
||||
@JsonProperty("Draught")
|
||||
private Double draught;
|
||||
|
||||
@JsonProperty("Name")
|
||||
private String name;
|
||||
|
||||
@JsonProperty("Callsign")
|
||||
private String callsign;
|
||||
|
||||
@JsonProperty("Destination")
|
||||
private String destination;
|
||||
|
||||
@JsonProperty("ETA")
|
||||
private String eta;
|
||||
|
||||
@JsonProperty("Status")
|
||||
private String status;
|
||||
|
||||
@JsonProperty("VesselType")
|
||||
private String vesselType;
|
||||
|
||||
@JsonProperty("ExtraInfo")
|
||||
private String extraInfo;
|
||||
|
||||
@JsonProperty("PositionAccuracy")
|
||||
private Integer positionAccuracy;
|
||||
|
||||
@JsonProperty("RoT")
|
||||
private Integer rot; // Rate of Turn
|
||||
|
||||
@JsonProperty("TimestampUTC")
|
||||
private Integer timestampUtc;
|
||||
|
||||
@JsonProperty("RepeatIndicator")
|
||||
private Integer repeatIndicator;
|
||||
|
||||
@JsonProperty("RAIMFlag")
|
||||
private Integer raimFlag;
|
||||
|
||||
@JsonProperty("RadioStatus")
|
||||
private Integer radioStatus;
|
||||
|
||||
@JsonProperty("Regional")
|
||||
private Integer regional;
|
||||
|
||||
@JsonProperty("Regional2")
|
||||
private Integer regional2;
|
||||
|
||||
@JsonProperty("Spare")
|
||||
private Integer spare;
|
||||
|
||||
@JsonProperty("Spare2")
|
||||
private Integer spare2;
|
||||
|
||||
@JsonProperty("AISVersion")
|
||||
private Integer aisVersion;
|
||||
|
||||
@JsonProperty("PositionFixType")
|
||||
private Integer positionFixType;
|
||||
|
||||
@JsonProperty("DTE")
|
||||
private Integer dte;
|
||||
|
||||
@JsonProperty("BandFlag")
|
||||
private Integer bandFlag;
|
||||
|
||||
@JsonProperty("ReceivedDate")
|
||||
private String receivedDate;
|
||||
|
||||
@JsonProperty("MessageTimestamp")
|
||||
private String messageTimestamp;
|
||||
|
||||
@JsonProperty("LengthBow")
|
||||
private Integer lengthBow;
|
||||
|
||||
@JsonProperty("LengthStern")
|
||||
private Integer lengthStern;
|
||||
|
||||
@JsonProperty("WidthPort")
|
||||
private Integer widthPort;
|
||||
|
||||
@JsonProperty("WidthStarboard")
|
||||
private Integer widthStarboard;
|
||||
|
||||
// TargetEnhanced 컬럼 추가
|
||||
@JsonProperty("TonnesCargo")
|
||||
private Integer tonnesCargo;
|
||||
@JsonProperty("InSTS")
|
||||
private Integer inSTS;
|
||||
@JsonProperty("OnBerth")
|
||||
private Boolean onBerth;
|
||||
@JsonProperty("DWT")
|
||||
private Integer dwt;
|
||||
@JsonProperty("Anomalous")
|
||||
private String anomalous;
|
||||
@JsonProperty("DestinationPortID")
|
||||
private Integer destinationPortID;
|
||||
@JsonProperty("DestinationTidied")
|
||||
private String destinationTidied;
|
||||
@JsonProperty("DestinationUNLOCODE")
|
||||
private String destinationUNLOCODE;
|
||||
@JsonProperty("ImoVerified")
|
||||
private String imoVerified;
|
||||
@JsonProperty("LastStaticUpdateReceived")
|
||||
private String lastStaticUpdateReceived;
|
||||
@JsonProperty("LPCCode")
|
||||
private Integer lpcCode;
|
||||
@JsonProperty("MessageType")
|
||||
private Integer messageType;
|
||||
@JsonProperty("Source")
|
||||
private String source;
|
||||
@JsonProperty("StationId")
|
||||
private String stationId;
|
||||
@JsonProperty("ZoneId")
|
||||
private Double zoneId;
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.snp.batch.common.batch.entity.BaseEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* AIS Target Entity
|
||||
*
|
||||
* 테이블: std_snp_data.ais_target
|
||||
* PK: mmsi + message_timestamp (복합키)
|
||||
*
|
||||
* 용도:
|
||||
* - 선박 위치 이력 저장 (항적 분석용)
|
||||
* - 특정 시점/구역 선박 조회
|
||||
* - LineString 항적 생성 기반 데이터
|
||||
*/
|
||||
@Data
|
||||
@SuperBuilder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AisTargetEntity extends BaseEntity {
|
||||
|
||||
// ========== PK (복합키) ==========
|
||||
private Long mmsi;
|
||||
private OffsetDateTime messageTimestamp;
|
||||
|
||||
// ========== 선박 식별 정보 ==========
|
||||
private Long imo;
|
||||
private String name;
|
||||
private String callsign;
|
||||
private String vesselType;
|
||||
private String extraInfo;
|
||||
|
||||
// ========== 위치 정보 ==========
|
||||
private Double lat;
|
||||
private Double lon;
|
||||
// geom은 DB에서 ST_SetSRID(ST_MakePoint(lon, lat), 4326)로 생성
|
||||
|
||||
// ========== 항해 정보 ==========
|
||||
private Double heading;
|
||||
private Double sog; // Speed over Ground
|
||||
private Double cog; // Course over Ground
|
||||
private Integer rot; // Rate of Turn
|
||||
|
||||
// ========== 선박 제원 ==========
|
||||
private Integer length;
|
||||
private Integer width;
|
||||
private Double draught;
|
||||
private Integer lengthBow;
|
||||
private Integer lengthStern;
|
||||
private Integer widthPort;
|
||||
private Integer widthStarboard;
|
||||
|
||||
// ========== 목적지 정보 ==========
|
||||
private String destination;
|
||||
private OffsetDateTime eta;
|
||||
private String status;
|
||||
|
||||
// ========== AIS 메시지 정보 ==========
|
||||
private Double ageMinutes;
|
||||
private Integer positionAccuracy;
|
||||
private Integer timestampUtc;
|
||||
private Integer repeatIndicator;
|
||||
private Integer raimFlag;
|
||||
private Integer radioStatus;
|
||||
private Integer regional;
|
||||
private Integer regional2;
|
||||
private Integer spare;
|
||||
private Integer spare2;
|
||||
private Integer aisVersion;
|
||||
private Integer positionFixType;
|
||||
private Integer dte;
|
||||
private Integer bandFlag;
|
||||
|
||||
// ========== 타임스탬프 ==========
|
||||
private OffsetDateTime receivedDate;
|
||||
private OffsetDateTime collectedAt; // 배치 수집 시점
|
||||
|
||||
// ========== 선종 분류 정보 ==========
|
||||
/**
|
||||
* MDA 범례코드 (signalKindCode)
|
||||
* - vesselType + extraInfo 기반으로 치환
|
||||
* - 예: "000020"(어선), "000023"(카고), "000027"(일반/기타)
|
||||
*/
|
||||
private String signalKindCode;
|
||||
|
||||
// ========== ClassType 분류 정보 ==========
|
||||
/**
|
||||
* 선박 클래스 타입
|
||||
* - "A": Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
* - "B": Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
* - null: 미분류 (캐시 저장 전)
|
||||
*/
|
||||
private String classType;
|
||||
|
||||
/**
|
||||
* Core20 테이블의 MMSI 값
|
||||
* - Class A인 경우에만 값이 있을 수 있음
|
||||
* - Class A이지만 Core20에 MMSI가 없는 경우 null
|
||||
* - Class B인 경우 항상 null
|
||||
*/
|
||||
private String core20Mmsi;
|
||||
|
||||
// TargetEnhanced 컬럼 추가
|
||||
private Integer tonnesCargo;
|
||||
private Integer inSTS;
|
||||
private Boolean onBerth;
|
||||
private Integer dwt;
|
||||
private String anomalous;
|
||||
private Integer destinationPortID;
|
||||
private String destinationTidied;
|
||||
private String destinationUNLOCODE;
|
||||
private String imoVerified;
|
||||
private OffsetDateTime lastStaticUpdateReceived;
|
||||
private Integer lpcCode;
|
||||
private Integer messageType;
|
||||
private String source;
|
||||
private String stationId;
|
||||
private Double zoneId;
|
||||
|
||||
}
|
||||
@ -1,137 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.processor;
|
||||
|
||||
import com.snp.batch.common.batch.processor.BaseProcessor;
|
||||
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* AIS Target 데이터 Processor
|
||||
*
|
||||
* DTO → Entity 변환
|
||||
* - 타임스탬프 파싱
|
||||
* - 필터링 (유효한 위치 정보만)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisTargetDataProcessor extends BaseProcessor<AisTargetDto, AisTargetEntity> {
|
||||
|
||||
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_DATE_TIME;
|
||||
|
||||
// 배치 수집 시점 (모든 레코드에 동일하게 적용)
|
||||
private OffsetDateTime collectedAt;
|
||||
|
||||
public void setCollectedAt(OffsetDateTime collectedAt) {
|
||||
this.collectedAt = collectedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AisTargetEntity processItem(AisTargetDto dto) throws Exception {
|
||||
// 유효성 검사: MMSI와 위치 정보는 필수
|
||||
if (dto.getMmsi() == null || dto.getLat() == null || dto.getLon() == null) {
|
||||
log.debug("유효하지 않은 데이터 스킵 - MMSI: {}, Lat: {}, Lon: {}",
|
||||
dto.getMmsi(), dto.getLat(), dto.getLon());
|
||||
return null;
|
||||
}
|
||||
|
||||
// MessageTimestamp 파싱 (PK의 일부)
|
||||
OffsetDateTime messageTimestamp = parseTimestamp(dto.getMessageTimestamp());
|
||||
if (messageTimestamp == null) {
|
||||
log.debug("MessageTimestamp 파싱 실패 - MMSI: {}, Timestamp: {}",
|
||||
dto.getMmsi(), dto.getMessageTimestamp());
|
||||
return null;
|
||||
}
|
||||
|
||||
return AisTargetEntity.builder()
|
||||
// PK
|
||||
.mmsi(dto.getMmsi())
|
||||
.messageTimestamp(messageTimestamp)
|
||||
// 선박 식별 정보
|
||||
.imo(dto.getImo())
|
||||
.name(dto.getName())
|
||||
.callsign(dto.getCallsign())
|
||||
.vesselType(dto.getVesselType())
|
||||
.extraInfo(dto.getExtraInfo())
|
||||
// 위치 정보
|
||||
.lat(dto.getLat())
|
||||
.lon(dto.getLon())
|
||||
// 항해 정보
|
||||
.heading(dto.getHeading())
|
||||
.sog(dto.getSog())
|
||||
.cog(dto.getCog())
|
||||
.rot(dto.getRot())
|
||||
// 선박 제원
|
||||
.length(dto.getLength())
|
||||
.width(dto.getWidth())
|
||||
.draught(dto.getDraught())
|
||||
.lengthBow(dto.getLengthBow())
|
||||
.lengthStern(dto.getLengthStern())
|
||||
.widthPort(dto.getWidthPort())
|
||||
.widthStarboard(dto.getWidthStarboard())
|
||||
// 목적지 정보
|
||||
.destination(dto.getDestination())
|
||||
.eta(parseEta(dto.getEta()))
|
||||
.status(dto.getStatus())
|
||||
// AIS 메시지 정보
|
||||
.ageMinutes(dto.getAgeMinutes())
|
||||
.positionAccuracy(dto.getPositionAccuracy())
|
||||
.timestampUtc(dto.getTimestampUtc())
|
||||
.repeatIndicator(dto.getRepeatIndicator())
|
||||
.raimFlag(dto.getRaimFlag())
|
||||
.radioStatus(dto.getRadioStatus())
|
||||
.regional(dto.getRegional())
|
||||
.regional2(dto.getRegional2())
|
||||
.spare(dto.getSpare())
|
||||
.spare2(dto.getSpare2())
|
||||
.aisVersion(dto.getAisVersion())
|
||||
.positionFixType(dto.getPositionFixType())
|
||||
.dte(dto.getDte())
|
||||
.bandFlag(dto.getBandFlag())
|
||||
// 타임스탬프
|
||||
.receivedDate(parseTimestamp(dto.getReceivedDate()))
|
||||
.collectedAt(collectedAt != null ? collectedAt : OffsetDateTime.now())
|
||||
// TargetEnhanced 컬럼 추가
|
||||
.tonnesCargo(dto.getTonnesCargo())
|
||||
.inSTS(dto.getInSTS())
|
||||
.onBerth(dto.getOnBerth())
|
||||
.dwt(dto.getDwt())
|
||||
.anomalous(dto.getAnomalous())
|
||||
.destinationPortID(dto.getDestinationPortID())
|
||||
.destinationTidied(dto.getDestinationTidied())
|
||||
.destinationUNLOCODE(dto.getDestinationUNLOCODE())
|
||||
.imoVerified(dto.getImoVerified())
|
||||
.lastStaticUpdateReceived(parseTimestamp(dto.getLastStaticUpdateReceived()))
|
||||
.lpcCode(dto.getLpcCode())
|
||||
.messageType(dto.getMessageType())
|
||||
.source(dto.getSource())
|
||||
.stationId(dto.getStationId())
|
||||
.zoneId(dto.getZoneId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private OffsetDateTime parseTimestamp(String timestamp) {
|
||||
if (timestamp == null || timestamp.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// ISO 8601 형식 파싱 (예: "2025-12-01T23:55:01.073Z")
|
||||
return OffsetDateTime.parse(timestamp, ISO_FORMATTER);
|
||||
} catch (DateTimeParseException e) {
|
||||
log.trace("타임스탬프 파싱 실패: {}", timestamp);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private OffsetDateTime parseEta(String eta) {
|
||||
if (eta == null || eta.isEmpty() || "9999-12-31T23:59:59Z".equals(eta)) {
|
||||
return null; // 유효하지 않은 ETA는 null 처리
|
||||
}
|
||||
return parseTimestamp(eta);
|
||||
}
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.reader;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.snp.batch.common.batch.reader.BaseApiReader;
|
||||
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetApiResponse;
|
||||
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AIS Target 데이터 Reader
|
||||
*
|
||||
* API: POST /AisSvc.svc/AIS/GetTargetsEnhanced
|
||||
* Request: {"sinceSeconds": "60"}
|
||||
*
|
||||
* 동작:
|
||||
* - 매 분 15초에 실행 (Quartz 스케줄)
|
||||
* - 최근 60초 동안의 전체 선박 위치 정보 조회
|
||||
* - 약 33,000건/분 처리
|
||||
* - 대용량 응답을 임시 파일로 스트리밍하여 메모리 버퍼 제한 우회
|
||||
*/
|
||||
@Slf4j
|
||||
public class AisTargetDataReader extends BaseApiReader<AisTargetDto> {
|
||||
|
||||
private final int sinceSeconds;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AisTargetDataReader(WebClient webClient, ObjectMapper objectMapper, int sinceSeconds) {
|
||||
super(webClient);
|
||||
this.objectMapper = objectMapper;
|
||||
this.sinceSeconds = sinceSeconds;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getReaderName() {
|
||||
return "AisTargetDataReader";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getApiPath() {
|
||||
return "/AisSvc.svc/AIS/GetTargetsEnhanced";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getHttpMethod() {
|
||||
return "POST";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object getRequestBody() {
|
||||
return Map.of("sinceSeconds", String.valueOf(sinceSeconds));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> getResponseType() {
|
||||
return AisTargetApiResponse.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void beforeFetch() {
|
||||
log.info("[{}] AIS GetTargets API 호출 준비 - sinceSeconds: {}", getReaderName(), sinceSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<AisTargetDto> fetchDataFromApi() {
|
||||
Path tempFile = null;
|
||||
try {
|
||||
log.info("[{}] API 호출 시작 (스트리밍 모드): {} {}", getReaderName(), getHttpMethod(), getApiPath());
|
||||
|
||||
tempFile = Files.createTempFile("ais-response-", ".json");
|
||||
|
||||
// 응답을 DataBuffer 스트림으로 받아 임시 파일에 기록
|
||||
long bytesWritten = streamResponseToFile(tempFile);
|
||||
log.info("[{}] 응답 스트리밍 완료: {} bytes → {}", getReaderName(), bytesWritten, tempFile.getFileName());
|
||||
|
||||
// 임시 파일에서 JSON 파싱
|
||||
AisTargetApiResponse response = parseResponseFromFile(tempFile);
|
||||
|
||||
if (response != null && response.getTargetArr() != null) {
|
||||
List<AisTargetDto> targets = response.getTargetArr();
|
||||
log.info("[{}] API 호출 완료: {} 건 조회", getReaderName(), targets.size());
|
||||
updateApiCallStats(1, 1);
|
||||
return targets;
|
||||
} else {
|
||||
log.warn("[{}] API 응답이 비어있습니다", getReaderName());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[{}] API 호출 실패: {}", getReaderName(), e.getMessage(), e);
|
||||
return handleApiError(e);
|
||||
} finally {
|
||||
deleteTempFile(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebClient 응답을 DataBuffer 스트림으로 받아 임시 파일에 기록한다.
|
||||
* bodyToMono()와 달리 메모리 버퍼 제한(maxInMemorySize)의 영향을 받지 않는다.
|
||||
*/
|
||||
private long streamResponseToFile(Path tempFile) throws IOException {
|
||||
try (OutputStream os = Files.newOutputStream(tempFile, StandardOpenOption.WRITE)) {
|
||||
webClient.post()
|
||||
.uri(getApiPath())
|
||||
.bodyValue(getRequestBody())
|
||||
.retrieve()
|
||||
.bodyToFlux(DataBuffer.class)
|
||||
.doOnNext(dataBuffer -> {
|
||||
try {
|
||||
byte[] bytes = new byte[dataBuffer.readableByteCount()];
|
||||
dataBuffer.read(bytes);
|
||||
os.write(bytes);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("임시 파일 쓰기 실패", e);
|
||||
} finally {
|
||||
DataBufferUtils.release(dataBuffer);
|
||||
}
|
||||
})
|
||||
.blockLast();
|
||||
}
|
||||
return Files.size(tempFile);
|
||||
}
|
||||
|
||||
private AisTargetApiResponse parseResponseFromFile(Path tempFile) throws IOException {
|
||||
try (InputStream is = Files.newInputStream(tempFile)) {
|
||||
return objectMapper.readValue(is, AisTargetApiResponse.class);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteTempFile(Path tempFile) {
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
log.warn("[{}] 임시 파일 삭제 실패: {}", getReaderName(), tempFile, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterFetch(List<AisTargetDto> data) {
|
||||
if (data != null && !data.isEmpty()) {
|
||||
log.info("[{}] 데이터 조회 완료 - 총 {} 건", getReaderName(), data.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* AIS Target Repository 인터페이스
|
||||
*/
|
||||
public interface AisTargetRepository {
|
||||
|
||||
/**
|
||||
* 복합키로 조회 (MMSI + MessageTimestamp)
|
||||
*/
|
||||
Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp);
|
||||
|
||||
/**
|
||||
* MMSI로 최신 위치 조회
|
||||
*/
|
||||
Optional<AisTargetEntity> findLatestByMmsi(Long mmsi);
|
||||
|
||||
/**
|
||||
* 여러 MMSI의 최신 위치 조회
|
||||
*/
|
||||
List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList);
|
||||
|
||||
/**
|
||||
* 여러 MMSI의 최신 위치 조회 (시간 범위 필터)
|
||||
*
|
||||
* @param mmsiList 대상 MMSI 목록
|
||||
* @param since 이 시점 이후 데이터만 조회
|
||||
*/
|
||||
List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since);
|
||||
|
||||
/**
|
||||
* 시간 범위 내 특정 MMSI의 항적 조회
|
||||
*/
|
||||
List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end);
|
||||
|
||||
/**
|
||||
* 시간 범위 + 공간 범위 내 선박 조회
|
||||
*/
|
||||
List<AisTargetEntity> findByTimeRangeAndArea(
|
||||
OffsetDateTime start,
|
||||
OffsetDateTime end,
|
||||
Double centerLon,
|
||||
Double centerLat,
|
||||
Double radiusMeters
|
||||
);
|
||||
|
||||
/**
|
||||
* 배치 INSERT (UPSERT)
|
||||
*/
|
||||
void batchUpsert(List<AisTargetEntity> entities);
|
||||
|
||||
/**
|
||||
* 전체 건수 조회
|
||||
*/
|
||||
long count();
|
||||
|
||||
/**
|
||||
* 오래된 데이터 삭제 (보존 기간 이전 데이터)
|
||||
*/
|
||||
int deleteOlderThan(OffsetDateTime threshold);
|
||||
}
|
||||
@ -1,409 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* AIS Target Repository 구현체
|
||||
*
|
||||
* 테이블: {targetSchema}.ais_target
|
||||
* PK: mmsi + message_timestamp (복합키)
|
||||
*/
|
||||
@Slf4j
|
||||
@Repository
|
||||
public class AisTargetRepositoryImpl implements AisTargetRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final String tableName;
|
||||
private final String upsertSql;
|
||||
|
||||
public AisTargetRepositoryImpl(JdbcTemplate jdbcTemplate,
|
||||
@Value("${app.batch.target-schema.name}") String targetSchema) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.tableName = targetSchema + ".ais_target";
|
||||
this.upsertSql = buildUpsertSql(targetSchema);
|
||||
}
|
||||
|
||||
private String buildUpsertSql(String schema) {
|
||||
return """
|
||||
INSERT INTO %s.ais_target (
|
||||
mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info,
|
||||
lat, lon, geom,
|
||||
heading, sog, cog, rot,
|
||||
length, width, draught, length_bow, length_stern, width_port, width_starboard,
|
||||
destination, eta, status,
|
||||
age_minutes, position_accuracy, timestamp_utc, repeat_indicator, raim_flag,
|
||||
radio_status, regional, regional2, spare, spare2,
|
||||
ais_version, position_fix_type, dte, band_flag,
|
||||
received_date, collected_at, created_at, updated_at,
|
||||
tonnes_cargo, in_sts, on_berth, dwt, anomalous,
|
||||
destination_port_id, destination_tidied, destination_unlocode, imo_verified, last_static_update_received,
|
||||
lpc_code, message_type, "source", station_id, zone_id
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326),
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, NOW(), NOW(),
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT (mmsi, message_timestamp) DO UPDATE SET
|
||||
imo = EXCLUDED.imo,
|
||||
name = EXCLUDED.name,
|
||||
callsign = EXCLUDED.callsign,
|
||||
vessel_type = EXCLUDED.vessel_type,
|
||||
extra_info = EXCLUDED.extra_info,
|
||||
lat = EXCLUDED.lat,
|
||||
lon = EXCLUDED.lon,
|
||||
geom = EXCLUDED.geom,
|
||||
heading = EXCLUDED.heading,
|
||||
sog = EXCLUDED.sog,
|
||||
cog = EXCLUDED.cog,
|
||||
rot = EXCLUDED.rot,
|
||||
length = EXCLUDED.length,
|
||||
width = EXCLUDED.width,
|
||||
draught = EXCLUDED.draught,
|
||||
length_bow = EXCLUDED.length_bow,
|
||||
length_stern = EXCLUDED.length_stern,
|
||||
width_port = EXCLUDED.width_port,
|
||||
width_starboard = EXCLUDED.width_starboard,
|
||||
destination = EXCLUDED.destination,
|
||||
eta = EXCLUDED.eta,
|
||||
status = EXCLUDED.status,
|
||||
age_minutes = EXCLUDED.age_minutes,
|
||||
position_accuracy = EXCLUDED.position_accuracy,
|
||||
timestamp_utc = EXCLUDED.timestamp_utc,
|
||||
repeat_indicator = EXCLUDED.repeat_indicator,
|
||||
raim_flag = EXCLUDED.raim_flag,
|
||||
radio_status = EXCLUDED.radio_status,
|
||||
regional = EXCLUDED.regional,
|
||||
regional2 = EXCLUDED.regional2,
|
||||
spare = EXCLUDED.spare,
|
||||
spare2 = EXCLUDED.spare2,
|
||||
ais_version = EXCLUDED.ais_version,
|
||||
position_fix_type = EXCLUDED.position_fix_type,
|
||||
dte = EXCLUDED.dte,
|
||||
band_flag = EXCLUDED.band_flag,
|
||||
received_date = EXCLUDED.received_date,
|
||||
collected_at = EXCLUDED.collected_at,
|
||||
updated_at = NOW(),
|
||||
tonnes_cargo = EXCLUDED.tonnes_cargo,
|
||||
in_sts = EXCLUDED.in_sts,
|
||||
on_berth = EXCLUDED.on_berth,
|
||||
dwt = EXCLUDED.dwt,
|
||||
anomalous = EXCLUDED.anomalous,
|
||||
destination_port_id = EXCLUDED.destination_port_id,
|
||||
destination_tidied = EXCLUDED.destination_tidied,
|
||||
destination_unlocode = EXCLUDED.destination_unlocode,
|
||||
imo_verified = EXCLUDED.imo_verified,
|
||||
last_static_update_received = EXCLUDED.last_static_update_received,
|
||||
lpc_code = EXCLUDED.lpc_code,
|
||||
message_type = EXCLUDED.message_type,
|
||||
"source" = EXCLUDED."source",
|
||||
station_id = EXCLUDED.station_id,
|
||||
zone_id = EXCLUDED.zone_id
|
||||
""".formatted(schema);
|
||||
}
|
||||
|
||||
|
||||
// ==================== RowMapper ====================
|
||||
|
||||
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
|
||||
.mmsi(rs.getLong("mmsi"))
|
||||
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
|
||||
.imo(toLong(rs, "imo"))
|
||||
.name(rs.getString("name"))
|
||||
.callsign(rs.getString("callsign"))
|
||||
.vesselType(rs.getString("vessel_type"))
|
||||
.extraInfo(rs.getString("extra_info"))
|
||||
.lat(rs.getObject("lat", Double.class))
|
||||
.lon(rs.getObject("lon", Double.class))
|
||||
.heading(rs.getObject("heading", Double.class))
|
||||
.sog(rs.getObject("sog", Double.class))
|
||||
.cog(rs.getObject("cog", Double.class))
|
||||
.rot(toInt(rs, "rot"))
|
||||
.length(toInt(rs, "length"))
|
||||
.width(toInt(rs, "width"))
|
||||
.draught(rs.getObject("draught", Double.class))
|
||||
.lengthBow(toInt(rs, "length_bow"))
|
||||
.lengthStern(toInt(rs, "length_stern"))
|
||||
.widthPort(toInt(rs, "width_port"))
|
||||
.widthStarboard(toInt(rs, "width_starboard"))
|
||||
.destination(rs.getString("destination"))
|
||||
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
|
||||
.status(rs.getString("status"))
|
||||
.ageMinutes(rs.getObject("age_minutes", Double.class))
|
||||
.positionAccuracy(toInt(rs, "position_accuracy"))
|
||||
.timestampUtc(toInt(rs, "timestamp_utc"))
|
||||
.repeatIndicator(toInt(rs, "repeat_indicator"))
|
||||
.raimFlag(toInt(rs, "raim_flag"))
|
||||
.radioStatus(toInt(rs, "radio_status"))
|
||||
.regional(toInt(rs, "regional"))
|
||||
.regional2(toInt(rs, "regional2"))
|
||||
.spare(toInt(rs, "spare"))
|
||||
.spare2(toInt(rs, "spare2"))
|
||||
.aisVersion(toInt(rs, "ais_version"))
|
||||
.positionFixType(toInt(rs, "position_fix_type"))
|
||||
.dte(toInt(rs, "dte"))
|
||||
.bandFlag(toInt(rs, "band_flag"))
|
||||
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
|
||||
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
|
||||
.tonnesCargo(toInt(rs, "tonnes_cargo"))
|
||||
.inSTS(toInt(rs, "in_sts"))
|
||||
.onBerth(rs.getObject("on_berth", Boolean.class))
|
||||
.dwt(toInt(rs, "dwt"))
|
||||
.anomalous(rs.getString("anomalous"))
|
||||
.destinationPortID(toInt(rs, "destination_port_id"))
|
||||
.destinationTidied(rs.getString("destination_tidied"))
|
||||
.destinationUNLOCODE(rs.getString("destination_unlocode"))
|
||||
.imoVerified(rs.getString("imo_verified"))
|
||||
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
|
||||
.lpcCode(toInt(rs, "lpc_code"))
|
||||
.messageType(toInt(rs, "message_type"))
|
||||
.source(rs.getString("source"))
|
||||
.stationId(rs.getString("station_id"))
|
||||
.zoneId(rs.getObject("zone_id", Double.class))
|
||||
.build();
|
||||
|
||||
// ==================== Repository Methods ====================
|
||||
|
||||
@Override
|
||||
public Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) {
|
||||
String sql = "SELECT * FROM " + tableName + " WHERE mmsi = ? AND message_timestamp = ?";
|
||||
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp));
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AisTargetEntity> findLatestByMmsi(Long mmsi) {
|
||||
String sql = """
|
||||
SELECT * FROM %s
|
||||
WHERE mmsi = ?
|
||||
ORDER BY message_timestamp DESC
|
||||
LIMIT 1
|
||||
""".formatted(tableName);
|
||||
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList) {
|
||||
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// DISTINCT ON을 사용하여 각 MMSI별 최신 레코드 조회
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (mmsi) *
|
||||
FROM %s
|
||||
WHERE mmsi = ANY(?)
|
||||
ORDER BY mmsi, message_timestamp DESC
|
||||
""".formatted(tableName);
|
||||
|
||||
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
||||
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since) {
|
||||
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (mmsi) *
|
||||
FROM %s
|
||||
WHERE mmsi = ANY(?)
|
||||
AND message_timestamp >= ?
|
||||
ORDER BY mmsi, message_timestamp DESC
|
||||
""".formatted(tableName);
|
||||
|
||||
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
|
||||
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray, toTimestamp(since));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) {
|
||||
String sql = """
|
||||
SELECT * FROM %s
|
||||
WHERE mmsi = ?
|
||||
AND message_timestamp BETWEEN ? AND ?
|
||||
ORDER BY message_timestamp ASC
|
||||
""".formatted(tableName);
|
||||
return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AisTargetEntity> findByTimeRangeAndArea(
|
||||
OffsetDateTime start,
|
||||
OffsetDateTime end,
|
||||
Double centerLon,
|
||||
Double centerLat,
|
||||
Double radiusMeters
|
||||
) {
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (mmsi) *
|
||||
FROM %s
|
||||
WHERE message_timestamp BETWEEN ? AND ?
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
|
||||
?
|
||||
)
|
||||
ORDER BY mmsi, message_timestamp DESC
|
||||
""".formatted(tableName);
|
||||
|
||||
return jdbcTemplate.query(sql, rowMapper,
|
||||
toTimestamp(start), toTimestamp(end),
|
||||
centerLon, centerLat, radiusMeters);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void batchUpsert(List<AisTargetEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size());
|
||||
|
||||
jdbcTemplate.batchUpdate(upsertSql, entities, 1000, (ps, entity) -> {
|
||||
int idx = 1;
|
||||
// PK
|
||||
ps.setLong(idx++, entity.getMmsi());
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getMessageTimestamp()));
|
||||
// 선박 식별 정보
|
||||
ps.setObject(idx++, entity.getImo());
|
||||
ps.setString(idx++, truncate(entity.getName(), 100));
|
||||
ps.setString(idx++, truncate(entity.getCallsign(), 20));
|
||||
ps.setString(idx++, truncate(entity.getVesselType(), 50));
|
||||
ps.setString(idx++, truncate(entity.getExtraInfo(), 100));
|
||||
// 위치 정보
|
||||
ps.setObject(idx++, entity.getLat());
|
||||
ps.setObject(idx++, entity.getLon());
|
||||
// geom용 lon, lat
|
||||
ps.setObject(idx++, entity.getLon());
|
||||
ps.setObject(idx++, entity.getLat());
|
||||
// 항해 정보
|
||||
ps.setObject(idx++, entity.getHeading());
|
||||
ps.setObject(idx++, entity.getSog());
|
||||
ps.setObject(idx++, entity.getCog());
|
||||
ps.setObject(idx++, entity.getRot());
|
||||
// 선박 제원
|
||||
ps.setObject(idx++, entity.getLength());
|
||||
ps.setObject(idx++, entity.getWidth());
|
||||
ps.setObject(idx++, entity.getDraught());
|
||||
ps.setObject(idx++, entity.getLengthBow());
|
||||
ps.setObject(idx++, entity.getLengthStern());
|
||||
ps.setObject(idx++, entity.getWidthPort());
|
||||
ps.setObject(idx++, entity.getWidthStarboard());
|
||||
// 목적지 정보
|
||||
ps.setString(idx++, truncate(entity.getDestination(), 200));
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getEta()));
|
||||
ps.setString(idx++, truncate(entity.getStatus(), 50));
|
||||
// AIS 메시지 정보
|
||||
ps.setObject(idx++, entity.getAgeMinutes());
|
||||
ps.setObject(idx++, entity.getPositionAccuracy());
|
||||
ps.setObject(idx++, entity.getTimestampUtc());
|
||||
ps.setObject(idx++, entity.getRepeatIndicator());
|
||||
ps.setObject(idx++, entity.getRaimFlag());
|
||||
ps.setObject(idx++, entity.getRadioStatus());
|
||||
ps.setObject(idx++, entity.getRegional());
|
||||
ps.setObject(idx++, entity.getRegional2());
|
||||
ps.setObject(idx++, entity.getSpare());
|
||||
ps.setObject(idx++, entity.getSpare2());
|
||||
ps.setObject(idx++, entity.getAisVersion());
|
||||
ps.setObject(idx++, entity.getPositionFixType());
|
||||
ps.setObject(idx++, entity.getDte());
|
||||
ps.setObject(idx++, entity.getBandFlag());
|
||||
// 타임스탬프
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getReceivedDate()));
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getCollectedAt()));
|
||||
// TargetEnhanced 컬럼 추가
|
||||
ps.setObject(idx++, entity.getTonnesCargo());
|
||||
ps.setObject(idx++, entity.getInSTS());
|
||||
ps.setObject(idx++, entity.getOnBerth());
|
||||
ps.setObject(idx++, entity.getDwt());
|
||||
ps.setObject(idx++, entity.getAnomalous());
|
||||
ps.setObject(idx++, entity.getDestinationPortID());
|
||||
ps.setObject(idx++, entity.getDestinationTidied());
|
||||
ps.setObject(idx++, entity.getDestinationUNLOCODE());
|
||||
ps.setObject(idx++, entity.getImoVerified());
|
||||
ps.setTimestamp(idx++, toTimestamp(entity.getLastStaticUpdateReceived()));
|
||||
ps.setObject(idx++, entity.getLpcCode());
|
||||
ps.setObject(idx++, entity.getMessageType());
|
||||
ps.setObject(idx++, entity.getSource());
|
||||
ps.setObject(idx++, entity.getStationId());
|
||||
ps.setObject(idx++, entity.getZoneId());
|
||||
});
|
||||
|
||||
log.info("AIS Target 배치 UPSERT 완료: {} 건", entities.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
String sql = "SELECT COUNT(*) FROM " + tableName;
|
||||
Long count = jdbcTemplate.queryForObject(sql, Long.class);
|
||||
return count != null ? count : 0L;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int deleteOlderThan(OffsetDateTime threshold) {
|
||||
String sql = "DELETE FROM " + tableName + " WHERE message_timestamp < ?";
|
||||
int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
|
||||
log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* int8(bigint) → Integer 안전 변환
|
||||
* PostgreSQL JDBC 드라이버는 int8 → Integer 자동 변환을 지원하지 않아
|
||||
* getObject("col", Integer.class) 사용 시 오류 발생. Number로 읽어서 변환.
|
||||
*/
|
||||
private Integer toInt(ResultSet rs, String column) throws SQLException {
|
||||
Object val = rs.getObject(column);
|
||||
if (val == null) return null;
|
||||
return ((Number) val).intValue();
|
||||
}
|
||||
|
||||
private Long toLong(ResultSet rs, String column) throws SQLException {
|
||||
Object val = rs.getObject(column);
|
||||
if (val == null) return null;
|
||||
return ((Number) val).longValue();
|
||||
}
|
||||
|
||||
private Timestamp toTimestamp(OffsetDateTime odt) {
|
||||
return odt != null ? Timestamp.from(odt.toInstant()) : null;
|
||||
}
|
||||
|
||||
private OffsetDateTime toOffsetDateTime(Timestamp ts) {
|
||||
return ts != null ? ts.toInstant().atOffset(ZoneOffset.UTC) : null;
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null) return null;
|
||||
return value.length() > maxLength ? value.substring(0, maxLength) : value;
|
||||
}
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.batch.writer;
|
||||
|
||||
import com.snp.batch.common.batch.writer.BaseWriter;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
|
||||
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
|
||||
import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AIS Target 데이터 Writer (캐시 전용)
|
||||
*
|
||||
* 동작:
|
||||
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
|
||||
* 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
|
||||
* 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함)
|
||||
* 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
|
||||
* 5. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만)
|
||||
*
|
||||
* 참고:
|
||||
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
|
||||
* - Kafka 전송 실패는 기본적으로 로그만 남기고 다음 처리 계속
|
||||
* - Kafka가 비활성화(enabled=false)이면 kafkaProducer가 null이므로 전송 단계를 스킵
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
|
||||
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final AisClassTypeClassifier classTypeClassifier;
|
||||
@Nullable
|
||||
private final AisTargetKafkaProducer kafkaProducer;
|
||||
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
||||
|
||||
public AisTargetDataWriter(
|
||||
AisTargetCacheManager cacheManager,
|
||||
AisClassTypeClassifier classTypeClassifier,
|
||||
@Nullable AisTargetKafkaProducer kafkaProducer,
|
||||
ChnPrmShipCacheManager chnPrmShipCacheManager) {
|
||||
super("AisTarget");
|
||||
this.cacheManager = cacheManager;
|
||||
this.classTypeClassifier = classTypeClassifier;
|
||||
this.kafkaProducer = kafkaProducer;
|
||||
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeItems(List<AisTargetEntity> items) throws Exception {
|
||||
log.info("AIS Target 캐시 업데이트 시작: {} 건", items.size());
|
||||
|
||||
// 1. ClassType 분류 (캐시 저장 전에 분류)
|
||||
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
|
||||
classTypeClassifier.classifyAll(items);
|
||||
|
||||
// 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드)
|
||||
items.forEach(item -> {
|
||||
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo());
|
||||
item.setSignalKindCode(kindCode.getCode());
|
||||
});
|
||||
|
||||
// 3. 캐시 업데이트 (classType, core20Mmsi, signalKindCode 포함)
|
||||
cacheManager.putAll(items);
|
||||
|
||||
log.info("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
|
||||
items.size(), cacheManager.size());
|
||||
|
||||
// 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
|
||||
chnPrmShipCacheManager.putIfTarget(items);
|
||||
|
||||
// 5. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만)
|
||||
if (kafkaProducer == null) {
|
||||
log.debug("AIS Kafka Producer 미등록 - topic 전송 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
AisTargetKafkaProducer.PublishSummary summary = kafkaProducer.publish(items);
|
||||
log.info("AIS Kafka 전송 완료 - topic: {}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
|
||||
kafkaProducer.getTopic(),
|
||||
summary.getRequestedCount(),
|
||||
summary.getSuccessCount(),
|
||||
summary.getFailedCount(),
|
||||
summary.getSkippedCount());
|
||||
}
|
||||
}
|
||||
@ -1,272 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.cache;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.github.benmanes.caffeine.cache.RemovalCause;
|
||||
import com.github.benmanes.caffeine.cache.stats.CacheStats;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* AIS Target 캐시 매니저 (Caffeine 기반)
|
||||
*
|
||||
* Caffeine 캐시의 장점:
|
||||
* - 고성능: ConcurrentHashMap 대비 우수한 성능
|
||||
* - 자동 만료: expireAfterWrite/expireAfterAccess 내장
|
||||
* - 최대 크기 제한: maximumSize + LRU/LFU 자동 정리
|
||||
* - 통계: 히트율, 미스율, 로드 시간 등 상세 통계
|
||||
* - 비동기 지원: AsyncCache로 비동기 로딩 가능
|
||||
*
|
||||
* 동작:
|
||||
* - 배치 Writer에서 DB 저장과 동시에 캐시 업데이트
|
||||
* - API 조회 시 캐시 우선 조회
|
||||
* - 캐시 미스 시 DB 조회 후 캐시 갱신
|
||||
* - TTL: 마지막 쓰기 이후 N분 뒤 자동 만료
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisTargetCacheManager {
|
||||
|
||||
private Cache<Long, AisTargetEntity> cache;
|
||||
|
||||
@Value("${app.batch.ais-target-cache.ttl-minutes:5}")
|
||||
private long ttlMinutes;
|
||||
|
||||
@Value("${app.batch.ais-target-cache.max-size:100000}")
|
||||
private int maxSize;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.cache = Caffeine.newBuilder()
|
||||
// 최대 캐시 크기 (초과 시 LRU 방식으로 정리)
|
||||
.maximumSize(maxSize)
|
||||
// 마지막 쓰기 이후 TTL (데이터 업데이트 시 자동 갱신)
|
||||
.expireAfterWrite(ttlMinutes, TimeUnit.MINUTES)
|
||||
// 통계 수집 활성화
|
||||
.recordStats()
|
||||
// 제거 리스너 (디버깅/모니터링용)
|
||||
.removalListener((Long key, AisTargetEntity value, RemovalCause cause) -> {
|
||||
if (cause != RemovalCause.REPLACED) {
|
||||
log.trace("캐시 제거 - MMSI: {}, 원인: {}", key, cause);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
log.info("AIS Target Caffeine 캐시 초기화 - TTL: {}분, 최대 크기: {}", ttlMinutes, maxSize);
|
||||
}
|
||||
|
||||
// ==================== 단건 조회/업데이트 ====================
|
||||
|
||||
/**
|
||||
* 캐시에서 최신 위치 조회
|
||||
*
|
||||
* @param mmsi MMSI 번호
|
||||
* @return 캐시된 데이터 (없으면 Optional.empty)
|
||||
*/
|
||||
public Optional<AisTargetEntity> get(Long mmsi) {
|
||||
AisTargetEntity entity = cache.getIfPresent(mmsi);
|
||||
return Optional.ofNullable(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에 데이터 저장/업데이트
|
||||
* - 기존 데이터보다 최신인 경우에만 업데이트
|
||||
* - 업데이트 시 TTL 자동 갱신 (expireAfterWrite)
|
||||
*
|
||||
* @param entity AIS Target 엔티티
|
||||
*/
|
||||
public void put(AisTargetEntity entity) {
|
||||
if (entity == null || entity.getMmsi() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long mmsi = entity.getMmsi();
|
||||
AisTargetEntity existing = cache.getIfPresent(mmsi);
|
||||
|
||||
// 기존 데이터보다 최신인 경우에만 업데이트
|
||||
if (existing == null || isNewer(entity, existing)) {
|
||||
cache.put(mmsi, entity);
|
||||
log.trace("캐시 저장 - MMSI: {}", mmsi);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 배치 조회/업데이트 ====================
|
||||
|
||||
/**
|
||||
* 여러 MMSI의 최신 위치 조회
|
||||
*
|
||||
* @param mmsiList MMSI 목록
|
||||
* @return 캐시에서 찾은 데이터 맵 (MMSI -> Entity)
|
||||
*/
|
||||
public Map<Long, AisTargetEntity> getAll(List<Long> mmsiList) {
|
||||
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
// Caffeine의 getAllPresent는 존재하는 키만 반환
|
||||
Map<Long, AisTargetEntity> result = cache.getAllPresent(mmsiList);
|
||||
|
||||
log.debug("캐시 배치 조회 - 요청: {}, 히트: {}",
|
||||
mmsiList.size(), result.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 데이터 일괄 저장/업데이트 (배치 Writer에서 호출)
|
||||
*
|
||||
* @param entities AIS Target 엔티티 목록
|
||||
*/
|
||||
public void putAll(List<AisTargetEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
int skipped = 0;
|
||||
|
||||
for (AisTargetEntity entity : entities) {
|
||||
if (entity == null || entity.getMmsi() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Long mmsi = entity.getMmsi();
|
||||
AisTargetEntity existing = cache.getIfPresent(mmsi);
|
||||
|
||||
// 기존 데이터보다 최신인 경우에만 업데이트
|
||||
if (existing == null || isNewer(entity, existing)) {
|
||||
cache.put(mmsi, entity);
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("캐시 배치 업데이트 - 입력: {}, 업데이트: {}, 스킵: {}, 현재 크기: {}",
|
||||
entities.size(), updated, skipped, cache.estimatedSize());
|
||||
}
|
||||
|
||||
// ==================== 캐시 관리 ====================
|
||||
|
||||
/**
|
||||
* 특정 MMSI 캐시 삭제
|
||||
*/
|
||||
public void evict(Long mmsi) {
|
||||
cache.invalidate(mmsi);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 MMSI 캐시 삭제
|
||||
*/
|
||||
public void evictAll(List<Long> mmsiList) {
|
||||
cache.invalidateAll(mmsiList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 캐시 삭제
|
||||
*/
|
||||
public void clear() {
|
||||
long size = cache.estimatedSize();
|
||||
cache.invalidateAll();
|
||||
log.info("캐시 전체 삭제 - {} 건", size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 캐시 크기 (추정값)
|
||||
*/
|
||||
public long size() {
|
||||
return cache.estimatedSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 정리 (만료된 엔트리 즉시 제거)
|
||||
*/
|
||||
public void cleanup() {
|
||||
cache.cleanUp();
|
||||
}
|
||||
|
||||
// ==================== 통계 ====================
|
||||
|
||||
/**
|
||||
* 캐시 통계 조회
|
||||
*/
|
||||
public Map<String, Object> getStats() {
|
||||
CacheStats stats = cache.stats();
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("estimatedSize", cache.estimatedSize());
|
||||
result.put("maxSize", maxSize);
|
||||
result.put("ttlMinutes", ttlMinutes);
|
||||
result.put("hitCount", stats.hitCount());
|
||||
result.put("missCount", stats.missCount());
|
||||
result.put("hitRate", String.format("%.2f%%", stats.hitRate() * 100));
|
||||
result.put("missRate", String.format("%.2f%%", stats.missRate() * 100));
|
||||
result.put("evictionCount", stats.evictionCount());
|
||||
result.put("loadCount", stats.loadCount());
|
||||
result.put("averageLoadPenalty", String.format("%.2fms", stats.averageLoadPenalty() / 1_000_000.0));
|
||||
result.put("utilizationPercent", String.format("%.2f%%", (cache.estimatedSize() * 100.0 / maxSize)));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 통계 조회 (Caffeine CacheStats 원본)
|
||||
*/
|
||||
public CacheStats getCacheStats() {
|
||||
return cache.stats();
|
||||
}
|
||||
|
||||
// ==================== 전체 데이터 조회 (공간 필터링용) ====================
|
||||
|
||||
/**
|
||||
* 캐시의 모든 데이터 조회 (공간 필터링용)
|
||||
* 주의: 대용량 데이터이므로 신중하게 사용
|
||||
*
|
||||
* @return 캐시된 모든 엔티티
|
||||
*/
|
||||
public Collection<AisTargetEntity> getAllValues() {
|
||||
return cache.asMap().values();
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 범위 내 데이터 필터링
|
||||
*
|
||||
* @param minutes 최근 N분
|
||||
* @return 시간 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> getByTimeRange(int minutes) {
|
||||
java.time.OffsetDateTime threshold = java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
|
||||
.minusMinutes(minutes);
|
||||
|
||||
return cache.asMap().values().stream()
|
||||
.filter(entity -> entity.getMessageTimestamp() != null)
|
||||
.filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
/**
|
||||
* 새 데이터가 기존 데이터보다 최신인지 확인
|
||||
*/
|
||||
private boolean isNewer(AisTargetEntity newEntity, AisTargetEntity existing) {
|
||||
OffsetDateTime newTimestamp = newEntity.getMessageTimestamp();
|
||||
OffsetDateTime existingTimestamp = existing.getMessageTimestamp();
|
||||
|
||||
if (newTimestamp == null) {
|
||||
return false;
|
||||
}
|
||||
if (existingTimestamp == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return newTimestamp.isAfter(existingTimestamp);
|
||||
}
|
||||
}
|
||||
@ -1,229 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.cache;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.NumericCondition;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AIS Target 필터링 유틸리티
|
||||
*
|
||||
* 캐시 데이터에 대한 조건 필터링 수행
|
||||
* - SOG, COG, Heading: 숫자 범위 조건
|
||||
* - Destination: 문자열 부분 일치
|
||||
* - Status: 다중 선택 일치
|
||||
* - ClassType: 선박 클래스 타입 (A/B)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisTargetFilterUtil {
|
||||
|
||||
/**
|
||||
* 필터 조건에 따라 엔티티 목록 필터링
|
||||
*
|
||||
* @param entities 원본 엔티티 목록
|
||||
* @param request 필터 조건
|
||||
* @return 필터링된 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filter(List<AisTargetEntity> entities, AisTargetFilterRequest request) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (!request.hasAnyFilter()) {
|
||||
return entities;
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<AisTargetEntity> result = entities.parallelStream()
|
||||
.filter(entity -> matchesSog(entity, request))
|
||||
.filter(entity -> matchesCog(entity, request))
|
||||
.filter(entity -> matchesHeading(entity, request))
|
||||
.filter(entity -> matchesDestination(entity, request))
|
||||
.filter(entity -> matchesStatus(entity, request))
|
||||
.filter(entity -> matchesClassType(entity, request))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.debug("필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms",
|
||||
entities.size(), result.size(), elapsed);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* AisTargetSearchRequest 기반 ClassType 필터링
|
||||
*
|
||||
* @param entities 원본 엔티티 목록
|
||||
* @param request 검색 조건
|
||||
* @return 필터링된 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByClassType(List<AisTargetEntity> entities, AisTargetSearchRequest request) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (!request.hasClassTypeFilter()) {
|
||||
return entities;
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<AisTargetEntity> result = entities.parallelStream()
|
||||
.filter(entity -> matchesClassType(entity, request.getClassType()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.debug("ClassType 필터링 완료 - 입력: {}, 결과: {}, 필터: {}, 소요: {}ms",
|
||||
entities.size(), result.size(), request.getClassType(), elapsed);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 classType으로 직접 필터링
|
||||
*/
|
||||
private boolean matchesClassType(AisTargetEntity entity, String classTypeFilter) {
|
||||
if (classTypeFilter == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityClassType = entity.getClassType();
|
||||
|
||||
// classType이 미분류(null)인 데이터 처리
|
||||
if (entityClassType == null) {
|
||||
// B 필터인 경우 미분류 데이터도 포함 (보수적 접근)
|
||||
return "B".equalsIgnoreCase(classTypeFilter);
|
||||
}
|
||||
|
||||
return classTypeFilter.equalsIgnoreCase(entityClassType);
|
||||
}
|
||||
|
||||
/**
|
||||
* SOG (속도) 조건 매칭
|
||||
*/
|
||||
private boolean matchesSog(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasSogFilter()) {
|
||||
return true; // 필터 없으면 통과
|
||||
}
|
||||
|
||||
NumericCondition condition = NumericCondition.fromString(request.getSogCondition());
|
||||
if (condition == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return condition.matches(
|
||||
entity.getSog(),
|
||||
request.getSogValue(),
|
||||
request.getSogMin(),
|
||||
request.getSogMax()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* COG (침로) 조건 매칭
|
||||
*/
|
||||
private boolean matchesCog(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasCogFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
NumericCondition condition = NumericCondition.fromString(request.getCogCondition());
|
||||
if (condition == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return condition.matches(
|
||||
entity.getCog(),
|
||||
request.getCogValue(),
|
||||
request.getCogMin(),
|
||||
request.getCogMax()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading (선수방위) 조건 매칭
|
||||
*/
|
||||
private boolean matchesHeading(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasHeadingFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
NumericCondition condition = NumericCondition.fromString(request.getHeadingCondition());
|
||||
if (condition == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return condition.matches(
|
||||
entity.getHeading(),
|
||||
request.getHeadingValue(),
|
||||
request.getHeadingMin(),
|
||||
request.getHeadingMax()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destination (목적지) 조건 매칭 - 부분 일치, 대소문자 무시
|
||||
*/
|
||||
private boolean matchesDestination(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasDestinationFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityDestination = entity.getDestination();
|
||||
if (entityDestination == null || entityDestination.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entityDestination.toUpperCase().contains(request.getDestination().toUpperCase().trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Status (항행상태) 조건 매칭 - 다중 선택 중 하나라도 일치
|
||||
*/
|
||||
private boolean matchesStatus(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasStatusFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityStatus = entity.getStatus();
|
||||
if (entityStatus == null || entityStatus.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// statusList에 포함되어 있으면 통과
|
||||
return request.getStatusList().stream()
|
||||
.anyMatch(status -> entityStatus.equalsIgnoreCase(status.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassType (선박 클래스 타입) 조건 매칭
|
||||
*
|
||||
* - A: Core20에 등록된 선박
|
||||
* - B: Core20 미등록 선박
|
||||
* - 필터 미지정: 전체 통과
|
||||
* - classType이 null인 데이터: B 필터에만 포함 (보수적 접근)
|
||||
*/
|
||||
private boolean matchesClassType(AisTargetEntity entity, AisTargetFilterRequest request) {
|
||||
if (!request.hasClassTypeFilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String entityClassType = entity.getClassType();
|
||||
|
||||
// classType이 미분류(null)인 데이터 처리
|
||||
if (entityClassType == null) {
|
||||
// B 필터인 경우 미분류 데이터도 포함 (보수적 접근)
|
||||
return "B".equalsIgnoreCase(request.getClassType());
|
||||
}
|
||||
|
||||
return request.getClassType().equalsIgnoreCase(entityClassType);
|
||||
}
|
||||
}
|
||||
@ -1,317 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.cache;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.*;
|
||||
import org.locationtech.jts.geom.impl.CoordinateArraySequence;
|
||||
import org.locationtech.jts.operation.distance.DistanceOp;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 공간 필터링 유틸리티 (JTS 기반)
|
||||
*
|
||||
* 지원 기능:
|
||||
* - 원형 범위 내 선박 필터링 (Point + Radius)
|
||||
* - 폴리곤 범위 내 선박 필터링 (Polygon)
|
||||
* - 거리 계산 (Haversine 공식 - 지구 곡률 고려)
|
||||
*
|
||||
* 성능:
|
||||
* - 25만 건 필터링: 약 50-100ms (병렬 처리 시)
|
||||
* - 단순 거리 계산은 JTS 없이 Haversine으로 처리 (더 빠름)
|
||||
* - 복잡한 폴리곤은 JTS 사용
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SpatialFilterUtil {
|
||||
|
||||
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(new PrecisionModel(), 4326);
|
||||
|
||||
// 지구 반경 (미터)
|
||||
private static final double EARTH_RADIUS_METERS = 6_371_000;
|
||||
|
||||
// ==================== 원형 범위 필터링 ====================
|
||||
|
||||
/**
|
||||
* 원형 범위 내 선박 필터링 (Haversine 거리 계산 - 빠름)
|
||||
*
|
||||
* @param entities 전체 엔티티 목록
|
||||
* @param centerLon 중심 경도
|
||||
* @param centerLat 중심 위도
|
||||
* @param radiusMeters 반경 (미터)
|
||||
* @return 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByCircle(
|
||||
Collection<AisTargetEntity> entities,
|
||||
double centerLon,
|
||||
double centerLat,
|
||||
double radiusMeters) {
|
||||
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 병렬 스트림으로 필터링 (대용량 데이터 최적화)
|
||||
List<AisTargetEntity> result = entities.parallelStream()
|
||||
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
|
||||
.filter(entity -> {
|
||||
double distance = haversineDistance(
|
||||
centerLat, centerLon,
|
||||
entity.getLat(), entity.getLon()
|
||||
);
|
||||
return distance <= radiusMeters;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.debug("원형 필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms",
|
||||
entities.size(), result.size(), elapsed);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원형 범위 내 선박 필터링 + 거리 정보 포함
|
||||
*/
|
||||
public List<EntityWithDistance> filterByCircleWithDistance(
|
||||
Collection<AisTargetEntity> entities,
|
||||
double centerLon,
|
||||
double centerLat,
|
||||
double radiusMeters) {
|
||||
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return entities.parallelStream()
|
||||
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
|
||||
.map(entity -> {
|
||||
double distance = haversineDistance(
|
||||
centerLat, centerLon,
|
||||
entity.getLat(), entity.getLon()
|
||||
);
|
||||
return new EntityWithDistance(entity, distance);
|
||||
})
|
||||
.filter(ewd -> ewd.getDistanceMeters() <= radiusMeters)
|
||||
.sorted((a, b) -> Double.compare(a.getDistanceMeters(), b.getDistanceMeters()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 폴리곤 범위 필터링 ====================
|
||||
|
||||
/**
|
||||
* 폴리곤 범위 내 선박 필터링 (JTS 사용)
|
||||
*
|
||||
* @param entities 전체 엔티티 목록
|
||||
* @param polygonCoordinates 폴리곤 좌표 [[lon, lat], [lon, lat], ...] (닫힌 형태)
|
||||
* @return 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByPolygon(
|
||||
Collection<AisTargetEntity> entities,
|
||||
double[][] polygonCoordinates) {
|
||||
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
if (polygonCoordinates == null || polygonCoordinates.length < 4) {
|
||||
log.warn("유효하지 않은 폴리곤 좌표 (최소 4개 점 필요)");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// JTS Polygon 생성
|
||||
Polygon polygon = createPolygon(polygonCoordinates);
|
||||
|
||||
if (polygon == null || !polygon.isValid()) {
|
||||
log.warn("유효하지 않은 폴리곤");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 병렬 스트림으로 필터링
|
||||
List<AisTargetEntity> result = entities.parallelStream()
|
||||
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
|
||||
.filter(entity -> {
|
||||
Point point = createPoint(entity.getLon(), entity.getLat());
|
||||
return polygon.contains(point);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.debug("폴리곤 필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms",
|
||||
entities.size(), result.size(), elapsed);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* WKT(Well-Known Text) 형식 폴리곤으로 필터링
|
||||
*
|
||||
* @param entities 전체 엔티티 목록
|
||||
* @param wkt WKT 문자열 (예: "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
|
||||
* @return 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByWkt(
|
||||
Collection<AisTargetEntity> entities,
|
||||
String wkt) {
|
||||
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
try {
|
||||
Geometry geometry = new org.locationtech.jts.io.WKTReader(GEOMETRY_FACTORY).read(wkt);
|
||||
|
||||
return entities.parallelStream()
|
||||
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
|
||||
.filter(entity -> {
|
||||
Point point = createPoint(entity.getLon(), entity.getLat());
|
||||
return geometry.contains(point);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("WKT 파싱 실패: {}", wkt, e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GeoJSON 지원 ====================
|
||||
|
||||
/**
|
||||
* GeoJSON 형식 폴리곤으로 필터링
|
||||
*
|
||||
* @param entities 전체 엔티티 목록
|
||||
* @param geoJsonCoordinates GeoJSON coordinates 배열 [[[lon, lat], ...]]
|
||||
* @return 범위 내 엔티티 목록
|
||||
*/
|
||||
public List<AisTargetEntity> filterByGeoJson(
|
||||
Collection<AisTargetEntity> entities,
|
||||
double[][][] geoJsonCoordinates) {
|
||||
|
||||
if (geoJsonCoordinates == null || geoJsonCoordinates.length == 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// GeoJSON의 첫 번째 링 (외부 경계)
|
||||
return filterByPolygon(entities, geoJsonCoordinates[0]);
|
||||
}
|
||||
|
||||
// ==================== 거리 계산 ====================
|
||||
|
||||
/**
|
||||
* Haversine 공식을 사용한 두 지점 간 거리 계산 (미터)
|
||||
* 지구 곡률을 고려한 정확한 거리 계산
|
||||
*/
|
||||
public double haversineDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
double dLat = Math.toRadians(lat2 - lat1);
|
||||
double dLon = Math.toRadians(lon2 - lon1);
|
||||
|
||||
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
||||
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return EARTH_RADIUS_METERS * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 엔티티 간 거리 계산 (미터)
|
||||
*/
|
||||
public double calculateDistance(AisTargetEntity entity1, AisTargetEntity entity2) {
|
||||
if (entity1.getLat() == null || entity1.getLon() == null ||
|
||||
entity2.getLat() == null || entity2.getLon() == null) {
|
||||
return Double.MAX_VALUE;
|
||||
}
|
||||
|
||||
return haversineDistance(
|
||||
entity1.getLat(), entity1.getLon(),
|
||||
entity2.getLat(), entity2.getLon()
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== JTS 헬퍼 메서드 ====================
|
||||
|
||||
/**
|
||||
* JTS Point 생성
|
||||
*/
|
||||
public Point createPoint(double lon, double lat) {
|
||||
return GEOMETRY_FACTORY.createPoint(new Coordinate(lon, lat));
|
||||
}
|
||||
|
||||
/**
|
||||
* JTS Polygon 생성
|
||||
*/
|
||||
public Polygon createPolygon(double[][] coordinates) {
|
||||
try {
|
||||
Coordinate[] coords = new Coordinate[coordinates.length];
|
||||
for (int i = 0; i < coordinates.length; i++) {
|
||||
coords[i] = new Coordinate(coordinates[i][0], coordinates[i][1]);
|
||||
}
|
||||
|
||||
// 폴리곤이 닫혀있지 않으면 닫기
|
||||
if (!coords[0].equals(coords[coords.length - 1])) {
|
||||
Coordinate[] closedCoords = new Coordinate[coords.length + 1];
|
||||
System.arraycopy(coords, 0, closedCoords, 0, coords.length);
|
||||
closedCoords[coords.length] = coords[0];
|
||||
coords = closedCoords;
|
||||
}
|
||||
|
||||
LinearRing ring = GEOMETRY_FACTORY.createLinearRing(coords);
|
||||
return GEOMETRY_FACTORY.createPolygon(ring);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("폴리곤 생성 실패", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 원형 폴리곤 생성 (근사치)
|
||||
*
|
||||
* @param centerLon 중심 경도
|
||||
* @param centerLat 중심 위도
|
||||
* @param radiusMeters 반경 (미터)
|
||||
* @param numPoints 폴리곤 점 개수 (기본: 64)
|
||||
*/
|
||||
public Polygon createCirclePolygon(double centerLon, double centerLat, double radiusMeters, int numPoints) {
|
||||
Coordinate[] coords = new Coordinate[numPoints + 1];
|
||||
|
||||
for (int i = 0; i < numPoints; i++) {
|
||||
double angle = (2 * Math.PI * i) / numPoints;
|
||||
|
||||
// 위도/경도 변환 (근사치)
|
||||
double dLat = (radiusMeters / EARTH_RADIUS_METERS) * (180 / Math.PI);
|
||||
double dLon = dLat / Math.cos(Math.toRadians(centerLat));
|
||||
|
||||
double lat = centerLat + dLat * Math.sin(angle);
|
||||
double lon = centerLon + dLon * Math.cos(angle);
|
||||
|
||||
coords[i] = new Coordinate(lon, lat);
|
||||
}
|
||||
coords[numPoints] = coords[0]; // 닫기
|
||||
|
||||
LinearRing ring = GEOMETRY_FACTORY.createLinearRing(coords);
|
||||
return GEOMETRY_FACTORY.createPolygon(ring);
|
||||
}
|
||||
|
||||
// ==================== 내부 클래스 ====================
|
||||
|
||||
/**
|
||||
* 엔티티 + 거리 정보
|
||||
*/
|
||||
@lombok.Data
|
||||
@lombok.AllArgsConstructor
|
||||
public static class EntityWithDistance {
|
||||
private AisTargetEntity entity;
|
||||
private double distanceMeters;
|
||||
}
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 중국 허가선박 전용 캐시
|
||||
*
|
||||
* - 대상 MMSI(~1,400척)만 별도 관리
|
||||
* - TTL: expireAfterWrite (마지막 put 이후 N일 경과 시 만료)
|
||||
* - 순수 캐시 조회 전용 (DB fallback 없음)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ChnPrmShipCacheManager {
|
||||
|
||||
private final ChnPrmShipProperties properties;
|
||||
private Cache<Long, AisTargetEntity> cache;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.cache = Caffeine.newBuilder()
|
||||
.maximumSize(properties.getMaxSize())
|
||||
.expireAfterWrite(properties.getTtlDays(), TimeUnit.DAYS)
|
||||
.recordStats()
|
||||
.build();
|
||||
|
||||
log.info("ChnPrmShip 캐시 초기화 - TTL: {}일, 최대 크기: {}",
|
||||
properties.getTtlDays(), properties.getMaxSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 MMSI에 해당하는 항목만 필터링하여 캐시에 저장
|
||||
*
|
||||
* @param items 전체 AIS Target 데이터 (배치 수집 결과)
|
||||
* @return 저장된 건수
|
||||
*/
|
||||
public int putIfTarget(List<AisTargetEntity> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
for (AisTargetEntity item : items) {
|
||||
if (!properties.isTarget(item.getMmsi())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AisTargetEntity existing = cache.getIfPresent(item.getMmsi());
|
||||
if (existing == null || isNewerOrEqual(item, existing)) {
|
||||
cache.put(item.getMmsi(), item);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
log.debug("ChnPrmShip 캐시 업데이트 - 입력: {}, 대상 저장: {}, 현재 크기: {}",
|
||||
items.size(), updated, cache.estimatedSize());
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 범위 내 캐시 데이터 조회
|
||||
*
|
||||
* @param minutes 조회 범위 (분)
|
||||
* @return 시간 범위 내 데이터 목록
|
||||
*/
|
||||
public List<AisTargetEntity> getByTimeRange(int minutes) {
|
||||
OffsetDateTime threshold = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(minutes);
|
||||
|
||||
return cache.asMap().values().stream()
|
||||
.filter(entity -> entity.getMessageTimestamp() != null)
|
||||
.filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 워밍업용 직접 저장 (시간 비교 없이 저장)
|
||||
*/
|
||||
public void putAll(List<AisTargetEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (AisTargetEntity entity : entities) {
|
||||
if (entity != null && entity.getMmsi() != null) {
|
||||
cache.put(entity.getMmsi(), entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long size() {
|
||||
return cache.estimatedSize();
|
||||
}
|
||||
|
||||
public Map<String, Object> getStats() {
|
||||
var stats = cache.stats();
|
||||
return Map.of(
|
||||
"estimatedSize", cache.estimatedSize(),
|
||||
"maxSize", properties.getMaxSize(),
|
||||
"ttlDays", properties.getTtlDays(),
|
||||
"targetMmsiCount", properties.getMmsiSet().size(),
|
||||
"hitCount", stats.hitCount(),
|
||||
"missCount", stats.missCount(),
|
||||
"hitRate", String.format("%.2f%%", stats.hitRate() * 100)
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isNewerOrEqual(AisTargetEntity candidate, AisTargetEntity existing) {
|
||||
if (candidate.getMessageTimestamp() == null) {
|
||||
return false;
|
||||
}
|
||||
if (existing.getMessageTimestamp() == null) {
|
||||
return true;
|
||||
}
|
||||
return !candidate.getMessageTimestamp().isBefore(existing.getMessageTimestamp());
|
||||
}
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 기동 시 ChnPrmShip 캐시 워밍업
|
||||
*
|
||||
* DB(ais_target)에서 대상 MMSI의 최근 데이터를 조회하여 캐시를 채운다.
|
||||
* 이후 매 분 배치 수집에서 실시간 데이터가 캐시를 갱신한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ChnPrmShipCacheWarmer implements ApplicationRunner {
|
||||
|
||||
private static final int DB_QUERY_CHUNK_SIZE = 500;
|
||||
|
||||
private final ChnPrmShipProperties properties;
|
||||
private final ChnPrmShipCacheManager cacheManager;
|
||||
private final AisTargetRepository aisTargetRepository;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
if (!properties.isWarmupEnabled()) {
|
||||
log.info("ChnPrmShip 캐시 워밍업 비활성화");
|
||||
return;
|
||||
}
|
||||
|
||||
if (properties.getMmsiSet().isEmpty()) {
|
||||
log.warn("ChnPrmShip 대상 MMSI가 없어 워밍업을 건너뜁니다");
|
||||
return;
|
||||
}
|
||||
|
||||
OffsetDateTime since = OffsetDateTime.now(ZoneOffset.UTC)
|
||||
.minusDays(properties.getWarmupDays());
|
||||
|
||||
log.info("ChnPrmShip 캐시 워밍업 시작 - 대상: {}건, 조회 범위: 최근 {}일 (since: {})",
|
||||
properties.getMmsiSet().size(), properties.getWarmupDays(), since);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<Long> mmsiList = new ArrayList<>(properties.getMmsiSet());
|
||||
int totalLoaded = 0;
|
||||
|
||||
for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) {
|
||||
List<Long> chunk = mmsiList.subList(i,
|
||||
Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size()));
|
||||
|
||||
List<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsiInSince(chunk, since);
|
||||
|
||||
// signalKindCode 치환 (DB 데이터는 치환이 안 되어 있을 수 있음)
|
||||
fromDb.forEach(entity -> {
|
||||
if (entity.getSignalKindCode() == null) {
|
||||
SignalKindCode kindCode = SignalKindCode.resolve(
|
||||
entity.getVesselType(), entity.getExtraInfo());
|
||||
entity.setSignalKindCode(kindCode.getCode());
|
||||
}
|
||||
});
|
||||
|
||||
cacheManager.putAll(fromDb);
|
||||
totalLoaded += fromDb.size();
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 로딩: {}건, 소요: {}ms",
|
||||
properties.getMmsiSet().size(), totalLoaded, elapsed);
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.chnprmship;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 중국 허가선박(ChnPrmShip) 설정
|
||||
*
|
||||
* 대상 MMSI 목록을 리소스 파일에서 로딩하여 Set으로 보관한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "app.batch.chnprmship")
|
||||
public class ChnPrmShipProperties {
|
||||
|
||||
/**
|
||||
* MMSI 목록 리소스 경로
|
||||
*/
|
||||
private String mmsiResourcePath = "classpath:chnprmship-mmsi.txt";
|
||||
|
||||
/**
|
||||
* 캐시 TTL (일)
|
||||
* - 마지막 put() 이후 이 기간이 지나면 만료
|
||||
*/
|
||||
private int ttlDays = 2;
|
||||
|
||||
/**
|
||||
* 최대 캐시 크기
|
||||
*/
|
||||
private int maxSize = 2000;
|
||||
|
||||
/**
|
||||
* 기동 시 DB 워밍업 활성화 여부
|
||||
*/
|
||||
private boolean warmupEnabled = true;
|
||||
|
||||
/**
|
||||
* DB 워밍업 조회 범위 (일)
|
||||
*/
|
||||
private int warmupDays = 2;
|
||||
|
||||
/**
|
||||
* 로딩된 대상 MMSI 집합
|
||||
*/
|
||||
private Set<Long> mmsiSet = Collections.emptySet();
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try {
|
||||
Resource resource = new DefaultResourceLoader().getResource(mmsiResourcePath);
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
mmsiSet = reader.lines()
|
||||
.map(String::trim)
|
||||
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||
.map(Long::parseLong)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
log.info("ChnPrmShip MMSI 로딩 완료 - {}건 (경로: {})", mmsiSet.size(), mmsiResourcePath);
|
||||
} catch (Exception e) {
|
||||
log.error("ChnPrmShip MMSI 로딩 실패 - 경로: {}, 오류: {}", mmsiResourcePath, e.getMessage());
|
||||
mmsiSet = Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isTarget(Long mmsi) {
|
||||
return mmsi != null && mmsiSet.contains(mmsi);
|
||||
}
|
||||
}
|
||||
@ -1,160 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* AIS Target ClassType 분류기
|
||||
*
|
||||
* 분류 기준:
|
||||
* - Core20 테이블에 IMO가 등록되어 있으면 Class A
|
||||
* - 등록되어 있지 않으면 Class B (기본값)
|
||||
*
|
||||
* 분류 결과:
|
||||
* - classType: "A" 또는 "B"
|
||||
* - core20Mmsi: Core20에 등록된 MMSI (Class A일 때만, nullable)
|
||||
*
|
||||
* 특이 케이스:
|
||||
* 1. IMO가 0이거나 null → Class B
|
||||
* 2. IMO가 7자리가 아닌 의미없는 숫자 → Class B
|
||||
* 3. IMO가 7자리이지만 Core20에 미등록 → Class B
|
||||
* 4. IMO가 Core20에 있지만 MMSI가 null → Class A, core20Mmsi = null
|
||||
*
|
||||
* 향후 제거 가능하도록 독립적인 모듈로 구현
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisClassTypeClassifier {
|
||||
|
||||
/**
|
||||
* 유효한 IMO 패턴 (7자리 숫자)
|
||||
*/
|
||||
private static final Pattern IMO_PATTERN = Pattern.compile("^\\d{7}$");
|
||||
|
||||
private final Core20CacheManager core20CacheManager;
|
||||
|
||||
/**
|
||||
* ClassType 분류 기능 활성화 여부
|
||||
*/
|
||||
@Value("${app.batch.class-type.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
public AisClassTypeClassifier(Core20CacheManager core20CacheManager) {
|
||||
this.core20CacheManager = core20CacheManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 Entity의 ClassType 분류
|
||||
*
|
||||
* @param entity AIS Target Entity
|
||||
*/
|
||||
public void classify(AisTargetEntity entity) {
|
||||
if (!enabled || entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long imo = entity.getImo();
|
||||
|
||||
// 1. IMO가 null이거나 0이면 Class B
|
||||
if (imo == null || imo == 0) {
|
||||
setClassB(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. IMO가 7자리 숫자인지 확인
|
||||
String imoStr = String.valueOf(imo);
|
||||
if (!isValidImo(imoStr)) {
|
||||
setClassB(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Core20 캐시에서 IMO 존재 여부 확인
|
||||
if (core20CacheManager.containsImo(imoStr)) {
|
||||
// Class A - Core20에 등록된 선박
|
||||
entity.setClassType("A");
|
||||
|
||||
// Core20의 MMSI 조회 (nullable - Core20에 MMSI가 없을 수도 있음)
|
||||
Optional<String> core20Mmsi = core20CacheManager.getMmsiByImo(imoStr);
|
||||
entity.setCore20Mmsi(core20Mmsi.orElse(null));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Core20에 없음 - Class B
|
||||
setClassB(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 Entity 일괄 분류
|
||||
*
|
||||
* @param entities AIS Target Entity 목록
|
||||
*/
|
||||
public void classifyAll(List<AisTargetEntity> entities) {
|
||||
if (!enabled || entities == null || entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int classACount = 0;
|
||||
int classBCount = 0;
|
||||
int classAWithMmsi = 0;
|
||||
int classAWithoutMmsi = 0;
|
||||
|
||||
for (AisTargetEntity entity : entities) {
|
||||
classify(entity);
|
||||
|
||||
if ("A".equals(entity.getClassType())) {
|
||||
classACount++;
|
||||
if (entity.getCore20Mmsi() != null) {
|
||||
classAWithMmsi++;
|
||||
} else {
|
||||
classAWithoutMmsi++;
|
||||
}
|
||||
} else {
|
||||
classBCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("ClassType 분류 완료 - 총: {}, Class A: {} (MMSI있음: {}, MMSI없음: {}), Class B: {}",
|
||||
entities.size(), classACount, classAWithMmsi, classAWithoutMmsi, classBCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class B로 설정 (기본값)
|
||||
*/
|
||||
private void setClassB(AisTargetEntity entity) {
|
||||
entity.setClassType("B");
|
||||
entity.setCore20Mmsi(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 IMO 번호인지 확인 (7자리 숫자)
|
||||
*
|
||||
* @param imo IMO 문자열
|
||||
* @return 유효 여부
|
||||
*/
|
||||
private boolean isValidImo(String imo) {
|
||||
return imo != null && IMO_PATTERN.matcher(imo).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기능 활성화 여부
|
||||
*/
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core20 캐시 상태 확인
|
||||
*/
|
||||
public boolean isCacheReady() {
|
||||
return core20CacheManager.isLoaded();
|
||||
}
|
||||
}
|
||||
@ -1,219 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Core20 테이블의 IMO → MMSI 매핑 캐시 매니저
|
||||
*
|
||||
* 동작:
|
||||
* - 애플리케이션 시작 시 또는 첫 조회 시 자동 로딩
|
||||
* - 매일 지정된 시간(기본 04:00)에 전체 갱신
|
||||
* - TTL 없음 (명시적 갱신만)
|
||||
*
|
||||
* 데이터 구조:
|
||||
* - Key: IMO/LRNO (7자리 문자열, NOT NULL)
|
||||
* - Value: MMSI (문자열, NULLABLE - 빈 문자열로 저장)
|
||||
*
|
||||
* 특이사항:
|
||||
* - Core20에 IMO는 있지만 MMSI가 null인 경우도 존재
|
||||
* - 이 경우 containsImo()는 true, getMmsiByImo()는 Optional.empty()
|
||||
* - ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열("")을 sentinel 값으로 사용
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class Core20CacheManager {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Core20Properties properties;
|
||||
|
||||
/**
|
||||
* MMSI가 없는 경우를 나타내는 sentinel 값
|
||||
* ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열 사용
|
||||
*/
|
||||
private static final String NO_MMSI = "";
|
||||
|
||||
/**
|
||||
* IMO → MMSI 매핑 캐시
|
||||
* - Key: IMO (NOT NULL)
|
||||
* - Value: MMSI (빈 문자열이면 MMSI 없음)
|
||||
*/
|
||||
private volatile Map<String, String> imoToMmsiMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 마지막 갱신 시간
|
||||
*/
|
||||
private volatile LocalDateTime lastRefreshTime;
|
||||
|
||||
/**
|
||||
* Core20 캐시 갱신 시간 (기본: 04시)
|
||||
*/
|
||||
@Value("${app.batch.class-type.refresh-hour:4}")
|
||||
private int refreshHour;
|
||||
|
||||
public Core20CacheManager(JdbcTemplate jdbcTemplate, Core20Properties properties) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* IMO로 MMSI 조회
|
||||
*
|
||||
* @param imo IMO 번호 (문자열)
|
||||
* @return MMSI 값 (없거나 null/빈 문자열이면 Optional.empty)
|
||||
*/
|
||||
public Optional<String> getMmsiByImo(String imo) {
|
||||
ensureCacheLoaded();
|
||||
|
||||
if (imo == null || !imoToMmsiMap.containsKey(imo)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String mmsi = imoToMmsiMap.get(imo);
|
||||
|
||||
// MMSI가 빈 문자열(NO_MMSI)인 경우
|
||||
if (mmsi == null || mmsi.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(mmsi);
|
||||
}
|
||||
|
||||
/**
|
||||
* IMO 존재 여부만 확인 (MMSI 유무와 무관)
|
||||
* - Core20에 등록된 선박인지 판단하는 용도
|
||||
* - MMSI가 null이어도 IMO가 있으면 true
|
||||
*
|
||||
* @param imo IMO 번호
|
||||
* @return Core20에 등록 여부
|
||||
*/
|
||||
public boolean containsImo(String imo) {
|
||||
ensureCacheLoaded();
|
||||
return imo != null && imoToMmsiMap.containsKey(imo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 전체 갱신 (DB에서 다시 로딩)
|
||||
*/
|
||||
public synchronized void refresh() {
|
||||
log.info("Core20 캐시 갱신 시작 - 테이블: {}", properties.getFullTableName());
|
||||
|
||||
try {
|
||||
String sql = properties.buildSelectSql();
|
||||
log.debug("Core20 조회 SQL: {}", sql);
|
||||
|
||||
Map<String, String> newMap = new ConcurrentHashMap<>();
|
||||
|
||||
jdbcTemplate.query(sql, rs -> {
|
||||
String imo = rs.getString(1);
|
||||
String mmsi = rs.getString(2); // nullable
|
||||
|
||||
if (imo != null && !imo.isBlank()) {
|
||||
// IMO는 trim하여 저장, MMSI는 빈 문자열로 대체 (ConcurrentHashMap은 null 불가)
|
||||
String trimmedImo = imo.trim();
|
||||
String trimmedMmsi = (mmsi != null && !mmsi.isBlank()) ? mmsi.trim() : NO_MMSI;
|
||||
newMap.put(trimmedImo, trimmedMmsi);
|
||||
}
|
||||
});
|
||||
|
||||
this.imoToMmsiMap = newMap;
|
||||
this.lastRefreshTime = LocalDateTime.now();
|
||||
|
||||
// 통계 로깅
|
||||
long withMmsi = newMap.values().stream()
|
||||
.filter(v -> !v.isEmpty())
|
||||
.count();
|
||||
|
||||
log.info("Core20 캐시 갱신 완료 - 총 {} 건 (MMSI 있음: {} 건, MMSI 없음: {} 건)",
|
||||
newMap.size(), withMmsi, newMap.size() - withMmsi);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Core20 캐시 갱신 실패: {}", e.getMessage(), e);
|
||||
// 기존 캐시 유지 (실패해도 서비스 중단 방지)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시가 비어있으면 자동 로딩
|
||||
*/
|
||||
private void ensureCacheLoaded() {
|
||||
if (imoToMmsiMap.isEmpty() && lastRefreshTime == null) {
|
||||
log.warn("Core20 캐시 비어있음 - 자동 로딩 실행");
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정된 시간대에 갱신이 필요한지 확인
|
||||
* - 기본: 04:00 ~ 04:01 사이
|
||||
* - 같은 날 이미 갱신했으면 스킵
|
||||
*
|
||||
* @return 갱신 필요 여부
|
||||
*/
|
||||
public boolean shouldRefresh() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
int currentHour = now.getHour();
|
||||
int currentMinute = now.getMinute();
|
||||
|
||||
// 지정된 시간(예: 04:00~04:01) 체크
|
||||
if (currentHour != refreshHour || currentMinute > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 오늘 해당 시간에 이미 갱신했으면 스킵
|
||||
if (lastRefreshTime != null &&
|
||||
lastRefreshTime.toLocalDate().equals(now.toLocalDate()) &&
|
||||
lastRefreshTime.getHour() == refreshHour) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 캐시 크기
|
||||
*/
|
||||
public int size() {
|
||||
return imoToMmsiMap.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 갱신 시간
|
||||
*/
|
||||
public LocalDateTime getLastRefreshTime() {
|
||||
return lastRefreshTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시가 로드되었는지 확인
|
||||
*/
|
||||
public boolean isLoaded() {
|
||||
return lastRefreshTime != null && !imoToMmsiMap.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 통계 조회 (모니터링/디버깅용)
|
||||
*/
|
||||
public Map<String, Object> getStats() {
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalCount", imoToMmsiMap.size());
|
||||
stats.put("withMmsiCount", imoToMmsiMap.values().stream()
|
||||
.filter(v -> !v.isEmpty()).count());
|
||||
stats.put("withoutMmsiCount", imoToMmsiMap.values().stream()
|
||||
.filter(String::isEmpty).count());
|
||||
stats.put("lastRefreshTime", lastRefreshTime);
|
||||
stats.put("refreshHour", refreshHour);
|
||||
stats.put("tableName", properties.getFullTableName());
|
||||
stats.put("imoColumn", properties.getImoColumn());
|
||||
stats.put("mmsiColumn", properties.getMmsiColumn());
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Core20 테이블 설정 프로퍼티
|
||||
*
|
||||
* 환경별(dev/qa/prod)로 테이블명, 컬럼명이 다를 수 있으므로
|
||||
* 프로파일별 설정 파일에서 지정할 수 있도록 구성
|
||||
*
|
||||
* 사용 예:
|
||||
* - dev: std_snp_svc.tb_ship_main_info (imo_no, mmsi_no)
|
||||
* - prod: std_snp_svc.tb_ship_main_info (imo_no, mmsi_no)
|
||||
*/
|
||||
@Slf4j
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "app.batch.core20")
|
||||
public class Core20Properties {
|
||||
|
||||
/**
|
||||
* 스키마명 (예: std_snp_svc)
|
||||
*/
|
||||
private String schema = "std_snp_svc";
|
||||
|
||||
/**
|
||||
* 테이블명 (예: tb_ship_main_info)
|
||||
*/
|
||||
private String table = "tb_ship_main_info";
|
||||
|
||||
/**
|
||||
* IMO/LRNO 컬럼명 (PK, NOT NULL)
|
||||
*/
|
||||
private String imoColumn = "imo_no";
|
||||
|
||||
/**
|
||||
* MMSI 컬럼명 (NULLABLE)
|
||||
*/
|
||||
private String mmsiColumn = "mmsi_no";
|
||||
|
||||
/**
|
||||
* 전체 테이블명 반환 (schema.table)
|
||||
*/
|
||||
public String getFullTableName() {
|
||||
if (schema != null && !schema.isBlank()) {
|
||||
return schema + "." + table;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* SELECT 쿼리 생성
|
||||
* IMO가 NOT NULL인 레코드만 조회
|
||||
*/
|
||||
public String buildSelectSql() {
|
||||
return String.format(
|
||||
"SELECT %s, %s FROM %s WHERE %s IS NOT NULL",
|
||||
imoColumn, mmsiColumn, getFullTableName(), imoColumn
|
||||
);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void logConfig() {
|
||||
log.info("Core20 설정 로드 - 테이블: {}, IMO컬럼: {}, MMSI컬럼: {}",
|
||||
getFullTableName(), imoColumn, mmsiColumn);
|
||||
}
|
||||
}
|
||||
@ -1,118 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.classifier;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* MDA 선종 범례코드
|
||||
*
|
||||
* GlobalAIS 원본 데이터의 vesselType + extraInfo를 기반으로
|
||||
* MDA 범례코드(signalKindCode)로 치환한다.
|
||||
*
|
||||
* @see <a href="GLOBALAIS - MDA 선종 범례 치환표.pdf">치환 규칙표</a>
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum SignalKindCode {
|
||||
|
||||
FISHING("000020", "어선"),
|
||||
KCGV("000021", "함정"),
|
||||
FERRY("000022", "여객선"),
|
||||
CARGO("000023", "카고"),
|
||||
TANKER("000024", "탱커"),
|
||||
GOV("000025", "관공선"),
|
||||
DEFAULT("000027", "일반/기타선박"),
|
||||
BUOY("000028", "부이/항로표지");
|
||||
|
||||
private final String code;
|
||||
private final String koreanName;
|
||||
|
||||
/**
|
||||
* GlobalAIS vesselType + extraInfo → MDA 범례코드 치환
|
||||
*
|
||||
* 치환 우선순위:
|
||||
* 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN 등)
|
||||
* 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing 등)
|
||||
* 3. fallback → DEFAULT (000027)
|
||||
*/
|
||||
public static SignalKindCode resolve(String vesselType, String extraInfo) {
|
||||
String vt = normalizeOrEmpty(vesselType);
|
||||
String ei = normalizeOrEmpty(extraInfo);
|
||||
|
||||
// 1. vesselType 단독 매칭 (extraInfo 무관)
|
||||
switch (vt) {
|
||||
case "cargo":
|
||||
return CARGO;
|
||||
case "tanker":
|
||||
return TANKER;
|
||||
case "passenger":
|
||||
return FERRY;
|
||||
case "aton":
|
||||
return BUOY;
|
||||
case "law enforcement":
|
||||
return GOV;
|
||||
case "search and rescue":
|
||||
return KCGV;
|
||||
case "local vessel":
|
||||
return FISHING;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// vesselType 그룹 매칭 (복합 선종명)
|
||||
if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) {
|
||||
return GOV;
|
||||
}
|
||||
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
|
||||
return FERRY;
|
||||
}
|
||||
|
||||
// 2. "Vessel" + extraInfo 조합
|
||||
if ("vessel".equals(vt)) {
|
||||
return resolveVesselExtraInfo(ei);
|
||||
}
|
||||
|
||||
// 3. "N/A" + extraInfo 조합
|
||||
if ("n/a".equals(vt)) {
|
||||
if (ei.startsWith("hazardous cat")) {
|
||||
return CARGO;
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
// 4. fallback
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
private static SignalKindCode resolveVesselExtraInfo(String extraInfo) {
|
||||
if ("fishing".equals(extraInfo)) {
|
||||
return FISHING;
|
||||
}
|
||||
if ("military operations".equals(extraInfo)) {
|
||||
return GOV;
|
||||
}
|
||||
if (matchesAny(extraInfo, "towing", "towing (large)", "dredging/underwater ops", "diving operations")) {
|
||||
return GOV;
|
||||
}
|
||||
if (matchesAny(extraInfo, "pleasure craft", "sailing", "n/a")) {
|
||||
return FISHING;
|
||||
}
|
||||
if (extraInfo.startsWith("hazardous cat")) {
|
||||
return CARGO;
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
private static boolean matchesAny(String value, String... candidates) {
|
||||
for (String candidate : candidates) {
|
||||
if (candidate.equals(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalizeOrEmpty(String value) {
|
||||
return (value == null || value.isBlank()) ? "" : value.strip().toLowerCase();
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.kafka;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
/**
|
||||
* Kafka 조건부 활성화 설정
|
||||
*
|
||||
* SnpBatchApplication에서 KafkaAutoConfiguration을 기본 제외한 뒤,
|
||||
* app.batch.ais-target.kafka.enabled=true인 경우에만 재활성화한다.
|
||||
*
|
||||
* enabled=false(기본값)이면 KafkaTemplate 등 Kafka 관련 빈이 전혀 생성되지 않는다.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(
|
||||
name = "app.batch.ais-target.kafka.enabled",
|
||||
havingValue = "true"
|
||||
)
|
||||
@Import(KafkaAutoConfiguration.class)
|
||||
public class AisTargetKafkaConfig {
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.kafka;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/**
|
||||
* AIS Target Kafka 메시지 스키마
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AisTargetKafkaMessage {
|
||||
|
||||
/**
|
||||
* 이벤트 고유 식별자
|
||||
* - 형식: {mmsi}_{messageTimestamp}
|
||||
*/
|
||||
private String eventId;
|
||||
|
||||
/**
|
||||
* Kafka key와 동일한 선박 식별자
|
||||
*/
|
||||
private String key;
|
||||
|
||||
/**
|
||||
* Kafka 발행 시각(UTC)
|
||||
*/
|
||||
private OffsetDateTime publishedAt;
|
||||
|
||||
/**
|
||||
* AIS 원본/가공 데이터 전체 필드
|
||||
*/
|
||||
private AisTargetEntity payload;
|
||||
|
||||
public static AisTargetKafkaMessage from(AisTargetEntity entity) {
|
||||
String key = entity.getMmsi() != null ? String.valueOf(entity.getMmsi()) : null;
|
||||
String messageTs = entity.getMessageTimestamp() != null ? entity.getMessageTimestamp().toString() : "null";
|
||||
|
||||
return AisTargetKafkaMessage.builder()
|
||||
.eventId(key + "_" + messageTs)
|
||||
.key(key)
|
||||
.publishedAt(OffsetDateTime.now(ZoneOffset.UTC))
|
||||
.payload(entity)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -1,211 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.kafka;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* AIS Target Kafka Producer
|
||||
*
|
||||
* 정책:
|
||||
* - key: MMSI
|
||||
* - value: AisTargetKafkaMessage(JSON)
|
||||
* - 실패 시 기본적으로 로그만 남기고 계속 진행 (failOnSendError=false)
|
||||
*
|
||||
* app.batch.ais-target.kafka.enabled=true인 경우에만 빈으로 등록된다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "app.batch.ais-target.kafka.enabled", havingValue = "true")
|
||||
public class AisTargetKafkaProducer {
|
||||
|
||||
private final KafkaTemplate<String, String> kafkaTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AisTargetKafkaProperties kafkaProperties;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return kafkaProperties.isEnabled();
|
||||
}
|
||||
|
||||
public String getTopic() {
|
||||
return kafkaProperties.getTopic();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집 청크 데이터를 Kafka 전송용 서브청크로 분할해 전송
|
||||
*/
|
||||
public PublishSummary publish(List<AisTargetEntity> entities) {
|
||||
if (!isEnabled()) {
|
||||
return PublishSummary.disabled();
|
||||
}
|
||||
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return PublishSummary.empty();
|
||||
}
|
||||
|
||||
int subChunkSize = Math.max(1, kafkaProperties.getSendChunkSize());
|
||||
PublishSummary totalSummary = PublishSummary.empty();
|
||||
|
||||
for (int from = 0; from < entities.size(); from += subChunkSize) {
|
||||
int to = Math.min(from + subChunkSize, entities.size());
|
||||
List<AisTargetEntity> subChunk = entities.subList(from, to);
|
||||
|
||||
PublishSummary chunkSummary = publishSubChunk(subChunk);
|
||||
totalSummary.merge(chunkSummary);
|
||||
|
||||
log.info("AIS Kafka 서브청크 전송 완료 - topic: {}, 범위: {}~{}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
|
||||
getTopic(), from, to - 1,
|
||||
chunkSummary.getRequestedCount(),
|
||||
chunkSummary.getSuccessCount(),
|
||||
chunkSummary.getFailedCount(),
|
||||
chunkSummary.getSkippedCount());
|
||||
}
|
||||
|
||||
if (kafkaProperties.isFailOnSendError() && totalSummary.getFailedCount() > 0) {
|
||||
throw new IllegalStateException("AIS Kafka 전송 실패 건수: " + totalSummary.getFailedCount());
|
||||
}
|
||||
|
||||
return totalSummary;
|
||||
}
|
||||
|
||||
private PublishSummary publishSubChunk(List<AisTargetEntity> subChunk) {
|
||||
AtomicInteger successCount = new AtomicInteger(0);
|
||||
AtomicInteger failedCount = new AtomicInteger(0);
|
||||
AtomicInteger skippedCount = new AtomicInteger(0);
|
||||
AtomicInteger sampledErrorLogs = new AtomicInteger(0);
|
||||
List<CompletableFuture<Void>> futures = new ArrayList<>(subChunk.size());
|
||||
|
||||
for (AisTargetEntity entity : subChunk) {
|
||||
if (!isValid(entity)) {
|
||||
skippedCount.incrementAndGet();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String key = String.valueOf(entity.getMmsi());
|
||||
String payload = objectMapper.writeValueAsString(AisTargetKafkaMessage.from(entity));
|
||||
|
||||
CompletableFuture<Void> trackedFuture = kafkaTemplate.send(getTopic(), key, payload)
|
||||
.handle((result, ex) -> {
|
||||
if (ex != null) {
|
||||
failedCount.incrementAndGet();
|
||||
logSendError(sampledErrorLogs,
|
||||
"AIS Kafka 전송 실패 - topic: " + getTopic()
|
||||
+ ", key: " + key
|
||||
+ ", messageTimestamp: " + entity.getMessageTimestamp()
|
||||
+ ", error: " + ex.getMessage());
|
||||
} else {
|
||||
successCount.incrementAndGet();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
futures.add(trackedFuture);
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
failedCount.incrementAndGet();
|
||||
logSendError(sampledErrorLogs,
|
||||
"AIS Kafka 메시지 직렬화 실패 - mmsi: " + entity.getMmsi()
|
||||
+ ", messageTimestamp: " + entity.getMessageTimestamp()
|
||||
+ ", error: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
failedCount.incrementAndGet();
|
||||
logSendError(sampledErrorLogs,
|
||||
"AIS Kafka 전송 요청 실패 - mmsi: " + entity.getMmsi()
|
||||
+ ", messageTimestamp: " + entity.getMessageTimestamp()
|
||||
+ ", error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!futures.isEmpty()) {
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||
kafkaTemplate.flush();
|
||||
}
|
||||
|
||||
return PublishSummary.of(
|
||||
false,
|
||||
subChunk.size(),
|
||||
successCount.get(),
|
||||
failedCount.get(),
|
||||
skippedCount.get()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isValid(AisTargetEntity entity) {
|
||||
return entity != null
|
||||
&& entity.getMmsi() != null
|
||||
&& entity.getMessageTimestamp() != null;
|
||||
}
|
||||
|
||||
private void logSendError(AtomicInteger sampledErrorLogs, String message) {
|
||||
int current = sampledErrorLogs.incrementAndGet();
|
||||
if (current <= 5) {
|
||||
log.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (current == 6) {
|
||||
log.error("AIS Kafka 전송 오류 로그가 많아 이후 상세 로그는 생략합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static class PublishSummary {
|
||||
private final boolean disabled;
|
||||
private int requestedCount;
|
||||
private int successCount;
|
||||
private int failedCount;
|
||||
private int skippedCount;
|
||||
|
||||
private PublishSummary(
|
||||
boolean disabled,
|
||||
int requestedCount,
|
||||
int successCount,
|
||||
int failedCount,
|
||||
int skippedCount
|
||||
) {
|
||||
this.disabled = disabled;
|
||||
this.requestedCount = requestedCount;
|
||||
this.successCount = successCount;
|
||||
this.failedCount = failedCount;
|
||||
this.skippedCount = skippedCount;
|
||||
}
|
||||
|
||||
public static PublishSummary disabled() {
|
||||
return of(true, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
public static PublishSummary empty() {
|
||||
return of(false, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
public static PublishSummary of(
|
||||
boolean disabled,
|
||||
int requestedCount,
|
||||
int successCount,
|
||||
int failedCount,
|
||||
int skippedCount
|
||||
) {
|
||||
return new PublishSummary(disabled, requestedCount, successCount, failedCount, skippedCount);
|
||||
}
|
||||
|
||||
public void merge(PublishSummary other) {
|
||||
this.requestedCount += other.requestedCount;
|
||||
this.successCount += other.successCount;
|
||||
this.failedCount += other.failedCount;
|
||||
this.skippedCount += other.skippedCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.kafka;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* AIS Target Kafka 전송 설정
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "app.batch.ais-target.kafka")
|
||||
public class AisTargetKafkaProperties {
|
||||
|
||||
/**
|
||||
* Kafka 전송 활성화 여부
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* 전송 대상 토픽
|
||||
*/
|
||||
private String topic = "tp_SNP_AIS_Signal";
|
||||
|
||||
/**
|
||||
* Kafka 전송 서브청크 크기
|
||||
* 수집 청크(예: 5만)와 별도로 전송 배치를 분할한다.
|
||||
*/
|
||||
private int sendChunkSize = 5000;
|
||||
|
||||
/**
|
||||
* 전송 실패 시 Step 실패 여부
|
||||
* false면 실패 로그만 남기고 다음 처리를 계속한다.
|
||||
*/
|
||||
private boolean failOnSendError = false;
|
||||
}
|
||||
@ -1,500 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.web.controller;
|
||||
|
||||
import com.snp.batch.common.web.ApiResponse;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.service.AisTargetService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AIS Target REST API Controller
|
||||
*
|
||||
* 캐시 우선 조회 전략:
|
||||
* - 캐시에서 먼저 조회
|
||||
* - 캐시 미스 시 DB 조회 후 캐시 업데이트
|
||||
*/
|
||||
@Slf4j
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/ais-target")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "AIS Target", description = "AIS 선박 위치 정보 API")
|
||||
public class AisTargetController {
|
||||
|
||||
private final AisTargetService aisTargetService;
|
||||
|
||||
// ==================== 중국 허가선박 전용 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "중국 허가선박 위치 조회",
|
||||
description = """
|
||||
중국 허가 어선(~1,400척) 전용 캐시에서 위치 정보를 조회합니다.
|
||||
|
||||
- 순수 캐시 조회 (DB fallback 없음)
|
||||
- 캐시에 없으면 빈 배열 반환
|
||||
- 응답 구조는 /search와 동일
|
||||
"""
|
||||
)
|
||||
@GetMapping("/chnprmship")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getChnPrmShip(
|
||||
@Parameter(description = "조회 범위 (분, 기본: 2880 = 2일)", example = "2880")
|
||||
@RequestParam(defaultValue = "2880") Integer minutes) {
|
||||
|
||||
log.info("ChnPrmShip 조회 요청 - minutes: {}", minutes);
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.findChnPrmShip(minutes);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"ChnPrmShip 조회 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "중국 허가선박 캐시 통계",
|
||||
description = "중국 허가선박 전용 캐시의 현재 상태를 조회합니다"
|
||||
)
|
||||
@GetMapping("/chnprmship/stats")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getChnPrmShipStats() {
|
||||
Map<String, Object> stats = aisTargetService.getChnPrmShipCacheStats();
|
||||
return ResponseEntity.ok(ApiResponse.success(stats));
|
||||
}
|
||||
|
||||
// ==================== 단건 조회 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "MMSI로 최신 위치 조회",
|
||||
description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 MMSI의 위치 정보 없음")
|
||||
}
|
||||
)
|
||||
@GetMapping("/{mmsi}")
|
||||
public ResponseEntity<ApiResponse<AisTargetResponseDto>> getLatestByMmsi(
|
||||
@Parameter(description = "MMSI 번호", required = true, example = "440123456")
|
||||
@PathVariable Long mmsi) {
|
||||
log.info("최신 위치 조회 요청 - MMSI: {}", mmsi);
|
||||
|
||||
return aisTargetService.findLatestByMmsi(mmsi)
|
||||
.map(dto -> ResponseEntity.ok(ApiResponse.success(dto)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
// ==================== 다건 조회 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "여러 MMSI의 최신 위치 조회",
|
||||
description = "여러 MMSI의 최신 위치 정보를 일괄 조회합니다 (캐시 우선)"
|
||||
)
|
||||
@PostMapping("/batch")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getLatestByMmsiList(
|
||||
@Parameter(description = "MMSI 번호 목록", required = true)
|
||||
@RequestBody List<Long> mmsiList) {
|
||||
log.info("다건 최신 위치 조회 요청 - 요청 수: {}", mmsiList.size());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.findLatestByMmsiList(mmsiList);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"조회 완료: " + result.size() + "/" + mmsiList.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 검색 조회 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "시간/공간 범위로 선박 검색",
|
||||
description = """
|
||||
시간 범위 (필수) + 공간 범위 (옵션) + 선박 클래스 타입 (옵션)으로 선박을 검색합니다.
|
||||
|
||||
- minutes: 조회 범위 (분, 필수)
|
||||
- centerLon, centerLat: 중심 좌표 (옵션)
|
||||
- radiusMeters: 반경 (미터, 옵션)
|
||||
- classType: 선박 클래스 타입 필터 (A/B, 옵션)
|
||||
|
||||
---
|
||||
## ClassType 설명
|
||||
- **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- **B**: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
|
||||
---
|
||||
## 응답 필드 설명
|
||||
- **classType**: 선박 클래스 타입 (A/B)
|
||||
- **core20Mmsi**: Core20 테이블의 MMSI 값 (Class A인 경우에만 존재할 수 있음)
|
||||
|
||||
공간 범위가 지정되지 않으면 전체 선박의 최신 위치를 반환합니다.
|
||||
"""
|
||||
)
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> search(
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||
@RequestParam @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") Integer minutes,
|
||||
@Parameter(description = "중심 경도", example = "129.0")
|
||||
@RequestParam(required = false) Double centerLon,
|
||||
@Parameter(description = "중심 위도", example = "35.0")
|
||||
@RequestParam(required = false) Double centerLat,
|
||||
@Parameter(description = "반경 (미터)", example = "50000")
|
||||
@RequestParam(required = false) Double radiusMeters,
|
||||
@Parameter(description = "선박 클래스 타입 필터 (A: Core20 등록, B: 미등록)", example = "A")
|
||||
@RequestParam(required = false) String classType) {
|
||||
|
||||
log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}, classType: {}",
|
||||
minutes, centerLon, centerLat, radiusMeters, classType);
|
||||
|
||||
AisTargetSearchRequest request = AisTargetSearchRequest.builder()
|
||||
.minutes(minutes)
|
||||
.centerLon(centerLon)
|
||||
.centerLat(centerLat)
|
||||
.radiusMeters(radiusMeters)
|
||||
.classType(classType)
|
||||
.build();
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.search(request);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "시간/공간 범위로 선박 검색 (POST)",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (minutes 누락 또는 1 미만)")
|
||||
},
|
||||
description = """
|
||||
POST 방식으로 검색 조건을 전달합니다.
|
||||
|
||||
---
|
||||
## 요청 예시
|
||||
```json
|
||||
{
|
||||
"minutes": 5,
|
||||
"centerLon": 129.0,
|
||||
"centerLat": 35.0,
|
||||
"radiusMeters": 50000,
|
||||
"classType": "A"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## ClassType 설명
|
||||
- **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- **B**: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
"""
|
||||
)
|
||||
@PostMapping("/search")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchPost(
|
||||
@Valid @RequestBody AisTargetSearchRequest request) {
|
||||
log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}",
|
||||
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.search(request);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 조건 필터 검색 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "항해 조건 필터 검색",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "필터 검색 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패")
|
||||
},
|
||||
description = """
|
||||
속도(SOG), 침로(COG), 선수방위(Heading), 목적지, 항행상태로 선박을 필터링합니다.
|
||||
|
||||
---
|
||||
## 조건 타입 및 파라미터 사용법
|
||||
|
||||
| 조건 | 의미 | 사용 파라미터 |
|
||||
|------|------|--------------|
|
||||
| GTE | 이상 (>=) | *Value (예: sogValue) |
|
||||
| GT | 초과 (>) | *Value |
|
||||
| LTE | 이하 (<=) | *Value |
|
||||
| LT | 미만 (<) | *Value |
|
||||
| BETWEEN | 범위 | *Min, *Max (예: sogMin, sogMax) |
|
||||
|
||||
---
|
||||
## 요청 예시
|
||||
|
||||
**예시 1: 단일 값 조건 (속도 10knots 이상)**
|
||||
```json
|
||||
{
|
||||
"minutes": 5,
|
||||
"sogCondition": "GTE",
|
||||
"sogValue": 10.0
|
||||
}
|
||||
```
|
||||
|
||||
**예시 2: 범위 조건 (속도 5~15knots, 침로 90~180도)**
|
||||
```json
|
||||
{
|
||||
"minutes": 5,
|
||||
"sogCondition": "BETWEEN",
|
||||
"sogMin": 5.0,
|
||||
"sogMax": 15.0,
|
||||
"cogCondition": "BETWEEN",
|
||||
"cogMin": 90.0,
|
||||
"cogMax": 180.0
|
||||
}
|
||||
```
|
||||
|
||||
**예시 3: 복합 조건**
|
||||
```json
|
||||
{
|
||||
"minutes": 5,
|
||||
"sogCondition": "GTE",
|
||||
"sogValue": 10.0,
|
||||
"cogCondition": "BETWEEN",
|
||||
"cogMin": 90.0,
|
||||
"cogMax": 180.0,
|
||||
"headingCondition": "LT",
|
||||
"headingValue": 180.0,
|
||||
"destination": "BUSAN",
|
||||
"statusList": ["Under way using engine", "At anchor", "Moored"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
## 항행상태 값 (statusList)
|
||||
|
||||
statusList에는 **텍스트 문자열**을 전달해야 합니다 (대소문자 무시).
|
||||
|
||||
| 값 | 설명 |
|
||||
|------|------|
|
||||
| Under way using engine | 기관 사용 항해 중 |
|
||||
| At anchor | 정박 중 |
|
||||
| Not under command | 조종불능 |
|
||||
| Restricted manoeuverability | 조종제한 |
|
||||
| Constrained by her draught | 흘수제약 |
|
||||
| Moored | 계류 중 |
|
||||
| Aground | 좌초 |
|
||||
| Engaged in Fishing | 어로 중 |
|
||||
| Under way sailing | 돛 항해 중 |
|
||||
| Power Driven Towing Astern | 예인선 (후방) |
|
||||
| Power Driven Towing Alongside | 예인선 (측방) |
|
||||
| AIS Sart | 비상위치지시기 |
|
||||
| N/A | 정보없음 |
|
||||
|
||||
---
|
||||
**참고:** 모든 필터는 선택사항이며, 미지정 시 해당 필드는 조건에서 제외됩니다 (전체 값 포함).
|
||||
"""
|
||||
)
|
||||
@PostMapping("/search/filter")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByFilter(
|
||||
@Valid @RequestBody AisTargetFilterRequest request) {
|
||||
log.info("필터 검색 요청 - minutes: {}, sog: {}/{}, cog: {}/{}, heading: {}/{}, dest: {}, status: {}",
|
||||
request.getMinutes(),
|
||||
request.getSogCondition(), request.getSogValue(),
|
||||
request.getCogCondition(), request.getCogValue(),
|
||||
request.getHeadingCondition(), request.getHeadingValue(),
|
||||
request.getDestination(),
|
||||
request.getStatusList());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.searchByFilter(request);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"필터 검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 폴리곤 검색 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "폴리곤 범위 내 선박 검색",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (coordinates 또는 minutes 누락)")
|
||||
},
|
||||
description = """
|
||||
폴리곤 범위 내 선박을 검색합니다.
|
||||
|
||||
요청 예시:
|
||||
{
|
||||
"minutes": 5,
|
||||
"coordinates": [[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]
|
||||
}
|
||||
|
||||
좌표는 [경도, 위도] 순서이며, 폴리곤은 닫힌 형태여야 합니다 (첫점 = 끝점).
|
||||
"""
|
||||
)
|
||||
@PostMapping("/search/polygon")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByPolygon(
|
||||
@Valid @RequestBody PolygonSearchRequest request) {
|
||||
log.info("폴리곤 검색 요청 - minutes: {}, points: {}",
|
||||
request.getMinutes(), request.getCoordinates().length);
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.searchByPolygon(
|
||||
request.getMinutes(),
|
||||
request.getCoordinates()
|
||||
);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"폴리곤 검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "WKT 범위 내 선박 검색",
|
||||
responses = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (wkt 또는 minutes 누락)")
|
||||
},
|
||||
description = """
|
||||
WKT(Well-Known Text) 형식으로 정의된 범위 내 선박을 검색합니다.
|
||||
|
||||
요청 예시:
|
||||
{
|
||||
"minutes": 5,
|
||||
"wkt": "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))"
|
||||
}
|
||||
|
||||
지원 형식: POLYGON, MULTIPOLYGON
|
||||
"""
|
||||
)
|
||||
@PostMapping("/search/wkt")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByWkt(
|
||||
@Valid @RequestBody WktSearchRequest request) {
|
||||
log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt());
|
||||
|
||||
List<AisTargetResponseDto> result = aisTargetService.searchByWkt(
|
||||
request.getMinutes(),
|
||||
request.getWkt()
|
||||
);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"WKT 검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "거리 포함 원형 범위 검색",
|
||||
description = """
|
||||
원형 범위 내 선박을 검색하고, 중심점으로부터의 거리 정보를 함께 반환합니다.
|
||||
결과는 거리순으로 정렬됩니다.
|
||||
"""
|
||||
)
|
||||
@GetMapping("/search/with-distance")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetService.AisTargetWithDistanceDto>>> searchWithDistance(
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
|
||||
@RequestParam Integer minutes,
|
||||
@Parameter(description = "중심 경도", required = true, example = "129.0")
|
||||
@RequestParam Double centerLon,
|
||||
@Parameter(description = "중심 위도", required = true, example = "35.0")
|
||||
@RequestParam Double centerLat,
|
||||
@Parameter(description = "반경 (미터)", required = true, example = "50000")
|
||||
@RequestParam Double radiusMeters) {
|
||||
|
||||
log.info("거리 포함 검색 요청 - minutes: {}, center: ({}, {}), radius: {}",
|
||||
minutes, centerLon, centerLat, radiusMeters);
|
||||
|
||||
List<AisTargetService.AisTargetWithDistanceDto> result =
|
||||
aisTargetService.searchWithDistance(minutes, centerLon, centerLat, radiusMeters);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"거리 포함 검색 완료: " + result.size() + " 건",
|
||||
result
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 항적 조회 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "항적 조회",
|
||||
description = "특정 MMSI의 시간 범위 내 항적 (위치 이력)을 조회합니다"
|
||||
)
|
||||
@GetMapping("/{mmsi}/track")
|
||||
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getTrack(
|
||||
@Parameter(description = "MMSI 번호", required = true, example = "440123456")
|
||||
@PathVariable Long mmsi,
|
||||
@Parameter(description = "조회 범위 (분)", required = true, example = "60")
|
||||
@RequestParam Integer minutes) {
|
||||
log.info("항적 조회 요청 - MMSI: {}, 범위: {}분", mmsi, minutes);
|
||||
|
||||
List<AisTargetResponseDto> track = aisTargetService.getTrack(mmsi, minutes);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
"항적 조회 완료: " + track.size() + " 포인트",
|
||||
track
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 캐시 관리 ====================
|
||||
|
||||
@Operation(
|
||||
summary = "캐시 통계 조회",
|
||||
description = "AIS Target 캐시의 현재 상태를 조회합니다"
|
||||
)
|
||||
@GetMapping("/cache/stats")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCacheStats() {
|
||||
Map<String, Object> stats = aisTargetService.getCacheStats();
|
||||
return ResponseEntity.ok(ApiResponse.success(stats));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "캐시 초기화",
|
||||
description = "AIS Target 캐시를 초기화합니다"
|
||||
)
|
||||
@DeleteMapping("/cache")
|
||||
public ResponseEntity<ApiResponse<Void>> clearCache() {
|
||||
log.warn("캐시 초기화 요청");
|
||||
aisTargetService.clearCache();
|
||||
return ResponseEntity.ok(ApiResponse.success("캐시가 초기화되었습니다", null));
|
||||
}
|
||||
|
||||
// ==================== 요청 DTO (내부 클래스) ====================
|
||||
|
||||
/**
|
||||
* 폴리곤 검색 요청 DTO
|
||||
*/
|
||||
@lombok.Data
|
||||
@Schema(description = "폴리곤 범위 검색 요청")
|
||||
public static class PolygonSearchRequest {
|
||||
@NotNull(message = "minutes는 필수입니다")
|
||||
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer minutes;
|
||||
|
||||
@NotNull(message = "coordinates는 필수입니다")
|
||||
@Schema(description = "폴리곤 좌표 [[경도, 위도], ...] (닫힌 형태: 첫점=끝점)",
|
||||
example = "[[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double[][] coordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* WKT 검색 요청 DTO
|
||||
*/
|
||||
@lombok.Data
|
||||
@Schema(description = "WKT 범위 검색 요청")
|
||||
public static class WktSearchRequest {
|
||||
@NotNull(message = "minutes는 필수입니다")
|
||||
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer minutes;
|
||||
|
||||
@NotNull(message = "wkt는 필수입니다")
|
||||
@Schema(description = "WKT 문자열 (POLYGON, MULTIPOLYGON 지원)",
|
||||
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String wkt;
|
||||
}
|
||||
}
|
||||
@ -1,165 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AIS Target 필터 검색 요청 DTO
|
||||
*
|
||||
* 조건 타입 (condition):
|
||||
* - GTE: 이상 (>=)
|
||||
* - GT: 초과 (>)
|
||||
* - LTE: 이하 (<=)
|
||||
* - LT: 미만 (<)
|
||||
* - BETWEEN: 범위 (min <= value <= max)
|
||||
*
|
||||
* 모든 필터는 선택사항이며, 미지정 시 해당 필드 전체 포함
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "AIS Target 필터 검색 요청")
|
||||
public class AisTargetFilterRequest {
|
||||
|
||||
@NotNull(message = "minutes는 필수입니다")
|
||||
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer minutes;
|
||||
|
||||
// ==================== 속도 (SOG) 필터 ====================
|
||||
@Schema(description = """
|
||||
속도(SOG) 조건 타입
|
||||
- GTE: 이상 (>=) - sogValue 사용
|
||||
- GT: 초과 (>) - sogValue 사용
|
||||
- LTE: 이하 (<=) - sogValue 사용
|
||||
- LT: 미만 (<) - sogValue 사용
|
||||
- BETWEEN: 범위 - sogMin, sogMax 사용
|
||||
""",
|
||||
example = "GTE", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"})
|
||||
private String sogCondition;
|
||||
|
||||
@Schema(description = "속도 값 (knots) - GTE/GT/LTE/LT 조건에서 사용", example = "10.0")
|
||||
private Double sogValue;
|
||||
|
||||
@Schema(description = "속도 최소값 (knots) - BETWEEN 조건에서 사용 (sogMin <= 속도 <= sogMax)", example = "5.0")
|
||||
private Double sogMin;
|
||||
|
||||
@Schema(description = "속도 최대값 (knots) - BETWEEN 조건에서 사용 (sogMin <= 속도 <= sogMax)", example = "15.0")
|
||||
private Double sogMax;
|
||||
|
||||
// ==================== 침로 (COG) 필터 ====================
|
||||
@Schema(description = """
|
||||
침로(COG) 조건 타입
|
||||
- GTE: 이상 (>=) - cogValue 사용
|
||||
- GT: 초과 (>) - cogValue 사용
|
||||
- LTE: 이하 (<=) - cogValue 사용
|
||||
- LT: 미만 (<) - cogValue 사용
|
||||
- BETWEEN: 범위 - cogMin, cogMax 사용
|
||||
""",
|
||||
example = "BETWEEN", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"})
|
||||
private String cogCondition;
|
||||
|
||||
@Schema(description = "침로 값 (degrees, 0-360) - GTE/GT/LTE/LT 조건에서 사용", example = "180.0")
|
||||
private Double cogValue;
|
||||
|
||||
@Schema(description = "침로 최소값 (degrees) - BETWEEN 조건에서 사용 (cogMin <= 침로 <= cogMax)", example = "90.0")
|
||||
private Double cogMin;
|
||||
|
||||
@Schema(description = "침로 최대값 (degrees) - BETWEEN 조건에서 사용 (cogMin <= 침로 <= cogMax)", example = "270.0")
|
||||
private Double cogMax;
|
||||
|
||||
// ==================== 선수방위 (Heading) 필터 ====================
|
||||
@Schema(description = """
|
||||
선수방위(Heading) 조건 타입
|
||||
- GTE: 이상 (>=) - headingValue 사용
|
||||
- GT: 초과 (>) - headingValue 사용
|
||||
- LTE: 이하 (<=) - headingValue 사용
|
||||
- LT: 미만 (<) - headingValue 사용
|
||||
- BETWEEN: 범위 - headingMin, headingMax 사용
|
||||
""",
|
||||
example = "LTE", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"})
|
||||
private String headingCondition;
|
||||
|
||||
@Schema(description = "선수방위 값 (degrees, 0-360) - GTE/GT/LTE/LT 조건에서 사용", example = "90.0")
|
||||
private Double headingValue;
|
||||
|
||||
@Schema(description = "선수방위 최소값 (degrees) - BETWEEN 조건에서 사용 (headingMin <= 선수방위 <= headingMax)", example = "0.0")
|
||||
private Double headingMin;
|
||||
|
||||
@Schema(description = "선수방위 최대값 (degrees) - BETWEEN 조건에서 사용 (headingMin <= 선수방위 <= headingMax)", example = "180.0")
|
||||
private Double headingMax;
|
||||
|
||||
// ==================== 목적지 (Destination) 필터 ====================
|
||||
@Schema(description = "목적지 (부분 일치, 대소문자 무시)", example = "BUSAN")
|
||||
private String destination;
|
||||
|
||||
// ==================== 항행상태 (Status) 필터 ====================
|
||||
@Schema(description = """
|
||||
항행상태 목록 (다중 선택 가능, 미선택 시 전체)
|
||||
- Under way using engine (기관 사용 항해 중)
|
||||
- Under way sailing (돛 항해 중)
|
||||
- Anchored (정박 중)
|
||||
- Moored (계류 중)
|
||||
- Not under command (조종불능)
|
||||
- Restriced manoeuverability (조종제한)
|
||||
- Constrained by draught (흘수제약)
|
||||
- Aground (좌초)
|
||||
- Engaged in fishing (어로 중)
|
||||
- Power Driven Towing Astern (예인선-후방)
|
||||
- Power Driven Towing Alongside (예인선-측방)
|
||||
- AIS Sart (비상위치지시기)
|
||||
- N/A (정보없음)
|
||||
""",
|
||||
example = "[\"Under way using engine\", \"Anchored\", \"Moored\"]")
|
||||
private List<String> statusList;
|
||||
|
||||
// ==================== 선박 클래스 타입 (ClassType) 필터 ====================
|
||||
@Schema(description = """
|
||||
선박 클래스 타입 필터
|
||||
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
""",
|
||||
example = "A", allowableValues = {"A", "B"})
|
||||
private String classType;
|
||||
|
||||
// ==================== 필터 존재 여부 확인 ====================
|
||||
|
||||
public boolean hasSogFilter() {
|
||||
return sogCondition != null && !sogCondition.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasCogFilter() {
|
||||
return cogCondition != null && !cogCondition.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasHeadingFilter() {
|
||||
return headingCondition != null && !headingCondition.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasDestinationFilter() {
|
||||
return destination != null && !destination.trim().isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasStatusFilter() {
|
||||
return statusList != null && !statusList.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasClassTypeFilter() {
|
||||
return classType != null &&
|
||||
(classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B"));
|
||||
}
|
||||
|
||||
public boolean hasAnyFilter() {
|
||||
return hasSogFilter() || hasCogFilter() || hasHeadingFilter()
|
||||
|| hasDestinationFilter() || hasStatusFilter() || hasClassTypeFilter();
|
||||
}
|
||||
}
|
||||
@ -1,157 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.web.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* AIS Target API 응답 DTO
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "AIS Target 응답")
|
||||
public class AisTargetResponseDto {
|
||||
|
||||
// 선박 식별 정보
|
||||
@Schema(description = "MMSI (Maritime Mobile Service Identity) 번호", example = "440123456")
|
||||
private Long mmsi;
|
||||
|
||||
@Schema(description = "IMO 번호 (0인 경우 미등록)", example = "9137960")
|
||||
private Long imo;
|
||||
|
||||
@Schema(description = "선박명", example = "ROYAUME DES OCEANS")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "호출 부호", example = "4SFTEST")
|
||||
private String callsign;
|
||||
|
||||
@Schema(description = "선박 유형 (외부 API 원본 텍스트)", example = "Vessel")
|
||||
private String vesselType;
|
||||
|
||||
// 위치 정보
|
||||
@Schema(description = "위도 (WGS84)", example = "35.0796")
|
||||
private Double lat;
|
||||
|
||||
@Schema(description = "경도 (WGS84)", example = "129.0756")
|
||||
private Double lon;
|
||||
|
||||
// 항해 정보
|
||||
@Schema(description = "선수방위 (degrees, 0-360)", example = "36.0")
|
||||
private Double heading;
|
||||
|
||||
@Schema(description = "대지속력 (knots)", example = "12.5")
|
||||
private Double sog;
|
||||
|
||||
@Schema(description = "대지침로 (degrees, 0-360)", example = "36.2")
|
||||
private Double cog;
|
||||
|
||||
@Schema(description = "회전율 (Rate of Turn)", example = "0")
|
||||
private Integer rot;
|
||||
|
||||
// 선박 제원
|
||||
@Schema(description = "선박 길이 (미터)", example = "19")
|
||||
private Integer length;
|
||||
|
||||
@Schema(description = "선박 폭 (미터)", example = "15")
|
||||
private Integer width;
|
||||
|
||||
@Schema(description = "흘수 (미터)", example = "5.5")
|
||||
private Double draught;
|
||||
|
||||
// 목적지 정보
|
||||
@Schema(description = "목적지", example = "BUSAN")
|
||||
private String destination;
|
||||
|
||||
@Schema(description = "예정 도착 시간 (UTC)")
|
||||
private OffsetDateTime eta;
|
||||
|
||||
@Schema(description = "항행상태 (텍스트)", example = "Under way using engine")
|
||||
private String status;
|
||||
|
||||
// 타임스탬프
|
||||
@Schema(description = "AIS 메시지 발생 시각 (UTC)")
|
||||
private OffsetDateTime messageTimestamp;
|
||||
|
||||
@Schema(description = "데이터 수신 시각 (UTC)")
|
||||
private OffsetDateTime receivedDate;
|
||||
|
||||
// 데이터 소스 (캐시/DB)
|
||||
@Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
|
||||
private String source;
|
||||
|
||||
// 선종 분류 정보
|
||||
@Schema(description = """
|
||||
MDA 범례코드 (선종 분류)
|
||||
- 000020: 어선 (FISHING)
|
||||
- 000021: 함정 (KCGV)
|
||||
- 000022: 여객선 (FERRY)
|
||||
- 000023: 카고 (CARGO)
|
||||
- 000024: 탱커 (TANKER)
|
||||
- 000025: 관공선 (GOV)
|
||||
- 000027: 일반/기타선박 (DEFAULT)
|
||||
- 000028: 부이/항로표지 (BUOY)
|
||||
""",
|
||||
example = "000023")
|
||||
private String signalKindCode;
|
||||
|
||||
// ClassType 분류 정보
|
||||
@Schema(description = """
|
||||
선박 클래스 타입
|
||||
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
""",
|
||||
example = "A", allowableValues = {"A", "B"})
|
||||
private String classType;
|
||||
|
||||
@Schema(description = """
|
||||
Core20 테이블의 MMSI 값
|
||||
- Class A인 경우에만 값이 있을 수 있음
|
||||
- null: Class B 또는 Core20에 MMSI가 미등록된 경우
|
||||
""",
|
||||
example = "440123456", nullable = true)
|
||||
private String core20Mmsi;
|
||||
|
||||
/**
|
||||
* Entity -> DTO 변환
|
||||
*/
|
||||
public static AisTargetResponseDto from(AisTargetEntity entity, String source) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AisTargetResponseDto.builder()
|
||||
.mmsi(entity.getMmsi())
|
||||
.imo(entity.getImo())
|
||||
.name(entity.getName())
|
||||
.callsign(entity.getCallsign())
|
||||
.vesselType(entity.getVesselType())
|
||||
.lat(entity.getLat())
|
||||
.lon(entity.getLon())
|
||||
.heading(entity.getHeading())
|
||||
.sog(entity.getSog())
|
||||
.cog(entity.getCog())
|
||||
.rot(entity.getRot())
|
||||
.length(entity.getLength())
|
||||
.width(entity.getWidth())
|
||||
.draught(entity.getDraught())
|
||||
.destination(entity.getDestination())
|
||||
.eta(entity.getEta())
|
||||
.status(entity.getStatus())
|
||||
.messageTimestamp(entity.getMessageTimestamp())
|
||||
.receivedDate(entity.getReceivedDate())
|
||||
.source(source)
|
||||
.signalKindCode(entity.getSignalKindCode())
|
||||
.classType(entity.getClassType())
|
||||
.core20Mmsi(entity.getCore20Mmsi())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* AIS Target 검색 요청 DTO
|
||||
*
|
||||
* 필수 파라미터:
|
||||
* - minutes: 분 단위 조회 범위 (1~60)
|
||||
*
|
||||
* 옵션 파라미터:
|
||||
* - centerLon, centerLat, radiusMeters: 공간 범위 필터
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "AIS Target 검색 요청")
|
||||
public class AisTargetSearchRequest {
|
||||
|
||||
@NotNull(message = "minutes는 필수입니다")
|
||||
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
|
||||
@Schema(description = "조회 범위 (분)", example = "5", required = true)
|
||||
private Integer minutes;
|
||||
|
||||
@Schema(description = "중심 경도 (옵션)", example = "129.0")
|
||||
private Double centerLon;
|
||||
|
||||
@Schema(description = "중심 위도 (옵션)", example = "35.0")
|
||||
private Double centerLat;
|
||||
|
||||
@Schema(description = "반경 (미터, 옵션)", example = "50000")
|
||||
private Double radiusMeters;
|
||||
|
||||
@Schema(description = """
|
||||
선박 클래스 타입 필터
|
||||
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
|
||||
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
|
||||
- 미지정: 전체 조회
|
||||
""",
|
||||
example = "A", allowableValues = {"A", "B"})
|
||||
private String classType;
|
||||
|
||||
/**
|
||||
* 공간 필터 사용 여부
|
||||
*/
|
||||
public boolean hasAreaFilter() {
|
||||
return centerLon != null && centerLat != null && radiusMeters != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassType 필터 사용 여부
|
||||
* - "A" 또는 "B"인 경우에만 true
|
||||
*/
|
||||
public boolean hasClassTypeFilter() {
|
||||
return classType != null &&
|
||||
(classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B"));
|
||||
}
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.web.dto;
|
||||
|
||||
/**
|
||||
* 숫자 비교 조건 열거형
|
||||
*
|
||||
* 사용: SOG, COG, Heading 필터링
|
||||
*/
|
||||
public enum NumericCondition {
|
||||
/**
|
||||
* 이상 (>=)
|
||||
*/
|
||||
GTE,
|
||||
|
||||
/**
|
||||
* 초과 (>)
|
||||
*/
|
||||
GT,
|
||||
|
||||
/**
|
||||
* 이하 (<=)
|
||||
*/
|
||||
LTE,
|
||||
|
||||
/**
|
||||
* 미만 (<)
|
||||
*/
|
||||
LT,
|
||||
|
||||
/**
|
||||
* 범위 (min <= value <= max)
|
||||
*/
|
||||
BETWEEN;
|
||||
|
||||
/**
|
||||
* 문자열을 NumericCondition으로 변환
|
||||
*
|
||||
* @param value 조건 문자열
|
||||
* @return NumericCondition (null이면 null 반환)
|
||||
*/
|
||||
public static NumericCondition fromString(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return NumericCondition.valueOf(value.toUpperCase().trim());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 값이 조건을 만족하는지 확인
|
||||
*
|
||||
* @param fieldValue 필드 값
|
||||
* @param compareValue 비교 값 (GTE, GT, LTE, LT용)
|
||||
* @param minValue 최소값 (BETWEEN용)
|
||||
* @param maxValue 최대값 (BETWEEN용)
|
||||
* @return 조건 만족 여부
|
||||
*/
|
||||
public boolean matches(Double fieldValue, Double compareValue, Double minValue, Double maxValue) {
|
||||
if (fieldValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return switch (this) {
|
||||
case GTE -> compareValue != null && fieldValue >= compareValue;
|
||||
case GT -> compareValue != null && fieldValue > compareValue;
|
||||
case LTE -> compareValue != null && fieldValue <= compareValue;
|
||||
case LT -> compareValue != null && fieldValue < compareValue;
|
||||
case BETWEEN -> minValue != null && maxValue != null
|
||||
&& fieldValue >= minValue && fieldValue <= maxValue;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 조건절 생성 (DB 쿼리용)
|
||||
*
|
||||
* @param columnName 컬럼명
|
||||
* @return SQL 조건절 문자열
|
||||
*/
|
||||
public String toSqlCondition(String columnName) {
|
||||
return switch (this) {
|
||||
case GTE -> columnName + " >= ?";
|
||||
case GT -> columnName + " > ?";
|
||||
case LTE -> columnName + " <= ?";
|
||||
case LT -> columnName + " < ?";
|
||||
case BETWEEN -> columnName + " BETWEEN ? AND ?";
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,422 +0,0 @@
|
||||
package com.snp.batch.jobs.aistarget.web.service;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetFilterUtil;
|
||||
import com.snp.batch.jobs.aistarget.cache.SpatialFilterUtil;
|
||||
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
|
||||
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AIS Target 서비스
|
||||
*
|
||||
* 조회 전략:
|
||||
* 1. 캐시 우선 조회 (Caffeine 캐시)
|
||||
* 2. 캐시 미스 시 DB Fallback
|
||||
* 3. 공간 필터링은 캐시에서 수행 (JTS 기반)
|
||||
*
|
||||
* 성능:
|
||||
* - 캐시 조회: O(1)
|
||||
* - 공간 필터링: O(n) with 병렬 처리 (25만건 ~50-100ms)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AisTargetService {
|
||||
|
||||
private final AisTargetRepository aisTargetRepository;
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final SpatialFilterUtil spatialFilterUtil;
|
||||
private final AisTargetFilterUtil filterUtil;
|
||||
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
||||
|
||||
private static final String SOURCE_CACHE = "cache";
|
||||
private static final String SOURCE_DB = "db";
|
||||
|
||||
// ==================== 단건 조회 ====================
|
||||
|
||||
/**
|
||||
* MMSI로 최신 위치 조회 (캐시 우선)
|
||||
*/
|
||||
public Optional<AisTargetResponseDto> findLatestByMmsi(Long mmsi) {
|
||||
log.debug("최신 위치 조회 - MMSI: {}", mmsi);
|
||||
|
||||
// 1. 캐시 조회
|
||||
Optional<AisTargetEntity> cached = cacheManager.get(mmsi);
|
||||
if (cached.isPresent()) {
|
||||
log.debug("캐시 히트 - MMSI: {}", mmsi);
|
||||
return Optional.of(AisTargetResponseDto.from(cached.get(), SOURCE_CACHE));
|
||||
}
|
||||
|
||||
// 2. DB 조회 (캐시 미스)
|
||||
log.debug("캐시 미스, DB 조회 - MMSI: {}", mmsi);
|
||||
Optional<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsi(mmsi);
|
||||
|
||||
if (fromDb.isPresent()) {
|
||||
// 3. 캐시 업데이트
|
||||
cacheManager.put(fromDb.get());
|
||||
log.debug("DB 조회 성공, 캐시 업데이트 - MMSI: {}", mmsi);
|
||||
return Optional.of(AisTargetResponseDto.from(fromDb.get(), SOURCE_DB));
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// ==================== 다건 조회 ====================
|
||||
|
||||
/**
|
||||
* 여러 MMSI의 최신 위치 조회 (캐시 우선)
|
||||
*/
|
||||
public List<AisTargetResponseDto> findLatestByMmsiList(List<Long> mmsiList) {
|
||||
if (mmsiList == null || mmsiList.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
log.debug("다건 최신 위치 조회 - 요청: {} 건", mmsiList.size());
|
||||
|
||||
List<AisTargetResponseDto> result = new ArrayList<>();
|
||||
|
||||
// 1. 캐시에서 조회
|
||||
Map<Long, AisTargetEntity> cachedData = cacheManager.getAll(mmsiList);
|
||||
for (AisTargetEntity entity : cachedData.values()) {
|
||||
result.add(AisTargetResponseDto.from(entity, SOURCE_CACHE));
|
||||
}
|
||||
|
||||
// 2. 캐시 미스 목록
|
||||
List<Long> missedMmsiList = mmsiList.stream()
|
||||
.filter(mmsi -> !cachedData.containsKey(mmsi))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 3. DB에서 캐시 미스 데이터 조회
|
||||
if (!missedMmsiList.isEmpty()) {
|
||||
log.debug("캐시 미스 DB 조회 - {} 건", missedMmsiList.size());
|
||||
List<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsiIn(missedMmsiList);
|
||||
|
||||
for (AisTargetEntity entity : fromDb) {
|
||||
// 캐시 업데이트
|
||||
cacheManager.put(entity);
|
||||
result.add(AisTargetResponseDto.from(entity, SOURCE_DB));
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("조회 완료 - 캐시: {}, DB: {}, 총: {}",
|
||||
cachedData.size(), result.size() - cachedData.size(), result.size());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 검색 조회 (캐시 기반) ====================
|
||||
|
||||
/**
|
||||
* 시간 범위 + 옵션 공간 범위로 선박 검색 (캐시 우선)
|
||||
*
|
||||
* 전략:
|
||||
* 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
* 2. 공간 필터 있으면 JTS로 필터링
|
||||
* 3. ClassType 필터 있으면 적용
|
||||
* 4. 캐시 데이터가 없으면 DB Fallback
|
||||
*/
|
||||
public List<AisTargetResponseDto> search(AisTargetSearchRequest request) {
|
||||
log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}",
|
||||
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(request.getMinutes());
|
||||
String source = SOURCE_CACHE;
|
||||
|
||||
// 캐시가 비어있으면 DB Fallback
|
||||
if (entities.isEmpty()) {
|
||||
log.debug("캐시 비어있음, DB Fallback");
|
||||
entities = searchFromDb(request);
|
||||
source = SOURCE_DB;
|
||||
|
||||
// DB 결과를 캐시에 저장
|
||||
for (AisTargetEntity entity : entities) {
|
||||
cacheManager.put(entity);
|
||||
}
|
||||
} else if (request.hasAreaFilter()) {
|
||||
// 2. 공간 필터링 (JTS 기반, 병렬 처리)
|
||||
entities = spatialFilterUtil.filterByCircle(
|
||||
entities,
|
||||
request.getCenterLon(),
|
||||
request.getCenterLat(),
|
||||
request.getRadiusMeters()
|
||||
);
|
||||
}
|
||||
|
||||
// 3. ClassType 필터 적용
|
||||
if (request.hasClassTypeFilter()) {
|
||||
entities = filterUtil.filterByClassType(entities, request);
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("선박 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms",
|
||||
source, entities.size(), elapsed);
|
||||
|
||||
final String finalSource = source;
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, finalSource))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* DB에서 검색 (Fallback)
|
||||
*/
|
||||
private List<AisTargetEntity> searchFromDb(AisTargetSearchRequest request) {
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
OffsetDateTime start = now.minusMinutes(request.getMinutes());
|
||||
|
||||
if (request.hasAreaFilter()) {
|
||||
return aisTargetRepository.findByTimeRangeAndArea(
|
||||
start, now,
|
||||
request.getCenterLon(),
|
||||
request.getCenterLat(),
|
||||
request.getRadiusMeters()
|
||||
);
|
||||
} else {
|
||||
// 공간 필터 없으면 전체 조회 (주의: 대량 데이터)
|
||||
return aisTargetRepository.findByTimeRangeAndArea(
|
||||
start, now,
|
||||
0.0, 0.0, Double.MAX_VALUE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 조건 필터 검색 ====================
|
||||
|
||||
/**
|
||||
* 항해 조건 필터 검색 (캐시 우선)
|
||||
*
|
||||
* 필터 조건:
|
||||
* - SOG (속도): 이상/초과/이하/미만/범위
|
||||
* - COG (침로): 이상/초과/이하/미만/범위
|
||||
* - Heading (선수방위): 이상/초과/이하/미만/범위
|
||||
* - Destination (목적지): 부분 일치
|
||||
* - Status (항행상태): 다중 선택
|
||||
*
|
||||
* @param request 필터 조건
|
||||
* @return 조건에 맞는 선박 목록
|
||||
*/
|
||||
public List<AisTargetResponseDto> searchByFilter(AisTargetFilterRequest request) {
|
||||
log.debug("필터 검색 - minutes: {}, hasFilter: {}",
|
||||
request.getMinutes(), request.hasAnyFilter());
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(request.getMinutes());
|
||||
String source = SOURCE_CACHE;
|
||||
|
||||
// 캐시가 비어있으면 DB Fallback
|
||||
if (entities.isEmpty()) {
|
||||
log.debug("캐시 비어있음, DB Fallback");
|
||||
entities = searchByFilterFromDb(request);
|
||||
source = SOURCE_DB;
|
||||
|
||||
// DB 결과를 캐시에 저장
|
||||
for (AisTargetEntity entity : entities) {
|
||||
cacheManager.put(entity);
|
||||
}
|
||||
|
||||
// DB에서 가져온 후에도 필터 적용 (DB 쿼리는 시간 범위만 적용)
|
||||
entities = filterUtil.filter(entities, request);
|
||||
} else {
|
||||
// 2. 캐시 데이터에 필터 적용
|
||||
entities = filterUtil.filter(entities, request);
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("필터 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms",
|
||||
source, entities.size(), elapsed);
|
||||
|
||||
final String finalSource = source;
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, finalSource))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* DB에서 필터 검색 (Fallback) - 시간 범위만 적용
|
||||
*/
|
||||
private List<AisTargetEntity> searchByFilterFromDb(AisTargetFilterRequest request) {
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
OffsetDateTime start = now.minusMinutes(request.getMinutes());
|
||||
|
||||
// DB에서는 시간 범위만 조회하고, 나머지 필터는 메모리에서 적용
|
||||
return aisTargetRepository.findByTimeRangeAndArea(
|
||||
start, now,
|
||||
0.0, 0.0, Double.MAX_VALUE
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 폴리곤 검색 ====================
|
||||
|
||||
/**
|
||||
* 폴리곤 범위 내 선박 검색 (캐시 기반)
|
||||
*
|
||||
* @param minutes 시간 범위 (분)
|
||||
* @param polygonCoordinates 폴리곤 좌표 [[lon, lat], ...]
|
||||
* @return 범위 내 선박 목록
|
||||
*/
|
||||
public List<AisTargetResponseDto> searchByPolygon(int minutes, double[][] polygonCoordinates) {
|
||||
log.debug("폴리곤 검색 - minutes: {}, points: {}", minutes, polygonCoordinates.length);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(minutes);
|
||||
|
||||
// 2. 폴리곤 필터링 (JTS 기반)
|
||||
entities = spatialFilterUtil.filterByPolygon(entities, polygonCoordinates);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("폴리곤 검색 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
|
||||
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* WKT 형식 폴리곤으로 검색
|
||||
*
|
||||
* @param minutes 시간 범위 (분)
|
||||
* @param wkt WKT 문자열 (예: "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
|
||||
* @return 범위 내 선박 목록
|
||||
*/
|
||||
public List<AisTargetResponseDto> searchByWkt(int minutes, String wkt) {
|
||||
log.debug("WKT 검색 - minutes: {}, wkt: {}", minutes, wkt);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(minutes);
|
||||
|
||||
// 2. WKT 필터링 (JTS 기반)
|
||||
entities = spatialFilterUtil.filterByWkt(entities, wkt);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("WKT 검색 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
|
||||
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 거리 포함 검색 ====================
|
||||
|
||||
/**
|
||||
* 원형 범위 검색 + 거리 정보 포함
|
||||
*/
|
||||
public List<AisTargetWithDistanceDto> searchWithDistance(
|
||||
int minutes, double centerLon, double centerLat, double radiusMeters) {
|
||||
|
||||
log.debug("거리 포함 검색 - minutes: {}, center: ({}, {}), radius: {}",
|
||||
minutes, centerLon, centerLat, radiusMeters);
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(minutes);
|
||||
|
||||
// 2. 거리 포함 필터링
|
||||
List<SpatialFilterUtil.EntityWithDistance> filtered =
|
||||
spatialFilterUtil.filterByCircleWithDistance(entities, centerLon, centerLat, radiusMeters);
|
||||
|
||||
return filtered.stream()
|
||||
.map(ewd -> new AisTargetWithDistanceDto(
|
||||
AisTargetResponseDto.from(ewd.getEntity(), SOURCE_CACHE),
|
||||
ewd.getDistanceMeters()
|
||||
))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 항적 조회 ====================
|
||||
|
||||
/**
|
||||
* 특정 MMSI의 시간 범위 내 항적 조회
|
||||
*/
|
||||
public List<AisTargetResponseDto> getTrack(Long mmsi, Integer minutes) {
|
||||
log.debug("항적 조회 - MMSI: {}, 범위: {}분", mmsi, minutes);
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
OffsetDateTime start = now.minusMinutes(minutes);
|
||||
|
||||
List<AisTargetEntity> track = aisTargetRepository.findByMmsiAndTimeRange(mmsi, start, now);
|
||||
|
||||
log.debug("항적 조회 완료 - MMSI: {}, 포인트: {} 개", mmsi, track.size());
|
||||
|
||||
return track.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, SOURCE_DB))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ==================== 중국 허가선박 전용 조회 ====================
|
||||
|
||||
/**
|
||||
* 중국 허가선박 전용 캐시 조회 (DB fallback 없음)
|
||||
*
|
||||
* @param minutes 조회 범위 (분)
|
||||
* @return 시간 범위 내 대상 선박 목록
|
||||
*/
|
||||
public List<AisTargetResponseDto> findChnPrmShip(int minutes) {
|
||||
log.debug("ChnPrmShip 조회 - minutes: {}", minutes);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
List<AisTargetEntity> entities = chnPrmShipCacheManager.getByTimeRange(minutes);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
log.info("ChnPrmShip 조회 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
|
||||
|
||||
return entities.stream()
|
||||
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* ChnPrmShip 캐시 통계 조회
|
||||
*/
|
||||
public Map<String, Object> getChnPrmShipCacheStats() {
|
||||
return chnPrmShipCacheManager.getStats();
|
||||
}
|
||||
|
||||
// ==================== 캐시 관리 ====================
|
||||
|
||||
/**
|
||||
* 캐시 통계 조회
|
||||
*/
|
||||
public Map<String, Object> getCacheStats() {
|
||||
return cacheManager.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화
|
||||
*/
|
||||
public void clearCache() {
|
||||
cacheManager.clear();
|
||||
}
|
||||
|
||||
// ==================== 내부 DTO ====================
|
||||
|
||||
/**
|
||||
* 거리 정보 포함 응답 DTO
|
||||
*/
|
||||
@lombok.Data
|
||||
@lombok.AllArgsConstructor
|
||||
public static class AisTargetWithDistanceDto {
|
||||
private AisTargetResponseDto target;
|
||||
private double distanceMeters;
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
package com.snp.batch.jobs.aistargetdbsync.batch.config;
|
||||
|
||||
import com.snp.batch.jobs.aistargetdbsync.batch.tasklet.AisTargetDbSyncTasklet;
|
||||
import com.snp.batch.jobs.aistargetdbsync.batch.tasklet.ShipLastPositionSyncTasklet;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.JobExecution;
|
||||
import org.springframework.batch.core.JobExecutionListener;
|
||||
import org.springframework.batch.core.Step;
|
||||
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
* AIS Target DB Sync Job Config
|
||||
*
|
||||
* 스케줄: 매 15분 (0 0/15 * * * ?)
|
||||
* API: 없음 (캐시 기반)
|
||||
*
|
||||
* 동작:
|
||||
* - Step 1: Caffeine 캐시에서 최근 15분 이내 데이터 조회 → ais_target DB UPSERT
|
||||
* - Step 2: 캐시에서 IMO별 최신 위치 조회 → tb_ship_main_info 위치 컬럼 UPDATE
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* - aisTargetImportJob (1분): API → 캐시 업데이트
|
||||
* - aisTargetDbSyncJob (15분): 캐시 → DB 저장 (이 Job)
|
||||
* - Step 1: 캐시 → ais_target UPSERT
|
||||
* - Step 2: 캐시 → tb_ship_main_info UPDATE (IMO별 최신 1건)
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class AisTargetDbSyncJobConfig {
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final AisTargetDbSyncTasklet aisTargetDbSyncTasklet;
|
||||
private final ShipLastPositionSyncTasklet shipLastPositionSyncTasklet;
|
||||
|
||||
public AisTargetDbSyncJobConfig(
|
||||
JobRepository jobRepository,
|
||||
PlatformTransactionManager transactionManager,
|
||||
AisTargetDbSyncTasklet aisTargetDbSyncTasklet,
|
||||
ShipLastPositionSyncTasklet shipLastPositionSyncTasklet) {
|
||||
this.jobRepository = jobRepository;
|
||||
this.transactionManager = transactionManager;
|
||||
this.aisTargetDbSyncTasklet = aisTargetDbSyncTasklet;
|
||||
this.shipLastPositionSyncTasklet = shipLastPositionSyncTasklet;
|
||||
}
|
||||
|
||||
@Bean(name = "aisTargetDbSyncStep")
|
||||
public Step aisTargetDbSyncStep() {
|
||||
return new StepBuilder("aisTargetDbSyncStep", jobRepository)
|
||||
.tasklet(aisTargetDbSyncTasklet, transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean(name = "shipLastPositionSyncStep")
|
||||
public Step shipLastPositionSyncStep() {
|
||||
return new StepBuilder("shipLastPositionSyncStep", jobRepository)
|
||||
.tasklet(shipLastPositionSyncTasklet, transactionManager)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean(name = "aisTargetDbSyncJob")
|
||||
public Job aisTargetDbSyncJob() {
|
||||
log.info("Job 생성: aisTargetDbSyncJob");
|
||||
|
||||
return new JobBuilder("aisTargetDbSyncJob", jobRepository)
|
||||
.listener(new JobExecutionListener() {
|
||||
@Override
|
||||
public void beforeJob(JobExecution jobExecution) {
|
||||
log.info("[aisTargetDbSyncJob] DB Sync Job 시작");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterJob(JobExecution jobExecution) {
|
||||
long writeCount = jobExecution.getStepExecutions().stream()
|
||||
.mapToLong(se -> se.getWriteCount())
|
||||
.sum();
|
||||
|
||||
log.info("[aisTargetDbSyncJob] DB Sync Job 완료 - 상태: {}, 저장 건수: {}",
|
||||
jobExecution.getStatus(), writeCount);
|
||||
}
|
||||
})
|
||||
.start(aisTargetDbSyncStep())
|
||||
.next(shipLastPositionSyncStep())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
package com.snp.batch.jobs.aistargetdbsync.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 캐시 기반 Ship Last Position 동기화 Repository
|
||||
*
|
||||
* AisTargetEntity(캐시 데이터)를 받아 tb_ship_main_info의 위치/항해 컬럼을 UPDATE
|
||||
*/
|
||||
public interface ShipLastPositionSyncRepository {
|
||||
|
||||
int updateLastPositions(List<AisTargetEntity> entities);
|
||||
void updateLastPositionsTemp(List<AisTargetEntity> entities);
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
package com.snp.batch.jobs.aistargetdbsync.batch.repository;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Types;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 캐시 기반 Ship Last Position 동기화 Repository 구현
|
||||
*
|
||||
* AisTargetEntity의 필드를 tb_ship_main_info 컬럼에 매핑하여 UPDATE 수행.
|
||||
* 기존 ShipLastPositionRepositoryImpl과 동일한 SQL을 사용하되,
|
||||
* AisTargetEntity의 타입(OffsetDateTime, Integer 등)에 맞게 변환 처리.
|
||||
*/
|
||||
@Slf4j
|
||||
@Repository("shipLastPositionSyncRepository")
|
||||
public class ShipLastPositionSyncRepositoryImpl implements ShipLastPositionSyncRepository {
|
||||
|
||||
private static final int BATCH_SIZE = 1000;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Value("${app.batch.service-schema.name}")
|
||||
private String targetSchema;
|
||||
|
||||
@Value("${app.batch.service-schema.tables.service-001}")
|
||||
private String tableName;
|
||||
|
||||
public ShipLastPositionSyncRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
private String getTableName() {
|
||||
return targetSchema + "." + tableName;
|
||||
}
|
||||
|
||||
private String getUpdateSql() {
|
||||
return """
|
||||
UPDATE %s
|
||||
SET last_cptr_hr_utc = ?::timestamptz,
|
||||
last_port = ?,
|
||||
now_position_lat = ?,
|
||||
now_position_lon = ?,
|
||||
ship_dest = ?,
|
||||
arvl_prnmnt_hr = ?::timestamptz,
|
||||
bow_drctn = ?,
|
||||
cog = ?,
|
||||
sog = ?,
|
||||
ship_nav_status = ?,
|
||||
cargo_ton = ?,
|
||||
add_info = ?,
|
||||
sts_yn = ?,
|
||||
ancrg_yn = ?,
|
||||
mmsi_no = ?
|
||||
WHERE imo_no = ?
|
||||
""".formatted(getTableName());
|
||||
}
|
||||
|
||||
private String getCoreUpdateSql() {
|
||||
return """
|
||||
UPDATE new_snp.core20
|
||||
SET lastseen = ?::timestamptz,
|
||||
lastport = ?,
|
||||
position_latitude = ?,
|
||||
position_longitude = ?,
|
||||
destination = ?,
|
||||
eta = ?::timestamptz,
|
||||
heading = ?,
|
||||
cog = ?,
|
||||
speedservice = ?,
|
||||
navstat = ?,
|
||||
tonnes_cargo = ?,
|
||||
extra_info = ?,
|
||||
in_sts = ?,
|
||||
on_berth = ?,
|
||||
mmsi = ?
|
||||
WHERE lrno = ?;
|
||||
""";
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int updateLastPositions(List<AisTargetEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
String sql = getUpdateSql();
|
||||
int totalUpdated = 0;
|
||||
|
||||
int[][] batchResults = jdbcTemplate.batchUpdate(sql, entities, BATCH_SIZE,
|
||||
(ps, entity) -> setUpdateParameters(ps, entity));
|
||||
|
||||
for (int[] batchResult : batchResults) {
|
||||
for (int result : batchResult) {
|
||||
if (result > 0) {
|
||||
totalUpdated += result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Ship Last Position 동기화 완료 ({}): 대상={} 건, 갱신={} 건", tableName, entities.size(), totalUpdated);
|
||||
return totalUpdated;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateLastPositionsTemp(List<AisTargetEntity> entities) {
|
||||
|
||||
String sql = getCoreUpdateSql();
|
||||
int totalUpdated = 0;
|
||||
|
||||
int[][] batchResults = jdbcTemplate.batchUpdate(sql, entities, BATCH_SIZE,
|
||||
(ps, entity) -> setUpdateParameters(ps, entity));
|
||||
|
||||
for (int[] batchResult : batchResults) {
|
||||
for (int result : batchResult) {
|
||||
if (result > 0) {
|
||||
totalUpdated += result;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("Ship Last Position 동기화 완료 (Core20): 대상={} 건, 갱신={} 건", entities.size(), totalUpdated);
|
||||
}
|
||||
|
||||
private void setUpdateParameters(PreparedStatement ps, AisTargetEntity entity) throws java.sql.SQLException {
|
||||
int idx = 1;
|
||||
|
||||
// last_cptr_hr_utc ← receivedDate (OffsetDateTime → String for ::timestamptz)
|
||||
ps.setString(idx++, entity.getReceivedDate() != null ? entity.getReceivedDate().toString() : null);
|
||||
|
||||
// last_port ← lpcCode (Integer → String)
|
||||
ps.setString(idx++, entity.getLpcCode() != null ? String.valueOf(entity.getLpcCode()) : null);
|
||||
|
||||
// now_position_lat ← lat (Double, null-safe)
|
||||
ps.setObject(idx++, entity.getLat(), Types.DOUBLE);
|
||||
|
||||
// now_position_lon ← lon (Double, null-safe)
|
||||
ps.setObject(idx++, entity.getLon(), Types.DOUBLE);
|
||||
|
||||
// ship_dest ← destination (String)
|
||||
ps.setString(idx++, entity.getDestination());
|
||||
|
||||
// arvl_prnmnt_hr ← eta (OffsetDateTime → String for ::timestamptz)
|
||||
ps.setString(idx++, entity.getEta() != null ? entity.getEta().toString() : null);
|
||||
|
||||
// bow_drctn ← heading (Double, null-safe)
|
||||
ps.setObject(idx++, entity.getHeading(), Types.DOUBLE);
|
||||
|
||||
// cog ← cog (Double, null-safe)
|
||||
ps.setObject(idx++, entity.getCog(), Types.DOUBLE);
|
||||
|
||||
// sog ← sog (Double, null-safe)
|
||||
ps.setObject(idx++, entity.getSog(), Types.DOUBLE);
|
||||
|
||||
// ship_nav_status ← status (String)
|
||||
ps.setString(idx++, entity.getStatus());
|
||||
|
||||
// cargo_ton ← tonnesCargo (Integer, null-safe)
|
||||
ps.setObject(idx++, entity.getTonnesCargo(), Types.INTEGER);
|
||||
|
||||
// add_info ← extraInfo (String)
|
||||
ps.setString(idx++, entity.getExtraInfo());
|
||||
|
||||
// sts_yn (tb_ship_main_info) / in_sts (core20) ← inSTS (Integer, null-safe)
|
||||
ps.setObject(idx++, entity.getInSTS(), Types.INTEGER);
|
||||
|
||||
// ancrg_yn ← onBerth (Boolean, null-safe)
|
||||
ps.setObject(idx++, entity.getOnBerth(), Types.BOOLEAN);
|
||||
|
||||
// mmsi_no ← mmsi (Long → 9자리 zero-padded String)
|
||||
ps.setString(idx++, entity.getMmsi() != null
|
||||
? String.format("%09d", entity.getMmsi()) : null);
|
||||
|
||||
// WHERE imo_no ← imoVerified (String)
|
||||
ps.setString(idx++, entity.getImoVerified());
|
||||
|
||||
}
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
package com.snp.batch.jobs.aistargetdbsync.batch.tasklet;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* AIS Target DB Sync Tasklet
|
||||
*
|
||||
* 동작:
|
||||
* - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 데이터를 조회
|
||||
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
|
||||
* - 캐시의 모든 컬럼 정보를 그대로 DB에 저장
|
||||
*
|
||||
* 시간 범위 결정 전략:
|
||||
* - 첫 실행 또는 마지막 실행 정보 없음 → fallback(time-range-minutes) 사용
|
||||
* - 이후 실행 → 마지막 성공 시각 기준으로 경과 시간 자동 계산
|
||||
* - cron 주기를 변경해도 별도 설정 불필요 (자동 동기화)
|
||||
*
|
||||
* 참고:
|
||||
* - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL)
|
||||
* - 기존 aisTargetImportJob은 캐시 업데이트만 수행
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AisTargetDbSyncTasklet implements Tasklet {
|
||||
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final AisTargetRepository aisTargetRepository;
|
||||
private final int fallbackMinutes;
|
||||
|
||||
/**
|
||||
* 마지막 성공 시각 (JVM 내 유지, 재기동 시 fallback 사용)
|
||||
*/
|
||||
private final AtomicReference<Instant> lastSuccessTime = new AtomicReference<>();
|
||||
|
||||
public AisTargetDbSyncTasklet(
|
||||
AisTargetCacheManager cacheManager,
|
||||
AisTargetRepository aisTargetRepository,
|
||||
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) {
|
||||
this.cacheManager = cacheManager;
|
||||
this.aisTargetRepository = aisTargetRepository;
|
||||
this.fallbackMinutes = fallbackMinutes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
Instant now = Instant.now();
|
||||
int rangeMinutes = resolveRangeMinutes(now);
|
||||
|
||||
log.info("========================================");
|
||||
log.info("AIS Target DB Sync 시작");
|
||||
log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes,
|
||||
lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback");
|
||||
log.info("현재 캐시 크기: {}", cacheManager.size());
|
||||
log.info("========================================");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(rangeMinutes);
|
||||
|
||||
if (entities.isEmpty()) {
|
||||
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes);
|
||||
lastSuccessTime.set(now);
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
log.info("캐시에서 {} 건 조회 완료", entities.size());
|
||||
|
||||
// 2. DB에 UPSERT
|
||||
aisTargetRepository.batchUpsert(entities);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
|
||||
// 성공 시각 기록
|
||||
lastSuccessTime.set(now);
|
||||
|
||||
log.info("========================================");
|
||||
log.info("AIS Target DB Sync 완료");
|
||||
log.info("저장 건수: {} 건", entities.size());
|
||||
log.info("소요 시간: {}ms", elapsed);
|
||||
log.info("========================================");
|
||||
|
||||
// Step 통계 업데이트
|
||||
contribution.incrementWriteCount(entities.size());
|
||||
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
private static final int MAX_RANGE_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* 조회 범위(분) 결정
|
||||
* - 마지막 성공 시각이 있으면: 경과 시간 + 1분 버퍼 (최대 60분)
|
||||
* - 없으면: fallback 값 사용
|
||||
* - 오래 중단 후 재가동 시에도 최대 60분으로 제한하여 과부하 방지
|
||||
*/
|
||||
private int resolveRangeMinutes(Instant now) {
|
||||
Instant last = lastSuccessTime.get();
|
||||
if (last == null) {
|
||||
return Math.min(fallbackMinutes, MAX_RANGE_MINUTES);
|
||||
}
|
||||
|
||||
long elapsedMinutes = java.time.Duration.between(last, now).toMinutes();
|
||||
// 경과 시간 + 1분 버퍼 (겹침 허용, UPSERT이므로 중복 안전), 최대 60분
|
||||
int range = (int) Math.max(elapsedMinutes + 1, 1);
|
||||
return Math.min(range, MAX_RANGE_MINUTES);
|
||||
}
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
package com.snp.batch.jobs.aistargetdbsync.batch.tasklet;
|
||||
|
||||
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
|
||||
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
|
||||
import com.snp.batch.jobs.aistargetdbsync.batch.repository.ShipLastPositionSyncRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Ship Last Position Sync Tasklet
|
||||
*
|
||||
* 동작:
|
||||
* - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 AIS 데이터를 조회
|
||||
* - imoVerified가 유효한 항목만 필터링
|
||||
* - IMO별 messageTimestamp 최신 1건만 선택
|
||||
* - tb_ship_main_info의 위치/항해 14개 컬럼을 UPDATE
|
||||
*
|
||||
* 시간 범위 결정 전략:
|
||||
* - AisTargetDbSyncTasklet과 동일 (lastSuccessTime 기반, fallback 사용)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ShipLastPositionSyncTasklet implements Tasklet {
|
||||
|
||||
private final AisTargetCacheManager cacheManager;
|
||||
private final ShipLastPositionSyncRepository repository;
|
||||
private final int fallbackMinutes;
|
||||
|
||||
private final AtomicReference<Instant> lastSuccessTime = new AtomicReference<>();
|
||||
|
||||
public ShipLastPositionSyncTasklet(
|
||||
AisTargetCacheManager cacheManager,
|
||||
ShipLastPositionSyncRepository repository,
|
||||
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) {
|
||||
this.cacheManager = cacheManager;
|
||||
this.repository = repository;
|
||||
this.fallbackMinutes = fallbackMinutes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||
Instant now = Instant.now();
|
||||
int rangeMinutes = resolveRangeMinutes(now);
|
||||
|
||||
log.info("========================================");
|
||||
log.info("Ship Last Position Sync 시작");
|
||||
log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes,
|
||||
lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback");
|
||||
log.info("========================================");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 1. 캐시에서 시간 범위 내 데이터 조회
|
||||
List<AisTargetEntity> entities = cacheManager.getByTimeRange(rangeMinutes);
|
||||
|
||||
if (entities.isEmpty()) {
|
||||
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes);
|
||||
lastSuccessTime.set(now);
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
// 2. imoVerified가 유효한 항목만 필터
|
||||
List<AisTargetEntity> imoFiltered = entities.stream()
|
||||
.filter(e -> isValidImo(e.getImoVerified()))
|
||||
.toList();
|
||||
|
||||
if (imoFiltered.isEmpty()) {
|
||||
log.info("유효한 IMO가 있는 데이터가 없습니다 (전체: {} 건)", entities.size());
|
||||
lastSuccessTime.set(now);
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
// 3. IMO별 messageTimestamp 최신 1건만 선택
|
||||
List<AisTargetEntity> latestPerImo = imoFiltered.stream()
|
||||
.collect(Collectors.groupingBy(AisTargetEntity::getImoVerified))
|
||||
.values().stream()
|
||||
.map(group -> group.stream()
|
||||
.max(Comparator.comparing(
|
||||
AisTargetEntity::getMessageTimestamp,
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())))
|
||||
.orElseThrow())
|
||||
.toList();
|
||||
|
||||
log.info("캐시 조회: {} 건 → IMO 유효: {} 건 → IMO별 최신: {} 건",
|
||||
entities.size(), imoFiltered.size(), latestPerImo.size());
|
||||
|
||||
// 4. DB UPDATE
|
||||
int updated = repository.updateLastPositions(latestPerImo);
|
||||
repository.updateLastPositionsTemp(latestPerImo); // 임시 추가
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
|
||||
lastSuccessTime.set(now);
|
||||
|
||||
log.info("========================================");
|
||||
log.info("Ship Last Position Sync 완료");
|
||||
log.info("대상: {} 건, 갱신: {} 건", latestPerImo.size(), updated);
|
||||
log.info("소요 시간: {}ms", elapsed);
|
||||
log.info("========================================");
|
||||
|
||||
contribution.incrementWriteCount(updated);
|
||||
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
private static final int MAX_RANGE_MINUTES = 60;
|
||||
|
||||
private int resolveRangeMinutes(Instant now) {
|
||||
Instant last = lastSuccessTime.get();
|
||||
if (last == null) {
|
||||
return Math.min(fallbackMinutes, MAX_RANGE_MINUTES);
|
||||
}
|
||||
|
||||
long elapsedMinutes = Duration.between(last, now).toMinutes();
|
||||
int range = (int) Math.max(elapsedMinutes + 1, 1);
|
||||
return Math.min(range, MAX_RANGE_MINUTES);
|
||||
}
|
||||
|
||||
private boolean isValidImo(String imoVerified) {
|
||||
return imoVerified != null && !imoVerified.isBlank() && !"0".equals(imoVerified);
|
||||
}
|
||||
}
|
||||
@ -53,22 +53,6 @@ spring:
|
||||
org.quartz.jobStore.isClustered: false
|
||||
org.quartz.jobStore.misfireThreshold: 60000
|
||||
|
||||
# Kafka Configuration (DEV)
|
||||
kafka:
|
||||
bootstrap-servers: localhost:9092 # TODO: DEV Kafka Broker IP/PORT 설정
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
acks: all
|
||||
retries: 3
|
||||
properties:
|
||||
enable.idempotence: true
|
||||
compression.type: snappy
|
||||
linger.ms: 20
|
||||
batch.size: 65536
|
||||
max.block.ms: 3000
|
||||
request.timeout.ms: 5000
|
||||
delivery.timeout.ms: 10000
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
@ -95,12 +79,6 @@ logging:
|
||||
app:
|
||||
batch:
|
||||
chunk-size: 1000
|
||||
ship-api:
|
||||
url: https://shipsapi.maritime.spglobal.com
|
||||
ais-api:
|
||||
url: https://aisapi.maritime.spglobal.com
|
||||
webservice-api:
|
||||
url: https://webservices.maritime.spglobal.com
|
||||
schedule:
|
||||
enabled: true
|
||||
cron: "0 0 * * * ?" # Every hour
|
||||
@ -116,48 +94,11 @@ app:
|
||||
max-retry-count: 3
|
||||
partition-count: 4 # dev: 2개 파티션
|
||||
|
||||
# AIS Target 배치 설정
|
||||
ais-target:
|
||||
since-seconds: 60 # API 조회 범위 (초)
|
||||
chunk-size: 50000 # 배치 청크 크기
|
||||
schedule:
|
||||
cron: "15 * * * * ?" # 매 분 15초 실행
|
||||
kafka:
|
||||
enabled: false
|
||||
topic: tp_Global_AIS_Signal
|
||||
send-chunk-size: 5000
|
||||
fail-on-send-error: false
|
||||
# AIS Target 캐시 설정
|
||||
ais-target-cache:
|
||||
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
|
||||
max-size: 300000 # 최대 캐시 크기 - 30만 건
|
||||
|
||||
# ClassType 분류 설정
|
||||
class-type:
|
||||
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
|
||||
|
||||
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
|
||||
core20:
|
||||
schema: std_snp_svc # 스키마명
|
||||
table: tb_ship_main_info # 테이블명
|
||||
imo-column: imo_no # IMO/LRNO 컬럼명 (PK, NOT NULL)
|
||||
mmsi-column: mmsi_no # MMSI 컬럼명 (NULLABLE)
|
||||
|
||||
# 파티션 관리 설정
|
||||
partition:
|
||||
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
|
||||
daily-tables:
|
||||
- schema: std_snp_data
|
||||
table-name: ais_target
|
||||
partition-column: message_timestamp
|
||||
periods-ahead: 3 # 미리 생성할 일수
|
||||
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
|
||||
monthly-tables: [] # 현재 없음
|
||||
# 기본 보관기간
|
||||
daily-tables: []
|
||||
monthly-tables: []
|
||||
retention:
|
||||
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
|
||||
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
|
||||
# 개별 테이블 보관기간 설정 (옵션)
|
||||
custom:
|
||||
# - table-name: ais_target
|
||||
# retention-days: 30 # ais_target만 30일 보관
|
||||
daily-default-days: 14
|
||||
monthly-default-months: 1
|
||||
custom: []
|
||||
|
||||
@ -53,22 +53,6 @@ spring:
|
||||
org.quartz.jobStore.isClustered: false
|
||||
org.quartz.jobStore.misfireThreshold: 60000
|
||||
|
||||
# Kafka Configuration (PROD)
|
||||
kafka:
|
||||
bootstrap-servers: 10.188.141.104:9092,10.188.141.105:9092,10.188.141.102:9092
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
acks: all
|
||||
retries: 3
|
||||
properties:
|
||||
enable.idempotence: true
|
||||
compression.type: snappy
|
||||
linger.ms: 20
|
||||
batch.size: 65536
|
||||
max.block.ms: 3000
|
||||
request.timeout.ms: 5000
|
||||
delivery.timeout.ms: 10000
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
@ -96,12 +80,6 @@ logging:
|
||||
app:
|
||||
batch:
|
||||
chunk-size: 1000
|
||||
ship-api:
|
||||
url: http://10.29.16.219:18030/shipsapi
|
||||
ais-api:
|
||||
url: http://10.29.16.219:18030/aisapi
|
||||
webservice-api:
|
||||
url: http://10.29.16.219:18030/webservices
|
||||
schedule:
|
||||
enabled: true
|
||||
cron: "0 0 * * * ?" # Every hour
|
||||
@ -117,48 +95,11 @@ app:
|
||||
max-retry-count: 3 # 최대 재시도 횟수
|
||||
partition-count: 4 # prod: 4개 파티션
|
||||
|
||||
# AIS Target 배치 설정
|
||||
ais-target:
|
||||
since-seconds: 60 # API 조회 범위 (초)
|
||||
chunk-size: 50000 # 배치 청크 크기
|
||||
schedule:
|
||||
cron: "15 * * * * ?" # 매 분 15초 실행
|
||||
kafka:
|
||||
enabled: true
|
||||
topic: tp_Global_AIS_Signal
|
||||
send-chunk-size: 5000
|
||||
fail-on-send-error: false
|
||||
# AIS Target 캐시 설정
|
||||
ais-target-cache:
|
||||
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
|
||||
max-size: 300000 # 최대 캐시 크기 - 30만 건
|
||||
|
||||
# ClassType 분류 설정
|
||||
class-type:
|
||||
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
|
||||
|
||||
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
|
||||
core20:
|
||||
schema: std_snp_svc # 스키마명
|
||||
table: tb_ship_main_info # 테이블명
|
||||
imo-column: imo_no # IMO/LRNO 컬럼명 (PK, NOT NULL)
|
||||
mmsi-column: mmsi_no # MMSI 컬럼명 (NULLABLE)
|
||||
|
||||
# 파티션 관리 설정
|
||||
partition:
|
||||
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
|
||||
daily-tables:
|
||||
- schema: std_snp_data
|
||||
table-name: ais_target
|
||||
partition-column: message_timestamp
|
||||
periods-ahead: 3 # 미리 생성할 일수
|
||||
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
|
||||
monthly-tables: [] # 현재 없음
|
||||
# 기본 보관기간
|
||||
daily-tables: []
|
||||
monthly-tables: []
|
||||
retention:
|
||||
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
|
||||
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
|
||||
# 개별 테이블 보관기간 설정 (옵션)
|
||||
custom:
|
||||
# - table-name: ais_target
|
||||
# retention-days: 30 # ais_target만 30일 보관
|
||||
daily-default-days: 14
|
||||
monthly-default-months: 1
|
||||
custom: []
|
||||
|
||||
@ -53,22 +53,6 @@ spring:
|
||||
org.quartz.jobStore.isClustered: false
|
||||
org.quartz.jobStore.misfireThreshold: 60000
|
||||
|
||||
# Kafka Configuration
|
||||
kafka:
|
||||
bootstrap-servers: localhost:9092
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
acks: all
|
||||
retries: 3
|
||||
properties:
|
||||
enable.idempotence: true
|
||||
compression.type: snappy
|
||||
linger.ms: 20
|
||||
batch.size: 65536
|
||||
max.block.ms: 3000
|
||||
request.timeout.ms: 5000
|
||||
delivery.timeout.ms: 10000
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
@ -181,48 +165,6 @@ app:
|
||||
max-retry-count: 3 # 최대 재시도 횟수
|
||||
partition-count: 4 # 병렬 파티션 수
|
||||
|
||||
# AIS Target Import 배치 설정 (캐시 업데이트 전용)
|
||||
ais-target:
|
||||
since-seconds: 60 # API 조회 범위 (초)
|
||||
chunk-size: 50000 # 배치 청크 크기
|
||||
schedule:
|
||||
cron: "15 * * * * ?" # 매 분 15초 실행
|
||||
kafka:
|
||||
enabled: false # true로 변경 시 Kafka 브로커 연결 필요
|
||||
topic: tp_Global_AIS_Signal
|
||||
send-chunk-size: 5000
|
||||
fail-on-send-error: false
|
||||
|
||||
# AIS Target DB Sync 배치 설정 (캐시 → DB 저장)
|
||||
ais-target-db-sync:
|
||||
time-range-minutes: 15 # 캐시에서 조회할 시간 범위 (분)
|
||||
schedule:
|
||||
cron: "0 0/15 * * * ?" # 매 15분 정각 실행 (00, 15, 30, 45분)
|
||||
|
||||
# AIS Target 캐시 설정
|
||||
ais-target-cache:
|
||||
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
|
||||
max-size: 300000 # 최대 캐시 크기 - 30만 건
|
||||
|
||||
# 중국 허가선박 전용 캐시 설정
|
||||
chnprmship:
|
||||
mmsi-resource-path: classpath:chnprmship-mmsi.txt
|
||||
ttl-days: 2
|
||||
max-size: 2000
|
||||
warmup-enabled: true
|
||||
warmup-days: 2
|
||||
|
||||
# ClassType 분류 설정
|
||||
class-type:
|
||||
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
|
||||
|
||||
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
|
||||
core20:
|
||||
schema: std_snp_svc # 스키마명
|
||||
table: tb_ship_main_info # 테이블명
|
||||
imo-column: imo_no # IMO/LRNO 컬럼명 (PK, NOT NULL)
|
||||
mmsi-column: mmsi_no # MMSI 컬럼명 (NULLABLE)
|
||||
|
||||
# 배치 로그 정리 설정
|
||||
log-cleanup:
|
||||
api-log-retention-days: 30 # batch_api_log 보존 기간
|
||||
@ -233,18 +175,11 @@ app:
|
||||
# 파티션 관리 설정
|
||||
partition:
|
||||
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
|
||||
daily-tables:
|
||||
- schema: std_snp_data
|
||||
table-name: ais_target
|
||||
partition-column: message_timestamp
|
||||
periods-ahead: 3 # 미리 생성할 일수
|
||||
daily-tables: []
|
||||
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
|
||||
monthly-tables: [] # 현재 없음
|
||||
monthly-tables: []
|
||||
# 기본 보관기간
|
||||
retention:
|
||||
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
|
||||
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
|
||||
# 개별 테이블 보관기간 설정 (옵션)
|
||||
custom:
|
||||
# - table-name: ais_target
|
||||
# retention-days: 30 # ais_target만 30일 보관
|
||||
custom: []
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
불러오는 중...
Reference in New Issue
Block a user