From a4e29629fca4cf58045770138202f2faa4d99706 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 17 Apr 2026 06:53:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(detection):=20GEAR=5FIDENTITY=5FCOLLISION?= =?UTF-8?q?=20=ED=83=90=EC=A7=80=20=ED=8C=A8=ED=84=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 공존 케이스를 신규 탐지 패턴으로 분리해 기록·분류한다. 부수 효과로 fleet_tracker.track_gear_identity 의 PK 충돌로 인한 사이클 실패도 해소. Prediction - algorithms/gear_identity.py: detect_gear_name_collisions + classify_severity - fleet_tracker.py: 공존/교체 분기 분리, UPSERT helper, savepoint 점수 이전 - output/event_generator.py: run_gear_identity_collision_events 추가 - scheduler.py: track_gear_identity 직후 이벤트 승격 호출 Backend (domain/analysis) - GearIdentityCollision 엔티티 + Repository(Specification+stats) - GearIdentityCollisionService (@Transactional readOnly / @Auditable resolve) - GearCollisionController /api/analysis/gear-collisions (list/stats/detail/resolve) - GearCollisionResponse / StatsResponse / ResolveRequest (record) DB - V030__gear_identity_collision.sql: gear_identity_collisions 테이블 + auth_perm_tree 엔트리(detection:gear-collision nav_sort=950) + 역할별 권한 Frontend - shared/constants/gearCollisionStatuses.ts + catalogRegistry 등록 - services/gearCollisionApi.ts (list/stats/get/resolve) - features/detection/GearCollisionDetection.tsx (PageContainer+Section+DataTable + 분류 액션 폼, design system SSOT 준수) - componentRegistry + feature index + i18n detection.json / common.json(ko/en) --- .../analysis/GearCollisionController.java | 64 +++ .../analysis/GearCollisionResolveRequest.java | 18 + .../analysis/GearCollisionResponse.java | 57 +++ .../analysis/GearCollisionStatsResponse.java | 14 + .../analysis/GearIdentityCollision.java | 99 ++++ .../GearIdentityCollisionRepository.java | 41 ++ .../GearIdentityCollisionService.java | 133 ++++++ .../V030__gear_identity_collision.sql | 90 ++++ frontend/src/app/componentRegistry.ts | 3 + .../detection/GearCollisionDetection.tsx | 427 ++++++++++++++++++ frontend/src/features/detection/index.ts | 1 + frontend/src/lib/i18n/locales/en/common.json | 1 + .../src/lib/i18n/locales/en/detection.json | 72 +++ frontend/src/lib/i18n/locales/ko/common.json | 1 + .../src/lib/i18n/locales/ko/detection.json | 72 +++ frontend/src/services/gearCollisionApi.ts | 114 +++++ .../src/shared/constants/catalogRegistry.ts | 10 + .../shared/constants/gearCollisionStatuses.ts | 74 +++ prediction/algorithms/gear_identity.py | 157 +++++++ prediction/fleet_tracker.py | 284 +++++++++--- prediction/output/event_generator.py | 122 +++++ prediction/scheduler.py | 15 + 22 files changed, 1804 insertions(+), 65 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionResolveRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionResponse.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionStatsResponse.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollision.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollisionRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollisionService.java create mode 100644 backend/src/main/resources/db/migration/V030__gear_identity_collision.sql create mode 100644 frontend/src/features/detection/GearCollisionDetection.tsx create mode 100644 frontend/src/services/gearCollisionApi.ts create mode 100644 frontend/src/shared/constants/gearCollisionStatuses.ts create mode 100644 prediction/algorithms/gear_identity.py diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionController.java new file mode 100644 index 0000000..56e2893 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionController.java @@ -0,0 +1,64 @@ +package gc.mda.kcg.domain.analysis; + +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.web.bind.annotation.*; + +/** + * 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회 + 분류 API. + * + * 경로: /api/analysis/gear-collisions + * - GET / 목록 (status/severity/name 필터, hours 윈도우) + * - GET /stats status/severity 집계 + * - GET /{id} 단건 상세 + * - POST /{id}/resolve 분류 (REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN) + */ +@RestController +@RequestMapping("/api/analysis/gear-collisions") +@RequiredArgsConstructor +public class GearCollisionController { + + private static final String RESOURCE = "detection:gear-collision"; + + private final GearIdentityCollisionService service; + + @GetMapping + @RequirePermission(resource = RESOURCE, operation = "READ") + public Page list( + @RequestParam(required = false) String status, + @RequestParam(required = false) String severity, + @RequestParam(required = false) String name, + @RequestParam(defaultValue = "48") int hours, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return service.list(status, severity, name, hours, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt"))) + .map(GearCollisionResponse::from); + } + + @GetMapping("/stats") + @RequirePermission(resource = RESOURCE, operation = "READ") + public GearCollisionStatsResponse stats(@RequestParam(defaultValue = "48") int hours) { + return service.stats(hours); + } + + @GetMapping("/{id}") + @RequirePermission(resource = RESOURCE, operation = "READ") + public GearCollisionResponse get(@PathVariable Long id) { + return GearCollisionResponse.from(service.get(id)); + } + + @PostMapping("/{id}/resolve") + @RequirePermission(resource = RESOURCE, operation = "UPDATE") + public GearCollisionResponse resolve( + @PathVariable Long id, + @Valid @RequestBody GearCollisionResolveRequest req + ) { + return GearCollisionResponse.from(service.resolve(id, req)); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionResolveRequest.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionResolveRequest.java new file mode 100644 index 0000000..2925490 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionResolveRequest.java @@ -0,0 +1,18 @@ +package gc.mda.kcg.domain.analysis; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * gear_identity_collisions 분류(해결) 액션 요청. + * + * action: REVIEWED | CONFIRMED_ILLEGAL | FALSE_POSITIVE | REOPEN + * note : 선택 (운영자 메모) + */ +public record GearCollisionResolveRequest( + @NotBlank + @Pattern(regexp = "REVIEWED|CONFIRMED_ILLEGAL|FALSE_POSITIVE|REOPEN") + String action, + String note +) { +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionResponse.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionResponse.java new file mode 100644 index 0000000..139f79a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionResponse.java @@ -0,0 +1,57 @@ +package gc.mda.kcg.domain.analysis; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; + +/** + * gear_identity_collisions 조회 응답 DTO. + */ +public record GearCollisionResponse( + Long id, + String name, + String mmsiLo, + String mmsiHi, + String parentName, + Long parentVesselId, + OffsetDateTime firstSeenAt, + OffsetDateTime lastSeenAt, + Integer coexistenceCount, + Integer swapCount, + BigDecimal maxDistanceKm, + BigDecimal lastLatLo, + BigDecimal lastLonLo, + BigDecimal lastLatHi, + BigDecimal lastLonHi, + String severity, + String status, + String resolutionNote, + List> evidence, + OffsetDateTime updatedAt +) { + public static GearCollisionResponse from(GearIdentityCollision e) { + return new GearCollisionResponse( + e.getId(), + e.getName(), + e.getMmsiLo(), + e.getMmsiHi(), + e.getParentName(), + e.getParentVesselId(), + e.getFirstSeenAt(), + e.getLastSeenAt(), + e.getCoexistenceCount(), + e.getSwapCount(), + e.getMaxDistanceKm(), + e.getLastLatLo(), + e.getLastLonLo(), + e.getLastLatHi(), + e.getLastLonHi(), + e.getSeverity(), + e.getStatus(), + e.getResolutionNote(), + e.getEvidence(), + e.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionStatsResponse.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionStatsResponse.java new file mode 100644 index 0000000..e82b243 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearCollisionStatsResponse.java @@ -0,0 +1,14 @@ +package gc.mda.kcg.domain.analysis; + +import java.util.Map; + +/** + * gear_identity_collisions status/severity 별 집계 응답. + */ +public record GearCollisionStatsResponse( + long total, + Map byStatus, + Map bySeverity, + int hours +) { +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollision.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollision.java new file mode 100644 index 0000000..01e2ca8 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollision.java @@ -0,0 +1,99 @@ +package gc.mda.kcg.domain.analysis; + +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.List; +import java.util.Map; +import java.util.UUID; + +/** + * gear_identity_collisions 엔티티 (GEAR_IDENTITY_COLLISION 탐지 패턴). + * + * 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 내 동시 송출되는 공존 이력. + * prediction 엔진이 5분 주기로 UPSERT, 백엔드는 조회 및 운영자 분류(status) 만 갱신. + */ +@Entity +@Table(name = "gear_identity_collisions", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class GearIdentityCollision { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "mmsi_lo", nullable = false, length = 20) + private String mmsiLo; + + @Column(name = "mmsi_hi", nullable = false, length = 20) + private String mmsiHi; + + @Column(name = "parent_name", length = 100) + private String parentName; + + @Column(name = "parent_vessel_id") + private Long parentVesselId; + + @Column(name = "first_seen_at", nullable = false) + private OffsetDateTime firstSeenAt; + + @Column(name = "last_seen_at", nullable = false) + private OffsetDateTime lastSeenAt; + + @Column(name = "coexistence_count", nullable = false) + private Integer coexistenceCount; + + @Column(name = "swap_count", nullable = false) + private Integer swapCount; + + @Column(name = "max_distance_km", precision = 8, scale = 2) + private BigDecimal maxDistanceKm; + + @Column(name = "last_lat_lo", precision = 9, scale = 6) + private BigDecimal lastLatLo; + + @Column(name = "last_lon_lo", precision = 10, scale = 6) + private BigDecimal lastLonLo; + + @Column(name = "last_lat_hi", precision = 9, scale = 6) + private BigDecimal lastLatHi; + + @Column(name = "last_lon_hi", precision = 10, scale = 6) + private BigDecimal lastLonHi; + + @Column(name = "severity", nullable = false, length = 20) + private String severity; + + @Column(name = "status", nullable = false, length = 30) + private String status; + + @Column(name = "resolved_by") + private UUID resolvedBy; + + @Column(name = "resolved_at") + private OffsetDateTime resolvedAt; + + @Column(name = "resolution_note", columnDefinition = "text") + private String resolutionNote; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "evidence", columnDefinition = "jsonb") + private List> evidence; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollisionRepository.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollisionRepository.java new file mode 100644 index 0000000..32c249d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollisionRepository.java @@ -0,0 +1,41 @@ +package gc.mda.kcg.domain.analysis; + +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 org.springframework.data.jpa.repository.Query; + +import java.time.OffsetDateTime; +import java.util.List; + +public interface GearIdentityCollisionRepository + extends JpaRepository, + JpaSpecificationExecutor { + + Page findAllByLastSeenAtAfterOrderByLastSeenAtDesc( + OffsetDateTime after, Pageable pageable); + + /** + * status 별 카운트 집계 (hours 윈도우). + * 반환: [{status, count}, ...] — Object[] {String status, Long count} + */ + @Query(""" + SELECT g.status AS status, COUNT(g) AS cnt + FROM GearIdentityCollision g + WHERE g.lastSeenAt > :after + GROUP BY g.status + """) + List countByStatus(OffsetDateTime after); + + /** + * severity 별 카운트 집계 (hours 윈도우). + */ + @Query(""" + SELECT g.severity AS severity, COUNT(g) AS cnt + FROM GearIdentityCollision g + WHERE g.lastSeenAt > :after + GROUP BY g.severity + """) + List countBySeverity(OffsetDateTime after); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollisionService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollisionService.java new file mode 100644 index 0000000..d2790b4 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearIdentityCollisionService.java @@ -0,0 +1,133 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.audit.annotation.Auditable; +import gc.mda.kcg.auth.AuthPrincipal; +import jakarta.persistence.EntityNotFoundException; +import jakarta.persistence.criteria.Predicate; +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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회/분류 서비스. + * + * 조회는 모두 {@link Transactional}(readOnly=true), 분류 액션은 {@link Auditable} 로 + * 감사로그 기록. 상태 전이 화이트리스트는 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GearIdentityCollisionService { + + private static final String RESOURCE_TYPE = "GEAR_COLLISION"; + + private final GearIdentityCollisionRepository repository; + + @Transactional(readOnly = true) + public Page list( + String status, + String severity, + String name, + int hours, + Pageable pageable + ) { + OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1)); + Specification spec = (root, query, cb) -> { + List preds = new ArrayList<>(); + preds.add(cb.greaterThan(root.get("lastSeenAt"), after)); + if (status != null && !status.isBlank()) { + preds.add(cb.equal(root.get("status"), status)); + } + if (severity != null && !severity.isBlank()) { + preds.add(cb.equal(root.get("severity"), severity)); + } + if (name != null && !name.isBlank()) { + preds.add(cb.like(root.get("name"), "%" + name + "%")); + } + return cb.and(preds.toArray(new Predicate[0])); + }; + return repository.findAll(spec, pageable); + } + + @Transactional(readOnly = true) + public GearIdentityCollision get(Long id) { + return repository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id)); + } + + @Transactional(readOnly = true) + public GearCollisionStatsResponse stats(int hours) { + OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1)); + Map byStatus = new HashMap<>(); + long total = 0; + for (Object[] row : repository.countByStatus(after)) { + String s = (String) row[0]; + long c = ((Number) row[1]).longValue(); + byStatus.put(s, c); + total += c; + } + Map bySeverity = new HashMap<>(); + for (Object[] row : repository.countBySeverity(after)) { + bySeverity.put((String) row[0], ((Number) row[1]).longValue()); + } + return new GearCollisionStatsResponse(total, byStatus, bySeverity, hours); + } + + @Auditable(action = "GEAR_COLLISION_RESOLVE", resourceType = RESOURCE_TYPE) + @Transactional + public GearIdentityCollision resolve(Long id, GearCollisionResolveRequest req) { + GearIdentityCollision row = repository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id)); + AuthPrincipal principal = currentPrincipal(); + OffsetDateTime now = OffsetDateTime.now(); + + switch (req.action().toUpperCase()) { + case "REVIEWED" -> { + row.setStatus("REVIEWED"); + row.setResolvedBy(principal != null ? principal.getUserId() : null); + row.setResolvedAt(now); + row.setResolutionNote(req.note()); + } + case "CONFIRMED_ILLEGAL" -> { + row.setStatus("CONFIRMED_ILLEGAL"); + row.setResolvedBy(principal != null ? principal.getUserId() : null); + row.setResolvedAt(now); + row.setResolutionNote(req.note()); + } + case "FALSE_POSITIVE" -> { + row.setStatus("FALSE_POSITIVE"); + row.setResolvedBy(principal != null ? principal.getUserId() : null); + row.setResolvedAt(now); + row.setResolutionNote(req.note()); + } + case "REOPEN" -> { + row.setStatus("OPEN"); + row.setResolvedBy(null); + row.setResolvedAt(null); + row.setResolutionNote(req.note()); + } + default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action()); + } + row.setUpdatedAt(now); + return repository.save(row); + } + + 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/resources/db/migration/V030__gear_identity_collision.sql b/backend/src/main/resources/db/migration/V030__gear_identity_collision.sql new file mode 100644 index 0000000..dbc28c6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V030__gear_identity_collision.sql @@ -0,0 +1,90 @@ +-- ============================================================================ +-- V030: 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴 +-- 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는 +-- 케이스를 독립 탐지 패턴으로 기록. 공존 이력·심각도·운영자 분류 상태를 보존한다. +-- ============================================================================ + +-- ────────────────────────────────────────────────────────────────── +-- 1. 충돌 이력 테이블 +-- ────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS kcg.gear_identity_collisions ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, -- 동일한 어구 이름 + mmsi_lo VARCHAR(20) NOT NULL, -- 정렬된 쌍 (lo < hi) + mmsi_hi VARCHAR(20) NOT NULL, + parent_name VARCHAR(100), + parent_vessel_id BIGINT, -- fleet_vessels.id + first_seen_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL, + coexistence_count INT NOT NULL DEFAULT 1, -- 동일 cycle 공존 누적 + swap_count INT NOT NULL DEFAULT 0, -- 시간차 스왑 누적(참고) + max_distance_km NUMERIC(8,2), -- 양 위치 최대 거리 + last_lat_lo NUMERIC(9,6), + last_lon_lo NUMERIC(10,6), + last_lat_hi NUMERIC(9,6), + last_lon_hi NUMERIC(10,6), + severity VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- CRITICAL/HIGH/MEDIUM/LOW + status VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE + resolved_by UUID, + resolved_at TIMESTAMPTZ, + resolution_note TEXT, + evidence JSONB, -- 최근 관측 요약 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT gear_identity_collisions_pair_uk UNIQUE (name, mmsi_lo, mmsi_hi), + CONSTRAINT gear_identity_collisions_pair_ord CHECK (mmsi_lo < mmsi_hi) +); + +CREATE INDEX IF NOT EXISTS idx_gic_status + ON kcg.gear_identity_collisions(status, last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_gic_severity + ON kcg.gear_identity_collisions(severity, last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_gic_parent + ON kcg.gear_identity_collisions(parent_vessel_id) + WHERE parent_vessel_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_gic_name + ON kcg.gear_identity_collisions(name); +CREATE INDEX IF NOT EXISTS idx_gic_last_seen + ON kcg.gear_identity_collisions(last_seen_at DESC); + +COMMENT ON TABLE kcg.gear_identity_collisions IS + '동일 어구 이름이 서로 다른 MMSI 로 공존 송출되는 스푸핑 의심 패턴 (GEAR_IDENTITY_COLLISION).'; + +-- ────────────────────────────────────────────────────────────────── +-- 2. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL) +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm_tree + (rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, + url_path, label_key, component_key, nav_sort, labels) +VALUES + ('detection:gear-collision', NULL, '어구 정체성 충돌', 1, 40, + '/gear-collision', 'nav.gearCollision', + 'features/detection/GearCollisionDetection', 950, + '{"ko":"어구 정체성 충돌","en":"Gear Identity Collision"}'::jsonb) +ON CONFLICT (rsrc_cd) DO NOTHING; + +-- ────────────────────────────────────────────────────────────────── +-- 3. 권한 부여 +-- ADMIN : 전체 op (READ/CREATE/UPDATE/DELETE/EXPORT) +-- OPERATOR: READ + UPDATE (분류 액션) +-- VIEWER/ANALYST/FIELD: READ +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y' +FROM kcg.auth_role r +CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd) +WHERE r.role_cd = 'ADMIN' +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y' +FROM kcg.auth_role r +CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd) +WHERE r.role_cd = 'OPERATOR' +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'detection:gear-collision', 'READ', 'Y' +FROM kcg.auth_role r +WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'FIELD') +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; diff --git a/frontend/src/app/componentRegistry.ts b/frontend/src/app/componentRegistry.ts index 8f255e7..2bbd930 100644 --- a/frontend/src/app/componentRegistry.ts +++ b/frontend/src/app/componentRegistry.ts @@ -39,6 +39,9 @@ export const COMPONENT_REGISTRY: Record = { 'features/detection/ChinaFishing': lazy(() => import('@features/detection').then((m) => ({ default: m.ChinaFishing })), ), + 'features/detection/GearCollisionDetection': lazy(() => + import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })), + ), // ── 단속·이벤트 ── 'features/enforcement/EnforcementHistory': lazy(() => import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })), diff --git a/frontend/src/features/detection/GearCollisionDetection.tsx b/frontend/src/features/detection/GearCollisionDetection.tsx new file mode 100644 index 0000000..86f52ff --- /dev/null +++ b/frontend/src/features/detection/GearCollisionDetection.tsx @@ -0,0 +1,427 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertOctagon, RefreshCw } from 'lucide-react'; + +import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Select } from '@shared/components/ui/select'; +import { Textarea } from '@shared/components/ui/textarea'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; + +import { formatDateTime } from '@shared/utils/dateFormat'; +import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; +import { + GEAR_COLLISION_STATUS_ORDER, + getGearCollisionStatusIntent, + getGearCollisionStatusLabel, +} from '@shared/constants/gearCollisionStatuses'; +import { + getGearCollisionStats, + listGearCollisions, + resolveGearCollision, + type GearCollision, + type GearCollisionResolveAction, + type GearCollisionStats, +} from '@/services/gearCollisionApi'; +import { useSettingsStore } from '@stores/settingsStore'; + +/** + * 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 페이지. + * + * 동일 어구 이름이 서로 다른 MMSI 로 같은 cycle 에 공존 송출되는 경우를 목록화하고 + * 운영자가 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE 로 분류할 수 있게 한다. + */ + +type SeverityCode = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; +const SEVERITY_OPTIONS: SeverityCode[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; +const DEFAULT_HOURS = 48; + +export function GearCollisionDetection() { + const { t } = useTranslation('detection'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language) as 'ko' | 'en'; + + const [rows, setRows] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [severityFilter, setSeverityFilter] = useState(''); + const [nameFilter, setNameFilter] = useState(''); + const [selected, setSelected] = useState(null); + const [resolveAction, setResolveAction] = useState('REVIEWED'); + const [resolveNote, setResolveNote] = useState(''); + const [resolving, setResolving] = useState(false); + + const loadData = useCallback(async () => { + setLoading(true); + setError(''); + try { + const [page, summary] = await Promise.all([ + listGearCollisions({ + status: statusFilter || undefined, + severity: severityFilter || undefined, + name: nameFilter || undefined, + hours: DEFAULT_HOURS, + size: 200, + }), + getGearCollisionStats(DEFAULT_HOURS), + ]); + setRows(page.content); + setStats(summary); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : t('gearCollision.error.loadFailed')); + } finally { + setLoading(false); + } + }, [statusFilter, severityFilter, nameFilter, t]); + + useEffect(() => { loadData(); }, [loadData]); + + // 선택된 row 와 현재 목록의 동기화 + const syncedSelected = useMemo( + () => selected ? rows.find((r) => r.id === selected.id) ?? selected : null, + [rows, selected], + ); + + const cols: DataColumn>[] = useMemo(() => [ + { + key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true, + render: (v) => {v as string}, + }, + { + key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px', + render: (_, row) => ( + + {row.mmsiLo} ↔ {row.mmsiHi} + + ), + }, + { + key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px', + render: (v) => {(v as string) || '-'}, + }, + { + key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'), + width: '90px', align: 'center', sortable: true, + render: (v) => {v as number}, + }, + { + key: 'maxDistanceKm', label: t('gearCollision.columns.maxDistance'), + width: '110px', align: 'right', sortable: true, + render: (v) => { + const n = typeof v === 'number' ? v : Number(v ?? 0); + return {n.toFixed(2)}; + }, + }, + { + key: 'severity', label: t('gearCollision.columns.severity'), + width: '90px', align: 'center', sortable: true, + render: (v) => ( + + {getAlertLevelLabel(v as string, tc, lang)} + + ), + }, + { + key: 'status', label: t('gearCollision.columns.status'), + width: '110px', align: 'center', sortable: true, + render: (v) => ( + + {getGearCollisionStatusLabel(v as string, t, lang)} + + ), + }, + { + key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'), + width: '130px', sortable: true, + render: (v) => ( + {formatDateTime(v as string)} + ), + }, + ], [t, tc, lang]); + + const handleResolve = useCallback(async () => { + if (!syncedSelected) return; + const ok = window.confirm(t('gearCollision.resolve.confirmPrompt')); + if (!ok) return; + setResolving(true); + try { + const updated = await resolveGearCollision(syncedSelected.id, { + action: resolveAction, + note: resolveNote || undefined, + }); + setSelected(updated); + setResolveNote(''); + await loadData(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : t('gearCollision.error.resolveFailed')); + } finally { + setResolving(false); + } + }, [syncedSelected, resolveAction, resolveNote, loadData, t]); + + const statusCount = (code: string) => stats?.byStatus?.[code] ?? 0; + + return ( + + } + > + {t('gearCollision.list.refresh')} + + } + /> + + {error && ( + + {error} + + )} + +
+
+ + + + + +
+
+ +
+
+ + + setNameFilter(e.target.value)} + /> +
+ + {t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h + +
+
+ + {rows.length === 0 && !loading ? ( +

+ {t('gearCollision.list.empty', { hours: DEFAULT_HOURS })} +

+ ) : ( + )[]} + columns={cols} + pageSize={20} + showSearch={false} + showExport={false} + showPrint={false} + onRowClick={(row) => setSelected(row as GearCollision)} + /> + )} +
+ + {syncedSelected && ( +
+
+
+ + + + + + + + +
+
+
+ + {t('gearCollision.columns.severity')}: + + + {getAlertLevelLabel(syncedSelected.severity, tc, lang)} + + + {t('gearCollision.columns.status')}: + + + {getGearCollisionStatusLabel(syncedSelected.status, t, lang)} + +
+ {syncedSelected.resolutionNote && ( +

+ {syncedSelected.resolutionNote} +

+ )} +
+ + +