From e9b30f8817ce51a9572d65dbe01f7911aa93a002 Mon Sep 17 00:00:00 2001 From: hyojin kim Date: Tue, 2 Dec 2025 12:26:49 +0900 Subject: [PATCH 1/7] =?UTF-8?q?:card=5Ffile=5Fbox:=20JPA=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=A7=80=EC=A0=95=20(snp=5Fdata)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 2 +- src/main/resources/application-prod.yml | 2 +- src/main/resources/application-qa.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2f36e2b..4987575 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,7 +22,7 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true - default_schema: public + default_schema: snp_data # Batch Configuration batch: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 36d6937..14efda7 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -22,7 +22,7 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true - default_schema: public + default_schema: snp_data # Batch Configuration batch: diff --git a/src/main/resources/application-qa.yml b/src/main/resources/application-qa.yml index fa83ad1..03f8e3b 100644 --- a/src/main/resources/application-qa.yml +++ b/src/main/resources/application-qa.yml @@ -22,7 +22,7 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true - default_schema: public + default_schema: snp_data # Batch Configuration batch: From 37f61fe9241b5f5877c76c704f76f21a695e73e8 Mon Sep 17 00:00:00 2001 From: hyojin kim Date: Tue, 2 Dec 2025 18:26:54 +0900 Subject: [PATCH 2/7] :sparkles: Add Port Import Job, Event Import Job --- .../batch/config/EventImportJobConfig.java | 84 +++++++ .../batch/jobs/event/batch/dto/EventDto.java | 51 ++++ .../jobs/event/batch/dto/EventResponse.java | 21 ++ .../jobs/event/batch/entity/EventEntity.java | 31 +++ .../batch/processor/EventDataProcessor.java | 35 +++ .../event/batch/reader/EventDataReader.java | 64 +++++ .../batch/repository/EventRepository.java | 9 + .../batch/repository/EventRepositoryImpl.java | 133 ++++++++++ .../event/batch/writer/EventDataWriter.java | 26 ++ .../batch/config/PortImportJobConfig.java | 84 +++++++ .../jobs/facility/batch/dto/PortDto.java | 172 +++++++++++++ .../jobs/facility/batch/dto/PortResponse.java | 16 ++ .../facility/batch/entity/PortEntity.java | 78 ++++++ .../batch/processor/PortDataProcessor.java | 80 ++++++ .../facility/batch/reader/PortDataReader.java | 69 +++++ .../batch/repository/FacilityRepository.java | 9 + .../repository/FacilityRepositoryImpl.java | 237 ++++++++++++++++++ .../facility/batch/writer/PortDataWriter.java | 26 ++ 18 files changed, 1225 insertions(+) create mode 100644 src/main/java/com/snp/batch/jobs/event/batch/config/EventImportJobConfig.java create mode 100644 src/main/java/com/snp/batch/jobs/event/batch/dto/EventDto.java create mode 100644 src/main/java/com/snp/batch/jobs/event/batch/dto/EventResponse.java create mode 100644 src/main/java/com/snp/batch/jobs/event/batch/entity/EventEntity.java create mode 100644 src/main/java/com/snp/batch/jobs/event/batch/processor/EventDataProcessor.java create mode 100644 src/main/java/com/snp/batch/jobs/event/batch/reader/EventDataReader.java create mode 100644 src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepository.java create mode 100644 src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepositoryImpl.java create mode 100644 src/main/java/com/snp/batch/jobs/event/batch/writer/EventDataWriter.java create mode 100644 src/main/java/com/snp/batch/jobs/facility/batch/config/PortImportJobConfig.java create mode 100644 src/main/java/com/snp/batch/jobs/facility/batch/dto/PortDto.java create mode 100644 src/main/java/com/snp/batch/jobs/facility/batch/dto/PortResponse.java create mode 100644 src/main/java/com/snp/batch/jobs/facility/batch/entity/PortEntity.java create mode 100644 src/main/java/com/snp/batch/jobs/facility/batch/processor/PortDataProcessor.java create mode 100644 src/main/java/com/snp/batch/jobs/facility/batch/reader/PortDataReader.java create mode 100644 src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepository.java create mode 100644 src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepositoryImpl.java create mode 100644 src/main/java/com/snp/batch/jobs/facility/batch/writer/PortDataWriter.java diff --git a/src/main/java/com/snp/batch/jobs/event/batch/config/EventImportJobConfig.java b/src/main/java/com/snp/batch/jobs/event/batch/config/EventImportJobConfig.java new file mode 100644 index 0000000..9c35796 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/config/EventImportJobConfig.java @@ -0,0 +1,84 @@ +package com.snp.batch.jobs.event.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.event.batch.dto.EventDto; +import com.snp.batch.jobs.event.batch.entity.EventEntity; +import com.snp.batch.jobs.event.batch.processor.EventDataProcessor; +import com.snp.batch.jobs.event.batch.reader.EventDataReader; +import com.snp.batch.jobs.event.batch.writer.EventDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +public class EventImportJobConfig extends BaseJobConfig { + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + private final EventDataProcessor eventDataProcessor; + + private final EventDataWriter eventDataWriter; + + @Override + protected int getChunkSize() { + return 5000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정 + } + public EventImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + EventDataProcessor eventDataProcessor, + EventDataWriter eventDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeApiWebClient")WebClient maritimeApiWebClient) { + super(jobRepository, transactionManager); + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + this.eventDataProcessor = eventDataProcessor; + this.eventDataWriter = eventDataWriter; + } + + @Override + protected String getJobName() { + return "eventImportJob"; + } + + @Override + protected String getStepName() { + return "eventImportStep"; + } + + @Override + protected ItemReader createReader() { + return new EventDataReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return eventDataProcessor; + } + + @Override + protected ItemWriter createWriter() { return eventDataWriter; } + + @Bean(name = "eventImportJob") + public Job eventImportJob() { + return job(); + } + + @Bean(name = "eventImportStep") + public Step eventImportStep() { + return step(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/event/batch/dto/EventDto.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventDto.java new file mode 100644 index 0000000..00d2e07 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventDto.java @@ -0,0 +1,51 @@ +package com.snp.batch.jobs.event.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventDto { + @JsonProperty("IncidentID") + private Long incidentId; + + @JsonProperty("EventID") + private Long eventId; + + @JsonProperty("StartDate") + private String startDate; + + @JsonProperty("EventType") + private String eventType; + + @JsonProperty("Significance") + private String significance; + + @JsonProperty("Headline") + private String headline; + + @JsonProperty("EndDate") + private String endDate; + + @JsonProperty("IHSLRorIMOShipNo") + private String ihslRorImoShipNo; + + @JsonProperty("VesselName") + private String vesselName; + + @JsonProperty("VesselType") + private String vesselType; + + @JsonProperty("LocationName") + private String locationName; + + @JsonProperty("PublishedDate") + private String publishedDate; + +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/event/batch/dto/EventResponse.java b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventResponse.java new file mode 100644 index 0000000..7c9118c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/dto/EventResponse.java @@ -0,0 +1,21 @@ +package com.snp.batch.jobs.event.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EventResponse { + @JsonProperty("EventCount") + private Integer eventCount; + + @JsonProperty("MaritimeEvents") + private List MaritimeEvents; +} diff --git a/src/main/java/com/snp/batch/jobs/event/batch/entity/EventEntity.java b/src/main/java/com/snp/batch/jobs/event/batch/entity/EventEntity.java new file mode 100644 index 0000000..19352c4 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/entity/EventEntity.java @@ -0,0 +1,31 @@ +package com.snp.batch.jobs.event.batch.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import jakarta.persistence.Embedded; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class EventEntity extends BaseEntity { + + private Long incidentId; + private Long eventId; + private String startDate; + private String eventType; + private String significance; + private String headline; + private String endDate; + private String ihslRorImoShipNo; + private String vesselName; + private String vesselType; + private String locationName; + private String publishedDate; + +} diff --git a/src/main/java/com/snp/batch/jobs/event/batch/processor/EventDataProcessor.java b/src/main/java/com/snp/batch/jobs/event/batch/processor/EventDataProcessor.java new file mode 100644 index 0000000..329ab54 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/processor/EventDataProcessor.java @@ -0,0 +1,35 @@ +package com.snp.batch.jobs.event.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.event.batch.dto.EventDto; +import com.snp.batch.jobs.event.batch.entity.EventEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class EventDataProcessor extends BaseProcessor { + @Override + protected EventEntity processItem(EventDto dto) throws Exception { + log.debug("Event 데이터 처리 시작: Event ID = {}", dto.getEventId()); + + EventEntity entity = EventEntity.builder() + .incidentId(dto.getIncidentId()) + .eventId(dto.getEventId()) + .startDate(dto.getStartDate()) + .eventType(dto.getEventType()) + .significance(dto.getSignificance()) + .headline(dto.getHeadline()) + .endDate(dto.getEndDate()) + .ihslRorImoShipNo(dto.getIhslRorImoShipNo()) + .vesselName(dto.getVesselName()) + .vesselType(dto.getVesselType()) + .locationName(dto.getLocationName()) + .publishedDate(dto.getPublishedDate()) + .build(); + + log.debug("Event 데이터 처리 완료: Event ID = {}", dto.getEventId()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/event/batch/reader/EventDataReader.java b/src/main/java/com/snp/batch/jobs/event/batch/reader/EventDataReader.java new file mode 100644 index 0000000..ba15574 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/reader/EventDataReader.java @@ -0,0 +1,64 @@ +package com.snp.batch.jobs.event.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.event.batch.dto.EventDto; +import com.snp.batch.jobs.event.batch.dto.EventResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public class EventDataReader extends BaseApiReader { + private final JdbcTemplate jdbcTemplate; + + public EventDataReader(WebClient webClient, JdbcTemplate jdbcTemplate) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + } + + @Override + protected String getReaderName() { + return "EventDataReader"; + } + + @Override + protected String getApiPath() { + return "MaritimeWCF/MaritimeAndTradeEventsService.svc/RESTFul/GetEventListByEventChangeDateRange?fromYear=2025&fromMonth=01&fromDay=01&fromHour=00&fromMinute=00&toYear=2025&toMonth=12&toDay=31&toHour=00&toMinute=00"; + } + + @Override + protected List fetchDataFromApi() { + try { + log.info("Event API 호출 시작"); + EventResponse response = callEventApiWithBatch(); + + if (response != null && response.getMaritimeEvents() != null) { + log.info("API 응답 성공: 총 {} 개의 Event 데이터 수신", response.getEventCount()); + return response.getMaritimeEvents(); + } else { + log.warn("API 응답이 null이거나 Event 데이터가 없습니다"); + return new ArrayList<>(); + } + + } catch (Exception e) { + log.error("Event API 호출 실패", e); + log.error("에러 메시지: {}", e.getMessage()); + return new ArrayList<>(); + } + } + + private EventResponse callEventApiWithBatch() { + String url = getApiPath(); + log.debug("[{}] API 호출: {}", getReaderName(), url); + return webClient.get() + .uri(url) + .retrieve() + .bodyToMono(EventResponse.class) + .block(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepository.java b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepository.java new file mode 100644 index 0000000..2448130 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.event.batch.repository; + +import com.snp.batch.jobs.event.batch.entity.EventEntity; + +import java.util.List; + +public interface EventRepository { + void saveEventAll(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepositoryImpl.java new file mode 100644 index 0000000..a45c451 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/repository/EventRepositoryImpl.java @@ -0,0 +1,133 @@ +package com.snp.batch.jobs.event.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.event.batch.entity.EventEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("EventRepository") +public class EventRepositoryImpl extends BaseJdbcRepository implements EventRepository { + + public EventRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return null; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected Long extractId(EventEntity entity) { + return null; + } + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return """ + INSERT INTO snp_data.event ( + Event_ID, Incident_ID, IHSLRorIMOShipNo, Vessel_Name, Vessel_Type, + Event_Type, Significance, Headline, Location_Name, + Published_Date, Event_Start_Date, Event_End_Date, batch_flag + ) VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?::timestamptz, ?::timestamptz, ?::timestamptz, 'N' + ) ON CONFLICT (Event_ID) DO UPDATE + SET + Incident_ID = EXCLUDED.Incident_ID, + IHSLRorIMOShipNo = EXCLUDED.IHSLRorIMOShipNo, + Vessel_Name = EXCLUDED.Vessel_Name, + Vessel_Type = EXCLUDED.Vessel_Type, + Event_Type = EXCLUDED.Event_Type, + Significance = EXCLUDED.Significance, + Headline = EXCLUDED.Headline, + Location_Name = EXCLUDED.Location_Name, + Published_Date = EXCLUDED.Published_Date, + Event_Start_Date = EXCLUDED.Event_Start_Date, + Event_End_Date = EXCLUDED.Event_End_Date, + batch_flag = 'N' + ; + """; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, EventEntity entity) throws Exception { + + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, EventEntity entity) throws Exception { + int idx = 1; + ps.setLong(idx++, entity.getEventId()); + ps.setLong(idx++, entity.getIncidentId()); + ps.setString(idx++, entity.getIhslRorImoShipNo()); + ps.setString(idx++, entity.getVesselName()); + ps.setString(idx++, entity.getVesselType()); + ps.setString(idx++, entity.getEventType()); + ps.setString(idx++, entity.getSignificance()); + ps.setString(idx++, entity.getHeadline()); + ps.setString(idx++, entity.getLocationName()); + ps.setString(idx++, entity.getPublishedDate()); + ps.setString(idx++, entity.getStartDate()); + ps.setString(idx++, entity.getEndDate()); + } + + @Override + protected String getEntityName() { + return "EventEntity"; + } + + @Override + public void saveEventAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 수정 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size()); + } + + private static void setStringOrNull(PreparedStatement ps, int index, String value) throws Exception { + if (value == null) { + ps.setNull(index, Types.VARCHAR); + } else { + ps.setString(index, value); + } + } + /** + * Double 값을 PreparedStatement에 설정 (null 처리 포함) + */ + private static void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value == null) { + ps.setNull(index, Types.DOUBLE); + } else { + ps.setDouble(index, value); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/event/batch/writer/EventDataWriter.java b/src/main/java/com/snp/batch/jobs/event/batch/writer/EventDataWriter.java new file mode 100644 index 0000000..5914587 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/event/batch/writer/EventDataWriter.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.event.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.event.batch.entity.EventEntity; +import com.snp.batch.jobs.event.batch.repository.EventRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class EventDataWriter extends BaseWriter { + + private final EventRepository eventRepository; + public EventDataWriter(EventRepository eventRepository) { + super("EventRepository"); + this.eventRepository = eventRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + eventRepository.saveEventAll(items); + log.info("Event 저장 완료: 수정={} 건", items.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/config/PortImportJobConfig.java b/src/main/java/com/snp/batch/jobs/facility/batch/config/PortImportJobConfig.java new file mode 100644 index 0000000..bcbc2f6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/config/PortImportJobConfig.java @@ -0,0 +1,84 @@ +package com.snp.batch.jobs.facility.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.facility.batch.dto.PortDto; +import com.snp.batch.jobs.facility.batch.entity.PortEntity; +import com.snp.batch.jobs.facility.batch.processor.PortDataProcessor; +import com.snp.batch.jobs.facility.batch.reader.PortDataReader; +import com.snp.batch.jobs.facility.batch.writer.PortDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +public class PortImportJobConfig extends BaseJobConfig { + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeServiceApiWebClient; + + private final PortDataProcessor portDataProcessor; + + private final PortDataWriter portDataWriter; + + @Override + protected int getChunkSize() { + return 5000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정 + } + public PortImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + PortDataProcessor portDataProcessor, + PortDataWriter portDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient) { + super(jobRepository, transactionManager); + this.jdbcTemplate = jdbcTemplate; + this.maritimeServiceApiWebClient = maritimeServiceApiWebClient; + this.portDataProcessor = portDataProcessor; + this.portDataWriter = portDataWriter; + } + + @Override + protected String getJobName() { + return "portImportJob"; + } + + @Override + protected String getStepName() { + return "portImportStep"; + } + + @Override + protected ItemReader createReader() { + return new PortDataReader(maritimeServiceApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return portDataProcessor; + } + + @Override + protected ItemWriter createWriter() { return portDataWriter; } + + @Bean(name = "portImportJob") + public Job portImportJob() { + return job(); + } + + @Bean(name = "portImportStep") + public Step portImportStep() { + return step(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortDto.java b/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortDto.java new file mode 100644 index 0000000..f324028 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortDto.java @@ -0,0 +1,172 @@ +package com.snp.batch.jobs.facility.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortDto { + @JsonProperty("port_ID") + private Long portId; + + @JsonProperty("old_ID") + private String oldId; + + @JsonProperty("status") + private String status; + + @JsonProperty("port_Name") + private String portName; + + @JsonProperty("unlocode") + private String unlocode; + + @JsonProperty("countryCode") + private String countryCode; + + @JsonProperty("country_Name") + private String countryName; + + @JsonProperty("dec_Lat") + private Double decLat; + + @JsonProperty("dec_Long") + private Double decLong; + + private PositionDto position; + + @JsonProperty("time_Zone") + private String timeZone; + + @JsonProperty("dayLight_Saving_Time") + private Boolean dayLightSavingTime; + + @JsonProperty("maximum_Draft") + private Double maximumDraft; + + @JsonProperty("breakbulk_Facilities") + private Boolean breakbulkFacilities; + + @JsonProperty("container_Facilities") + private Boolean containerFacilities; + + @JsonProperty("dry_Bulk_Facilities") + private Boolean dryBulkFacilities; + + @JsonProperty("liquid_Facilities") + private Boolean liquidFacilities; + + @JsonProperty("roRo_Facilities") + private Boolean roRoFacilities; + + @JsonProperty("passenger_Facilities") + private Boolean passengerFacilities; + + @JsonProperty("dry_Dock_Facilities") + private Boolean dryDockFacilities; + + @JsonProperty("lpG_Facilities") + private Integer lpgFacilities; + + @JsonProperty("lnG_Facilities") + private Integer lngFacilities; + + @JsonProperty("ispS_Compliant") + private Boolean ispsCompliant; + + @JsonProperty("csI_Compliant") + private Boolean csiCompliant; + + @JsonProperty("last_Update") + private String lastUpdate; + + @JsonProperty("entry_Date") + private String entryDate; + + @JsonProperty("region_Name") + private String regionName; + + @JsonProperty("continent_Name") + private String continentName; + + @JsonProperty("master_POID") + private String masterPoid; + + @JsonProperty("wS_Port") + private Integer wsPort; + + @JsonProperty("max_LOA") + private Double maxLoa; + + @JsonProperty("max_Beam") + private Double maxBeam; + + @JsonProperty("max_DWT") + private Double maxDwt; + + @JsonProperty("max_Offshore_Draught") + private Double maxOffshoreDraught; + + @JsonProperty("max_Offshore_LOA") + private Double maxOffshoreLoa; + + @JsonProperty("max_Offshore_BCM") + private Double maxOffshoreBcm; + + @JsonProperty("max_Offshore_DWT") + private Double maxOffshoreDwt; + + @JsonProperty("lnG_Bunker") + private Boolean lngBunker; + + @JsonProperty("dO_Bunker") + private Boolean doBunker; + + @JsonProperty("fO_Bunker") + private Boolean foBunker; + + @JsonProperty("free_Trade_Zone") + private Boolean freeTradeZone; + + @JsonProperty("ecO_Port") + private Boolean ecoPort; + + @JsonProperty("emission_Control_Area") + private Boolean emissionControlArea; + @Data + @SuperBuilder + @NoArgsConstructor + @AllArgsConstructor + public static class PositionDto { + + @JsonProperty("isNull") + private Boolean isNull; + + @JsonProperty("stSrid") + private Integer stSrid; + + @JsonProperty("lat") + private Double lat; + + @JsonProperty("long") // JSON 키가 Java 예약어 'long'이므로 @JsonProperty를 사용 + private Double longitude; + @JsonProperty("z") + private Object z; + @JsonProperty("m") + private Object m; + + @JsonProperty("hasZ") + private Boolean hasZ; + + @JsonProperty("hasM") + private Boolean hasM; + + } + +} \ No newline at end of file diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortResponse.java b/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortResponse.java new file mode 100644 index 0000000..99918db --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/dto/PortResponse.java @@ -0,0 +1,16 @@ +package com.snp.batch.jobs.facility.batch.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortResponse { + private List portDtoList; +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/entity/PortEntity.java b/src/main/java/com/snp/batch/jobs/facility/batch/entity/PortEntity.java new file mode 100644 index 0000000..b4b7875 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/entity/PortEntity.java @@ -0,0 +1,78 @@ +package com.snp.batch.jobs.facility.batch.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import jakarta.persistence.Embedded; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class PortEntity extends BaseEntity { + + private Long portID; + private String oldID; + private String status; + private String portName; + private String unlocode; + private String countryCode; + private String countryName; + private Double decLat; + private Double decLong; + @Embedded + private PositionEntity position; + private String timeZone; + private Boolean dayLightSavingTime; + private Double maximumDraft; + private Boolean breakbulkFacilities; + private Boolean containerFacilities; + private Boolean dryBulkFacilities; + private Boolean liquidFacilities; + private Boolean roRoFacilities; + private Boolean passengerFacilities; + private Boolean dryDockFacilities; + private Integer lpGFacilities; + private Integer lnGFacilities; + private Boolean ispsCompliant; + private Boolean csiCompliant; + private String lastUpdate; + private String entryDate; + private String regionName; + private String continentName; + private String masterPoid; + private Integer wsPort; + private Double maxLoa; + private Double maxBeam; + private Double maxDwt; + private Double maxOffshoreDraught; + private Double maxOffshoreLoa; + private Double maxOffshoreBcm; + private Double maxOffshoreDwt; + private Boolean lngBunker; + private Boolean doBunker; + private Boolean foBunker; + private Boolean freeTradeZone; + private Boolean ecoPort; + private Boolean emissionControlArea; + + @Data + @SuperBuilder + @NoArgsConstructor + @AllArgsConstructor + public static class PositionEntity { + private Boolean isNull; + private Integer stSrid; + private Double lat; + private Double longitude; + private Object z; + private Object m; + private Boolean hasZ; + private Boolean hasM; + } + +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/processor/PortDataProcessor.java b/src/main/java/com/snp/batch/jobs/facility/batch/processor/PortDataProcessor.java new file mode 100644 index 0000000..2e0ce64 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/processor/PortDataProcessor.java @@ -0,0 +1,80 @@ +package com.snp.batch.jobs.facility.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.facility.batch.dto.PortDto; +import com.snp.batch.jobs.facility.batch.entity.PortEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class PortDataProcessor extends BaseProcessor { + @Override + protected PortEntity processItem(PortDto dto) throws Exception { + log.debug("Port 데이터 처리 시작: Port ID = {}", dto.getPortId()); + + PortEntity.PositionEntity positionEntity = null; + if (dto.getPosition() != null) { + positionEntity = PortEntity.PositionEntity.builder() + .isNull(dto.getPosition().getIsNull()) + .stSrid(dto.getPosition().getStSrid()) + .lat(dto.getPosition().getLat()) + .longitude(dto.getPosition().getLongitude()) + .z(dto.getPosition().getZ()) + .m(dto.getPosition().getM()) + .hasZ(dto.getPosition().getHasZ()) + .hasM(dto.getPosition().getHasM()) + .build(); + } + + PortEntity entity = PortEntity.builder() + .portID(dto.getPortId()) + .oldID(dto.getOldId()) + .status(dto.getStatus()) + .portName(dto.getPortName()) + .unlocode(dto.getUnlocode()) + .countryCode(dto.getCountryCode()) + .countryName(dto.getCountryName()) + .decLat(dto.getDecLat()) + .decLong(dto.getDecLong()) + .position(positionEntity) // 변환된 PositionEntity 객체 + .timeZone(dto.getTimeZone()) + .dayLightSavingTime(dto.getDayLightSavingTime()) + .maximumDraft(dto.getMaximumDraft()) + .breakbulkFacilities(dto.getBreakbulkFacilities()) + .containerFacilities(dto.getContainerFacilities()) + .dryBulkFacilities(dto.getDryBulkFacilities()) + .liquidFacilities(dto.getLiquidFacilities()) + .roRoFacilities(dto.getRoRoFacilities()) + .passengerFacilities(dto.getPassengerFacilities()) + .dryDockFacilities(dto.getDryDockFacilities()) + .lpGFacilities(dto.getLpgFacilities()) + .lnGFacilities(dto.getLngFacilities()) + .ispsCompliant(dto.getIspsCompliant()) + .csiCompliant(dto.getCsiCompliant()) + .lastUpdate(dto.getLastUpdate()) + .entryDate(dto.getEntryDate()) + .regionName(dto.getRegionName()) + .continentName(dto.getContinentName()) + .masterPoid(dto.getMasterPoid()) + .wsPort(dto.getWsPort()) + .maxLoa(dto.getMaxLoa()) + .maxBeam(dto.getMaxBeam()) + .maxDwt(dto.getMaxDwt()) + .maxOffshoreDraught(dto.getMaxOffshoreDraught()) + .maxOffshoreLoa(dto.getMaxOffshoreLoa()) + .maxOffshoreBcm(dto.getMaxOffshoreBcm()) + .maxOffshoreDwt(dto.getMaxOffshoreDwt()) + .lngBunker(dto.getLngBunker()) + .doBunker(dto.getDoBunker()) + .foBunker(dto.getFoBunker()) + .freeTradeZone(dto.getFreeTradeZone()) + .ecoPort(dto.getEcoPort()) + .emissionControlArea(dto.getEmissionControlArea()) + .build(); + + log.debug("Port 데이터 처리 완료: Port ID = {}", dto.getPortId()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/reader/PortDataReader.java b/src/main/java/com/snp/batch/jobs/facility/batch/reader/PortDataReader.java new file mode 100644 index 0000000..42b7dae --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/reader/PortDataReader.java @@ -0,0 +1,69 @@ +package com.snp.batch.jobs.facility.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.facility.batch.dto.PortDto; +import com.snp.batch.jobs.shipimport.batch.dto.ShipApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Slf4j +public class PortDataReader extends BaseApiReader { + private final JdbcTemplate jdbcTemplate; + private List allImoNumbers; + private int currentBatchIndex = 0; + private final int batchSize = 100; + + public PortDataReader(WebClient webClient, JdbcTemplate jdbcTemplate) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + } + + @Override + protected String getReaderName() { + return "PortDataReader"; + } + + @Override + protected String getApiPath() { + return "/Facilities/Ports"; + } + + @Override + protected List fetchDataFromApi() { + try { + log.info("Facility Port API 호출 시작"); + + List response = callFacilityPortApiWithBatch(); + + if (response != null) { + log.info("API 응답 성공: 총 {} 개의 Port 데이터 수신", response.size()); + return response; + } else { + log.warn("API 응답이 null이거나 Port 데이터가 없습니다"); + return new ArrayList<>(); + } + + } catch (Exception e) { + log.error("Facility Port API 호출 실패", e); + log.error("에러 메시지: {}", e.getMessage()); + return new ArrayList<>(); + } + } + + private List callFacilityPortApiWithBatch() { + String url = getApiPath(); + log.debug("[{}] API 호출: {}", getReaderName(), url); + return webClient.get() + .uri(url) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + } + +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepository.java b/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepository.java new file mode 100644 index 0000000..358b068 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepository.java @@ -0,0 +1,9 @@ +package com.snp.batch.jobs.facility.batch.repository; + +import com.snp.batch.jobs.facility.batch.entity.PortEntity; + +import java.util.List; + +public interface FacilityRepository { + void savePortAll(List items); +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepositoryImpl.java new file mode 100644 index 0000000..1ef9e0c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/repository/FacilityRepositoryImpl.java @@ -0,0 +1,237 @@ +package com.snp.batch.jobs.facility.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.facility.batch.entity.PortEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.util.List; + +@Slf4j +@Repository("FacilityRepository") +public class FacilityRepositoryImpl extends BaseJdbcRepository implements FacilityRepository { + + public FacilityRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return null; + } + + @Override + protected RowMapper getRowMapper() { + return null; + } + + @Override + protected Long extractId(PortEntity entity) { + return null; + } + + @Override + protected String getInsertSql() { + return null; + } + + @Override + protected String getUpdateSql() { + return """ + INSERT INTO snp_data.facility_port ( + port_ID, old_ID, status, port_Name, unlocode, countryCode, country_Name, region_Name, continent_Name, master_POID, + dec_Lat, dec_Long, position_lat, position_long, position_z, position_m, position_hasZ, position_hasM, position_isNull, position_stSrid, time_Zone, dayLight_Saving_Time, + maximum_Draft, max_LOA, max_Beam, max_DWT, max_Offshore_Draught, max_Offshore_LOA, max_Offshore_BCM, max_Offshore_DWT, + breakbulk_Facilities, container_Facilities, dry_Bulk_Facilities, liquid_Facilities, roRo_Facilities, passenger_Facilities, dry_Dock_Facilities, + lpG_Facilities, lnG_Facilities, lnG_Bunker, dO_Bunker, fO_Bunker, ispS_Compliant, csI_Compliant, free_Trade_Zone, ecO_Port, emission_Control_Area, wS_Port, + last_Update, entry_Date, batch_flag + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?::timestamptz, ?::timestamptz, 'N' + ) ON CONFLICT (port_ID) DO UPDATE + SET + old_ID = EXCLUDED.old_ID, + status = EXCLUDED.status, + port_Name = EXCLUDED.port_Name, + unlocode = EXCLUDED.unlocode, + countryCode = EXCLUDED.countryCode, + country_Name = EXCLUDED.country_Name, + region_Name = EXCLUDED.region_Name, + continent_Name = EXCLUDED.continent_Name, + master_POID = EXCLUDED.master_POID, + dec_Lat = EXCLUDED.dec_Lat, + dec_Long = EXCLUDED.dec_Long, + position_lat = EXCLUDED.position_lat, + position_long = EXCLUDED.position_long, + position_z = EXCLUDED.position_z, + position_m = EXCLUDED.position_m, + position_hasZ = EXCLUDED.position_hasZ, + position_hasM = EXCLUDED.position_hasM, + position_isNull = EXCLUDED.position_isNull, + position_stSrid = EXCLUDED.position_stSrid, + time_Zone = EXCLUDED.time_Zone, + dayLight_Saving_Time = EXCLUDED.dayLight_Saving_Time, + maximum_Draft = EXCLUDED.maximum_Draft, + max_LOA = EXCLUDED.max_LOA, + max_Beam = EXCLUDED.max_Beam, + max_DWT = EXCLUDED.max_DWT, + max_Offshore_Draught = EXCLUDED.max_Offshore_Draught, + max_Offshore_LOA = EXCLUDED.max_Offshore_LOA, + max_Offshore_BCM = EXCLUDED.max_Offshore_BCM, + max_Offshore_DWT = EXCLUDED.max_Offshore_DWT, + breakbulk_Facilities = EXCLUDED.breakbulk_Facilities, + container_Facilities = EXCLUDED.container_Facilities, + dry_Bulk_Facilities = EXCLUDED.dry_Bulk_Facilities, + liquid_Facilities = EXCLUDED.liquid_Facilities, + roRo_Facilities = EXCLUDED.roRo_Facilities, + passenger_Facilities = EXCLUDED.passenger_Facilities, + dry_Dock_Facilities = EXCLUDED.dry_Dock_Facilities, + lpG_Facilities = EXCLUDED.lpG_Facilities, + lnG_Facilities = EXCLUDED.lnG_Facilities, + lnG_Bunker = EXCLUDED.lnG_Bunker, + dO_Bunker = EXCLUDED.dO_Bunker, + fO_Bunker = EXCLUDED.fO_Bunker, + ispS_Compliant = EXCLUDED.ispS_Compliant, + csI_Compliant = EXCLUDED.csI_Compliant, + free_Trade_Zone = EXCLUDED.free_Trade_Zone, + ecO_Port = EXCLUDED.ecO_Port, + emission_Control_Area = EXCLUDED.emission_Control_Area, + wS_Port = EXCLUDED.wS_Port, + last_Update = EXCLUDED.last_Update, + entry_Date = EXCLUDED.entry_Date, + batch_flag = 'N' + """; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, PortEntity entity) throws Exception { + + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, PortEntity entity) throws Exception { + int idx = 1; + ps.setLong(idx++, entity.getPortID()); + ps.setString(idx++, entity.getOldID()); + ps.setString(idx++, entity.getStatus()); + ps.setString(idx++, entity.getPortName()); + ps.setString(idx++, entity.getUnlocode()); + ps.setString(idx++, entity.getCountryCode()); + ps.setString(idx++, entity.getCountryName()); + ps.setString(idx++, entity.getRegionName()); + ps.setString(idx++, entity.getContinentName()); + ps.setString(idx++, entity.getMasterPoid()); + setDoubleOrNull(ps, idx++, entity.getDecLat()); + setDoubleOrNull(ps, idx++, entity.getDecLong()); + PortEntity.PositionEntity pos = entity.getPosition(); + if (pos != null) { + setDoubleOrNull(ps, idx++, pos.getLat()); + setDoubleOrNull(ps, idx++, pos.getLongitude()); + ps.setObject(idx++, pos.getZ(), Types.OTHER); + ps.setObject(idx++, pos.getM(), Types.OTHER); + setBooleanOrNull(ps, idx++, pos.getHasZ()); + setBooleanOrNull(ps, idx++, pos.getHasM()); + setBooleanOrNull(ps, idx++, pos.getIsNull()); + setIntegerOrNull(ps, idx++, pos.getStSrid()); + } else { + for (int i = 0; i < 8; i++) { + ps.setNull(idx++, Types.NULL); + } + } + ps.setString(idx++, entity.getTimeZone()); + setBooleanOrNull(ps, idx++, entity.getDayLightSavingTime()); + setDoubleOrNull(ps, idx++, entity.getMaximumDraft()); // 원본: setIntegerOrNull(getMaximumDraft())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxLoa()); // 원본: setIntegerOrNull(getMaxLoa())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxBeam()); // 원본: setIntegerOrNull(getMaxBeam())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxDwt()); // 원본: setIntegerOrNull(getMaxDwt())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxOffshoreDraught()); // 원본: setIntegerOrNull(getMaxOffshoreDraught())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxOffshoreLoa()); // 원본: setIntegerOrNull(getMaxOffshoreLoa())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxOffshoreBcm()); // 원본: setIntegerOrNull(getMaxOffshoreBcm())였으나 FLOAT에 맞게 수정 + setDoubleOrNull(ps, idx++, entity.getMaxOffshoreDwt()); // 원본: setIntegerOrNull(getMaxOffshoreDwt())였으나 FLOAT에 맞게 수정 + setBooleanOrNull(ps, idx++, entity.getBreakbulkFacilities()); + setBooleanOrNull(ps, idx++, entity.getContainerFacilities()); + setBooleanOrNull(ps, idx++, entity.getDryBulkFacilities()); + setBooleanOrNull(ps, idx++, entity.getLiquidFacilities()); + setBooleanOrNull(ps, idx++, entity.getRoRoFacilities()); + setBooleanOrNull(ps, idx++, entity.getPassengerFacilities()); + setBooleanOrNull(ps, idx++, entity.getDryDockFacilities()); + setIntegerOrNull(ps, idx++, entity.getLpGFacilities()); // INT8(BIGINT)에 맞게 setLongOrNull 사용 가정 + setIntegerOrNull(ps, idx++, entity.getLnGFacilities()); // INT8(BIGINT)에 맞게 setLongOrNull 사용 가정 + setBooleanOrNull(ps, idx++, entity.getLngBunker()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getDoBunker()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getFoBunker()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getIspsCompliant()); + setBooleanOrNull(ps, idx++, entity.getCsiCompliant()); + setBooleanOrNull(ps, idx++, entity.getFreeTradeZone()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getEcoPort()); // 원본 위치: 마지막 부분 + setBooleanOrNull(ps, idx++, entity.getEmissionControlArea()); // 원본 위치: 마지막 부분 + setIntegerOrNull(ps, idx++, entity.getWsPort()); // 원본 위치: 마지막 부분 (INT8에 맞게 setLongOrNull 사용 가정) + ps.setString(idx++, entity.getLastUpdate()); // String 대신 Timestamp 타입이 JDBC 표준에 적합합니다. + ps.setString(idx++, entity.getEntryDate()); // String 대신 Timestamp 타입이 JDBC 표준에 적합합니다. + } + + @Override + protected String getEntityName() { + return "RiskEntity"; + } + + @Override + public void savePortAll(List items) { + if (items == null || items.isEmpty()) { + return; + } + jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 수정 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size()); + } + + /** + * Integer 값을 PreparedStatement에 설정 (null 처리 포함) + */ + private void setIntegerOrNull(PreparedStatement ps, int index, Integer value) throws Exception { + if (value == null) { + ps.setNull(index, Types.INTEGER); + } else { + ps.setInt(index, value); + } + } + + /** + * Double 값을 PreparedStatement에 설정 (null 처리 포함) + */ + private void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception { + if (value == null) { + ps.setNull(index, Types.DOUBLE); + } else { + ps.setDouble(index, value); + } + } + + /** + * Boolean 값을 PreparedStatement에 설정 (null 처리 포함) + */ + private void setBooleanOrNull(PreparedStatement ps, int index, Boolean value) throws Exception { + if (value == null) { + // DB 타입에 따라 BOOLEAN 또는 TINYINT(1) 사용 + ps.setNull(index, Types.BOOLEAN); + } else { + ps.setBoolean(index, value); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/facility/batch/writer/PortDataWriter.java b/src/main/java/com/snp/batch/jobs/facility/batch/writer/PortDataWriter.java new file mode 100644 index 0000000..2ddafcd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/facility/batch/writer/PortDataWriter.java @@ -0,0 +1,26 @@ +package com.snp.batch.jobs.facility.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.facility.batch.entity.PortEntity; +import com.snp.batch.jobs.facility.batch.repository.FacilityRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class PortDataWriter extends BaseWriter { + + private final FacilityRepository facilityRepository; + public PortDataWriter(FacilityRepository facilityRepository) { + super("FacilityRepository"); + this.facilityRepository = facilityRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + facilityRepository.savePortAll(items); + log.info("Port 저장 완료: 수정={} 건", items.size()); + } +} From 18ab11068af3d63968308917a92541ea8eb3b929 Mon Sep 17 00:00:00 2001 From: Kim JiMyeung Date: Tue, 2 Dec 2025 12:50:28 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=EB=B9=88=20=EB=B0=B0=EC=97=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/batch/reader/BaseApiReader.java | 25 +++++++++++++++++-- .../batch/reader/ShipMovementReader.java | 19 +++----------- src/main/resources/application.yml | 2 +- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java b/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java index 664c8de..6edbc66 100644 --- a/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java +++ b/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java @@ -254,21 +254,42 @@ public abstract class BaseApiReader implements ItemReader { } // currentBatch가 비어있으면 다음 배치 로드 - if (currentBatch == null || !currentBatch.hasNext()) { + /*if (currentBatch == null || !currentBatch.hasNext()) { List nextBatch = fetchNextBatch(); // 더 이상 데이터가 없으면 종료 - if (nextBatch == null || nextBatch.isEmpty()) { +// if (nextBatch == null || nextBatch.isEmpty()) { + if (nextBatch == null ) { afterFetch(null); log.info("[{}] 모든 배치 처리 완료", getReaderName()); return null; } + // Iterator 갱신 + currentBatch = nextBatch.iterator(); + log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size()); + }*/ + // currentBatch가 비어있으면 다음 배치 로드 + while (currentBatch == null || !currentBatch.hasNext()) { + List nextBatch = fetchNextBatch(); + + if (nextBatch == null) { // 진짜 종료 + afterFetch(null); + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + if (nextBatch.isEmpty()) { // emptyList면 다음 batch를 시도 + log.warn("[{}] 빈 배치 수신 → 다음 배치 재요청", getReaderName()); + continue; // while 반복문으로 다시 fetch + } + currentBatch = nextBatch.iterator(); log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size()); } + // Iterator에서 1건씩 반환 return currentBatch.next(); } diff --git a/src/main/java/com/snp/batch/jobs/shipMovement/batch/reader/ShipMovementReader.java b/src/main/java/com/snp/batch/jobs/shipMovement/batch/reader/ShipMovementReader.java index f251206..612a1be 100644 --- a/src/main/java/com/snp/batch/jobs/shipMovement/batch/reader/ShipMovementReader.java +++ b/src/main/java/com/snp/batch/jobs/shipMovement/batch/reader/ShipMovementReader.java @@ -50,7 +50,7 @@ public class ShipMovementReader extends BaseApiReader { // DB 해시값을 저장할 맵 private Map dbMasterHashes; private int currentBatchIndex = 0; - private final int batchSize = 50; + private final int batchSize = 10; @Value("#{jobParameters['startDate']}") private String startDate; @@ -91,7 +91,8 @@ public class ShipMovementReader extends BaseApiReader { } private static final String GET_ALL_IMO_QUERY = - "SELECT imo_number FROM ship_data ORDER BY id"; +// "SELECT imo_number FROM ship_data ORDER BY id"; + "SELECT imo_number FROM snp_data.ship_data where imo_number > (select max(imo) from snp_data.t_ship_stpov_info) ORDER BY imo_number"; private static final String FETCH_ALL_HASHES_QUERY = "SELECT imo_number, ship_detail_hash FROM ship_detail_hash_json ORDER BY imo_number"; @@ -112,20 +113,6 @@ public class ShipMovementReader extends BaseApiReader { log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); - /* // Step 2. 전 배치 결과 imo_number, ship_detail_json, ship_detail_hash 데이터 전체 조회 - log.info("[{}] DB Master Hash 전체 조회 시작...", getReaderName()); - - // 1-1. DB에서 모든 IMO와 Hash 조회 - dbMasterHashes = jdbcTemplate.query(FETCH_ALL_HASHES_QUERY, rs -> { - Map map = new HashMap<>(); - while (rs.next()) { - map.put(rs.getString("imo_number"), rs.getString("ship_detail_hash")); - } - return map; - }); - - log.info("[{}] DB Master Hash 조회 완료. 총 {}건.", getReaderName(), dbMasterHashes.size());*/ - // API 통계 초기화 updateApiCallStats(totalBatches, 0); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index daaab5f..33e7908 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -55,7 +55,7 @@ spring: # Server Configuration server: - port: 8081 + port: 8041 servlet: context-path: /snp-api From 3dde3d01675ff693da8d41be7a88bc1b3fb96e55 Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Wed, 10 Dec 2025 08:14:28 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[=EC=B6=94=EA=B0=80]=20=20-=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=84=A0=EB=B0=95=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20Classtype=20=EA=B5=AC=EB=B6=84=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80=20(co?= =?UTF-8?q?re20=20=ED=85=8C=EC=9D=B4=EB=B8=94=20imo=20=EC=9C=A0=EB=AC=B4?= =?UTF-8?q?=EB=A1=9C=20ClassA,=20ClassB=20=EB=B6=84=EB=A5=98)=20=20-=20htm?= =?UTF-8?q?l=20PUT,DELETE,=20PATCH=20=EB=A9=94=EC=86=8C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20POST=20=EB=A9=94=EC=86=8C?= =?UTF-8?q?=EB=93=9C=20=EC=82=AC=EC=9A=A9=20=EB=B3=80=EA=B2=BD=20(?= =?UTF-8?q?=EB=B3=B4=EC=95=88=EC=9D=B4=EC=8A=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/snp/batch/SnpBatchApplication.java | 2 + .../config/MaritimeApiWebClientConfig.java | 2 +- .../global/controller/BatchController.java | 6 +- .../config/AisTargetImportJobConfig.java | 23 +- .../batch/entity/AisTargetEntity.java | 17 ++ .../repository/AisTargetRepositoryImpl.java | 6 +- .../batch/writer/AisTargetDataWriter.java | 19 +- .../aistarget/cache/AisTargetFilterUtil.java | 76 ++++++ .../classifier/AisClassTypeClassifier.java | 160 +++++++++++++ .../classifier/Core20CacheManager.java | 219 ++++++++++++++++++ .../classifier/Core20Properties.java | 71 ++++++ .../web/controller/AisTargetController.java | 49 +++- .../web/dto/AisTargetFilterRequest.java | 17 +- .../web/dto/AisTargetResponseDto.java | 22 ++ .../web/dto/AisTargetSearchRequest.java | 18 ++ .../web/service/AisTargetService.java | 12 +- src/main/resources/application.yml | 11 + src/main/resources/templates/schedules.html | 10 +- 18 files changed, 709 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java create mode 100644 src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java create mode 100644 src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java diff --git a/src/main/java/com/snp/batch/SnpBatchApplication.java b/src/main/java/com/snp/batch/SnpBatchApplication.java index bf4315f..b59535c 100644 --- a/src/main/java/com/snp/batch/SnpBatchApplication.java +++ b/src/main/java/com/snp/batch/SnpBatchApplication.java @@ -2,10 +2,12 @@ package com.snp.batch; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@ConfigurationPropertiesScan public class SnpBatchApplication { public static void main(String[] args) { diff --git a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java index e954a9c..ddeb443 100644 --- a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java +++ b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java @@ -96,7 +96,7 @@ public class MaritimeApiWebClientConfig { .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) .codecs(configurer -> configurer .defaultCodecs() - .maxInMemorySize(30 * 1024 * 1024)) // 30MB 버퍼 + .maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼 .build(); } } diff --git a/src/main/java/com/snp/batch/global/controller/BatchController.java b/src/main/java/com/snp/batch/global/controller/BatchController.java index db66315..fc95e42 100644 --- a/src/main/java/com/snp/batch/global/controller/BatchController.java +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -178,7 +178,7 @@ public class BatchController { } } - @PutMapping("/schedules/{jobName}") + @PostMapping("/schedules/{jobName}/update") public ResponseEntity> updateSchedule( @PathVariable String jobName, @RequestBody Map request) { @@ -206,7 +206,7 @@ public class BatchController { @ApiResponse(responseCode = "200", description = "삭제 성공"), @ApiResponse(responseCode = "500", description = "삭제 실패") }) - @DeleteMapping("/schedules/{jobName}") + @PostMapping("/schedules/{jobName}/delete") public ResponseEntity> deleteSchedule( @Parameter(description = "배치 작업 이름", required = true) @PathVariable String jobName) { @@ -226,7 +226,7 @@ public class BatchController { } } - @PatchMapping("/schedules/{jobName}/toggle") + @PostMapping("/schedules/{jobName}/toggle") public ResponseEntity> toggleSchedule( @PathVariable String jobName, @RequestBody Map request) { diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java index c26f40a..d5f7ccf 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java @@ -6,6 +6,7 @@ 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; @@ -43,6 +44,7 @@ public class AisTargetImportJobConfig extends BaseJobConfig se.getWriteCount()) - .sum()); + .sum(), + core20CacheManager.size()); } }); } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java index 57bb322..0e4e1a9 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java @@ -82,4 +82,21 @@ public class AisTargetEntity extends BaseEntity { // ========== 타임스탬프 ========== private OffsetDateTime receivedDate; private OffsetDateTime collectedAt; // 배치 수집 시점 + + // ========== 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; } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java index 98204b3..d70e02b 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java @@ -46,7 +46,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { received_date, collected_at, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, - ?, ?, public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326), + ?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -203,9 +203,9 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { SELECT DISTINCT ON (mmsi) * FROM %s WHERE message_timestamp BETWEEN ? AND ? - AND public.ST_DWithin( + AND ST_DWithin( geom::geography, - public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326)::geography, + ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ? ) ORDER BY mmsi, message_timestamp DESC diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java index 05e302f..76f81b0 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java @@ -4,6 +4,7 @@ import com.snp.batch.common.batch.writer.BaseWriter; 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.classifier.AisClassTypeClassifier; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -13,8 +14,9 @@ import java.util.List; * AIS Target 데이터 Writer * * 동작: - * - UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp) - * - 동시에 캐시에도 최신 위치 정보 업데이트 + * 1. UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp) + * 2. ClassType 분류 (Core20 캐시 기반 A/B 분류) + * 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함) */ @Slf4j @Component @@ -22,23 +24,30 @@ public class AisTargetDataWriter extends BaseWriter { private final AisTargetRepository aisTargetRepository; private final AisTargetCacheManager cacheManager; + private final AisClassTypeClassifier classTypeClassifier; public AisTargetDataWriter( AisTargetRepository aisTargetRepository, - AisTargetCacheManager cacheManager) { + AisTargetCacheManager cacheManager, + AisClassTypeClassifier classTypeClassifier) { super("AisTarget"); this.aisTargetRepository = aisTargetRepository; this.cacheManager = cacheManager; + this.classTypeClassifier = classTypeClassifier; } @Override protected void writeItems(List items) throws Exception { log.debug("AIS Target 데이터 저장 시작: {} 건", items.size()); - // 1. DB 저장 + // 1. DB 저장 (classType 없이 원본 데이터만 저장) aisTargetRepository.batchUpsert(items); - // 2. 캐시 업데이트 (최신 위치 정보) + // 2. ClassType 분류 (캐시 저장 전에 분류) + // - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정 + classTypeClassifier.classifyAll(items); + + // 3. 캐시 업데이트 (classType, core20Mmsi 포함) cacheManager.putAll(items); log.debug("AIS Target 데이터 저장 완료: {} 건 (캐시 크기: {})", diff --git a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java index ecffa32..c884954 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java @@ -2,10 +2,12 @@ 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; @@ -16,6 +18,7 @@ import java.util.stream.Collectors; * - SOG, COG, Heading: 숫자 범위 조건 * - Destination: 문자열 부분 일치 * - Status: 다중 선택 일치 + * - ClassType: 선박 클래스 타입 (A/B) */ @Slf4j @Component @@ -45,6 +48,7 @@ public class AisTargetFilterUtil { .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; @@ -54,6 +58,54 @@ public class AisTargetFilterUtil { return result; } + /** + * AisTargetSearchRequest 기반 ClassType 필터링 + * + * @param entities 원본 엔티티 목록 + * @param request 검색 조건 + * @return 필터링된 엔티티 목록 + */ + public List filterByClassType(List entities, AisTargetSearchRequest request) { + if (entities == null || entities.isEmpty()) { + return Collections.emptyList(); + } + + if (!request.hasClassTypeFilter()) { + return entities; + } + + long startTime = System.currentTimeMillis(); + + List 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 (속도) 조건 매칭 */ @@ -150,4 +202,28 @@ public class AisTargetFilterUtil { 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); + } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java new file mode 100644 index 0000000..e8672ce --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java @@ -0,0 +1,160 @@ +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 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 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(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java new file mode 100644 index 0000000..0a63ae8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java @@ -0,0 +1,219 @@ +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 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 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 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 getStats() { + Map 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; + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java new file mode 100644 index 0000000..1e1eb3f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java @@ -0,0 +1,71 @@ +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: snp_data.core20 (ihslrorimoshipno, maritimemobileserviceidentitymmsinumber) + * - prod: new_snp.core20 (lrno, mmsi) + */ +@Slf4j +@Getter +@Setter +@ConfigurationProperties(prefix = "app.batch.core20") +public class Core20Properties { + + /** + * 스키마명 (예: snp_data, new_snp) + */ + private String schema = "snp_data"; + + /** + * 테이블명 (예: core20) + */ + private String table = "core20"; + + /** + * IMO/LRNO 컬럼명 (PK, NOT NULL) + */ + private String imoColumn = "ihslrorimoshipno"; + + /** + * MMSI 컬럼명 (NULLABLE) + */ + private String mmsiColumn = "maritimemobileserviceidentitymmsinumber"; + + /** + * 전체 테이블명 반환 (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); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java b/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java index fea1864..609fc70 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java @@ -74,11 +74,23 @@ public class AisTargetController { @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인 경우에만 존재할 수 있음) 공간 범위가 지정되지 않으면 전체 선박의 최신 위치를 반환합니다. """ @@ -92,16 +104,19 @@ public class AisTargetController { @Parameter(description = "중심 위도", example = "35.0") @RequestParam(required = false) Double centerLat, @Parameter(description = "반경 (미터)", example = "50000") - @RequestParam(required = false) Double radiusMeters) { + @RequestParam(required = false) Double radiusMeters, + @Parameter(description = "선박 클래스 타입 필터 (A: Core20 등록, B: 미등록)", example = "A") + @RequestParam(required = false) String classType) { - log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}", - minutes, centerLon, centerLat, radiusMeters); + 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 result = aisTargetService.search(request); @@ -113,13 +128,33 @@ public class AisTargetController { @Operation( summary = "시간/공간 범위로 선박 검색 (POST)", - description = "POST 방식으로 검색 조건을 전달합니다" + 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>> searchPost( @Valid @RequestBody AisTargetSearchRequest request) { - log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}", - request.getMinutes(), request.hasAreaFilter()); + log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}", + request.getMinutes(), request.hasAreaFilter(), request.getClassType()); List result = aisTargetService.search(request); return ResponseEntity.ok(ApiResponse.success( diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java index 34e172a..66637d8 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java @@ -121,6 +121,16 @@ public class AisTargetFilterRequest { example = "[\"Under way using engine\", \"Anchored\", \"Moored\"]") private List 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() { @@ -143,8 +153,13 @@ public class AisTargetFilterRequest { 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(); + || hasDestinationFilter() || hasStatusFilter() || hasClassTypeFilter(); } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java index b7b0c74..aa8f3f4 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java @@ -2,6 +2,7 @@ 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; @@ -17,6 +18,7 @@ import java.time.OffsetDateTime; @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "AIS Target 응답") public class AisTargetResponseDto { // 선박 식별 정보 @@ -51,8 +53,26 @@ public class AisTargetResponseDto { private OffsetDateTime receivedDate; // 데이터 소스 (캐시/DB) + @Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"}) private String source; + // 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 변환 */ @@ -82,6 +102,8 @@ public class AisTargetResponseDto { .messageTimestamp(entity.getMessageTimestamp()) .receivedDate(entity.getReceivedDate()) .source(source) + .classType(entity.getClassType()) + .core20Mmsi(entity.getCore20Mmsi()) .build(); } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java index 9e7a7a8..33d334f 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java @@ -39,10 +39,28 @@ public class AisTargetSearchRequest { @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")); + } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java index 6b1b79f..a7e7fe2 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java @@ -122,11 +122,12 @@ public class AisTargetService { * 전략: * 1. 캐시에서 시간 범위 내 데이터 조회 * 2. 공간 필터 있으면 JTS로 필터링 - * 3. 캐시 데이터가 없으면 DB Fallback + * 3. ClassType 필터 있으면 적용 + * 4. 캐시 데이터가 없으면 DB Fallback */ public List search(AisTargetSearchRequest request) { - log.debug("선박 검색 - minutes: {}, hasArea: {}", - request.getMinutes(), request.hasAreaFilter()); + log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}", + request.getMinutes(), request.hasAreaFilter(), request.getClassType()); long startTime = System.currentTimeMillis(); @@ -154,6 +155,11 @@ public class AisTargetService { ); } + // 3. ClassType 필터 적용 + if (request.hasClassTypeFilter()) { + entities = filterUtil.filterByClassType(entities, request); + } + long elapsed = System.currentTimeMillis() - startTime; log.info("선박 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms", source, entities.size(), elapsed); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 33e7908..9bbaeb4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -103,6 +103,17 @@ app: ttl-minutes: 120 # 캐시 TTL (분) - 2시간 max-size: 300000 # 최대 캐시 크기 - 30만 건 + # ClassType 분류 설정 + class-type: + refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시) + + # Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음) + core20: + schema: snp_data # 스키마명 + table: core20 # 테이블명 + imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL) + mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE) + # 파티션 관리 설정 partition: # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD) diff --git a/src/main/resources/templates/schedules.html b/src/main/resources/templates/schedules.html index 3d62f20..17965c4 100644 --- a/src/main/resources/templates/schedules.html +++ b/src/main/resources/templates/schedules.html @@ -410,8 +410,8 @@ if (!confirmUpdate) { return; } - method = 'PUT'; - url = contextPath + `api/batch/schedules/${jobName}`; + method = 'POST'; + url = contextPath + `api/batch/schedules/${jobName}/update`; } const response = await fetch(url, { @@ -455,7 +455,7 @@ try { const response = await fetch(contextPath + `api/batch/schedules/${jobName}/toggle`, { - method: 'PATCH', + method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -482,8 +482,8 @@ } try { - const response = await fetch(contextPath + `api/batch/schedules/${jobName}`, { - method: 'DELETE' + const response = await fetch(contextPath + `api/batch/schedules/${jobName}/delete`, { + method: 'POST' }); const result = await response.json(); From fedd89c9ca8dd9b75b05a11f3126a97dd9d2a3de Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Wed, 10 Dec 2025 08:46:15 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[=EC=88=98=EC=A0=95]=20=20-=20GPU=20DB=20co?= =?UTF-8?q?re20=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 14efda7..623f54e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -74,6 +74,7 @@ management: logging: config: classpath:logback-spring.xml + # Custom Application Properties app: batch: @@ -92,3 +93,10 @@ app: schedule: enabled: true cron: "0 0 * * * ?" # Every hour + + # Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음) + core20: + schema: new_snp # 스키마명 + table: core20 # 테이블명 + imo-column: lrno # IMO/LRNO 컬럼명 (PK, NOT NULL) + mmsi-column: mmsi # MMSI 컬럼명 (NULLABLE) From 655318e3533de9a021cfb706f2b59a1982b0df04 Mon Sep 17 00:00:00 2001 From: hyojin kim Date: Wed, 10 Dec 2025 10:13:09 +0900 Subject: [PATCH 6/7] =?UTF-8?q?:card=5Ffile=5Fbox:=20Risk&Compliance=20?= =?UTF-8?q?=EC=A0=81=EC=9E=AC=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20(?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/jobs/risk/batch/repository/RiskRepositoryImpl.java | 2 +- .../sanction/batch/repository/ComplianceRepositoryImpl.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepositoryImpl.java index 4a1236c..a5c8695 100644 --- a/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/risk/batch/repository/RiskRepositoryImpl.java @@ -65,7 +65,7 @@ public class RiskRepositoryImpl extends BaseJdbcRepository imp VALUES ( ?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N' ) - ON CONFLICT (lrno) + ON CONFLICT (lrno, lastupdated) DO UPDATE SET riskdatamaintained = EXCLUDED.riskdatamaintained, dayssincelastseenonais = EXCLUDED.dayssincelastseenonais, diff --git a/src/main/java/com/snp/batch/jobs/sanction/batch/repository/ComplianceRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/sanction/batch/repository/ComplianceRepositoryImpl.java index 981e081..db90923 100644 --- a/src/main/java/com/snp/batch/jobs/sanction/batch/repository/ComplianceRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/sanction/batch/repository/ComplianceRepositoryImpl.java @@ -58,7 +58,7 @@ public class ComplianceRepositoryImpl extends BaseJdbcRepository Date: Wed, 10 Dec 2025 10:54:44 +0900 Subject: [PATCH 7/7] =?UTF-8?q?:card=5Ffile=5Fbox:=20application.xml=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 62 +++++++++++---- src/main/resources/application-prod.yml | 42 +++++++++- src/main/resources/application-qa.yml | 101 ------------------------ src/main/resources/application.yml | 2 +- 4 files changed, 86 insertions(+), 121 deletions(-) delete mode 100644 src/main/resources/application-qa.yml diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 4987575..a3d3ef9 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -4,9 +4,9 @@ spring: # PostgreSQL Database Configuration datasource: - url: jdbc:postgresql://10.26.252.39:5432/mdadb?currentSchema=snp_data - username: mda - password: mda#8932 + url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=snp_data,public + username: snp + password: snp#8932 driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 10 @@ -57,7 +57,7 @@ spring: server: port: 8041 servlet: - context-path: / + context-path: /snp-api # Actuator Configuration management: @@ -69,18 +69,9 @@ management: health: show-details: always -# Logging Configuration +# Logging Configuration (logback-spring.xml에서 상세 설정) logging: - level: - root: INFO - com.snp.batch: DEBUG - org.springframework.batch: DEBUG - org.springframework.jdbc: DEBUG - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" - file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" - file: - name: logs/snp-batch.log + config: classpath:logback-spring.xml # Custom Application Properties app: @@ -100,3 +91,44 @@ app: schedule: enabled: true cron: "0 0 * * * ?" # Every hour + + # AIS Target 배치 설정 + ais-target: + since-seconds: 60 # API 조회 범위 (초) + chunk-size: 5000 # 배치 청크 크기 + schedule: + cron: "15 * * * * ?" # 매 분 15초 실행 + # 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: snp_data # 스키마명 + table: core20 # 테이블명 + imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL) + mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE) + + # 파티션 관리 설정 + partition: + # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD) + daily-tables: + - schema: snp_data + table-name: ais_target + partition-column: message_timestamp + periods-ahead: 3 # 미리 생성할 일수 + # 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM) + monthly-tables: [] # 현재 없음 + # 기본 보관기간 + retention: + daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일) + monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월) + # 개별 테이블 보관기간 설정 (옵션) + custom: + # - table-name: ais_target + # retention-days: 30 # ais_target만 30일 보관 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 623f54e..c202e03 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -55,7 +55,7 @@ spring: # Server Configuration server: - port: 9000 + port: 8041 servlet: context-path: /snp-api @@ -94,9 +94,43 @@ app: enabled: true cron: "0 0 * * * ?" # Every hour + # AIS Target 배치 설정 + ais-target: + since-seconds: 60 # API 조회 범위 (초) + chunk-size: 5000 # 배치 청크 크기 + schedule: + cron: "15 * * * * ?" # 매 분 15초 실행 + # 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: new_snp # 스키마명 + schema: snp_data # 스키마명 table: core20 # 테이블명 - imo-column: lrno # IMO/LRNO 컬럼명 (PK, NOT NULL) - mmsi-column: mmsi # MMSI 컬럼명 (NULLABLE) + imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL) + mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE) + + # 파티션 관리 설정 + partition: + # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD) + daily-tables: + - schema: snp_data + table-name: ais_target + partition-column: message_timestamp + periods-ahead: 3 # 미리 생성할 일수 + # 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM) + monthly-tables: [] # 현재 없음 + # 기본 보관기간 + retention: + daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일) + monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월) + # 개별 테이블 보관기간 설정 (옵션) + custom: + # - table-name: ais_target + # retention-days: 30 # ais_target만 30일 보관 \ No newline at end of file diff --git a/src/main/resources/application-qa.yml b/src/main/resources/application-qa.yml deleted file mode 100644 index 03f8e3b..0000000 --- a/src/main/resources/application-qa.yml +++ /dev/null @@ -1,101 +0,0 @@ -spring: - application: - name: snp-batch - - # PostgreSQL Database Configuration - datasource: - url: jdbc:postgresql://211.208.115.83:5432/snpdb - username: snp - password: snp#8932 - driver-class-name: org.postgresql.Driver - hikari: - maximum-pool-size: 10 - minimum-idle: 5 - connection-timeout: 30000 - - # JPA Configuration - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect - format_sql: true - default_schema: snp_data - - # Batch Configuration - batch: - jdbc: - initialize-schema: never # Changed to 'never' as tables already exist - job: - enabled: false # Prevent auto-run on startup - - # Thymeleaf Configuration - thymeleaf: - cache: false - prefix: classpath:/templates/ - suffix: .html - - # Quartz Scheduler Configuration - Using JDBC Store for persistence - quartz: - job-store-type: jdbc # JDBC store for schedule persistence - jdbc: - initialize-schema: always # Create Quartz tables if not exist - properties: - org.quartz.scheduler.instanceName: SNPBatchScheduler - org.quartz.scheduler.instanceId: AUTO - org.quartz.threadPool.threadCount: 10 - org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX - org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate - org.quartz.jobStore.tablePrefix: QRTZ_ - org.quartz.jobStore.isClustered: false - org.quartz.jobStore.misfireThreshold: 60000 - -# Server Configuration -server: - port: 8041 - servlet: - context-path: / - -# Actuator Configuration -management: - endpoints: - web: - exposure: - include: health,info,metrics,prometheus,batch - endpoint: - health: - show-details: always - -# Logging Configuration -logging: - level: - root: INFO - com.snp.batch: DEBUG - org.springframework.batch: DEBUG - org.springframework.jdbc: DEBUG - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" - file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" - file: - name: logs/snp-batch.log - -# Custom Application Properties -app: - batch: - chunk-size: 1000 - api: - url: https://api.example.com/data - timeout: 30000 - ship-api: - url: https://shipsapi.maritime.spglobal.com - username: 7cc0517d-5ed6-452e-a06f-5bbfd6ab6ade - password: 2LLzSJNqtxWVD8zC - ais-api: - url: https://aisapi.maritime.spglobal.com - webservice-api: - url: https://webservices.maritime.spglobal.com - schedule: - enabled: true - cron: "0 0 * * * ?" # Every hour diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6950fe3..3431086 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,7 +4,7 @@ spring: # PostgreSQL Database Configuration datasource: - url: jdbc:postgresql://211.208.115.83:5432/snpdb + url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=snp_data,public username: snp password: snp#8932 driver-class-name: org.postgresql.Driver