feat(detection): GEAR_IDENTITY_COLLISION 탐지 패턴 추가 + docs 정비 #73
@ -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<GearCollisionResponse> 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));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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<Map<String, Object>> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<String, Long> byStatus,
|
||||
Map<String, Long> bySeverity,
|
||||
int hours
|
||||
) {
|
||||
}
|
||||
@ -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<Map<String, Object>> evidence;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
}
|
||||
@ -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<GearIdentityCollision, Long>,
|
||||
JpaSpecificationExecutor<GearIdentityCollision> {
|
||||
|
||||
Page<GearIdentityCollision> 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<Object[]> 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<Object[]> countBySeverity(OffsetDateTime after);
|
||||
}
|
||||
@ -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<GearIdentityCollision> list(
|
||||
String status,
|
||||
String severity,
|
||||
String name,
|
||||
int hours,
|
||||
Pageable pageable
|
||||
) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
|
||||
Specification<GearIdentityCollision> spec = (root, query, cb) -> {
|
||||
List<Predicate> 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<String, Long> 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<String, Long> 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -39,6 +39,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
||||
'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 })),
|
||||
|
||||
427
frontend/src/features/detection/GearCollisionDetection.tsx
Normal file
427
frontend/src/features/detection/GearCollisionDetection.tsx
Normal file
@ -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<GearCollision[]>([]);
|
||||
const [stats, setStats] = useState<GearCollisionStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('');
|
||||
const [nameFilter, setNameFilter] = useState<string>('');
|
||||
const [selected, setSelected] = useState<GearCollision | null>(null);
|
||||
const [resolveAction, setResolveAction] = useState<GearCollisionResolveAction>('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<GearCollision & Record<string, unknown>>[] = useMemo(() => [
|
||||
{
|
||||
key: 'name', label: t('gearCollision.columns.name'), minWidth: '120px', sortable: true,
|
||||
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span>,
|
||||
},
|
||||
{
|
||||
key: 'mmsiLo', label: t('gearCollision.columns.mmsiPair'), minWidth: '160px',
|
||||
render: (_, row) => (
|
||||
<span className="font-mono text-[10px] text-label">
|
||||
{row.mmsiLo} ↔ {row.mmsiHi}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'parentName', label: t('gearCollision.columns.parentName'), minWidth: '110px',
|
||||
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'coexistenceCount', label: t('gearCollision.columns.coexistenceCount'),
|
||||
width: '90px', align: 'center', sortable: true,
|
||||
render: (v) => <span className="font-mono text-label">{v as number}</span>,
|
||||
},
|
||||
{
|
||||
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 <span className="font-mono text-[10px] text-label">{n.toFixed(2)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'severity', label: t('gearCollision.columns.severity'),
|
||||
width: '90px', align: 'center', sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||
{getAlertLevelLabel(v as string, tc, lang)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status', label: t('gearCollision.columns.status'),
|
||||
width: '110px', align: 'center', sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent={getGearCollisionStatusIntent(v as string)} size="sm">
|
||||
{getGearCollisionStatusLabel(v as string, t, lang)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastSeenAt', label: t('gearCollision.columns.lastSeen'),
|
||||
width: '130px', sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||
),
|
||||
},
|
||||
], [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 (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={AlertOctagon}
|
||||
iconColor="text-orange-600 dark:text-orange-400"
|
||||
title={t('gearCollision.title')}
|
||||
description={t('gearCollision.desc')}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t('gearCollision.list.refresh')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Card variant="default">
|
||||
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Section title={t('gearCollision.stats.title')}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
<StatCard label={t('gearCollision.stats.total')} value={stats?.total ?? 0} />
|
||||
<StatCard
|
||||
label={t('gearCollision.stats.open')}
|
||||
value={statusCount('OPEN')}
|
||||
intent="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('gearCollision.stats.reviewed')}
|
||||
value={statusCount('REVIEWED')}
|
||||
intent="info"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('gearCollision.stats.confirmed')}
|
||||
value={statusCount('CONFIRMED_ILLEGAL')}
|
||||
intent="critical"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('gearCollision.stats.falsePositive')}
|
||||
value={statusCount('FALSE_POSITIVE')}
|
||||
intent="muted"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('gearCollision.list.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||
<Select
|
||||
aria-label={t('gearCollision.filters.status')}
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">{t('gearCollision.filters.allStatus')}</option>
|
||||
{GEAR_COLLISION_STATUS_ORDER.map((s) => (
|
||||
<option key={s} value={s}>{getGearCollisionStatusLabel(s, t, lang)}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
aria-label={t('gearCollision.filters.severity')}
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||
>
|
||||
<option value="">{t('gearCollision.filters.allSeverity')}</option>
|
||||
{SEVERITY_OPTIONS.map((sv) => (
|
||||
<option key={sv} value={sv}>{getAlertLevelLabel(sv, tc, lang)}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
aria-label={t('gearCollision.filters.name')}
|
||||
placeholder={t('gearCollision.filters.name')}
|
||||
value={nameFilter}
|
||||
onChange={(e) => setNameFilter(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-end">
|
||||
<Badge intent="info" size="sm">
|
||||
{t('gearCollision.filters.hours')} · {DEFAULT_HOURS}h
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 && !loading ? (
|
||||
<p className="text-hint text-xs py-4 text-center">
|
||||
{t('gearCollision.list.empty', { hours: DEFAULT_HOURS })}
|
||||
</p>
|
||||
) : (
|
||||
<DataTable
|
||||
data={rows as (GearCollision & Record<string, unknown>)[]}
|
||||
columns={cols}
|
||||
pageSize={20}
|
||||
showSearch={false}
|
||||
showExport={false}
|
||||
showPrint={false}
|
||||
onRowClick={(row) => setSelected(row as GearCollision)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{syncedSelected && (
|
||||
<Section title={t('gearCollision.detail.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<DetailRow label={t('gearCollision.columns.name')} value={syncedSelected.name} mono />
|
||||
<DetailRow
|
||||
label={t('gearCollision.columns.mmsiPair')}
|
||||
value={`${syncedSelected.mmsiLo} ↔ ${syncedSelected.mmsiHi}`}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('gearCollision.columns.parentName')}
|
||||
value={syncedSelected.parentName ?? '-'}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('gearCollision.detail.firstSeenAt')}
|
||||
value={formatDateTime(syncedSelected.firstSeenAt)}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('gearCollision.detail.lastSeenAt')}
|
||||
value={formatDateTime(syncedSelected.lastSeenAt)}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('gearCollision.columns.coexistenceCount')}
|
||||
value={String(syncedSelected.coexistenceCount)}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('gearCollision.detail.swapCount')}
|
||||
value={String(syncedSelected.swapCount)}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('gearCollision.columns.maxDistance')}
|
||||
value={
|
||||
syncedSelected.maxDistanceKm != null
|
||||
? Number(syncedSelected.maxDistanceKm).toFixed(2)
|
||||
: '-'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-label">
|
||||
{t('gearCollision.columns.severity')}:
|
||||
</span>
|
||||
<Badge intent={getAlertLevelIntent(syncedSelected.severity)} size="sm">
|
||||
{getAlertLevelLabel(syncedSelected.severity, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-xs text-label ml-3">
|
||||
{t('gearCollision.columns.status')}:
|
||||
</span>
|
||||
<Badge intent={getGearCollisionStatusIntent(syncedSelected.status)} size="sm">
|
||||
{getGearCollisionStatusLabel(syncedSelected.status, t, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
{syncedSelected.resolutionNote && (
|
||||
<p className="text-xs text-hint border-l-2 border-border pl-2">
|
||||
{syncedSelected.resolutionNote}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="gc-resolve-action"
|
||||
className="block text-xs text-label"
|
||||
>
|
||||
{t('gearCollision.resolve.title')}
|
||||
</label>
|
||||
<Select
|
||||
id="gc-resolve-action"
|
||||
aria-label={t('gearCollision.resolve.title')}
|
||||
value={resolveAction}
|
||||
onChange={(e) => setResolveAction(e.target.value as GearCollisionResolveAction)}
|
||||
>
|
||||
<option value="REVIEWED">{t('gearCollision.resolve.reviewed')}</option>
|
||||
<option value="CONFIRMED_ILLEGAL">
|
||||
{t('gearCollision.resolve.confirmedIllegal')}
|
||||
</option>
|
||||
<option value="FALSE_POSITIVE">
|
||||
{t('gearCollision.resolve.falsePositive')}
|
||||
</option>
|
||||
<option value="REOPEN">{t('gearCollision.resolve.reopen')}</option>
|
||||
</Select>
|
||||
<Textarea
|
||||
aria-label={t('gearCollision.resolve.note')}
|
||||
placeholder={t('gearCollision.resolve.notePlaceholder')}
|
||||
value={resolveNote}
|
||||
onChange={(e) => setResolveNote(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setSelected(null); setResolveNote(''); }}
|
||||
>
|
||||
{t('gearCollision.resolve.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleResolve}
|
||||
disabled={resolving}
|
||||
>
|
||||
{t('gearCollision.resolve.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 내부 컴포넌트 ─────────────
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
||||
}
|
||||
|
||||
function StatCard({ label, value, intent }: StatCardProps) {
|
||||
return (
|
||||
<Card variant="default">
|
||||
<CardContent className="py-3 flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] text-hint">{label}</span>
|
||||
{intent ? (
|
||||
<Badge intent={intent} size="md">
|
||||
{value}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-heading">{value}</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: DetailRowProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-hint w-24 shrink-0">{label}</span>
|
||||
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GearCollisionDetection;
|
||||
@ -2,3 +2,4 @@ export { DarkVesselDetection } from './DarkVesselDetection';
|
||||
export { GearDetection } from './GearDetection';
|
||||
export { ChinaFishing } from './ChinaFishing';
|
||||
export { GearIdentification } from './GearIdentification';
|
||||
export { GearCollisionDetection } from './GearCollisionDetection';
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"darkVessel": "Dark Vessel",
|
||||
"gearDetection": "Gear Detection",
|
||||
"chinaFishing": "Chinese Vessel",
|
||||
"gearCollision": "Gear Collision",
|
||||
"patrolRoute": "Patrol Route",
|
||||
"fleetOptimization": "Fleet Optimize",
|
||||
"enforcementHistory": "History",
|
||||
|
||||
@ -14,5 +14,77 @@
|
||||
"gearId": {
|
||||
"title": "Gear Identification",
|
||||
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
||||
},
|
||||
"gearCollision": {
|
||||
"title": "Gear Identity Collision",
|
||||
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",
|
||||
"stats": {
|
||||
"title": "Overview",
|
||||
"total": "Total",
|
||||
"open": "Open",
|
||||
"reviewed": "Reviewed",
|
||||
"confirmed": "Confirmed Illegal",
|
||||
"falsePositive": "False Positive"
|
||||
},
|
||||
"list": {
|
||||
"title": "Collision Log",
|
||||
"empty": "No collisions detected in the last {{hours}} hours.",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"columns": {
|
||||
"name": "Gear Name",
|
||||
"mmsiPair": "MMSI Pair",
|
||||
"parentName": "Parent Vessel (est.)",
|
||||
"coexistenceCount": "Coexistence",
|
||||
"maxDistance": "Max Distance (km)",
|
||||
"severity": "Severity",
|
||||
"status": "Status",
|
||||
"lastSeen": "Last Seen",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"filters": {
|
||||
"status": "Status",
|
||||
"severity": "Severity",
|
||||
"name": "Search by name",
|
||||
"hours": "Window (hours)",
|
||||
"allStatus": "All statuses",
|
||||
"allSeverity": "All severities"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Collision Detail",
|
||||
"evidence": "Observations",
|
||||
"trajectoryCompare": "Trajectory Compare",
|
||||
"firstSeenAt": "First Seen",
|
||||
"lastSeenAt": "Last Seen",
|
||||
"swapCount": "Swap Count"
|
||||
},
|
||||
"resolve": {
|
||||
"title": "Operator Review",
|
||||
"reviewed": "Mark as Reviewed",
|
||||
"confirmedIllegal": "Confirm Illegal",
|
||||
"falsePositive": "Mark False Positive",
|
||||
"reopen": "Reopen",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Record rationale or supporting evidence",
|
||||
"submit": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirmPrompt": "Update status to the selected classification. Continue?"
|
||||
},
|
||||
"status": {
|
||||
"open": "Open",
|
||||
"reviewed": "Reviewed",
|
||||
"confirmedIllegal": "Confirmed Illegal",
|
||||
"falsePositive": "False Positive"
|
||||
},
|
||||
"severity": {
|
||||
"CRITICAL": "Critical",
|
||||
"HIGH": "High",
|
||||
"MEDIUM": "Medium",
|
||||
"LOW": "Low"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load collisions",
|
||||
"resolveFailed": "Failed to save classification"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"darkVessel": "다크베셀 탐지",
|
||||
"gearDetection": "어구 탐지",
|
||||
"chinaFishing": "중국어선 분석",
|
||||
"gearCollision": "어구 정체성 충돌",
|
||||
"patrolRoute": "순찰경로 추천",
|
||||
"fleetOptimization": "다함정 최적화",
|
||||
"enforcementHistory": "단속 이력",
|
||||
|
||||
@ -14,5 +14,77 @@
|
||||
"gearId": {
|
||||
"title": "어구 식별 분석",
|
||||
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
|
||||
},
|
||||
"gearCollision": {
|
||||
"title": "어구 정체성 충돌 탐지",
|
||||
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",
|
||||
"stats": {
|
||||
"title": "현황 요약",
|
||||
"total": "전체",
|
||||
"open": "미검토",
|
||||
"reviewed": "검토됨",
|
||||
"confirmed": "불법 확정",
|
||||
"falsePositive": "오탐"
|
||||
},
|
||||
"list": {
|
||||
"title": "충돌 이력",
|
||||
"empty": "최근 {{hours}}시간 내 감지된 충돌이 없습니다.",
|
||||
"refresh": "새로고침"
|
||||
},
|
||||
"columns": {
|
||||
"name": "어구명",
|
||||
"mmsiPair": "MMSI 쌍",
|
||||
"parentName": "추정 모선",
|
||||
"coexistenceCount": "공존 횟수",
|
||||
"maxDistance": "최대 거리(km)",
|
||||
"severity": "심각도",
|
||||
"status": "상태",
|
||||
"lastSeen": "마지막 감지",
|
||||
"actions": "액션"
|
||||
},
|
||||
"filters": {
|
||||
"status": "상태",
|
||||
"severity": "심각도",
|
||||
"name": "어구명 검색",
|
||||
"hours": "조회 기간(시간)",
|
||||
"allStatus": "전체 상태",
|
||||
"allSeverity": "전체 심각도"
|
||||
},
|
||||
"detail": {
|
||||
"title": "공존 상세",
|
||||
"evidence": "관측 이력",
|
||||
"trajectoryCompare": "궤적 비교",
|
||||
"firstSeenAt": "최초 감지",
|
||||
"lastSeenAt": "마지막 감지",
|
||||
"swapCount": "교체 누적"
|
||||
},
|
||||
"resolve": {
|
||||
"title": "운영자 분류",
|
||||
"reviewed": "검토 완료",
|
||||
"confirmedIllegal": "불법으로 확정",
|
||||
"falsePositive": "오탐으로 분류",
|
||||
"reopen": "재오픈",
|
||||
"note": "판정 메모",
|
||||
"notePlaceholder": "분류 사유·추가 증거 등을 기록하세요",
|
||||
"submit": "저장",
|
||||
"cancel": "취소",
|
||||
"confirmPrompt": "선택한 분류로 상태를 갱신합니다. 계속할까요?"
|
||||
},
|
||||
"status": {
|
||||
"open": "미검토",
|
||||
"reviewed": "검토됨",
|
||||
"confirmedIllegal": "불법 확정",
|
||||
"falsePositive": "오탐"
|
||||
},
|
||||
"severity": {
|
||||
"CRITICAL": "매우 심각",
|
||||
"HIGH": "심각",
|
||||
"MEDIUM": "주의",
|
||||
"LOW": "경미"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "충돌 목록을 불러오지 못했습니다",
|
||||
"resolveFailed": "분류 저장에 실패했습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
114
frontend/src/services/gearCollisionApi.ts
Normal file
114
frontend/src/services/gearCollisionApi.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* gear_identity_collisions 조회 + 분류 액션 API 서비스.
|
||||
* 백엔드 /api/analysis/gear-collisions 연동.
|
||||
*/
|
||||
|
||||
import type { AnalysisPageResponse } from './analysisApi';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
// ─── DTO (백엔드 GearCollisionResponse 1:1 매핑) ─────────────
|
||||
|
||||
export interface GearCollision {
|
||||
id: number;
|
||||
name: string;
|
||||
mmsiLo: string;
|
||||
mmsiHi: string;
|
||||
parentName: string | null;
|
||||
parentVesselId: number | null;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
coexistenceCount: number;
|
||||
swapCount: number;
|
||||
maxDistanceKm: number | null;
|
||||
lastLatLo: number | null;
|
||||
lastLonLo: number | null;
|
||||
lastLatHi: number | null;
|
||||
lastLonHi: number | null;
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | string;
|
||||
status: 'OPEN' | 'REVIEWED' | 'CONFIRMED_ILLEGAL' | 'FALSE_POSITIVE' | string;
|
||||
resolutionNote: string | null;
|
||||
evidence: Array<Record<string, unknown>> | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GearCollisionStats {
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
bySeverity: Record<string, number>;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
export type GearCollisionResolveAction =
|
||||
| 'REVIEWED'
|
||||
| 'CONFIRMED_ILLEGAL'
|
||||
| 'FALSE_POSITIVE'
|
||||
| 'REOPEN';
|
||||
|
||||
export interface GearCollisionResolveRequest {
|
||||
action: GearCollisionResolveAction;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// ─── 내부 헬퍼 ─────────────
|
||||
|
||||
function buildQuery(params: Record<string, unknown>): string {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
qs.set(k, String(v));
|
||||
}
|
||||
const s = qs.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}${buildQuery(params)}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 공개 함수 ─────────────
|
||||
|
||||
/** 어구 정체성 충돌 목록 조회 (필터 + 페이징). */
|
||||
export function listGearCollisions(params?: {
|
||||
status?: string;
|
||||
severity?: string;
|
||||
name?: string;
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse<GearCollision>> {
|
||||
return apiGet('/analysis/gear-collisions', {
|
||||
hours: 48, page: 0, size: 50, ...params,
|
||||
});
|
||||
}
|
||||
|
||||
/** status/severity 집계 */
|
||||
export function getGearCollisionStats(hours = 48): Promise<GearCollisionStats> {
|
||||
return apiGet('/analysis/gear-collisions/stats', { hours });
|
||||
}
|
||||
|
||||
/** 단건 상세 조회 */
|
||||
export function getGearCollision(id: number): Promise<GearCollision> {
|
||||
return apiGet(`/analysis/gear-collisions/${id}`);
|
||||
}
|
||||
|
||||
/** 운영자 분류 액션 */
|
||||
export function resolveGearCollision(
|
||||
id: number,
|
||||
body: GearCollisionResolveRequest,
|
||||
): Promise<GearCollision> {
|
||||
return apiPost(`/analysis/gear-collisions/${id}/resolve`, body);
|
||||
}
|
||||
@ -45,6 +45,7 @@ import { ZONE_CODES } from './zoneCodes';
|
||||
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
||||
import { VESSEL_TYPES } from './vesselTypes';
|
||||
import { PERFORMANCE_STATUS_META } from './performanceStatus';
|
||||
import { GEAR_COLLISION_STATUSES } from './gearCollisionStatuses';
|
||||
|
||||
/**
|
||||
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
||||
@ -337,6 +338,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
|
||||
source: 'admin 성능·데이터 보관·모델 검증 공통',
|
||||
items: PERFORMANCE_STATUS_META,
|
||||
},
|
||||
{
|
||||
id: 'gear-collision-status',
|
||||
showcaseId: 'TRK-CAT-gear-collision-status',
|
||||
titleKo: '어구 정체성 충돌 상태',
|
||||
titleEn: 'Gear Identity Collision Status',
|
||||
description: 'OPEN / REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE',
|
||||
source: 'backend gear_identity_collisions.status (V030)',
|
||||
items: GEAR_COLLISION_STATUSES,
|
||||
},
|
||||
];
|
||||
|
||||
/** ID로 특정 카탈로그 조회 */
|
||||
|
||||
74
frontend/src/shared/constants/gearCollisionStatuses.ts
Normal file
74
frontend/src/shared/constants/gearCollisionStatuses.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 운영자 분류 상태 카탈로그
|
||||
*
|
||||
* SSOT: backend GearIdentityCollision.status 컬럼 (V030 마이그레이션)
|
||||
* 사용처: GearCollisionDetection 페이지 필터/테이블 Badge
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type GearCollisionStatus =
|
||||
| 'OPEN' // 신규 탐지 (미검토)
|
||||
| 'REVIEWED' // 검토됨 (확정 보류)
|
||||
| 'CONFIRMED_ILLEGAL' // 불법 확정
|
||||
| 'FALSE_POSITIVE'; // 오탐 처리
|
||||
|
||||
export interface GearCollisionStatusMeta {
|
||||
code: GearCollisionStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const GEAR_COLLISION_STATUSES: Record<GearCollisionStatus, GearCollisionStatusMeta> = {
|
||||
OPEN: {
|
||||
code: 'OPEN',
|
||||
i18nKey: 'gearCollision.status.open',
|
||||
fallback: { ko: '미검토', en: 'Open' },
|
||||
intent: 'warning',
|
||||
},
|
||||
REVIEWED: {
|
||||
code: 'REVIEWED',
|
||||
i18nKey: 'gearCollision.status.reviewed',
|
||||
fallback: { ko: '검토됨', en: 'Reviewed' },
|
||||
intent: 'info',
|
||||
},
|
||||
CONFIRMED_ILLEGAL: {
|
||||
code: 'CONFIRMED_ILLEGAL',
|
||||
i18nKey: 'gearCollision.status.confirmedIllegal',
|
||||
fallback: { ko: '불법 확정', en: 'Confirmed Illegal' },
|
||||
intent: 'critical',
|
||||
},
|
||||
FALSE_POSITIVE: {
|
||||
code: 'FALSE_POSITIVE',
|
||||
i18nKey: 'gearCollision.status.falsePositive',
|
||||
fallback: { ko: '오탐', en: 'False Positive' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getGearCollisionStatusMeta(s: string): GearCollisionStatusMeta | undefined {
|
||||
return GEAR_COLLISION_STATUSES[s as GearCollisionStatus];
|
||||
}
|
||||
|
||||
export function getGearCollisionStatusIntent(s: string): BadgeIntent {
|
||||
return getGearCollisionStatusMeta(s)?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
export function getGearCollisionStatusLabel(
|
||||
s: string,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getGearCollisionStatusMeta(s);
|
||||
if (!meta) return s;
|
||||
const translated = t(meta.i18nKey, { defaultValue: '' });
|
||||
return translated || meta.fallback[lang];
|
||||
}
|
||||
|
||||
export const GEAR_COLLISION_STATUS_ORDER: GearCollisionStatus[] = [
|
||||
'OPEN',
|
||||
'REVIEWED',
|
||||
'CONFIRMED_ILLEGAL',
|
||||
'FALSE_POSITIVE',
|
||||
];
|
||||
157
prediction/algorithms/gear_identity.py
Normal file
157
prediction/algorithms/gear_identity.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""
|
||||
어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 알고리즘.
|
||||
|
||||
동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는 케이스를
|
||||
스푸핑/복제 의심 패턴으로 탐지한다. fleet_tracker.track_gear_identity() 루프 진입
|
||||
전에 사이클 단위로 사전 집계하는 데 사용된다.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from itertools import combinations
|
||||
from typing import Optional
|
||||
|
||||
from algorithms.location import haversine_nm
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 공존 판정 · 심각도 임계
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
MIN_COEXISTENCE_GROUP = 2 # 같은 이름에 MMSI 2개 이상
|
||||
IMPOSSIBLE_SPEED_KTS = 60.0 # 두 위치 이동에 필요한 속도가 이보다 크면 물리 불가능
|
||||
CRITICAL_DISTANCE_KM = 50.0 # 단발이라도 이 거리 이상이면 즉시 CRITICAL
|
||||
HIGH_DISTANCE_KM = 10.0 # HIGH 기준 거리
|
||||
CRITICAL_COEXISTENCE_COUNT = 3 # 누적 공존 N회 이상이면 CRITICAL 승격
|
||||
HIGH_COEXISTENCE_COUNT = 2 # 누적 공존 N회 이상이면 HIGH
|
||||
NM_TO_KM = 1.852 # 1 nautical mile = 1.852 km
|
||||
|
||||
|
||||
def detect_gear_name_collisions(
|
||||
gear_signals: list[dict],
|
||||
now: datetime,
|
||||
) -> list[dict]:
|
||||
"""동일 이름 · 다중 MMSI 공존 세트 추출.
|
||||
|
||||
Args:
|
||||
gear_signals: [{mmsi, name, lat, lon}, ...] — track_gear_identity 와 동일 입력.
|
||||
now: 사이클 기준 시각(UTC).
|
||||
|
||||
Returns:
|
||||
공존 쌍 리스트. 세 개 이상 동시 송출 케이스는 모든 2-조합을 생성한다.
|
||||
각 원소:
|
||||
{
|
||||
'name': str,
|
||||
'mmsi_lo': str, # 사전순으로 작은 MMSI
|
||||
'mmsi_hi': str,
|
||||
'lat_lo', 'lon_lo': float,
|
||||
'lat_hi', 'lon_hi': float,
|
||||
'distance_km': float,
|
||||
'parent_name': Optional[str], # 힌트 (GEAR_PATTERN parent 그룹, 있으면)
|
||||
'observed_at': datetime,
|
||||
}
|
||||
"""
|
||||
if not gear_signals:
|
||||
return []
|
||||
|
||||
# 이름 기준 그룹핑
|
||||
by_name: dict[str, list[dict]] = {}
|
||||
for sig in gear_signals:
|
||||
name = sig.get('name')
|
||||
mmsi = sig.get('mmsi')
|
||||
if not name or not mmsi:
|
||||
continue
|
||||
by_name.setdefault(name, []).append(sig)
|
||||
|
||||
collisions: list[dict] = []
|
||||
for name, signals in by_name.items():
|
||||
if len(signals) < MIN_COEXISTENCE_GROUP:
|
||||
continue
|
||||
# 같은 MMSI 중복은 제거 (한 cycle 에 동일 MMSI 가 다수 신호로 들어올 수 있음)
|
||||
unique_by_mmsi: dict[str, dict] = {}
|
||||
for sig in signals:
|
||||
unique_by_mmsi.setdefault(sig['mmsi'], sig)
|
||||
if len(unique_by_mmsi) < MIN_COEXISTENCE_GROUP:
|
||||
continue
|
||||
|
||||
parent_name = _infer_parent_name(name)
|
||||
mmsis = sorted(unique_by_mmsi.keys())
|
||||
for a, b in combinations(mmsis, 2):
|
||||
sa, sb = unique_by_mmsi[a], unique_by_mmsi[b]
|
||||
dist_km = _haversine_km(
|
||||
sa.get('lat'), sa.get('lon'),
|
||||
sb.get('lat'), sb.get('lon'),
|
||||
)
|
||||
collisions.append({
|
||||
'name': name,
|
||||
'mmsi_lo': a,
|
||||
'mmsi_hi': b,
|
||||
'lat_lo': _to_float(sa.get('lat')),
|
||||
'lon_lo': _to_float(sa.get('lon')),
|
||||
'lat_hi': _to_float(sb.get('lat')),
|
||||
'lon_hi': _to_float(sb.get('lon')),
|
||||
'distance_km': dist_km,
|
||||
'parent_name': parent_name,
|
||||
'observed_at': now,
|
||||
})
|
||||
return collisions
|
||||
|
||||
|
||||
def classify_severity(
|
||||
coexistence_count: int,
|
||||
max_distance_km: Optional[float],
|
||||
swap_count: int = 0,
|
||||
) -> str:
|
||||
"""충돌 심각도 산정.
|
||||
|
||||
- CRITICAL: 거리 불가능 / 누적 공존 N회 이상
|
||||
- HIGH: 상당 거리 / 2회 이상
|
||||
- MEDIUM: 단발 근거리
|
||||
- LOW: 근거리 + 거리 정보 없음
|
||||
"""
|
||||
distance = max_distance_km or 0.0
|
||||
if distance >= CRITICAL_DISTANCE_KM:
|
||||
return 'CRITICAL'
|
||||
if coexistence_count >= CRITICAL_COEXISTENCE_COUNT:
|
||||
return 'CRITICAL'
|
||||
if distance >= HIGH_DISTANCE_KM:
|
||||
return 'HIGH'
|
||||
if coexistence_count >= HIGH_COEXISTENCE_COUNT:
|
||||
return 'HIGH'
|
||||
if swap_count >= HIGH_COEXISTENCE_COUNT:
|
||||
return 'HIGH'
|
||||
if max_distance_km is None or max_distance_km < 0.1:
|
||||
return 'LOW'
|
||||
return 'MEDIUM'
|
||||
|
||||
|
||||
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
||||
"""두 좌표 사이 거리를 km 로 반환. 입력 누락 시 0.0."""
|
||||
try:
|
||||
if lat1 is None or lon1 is None or lat2 is None or lon2 is None:
|
||||
return 0.0
|
||||
nm = haversine_nm(float(lat1), float(lon1), float(lat2), float(lon2))
|
||||
return round(nm * NM_TO_KM, 2)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _to_float(val) -> Optional[float]:
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _infer_parent_name(gear_name: str) -> Optional[str]:
|
||||
"""어구 이름에서 모선명 부분 추출 (느슨).
|
||||
|
||||
fleet_tracker 가 이미 GEAR_PATTERN 으로 정교하게 파싱하지만, 알고리즘 모듈 독립성을
|
||||
위해 단순 휴리스틱만 유지한다. 값이 필요한 경우 fleet_tracker 호출부에서 덮어쓴다.
|
||||
"""
|
||||
if not gear_name:
|
||||
return None
|
||||
# '_숫자' 로 끝나는 서픽스 제거
|
||||
base = gear_name
|
||||
parts = base.rsplit('_', 2)
|
||||
if len(parts) >= 2 and any(ch.isdigit() for ch in parts[-1]):
|
||||
return parts[0]
|
||||
return None
|
||||
@ -1,4 +1,5 @@
|
||||
"""등록 선단 기반 추적기."""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@ -6,7 +7,9 @@ from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
import psycopg2
|
||||
|
||||
from algorithms.gear_identity import classify_severity, detect_gear_name_collisions
|
||||
from algorithms.gear_name_rules import is_trackable_parent_name
|
||||
from config import qualified_table
|
||||
|
||||
@ -21,6 +24,7 @@ FLEET_COMPANIES = qualified_table('fleet_companies')
|
||||
FLEET_VESSELS = qualified_table('fleet_vessels')
|
||||
GEAR_IDENTITY_LOG = qualified_table('gear_identity_log')
|
||||
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
||||
GEAR_IDENTITY_COLLISIONS = qualified_table('gear_identity_collisions')
|
||||
FLEET_TRACKING_SNAPSHOT = qualified_table('fleet_tracking_snapshot')
|
||||
|
||||
# 선박명 정규화 (중국/한국 어선 식별자 보존):
|
||||
@ -341,61 +345,40 @@ class FleetTracker:
|
||||
"""어구/어망 정체성 추적.
|
||||
|
||||
gear_signals: [{mmsi, name, lat, lon}, ...] — 이름이 XXX_숫자_숫자 패턴인 AIS 신호
|
||||
|
||||
동일 이름이 서로 다른 MMSI 로 같은 cycle 에 동시에 수신된 경우는 "공존
|
||||
(GEAR_IDENTITY_COLLISION)" 으로 간주해 gear_identity_collisions 에 누적하고,
|
||||
gear_identity_log 는 각 MMSI 별로 독립 active 유지한다 (비활성화/점수 이전 X).
|
||||
단일 MMSI 이름에 한해 기존 교체(sequential) 로직을 적용한다.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
now = datetime.now(timezone.utc)
|
||||
self._recent_collision_ids: list[int] = []
|
||||
|
||||
# 1) 공존 감지 + UPSERT (이번 사이클에 동일 이름 다중 MMSI 수신 확인)
|
||||
collisions = detect_gear_name_collisions(gear_signals, now)
|
||||
colliding_names: set[str] = {c['name'] for c in collisions}
|
||||
for c in collisions:
|
||||
cid = self._upsert_gear_collision(cur, c, now)
|
||||
if cid is not None:
|
||||
self._recent_collision_ids.append(cid)
|
||||
|
||||
# 2) 개별 신호 처리
|
||||
for g in gear_signals:
|
||||
mmsi = g['mmsi']
|
||||
name = g['name']
|
||||
lat = g.get('lat', 0)
|
||||
lon = g.get('lon', 0)
|
||||
|
||||
# 모선명 + 인덱스 추출
|
||||
parent_name: Optional[str] = None
|
||||
idx1: Optional[int] = None
|
||||
idx2: Optional[int] = None
|
||||
|
||||
m = GEAR_PATTERN.match(name)
|
||||
if m:
|
||||
# group(1): parent+index 패턴, group(2): 순수 숫자 패턴
|
||||
if m.group(1):
|
||||
parent_name = m.group(1).strip()
|
||||
suffix = name[m.end(1):].strip(' _')
|
||||
digits = re.findall(r'\d+', suffix)
|
||||
idx1 = int(digits[0]) if len(digits) >= 1 else None
|
||||
idx2 = int(digits[1]) if len(digits) >= 2 else None
|
||||
else:
|
||||
# 순수 숫자 이름 (예: 12345) — parent 없음, 인덱스만
|
||||
idx1 = int(m.group(2))
|
||||
else:
|
||||
m2 = GEAR_PATTERN_PCT.match(name)
|
||||
if m2:
|
||||
parent_name = m2.group(1).strip()
|
||||
|
||||
parent_name, idx1, idx2 = self._parse_gear_name(name)
|
||||
effective_parent_name = parent_name or name
|
||||
if not is_trackable_parent_name(effective_parent_name):
|
||||
continue
|
||||
|
||||
# 모선 매칭 (EXACT → FUZZY 순)
|
||||
parent_mmsi: Optional[str] = None
|
||||
parent_vid: Optional[int] = None
|
||||
if parent_name:
|
||||
vid = self._name_cn_map.get(parent_name)
|
||||
if not vid:
|
||||
vid = self._name_en_map.get(parent_name.lower())
|
||||
if not vid:
|
||||
key = _normalize_vessel_name(parent_name)
|
||||
if key:
|
||||
candidates = self._name_fuzzy_map.get(key, [])
|
||||
if len(candidates) == 1:
|
||||
vid = candidates[0]
|
||||
if vid:
|
||||
parent_vid = vid
|
||||
parent_mmsi = self._vessels[vid].get('mmsi')
|
||||
|
||||
parent_mmsi, parent_vid = self._match_parent_vessel(parent_name)
|
||||
match_method: Optional[str] = 'NAME_PARENT' if parent_vid else None
|
||||
confidence = 0.9 if parent_vid else 0.0
|
||||
is_colliding = name in colliding_names
|
||||
|
||||
# 기존 활성 행 조회
|
||||
cur.execute(
|
||||
@ -431,36 +414,26 @@ class FleetTracker:
|
||||
match_method, confidence, now, now),
|
||||
)
|
||||
else:
|
||||
# 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인
|
||||
cur.execute(
|
||||
f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
|
||||
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
|
||||
(name, mmsi),
|
||||
)
|
||||
old_mmsi_row = cur.fetchone()
|
||||
if old_mmsi_row:
|
||||
# 같은 이름 + 다른 MMSI → MMSI 변경
|
||||
# 새 MMSI — 이름이 공존 케이스면 다른 MMSI 활성행을 건드리지 않고 이번 것만 INSERT
|
||||
if not is_colliding:
|
||||
# 교체 경로 — 같은 이름이 다른 MMSI 로 active 면 MMSI 교체로 간주
|
||||
cur.execute(
|
||||
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
|
||||
(old_mmsi_row[0],),
|
||||
f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
|
||||
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
|
||||
(name, mmsi),
|
||||
)
|
||||
logger.info('gear MMSI change: %s → %s (name=%s)', old_mmsi_row[1], mmsi, name)
|
||||
|
||||
# 어피니티 점수 이전 (이전 MMSI → 새 MMSI)
|
||||
try:
|
||||
old_mmsi_row = cur.fetchone()
|
||||
if old_mmsi_row:
|
||||
cur.execute(
|
||||
f"UPDATE {GEAR_CORRELATION_SCORES} "
|
||||
"SET target_mmsi = %s, updated_at = NOW() "
|
||||
"WHERE target_mmsi = %s",
|
||||
(mmsi, old_mmsi_row[1]),
|
||||
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
|
||||
(old_mmsi_row[0],),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
logger.info(
|
||||
'transferred %d affinity scores: %s → %s',
|
||||
cur.rowcount, old_mmsi_row[1], mmsi,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('affinity score transfer failed: %s', e)
|
||||
logger.info(
|
||||
'gear MMSI change: %s → %s (name=%s)',
|
||||
old_mmsi_row[1], mmsi, name,
|
||||
)
|
||||
# 어피니티 점수 이전 (savepoint 로 격리 — PK 충돌 시 트랜잭션 유지)
|
||||
self._transfer_affinity_scores(cur, old_mmsi_row[1], mmsi)
|
||||
|
||||
cur.execute(
|
||||
f"""INSERT INTO {GEAR_IDENTITY_LOG}
|
||||
@ -473,9 +446,190 @@ class FleetTracker:
|
||||
match_method, confidence, now, now),
|
||||
)
|
||||
|
||||
if collisions:
|
||||
logger.info(
|
||||
'gear identity collisions: %d pairs touched (%d distinct names)',
|
||||
len(collisions), len(colliding_names),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
def _parse_gear_name(self, name: str) -> tuple[Optional[str], Optional[int], Optional[int]]:
|
||||
"""어구 이름에서 parent_name, idx1, idx2 추출."""
|
||||
parent_name: Optional[str] = None
|
||||
idx1: Optional[int] = None
|
||||
idx2: Optional[int] = None
|
||||
m = GEAR_PATTERN.match(name)
|
||||
if m:
|
||||
if m.group(1):
|
||||
parent_name = m.group(1).strip()
|
||||
suffix = name[m.end(1):].strip(' _')
|
||||
digits = re.findall(r'\d+', suffix)
|
||||
idx1 = int(digits[0]) if len(digits) >= 1 else None
|
||||
idx2 = int(digits[1]) if len(digits) >= 2 else None
|
||||
else:
|
||||
idx1 = int(m.group(2))
|
||||
else:
|
||||
m2 = GEAR_PATTERN_PCT.match(name)
|
||||
if m2:
|
||||
parent_name = m2.group(1).strip()
|
||||
return parent_name, idx1, idx2
|
||||
|
||||
def _match_parent_vessel(self, parent_name: Optional[str]) -> tuple[Optional[str], Optional[int]]:
|
||||
"""parent_name → (parent_mmsi, parent_vessel_id) 매칭. EXACT → FUZZY 순."""
|
||||
if not parent_name:
|
||||
return None, None
|
||||
vid = self._name_cn_map.get(parent_name)
|
||||
if not vid:
|
||||
vid = self._name_en_map.get(parent_name.lower())
|
||||
if not vid:
|
||||
key = _normalize_vessel_name(parent_name)
|
||||
if key:
|
||||
candidates = self._name_fuzzy_map.get(key, [])
|
||||
if len(candidates) == 1:
|
||||
vid = candidates[0]
|
||||
if vid:
|
||||
return self._vessels[vid].get('mmsi'), vid
|
||||
return None, None
|
||||
|
||||
def _transfer_affinity_scores(self, cur, old_mmsi: str, new_mmsi: str) -> None:
|
||||
"""gear_correlation_scores 의 target_mmsi 를 새 MMSI 로 이전.
|
||||
|
||||
PK = (model_id, group_key, sub_cluster_id, target_mmsi) 충돌 가능성이 있어
|
||||
SAVEPOINT 로 격리. 충돌 시에는 이전을 포기하고 경고만 남긴다 — 상위 트랜잭션은
|
||||
유지되어 뒤이은 INSERT 가 정상 동작한다.
|
||||
"""
|
||||
cur.execute('SAVEPOINT sp_score_xfer')
|
||||
try:
|
||||
cur.execute(
|
||||
f"UPDATE {GEAR_CORRELATION_SCORES} "
|
||||
"SET target_mmsi = %s, updated_at = NOW() "
|
||||
"WHERE target_mmsi = %s",
|
||||
(new_mmsi, old_mmsi),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
logger.info(
|
||||
'transferred %d affinity scores: %s → %s',
|
||||
cur.rowcount, old_mmsi, new_mmsi,
|
||||
)
|
||||
cur.execute('RELEASE SAVEPOINT sp_score_xfer')
|
||||
except psycopg2.errors.UniqueViolation as e:
|
||||
cur.execute('ROLLBACK TO SAVEPOINT sp_score_xfer')
|
||||
cur.execute('RELEASE SAVEPOINT sp_score_xfer')
|
||||
logger.warning(
|
||||
'affinity score transfer skipped (pk conflict): %s → %s | %s',
|
||||
old_mmsi, new_mmsi, e,
|
||||
)
|
||||
|
||||
def _upsert_gear_collision(
|
||||
self,
|
||||
cur,
|
||||
collision: dict,
|
||||
now: datetime,
|
||||
) -> Optional[int]:
|
||||
"""단일 공존 쌍 UPSERT → row id 반환.
|
||||
|
||||
- 기존 행: coexistence_count += 1, last_seen_at/좌표 갱신, max_distance_km =
|
||||
GREATEST(기존, 신규). status 가 OPEN/REVIEWED 인 경우에 한해 severity 재계산.
|
||||
- 신규 행: first_seen_at = last_seen_at = now, severity 계산 후 INSERT.
|
||||
"""
|
||||
name = collision['name']
|
||||
mmsi_lo = collision['mmsi_lo']
|
||||
mmsi_hi = collision['mmsi_hi']
|
||||
distance_km = collision.get('distance_km') or 0.0
|
||||
|
||||
# 모선 힌트 — fleet_tracker 매칭 결과를 우선, 없으면 알고리즘이 추출한 값 사용
|
||||
hint_parent = collision.get('parent_name')
|
||||
parent_mmsi, parent_vid = self._match_parent_vessel(hint_parent)
|
||||
|
||||
cur.execute(
|
||||
f"""SELECT id, coexistence_count, max_distance_km, swap_count,
|
||||
status, parent_name, parent_vessel_id
|
||||
FROM {GEAR_IDENTITY_COLLISIONS}
|
||||
WHERE name = %s AND mmsi_lo = %s AND mmsi_hi = %s""",
|
||||
(name, mmsi_lo, mmsi_hi),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
evidence_fragment = {
|
||||
'observed_at': now.isoformat(),
|
||||
'distance_km': distance_km,
|
||||
'positions': {
|
||||
mmsi_lo: {
|
||||
'lat': collision.get('lat_lo'),
|
||||
'lon': collision.get('lon_lo'),
|
||||
},
|
||||
mmsi_hi: {
|
||||
'lat': collision.get('lat_hi'),
|
||||
'lon': collision.get('lon_hi'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if row:
|
||||
(row_id, cur_count, cur_max_dist, swap_cnt,
|
||||
cur_status, cur_parent_name, cur_parent_vid) = row
|
||||
new_count = (cur_count or 0) + 1
|
||||
merged_max = max(
|
||||
float(cur_max_dist or 0),
|
||||
float(distance_km or 0),
|
||||
)
|
||||
if cur_status in ('CONFIRMED_ILLEGAL', 'FALSE_POSITIVE'):
|
||||
new_severity = None # 확정 상태는 유지
|
||||
else:
|
||||
new_severity = classify_severity(new_count, merged_max, swap_cnt or 0)
|
||||
cur.execute(
|
||||
f"""UPDATE {GEAR_IDENTITY_COLLISIONS}
|
||||
SET last_seen_at = %s,
|
||||
coexistence_count = %s,
|
||||
max_distance_km = %s,
|
||||
last_lat_lo = %s,
|
||||
last_lon_lo = %s,
|
||||
last_lat_hi = %s,
|
||||
last_lon_hi = %s,
|
||||
parent_name = COALESCE(%s, parent_name),
|
||||
parent_vessel_id = COALESCE(%s, parent_vessel_id),
|
||||
severity = COALESCE(%s, severity),
|
||||
evidence = COALESCE(evidence, '[]'::jsonb)
|
||||
|| to_jsonb(%s::jsonb),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s""",
|
||||
(
|
||||
now, new_count, merged_max,
|
||||
collision.get('lat_lo'), collision.get('lon_lo'),
|
||||
collision.get('lat_hi'), collision.get('lon_hi'),
|
||||
hint_parent, parent_vid,
|
||||
new_severity,
|
||||
json.dumps([evidence_fragment]),
|
||||
row_id,
|
||||
),
|
||||
)
|
||||
return row_id
|
||||
|
||||
severity = classify_severity(1, distance_km, 0)
|
||||
cur.execute(
|
||||
f"""INSERT INTO {GEAR_IDENTITY_COLLISIONS}
|
||||
(name, mmsi_lo, mmsi_hi, parent_name, parent_vessel_id,
|
||||
first_seen_at, last_seen_at,
|
||||
coexistence_count, max_distance_km,
|
||||
last_lat_lo, last_lon_lo, last_lat_hi, last_lon_hi,
|
||||
severity, status, evidence)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,1,%s,%s,%s,%s,%s,%s,'OPEN',%s)
|
||||
RETURNING id""",
|
||||
(
|
||||
name, mmsi_lo, mmsi_hi, hint_parent, parent_vid,
|
||||
now, now, distance_km,
|
||||
collision.get('lat_lo'), collision.get('lon_lo'),
|
||||
collision.get('lat_hi'), collision.get('lon_hi'),
|
||||
severity,
|
||||
json.dumps([evidence_fragment]),
|
||||
),
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
|
||||
def get_recent_collision_ids(self) -> list[int]:
|
||||
"""이번 사이클에 갱신·추가된 gear_identity_collisions row id 목록."""
|
||||
return list(getattr(self, '_recent_collision_ids', []))
|
||||
|
||||
def build_fleet_clusters(self, vessel_dfs: dict[str, pd.DataFrame]) -> dict[str, dict]:
|
||||
"""등록 선단 기준으로 cluster 정보 구성.
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ from db.kcgdb import get_conn
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EVENTS_TABLE = qualified_table('prediction_events')
|
||||
GEAR_IDENTITY_COLLISIONS_TABLE = qualified_table('gear_identity_collisions')
|
||||
|
||||
# 카테고리별 dedup 윈도우 (분).
|
||||
# 사이클이 5분 간격이므로 5의 배수를 피해서 boundary 일제 만료 패턴을 회피한다.
|
||||
@ -34,6 +35,7 @@ DEDUP_WINDOWS = {
|
||||
'GEAR_ILLEGAL': 367,
|
||||
'AIS_RESUME': 67,
|
||||
'HIGH_RISK_VESSEL': 67,
|
||||
'GEAR_IDENTITY_COLLISION': 367, # 같은 쌍의 반복 공존은 장기 이벤트로 집계
|
||||
}
|
||||
|
||||
# 이벤트 생성 룰
|
||||
@ -294,3 +296,123 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
|
||||
|
||||
logger.info(f'event_generator: generated={generated}, skipped_dedup={skipped_dedup}')
|
||||
return {'generated': generated, 'skipped_dedup': skipped_dedup}
|
||||
|
||||
|
||||
_COLLISION_EVENT_LEVELS = {'CRITICAL', 'HIGH'}
|
||||
|
||||
|
||||
def run_gear_identity_collision_events(collision_ids: list[int]) -> dict:
|
||||
"""gear_identity_collisions 의 갱신 행을 prediction_events 로 승격.
|
||||
|
||||
이번 cycle 에 갱신·추가된 row 중 severity 가 CRITICAL/HIGH 이고 status=OPEN 인 것만
|
||||
이벤트 허브에 등록한다. dedup 키는 `{name}:{mmsi_lo}:{mmsi_hi}` 기준으로 장기 윈도우.
|
||||
|
||||
Args:
|
||||
collision_ids: fleet_tracker.get_recent_collision_ids() 가 돌려준 id 목록
|
||||
|
||||
Returns:
|
||||
{ 'generated': int, 'skipped_dedup': int, 'skipped_low': int }
|
||||
"""
|
||||
if not collision_ids:
|
||||
return {'generated': 0, 'skipped_dedup': 0, 'skipped_low': 0}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
generated = 0
|
||||
skipped_dedup = 0
|
||||
skipped_low = 0
|
||||
events_to_insert: list[tuple] = []
|
||||
|
||||
with get_conn() as conn:
|
||||
date_str = now.strftime('%Y%m%d')
|
||||
seq = _get_next_seq(conn, date_str)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
f"""SELECT id, name, mmsi_lo, mmsi_hi, parent_name,
|
||||
coexistence_count, max_distance_km, severity, status,
|
||||
last_lat_lo, last_lon_lo
|
||||
FROM {GEAR_IDENTITY_COLLISIONS_TABLE}
|
||||
WHERE id = ANY(%s)""",
|
||||
(list(collision_ids),),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
for (cid, name, mmsi_lo, mmsi_hi, parent_name,
|
||||
coexist_count, max_dist, severity, status,
|
||||
lat_lo, lon_lo) in rows:
|
||||
if status != 'OPEN':
|
||||
skipped_low += 1
|
||||
continue
|
||||
if severity not in _COLLISION_EVENT_LEVELS:
|
||||
skipped_low += 1
|
||||
continue
|
||||
|
||||
category = 'GEAR_IDENTITY_COLLISION'
|
||||
dedup_key = f"{name}:{mmsi_lo}:{mmsi_hi}:{category}"
|
||||
|
||||
if _check_dedup(conn, dedup_key, category, now):
|
||||
skipped_dedup += 1
|
||||
continue
|
||||
|
||||
event_uid = _make_event_uid(now, seq)
|
||||
seq += 1
|
||||
|
||||
distance_txt = f"{float(max_dist or 0):.1f}km"
|
||||
title = (
|
||||
f"어구 정체성 충돌: {name} "
|
||||
f"({mmsi_lo} ↔ {mmsi_hi}, 공존 {coexist_count}회, 거리 {distance_txt})"
|
||||
)
|
||||
features = {
|
||||
'collision_id': cid,
|
||||
'name': name,
|
||||
'mmsi_lo': mmsi_lo,
|
||||
'mmsi_hi': mmsi_hi,
|
||||
'coexistence_count': coexist_count,
|
||||
'max_distance_km': float(max_dist or 0),
|
||||
'parent_name': parent_name,
|
||||
}
|
||||
events_to_insert.append((
|
||||
event_uid,
|
||||
now,
|
||||
severity,
|
||||
category,
|
||||
title,
|
||||
None, # detail
|
||||
mmsi_lo, # 대표 MMSI (검색 편의)
|
||||
parent_name, # vessel_name
|
||||
None, # area_name
|
||||
None, # zone_code
|
||||
float(lat_lo) if lat_lo is not None else None,
|
||||
float(lon_lo) if lon_lo is not None else None,
|
||||
None, # speed_kn
|
||||
'GEAR_COLLISION', # source_type
|
||||
cid, # source_ref_id
|
||||
None, # ai_confidence
|
||||
'NEW',
|
||||
dedup_key,
|
||||
json.dumps(features, ensure_ascii=False, default=str),
|
||||
))
|
||||
generated += 1
|
||||
|
||||
if events_to_insert:
|
||||
execute_values(
|
||||
conn.cursor(),
|
||||
f"""INSERT INTO {EVENTS_TABLE}
|
||||
(event_uid, occurred_at, level, category, title, detail,
|
||||
vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn,
|
||||
source_type, source_ref_id, ai_confidence, status, dedup_key, features)
|
||||
VALUES %s
|
||||
ON CONFLICT (event_uid) DO NOTHING""",
|
||||
events_to_insert,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
logger.info(
|
||||
'gear_collision_events: generated=%d, skipped_dedup=%d, skipped_low=%d',
|
||||
generated, skipped_dedup, skipped_low,
|
||||
)
|
||||
return {
|
||||
'generated': generated,
|
||||
'skipped_dedup': skipped_dedup,
|
||||
'skipped_low': skipped_low,
|
||||
}
|
||||
|
||||
@ -157,6 +157,21 @@ def run_analysis_cycle():
|
||||
gear_signals = [v for v in all_ais if GEAR_PATTERN.match(v.get('name', '') or '')]
|
||||
fleet_tracker.track_gear_identity(gear_signals, kcg_conn)
|
||||
|
||||
# 이번 사이클에 갱신된 어구 정체성 충돌을 이벤트 허브로 승격 (CRITICAL/HIGH 만)
|
||||
collision_ids = fleet_tracker.get_recent_collision_ids()
|
||||
if collision_ids:
|
||||
try:
|
||||
from output.event_generator import run_gear_identity_collision_events
|
||||
collision_events = run_gear_identity_collision_events(collision_ids)
|
||||
logger.info(
|
||||
'gear collision events: generated=%d, skipped_dedup=%d, skipped_low=%d',
|
||||
collision_events['generated'],
|
||||
collision_events['skipped_dedup'],
|
||||
collision_events['skipped_low'],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('gear collision event promotion failed: %s', e)
|
||||
|
||||
fleet_roles = fleet_tracker.build_fleet_clusters(vessel_dfs)
|
||||
|
||||
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user