diff --git a/src/main/java/com/snp/batch/SnpBatchApplication.java b/src/main/java/com/snp/batch/SnpBatchApplication.java index b59535c..8a395e9 100644 --- a/src/main/java/com/snp/batch/SnpBatchApplication.java +++ b/src/main/java/com/snp/batch/SnpBatchApplication.java @@ -2,10 +2,11 @@ package com.snp.batch; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.scheduling.annotation.EnableScheduling; -@SpringBootApplication +@SpringBootApplication(exclude = KafkaAutoConfiguration.class) @EnableScheduling @ConfigurationPropertiesScan public class SnpBatchApplication { diff --git a/src/main/java/com/snp/batch/common/web/ApiResponse.java b/src/main/java/com/snp/batch/common/web/ApiResponse.java index 68d6edb..b646cb0 100644 --- a/src/main/java/com/snp/batch/common/web/ApiResponse.java +++ b/src/main/java/com/snp/batch/common/web/ApiResponse.java @@ -1,5 +1,6 @@ package com.snp.batch.common.web; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -14,26 +15,19 @@ import lombok.NoArgsConstructor; @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(description = "공통 API 응답 래퍼") public class ApiResponse { - /** - * 성공 여부 - */ + @Schema(description = "성공 여부", example = "true") private boolean success; - /** - * 메시지 - */ + @Schema(description = "응답 메시지", example = "Success") private String message; - /** - * 응답 데이터 - */ + @Schema(description = "응답 데이터") private T data; - /** - * 에러 코드 (실패 시) - */ + @Schema(description = "에러 코드 (실패 시에만 존재)", example = "NOT_FOUND", nullable = true) private String errorCode; /** diff --git a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java index f03338d..3e056b9 100644 --- a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java +++ b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java @@ -15,9 +15,9 @@ import java.util.List; * Swagger/OpenAPI 3.0 설정 * * Swagger UI 접속 URL: - * - Swagger UI: http://localhost:8081/swagger-ui/index.html - * - API 문서 (JSON): http://localhost:8081/v3/api-docs - * - API 문서 (YAML): http://localhost:8081/v3/api-docs.yaml + * - Swagger UI: http://localhost:8041/snp-api/swagger-ui/index.html + * - API 문서 (JSON): http://localhost:8041/snp-api/v3/api-docs + * - API 문서 (YAML): http://localhost:8041/snp-api/v3/api-docs.yaml * * 주요 기능: * - REST API 자동 문서화 @@ -62,17 +62,19 @@ public class SwaggerConfig { .description(""" ## SNP Batch 시스템 REST API 문서 - Spring Batch 기반 데이터 통합 시스템의 REST API 문서입니다. + 해양 데이터 통합 배치 시스템의 REST API 문서입니다. ### 제공 API - - **Batch API**: 배치 Job 실행 및 관리 - - **Product API**: 샘플 제품 데이터 CRUD (샘플용) + - **Batch Management API**: 배치 Job 실행, 이력 조회, 스케줄 관리 + - **AIS Target API**: AIS 선박 위치 정보 조회 (캐시 기반, 공간/조건 검색) ### 주요 기능 - 배치 Job 실행 및 중지 - Job 실행 이력 조회 - 스케줄 관리 (Quartz) - - 제품 데이터 CRUD (샘플) + - AIS 선박 실시간 위치 조회 (MMSI 단건/다건, 시간/공간 범위 검색) + - 항해 조건 필터 검색 (SOG, COG, Heading, 목적지, 항행상태) + - 폴리곤/WKT 범위 검색, 거리 포함 검색, 항적 조회 ### 버전 정보 - API Version: v1.0.0 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 fcdc654..3883008 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 @@ -7,6 +7,7 @@ import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier; import com.snp.batch.jobs.aistarget.classifier.SignalKindCode; import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer; import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import java.util.List; @@ -18,11 +19,12 @@ import java.util.List; * 1. ClassType 분류 (Core20 캐시 기반 A/B 분류) * 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드) * 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함) - * 4. Kafka 토픽으로 AIS Target 정보 전송 (서브청크 분할) + * 4. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만) * * 참고: * - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행 * - Kafka 전송 실패는 기본적으로 로그만 남기고 다음 처리 계속 + * - Kafka가 비활성화(enabled=false)이면 kafkaProducer가 null이므로 전송 단계를 스킵 */ @Slf4j @Component @@ -30,12 +32,13 @@ public class AisTargetDataWriter extends BaseWriter { private final AisTargetCacheManager cacheManager; private final AisClassTypeClassifier classTypeClassifier; + @Nullable private final AisTargetKafkaProducer kafkaProducer; public AisTargetDataWriter( AisTargetCacheManager cacheManager, AisClassTypeClassifier classTypeClassifier, - AisTargetKafkaProducer kafkaProducer) { + @Nullable AisTargetKafkaProducer kafkaProducer) { super("AisTarget"); this.cacheManager = cacheManager; this.classTypeClassifier = classTypeClassifier; @@ -62,9 +65,9 @@ public class AisTargetDataWriter extends BaseWriter { log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})", items.size(), cacheManager.size()); - // 4. Kafka 전송 (설정 enabled=true 인 경우) - if (!kafkaProducer.isEnabled()) { - log.debug("AIS Kafka 전송 비활성화 - topic 전송 스킵"); + // 4. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만) + if (kafkaProducer == null) { + log.debug("AIS Kafka Producer 미등록 - topic 전송 스킵"); return; } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaConfig.java b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaConfig.java new file mode 100644 index 0000000..542397f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaConfig.java @@ -0,0 +1,23 @@ +package com.snp.batch.jobs.aistarget.kafka; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Kafka 조건부 활성화 설정 + * + * SnpBatchApplication에서 KafkaAutoConfiguration을 기본 제외한 뒤, + * app.batch.ais-target.kafka.enabled=true인 경우에만 재활성화한다. + * + * enabled=false(기본값)이면 KafkaTemplate 등 Kafka 관련 빈이 전혀 생성되지 않는다. + */ +@Configuration +@ConditionalOnProperty( + name = "app.batch.ais-target.kafka.enabled", + havingValue = "true" +) +@Import(KafkaAutoConfiguration.class) +public class AisTargetKafkaConfig { +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java index fb58d8e..9389945 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java @@ -6,6 +6,7 @@ import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @@ -21,9 +22,15 @@ import java.util.concurrent.atomic.AtomicInteger; * - key: MMSI * - value: AisTargetKafkaMessage(JSON) * - 실패 시 기본적으로 로그만 남기고 계속 진행 (failOnSendError=false) + * + * app.batch.ais-target.kafka.enabled=true인 경우에만 빈으로 등록된다. */ @Slf4j @Component +@ConditionalOnProperty( + name = "app.batch.ais-target.kafka.enabled", + havingValue = "true" +) @RequiredArgsConstructor public class AisTargetKafkaProducer { 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 609fc70..7c98f09 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 @@ -7,13 +7,17 @@ import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest; import com.snp.batch.jobs.aistarget.web.service.AisTargetService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import java.util.List; import java.util.Map; @@ -25,6 +29,7 @@ import java.util.Map; * - 캐시 미스 시 DB 조회 후 캐시 업데이트 */ @Slf4j +@Validated @RestController @RequestMapping("/api/ais-target") @RequiredArgsConstructor @@ -37,7 +42,11 @@ public class AisTargetController { @Operation( summary = "MMSI로 최신 위치 조회", - description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)" + description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 MMSI의 위치 정보 없음") + } ) @GetMapping("/{mmsi}") public ResponseEntity> getLatestByMmsi( @@ -98,7 +107,7 @@ public class AisTargetController { @GetMapping("/search") public ResponseEntity>> search( @Parameter(description = "조회 범위 (분)", required = true, example = "5") - @RequestParam Integer minutes, + @RequestParam @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") Integer minutes, @Parameter(description = "중심 경도", example = "129.0") @RequestParam(required = false) Double centerLon, @Parameter(description = "중심 위도", example = "35.0") @@ -128,6 +137,10 @@ public class AisTargetController { @Operation( summary = "시간/공간 범위로 선박 검색 (POST)", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (minutes 누락 또는 1 미만)") + }, description = """ POST 방식으로 검색 조건을 전달합니다. @@ -167,6 +180,10 @@ public class AisTargetController { @Operation( summary = "항해 조건 필터 검색", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "필터 검색 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패") + }, description = """ 속도(SOG), 침로(COG), 선수방위(Heading), 목적지, 항행상태로 선박을 필터링합니다. @@ -218,30 +235,30 @@ public class AisTargetController { "headingCondition": "LT", "headingValue": 180.0, "destination": "BUSAN", - "statusList": ["0", "1", "5"] + "statusList": ["Under way using engine", "At anchor", "Moored"] } ``` --- - ## 항행상태 코드 (statusList) + ## 항행상태 값 (statusList) - | 코드 | 상태 | + statusList에는 **텍스트 문자열**을 전달해야 합니다 (대소문자 무시). + + | 값 | 설명 | |------|------| - | 0 | Under way using engine (기관 사용 항해 중) | - | 1 | At anchor (정박 중) | - | 2 | Not under command (조종불능) | - | 3 | Restricted manoeuverability (조종제한) | - | 4 | Constrained by her draught (흘수제약) | - | 5 | Moored (계류 중) | - | 6 | Aground (좌초) | - | 7 | Engaged in Fishing (어로 중) | - | 8 | Under way sailing (돛 항해 중) | - | 9-10 | Reserved for future use | - | 11 | Power-driven vessel towing astern | - | 12 | Power-driven vessel pushing ahead | - | 13 | Reserved for future use | - | 14 | AIS-SART, MOB-AIS, EPIRB-AIS | - | 15 | Undefined (default) | + | Under way using engine | 기관 사용 항해 중 | + | At anchor | 정박 중 | + | Not under command | 조종불능 | + | Restricted manoeuverability | 조종제한 | + | Constrained by her draught | 흘수제약 | + | Moored | 계류 중 | + | Aground | 좌초 | + | Engaged in Fishing | 어로 중 | + | Under way sailing | 돛 항해 중 | + | Power Driven Towing Astern | 예인선 (후방) | + | Power Driven Towing Alongside | 예인선 (측방) | + | AIS Sart | 비상위치지시기 | + | N/A | 정보없음 | --- **참고:** 모든 필터는 선택사항이며, 미지정 시 해당 필드는 조건에서 제외됩니다 (전체 값 포함). @@ -269,6 +286,10 @@ public class AisTargetController { @Operation( summary = "폴리곤 범위 내 선박 검색", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (coordinates 또는 minutes 누락)") + }, description = """ 폴리곤 범위 내 선박을 검색합니다. @@ -283,7 +304,7 @@ public class AisTargetController { ) @PostMapping("/search/polygon") public ResponseEntity>> searchByPolygon( - @RequestBody PolygonSearchRequest request) { + @Valid @RequestBody PolygonSearchRequest request) { log.info("폴리곤 검색 요청 - minutes: {}, points: {}", request.getMinutes(), request.getCoordinates().length); @@ -299,6 +320,10 @@ public class AisTargetController { @Operation( summary = "WKT 범위 내 선박 검색", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (wkt 또는 minutes 누락)") + }, description = """ WKT(Well-Known Text) 형식으로 정의된 범위 내 선박을 검색합니다. @@ -313,7 +338,7 @@ public class AisTargetController { ) @PostMapping("/search/wkt") public ResponseEntity>> searchByWkt( - @RequestBody WktSearchRequest request) { + @Valid @RequestBody WktSearchRequest request) { log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt()); List result = aisTargetService.searchByWkt( @@ -405,11 +430,17 @@ public class AisTargetController { * 폴리곤 검색 요청 DTO */ @lombok.Data + @Schema(description = "폴리곤 범위 검색 요청") public static class PolygonSearchRequest { - @Parameter(description = "조회 범위 (분)", required = true, example = "5") - private int minutes; + @NotNull(message = "minutes는 필수입니다") + @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") + @Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer minutes; - @Parameter(description = "폴리곤 좌표 [[lon, lat], ...]", required = true) + @NotNull(message = "coordinates는 필수입니다") + @Schema(description = "폴리곤 좌표 [[경도, 위도], ...] (닫힌 형태: 첫점=끝점)", + example = "[[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]", + requiredMode = Schema.RequiredMode.REQUIRED) private double[][] coordinates; } @@ -417,12 +448,17 @@ public class AisTargetController { * WKT 검색 요청 DTO */ @lombok.Data + @Schema(description = "WKT 범위 검색 요청") public static class WktSearchRequest { - @Parameter(description = "조회 범위 (분)", required = true, example = "5") - private int minutes; + @NotNull(message = "minutes는 필수입니다") + @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") + @Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer minutes; - @Parameter(description = "WKT 문자열", required = true, - example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))") + @NotNull(message = "wkt는 필수입니다") + @Schema(description = "WKT 문자열 (POLYGON, MULTIPOLYGON 지원)", + example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))", + requiredMode = Schema.RequiredMode.REQUIRED) private String wkt; } } 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 38b6db0..3c7eb44 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 @@ -22,34 +22,66 @@ import java.time.OffsetDateTime; public class AisTargetResponseDto { // 선박 식별 정보 + @Schema(description = "MMSI (Maritime Mobile Service Identity) 번호", example = "440123456") private Long mmsi; + + @Schema(description = "IMO 번호 (0인 경우 미등록)", example = "9137960") private Long imo; + + @Schema(description = "선박명", example = "ROYAUME DES OCEANS") private String name; + + @Schema(description = "호출 부호", example = "4SFTEST") private String callsign; + + @Schema(description = "선박 유형 (외부 API 원본 텍스트)", example = "Vessel") private String vesselType; // 위치 정보 + @Schema(description = "위도 (WGS84)", example = "35.0796") private Double lat; + + @Schema(description = "경도 (WGS84)", example = "129.0756") private Double lon; // 항해 정보 + @Schema(description = "선수방위 (degrees, 0-360)", example = "36.0") private Double heading; - private Double sog; // Speed over Ground - private Double cog; // Course over Ground - private Integer rot; // Rate of Turn + + @Schema(description = "대지속력 (knots)", example = "12.5") + private Double sog; + + @Schema(description = "대지침로 (degrees, 0-360)", example = "36.2") + private Double cog; + + @Schema(description = "회전율 (Rate of Turn)", example = "0") + private Integer rot; // 선박 제원 + @Schema(description = "선박 길이 (미터)", example = "19") private Integer length; + + @Schema(description = "선박 폭 (미터)", example = "15") private Integer width; + + @Schema(description = "흘수 (미터)", example = "5.5") private Double draught; // 목적지 정보 + @Schema(description = "목적지", example = "BUSAN") private String destination; + + @Schema(description = "예정 도착 시간 (UTC)") private OffsetDateTime eta; + + @Schema(description = "항행상태 (텍스트)", example = "Under way using engine") private String status; // 타임스탬프 + @Schema(description = "AIS 메시지 발생 시각 (UTC)") private OffsetDateTime messageTimestamp; + + @Schema(description = "데이터 수신 시각 (UTC)") private OffsetDateTime receivedDate; // 데이터 소스 (캐시/DB) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index df9faad..c98e91f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -117,7 +117,7 @@ app: schedule: cron: "15 * * * * ?" # 매 분 15초 실행 kafka: - enabled: true + enabled: false topic: tp_Global_AIS_Signal send-chunk-size: 5000 fail-on-send-error: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 62d966d..ef74351 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -169,7 +169,7 @@ app: schedule: cron: "15 * * * * ?" # 매 분 15초 실행 kafka: - enabled: true + enabled: false # true로 변경 시 Kafka 브로커 연결 필요 topic: tp_Global_AIS_Signal send-chunk-size: 5000 fail-on-send-error: false