From 91deb3ae5593a9bd93465921afac4100fae1b446 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 7 Apr 2026 12:02:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20S1=20=EB=B0=B1=EC=97=94=EB=93=9C=20API?= =?UTF-8?q?=20=E2=80=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8/=ED=86=B5=EA=B3=84/?= =?UTF-8?q?=EB=8B=A8=EC=86=8D/=EB=A7=88=EC=8A=A4=ED=84=B0=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이벤트 허브 (domain/event/): - PredictionEvent/EventWorkflow 엔티티 + JPA Specification 필터 - EventController: 목록/상세/이력/상태변경/통계 6개 엔드포인트 - 상태 변경 시 EventWorkflow 자동 기록 (감사 추적) 통계/KPI (domain/stats/): - PredictionKpi/StatsMonthly/StatsDaily 엔티티 - StatsController: KPI/월별/일별 통계 3개 엔드포인트 단속 이력/계획 (domain/enforcement/): - EnforcementRecord/Plan 엔티티 + UID 자동생성 - EnforcementController: 단속이력/계획 CRUD 6개 엔드포인트 - 단속 등록 시 이벤트 상태 자동 RESOLVED 연동 마스터 데이터 (master/): - CodeMaster/GearType/PatrolShip/VesselPermit 엔티티 + Repository - MasterDataController: 코드/어구유형/함정/선박허가 10개 엔드포인트 총 25개 신규 엔드포인트, 98개 Java 소스 파일 컴파일 성공. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../enforcement/EnforcementController.java | 97 +++++++++++ .../domain/enforcement/EnforcementPlan.java | 100 +++++++++++ .../domain/enforcement/EnforcementRecord.java | 101 +++++++++++ .../enforcement/EnforcementService.java | 146 ++++++++++++++++ .../enforcement/dto/CreatePlanRequest.java | 23 +++ .../enforcement/dto/CreateRecordRequest.java | 26 +++ .../enforcement/dto/UpdateRecordRequest.java | 7 + .../repository/EnforcementPlanRepository.java | 11 ++ .../EnforcementRecordRepository.java | 11 ++ .../mda/kcg/domain/event/EventController.java | 111 ++++++++++++ .../gc/mda/kcg/domain/event/EventService.java | 162 ++++++++++++++++++ .../mda/kcg/domain/event/EventWorkflow.java | 50 ++++++ .../domain/event/EventWorkflowRepository.java | 10 ++ .../mda/kcg/domain/event/PredictionEvent.java | 114 ++++++++++++ .../event/PredictionEventRepository.java | 22 +++ .../event/dto/EventStatusUpdateRequest.java | 11 ++ .../mda/kcg/domain/stats/PredictionKpi.java | 32 ++++ .../domain/stats/PredictionKpiRepository.java | 6 + .../domain/stats/PredictionStatsDaily.java | 65 +++++++ .../stats/PredictionStatsDailyRepository.java | 10 ++ .../domain/stats/PredictionStatsMonthly.java | 61 +++++++ .../PredictionStatsMonthlyRepository.java | 9 + .../mda/kcg/domain/stats/StatsController.java | 60 +++++++ .../java/gc/mda/kcg/master/CodeMaster.java | 66 +++++++ .../mda/kcg/master/CodeMasterRepository.java | 14 ++ .../main/java/gc/mda/kcg/master/GearType.java | 94 ++++++++++ .../gc/mda/kcg/master/GearTypeRepository.java | 10 ++ .../mda/kcg/master/MasterDataController.java | 147 ++++++++++++++++ .../java/gc/mda/kcg/master/PatrolShip.java | 78 +++++++++ .../mda/kcg/master/PatrolShipRepository.java | 12 ++ .../java/gc/mda/kcg/master/VesselPermit.java | 87 ++++++++++ .../kcg/master/VesselPermitRepository.java | 16 ++ .../V012__prediction_events_stats.sql | 2 +- 33 files changed, 1770 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementPlan.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementRecord.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/CreatePlanRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/CreateRecordRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/UpdateRecordRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementPlanRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/EventController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/EventService.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/EventWorkflow.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/EventWorkflowRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/PredictionEventRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/dto/EventStatusUpdateRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpi.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpiRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDaily.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDailyRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthly.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthlyRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/CodeMaster.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/CodeMasterRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/GearType.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/GearTypeRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/MasterDataController.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/PatrolShip.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/PatrolShipRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/VesselPermit.java create mode 100644 backend/src/main/java/gc/mda/kcg/master/VesselPermitRepository.java diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java new file mode 100644 index 0000000..dd26dfc --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java @@ -0,0 +1,97 @@ +package gc.mda.kcg.domain.enforcement; + +import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest; +import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest; +import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest; +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +/** + * 단속 이력/계획 CRUD API. + * enforcement_records, enforcement_plans 테이블 기반. + */ +@RestController +@RequestMapping("/api/enforcement") +@RequiredArgsConstructor +public class EnforcementController { + + private final EnforcementService service; + + // ======================================================================== + // 단속 이력 (Records) + // ======================================================================== + + /** + * 단속 이력 목록 조회 (violationType 필터, 페이징) + */ + @GetMapping("/records") + @RequirePermission(resource = "enforcement:enforcement-history", operation = "READ") + public Page listRecords( + @RequestParam(required = false) String violationType, + Pageable pageable + ) { + return service.listRecords(violationType, pageable); + } + + /** + * 단속 이력 상세 조회 + */ + @GetMapping("/records/{id}") + @RequirePermission(resource = "enforcement:enforcement-history", operation = "READ") + public EnforcementRecord getRecord(@PathVariable Long id) { + return service.getRecord(id); + } + + /** + * 단속 이력 신규 등록. UID 자동 생성 (ENF-yyyyMMdd-NNNN). + * event_id가 있으면 해당 prediction_events.status를 RESOLVED로 갱신. + */ + @PostMapping("/records") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(resource = "enforcement:enforcement-history", operation = "CREATE") + public EnforcementRecord createRecord(@RequestBody CreateRecordRequest req) { + return service.createRecord(req); + } + + /** + * 단속 이력 결과 수정 (result, ai_match_status, remarks) + */ + @PatchMapping("/records/{id}") + @RequirePermission(resource = "enforcement:enforcement-history", operation = "UPDATE") + public EnforcementRecord updateRecord( + @PathVariable Long id, + @RequestBody UpdateRecordRequest req + ) { + return service.updateRecord(id, req); + } + + // ======================================================================== + // 단속 계획 (Plans) + // ======================================================================== + + /** + * 단속 계획 목록 조회 (status 필터, 페이징) + */ + @GetMapping("/plans") + @RequirePermission(resource = "enforcement:enforcement-history", operation = "READ") + public Page listPlans( + @RequestParam(required = false) String status, + Pageable pageable + ) { + return service.listPlans(status, pageable); + } + + /** + * 단속 계획 생성 + */ + @PostMapping("/plans") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(resource = "enforcement:enforcement-history", operation = "CREATE") + public EnforcementPlan createPlan(@RequestBody CreatePlanRequest req) { + return service.createPlan(req); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementPlan.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementPlan.java new file mode 100644 index 0000000..8fd6334 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementPlan.java @@ -0,0 +1,100 @@ +package gc.mda.kcg.domain.enforcement; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * 단속 계획. + * 향후 단속 예정 계획을 관리. + */ +@Entity +@Table(name = "enforcement_plans", schema = "kcg", + uniqueConstraints = @UniqueConstraint(columnNames = "plan_uid")) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class EnforcementPlan { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "plan_uid", nullable = false, length = 50, unique = true) + private String planUid; + + @Column(name = "title", length = 200) + private String title; + + @Column(name = "zone_code", length = 30) + private String zoneCode; + + @Column(name = "area_name", length = 100) + private String areaName; + + @Column(name = "lat") + private Double lat; + + @Column(name = "lon") + private Double lon; + + @Column(name = "planned_date") + private LocalDate plannedDate; + + @Column(name = "planned_from") + private OffsetDateTime plannedFrom; + + @Column(name = "planned_to") + private OffsetDateTime plannedTo; + + @Column(name = "risk_level", length = 20) + private String riskLevel; + + @Column(name = "risk_score") + private Integer riskScore; + + @Column(name = "assigned_ship_count") + private Integer assignedShipCount; + + @Column(name = "assigned_crew") + private Integer assignedCrew; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "alert_status", length = 20) + private String alertStatus; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "created_by") + private UUID createdBy; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "approved_by") + private UUID approvedBy; + + @Column(name = "remarks", columnDefinition = "text") + private String remarks; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + void prePersist() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + if (status == null) status = "DRAFT"; + } + + @PreUpdate + void preUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementRecord.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementRecord.java new file mode 100644 index 0000000..59ea293 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementRecord.java @@ -0,0 +1,101 @@ +package gc.mda.kcg.domain.enforcement; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * 단속 이력. + * 실제 단속 수행 기록을 저장. + */ +@Entity +@Table(name = "enforcement_records", schema = "kcg", + uniqueConstraints = @UniqueConstraint(columnNames = "enf_uid")) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class EnforcementRecord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "enf_uid", nullable = false, length = 50, unique = true) + private String enfUid; + + @Column(name = "event_id") + private Long eventId; + + @Column(name = "enforced_at") + private OffsetDateTime enforcedAt; + + @Column(name = "zone_code", length = 30) + private String zoneCode; + + @Column(name = "area_name", length = 100) + private String areaName; + + @Column(name = "lat") + private Double lat; + + @Column(name = "lon") + private Double lon; + + @Column(name = "vessel_mmsi", length = 20) + private String vesselMmsi; + + @Column(name = "vessel_name", length = 100) + private String vesselName; + + @Column(name = "flag_country", length = 10) + private String flagCountry; + + @Column(name = "violation_type", length = 50) + private String violationType; + + @Column(name = "action", length = 50) + private String action; + + @Column(name = "result", length = 50) + private String result; + + @Column(name = "ai_match_status", length = 20) + private String aiMatchStatus; + + @Column(name = "ai_confidence", precision = 5, scale = 4) + private BigDecimal aiConfidence; + + @Column(name = "patrol_ship_id") + private Long patrolShipId; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "enforced_by") + private UUID enforcedBy; + + @Column(name = "enforced_by_name", length = 100) + private String enforcedByName; + + @Column(name = "remarks", columnDefinition = "text") + private String remarks; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + void prePersist() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + } + + @PreUpdate + void preUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java new file mode 100644 index 0000000..27464be --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java @@ -0,0 +1,146 @@ +package gc.mda.kcg.domain.enforcement; + +import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest; +import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest; +import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest; +import gc.mda.kcg.domain.enforcement.repository.EnforcementPlanRepository; +import gc.mda.kcg.domain.enforcement.repository.EnforcementRecordRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class EnforcementService { + + private final EnforcementRecordRepository recordRepository; + private final EnforcementPlanRepository planRepository; + private final EntityManager entityManager; + + private static final DateTimeFormatter UID_DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + // ======================================================================== + // 단속 이력 + // ======================================================================== + + public Page listRecords(String violationType, Pageable pageable) { + if (violationType != null && !violationType.isBlank()) { + return recordRepository.findByViolationType(violationType, pageable); + } + return recordRepository.findAllByOrderByEnforcedAtDesc(pageable); + } + + public EnforcementRecord getRecord(Long id) { + return recordRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("EnforcementRecord not found: " + id)); + } + + @Transactional + public EnforcementRecord createRecord(CreateRecordRequest req) { + EnforcementRecord record = EnforcementRecord.builder() + .enfUid(generateEnfUid()) + .eventId(req.eventId()) + .enforcedAt(req.enforcedAt()) + .zoneCode(req.zoneCode()) + .areaName(req.areaName()) + .lat(req.lat()) + .lon(req.lon()) + .vesselMmsi(req.vesselMmsi()) + .vesselName(req.vesselName()) + .flagCountry(req.flagCountry()) + .violationType(req.violationType()) + .action(req.action()) + .result(req.result()) + .aiMatchStatus(req.aiMatchStatus()) + .aiConfidence(req.aiConfidence()) + .patrolShipId(req.patrolShipId()) + .enforcedBy(req.enforcedBy()) + .enforcedByName(req.enforcedByName()) + .remarks(req.remarks()) + .build(); + + EnforcementRecord saved = recordRepository.save(record); + + // event_id가 있으면 prediction_events.status를 RESOLVED로 갱신 + if (req.eventId() != null) { + entityManager.createQuery( + "UPDATE PredictionEvent e SET e.status = 'RESOLVED', e.resolvedAt = :now, e.updatedAt = :now WHERE e.id = :eventId" + ) + .setParameter("now", OffsetDateTime.now()) + .setParameter("eventId", req.eventId()) + .executeUpdate(); + } + + return saved; + } + + @Transactional + public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) { + EnforcementRecord record = getRecord(id); + if (req.result() != null) record.setResult(req.result()); + if (req.aiMatchStatus() != null) record.setAiMatchStatus(req.aiMatchStatus()); + if (req.remarks() != null) record.setRemarks(req.remarks()); + return recordRepository.save(record); + } + + // ======================================================================== + // 단속 계획 + // ======================================================================== + + public Page listPlans(String status, Pageable pageable) { + if (status != null && !status.isBlank()) { + return planRepository.findByStatusOrderByPlannedDateAsc(status, pageable); + } + return planRepository.findAllByOrderByPlannedDateDesc(pageable); + } + + @Transactional + public EnforcementPlan createPlan(CreatePlanRequest req) { + EnforcementPlan plan = EnforcementPlan.builder() + .planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase()) + .title(req.title()) + .zoneCode(req.zoneCode()) + .areaName(req.areaName()) + .lat(req.lat()) + .lon(req.lon()) + .plannedDate(req.plannedDate()) + .plannedFrom(req.plannedFrom()) + .plannedTo(req.plannedTo()) + .riskLevel(req.riskLevel()) + .riskScore(req.riskScore()) + .assignedShipCount(req.assignedShipCount()) + .assignedCrew(req.assignedCrew()) + .alertStatus(req.alertStatus()) + .createdBy(req.createdBy()) + .remarks(req.remarks()) + .build(); + + return planRepository.save(plan); + } + + // ======================================================================== + // UID 생성: ENF-yyyyMMdd-NNNN (일 단위 시퀀스) + // ======================================================================== + + private String generateEnfUid() { + String dateStr = LocalDate.now().format(UID_DATE_FMT); + String prefix = "ENF-" + dateStr + "-"; + + Long count = (Long) entityManager.createQuery( + "SELECT COUNT(r) FROM EnforcementRecord r WHERE r.enfUid LIKE :prefix" + ) + .setParameter("prefix", prefix + "%") + .getSingleResult(); + + return prefix + String.format("%04d", count + 1); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/CreatePlanRequest.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/CreatePlanRequest.java new file mode 100644 index 0000000..afc0ac4 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/CreatePlanRequest.java @@ -0,0 +1,23 @@ +package gc.mda.kcg.domain.enforcement.dto; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record CreatePlanRequest( + String title, + String zoneCode, + String areaName, + Double lat, + Double lon, + LocalDate plannedDate, + OffsetDateTime plannedFrom, + OffsetDateTime plannedTo, + String riskLevel, + Integer riskScore, + Integer assignedShipCount, + Integer assignedCrew, + String alertStatus, + UUID createdBy, + String remarks +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/CreateRecordRequest.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/CreateRecordRequest.java new file mode 100644 index 0000000..af88fda --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/CreateRecordRequest.java @@ -0,0 +1,26 @@ +package gc.mda.kcg.domain.enforcement.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record CreateRecordRequest( + Long eventId, + OffsetDateTime enforcedAt, + String zoneCode, + String areaName, + Double lat, + Double lon, + String vesselMmsi, + String vesselName, + String flagCountry, + String violationType, + String action, + String result, + String aiMatchStatus, + BigDecimal aiConfidence, + Long patrolShipId, + UUID enforcedBy, + String enforcedByName, + String remarks +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/UpdateRecordRequest.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/UpdateRecordRequest.java new file mode 100644 index 0000000..a273511 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/dto/UpdateRecordRequest.java @@ -0,0 +1,7 @@ +package gc.mda.kcg.domain.enforcement.dto; + +public record UpdateRecordRequest( + String result, + String aiMatchStatus, + String remarks +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementPlanRepository.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementPlanRepository.java new file mode 100644 index 0000000..21f36fe --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementPlanRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.enforcement.repository; + +import gc.mda.kcg.domain.enforcement.EnforcementPlan; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EnforcementPlanRepository extends JpaRepository { + Page findByStatusOrderByPlannedDateAsc(String status, Pageable pageable); + Page findAllByOrderByPlannedDateDesc(Pageable pageable); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java new file mode 100644 index 0000000..749259e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.enforcement.repository; + +import gc.mda.kcg.domain.enforcement.EnforcementRecord; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EnforcementRecordRepository extends JpaRepository { + Page findAllByOrderByEnforcedAtDesc(Pageable pageable); + Page findByViolationType(String violationType, Pageable pageable); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java new file mode 100644 index 0000000..f9b2a17 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java @@ -0,0 +1,111 @@ +package gc.mda.kcg.domain.event; + +import gc.mda.kcg.auth.AuthPrincipal; +import gc.mda.kcg.domain.event.dto.EventStatusUpdateRequest; +import gc.mda.kcg.permission.annotation.RequirePermission; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 이벤트 관리 API. + * 예측 이벤트의 조회, 확인, 상태 변경, 처리 이력을 제공. + */ +@RestController +@RequestMapping("/api/events") +@RequiredArgsConstructor +public class EventController { + + private final EventService eventService; + + /** + * 이벤트 목록 조회 (필터 + 페이징). + */ + @GetMapping + @RequirePermission(resource = "monitoring", operation = "READ") + public Page getEvents( + @RequestParam(required = false) String status, + @RequestParam(required = false) String level, + @RequestParam(required = false) String category, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return eventService.getEvents( + status, level, category, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "occurredAt")) + ); + } + + /** + * 이벤트 상세 조회. + */ + @GetMapping("/{id}") + @RequirePermission(resource = "monitoring", operation = "READ") + public PredictionEvent getEvent(@PathVariable Long id) { + return eventService.getEventById(id); + } + + /** + * 이벤트 처리 이력 조회. + */ + @GetMapping("/{id}/workflow") + @RequirePermission(resource = "monitoring", operation = "READ") + public List getWorkflowHistory(@PathVariable Long id) { + return eventService.getEventWorkflowHistory(id); + } + + /** + * 이벤트 확인 처리 (NEW → ACK). + */ + @PatchMapping("/{id}/ack") + @RequirePermission(resource = "monitoring", operation = "UPDATE") + public PredictionEvent acknowledgeEvent(@PathVariable Long id) { + AuthPrincipal principal = currentPrincipal(); + return eventService.acknowledgeEvent( + id, + principal != null ? principal.getUserId() : null, + principal != null ? principal.getUserNm() : null + ); + } + + /** + * 이벤트 상태 변경 (범용). + */ + @PatchMapping("/{id}/status") + @RequirePermission(resource = "monitoring", operation = "UPDATE") + public PredictionEvent updateStatus( + @PathVariable Long id, + @Valid @RequestBody EventStatusUpdateRequest req + ) { + AuthPrincipal principal = currentPrincipal(); + return eventService.updateEventStatus( + id, + req.status(), + principal != null ? principal.getUserId() : null, + principal != null ? principal.getUserNm() : null, + req.comment() + ); + } + + /** + * 상태별 이벤트 카운트 통계. + */ + @GetMapping("/stats") + @RequirePermission(resource = "monitoring", operation = "READ") + public Map getEventStats() { + return eventService.getEventStats(); + } + + private AuthPrincipal currentPrincipal() { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p; + return null; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java new file mode 100644 index 0000000..69dd17d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java @@ -0,0 +1,162 @@ +package gc.mda.kcg.domain.event; + +import gc.mda.kcg.audit.annotation.Auditable; +import gc.mda.kcg.auth.AuthPrincipal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.*; + +/** + * 이벤트 조회/상태 관리 서비스. + * 모든 상태 변경은 EventWorkflow에 이력 기록. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EventService { + + private static final Set RESOLVED_STATUSES = Set.of("RESOLVED", "FALSE_POSITIVE"); + + private final PredictionEventRepository eventRepository; + private final EventWorkflowRepository workflowRepository; + + /** + * 이벤트 목록 조회 (필터 조합). + */ + @Transactional(readOnly = true) + public Page getEvents(String status, String level, String category, Pageable pageable) { + Specification spec = Specification.where(null); + + if (status != null && !status.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("status"), status)); + } + if (level != null && !level.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("level"), level)); + } + if (category != null && !category.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("category"), category)); + } + + // 기본 정렬: occurredAt DESC + return eventRepository.findAll(spec, pageable); + } + + /** + * 이벤트 상세 조회. + */ + @Transactional(readOnly = true) + public PredictionEvent getEventById(Long id) { + return eventRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("EVENT_NOT_FOUND: " + id)); + } + + /** + * 이벤트 확인 처리 (NEW → ACK). + */ + @Auditable(action = "ACK_EVENT", resourceType = "PREDICTION_EVENT") + @Transactional + public PredictionEvent acknowledgeEvent(Long id, UUID actorId, String actorName) { + PredictionEvent event = getEventById(id); + String prevStatus = event.getStatus(); + + if (!"NEW".equals(prevStatus)) { + throw new IllegalStateException("ACK_ONLY_FROM_NEW: current=" + prevStatus); + } + + event.setStatus("ACK"); + event.setAssigneeId(actorId); + event.setAssigneeName(actorName); + event.setAckedAt(OffsetDateTime.now()); + + PredictionEvent saved = eventRepository.save(event); + + workflowRepository.save(EventWorkflow.builder() + .eventId(id) + .prevStatus(prevStatus) + .newStatus("ACK") + .actorId(actorId) + .actorName(actorName) + .build()); + + return saved; + } + + /** + * 이벤트 상태 변경 (범용) + EventWorkflow INSERT. + */ + @Auditable(action = "UPDATE_EVENT_STATUS", resourceType = "PREDICTION_EVENT") + @Transactional + public PredictionEvent updateEventStatus(Long id, String newStatus, UUID actorId, String actorName, String comment) { + PredictionEvent event = getEventById(id); + String prevStatus = event.getStatus(); + + event.setStatus(newStatus); + + // ACK 전환 시 acked_at 자동 설정 + if ("ACK".equals(newStatus) && event.getAckedAt() == null) { + event.setAckedAt(OffsetDateTime.now()); + event.setAssigneeId(actorId); + event.setAssigneeName(actorName); + } + + // RESOLVED/FALSE_POSITIVE 전환 시 resolved_at 자동 설정 + if (RESOLVED_STATUSES.contains(newStatus) && event.getResolvedAt() == null) { + event.setResolvedAt(OffsetDateTime.now()); + } + + if (comment != null && !comment.isBlank()) { + event.setResolutionNote(comment); + } + + PredictionEvent saved = eventRepository.save(event); + + workflowRepository.save(EventWorkflow.builder() + .eventId(id) + .prevStatus(prevStatus) + .newStatus(newStatus) + .actorId(actorId) + .actorName(actorName) + .comment(comment) + .build()); + + return saved; + } + + /** + * 이벤트 처리 이력 조회. + */ + @Transactional(readOnly = true) + public List getEventWorkflowHistory(Long eventId) { + return workflowRepository.findByEventIdOrderByCreatedAtDesc(eventId); + } + + /** + * 상태별 이벤트 카운트. + */ + @Transactional(readOnly = true) + public Map getEventStats() { + Map stats = new LinkedHashMap<>(); + for (String s : List.of("NEW", "ACK", "IN_PROGRESS", "RESOLVED", "FALSE_POSITIVE", "DISMISSED")) { + stats.put(s, eventRepository.countByStatus(s)); + } + return stats; + } + + // ======================================================================== + // 헬퍼 + // ======================================================================== + + AuthPrincipal currentPrincipal() { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p; + return null; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventWorkflow.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventWorkflow.java new file mode 100644 index 0000000..87fd3cb --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventWorkflow.java @@ -0,0 +1,50 @@ +package gc.mda.kcg.domain.event; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * 이벤트 상태 변경 이력 (감사 추적). + * 이벤트의 상태가 변경될 때마다 기록. + */ +@Entity +@Table(name = "event_workflow", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class EventWorkflow { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false) + private Long eventId; + + @Column(name = "prev_status", length = 20) + private String prevStatus; + + @Column(name = "new_status", length = 20) + private String newStatus; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "actor_id") + private UUID actorId; + + @Column(name = "actor_name", length = 100) + private String actorName; + + @Column(name = "comment", columnDefinition = "text") + private String comment; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventWorkflowRepository.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventWorkflowRepository.java new file mode 100644 index 0000000..d2c560d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventWorkflowRepository.java @@ -0,0 +1,10 @@ +package gc.mda.kcg.domain.event; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface EventWorkflowRepository extends JpaRepository { + + List findByEventIdOrderByCreatedAtDesc(Long eventId); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java new file mode 100644 index 0000000..cbef145 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java @@ -0,0 +1,114 @@ +package gc.mda.kcg.domain.event; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * AI 예측 이벤트. + * 불법어선 탐지, 이상행위 감지 등 시스템이 생성한 이벤트를 저장. + */ +@Entity +@Table(name = "prediction_events", schema = "kcg", + uniqueConstraints = @UniqueConstraint(columnNames = "event_uid")) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class PredictionEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_uid", nullable = false, length = 50, unique = true) + private String eventUid; + + @Column(name = "occurred_at") + private OffsetDateTime occurredAt; + + @Column(name = "level", length = 20) + private String level; + + @Column(name = "category", length = 50) + private String category; + + @Column(name = "title", length = 200) + private String title; + + @Column(name = "detail", columnDefinition = "text") + private String detail; + + @Column(name = "vessel_mmsi", length = 20) + private String vesselMmsi; + + @Column(name = "vessel_name", length = 100) + private String vesselName; + + @Column(name = "area_name", length = 100) + private String areaName; + + @Column(name = "zone_code", length = 30) + private String zoneCode; + + @Column(name = "lat") + private Double lat; + + @Column(name = "lon") + private Double lon; + + @Column(name = "speed_kn", precision = 5, scale = 2) + private BigDecimal speedKn; + + @Column(name = "source_type", length = 50) + private String sourceType; + + @Column(name = "source_ref_id") + private Long sourceRefId; + + @Column(name = "ai_confidence", precision = 5, scale = 4) + private BigDecimal aiConfidence; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "assignee_id") + private UUID assigneeId; + + @Column(name = "assignee_name", length = 100) + private String assigneeName; + + @Column(name = "acked_at") + private OffsetDateTime ackedAt; + + @Column(name = "resolved_at") + private OffsetDateTime resolvedAt; + + @Column(name = "resolution_note", columnDefinition = "text") + private String resolutionNote; + + @Column(name = "dedup_key", length = 200) + private String dedupKey; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + void prePersist() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + if (status == null) status = "NEW"; + } + + @PreUpdate + void preUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEventRepository.java b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEventRepository.java new file mode 100644 index 0000000..efa57c1 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEventRepository.java @@ -0,0 +1,22 @@ +package gc.mda.kcg.domain.event; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.List; + +public interface PredictionEventRepository + extends JpaRepository, JpaSpecificationExecutor { + + Page findByStatusInOrderByOccurredAtDesc(List statuses, Pageable pageable); + + Page findByLevelOrderByOccurredAtDesc(String level, Pageable pageable); + + Page findByCategoryOrderByOccurredAtDesc(String category, Pageable pageable); + + Page findByVesselMmsiOrderByOccurredAtDesc(String mmsi, Pageable pageable); + + long countByStatus(String status); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/dto/EventStatusUpdateRequest.java b/backend/src/main/java/gc/mda/kcg/domain/event/dto/EventStatusUpdateRequest.java new file mode 100644 index 0000000..b925823 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/dto/EventStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.event.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 이벤트 상태 변경 요청 DTO. + */ +public record EventStatusUpdateRequest( + @NotBlank String status, + String comment +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpi.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpi.java new file mode 100644 index 0000000..cc5a27a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpi.java @@ -0,0 +1,32 @@ +package gc.mda.kcg.domain.stats; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "prediction_kpi_realtime", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class PredictionKpi { + + @Id + @Column(name = "kpi_key", length = 50) + private String kpiKey; + + @Column(name = "kpi_label", length = 100) + private String kpiLabel; + + @Column(name = "value") + private Integer value; + + @Column(name = "trend", length = 10) + private String trend; + + @Column(name = "delta_pct", precision = 5, scale = 2) + private BigDecimal deltaPct; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpiRepository.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpiRepository.java new file mode 100644 index 0000000..b59944a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionKpiRepository.java @@ -0,0 +1,6 @@ +package gc.mda.kcg.domain.stats; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PredictionKpiRepository extends JpaRepository { +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDaily.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDaily.java new file mode 100644 index 0000000..3127df1 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDaily.java @@ -0,0 +1,65 @@ +package gc.mda.kcg.domain.stats; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.Map; + +@Entity +@Table(name = "prediction_stats_daily", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class PredictionStatsDaily { + + @Id + @Column(name = "stat_date") + private LocalDate statDate; + + @Column(name = "total_detections") + private Integer totalDetections; + + @Column(name = "enforcement_count") + private Integer enforcementCount; + + @Column(name = "manual_confirmed_parents") + private Integer manualConfirmedParents; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_category", columnDefinition = "jsonb") + private Map byCategory; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_zone", columnDefinition = "jsonb") + private Map byZone; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_risk_level", columnDefinition = "jsonb") + private Map byRiskLevel; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_gear_type", columnDefinition = "jsonb") + private Map byGearType; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_violation_type", columnDefinition = "jsonb") + private Map byViolationType; + + @Column(name = "event_count") + private Integer eventCount; + + @Column(name = "critical_event_count") + private Integer criticalEventCount; + + @Column(name = "false_positive_count") + private Integer falsePositiveCount; + + @Column(name = "ai_accuracy_pct", precision = 5, scale = 2) + private BigDecimal aiAccuracyPct; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDailyRepository.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDailyRepository.java new file mode 100644 index 0000000..0bd8947 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsDailyRepository.java @@ -0,0 +1,10 @@ +package gc.mda.kcg.domain.stats; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +public interface PredictionStatsDailyRepository extends JpaRepository { + List findByStatDateBetweenOrderByStatDateAsc(LocalDate from, LocalDate to); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthly.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthly.java new file mode 100644 index 0000000..9a406ff --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthly.java @@ -0,0 +1,61 @@ +package gc.mda.kcg.domain.stats; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; + +@Entity +@Table(name = "prediction_stats_monthly", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class PredictionStatsMonthly { + + @Id + @Column(name = "stat_month", length = 7, columnDefinition = "char(7)") + private String statMonth; + + @Column(name = "total_detections") + private Integer totalDetections; + + @Column(name = "total_enforcements") + private Integer totalEnforcements; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_category", columnDefinition = "jsonb") + private Map byCategory; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_zone", columnDefinition = "jsonb") + private Map byZone; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_risk_level", columnDefinition = "jsonb") + private Map byRiskLevel; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_gear_type", columnDefinition = "jsonb") + private Map byGearType; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "by_violation_type", columnDefinition = "jsonb") + private Map byViolationType; + + @Column(name = "event_count") + private Integer eventCount; + + @Column(name = "critical_event_count") + private Integer criticalEventCount; + + @Column(name = "false_positive_count") + private Integer falsePositiveCount; + + @Column(name = "ai_accuracy_pct", precision = 5, scale = 2) + private BigDecimal aiAccuracyPct; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthlyRepository.java b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthlyRepository.java new file mode 100644 index 0000000..5763620 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/PredictionStatsMonthlyRepository.java @@ -0,0 +1,9 @@ +package gc.mda.kcg.domain.stats; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PredictionStatsMonthlyRepository extends JpaRepository { + List findByStatMonthBetweenOrderByStatMonthAsc(String from, String to); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java b/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java new file mode 100644 index 0000000..2d565ce --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java @@ -0,0 +1,60 @@ +package gc.mda.kcg.domain.stats; + +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +/** + * 통계/KPI 조회 API. + * prediction_kpi_realtime, prediction_stats_monthly, prediction_stats_daily 테이블 기반. + */ +@RestController +@RequestMapping("/api/stats") +@RequiredArgsConstructor +public class StatsController { + + private final PredictionKpiRepository kpiRepository; + private final PredictionStatsMonthlyRepository monthlyRepository; + private final PredictionStatsDailyRepository dailyRepository; + + /** + * 실시간 KPI 전체 목록 조회 + */ + @GetMapping("/kpi") + @RequirePermission(resource = "statistics", operation = "READ") + public List getKpi() { + return kpiRepository.findAll(); + } + + /** + * 월별 통계 조회 + * @param from 시작 월 (예: 2025-10) + * @param to 종료 월 (예: 2026-04) + */ + @GetMapping("/monthly") + @RequirePermission(resource = "statistics", operation = "READ") + public List getMonthly( + @RequestParam String from, + @RequestParam String to + ) { + return monthlyRepository.findByStatMonthBetweenOrderByStatMonthAsc(from, to); + } + + /** + * 일별 통계 조회 + * @param from 시작 날짜 (예: 2026-04-01) + * @param to 종료 날짜 (예: 2026-04-07) + */ + @GetMapping("/daily") + @RequirePermission(resource = "statistics", operation = "READ") + public List getDaily( + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to + ) { + return dailyRepository.findByStatDateBetweenOrderByStatDateAsc(from, to); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/master/CodeMaster.java b/backend/src/main/java/gc/mda/kcg/master/CodeMaster.java new file mode 100644 index 0000000..8d59eda --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/CodeMaster.java @@ -0,0 +1,66 @@ +package gc.mda.kcg.master; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; +import java.util.Map; + +/** + * 계층형 코드 마스터. + * 시스템 전반에서 사용하는 분류 코드를 트리 구조로 관리. + */ +@Entity +@Table(name = "code_master", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class CodeMaster { + + @Id + @Column(name = "code_id", length = 100) + private String codeId; + + @Column(name = "parent_id", length = 100) + private String parentId; + + @Column(name = "group_code", length = 50) + private String groupCode; + + @Column(name = "code", length = 50) + private String code; + + @Column(name = "depth") + private Integer depth; + + @Column(name = "name_ko", length = 100) + private String nameKo; + + @Column(name = "name_en", length = 100) + private String nameEn; + + @Column(name = "sort_order") + private Integer sortOrder; + + @Column(name = "color_hex", length = 10) + private String colorHex; + + @Column(name = "icon", length = 30) + private String icon; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", columnDefinition = "jsonb") + private Map metadata; + + @Column(name = "is_active") + private Boolean isActive; + + @Column(name = "created_at") + private OffsetDateTime createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = OffsetDateTime.now(); + if (isActive == null) isActive = true; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/master/CodeMasterRepository.java b/backend/src/main/java/gc/mda/kcg/master/CodeMasterRepository.java new file mode 100644 index 0000000..941f04e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/CodeMasterRepository.java @@ -0,0 +1,14 @@ +package gc.mda.kcg.master; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CodeMasterRepository extends JpaRepository { + + List findByGroupCodeAndIsActiveTrueOrderBySortOrder(String groupCode); + + List findByGroupCodeAndDepthOrderBySortOrder(String groupCode, int depth); + + List findByParentIdOrderBySortOrder(String parentId); +} diff --git a/backend/src/main/java/gc/mda/kcg/master/GearType.java b/backend/src/main/java/gc/mda/kcg/master/GearType.java new file mode 100644 index 0000000..c76d52b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/GearType.java @@ -0,0 +1,94 @@ +package gc.mda.kcg.master; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +/** + * 어구 유형 마스터. + * 어구별 속도/패턴/법적 허용 구역 등 분석에 필요한 메타데이터 관리. + */ +@Entity +@Table(name = "gear_type_master", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class GearType { + + @Id + @Column(name = "gear_code", length = 20) + private String gearCode; + + @Column(name = "gear_name_ko", length = 50) + private String gearNameKo; + + @Column(name = "gear_name_en", length = 50) + private String gearNameEn; + + @Column(name = "category", length = 20) + private String category; + + @Column(name = "speed_min_kn") + private BigDecimal speedMinKn; + + @Column(name = "speed_max_kn") + private BigDecimal speedMaxKn; + + @Column(name = "duration_min_minutes") + private Integer durationMinMinutes; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "pattern_signature", columnDefinition = "jsonb") + private Map patternSignature; + + @Column(name = "polygon_shape_hint", length = 20) + private String polygonShapeHint; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "legal_zones", columnDefinition = "text[]") + private String[] legalZones; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "legal_seasons", columnDefinition = "jsonb") + private Map legalSeasons; + + @Column(name = "permit_required") + private Boolean permitRequired; + + @Column(name = "display_color", length = 7) + private String displayColor; + + @Column(name = "display_icon", length = 30) + private String displayIcon; + + @Column(name = "display_order") + private Integer displayOrder; + + @Column(name = "description", columnDefinition = "text") + private String description; + + @Column(name = "is_active") + private Boolean isActive; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "created_by") + private UUID createdBy; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; + + @PrePersist + void prePersist() { + if (isActive == null) isActive = true; + if (updatedAt == null) updatedAt = OffsetDateTime.now(); + } + + @PreUpdate + void preUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/master/GearTypeRepository.java b/backend/src/main/java/gc/mda/kcg/master/GearTypeRepository.java new file mode 100644 index 0000000..2c3fce5 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/GearTypeRepository.java @@ -0,0 +1,10 @@ +package gc.mda.kcg.master; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface GearTypeRepository extends JpaRepository { + + List findByIsActiveTrueOrderByDisplayOrder(); +} diff --git a/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java b/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java new file mode 100644 index 0000000..3352cbf --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java @@ -0,0 +1,147 @@ +package gc.mda.kcg.master; + +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +/** + * 마스터 데이터 통합 컨트롤러. + * 코드 마스터, 어구 유형, 함정, 선박 허가 조회/관리 API 제공. + */ +@RestController +@RequiredArgsConstructor +public class MasterDataController { + + private final CodeMasterRepository codeMasterRepository; + private final GearTypeRepository gearTypeRepository; + private final PatrolShipRepository patrolShipRepository; + private final VesselPermitRepository vesselPermitRepository; + + // ======================================================================== + // 코드 마스터 (인증만, 권한 불필요) + // ======================================================================== + + @GetMapping("/api/codes") + public List listCodes(@RequestParam String group) { + return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group); + } + + @GetMapping("/api/codes/{codeId}/children") + public List listChildren(@PathVariable String codeId) { + return codeMasterRepository.findByParentIdOrderBySortOrder(codeId); + } + + // ======================================================================== + // 어구 유형 (조회: 인증만 / 생성·수정: admin:system-config) + // ======================================================================== + + @GetMapping("/api/gear-types") + public List listGearTypes() { + return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder(); + } + + @GetMapping("/api/gear-types/{gearCode}") + public GearType getGearType(@PathVariable String gearCode) { + return gearTypeRepository.findById(gearCode) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "어구 유형을 찾을 수 없습니다: " + gearCode)); + } + + @PostMapping("/api/gear-types") + @RequirePermission(resource = "admin:system-config", operation = "CREATE") + public GearType createGearType(@RequestBody GearType gearType) { + if (gearTypeRepository.existsById(gearType.getGearCode())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "이미 존재하는 어구 코드입니다: " + gearType.getGearCode()); + } + return gearTypeRepository.save(gearType); + } + + @PutMapping("/api/gear-types/{gearCode}") + @RequirePermission(resource = "admin:system-config", operation = "UPDATE") + public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) { + if (!gearTypeRepository.existsById(gearCode)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "어구 유형을 찾을 수 없습니다: " + gearCode); + } + gearType.setGearCode(gearCode); + return gearTypeRepository.save(gearType); + } + + // ======================================================================== + // 함정 (patrol 권한) + // ======================================================================== + + @GetMapping("/api/patrol-ships") + @RequirePermission(resource = "patrol", operation = "READ") + public List listPatrolShips() { + return patrolShipRepository.findByIsActiveTrueOrderByShipCode(); + } + + @PatchMapping("/api/patrol-ships/{id}/status") + @RequirePermission(resource = "patrol", operation = "UPDATE") + public PatrolShip updatePatrolShipStatus( + @PathVariable Long id, + @RequestBody PatrolShipStatusRequest request + ) { + PatrolShip ship = patrolShipRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "함정을 찾을 수 없습니다: " + id)); + + if (request.status() != null) ship.setCurrentStatus(request.status()); + if (request.lat() != null) ship.setCurrentLat(request.lat()); + if (request.lon() != null) ship.setCurrentLon(request.lon()); + if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode()); + if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct()); + + return patrolShipRepository.save(ship); + } + + // ======================================================================== + // 선박 허가 (vessel 권한) + // ======================================================================== + + @GetMapping("/api/vessel-permits") + @RequirePermission(resource = "vessel", operation = "READ") + public Page listVesselPermits( + @RequestParam(required = false) String flag, + @RequestParam(required = false) String permitStatus, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageRequest pageable = PageRequest.of(page, size); + if (flag != null) { + return vesselPermitRepository.findByFlagCountry(flag, pageable); + } + if (permitStatus != null) { + return vesselPermitRepository.findByPermitStatus(permitStatus, pageable); + } + return vesselPermitRepository.findAll(pageable); + } + + @GetMapping("/api/vessel-permits/{mmsi}") + @RequirePermission(resource = "vessel", operation = "READ") + public VesselPermit getVesselPermit(@PathVariable String mmsi) { + return vesselPermitRepository.findByMmsi(mmsi) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "선박 허가 정보를 찾을 수 없습니다: " + mmsi)); + } + + // ======================================================================== + // 내부 DTO + // ======================================================================== + + record PatrolShipStatusRequest( + String status, + Double lat, + Double lon, + String zoneCode, + Integer fuelPct + ) {} +} diff --git a/backend/src/main/java/gc/mda/kcg/master/PatrolShip.java b/backend/src/main/java/gc/mda/kcg/master/PatrolShip.java new file mode 100644 index 0000000..ef8d55e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/PatrolShip.java @@ -0,0 +1,78 @@ +package gc.mda.kcg.master; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +/** + * 함정(경비함) 마스터. + * 해양경찰 소속 함정의 제원 및 현재 상태 관리. + */ +@Entity +@Table(name = "patrol_ship_master", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class PatrolShip { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ship_id") + private Long shipId; + + @Column(name = "ship_code", length = 20, unique = true) + private String shipCode; + + @Column(name = "ship_name", length = 100) + private String shipName; + + @Column(name = "ship_class", length = 50) + private String shipClass; + + @Column(name = "tonnage") + private BigDecimal tonnage; + + @Column(name = "max_speed_kn") + private BigDecimal maxSpeedKn; + + @Column(name = "fuel_capacity_l") + private BigDecimal fuelCapacityL; + + @Column(name = "base_port", length = 50) + private String basePort; + + @Column(name = "current_status", length = 20) + private String currentStatus; + + @Column(name = "current_lat") + private Double currentLat; + + @Column(name = "current_lon") + private Double currentLon; + + @Column(name = "current_zone_code", length = 30) + private String currentZoneCode; + + @Column(name = "fuel_pct") + private Integer fuelPct; + + @Column(name = "crew_count") + private Integer crewCount; + + @Column(name = "is_active") + private Boolean isActive; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; + + @PrePersist + void prePersist() { + if (isActive == null) isActive = true; + if (updatedAt == null) updatedAt = OffsetDateTime.now(); + } + + @PreUpdate + void preUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/master/PatrolShipRepository.java b/backend/src/main/java/gc/mda/kcg/master/PatrolShipRepository.java new file mode 100644 index 0000000..863ef00 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/PatrolShipRepository.java @@ -0,0 +1,12 @@ +package gc.mda.kcg.master; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PatrolShipRepository extends JpaRepository { + + List findByIsActiveTrueOrderByShipCode(); + + List findByCurrentStatus(String status); +} diff --git a/backend/src/main/java/gc/mda/kcg/master/VesselPermit.java b/backend/src/main/java/gc/mda/kcg/master/VesselPermit.java new file mode 100644 index 0000000..db636d6 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/VesselPermit.java @@ -0,0 +1,87 @@ +package gc.mda.kcg.master; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; + +/** + * 선박 허가 마스터. + * 어선 허가 정보, 허용 어구/구역, 유효기간 등 관리. + */ +@Entity +@Table(name = "vessel_permit_master", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class VesselPermit { + + @Id + @Column(name = "mmsi", length = 20) + private String mmsi; + + @Column(name = "vessel_name", length = 100) + private String vesselName; + + @Column(name = "vessel_name_cn", length = 100) + private String vesselNameCn; + + @Column(name = "flag_country", length = 10) + private String flagCountry; + + @Column(name = "vessel_type", length = 30) + private String vesselType; + + @Column(name = "tonnage") + private BigDecimal tonnage; + + @Column(name = "length_m") + private BigDecimal lengthM; + + @Column(name = "build_year") + private Integer buildYear; + + @Column(name = "permit_status", length = 20) + private String permitStatus; + + @Column(name = "permit_no", length = 50) + private String permitNo; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "permitted_gear_codes", columnDefinition = "text[]") + private String[] permittedGearCodes; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "permitted_zones", columnDefinition = "text[]") + private String[] permittedZones; + + @Column(name = "permit_valid_from") + private LocalDate permitValidFrom; + + @Column(name = "permit_valid_to") + private LocalDate permitValidTo; + + @Column(name = "company_id") + private Long companyId; + + @Column(name = "data_source", length = 50) + private String dataSource; + + @Column(name = "last_synced_at") + private OffsetDateTime lastSyncedAt; + + @Column(name = "updated_at") + private OffsetDateTime updatedAt; + + @PrePersist + void prePersist() { + if (updatedAt == null) updatedAt = OffsetDateTime.now(); + } + + @PreUpdate + void preUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/master/VesselPermitRepository.java b/backend/src/main/java/gc/mda/kcg/master/VesselPermitRepository.java new file mode 100644 index 0000000..8849e89 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/master/VesselPermitRepository.java @@ -0,0 +1,16 @@ +package gc.mda.kcg.master; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface VesselPermitRepository extends JpaRepository { + + Page findByFlagCountry(String flagCountry, Pageable pageable); + + Page findByPermitStatus(String permitStatus, Pageable pageable); + + Optional findByMmsi(String mmsi); +} diff --git a/backend/src/main/resources/db/migration/V012__prediction_events_stats.sql b/backend/src/main/resources/db/migration/V012__prediction_events_stats.sql index b82c849..0e8ee95 100644 --- a/backend/src/main/resources/db/migration/V012__prediction_events_stats.sql +++ b/backend/src/main/resources/db/migration/V012__prediction_events_stats.sql @@ -181,7 +181,7 @@ CREATE TABLE kcg.prediction_stats_daily ( -- 사전 집계 통계 — 월별 -- ============================================================ CREATE TABLE kcg.prediction_stats_monthly ( - stat_month CHAR(7) PRIMARY KEY, -- 'YYYY-MM' + stat_month VARCHAR(7) PRIMARY KEY, -- 'YYYY-MM' total_detections INT DEFAULT 0, total_enforcements INT DEFAULT 0, by_category JSONB,