feat: 워크플로우 연결 + 메뉴 DB SSOT 구조화 #26
@ -4,6 +4,7 @@ import gc.mda.kcg.auth.dto.LoginRequest;
|
||||
import gc.mda.kcg.auth.dto.UserInfoResponse;
|
||||
import gc.mda.kcg.auth.provider.AuthProvider;
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.menu.MenuConfigService;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@ -25,6 +26,7 @@ public class AuthController {
|
||||
private final AuthService authService;
|
||||
private final JwtService jwtService;
|
||||
private final AppProperties appProperties;
|
||||
private final MenuConfigService menuConfigService;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest req,
|
||||
@ -95,7 +97,8 @@ public class AuthController {
|
||||
u.getUserSttsCd(),
|
||||
u.getAuthProvider(),
|
||||
info.roles(),
|
||||
info.permissions()
|
||||
info.permissions(),
|
||||
menuConfigService.getActiveMenuConfig()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package gc.mda.kcg.auth.dto;
|
||||
|
||||
import gc.mda.kcg.menu.MenuConfigDto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -12,5 +14,6 @@ public record UserInfoResponse(
|
||||
String status,
|
||||
String authProvider,
|
||||
List<String> roles,
|
||||
Map<String, List<String>> permissions
|
||||
Map<String, List<String>> permissions,
|
||||
List<MenuConfigDto> menuConfig
|
||||
) {}
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 직접 조회 API.
|
||||
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
|
||||
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/analysis")
|
||||
@RequiredArgsConstructor
|
||||
public class VesselAnalysisController {
|
||||
|
||||
private final VesselAnalysisService service;
|
||||
|
||||
/**
|
||||
* 분석 결과 목록 조회 (필터 + 페이징).
|
||||
* 기본: 최근 1시간 내 결과.
|
||||
*/
|
||||
@GetMapping("/vessels")
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
public Page<VesselAnalysisResponse> listVessels(
|
||||
@RequestParam(required = false) String mmsi,
|
||||
@RequestParam(required = false) String zoneCode,
|
||||
@RequestParam(required = false) String riskLevel,
|
||||
@RequestParam(required = false) Boolean isDark,
|
||||
@RequestParam(defaultValue = "1") int hours,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
return service.getAnalysisResults(
|
||||
mmsi, zoneCode, riskLevel, isDark, after,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
|
||||
).map(VesselAnalysisResponse::from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 최신 분석 결과 (features 포함).
|
||||
*/
|
||||
@GetMapping("/vessels/{mmsi}")
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
public VesselAnalysisResponse getLatest(@PathVariable String mmsi) {
|
||||
return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 분석 이력 (기본 24시간).
|
||||
*/
|
||||
@GetMapping("/vessels/{mmsi}/history")
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
public List<VesselAnalysisResponse> getHistory(
|
||||
@PathVariable String mmsi,
|
||||
@RequestParam(defaultValue = "24") int hours
|
||||
) {
|
||||
return service.getHistory(mmsi, hours).stream()
|
||||
.map(VesselAnalysisResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
|
||||
*/
|
||||
@GetMapping("/dark")
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
public Page<VesselAnalysisResponse> listDarkVessels(
|
||||
@RequestParam(defaultValue = "1") int hours,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
return service.getDarkVessels(hours,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
|
||||
).map(VesselAnalysisResponse::from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
|
||||
*/
|
||||
@GetMapping("/transship")
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
public Page<VesselAnalysisResponse> listTransshipSuspects(
|
||||
@RequestParam(defaultValue = "1") int hours,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
return service.getTransshipSuspects(hours,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
|
||||
).map(VesselAnalysisResponse::from);
|
||||
}
|
||||
}
|
||||
@ -29,7 +29,7 @@ public class VesselAnalysisProxyController {
|
||||
private final ParentResolutionRepository resolutionRepository;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(resource = "detection", operation = "READ")
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
public ResponseEntity<?> getVesselAnalysis() {
|
||||
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis");
|
||||
if (data == null) {
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
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 org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 읽기 전용 Repository.
|
||||
*/
|
||||
public interface VesselAnalysisRepository
|
||||
extends JpaRepository<VesselAnalysisResult, Long>, JpaSpecificationExecutor<VesselAnalysisResult> {
|
||||
|
||||
/**
|
||||
* 특정 선박의 최신 분석 결과.
|
||||
*/
|
||||
Optional<VesselAnalysisResult> findTopByMmsiOrderByAnalyzedAtDesc(String mmsi);
|
||||
|
||||
/**
|
||||
* 특정 선박의 분석 이력 (시간 범위).
|
||||
*/
|
||||
List<VesselAnalysisResult> findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(
|
||||
String mmsi, OffsetDateTime after);
|
||||
|
||||
/**
|
||||
* 다크 베셀 목록 (최근 분석 결과, MMSI 중복 제거).
|
||||
*/
|
||||
@Query("""
|
||||
SELECT v FROM VesselAnalysisResult v
|
||||
WHERE v.isDark = true AND v.analyzedAt > :after
|
||||
AND v.analyzedAt = (
|
||||
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
|
||||
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
|
||||
)
|
||||
ORDER BY v.riskScore DESC
|
||||
""")
|
||||
Page<VesselAnalysisResult> findLatestDarkVessels(
|
||||
@Param("after") OffsetDateTime after, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 환적 의심 목록 (최근 분석 결과, MMSI 중복 제거).
|
||||
*/
|
||||
@Query("""
|
||||
SELECT v FROM VesselAnalysisResult v
|
||||
WHERE v.transshipSuspect = true AND v.analyzedAt > :after
|
||||
AND v.analyzedAt = (
|
||||
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
|
||||
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
|
||||
)
|
||||
ORDER BY v.riskScore DESC
|
||||
""")
|
||||
Page<VesselAnalysisResult> findLatestTransshipSuspects(
|
||||
@Param("after") OffsetDateTime after, Pageable pageable);
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 응답 DTO.
|
||||
* 프론트엔드에서 필요한 핵심 필드만 포함.
|
||||
*/
|
||||
public record VesselAnalysisResponse(
|
||||
Long id,
|
||||
String mmsi,
|
||||
OffsetDateTime analyzedAt,
|
||||
// 분류
|
||||
String vesselType,
|
||||
BigDecimal confidence,
|
||||
BigDecimal fishingPct,
|
||||
String season,
|
||||
// 위치
|
||||
Double lat,
|
||||
Double lon,
|
||||
String zoneCode,
|
||||
BigDecimal distToBaselineNm,
|
||||
// 행동
|
||||
String activityState,
|
||||
// 위협
|
||||
Boolean isDark,
|
||||
Integer gapDurationMin,
|
||||
String darkPattern,
|
||||
BigDecimal spoofingScore,
|
||||
Integer speedJumpCount,
|
||||
// 환적
|
||||
Boolean transshipSuspect,
|
||||
String transshipPairMmsi,
|
||||
Integer transshipDurationMin,
|
||||
// 선단
|
||||
Integer fleetClusterId,
|
||||
String fleetRole,
|
||||
Boolean fleetIsLeader,
|
||||
// 위험도
|
||||
Integer riskScore,
|
||||
String riskLevel,
|
||||
// 확장
|
||||
String gearCode,
|
||||
String gearJudgment,
|
||||
String permitStatus,
|
||||
// features
|
||||
Map<String, Object> features
|
||||
) {
|
||||
public static VesselAnalysisResponse from(VesselAnalysisResult e) {
|
||||
return new VesselAnalysisResponse(
|
||||
e.getId(),
|
||||
e.getMmsi(),
|
||||
e.getAnalyzedAt(),
|
||||
e.getVesselType(),
|
||||
e.getConfidence(),
|
||||
e.getFishingPct(),
|
||||
e.getSeason(),
|
||||
e.getLat(),
|
||||
e.getLon(),
|
||||
e.getZoneCode(),
|
||||
e.getDistToBaselineNm(),
|
||||
e.getActivityState(),
|
||||
e.getIsDark(),
|
||||
e.getGapDurationMin(),
|
||||
e.getDarkPattern(),
|
||||
e.getSpoofingScore(),
|
||||
e.getSpeedJumpCount(),
|
||||
e.getTransshipSuspect(),
|
||||
e.getTransshipPairMmsi(),
|
||||
e.getTransshipDurationMin(),
|
||||
e.getFleetClusterId(),
|
||||
e.getFleetRole(),
|
||||
e.getFleetIsLeader(),
|
||||
e.getRiskScore(),
|
||||
e.getRiskLevel(),
|
||||
e.getGearCode(),
|
||||
e.getGearJudgment(),
|
||||
e.getPermitStatus(),
|
||||
e.getFeatures()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
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.Map;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 읽기 전용 Entity.
|
||||
* prediction 엔진이 5분 주기로 INSERT, 백엔드는 READ만 수행.
|
||||
*
|
||||
* DB PK는 (id, analyzed_at) 복합키(파티션)이지만,
|
||||
* BIGSERIAL id가 전역 유니크이므로 JPA에서는 id만 @Id로 매핑.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "vessel_analysis_results", schema = "kcg")
|
||||
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class VesselAnalysisResult {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column(name = "mmsi", nullable = false, length = 20)
|
||||
private String mmsi;
|
||||
|
||||
@Column(name = "analyzed_at", nullable = false)
|
||||
private OffsetDateTime analyzedAt;
|
||||
|
||||
// 분류
|
||||
@Column(name = "vessel_type", length = 30)
|
||||
private String vesselType;
|
||||
|
||||
@Column(name = "confidence", precision = 5, scale = 4)
|
||||
private BigDecimal confidence;
|
||||
|
||||
@Column(name = "fishing_pct", precision = 5, scale = 4)
|
||||
private BigDecimal fishingPct;
|
||||
|
||||
@Column(name = "cluster_id")
|
||||
private Integer clusterId;
|
||||
|
||||
@Column(name = "season", length = 20)
|
||||
private String season;
|
||||
|
||||
// 위치
|
||||
@Column(name = "lat")
|
||||
private Double lat;
|
||||
|
||||
@Column(name = "lon")
|
||||
private Double lon;
|
||||
|
||||
@Column(name = "zone_code", length = 30)
|
||||
private String zoneCode;
|
||||
|
||||
@Column(name = "dist_to_baseline_nm", precision = 8, scale = 2)
|
||||
private BigDecimal distToBaselineNm;
|
||||
|
||||
// 행동 분석
|
||||
@Column(name = "activity_state", length = 20)
|
||||
private String activityState;
|
||||
|
||||
@Column(name = "ucaf_score", precision = 5, scale = 4)
|
||||
private BigDecimal ucafScore;
|
||||
|
||||
@Column(name = "ucft_score", precision = 5, scale = 4)
|
||||
private BigDecimal ucftScore;
|
||||
|
||||
// 위협 탐지
|
||||
@Column(name = "is_dark")
|
||||
private Boolean isDark;
|
||||
|
||||
@Column(name = "gap_duration_min")
|
||||
private Integer gapDurationMin;
|
||||
|
||||
@Column(name = "dark_pattern", length = 30)
|
||||
private String darkPattern;
|
||||
|
||||
@Column(name = "spoofing_score", precision = 5, scale = 4)
|
||||
private BigDecimal spoofingScore;
|
||||
|
||||
@Column(name = "bd09_offset_m", precision = 8, scale = 2)
|
||||
private BigDecimal bd09OffsetM;
|
||||
|
||||
@Column(name = "speed_jump_count")
|
||||
private Integer speedJumpCount;
|
||||
|
||||
// 환적
|
||||
@Column(name = "transship_suspect")
|
||||
private Boolean transshipSuspect;
|
||||
|
||||
@Column(name = "transship_pair_mmsi", length = 20)
|
||||
private String transshipPairMmsi;
|
||||
|
||||
@Column(name = "transship_duration_min")
|
||||
private Integer transshipDurationMin;
|
||||
|
||||
// 선단
|
||||
@Column(name = "fleet_cluster_id")
|
||||
private Integer fleetClusterId;
|
||||
|
||||
@Column(name = "fleet_role", length = 20)
|
||||
private String fleetRole;
|
||||
|
||||
@Column(name = "fleet_is_leader")
|
||||
private Boolean fleetIsLeader;
|
||||
|
||||
// 위험도
|
||||
@Column(name = "risk_score")
|
||||
private Integer riskScore;
|
||||
|
||||
@Column(name = "risk_level", length = 20)
|
||||
private String riskLevel;
|
||||
|
||||
// 확장
|
||||
@Column(name = "gear_code", length = 20)
|
||||
private String gearCode;
|
||||
|
||||
@Column(name = "gear_judgment", length = 30)
|
||||
private String gearJudgment;
|
||||
|
||||
@Column(name = "permit_status", length = 20)
|
||||
private String permitStatus;
|
||||
|
||||
// features JSONB
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "features", columnDefinition = "jsonb")
|
||||
private Map<String, Object> features;
|
||||
|
||||
@Column(name = "created_at")
|
||||
private OffsetDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 직접 조회 서비스.
|
||||
* prediction이 write한 분석 결과를 프론트엔드에 제공.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class VesselAnalysisService {
|
||||
|
||||
private final VesselAnalysisRepository repository;
|
||||
|
||||
/**
|
||||
* 분석 결과 목록 조회 (동적 필터).
|
||||
*/
|
||||
public Page<VesselAnalysisResult> getAnalysisResults(
|
||||
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
|
||||
OffsetDateTime after, Pageable pageable
|
||||
) {
|
||||
Specification<VesselAnalysisResult> spec = Specification.where(null);
|
||||
|
||||
if (after != null) {
|
||||
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
|
||||
}
|
||||
if (mmsi != null && !mmsi.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
|
||||
}
|
||||
if (zoneCode != null && !zoneCode.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
|
||||
}
|
||||
if (riskLevel != null && !riskLevel.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("riskLevel"), riskLevel));
|
||||
}
|
||||
if (isDark != null && isDark) {
|
||||
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
|
||||
}
|
||||
|
||||
return repository.findAll(spec, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 최신 분석 결과.
|
||||
*/
|
||||
public VesselAnalysisResult getLatestByMmsi(String mmsi) {
|
||||
return repository.findTopByMmsiOrderByAnalyzedAtDesc(mmsi)
|
||||
.orElseThrow(() -> new IllegalArgumentException("ANALYSIS_NOT_FOUND: " + mmsi));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 분석 이력 (시간 범위).
|
||||
*/
|
||||
public List<VesselAnalysisResult> getHistory(String mmsi, int hours) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
return repository.findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(mmsi, after);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
|
||||
*/
|
||||
public Page<VesselAnalysisResult> getDarkVessels(int hours, Pageable pageable) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
return repository.findLatestDarkVessels(after, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
|
||||
*/
|
||||
public Page<VesselAnalysisResult> getTransshipSuspects(int hours, Pageable pageable) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
return repository.findLatestTransshipSuspects(after, pageable);
|
||||
}
|
||||
}
|
||||
@ -32,9 +32,10 @@ public class EnforcementController {
|
||||
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
|
||||
public Page<EnforcementRecord> listRecords(
|
||||
@RequestParam(required = false) String violationType,
|
||||
@RequestParam(required = false) String vesselMmsi,
|
||||
Pageable pageable
|
||||
) {
|
||||
return service.listRecords(violationType, pageable);
|
||||
return service.listRecords(violationType, vesselMmsi, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -32,7 +32,10 @@ public class EnforcementService {
|
||||
// 단속 이력
|
||||
// ========================================================================
|
||||
|
||||
public Page<EnforcementRecord> listRecords(String violationType, Pageable pageable) {
|
||||
public Page<EnforcementRecord> listRecords(String violationType, String vesselMmsi, Pageable pageable) {
|
||||
if (vesselMmsi != null && !vesselMmsi.isBlank()) {
|
||||
return recordRepository.findByVesselMmsiOrderByEnforcedAtDesc(vesselMmsi, pageable);
|
||||
}
|
||||
if (violationType != null && !violationType.isBlank()) {
|
||||
return recordRepository.findByViolationType(violationType, pageable);
|
||||
}
|
||||
|
||||
@ -8,4 +8,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface EnforcementRecordRepository extends JpaRepository<EnforcementRecord, Long> {
|
||||
Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable);
|
||||
Page<EnforcementRecord> findByViolationType(String violationType, Pageable pageable);
|
||||
Page<EnforcementRecord> findByVesselMmsiOrderByEnforcedAtDesc(String vesselMmsi, Pageable pageable);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@ -93,6 +94,10 @@ public class PredictionEvent {
|
||||
@Column(name = "dedup_key", length = 200)
|
||||
private String dedupKey;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "features", columnDefinition = "jsonb")
|
||||
private Map<String, Object> features;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ public class StatsController {
|
||||
* 실시간 KPI 전체 목록 조회
|
||||
*/
|
||||
@GetMapping("/kpi")
|
||||
@RequirePermission(resource = "statistics", operation = "READ")
|
||||
@RequirePermission(resource = "statistics:statistics", operation = "READ")
|
||||
public List<PredictionKpi> getKpi() {
|
||||
return kpiRepository.findAll();
|
||||
}
|
||||
@ -38,7 +38,7 @@ public class StatsController {
|
||||
* @param to 종료 월 (예: 2026-04)
|
||||
*/
|
||||
@GetMapping("/monthly")
|
||||
@RequirePermission(resource = "statistics", operation = "READ")
|
||||
@RequirePermission(resource = "statistics:statistics", operation = "READ")
|
||||
public List<PredictionStatsMonthly> getMonthly(
|
||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
|
||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
|
||||
@ -52,7 +52,7 @@ public class StatsController {
|
||||
* @param to 종료 날짜 (예: 2026-04-07)
|
||||
*/
|
||||
@GetMapping("/daily")
|
||||
@RequirePermission(resource = "statistics", operation = "READ")
|
||||
@RequirePermission(resource = "statistics:statistics", operation = "READ")
|
||||
public List<PredictionStatsDaily> getDaily(
|
||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
|
||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
|
||||
@ -65,7 +65,7 @@ public class StatsController {
|
||||
* @param hours 조회 시간 범위 (기본 24시간)
|
||||
*/
|
||||
@GetMapping("/hourly")
|
||||
@RequirePermission(resource = "statistics", operation = "READ")
|
||||
@RequirePermission(resource = "statistics:statistics", operation = "READ")
|
||||
public List<PredictionStatsHourly> getHourly(
|
||||
@RequestParam(defaultValue = "24") int hours
|
||||
) {
|
||||
|
||||
@ -79,13 +79,13 @@ public class MasterDataController {
|
||||
// ========================================================================
|
||||
|
||||
@GetMapping("/api/patrol-ships")
|
||||
@RequirePermission(resource = "patrol", operation = "READ")
|
||||
@RequirePermission(resource = "patrol:patrol-route", operation = "READ")
|
||||
public List<PatrolShip> listPatrolShips() {
|
||||
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
|
||||
}
|
||||
|
||||
@PatchMapping("/api/patrol-ships/{id}/status")
|
||||
@RequirePermission(resource = "patrol", operation = "UPDATE")
|
||||
@RequirePermission(resource = "patrol:patrol-route", operation = "UPDATE")
|
||||
public PatrolShip updatePatrolShipStatus(
|
||||
@PathVariable Long id,
|
||||
@RequestBody PatrolShipStatusRequest request
|
||||
@ -108,7 +108,7 @@ public class MasterDataController {
|
||||
// ========================================================================
|
||||
|
||||
@GetMapping("/api/vessel-permits")
|
||||
@RequirePermission(resource = "vessel", operation = "READ")
|
||||
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
|
||||
public Page<VesselPermit> listVesselPermits(
|
||||
@RequestParam(required = false) String flag,
|
||||
@RequestParam(required = false) String permitStatus,
|
||||
@ -126,7 +126,7 @@ public class MasterDataController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/vessel-permits/{mmsi}")
|
||||
@RequirePermission(resource = "vessel", operation = "READ")
|
||||
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
|
||||
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
|
||||
return vesselPermitRepository.findByMmsi(mmsi)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
package gc.mda.kcg.menu;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 메뉴 설정 API — auth_perm_tree 기반.
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class MenuConfigController {
|
||||
|
||||
private final MenuConfigService menuConfigService;
|
||||
|
||||
@GetMapping("/api/menu-config")
|
||||
public List<MenuConfigDto> getMenuConfig() {
|
||||
return menuConfigService.getActiveMenuConfig();
|
||||
}
|
||||
}
|
||||
19
backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java
Normal file
19
backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java
Normal file
@ -0,0 +1,19 @@
|
||||
package gc.mda.kcg.menu;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record MenuConfigDto(
|
||||
String menuCd,
|
||||
String parentMenuCd,
|
||||
String menuType,
|
||||
String urlPath,
|
||||
String rsrcCd,
|
||||
String componentKey,
|
||||
String icon,
|
||||
String labelKey,
|
||||
String dividerLabel,
|
||||
int menuLevel,
|
||||
int sortOrd,
|
||||
String useYn,
|
||||
Map<String, String> labels
|
||||
) {}
|
||||
115
backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java
Normal file
115
backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java
Normal file
@ -0,0 +1,115 @@
|
||||
package gc.mda.kcg.menu;
|
||||
|
||||
import gc.mda.kcg.permission.PermTree;
|
||||
import gc.mda.kcg.permission.PermTreeRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* auth_perm_tree를 메뉴 SSOT로 사용하여 MenuConfigDto 목록을 생성.
|
||||
* 디바이더는 nav_sub_group 변경 시점에 동적 삽입.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MenuConfigService {
|
||||
|
||||
private final PermTreeRepository permTreeRepository;
|
||||
|
||||
// 메뉴 그룹으로 동작하는 Level-0 노드 rsrc_cd
|
||||
private static final Set<String> GROUP_NODES = Set.of(
|
||||
"field-ops", "parent-inference-workflow", "admin"
|
||||
);
|
||||
|
||||
@Cacheable("menuConfig")
|
||||
@Transactional(readOnly = true)
|
||||
public List<MenuConfigDto> getActiveMenuConfig() {
|
||||
List<PermTree> all = permTreeRepository.findByUseYn("Y");
|
||||
List<MenuConfigDto> result = new ArrayList<>();
|
||||
|
||||
// 1) 최상위 ITEM (nav_sort > 0, nav_group IS NULL, url_path IS NOT NULL)
|
||||
List<PermTree> topItems = all.stream()
|
||||
.filter(n -> n.getNavSort() > 0 && n.getNavGroup() == null && n.getUrlPath() != null)
|
||||
.sorted(Comparator.comparingInt(PermTree::getNavSort))
|
||||
.toList();
|
||||
for (PermTree n : topItems) {
|
||||
result.add(toDto(n, null, "ITEM", 0));
|
||||
}
|
||||
|
||||
// 2) 그룹 헤더 + 자식 (nav_sort > 0 인 GROUP_NODES)
|
||||
List<PermTree> groups = all.stream()
|
||||
.filter(n -> GROUP_NODES.contains(n.getRsrcCd()) && n.getNavSort() > 0)
|
||||
.sorted(Comparator.comparingInt(PermTree::getNavSort))
|
||||
.toList();
|
||||
for (PermTree g : groups) {
|
||||
result.add(toDto(g, null, "GROUP", 0));
|
||||
|
||||
// 이 그룹의 자식들
|
||||
List<PermTree> children = all.stream()
|
||||
.filter(n -> g.getRsrcCd().equals(n.getNavGroup()) && n.getUrlPath() != null)
|
||||
.sorted(Comparator.comparingInt(PermTree::getNavSort))
|
||||
.toList();
|
||||
|
||||
// 디바이더 삽입: nav_sub_group 변경 시점마다
|
||||
String currentSubGroup = null;
|
||||
int dividerSeq = 0;
|
||||
for (PermTree c : children) {
|
||||
String sub = c.getNavSubGroup();
|
||||
if (sub != null && !sub.equals(currentSubGroup)) {
|
||||
currentSubGroup = sub;
|
||||
dividerSeq++;
|
||||
result.add(new MenuConfigDto(
|
||||
g.getRsrcCd() + ".div-" + dividerSeq,
|
||||
g.getRsrcCd(), "DIVIDER",
|
||||
null, null, null, null, null,
|
||||
sub, 1, c.getNavSort() - 1, "Y",
|
||||
Map.of()
|
||||
));
|
||||
}
|
||||
result.add(toDto(c, g.getRsrcCd(), "ITEM", 1));
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 숨김 라우트 (nav_sort = 0, url_path IS NOT NULL)
|
||||
List<PermTree> hidden = all.stream()
|
||||
.filter(n -> n.getNavSort() == 0 && n.getUrlPath() != null && !GROUP_NODES.contains(n.getRsrcCd()))
|
||||
.toList();
|
||||
for (PermTree h : hidden) {
|
||||
result.add(new MenuConfigDto(
|
||||
h.getRsrcCd(), null, "ITEM",
|
||||
h.getUrlPath(), h.getRsrcCd(), h.getComponentKey(),
|
||||
h.getIcon(), h.getLabelKey(), null,
|
||||
0, 9999, "H",
|
||||
h.getLabels() != null ? h.getLabels() : Map.of()
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@CacheEvict(value = "menuConfig", allEntries = true)
|
||||
public void evictCache() {
|
||||
}
|
||||
|
||||
private MenuConfigDto toDto(PermTree n, String parentMenuCd, String menuType, int menuLevel) {
|
||||
return new MenuConfigDto(
|
||||
n.getRsrcCd(),
|
||||
parentMenuCd,
|
||||
menuType,
|
||||
n.getUrlPath(),
|
||||
n.getRsrcCd(),
|
||||
n.getComponentKey(),
|
||||
n.getIcon(),
|
||||
n.getLabelKey(),
|
||||
n.getNavSubGroup(),
|
||||
menuLevel,
|
||||
n.getNavSort(),
|
||||
n.getUseYn(),
|
||||
n.getLabels() != null ? n.getLabels() : Map.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@ package gc.mda.kcg.permission;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@ -39,6 +41,29 @@ public class PermTree {
|
||||
@Column(name = "use_yn", nullable = false, length = 1)
|
||||
private String useYn;
|
||||
|
||||
// ── 메뉴 SSOT 컬럼 (V021) ──
|
||||
@Column(name = "url_path", length = 200)
|
||||
private String urlPath;
|
||||
|
||||
@Column(name = "label_key", length = 100)
|
||||
private String labelKey;
|
||||
|
||||
@Column(name = "component_key", length = 150)
|
||||
private String componentKey;
|
||||
|
||||
@Column(name = "nav_group", length = 100)
|
||||
private String navGroup;
|
||||
|
||||
@Column(name = "nav_sub_group", length = 100)
|
||||
private String navSubGroup;
|
||||
|
||||
@Column(name = "nav_sort", nullable = false)
|
||||
private Integer navSort;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "labels", columnDefinition = "jsonb")
|
||||
private java.util.Map<String, String> labels;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@ -53,6 +78,7 @@ public class PermTree {
|
||||
if (useYn == null) useYn = "Y";
|
||||
if (sortOrd == null) sortOrd = 0;
|
||||
if (rsrcLevel == null) rsrcLevel = 0;
|
||||
if (navSort == null) navSort = 0;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
|
||||
@ -33,7 +33,7 @@ spring:
|
||||
|
||||
cache:
|
||||
type: caffeine
|
||||
cache-names: permissions,users
|
||||
cache-names: permissions,users,menuConfig
|
||||
caffeine:
|
||||
spec: maximumSize=1000,expireAfterWrite=10m
|
||||
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
-- ============================================================
|
||||
-- V018: prediction_events에 features JSONB 컬럼 추가
|
||||
-- event_generator가 분석 결과의 핵심 특성(dark_tier, transship_score 등)을
|
||||
-- 이벤트와 함께 저장하여 프론트엔드에서 직접 활용할 수 있도록 한다.
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE kcg.prediction_events
|
||||
ADD COLUMN IF NOT EXISTS features JSONB;
|
||||
|
||||
COMMENT ON COLUMN kcg.prediction_events.features IS
|
||||
'분석 결과 핵심 특성 (dark_tier, dark_suspicion_score, transship_tier, transship_score 등)';
|
||||
@ -0,0 +1,16 @@
|
||||
-- ============================================================
|
||||
-- V019: LLM 운영 페이지 권한 트리 항목 추가
|
||||
-- PR #22에서 추가된 /llm-ops 페이지에 대응하는 권한 리소스
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||
VALUES ('ai-operations:llm-ops', 'ai-operations', 'LLM 운영', 1, 35)
|
||||
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||
|
||||
-- ADMIN 역할에 자동 부여
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, 'ai-operations:llm-ops', 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;
|
||||
@ -0,0 +1,97 @@
|
||||
-- ============================================================================
|
||||
-- V020: 메뉴 설정 SSOT 테이블
|
||||
-- 프론트엔드 사이드바 + 라우팅 + 권한 매핑의 단일 진실 공급원
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE kcg.menu_config (
|
||||
menu_cd VARCHAR(100) PRIMARY KEY,
|
||||
parent_menu_cd VARCHAR(100) REFERENCES kcg.menu_config(menu_cd) ON DELETE CASCADE,
|
||||
menu_type VARCHAR(10) NOT NULL DEFAULT 'ITEM',
|
||||
url_path VARCHAR(200),
|
||||
rsrc_cd VARCHAR(100) REFERENCES kcg.auth_perm_tree(rsrc_cd),
|
||||
component_key VARCHAR(150),
|
||||
icon VARCHAR(50),
|
||||
label_key VARCHAR(100),
|
||||
divider_label VARCHAR(100),
|
||||
menu_level INT NOT NULL DEFAULT 0,
|
||||
sort_ord INT NOT NULL DEFAULT 0,
|
||||
use_yn VARCHAR(1) NOT NULL DEFAULT 'Y',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE kcg.menu_config IS '메뉴 레이아웃 SSOT — 사이드바, 라우팅, 경로→권한 매핑 구동';
|
||||
COMMENT ON COLUMN kcg.menu_config.menu_type IS 'ITEM(네비게이션) | GROUP(접기/펼치기) | DIVIDER(서브그룹 라벨)';
|
||||
COMMENT ON COLUMN kcg.menu_config.rsrc_cd IS 'auth_perm_tree FK. 권한 체크용. GROUP/DIVIDER는 NULL';
|
||||
COMMENT ON COLUMN kcg.menu_config.component_key IS '프론트 COMPONENT_REGISTRY 키. GROUP/DIVIDER는 NULL';
|
||||
COMMENT ON COLUMN kcg.menu_config.use_yn IS 'Y=사이드바+라우트, H=라우트만(숨김), N=비활성';
|
||||
|
||||
CREATE INDEX idx_menu_config_parent ON kcg.menu_config(parent_menu_cd);
|
||||
CREATE INDEX idx_menu_config_sort ON kcg.menu_config(menu_level, sort_ord);
|
||||
CREATE INDEX idx_menu_config_rsrc ON kcg.menu_config(rsrc_cd);
|
||||
|
||||
-- ============================================================================
|
||||
-- 시드 데이터: 현재 NAV_ENTRIES + Route 정의 기반 35건
|
||||
-- sort_ord 100 간격 (삽입 여유)
|
||||
-- ============================================================================
|
||||
|
||||
-- ─── Top-level ITEM (13개) ─────────────────────────────────────────────────
|
||||
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
|
||||
('dashboard', NULL, 'ITEM', '/dashboard', 'dashboard', 'features/dashboard/Dashboard', 'LayoutDashboard', 'nav.dashboard', 0, 100),
|
||||
('monitoring', NULL, 'ITEM', '/monitoring', 'monitoring', 'features/monitoring/MonitoringDashboard', 'Activity', 'nav.monitoring', 0, 200),
|
||||
('events', NULL, 'ITEM', '/events', 'surveillance:live-map', 'features/surveillance/LiveMapView', 'Radar', 'nav.realtimeEvent', 0, 300),
|
||||
('map-control', NULL, 'ITEM', '/map-control', 'surveillance:map-control', 'features/surveillance/MapControl', 'Map', 'nav.mapControl', 0, 400),
|
||||
('risk-map', NULL, 'ITEM', '/risk-map', 'risk-assessment:risk-map', 'features/risk-assessment/RiskMap', 'Layers', 'nav.riskMap', 0, 500),
|
||||
('enforcement-plan', NULL, 'ITEM', '/enforcement-plan', 'risk-assessment:enforcement-plan', 'features/risk-assessment/EnforcementPlan', 'Shield', 'nav.enforcementPlan', 0, 600),
|
||||
('dark-vessel', NULL, 'ITEM', '/dark-vessel', 'detection:dark-vessel', 'features/detection/DarkVesselDetection', 'EyeOff', 'nav.darkVessel', 0, 700),
|
||||
('gear-detection', NULL, 'ITEM', '/gear-detection', 'detection:gear-detection', 'features/detection/GearDetection', 'Anchor', 'nav.gearDetection', 0, 800),
|
||||
('china-fishing', NULL, 'ITEM', '/china-fishing', 'detection:china-fishing', 'features/detection/ChinaFishing', 'Ship', 'nav.chinaFishing', 0, 900),
|
||||
('enforcement-history', NULL, 'ITEM', '/enforcement-history', 'enforcement:enforcement-history', 'features/enforcement/EnforcementHistory', 'FileText', 'nav.enforcementHistory', 0, 1000),
|
||||
('event-list', NULL, 'ITEM', '/event-list', 'enforcement:event-list', 'features/enforcement/EventList', 'List', 'nav.eventList', 0, 1100),
|
||||
('statistics', NULL, 'ITEM', '/statistics', 'statistics:statistics', 'features/statistics/Statistics', 'BarChart3', 'nav.statistics', 0, 1200),
|
||||
('reports', NULL, 'ITEM', '/reports', 'statistics:statistics', 'features/statistics/ReportManagement', 'FileText', 'nav.reports', 0, 1300);
|
||||
|
||||
-- ─── GROUP: 현장작전 ───────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
|
||||
('field-ops', NULL, 'GROUP', NULL, NULL, NULL, 'Ship', 'group.fieldOps', 0, 1400),
|
||||
('field-ops.patrol', 'field-ops', 'ITEM', '/patrol-route', 'patrol:patrol-route', 'features/patrol/PatrolRoute', 'Navigation', 'nav.patrolRoute', 1, 100),
|
||||
('field-ops.fleet', 'field-ops', 'ITEM', '/fleet-optimization', 'patrol:fleet-optimization', 'features/patrol/FleetOptimization', 'Users', 'nav.fleetOptimization', 1, 200),
|
||||
('field-ops.alert', 'field-ops', 'ITEM', '/ai-alert', 'field-ops:ai-alert', 'features/field-ops/AIAlert', 'Send', 'nav.aiAlert', 1, 300),
|
||||
('field-ops.mobile', 'field-ops', 'ITEM', '/mobile-service', 'field-ops:mobile-service', 'features/field-ops/MobileService', 'Smartphone', 'nav.mobileService', 1, 400),
|
||||
('field-ops.ship', 'field-ops', 'ITEM', '/ship-agent', 'field-ops:ship-agent', 'features/field-ops/ShipAgent', 'Monitor', 'nav.shipAgent', 1, 500);
|
||||
|
||||
-- ─── GROUP: 모선 워크플로우 ────────────────────────────────────────────────
|
||||
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
|
||||
('parent-inference', NULL, 'GROUP', NULL, NULL, NULL, 'GitBranch', 'group.parentInference', 0, 1500),
|
||||
('parent-inference.review', 'parent-inference', 'ITEM', '/parent-inference/review', 'parent-inference-workflow:parent-review', 'features/parent-inference/ParentReview', 'CheckSquare', 'nav.parentReview', 1, 100),
|
||||
('parent-inference.exclusion', 'parent-inference', 'ITEM', '/parent-inference/exclusion', 'parent-inference-workflow:parent-exclusion', 'features/parent-inference/ParentExclusion', 'Ban', 'nav.parentExclusion', 1, 200),
|
||||
('parent-inference.label', 'parent-inference', 'ITEM', '/parent-inference/label-session', 'parent-inference-workflow:label-session', 'features/parent-inference/LabelSession', 'Tag', 'nav.labelSession', 1, 300);
|
||||
|
||||
-- ─── GROUP: 관리자 ─────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, divider_label, menu_level, sort_ord) VALUES
|
||||
('admin-group', NULL, 'GROUP', NULL, NULL, NULL, 'Settings', 'group.admin', NULL, 0, 1600),
|
||||
-- AI 플랫폼
|
||||
('admin-group.div-ai', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, 'AI 플랫폼', 1, 100),
|
||||
('admin-group.ai-model', 'admin-group', 'ITEM', '/ai-model', 'ai-operations:ai-model', 'features/ai-operations/AIModelManagement', 'Brain', 'nav.aiModel', NULL, 1, 200),
|
||||
('admin-group.mlops', 'admin-group', 'ITEM', '/mlops', 'ai-operations:mlops', 'features/ai-operations/MLOpsPage', 'Cpu', 'nav.mlops', NULL, 1, 300),
|
||||
('admin-group.llm-ops', 'admin-group', 'ITEM', '/llm-ops', 'ai-operations:llm-ops', 'features/ai-operations/LLMOpsPage', 'Brain', 'nav.llmOps', NULL, 1, 400),
|
||||
('admin-group.ai-assistant', 'admin-group', 'ITEM', '/ai-assistant', 'ai-operations:ai-assistant', 'features/ai-operations/AIAssistant', 'MessageSquare', 'nav.aiAssistant', NULL, 1, 500),
|
||||
-- 시스템 운영
|
||||
('admin-group.div-sys', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '시스템 운영', 1, 600),
|
||||
('admin-group.system-config', 'admin-group', 'ITEM', '/system-config', 'admin:system-config', 'features/admin/SystemConfig', 'Database', 'nav.systemConfig',NULL, 1, 700),
|
||||
('admin-group.data-hub', 'admin-group', 'ITEM', '/data-hub', 'admin:system-config', 'features/admin/DataHub', 'Wifi', 'nav.dataHub', NULL, 1, 800),
|
||||
('admin-group.external', 'admin-group', 'ITEM', '/external-service', 'statistics:external-service', 'features/statistics/ExternalService', 'Globe', 'nav.externalService', NULL, 1, 900),
|
||||
-- 사용자 관리
|
||||
('admin-group.div-user', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '사용자 관리', 1, 1000),
|
||||
('admin-group.admin', 'admin-group', 'ITEM', '/admin', 'admin', 'features/admin/AdminPanel', 'Settings', 'nav.admin', NULL, 1, 1100),
|
||||
('admin-group.access', 'admin-group', 'ITEM', '/access-control', 'admin:permission-management', 'features/admin/AccessControl', 'Fingerprint', 'nav.accessControl', NULL, 1, 1200),
|
||||
('admin-group.notices', 'admin-group', 'ITEM', '/notices', 'admin', 'features/admin/NoticeManagement', 'Megaphone', 'nav.notices', NULL, 1, 1300),
|
||||
-- 감사·보안
|
||||
('admin-group.div-audit', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '감사·보안', 1, 1400),
|
||||
('admin-group.audit-logs', 'admin-group', 'ITEM', '/admin/audit-logs', 'admin:audit-logs', 'features/admin/AuditLogs', 'ScrollText', 'nav.auditLogs', NULL, 1, 1500),
|
||||
('admin-group.access-logs', 'admin-group', 'ITEM', '/admin/access-logs', 'admin:access-logs', 'features/admin/AccessLogs', 'History', 'nav.accessLogs', NULL, 1, 1600),
|
||||
('admin-group.login-history', 'admin-group', 'ITEM', '/admin/login-history', 'admin:login-history', 'features/admin/LoginHistoryView', 'KeyRound', 'nav.loginHistory',NULL, 1, 1700);
|
||||
|
||||
-- ─── 숨김 라우트 (사이드바 미표시, 라우팅만) ───────────────────────────────
|
||||
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord, use_yn) VALUES
|
||||
('vessel-detail', NULL, 'ITEM', '/vessel/:id', 'vessel:vessel-detail', 'features/vessel/VesselDetail', NULL, NULL, 0, 9999, 'H');
|
||||
@ -0,0 +1,115 @@
|
||||
-- ============================================================================
|
||||
-- V021: auth_perm_tree를 메뉴 SSOT로 확장 + menu_config 테이블 폐기
|
||||
-- 메뉴·권한·감사가 동일 레코드를 참조하여 완전 동기화
|
||||
-- ============================================================================
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 1. auth_perm_tree에 메뉴 컬럼 추가
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE kcg.auth_perm_tree ADD COLUMN url_path VARCHAR(200);
|
||||
ALTER TABLE kcg.auth_perm_tree ADD COLUMN label_key VARCHAR(100);
|
||||
ALTER TABLE kcg.auth_perm_tree ADD COLUMN component_key VARCHAR(150);
|
||||
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_group VARCHAR(100); -- 소속 메뉴 그룹 (NULL=최상위)
|
||||
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sub_group VARCHAR(100); -- 디바이더 라벨 (admin 서브그룹)
|
||||
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sort INT NOT NULL DEFAULT 0; -- 메뉴 정렬 (0=미표시)
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 2. 공유 리소스 분리 — 1메뉴=1노드 보장
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||
VALUES ('statistics:reports', 'statistics', '보고서 관리', 1, 30)
|
||||
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||
|
||||
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||
VALUES ('admin:data-hub', 'admin', '데이터 허브', 1, 85)
|
||||
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||
|
||||
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
|
||||
VALUES ('admin:notices', 'admin', '공지사항', 1, 45)
|
||||
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||
|
||||
-- 신규 노드에 ADMIN 전체 권한 부여
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, n.rsrc_cd, op.oper_cd, 'Y'
|
||||
FROM kcg.auth_role r
|
||||
CROSS JOIN (VALUES ('statistics:reports'), ('admin:data-hub'), ('admin:notices')) AS n(rsrc_cd)
|
||||
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;
|
||||
|
||||
-- 기존 역할에도 READ 부여 (statistics:reports → VIEWER 이상, admin:* → ADMIN 전용)
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, 'statistics:reports', 'READ', 'Y'
|
||||
FROM kcg.auth_role r WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'OPERATOR', 'FIELD')
|
||||
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 3. 메뉴 데이터 채우기 — 최상위 ITEM (13개)
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/dashboard', label_key='nav.dashboard', component_key='features/dashboard/Dashboard', nav_sort=100 WHERE rsrc_cd='dashboard';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/monitoring', label_key='nav.monitoring', component_key='features/monitoring/MonitoringDashboard', nav_sort=200 WHERE rsrc_cd='monitoring';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/events', label_key='nav.realtimeEvent', component_key='features/surveillance/LiveMapView', nav_sort=300 WHERE rsrc_cd='surveillance:live-map';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/map-control', label_key='nav.mapControl', component_key='features/surveillance/MapControl', nav_sort=400 WHERE rsrc_cd='surveillance:map-control';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/risk-map', label_key='nav.riskMap', component_key='features/risk-assessment/RiskMap', nav_sort=500 WHERE rsrc_cd='risk-assessment:risk-map';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/enforcement-plan', label_key='nav.enforcementPlan', component_key='features/risk-assessment/EnforcementPlan', nav_sort=600 WHERE rsrc_cd='risk-assessment:enforcement-plan';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/dark-vessel', label_key='nav.darkVessel', component_key='features/detection/DarkVesselDetection', nav_sort=700 WHERE rsrc_cd='detection:dark-vessel';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/gear-detection', label_key='nav.gearDetection', component_key='features/detection/GearDetection', nav_sort=800 WHERE rsrc_cd='detection:gear-detection';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/china-fishing', label_key='nav.chinaFishing', component_key='features/detection/ChinaFishing', nav_sort=900 WHERE rsrc_cd='detection:china-fishing';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/enforcement-history',label_key='nav.enforcementHistory', component_key='features/enforcement/EnforcementHistory', nav_sort=1000 WHERE rsrc_cd='enforcement:enforcement-history';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/event-list', label_key='nav.eventList', component_key='features/enforcement/EventList', nav_sort=1100 WHERE rsrc_cd='enforcement:event-list';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/statistics', label_key='nav.statistics', component_key='features/statistics/Statistics', nav_sort=1200 WHERE rsrc_cd='statistics:statistics';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/reports', label_key='nav.reports', component_key='features/statistics/ReportManagement', nav_sort=1300 WHERE rsrc_cd='statistics:reports';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 4. 그룹 헤더 (Level 0 노드에 label_key + nav_sort)
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET label_key='group.fieldOps', nav_sort=1400 WHERE rsrc_cd='field-ops';
|
||||
UPDATE kcg.auth_perm_tree SET label_key='group.parentInference', nav_sort=1500 WHERE rsrc_cd='parent-inference-workflow';
|
||||
UPDATE kcg.auth_perm_tree SET label_key='group.admin', nav_sort=1600 WHERE rsrc_cd='admin';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 5. 그룹 자식 — field-ops
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/patrol-route', label_key='nav.patrolRoute', component_key='features/patrol/PatrolRoute', nav_group='field-ops', nav_sort=100 WHERE rsrc_cd='patrol:patrol-route';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/fleet-optimization', label_key='nav.fleetOptimization', component_key='features/patrol/FleetOptimization', nav_group='field-ops', nav_sort=200 WHERE rsrc_cd='patrol:fleet-optimization';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/ai-alert', label_key='nav.aiAlert', component_key='features/field-ops/AIAlert', nav_group='field-ops', nav_sort=300 WHERE rsrc_cd='field-ops:ai-alert';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/mobile-service', label_key='nav.mobileService', component_key='features/field-ops/MobileService', nav_group='field-ops', nav_sort=400 WHERE rsrc_cd='field-ops:mobile-service';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/ship-agent', label_key='nav.shipAgent', component_key='features/field-ops/ShipAgent', nav_group='field-ops', nav_sort=500 WHERE rsrc_cd='field-ops:ship-agent';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 6. 그룹 자식 — parent-inference
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/review', label_key='nav.parentReview', component_key='features/parent-inference/ParentReview', nav_group='parent-inference-workflow', nav_sort=100 WHERE rsrc_cd='parent-inference-workflow:parent-review';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/exclusion', label_key='nav.parentExclusion', component_key='features/parent-inference/ParentExclusion', nav_group='parent-inference-workflow', nav_sort=200 WHERE rsrc_cd='parent-inference-workflow:parent-exclusion';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/label-session', label_key='nav.labelSession', component_key='features/parent-inference/LabelSession', nav_group='parent-inference-workflow', nav_sort=300 WHERE rsrc_cd='parent-inference-workflow:label-session';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 7. 그룹 자식 — admin (서브그룹 포함)
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- AI 플랫폼
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/ai-model', label_key='nav.aiModel', component_key='features/ai-operations/AIModelManagement', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=200 WHERE rsrc_cd='ai-operations:ai-model';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/mlops', label_key='nav.mlops', component_key='features/ai-operations/MLOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=300 WHERE rsrc_cd='ai-operations:mlops';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/llm-ops', label_key='nav.llmOps', component_key='features/ai-operations/LLMOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=400 WHERE rsrc_cd='ai-operations:llm-ops';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/ai-assistant', label_key='nav.aiAssistant', component_key='features/ai-operations/AIAssistant', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=500 WHERE rsrc_cd='ai-operations:ai-assistant';
|
||||
-- 시스템 운영
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/system-config', label_key='nav.systemConfig', component_key='features/admin/SystemConfig', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=700 WHERE rsrc_cd='admin:system-config';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/data-hub', label_key='nav.dataHub', component_key='features/admin/DataHub', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=800 WHERE rsrc_cd='admin:data-hub';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/external-service',label_key='nav.externalService',component_key='features/statistics/ExternalService', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=900 WHERE rsrc_cd='statistics:external-service';
|
||||
-- 사용자 관리
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/admin', label_key='nav.admin', component_key='features/admin/AdminPanel', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1100 WHERE rsrc_cd='admin:user-management';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/access-control', label_key='nav.accessControl', component_key='features/admin/AccessControl', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1200 WHERE rsrc_cd='admin:permission-management';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/notices', label_key='nav.notices', component_key='features/admin/NoticeManagement', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1300 WHERE rsrc_cd='admin:notices';
|
||||
-- 감사·보안
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/admin/audit-logs', label_key='nav.auditLogs', component_key='features/admin/AuditLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1500 WHERE rsrc_cd='admin:audit-logs';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/admin/access-logs', label_key='nav.accessLogs', component_key='features/admin/AccessLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1600 WHERE rsrc_cd='admin:access-logs';
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/admin/login-history',label_key='nav.loginHistory', component_key='features/admin/LoginHistoryView', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1700 WHERE rsrc_cd='admin:login-history';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 8. 숨김 라우트 (라우팅만, 사이드바 미표시)
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET url_path='/vessel/:id', component_key='features/vessel/VesselDetail' WHERE rsrc_cd='vessel:vessel-detail';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 9. menu_config 테이블 폐기
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
DROP TABLE IF EXISTS kcg.menu_config;
|
||||
@ -0,0 +1,83 @@
|
||||
-- ============================================================================
|
||||
-- V022: auth_perm_tree에 다국어 라벨 JSONB — DB가 i18n SSOT
|
||||
-- labels = {"ko": "종합 상황판", "en": "Dashboard"} (언어 추가 시 DDL 변경 불필요)
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE kcg.auth_perm_tree ADD COLUMN labels JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 최상위 ITEM
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"종합 상황판","en":"Dashboard"}' WHERE rsrc_cd='dashboard';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"경보 현황판","en":"Alert Monitor"}' WHERE rsrc_cd='monitoring';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"실시간 이벤트","en":"Realtime Events"}' WHERE rsrc_cd='surveillance:live-map';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"해역 관리","en":"Zone Management"}' WHERE rsrc_cd='surveillance:map-control';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험도 지도","en":"Risk Map"}' WHERE rsrc_cd='risk-assessment:risk-map';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 계획","en":"Enforcement Plan"}' WHERE rsrc_cd='risk-assessment:enforcement-plan';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"다크베셀 탐지","en":"Dark Vessel Detection"}' WHERE rsrc_cd='detection:dark-vessel';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구 탐지","en":"Gear Detection"}' WHERE rsrc_cd='detection:gear-detection';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"중국어선 분석","en":"China Fishing"}' WHERE rsrc_cd='detection:china-fishing';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 이력","en":"Enforcement History"}' WHERE rsrc_cd='enforcement:enforcement-history';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"이벤트 목록","en":"Event List"}' WHERE rsrc_cd='enforcement:event-list';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계 분석","en":"Statistics"}' WHERE rsrc_cd='statistics:statistics';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"보고서 관리","en":"Report Management"}' WHERE rsrc_cd='statistics:reports';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 그룹 헤더
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"현장작전","en":"Field Operations"}' WHERE rsrc_cd='field-ops';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 워크플로우","en":"Parent Inference"}' WHERE rsrc_cd='parent-inference-workflow';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Administration"}' WHERE rsrc_cd='admin';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- field-ops 자식
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰경로 추천","en":"Patrol Route"}' WHERE rsrc_cd='patrol:patrol-route';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"다함정 최적화","en":"Fleet Optimization"}' WHERE rsrc_cd='patrol:fleet-optimization';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 알림 발송","en":"AI Alert"}' WHERE rsrc_cd='field-ops:ai-alert';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모바일 서비스","en":"Mobile Service"}' WHERE rsrc_cd='field-ops:mobile-service';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"함정 Agent","en":"Ship Agent"}' WHERE rsrc_cd='field-ops:ship-agent';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- parent-inference 자식
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 확정/거부","en":"Parent Review"}' WHERE rsrc_cd='parent-inference-workflow:parent-review';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"후보 제외","en":"Exclusion Management"}' WHERE rsrc_cd='parent-inference-workflow:parent-exclusion';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"학습 세션","en":"Label Session"}' WHERE rsrc_cd='parent-inference-workflow:label-session';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- admin 자식
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 모델관리","en":"AI Model Management"}' WHERE rsrc_cd='ai-operations:ai-model';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"MLOps","en":"MLOps"}' WHERE rsrc_cd='ai-operations:mlops';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"LLM 운영","en":"LLM Operations"}' WHERE rsrc_cd='ai-operations:llm-ops';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 의사결정 지원","en":"AI Assistant"}' WHERE rsrc_cd='ai-operations:ai-assistant';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"환경설정","en":"System Config"}' WHERE rsrc_cd='admin:system-config';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"데이터 허브","en":"Data Hub"}' WHERE rsrc_cd='admin:data-hub';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"외부 서비스","en":"External Service"}' WHERE rsrc_cd='statistics:external-service';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Admin Panel"}' WHERE rsrc_cd='admin:user-management';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"권한 관리","en":"Access Control"}' WHERE rsrc_cd='admin:permission-management';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"공지사항","en":"Notices"}' WHERE rsrc_cd='admin:notices';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"감사 로그","en":"Audit Logs"}' WHERE rsrc_cd='admin:audit-logs';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"접근 이력","en":"Access Logs"}' WHERE rsrc_cd='admin:access-logs';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"로그인 이력","en":"Login History"}' WHERE rsrc_cd='admin:login-history';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 메뉴 미표시 권한 노드 (권한 관리 UI에서만 표시)
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"감시","en":"Surveillance"}' WHERE rsrc_cd='surveillance';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"탐지","en":"Detection"}' WHERE rsrc_cd='detection';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박","en":"Vessel"}' WHERE rsrc_cd='vessel';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험평가","en":"Risk Assessment"}' WHERE rsrc_cd='risk-assessment';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰","en":"Patrol"}' WHERE rsrc_cd='patrol';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속","en":"Enforcement"}' WHERE rsrc_cd='enforcement';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 운영","en":"AI Operations"}' WHERE rsrc_cd='ai-operations';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계","en":"Statistics"}' WHERE rsrc_cd='statistics';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박상세","en":"Vessel Detail"}' WHERE rsrc_cd='vessel:vessel-detail';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"전재탐지","en":"Transfer Detection"}' WHERE rsrc_cd='vessel:transfer-detection';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"알림 목록","en":"Alert List"}' WHERE rsrc_cd='monitoring:alert-list';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"KPI 패널","en":"KPI Panel"}' WHERE rsrc_cd='monitoring:kpi-panel';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구식별","en":"Gear Identification"}' WHERE rsrc_cd='detection:gear-identification';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"역할 관리","en":"Role Management"}' WHERE rsrc_cd='admin:role-management';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"메뉴 설정","en":"Menu Config"}' WHERE rsrc_cd='admin:menu-management';
|
||||
UPDATE kcg.auth_perm_tree SET labels='{"ko":"전역 제외 관리","en":"Global Exclusion"}' WHERE rsrc_cd='parent-inference-workflow:exclusion-management';
|
||||
@ -0,0 +1,22 @@
|
||||
-- ============================================================================
|
||||
-- V023: auth_perm_tree Level-0 sort_ord를 좌측 메뉴 순서와 일치
|
||||
-- 권한 관리 UI와 사이드바 메뉴의 나열 순서를 동일하게 맞춤
|
||||
-- ============================================================================
|
||||
|
||||
-- Level-0 노드 sort_ord를 메뉴 nav_sort 순서 기준으로 재배치
|
||||
-- 메뉴 자식이 있는 그룹: 자식 nav_sort 최소값 기준으로 부모 위치 결정
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 100 WHERE rsrc_cd = 'dashboard'; -- nav_sort=100
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 200 WHERE rsrc_cd = 'monitoring'; -- nav_sort=200
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 300 WHERE rsrc_cd = 'surveillance'; -- 자식 nav_sort 300~400
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 500 WHERE rsrc_cd = 'risk-assessment'; -- 자식 nav_sort 500~600
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 700 WHERE rsrc_cd = 'detection'; -- 자식 nav_sort 700~900
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 950 WHERE rsrc_cd = 'statistics'; -- 자식 nav_sort 1200~1300
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 1000 WHERE rsrc_cd = 'enforcement'; -- 자식 nav_sort 1000~1100
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 1400 WHERE rsrc_cd = 'field-ops'; -- nav_sort=1400
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 1500 WHERE rsrc_cd = 'parent-inference-workflow'; -- nav_sort=1500
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 1600 WHERE rsrc_cd = 'admin'; -- nav_sort=1600
|
||||
|
||||
-- 메뉴 미표시 Level-0 노드: 관련 메뉴 순서 근처에 배치
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 650 WHERE rsrc_cd = 'vessel'; -- detection 뒤, 단속 앞
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 1350 WHERE rsrc_cd = 'patrol'; -- field-ops 바로 앞
|
||||
UPDATE kcg.auth_perm_tree SET sort_ord = 1550 WHERE rsrc_cd = 'ai-operations'; -- admin 바로 앞
|
||||
@ -0,0 +1,78 @@
|
||||
-- ============================================================================
|
||||
-- V024: 권한 트리 = 메뉴 트리 완전 동기화
|
||||
-- 보이지 않는 도메인 그룹 8개 삭제, 자식을 메뉴 구조에 맞게 재배치
|
||||
-- ============================================================================
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 1. 그룹 레벨 권한 → 개별 자식 권한으로 확장
|
||||
-- 예: (VIEWER, detection, READ, Y) → 각 detection 자식에 READ Y 복사
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT ap.role_sn, c.rsrc_cd, ap.oper_cd, ap.grant_yn
|
||||
FROM kcg.auth_perm ap
|
||||
JOIN kcg.auth_perm_tree c ON c.parent_cd = ap.rsrc_cd
|
||||
WHERE ap.rsrc_cd IN (
|
||||
'surveillance','detection','risk-assessment','enforcement',
|
||||
'statistics','patrol','ai-operations','vessel'
|
||||
)
|
||||
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 2. 그룹 권한 행 삭제
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
DELETE FROM kcg.auth_perm
|
||||
WHERE rsrc_cd IN (
|
||||
'surveillance','detection','risk-assessment','enforcement',
|
||||
'statistics','patrol','ai-operations','vessel'
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 3. 자식 노드 parent_cd 재배치 (그룹 삭제 전에 수행)
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
|
||||
-- 최상위 평탄 아이템: parent_cd → NULL
|
||||
UPDATE kcg.auth_perm_tree SET parent_cd = NULL
|
||||
WHERE parent_cd IN (
|
||||
'surveillance','detection','risk-assessment','enforcement','statistics','vessel'
|
||||
);
|
||||
|
||||
-- patrol 자식 → field-ops 메뉴 그룹으로 이동
|
||||
UPDATE kcg.auth_perm_tree SET parent_cd = 'field-ops'
|
||||
WHERE parent_cd = 'patrol';
|
||||
|
||||
-- ai-operations 자식 → admin 메뉴 그룹으로 이동
|
||||
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin'
|
||||
WHERE parent_cd = 'ai-operations';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 4. 그룹 노드 삭제 (자식이 모두 이동된 후)
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
DELETE FROM kcg.auth_perm_tree
|
||||
WHERE rsrc_cd IN (
|
||||
'surveillance','detection','risk-assessment','enforcement',
|
||||
'statistics','patrol','ai-operations','vessel'
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 5. 패널 노드 parent_cd를 실제 소속 페이지로 수정
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
|
||||
-- 전역 제외 관리 → 후보 제외 페이지 내부
|
||||
UPDATE kcg.auth_perm_tree SET parent_cd = 'parent-inference-workflow:parent-exclusion'
|
||||
WHERE rsrc_cd = 'parent-inference-workflow:exclusion-management';
|
||||
|
||||
-- 역할 관리 → 권한 관리 페이지 내부
|
||||
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management'
|
||||
WHERE rsrc_cd = 'admin:role-management';
|
||||
|
||||
-- 메뉴 설정 → 권한 관리 페이지 내부
|
||||
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management'
|
||||
WHERE rsrc_cd = 'admin:menu-management';
|
||||
|
||||
-- 어구식별 → 어구 탐지 페이지 내부
|
||||
UPDATE kcg.auth_perm_tree SET parent_cd = 'detection:gear-detection'
|
||||
WHERE rsrc_cd = 'detection:gear-identification';
|
||||
|
||||
-- 전재탐지 → 선박상세 페이지 내부
|
||||
UPDATE kcg.auth_perm_tree SET parent_cd = 'vessel:vessel-detail'
|
||||
WHERE rsrc_cd = 'vessel:transfer-detection';
|
||||
@ -1,6 +1,6 @@
|
||||
# Database Migrations
|
||||
|
||||
> ⚠️ **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)
|
||||
> **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)
|
||||
>
|
||||
> Spring Boot Flyway 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다.
|
||||
> Spring Boot 기동 시 Flyway가 자동으로 적용합니다.
|
||||
@ -10,37 +10,208 @@
|
||||
- **User**: `kcg-app`
|
||||
- **Schema**: `kcg`
|
||||
- **Host**: `211.208.115.83:5432`
|
||||
- **현재 버전**: v022 (2026-04-09)
|
||||
|
||||
## 적용된 마이그레이션 (V001~V013)
|
||||
---
|
||||
|
||||
### Phase 1~8: 인증/권한/감사 (V001~V007)
|
||||
## 마이그레이션 히스토리 (V001~V022)
|
||||
|
||||
Flyway 마이그레이션은 **증분 방식** — 각 파일은 이전 버전에 대한 변경(ALTER/INSERT/CREATE)만 포함합니다.
|
||||
V001이 처음 테이블을 만들고, 이후 파일들이 컬럼 추가·시드 INSERT·신규 테이블 생성 등을 수행합니다.
|
||||
|
||||
### 인증/권한/감사 (V001~V007)
|
||||
|
||||
| 파일 | 내용 |
|
||||
|---|---|
|
||||
| `V001__auth_init.sql` | 인증/조직/역할/사용자-역할/로그인 이력 |
|
||||
| `V002__perm_tree.sql` | 권한 트리 + 권한 매트릭스 |
|
||||
| `V003__perm_seed.sql` | 초기 역할 5종 + 트리 노드 45개 + 권한 매트릭스 시드 |
|
||||
| `V004__access_logs.sql` | 감사로그/접근이력 |
|
||||
| `V005__parent_workflow.sql` | 모선 워크플로우 (resolution/review_log/exclusions/label_sessions) |
|
||||
| `V006__demo_accounts.sql` | 데모 계정 5종 |
|
||||
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭을 사이드바 i18n 라벨과 일치 |
|
||||
| `V001__auth_init.sql` | auth_user, auth_org, auth_role, auth_user_role, auth_login_hist, auth_setting |
|
||||
| `V002__perm_tree.sql` | auth_perm_tree (권한 트리) + auth_perm (권한 매트릭스) |
|
||||
| `V003__perm_seed.sql` | 역할 5종 시드 + 트리 노드 47개 + 역할별 권한 매트릭스 |
|
||||
| `V004__access_logs.sql` | auth_audit_log + auth_access_log |
|
||||
| `V005__parent_workflow.sql` | gear_group_parent_resolution, review_log, exclusions, label_sessions |
|
||||
| `V006__demo_accounts.sql` | 데모 계정 5종 (admin/operator/analyst/field/viewer) |
|
||||
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭 일치 조정 |
|
||||
|
||||
### S1: 마스터 데이터 + Prediction 기반 (V008~V013)
|
||||
### 마스터 데이터 (V008~V011)
|
||||
|
||||
| 파일 | 내용 |
|
||||
|---|---|
|
||||
| `V008__code_master.sql` | 계층형 코드 마스터 (12그룹, 72코드: 위반유형/이벤트/단속/허가/함정 등) |
|
||||
| `V009__gear_type_master.sql` | 어구 유형 마스터 6종 (분류 룰 + 합법성 기준) |
|
||||
| `V010__zone_polygon_master.sql` | 해역 폴리곤 마스터 (PostGIS GEOMETRY, 8개 해역 시드) |
|
||||
| `V011__vessel_permit_patrol.sql` | 어선 허가 마스터 + 함정 마스터 + fleet_companies (선박 9척, 함정 6척) |
|
||||
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + 이벤트 허브 + 알림 + 통계(시/일/월) + KPI + 위험격자 + 학습피드백 |
|
||||
| `V013__enforcement_operations.sql` | 단속 이력/계획 + 함정 배치 + AI모델 버전/메트릭 (시드 포함) |
|
||||
| `V008__code_master.sql` | code_master (계층형 72코드: 위반유형/이벤트/단속 등) |
|
||||
| `V009__gear_type_master.sql` | gear_type_master 6종 (어구 분류 룰 + 합법성 기준) |
|
||||
| `V010__zone_polygon_master.sql` | zone_polygon_master (PostGIS, 8개 해역 시드) |
|
||||
| `V011__vessel_permit_patrol.sql` | vessel_permit_master(9척) + patrol_ship_master(6척) + fleet_companies(2개) |
|
||||
|
||||
### Prediction 분석 (V012~V015)
|
||||
|
||||
| 파일 | 내용 |
|
||||
|---|---|
|
||||
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + prediction_events + alerts + stats(시/일/월) + KPI + risk_grid + label_input |
|
||||
| `V013__enforcement_operations.sql` | enforcement_records + plans + patrol_assignments + ai_model_versions + metrics |
|
||||
| `V014__fleet_prediction_tables.sql` | fleet_vessels/tracking_snapshot + gear_identity_log + correlation_scores/raw_metrics + correlation_param_models + group_polygon_snapshots + gear_group_episodes/episode_snapshots + parent_candidate_snapshots + label_tracking_cycles + system_config |
|
||||
| `V015__fix_numeric_precision.sql` | NUMERIC 정밀도 확대 (점수/비율 컬럼) |
|
||||
|
||||
### 모선 워크플로우 확장 + 기능 추가 (V016~V019)
|
||||
|
||||
| 파일 | 내용 |
|
||||
|---|---|
|
||||
| `V016__parent_workflow_columns.sql` | gear_group_parent_resolution 확장 (confidence, decision_source, episode_id 등) |
|
||||
| `V017__role_color_hex.sql` | auth_role.color_hex 컬럼 추가 |
|
||||
| `V018__prediction_event_features.sql` | prediction_events.features JSONB 컬럼 추가 |
|
||||
| `V019__llm_ops_perm.sql` | ai-operations:llm-ops 권한 트리 노드 + ADMIN 권한 |
|
||||
|
||||
### 메뉴 DB SSOT (V020~V022)
|
||||
|
||||
| 파일 | 내용 |
|
||||
|---|---|
|
||||
| `V020__menu_config.sql` | menu_config 테이블 생성 + 시드 (V021에서 통합 후 폐기) |
|
||||
| `V021__menu_into_perm_tree.sql` | auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort) + 공유 리소스 분리 (statistics:reports, admin:data-hub, admin:notices) + menu_config DROP |
|
||||
| `V022__perm_tree_i18n_labels.sql` | auth_perm_tree.labels JSONB 추가 — DB가 i18n SSOT (`{"ko":"...", "en":"..."}`) |
|
||||
|
||||
---
|
||||
|
||||
## 테이블 목록 (49개, flyway_schema_history 포함)
|
||||
|
||||
### 인증/권한 (8 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 | 주요 컬럼 |
|
||||
|---|---|---|---|
|
||||
| `auth_user` | user_id (UUID) | 사용자 | user_acnt(UQ), pswd_hash, user_nm, rnkp_nm, email, org_sn(FK→auth_org), user_stts_cd, fail_cnt, auth_provider |
|
||||
| `auth_org` | org_sn (BIGSERIAL) | 조직 | org_nm, org_abbr_nm, org_tp_cd, upper_org_sn(FK 자기참조) |
|
||||
| `auth_role` | role_sn (BIGSERIAL) | 역할 | role_cd(UQ), role_nm, role_dc, dflt_yn, builtin_yn, color_hex |
|
||||
| `auth_user_role` | (user_id, role_sn) | 사용자-역할 매핑 | granted_at, granted_by |
|
||||
| `auth_perm_tree` | rsrc_cd (VARCHAR 100) | 권한 트리 + **메뉴 SSOT** | parent_cd(FK 자기참조), rsrc_nm, icon, rsrc_level, sort_ord, **url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort, labels(JSONB)** |
|
||||
| `auth_perm` | perm_sn (BIGSERIAL) | 권한 매트릭스 | role_sn(FK→auth_role), rsrc_cd(FK→auth_perm_tree), oper_cd, grant_yn, UQ(role_sn,rsrc_cd,oper_cd) |
|
||||
| `auth_setting` | setting_key (VARCHAR 50) | 시스템 설정 | setting_val(JSONB) |
|
||||
| `auth_login_hist` | hist_sn (BIGSERIAL) | 로그인 이력 | user_id, user_acnt, login_dtm, login_ip, result, fail_reason, auth_provider |
|
||||
|
||||
### 감사 (2 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 | 주요 컬럼 |
|
||||
|---|---|---|---|
|
||||
| `auth_audit_log` | audit_sn (BIGSERIAL) | 감사 로그 | user_id, action_cd, resource_type, resource_id, detail(JSONB), ip_address, result |
|
||||
| `auth_access_log` | access_sn (BIGSERIAL) | API 접근 이력 | user_id, http_method, request_path, status_code, duration_ms, ip_address |
|
||||
|
||||
### 모선 워크플로우 (7 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 |
|
||||
|---|---|---|
|
||||
| `gear_group_parent_resolution` | id (BIGSERIAL), UQ(group_key, sub_cluster_id) | 모선 확정/거부 결과 (status, selected_parent_mmsi, confidence, decision_source, scores, episode_id) |
|
||||
| `gear_group_parent_review_log` | id (BIGSERIAL) | 운영자 리뷰 이력 (action, actor, comment) |
|
||||
| `gear_parent_candidate_exclusions` | id (BIGSERIAL) | 후보 제외 관리 (scope_type, excluded_mmsi, reason, active_from/until) |
|
||||
| `gear_parent_label_sessions` | id (BIGSERIAL) | 학습 세션 (label_parent_mmsi, status, duration_days, anchor_snapshot) |
|
||||
| `gear_parent_label_tracking_cycles` | (label_session_id, observed_at) | 학습 추적 사이클 (top_candidate, labeled_candidate 비교) |
|
||||
| `gear_group_episodes` | episode_id (VARCHAR 50) | 어구 그룹 에피소드 (lineage_key, status, member_mmsis, center_point) |
|
||||
| `gear_group_episode_snapshots` | (episode_id, observed_at) | 에피소드 스냅샷 |
|
||||
|
||||
### 마스터 데이터 (5 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 | 시드 |
|
||||
|---|---|---|---|
|
||||
| `code_master` | code_id (VARCHAR 100) | 계층형 코드 | 12그룹, 72코드 |
|
||||
| `gear_type_master` | gear_code (VARCHAR 20) | 어구 유형 | 6종 |
|
||||
| `zone_polygon_master` | zone_code (VARCHAR 30) | 해역 폴리곤 (PostGIS GEOMETRY 4326) | 8해역 |
|
||||
| `vessel_permit_master` | mmsi (VARCHAR 20) | 어선 허가 | 9척 |
|
||||
| `patrol_ship_master` | ship_id (BIGSERIAL), UQ(ship_code) | 함정 | 6척 |
|
||||
|
||||
### Prediction 이벤트/통계 (8 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 |
|
||||
|---|---|---|
|
||||
| `vessel_analysis_results` | (id, analyzed_at) 파티션 | 선박 분석 결과 (35컬럼: mmsi, risk_score, is_dark, transship_suspect, features JSONB 등) |
|
||||
| `vessel_analysis_results_default` | — | 기본 파티션 |
|
||||
| `prediction_events` | id (BIGSERIAL), UQ(event_uid) | 탐지 이벤트 (level, category, vessel_mmsi, status, features JSONB) |
|
||||
| `prediction_alerts` | id (BIGSERIAL) | 경보 발송 (event_id FK, channel, delivery_status) |
|
||||
| `event_workflow` | id (BIGSERIAL) | 이벤트 상태 변경 이력 (prev/new_status, actor) |
|
||||
| `prediction_stats_hourly` | stat_hour (TIMESTAMPTZ) | 시간별 통계 (by_category/by_zone JSONB) |
|
||||
| `prediction_stats_daily` | stat_date (DATE) | 일별 통계 |
|
||||
| `prediction_stats_monthly` | stat_month (DATE) | 월별 통계 |
|
||||
|
||||
### Prediction 보조 (7 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 |
|
||||
|---|---|---|
|
||||
| `prediction_kpi_realtime` | kpi_key (VARCHAR 50) | 실시간 KPI (value, trend, delta_pct) |
|
||||
| `prediction_risk_grid` | (cell_id, stat_hour) | 위험도 격자 |
|
||||
| `prediction_label_input` | id (BIGSERIAL) | 학습 피드백 입력 |
|
||||
| `gear_correlation_scores` | (model_id, group_key, sub_cluster_id, target_mmsi) | 어구-선박 상관 점수 |
|
||||
| `gear_correlation_raw_metrics` | id (BIGSERIAL) | 상관 원시 지표 |
|
||||
| `correlation_param_models` | id (BIGSERIAL) | 상관 모델 파라미터 |
|
||||
| `group_polygon_snapshots` | id (BIGSERIAL) | 그룹 폴리곤 스냅샷 (PostGIS) |
|
||||
|
||||
### Prediction 후보 (1 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 |
|
||||
|---|---|---|
|
||||
| `gear_group_parent_candidate_snapshots` | id (BIGSERIAL) | 모선 후보 스냅샷 (25컬럼: 점수 분해, evidence JSONB) |
|
||||
|
||||
### 단속/작전 (3 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 |
|
||||
|---|---|---|
|
||||
| `enforcement_records` | id (BIGSERIAL), UQ(enf_uid) | 단속 이력 (event_id FK, vessel_mmsi, action, result) |
|
||||
| `enforcement_plans` | id (BIGSERIAL), UQ(plan_uid) | 단속 계획 (planned_date, risk_level, status) |
|
||||
| `patrol_assignments` | id (BIGSERIAL) | 함정 배치 (ship_id FK, plan_id FK, waypoints JSONB) |
|
||||
|
||||
### AI 모델 (2 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 |
|
||||
|---|---|---|
|
||||
| `ai_model_versions` | id (BIGSERIAL) | AI 모델 버전 (accuracy, status, train_config JSONB) |
|
||||
| `ai_model_metrics` | id (BIGSERIAL) | 모델 메트릭 (model_id FK, metric_name, metric_value) |
|
||||
|
||||
### Fleet (3 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 |
|
||||
|---|---|---|
|
||||
| `fleet_companies` | id (BIGSERIAL) | 선단 업체 (name_cn/en/ko, country) |
|
||||
| `fleet_vessels` | id (BIGSERIAL) | 선단 선박 (company_id FK, mmsi, gear_code, fleet_role) |
|
||||
| `fleet_tracking_snapshot` | id (BIGSERIAL) | 선단 추적 스냅샷 (company_id FK) |
|
||||
|
||||
### 기타 (2 테이블)
|
||||
|
||||
| 테이블 | PK | 설명 |
|
||||
|---|---|---|
|
||||
| `gear_identity_log` | id (BIGSERIAL) | 어구 식별 로그 (mmsi, name, parent_mmsi, match_method) |
|
||||
| `system_config` | key (VARCHAR 100) | 시스템 설정 (value JSONB) |
|
||||
|
||||
---
|
||||
|
||||
## 인덱스 현황 (149개)
|
||||
|
||||
주요 패턴:
|
||||
- **시계열 DESC**: `(occurred_at DESC)`, `(created_at DESC)`, `(analyzed_at DESC)` — 최신 데이터 우선 조회
|
||||
- **복합 키**: `(group_key, sub_cluster_id, observed_at DESC)` — 어구 그룹 시계열
|
||||
- **GiST 공간**: `polygon`, `polygon_geom` — PostGIS 공간 검색
|
||||
- **GIN 배열**: `violation_categories` — 위반 카테고리 배열 검색
|
||||
- **부분 인덱스**: `(released_at) WHERE released_at IS NULL` — 활성 제외만, `(is_dark) WHERE is_dark = true` — dark vessel만
|
||||
|
||||
## FK 관계 (21개)
|
||||
|
||||
```
|
||||
auth_user ─→ auth_org (org_sn)
|
||||
auth_user_role ─→ auth_user (user_id), auth_role (role_sn)
|
||||
auth_perm ─→ auth_role (role_sn), auth_perm_tree (rsrc_cd)
|
||||
auth_perm_tree ─→ auth_perm_tree (parent_cd, 자기참조)
|
||||
code_master ─→ code_master (parent_id, 자기참조)
|
||||
zone_polygon_master ─→ zone_polygon_master (parent_zone_code, 자기참조)
|
||||
auth_org ─→ auth_org (upper_org_sn, 자기참조)
|
||||
enforcement_records ─→ prediction_events (event_id), patrol_ship_master (patrol_ship_id)
|
||||
event_workflow ─→ prediction_events (event_id)
|
||||
prediction_alerts ─→ prediction_events (event_id)
|
||||
patrol_assignments ─→ patrol_ship_master (ship_id), enforcement_plans (plan_id)
|
||||
ai_model_metrics ─→ ai_model_versions (model_id)
|
||||
gear_correlation_scores ─→ correlation_param_models (model_id)
|
||||
gear_parent_label_tracking_cycles ─→ gear_parent_label_sessions (label_session_id)
|
||||
fleet_tracking_snapshot ─→ fleet_companies (company_id)
|
||||
fleet_vessels ─→ fleet_companies (company_id)
|
||||
vessel_permit_master ─→ fleet_companies (company_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 실행 방법
|
||||
|
||||
### 최초 1회 - DB/사용자 생성 (관리자 권한 필요)
|
||||
```sql
|
||||
-- snp 관리자 계정으로 접속
|
||||
psql -h 211.208.115.83 -U snp -d postgres
|
||||
|
||||
CREATE DATABASE kcgaidb;
|
||||
@ -61,7 +232,11 @@ cd backend && ./mvnw spring-boot:run
|
||||
|
||||
### 수동 적용
|
||||
```bash
|
||||
cd backend && ./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb -Dflyway.user=kcg-app -Dflyway.password=Kcg2026ai -Dflyway.schemas=kcg
|
||||
cd backend && ./mvnw flyway:migrate \
|
||||
-Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb \
|
||||
-Dflyway.user=kcg-app \
|
||||
-Dflyway.password=Kcg2026ai \
|
||||
-Dflyway.schemas=kcg
|
||||
```
|
||||
|
||||
### Checksum 불일치 시 (마이그레이션 파일 수정 후)
|
||||
@ -70,4 +245,18 @@ cd backend && ./mvnw flyway:repair -Dflyway.url=... (위와 동일)
|
||||
```
|
||||
|
||||
## 신규 마이그레이션 추가
|
||||
[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V00N__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다.
|
||||
[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V0NN__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다.
|
||||
|
||||
### 메뉴 추가 시 필수 포함 사항
|
||||
auth_perm_tree에 INSERT 시 메뉴 SSOT 컬럼도 함께 지정:
|
||||
```sql
|
||||
INSERT INTO kcg.auth_perm_tree(
|
||||
rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, icon,
|
||||
url_path, label_key, component_key, nav_group, nav_sort,
|
||||
labels
|
||||
) VALUES (
|
||||
'new-feature:sub', 'new-feature', '새 기능', 1, 10, 'Sparkles',
|
||||
'/new-feature/sub', 'nav.newFeatureSub', 'features/new-feature/SubPage', NULL, 1400,
|
||||
'{"ko":"새 기능 서브","en":"New Feature Sub"}'
|
||||
);
|
||||
```
|
||||
|
||||
@ -4,6 +4,57 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현
|
||||
- **VesselAnalysis 직접 조회 API 5개** (`/api/analysis/*`) — iran proxy 없이 prediction DB 직접 조회
|
||||
- vessels 목록 (필터: mmsi, zone, riskLevel, isDark)
|
||||
- vessels/{mmsi} 최신 분석 (features JSONB 포함)
|
||||
- vessels/{mmsi}/history 분석 이력 (24h)
|
||||
- dark 베셀 목록 (MMSI 중복 제거)
|
||||
- transship 의심 목록
|
||||
- **EventList 인라인 액션 4종** — 확인(ACK)/선박상세/단속등록/오탐 처리
|
||||
- **MMSI → VesselDetail 링크** — EventList, DarkVessel, EnforcementHistory 3개 화면
|
||||
- **VesselDetail 전면 개편** — prediction 직접 API 전환, dark 패턴 시각화(tier/score/patterns), 환적 분석, 24h AIS 수신 타임라인, 단속 이력 탭
|
||||
- **DarkVesselDetection prediction 전환** — iran proxy 제거, tier 기반 KPI/필터/정렬
|
||||
- **EnforcementHistory eventId 역추적** — 단속→이벤트 역링크
|
||||
- **EnforcementPlan 미배정 CRITICAL 이벤트 패널** — NEW 상태 CRITICAL 이벤트 표시
|
||||
- **모선추론 자동 연결** — CONFIRM→LabelSession, REJECT→Exclusion 자동 호출
|
||||
- **30초 자동 갱신** — EventList, DarkVessel (silentRefresh 패턴, 깜박임 없음)
|
||||
- **admin 메뉴 4개 서브그룹** — AI 플랫폼/시스템 운영/사용자 관리/감사·보안
|
||||
- **V018 마이그레이션** — prediction_events.features JSONB 컬럼
|
||||
- **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목
|
||||
- **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동)
|
||||
- **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등)
|
||||
|
||||
- **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합
|
||||
- auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort)
|
||||
- labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT
|
||||
- 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection 등) → 권한 트리 = 메뉴 트리 완전 동기화
|
||||
- 패널 노드 parent_cd 실제 소속 페이지로 수정 (어구식별→어구탐지, 전역제외→후보제외)
|
||||
- vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크)
|
||||
- 공유 리소스 분리: statistics:reports, admin:data-hub, admin:notices 독립 노드 생성
|
||||
- V020~V024 마이그레이션 5건
|
||||
- **프론트엔드 동적 메뉴/라우팅** — DB 기반 자동 구성
|
||||
- menuStore(Zustand) + componentRegistry(lazy loading) + iconRegistry
|
||||
- NAV_ENTRIES/PATH_TO_RESOURCE 하드코딩 제거
|
||||
- App.tsx DynamicRoutes: DB menuConfig에서 Route 자동 생성
|
||||
- MainLayout: DB menuConfig에서 사이드바 자동 렌더링
|
||||
- **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬
|
||||
- **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화
|
||||
|
||||
### 변경
|
||||
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
|
||||
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
|
||||
- **EnforcementController** vesselMmsi 필터 파라미터 추가
|
||||
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
|
||||
|
||||
### 수정
|
||||
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
|
||||
- system-flow 08-frontend.json 누락 노드 14개 추가
|
||||
|
||||
### 문서
|
||||
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
|
||||
|
||||
## [2026-04-09]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -1,42 +1,13 @@
|
||||
import { Suspense, useMemo, lazy } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from '@/app/auth/AuthContext';
|
||||
import { MainLayout } from '@/app/layout/MainLayout';
|
||||
import { LoginPage } from '@features/auth';
|
||||
/* SFR-01 */ import { AccessControl } from '@features/admin';
|
||||
/* SFR-02 */ import { SystemConfig, NoticeManagement } from '@features/admin';
|
||||
/* SFR-03 */ import { DataHub } from '@features/admin';
|
||||
/* SFR-04 */ import { AIModelManagement } from '@features/ai-operations';
|
||||
/* SFR-05 */ import { RiskMap } from '@features/risk-assessment';
|
||||
/* SFR-06 */ import { EnforcementPlan } from '@features/risk-assessment';
|
||||
/* SFR-07 */ import { PatrolRoute } from '@features/patrol';
|
||||
/* SFR-08 */ import { FleetOptimization } from '@features/patrol';
|
||||
/* SFR-09 */ import { DarkVesselDetection } from '@features/detection';
|
||||
/* SFR-10 */ import { GearDetection } from '@features/detection';
|
||||
/* SFR-11 */ import { EnforcementHistory } from '@features/enforcement';
|
||||
/* SFR-12 */ import { MonitoringDashboard } from '@features/monitoring';
|
||||
/* SFR-13 */ import { Statistics } from '@features/statistics';
|
||||
/* SFR-14 */ import { ExternalService } from '@features/statistics';
|
||||
/* SFR-15 */ import { MobileService } from '@features/field-ops';
|
||||
/* SFR-16 */ import { ShipAgent } from '@features/field-ops';
|
||||
/* SFR-17 */ import { AIAlert } from '@features/field-ops';
|
||||
/* SFR-18+19 */ import { MLOpsPage } from '@features/ai-operations';
|
||||
/* SFR-20 */ import { AIAssistant } from '@features/ai-operations';
|
||||
/* SFR-20 LLM운영 */ import { LLMOpsPage } from '@features/ai-operations';
|
||||
/* 기존 */ import { Dashboard } from '@features/dashboard';
|
||||
import { LiveMapView, MapControl } from '@features/surveillance';
|
||||
import { EventList } from '@features/enforcement';
|
||||
import { VesselDetail } from '@features/vessel';
|
||||
import { ChinaFishing } from '@features/detection';
|
||||
import { ReportManagement } from '@features/statistics';
|
||||
import { AdminPanel } from '@features/admin';
|
||||
// Phase 4: 모선 워크플로우
|
||||
import { ParentReview } from '@features/parent-inference/ParentReview';
|
||||
import { ParentExclusion } from '@features/parent-inference/ParentExclusion';
|
||||
import { LabelSession } from '@features/parent-inference/LabelSession';
|
||||
// Phase 4: 관리자 로그
|
||||
import { AuditLogs } from '@features/admin/AuditLogs';
|
||||
import { AccessLogs } from '@features/admin/AccessLogs';
|
||||
import { LoginHistoryView } from '@features/admin/LoginHistoryView';
|
||||
import { useMenuStore } from '@stores/menuStore';
|
||||
import { COMPONENT_REGISTRY } from '@/app/componentRegistry';
|
||||
|
||||
// 권한 노드 없는 드릴다운 라우트 (인증만 체크)
|
||||
const VesselDetail = lazy(() => import('@features/vessel').then((m) => ({ default: m.VesselDetail })));
|
||||
|
||||
/**
|
||||
* 권한 가드.
|
||||
@ -69,66 +40,66 @@ function ProtectedRoute({
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[40vh]">
|
||||
<div className="text-sm text-hint">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DB menu_config 기반 동적 라우트를 Route 배열로 반환.
|
||||
* React Router v6는 <Routes> 직계 자식으로 <Route>만 허용하므로 컴포넌트가 아닌 함수로 생성.
|
||||
*/
|
||||
function useDynamicRoutes() {
|
||||
const items = useMenuStore((s) => s.items);
|
||||
const routableItems = useMemo(
|
||||
() => items.filter((i) => i.menuType === 'ITEM' && i.urlPath),
|
||||
[items],
|
||||
);
|
||||
|
||||
return routableItems.map((item) => {
|
||||
const Comp = item.componentKey ? COMPONENT_REGISTRY[item.componentKey] : null;
|
||||
if (!Comp || !item.urlPath) return null;
|
||||
const path = item.urlPath.replace(/^\//, '');
|
||||
return (
|
||||
<Route
|
||||
key={item.menuCd}
|
||||
path={path}
|
||||
element={
|
||||
<ProtectedRoute resource={item.rsrcCd ?? undefined}>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Comp />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const dynamicRoutes = useDynamicRoutes();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
{dynamicRoutes}
|
||||
{/* 드릴다운 전용 라우트 — 메뉴/권한 노드 없음, 인증만 체크 */}
|
||||
<Route path="vessel/:id" element={<Suspense fallback={<LoadingFallback />}><VesselDetail /></Suspense>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
{/* SFR-12 대시보드 */}
|
||||
<Route path="dashboard" element={<ProtectedRoute resource="dashboard"><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="monitoring" element={<ProtectedRoute resource="monitoring"><MonitoringDashboard /></ProtectedRoute>} />
|
||||
{/* SFR-05~06 위험도·단속계획 */}
|
||||
<Route path="risk-map" element={<ProtectedRoute resource="risk-assessment:risk-map"><RiskMap /></ProtectedRoute>} />
|
||||
<Route path="enforcement-plan" element={<ProtectedRoute resource="risk-assessment:enforcement-plan"><EnforcementPlan /></ProtectedRoute>} />
|
||||
{/* SFR-09~10 탐지 */}
|
||||
<Route path="dark-vessel" element={<ProtectedRoute resource="detection:dark-vessel"><DarkVesselDetection /></ProtectedRoute>} />
|
||||
<Route path="gear-detection" element={<ProtectedRoute resource="detection:gear-detection"><GearDetection /></ProtectedRoute>} />
|
||||
<Route path="china-fishing" element={<ProtectedRoute resource="detection:china-fishing"><ChinaFishing /></ProtectedRoute>} />
|
||||
{/* SFR-07~08 순찰경로 */}
|
||||
<Route path="patrol-route" element={<ProtectedRoute resource="patrol:patrol-route"><PatrolRoute /></ProtectedRoute>} />
|
||||
<Route path="fleet-optimization" element={<ProtectedRoute resource="patrol:fleet-optimization"><FleetOptimization /></ProtectedRoute>} />
|
||||
{/* SFR-11 이력 */}
|
||||
<Route path="enforcement-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><EnforcementHistory /></ProtectedRoute>} />
|
||||
<Route path="event-list" element={<ProtectedRoute resource="enforcement:event-list"><EventList /></ProtectedRoute>} />
|
||||
{/* SFR-15~17 현장 대응 */}
|
||||
<Route path="mobile-service" element={<ProtectedRoute resource="field-ops:mobile-service"><MobileService /></ProtectedRoute>} />
|
||||
<Route path="ship-agent" element={<ProtectedRoute resource="field-ops:ship-agent"><ShipAgent /></ProtectedRoute>} />
|
||||
<Route path="ai-alert" element={<ProtectedRoute resource="field-ops:ai-alert"><AIAlert /></ProtectedRoute>} />
|
||||
{/* SFR-13~14 통계·외부연계 */}
|
||||
<Route path="statistics" element={<ProtectedRoute resource="statistics:statistics"><Statistics /></ProtectedRoute>} />
|
||||
<Route path="external-service" element={<ProtectedRoute resource="statistics:external-service"><ExternalService /></ProtectedRoute>} />
|
||||
<Route path="reports" element={<ProtectedRoute resource="statistics:statistics"><ReportManagement /></ProtectedRoute>} />
|
||||
{/* SFR-04 AI 모델 */}
|
||||
<Route path="ai-model" element={<ProtectedRoute resource="ai-operations:ai-model"><AIModelManagement /></ProtectedRoute>} />
|
||||
{/* SFR-18~20 AI 운영 */}
|
||||
<Route path="mlops" element={<ProtectedRoute resource="ai-operations:mlops"><MLOpsPage /></ProtectedRoute>} />
|
||||
<Route path="llm-ops" element={<ProtectedRoute resource="ai-operations:llm-ops"><LLMOpsPage /></ProtectedRoute>} />
|
||||
<Route path="ai-assistant" element={<ProtectedRoute resource="ai-operations:ai-assistant"><AIAssistant /></ProtectedRoute>} />
|
||||
{/* SFR-03 데이터허브 */}
|
||||
<Route path="data-hub" element={<ProtectedRoute resource="admin:system-config"><DataHub /></ProtectedRoute>} />
|
||||
{/* SFR-02 환경설정 */}
|
||||
<Route path="system-config" element={<ProtectedRoute resource="admin:system-config"><SystemConfig /></ProtectedRoute>} />
|
||||
<Route path="notices" element={<ProtectedRoute resource="admin"><NoticeManagement /></ProtectedRoute>} />
|
||||
{/* SFR-01 권한·시스템 */}
|
||||
<Route path="access-control" element={<ProtectedRoute resource="admin:permission-management"><AccessControl /></ProtectedRoute>} />
|
||||
<Route path="admin" element={<ProtectedRoute resource="admin"><AdminPanel /></ProtectedRoute>} />
|
||||
{/* Phase 4: 관리자 로그 */}
|
||||
<Route path="admin/audit-logs" element={<ProtectedRoute resource="admin:audit-logs"><AuditLogs /></ProtectedRoute>} />
|
||||
<Route path="admin/access-logs" element={<ProtectedRoute resource="admin:access-logs"><AccessLogs /></ProtectedRoute>} />
|
||||
<Route path="admin/login-history" element={<ProtectedRoute resource="admin:login-history"><LoginHistoryView /></ProtectedRoute>} />
|
||||
{/* Phase 4: 모선 워크플로우 */}
|
||||
<Route path="parent-inference/review" element={<ProtectedRoute resource="parent-inference-workflow:parent-review"><ParentReview /></ProtectedRoute>} />
|
||||
<Route path="parent-inference/exclusion" element={<ProtectedRoute resource="parent-inference-workflow:parent-exclusion"><ParentExclusion /></ProtectedRoute>} />
|
||||
<Route path="parent-inference/label-session" element={<ProtectedRoute resource="parent-inference-workflow:label-session"><LabelSession /></ProtectedRoute>} />
|
||||
{/* 기존 유지 */}
|
||||
<Route path="events" element={<ProtectedRoute resource="surveillance:live-map"><LiveMapView /></ProtectedRoute>} />
|
||||
<Route path="map-control" element={<ProtectedRoute resource="surveillance:map-control"><MapControl /></ProtectedRoute>} />
|
||||
<Route path="vessel/:id" element={<ProtectedRoute resource="vessel:vessel-detail"><VesselDetail /></ProtectedRoute>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
|
||||
import { useMenuStore } from '@stores/menuStore';
|
||||
|
||||
/*
|
||||
* SFR-01: 시스템 로그인 및 권한 관리
|
||||
@ -33,47 +34,6 @@ export interface AuthUser {
|
||||
// ─── 세션 타임아웃 (30분) ──────────────────
|
||||
const SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||
|
||||
// 경로 → 권한 리소스 매핑 (ProtectedRoute용)
|
||||
const PATH_TO_RESOURCE: Record<string, string> = {
|
||||
'/dashboard': 'dashboard',
|
||||
'/monitoring': 'monitoring',
|
||||
'/events': 'surveillance:live-map',
|
||||
'/map-control': 'surveillance:map-control',
|
||||
'/dark-vessel': 'detection:dark-vessel',
|
||||
'/gear-detection': 'detection:gear-detection',
|
||||
'/china-fishing': 'detection:china-fishing',
|
||||
'/vessel': 'vessel',
|
||||
'/risk-map': 'risk-assessment:risk-map',
|
||||
'/enforcement-plan': 'risk-assessment:enforcement-plan',
|
||||
'/patrol-route': 'patrol:patrol-route',
|
||||
'/fleet-optimization': 'patrol:fleet-optimization',
|
||||
'/enforcement-history': 'enforcement:enforcement-history',
|
||||
'/event-list': 'enforcement:event-list',
|
||||
'/mobile-service': 'field-ops:mobile-service',
|
||||
'/ship-agent': 'field-ops:ship-agent',
|
||||
'/ai-alert': 'field-ops:ai-alert',
|
||||
'/ai-assistant': 'ai-operations:ai-assistant',
|
||||
'/ai-model': 'ai-operations:ai-model',
|
||||
'/mlops': 'ai-operations:mlops',
|
||||
'/llm-ops': 'ai-operations:llm-ops',
|
||||
'/statistics': 'statistics:statistics',
|
||||
'/external-service': 'statistics:external-service',
|
||||
'/admin/audit-logs': 'admin:audit-logs',
|
||||
'/admin/access-logs': 'admin:access-logs',
|
||||
'/admin/login-history': 'admin:login-history',
|
||||
'/admin': 'admin',
|
||||
'/access-control': 'admin:permission-management',
|
||||
'/system-config': 'admin:system-config',
|
||||
'/notices': 'admin',
|
||||
'/reports': 'statistics:statistics',
|
||||
'/data-hub': 'admin:system-config',
|
||||
// 모선 워크플로우
|
||||
'/parent-inference/review': 'parent-inference-workflow:parent-review',
|
||||
'/parent-inference/exclusion': 'parent-inference-workflow:parent-exclusion',
|
||||
'/parent-inference/label-session': 'parent-inference-workflow:label-session',
|
||||
'/parent-inference': 'parent-inference-workflow',
|
||||
};
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
@ -133,7 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
let alive = true;
|
||||
fetchMe()
|
||||
.then((b) => {
|
||||
if (alive && b) setUser(backendToAuthUser(b));
|
||||
if (alive && b) {
|
||||
setUser(backendToAuthUser(b));
|
||||
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
@ -175,6 +138,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const b = await loginApi(account, password);
|
||||
setUser(backendToAuthUser(b));
|
||||
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
|
||||
setLastActivity(Date.now());
|
||||
} catch (e) {
|
||||
if (e instanceof LoginError) throw e;
|
||||
@ -187,6 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
await logoutApi();
|
||||
} finally {
|
||||
setUser(null);
|
||||
useMenuStore.getState().clear();
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -201,10 +166,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const hasAccess = useCallback(
|
||||
(path: string) => {
|
||||
if (!user) return false;
|
||||
// 경로의 첫 세그먼트로 매핑
|
||||
const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p));
|
||||
if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능)
|
||||
const resource = PATH_TO_RESOURCE[matched];
|
||||
// DB menu_config 기반 longest-match (PATH_TO_RESOURCE 대체)
|
||||
const resource = useMenuStore.getState().getResourceForPath(path);
|
||||
if (!resource) return true;
|
||||
return hasPermission(resource, 'READ');
|
||||
},
|
||||
[user, hasPermission],
|
||||
|
||||
136
frontend/src/app/componentRegistry.ts
Normal file
136
frontend/src/app/componentRegistry.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { lazy, type ComponentType } from 'react';
|
||||
|
||||
type LazyComponent = React.LazyExoticComponent<ComponentType<unknown>>;
|
||||
|
||||
/**
|
||||
* DB menu_config.component_key → lazy-loaded React 컴포넌트 매핑.
|
||||
* 메뉴 추가 시 이 레지스트리에 1줄만 추가하면 됨.
|
||||
*/
|
||||
export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
||||
// ── 상황판·감시 ──
|
||||
'features/dashboard/Dashboard': lazy(() =>
|
||||
import('@features/dashboard/Dashboard').then((m) => ({ default: m.Dashboard })),
|
||||
),
|
||||
'features/monitoring/MonitoringDashboard': lazy(() =>
|
||||
import('@features/monitoring/MonitoringDashboard').then((m) => ({
|
||||
default: m.MonitoringDashboard,
|
||||
})),
|
||||
),
|
||||
'features/surveillance/LiveMapView': lazy(() =>
|
||||
import('@features/surveillance').then((m) => ({ default: m.LiveMapView })),
|
||||
),
|
||||
'features/surveillance/MapControl': lazy(() =>
|
||||
import('@features/surveillance').then((m) => ({ default: m.MapControl })),
|
||||
),
|
||||
// ── 위험도·단속 ──
|
||||
'features/risk-assessment/RiskMap': lazy(() =>
|
||||
import('@features/risk-assessment').then((m) => ({ default: m.RiskMap })),
|
||||
),
|
||||
'features/risk-assessment/EnforcementPlan': lazy(() =>
|
||||
import('@features/risk-assessment').then((m) => ({ default: m.EnforcementPlan })),
|
||||
),
|
||||
// ── 탐지 ──
|
||||
'features/detection/DarkVesselDetection': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.DarkVesselDetection })),
|
||||
),
|
||||
'features/detection/GearDetection': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.GearDetection })),
|
||||
),
|
||||
'features/detection/ChinaFishing': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
|
||||
),
|
||||
// ── 단속·이벤트 ──
|
||||
'features/enforcement/EnforcementHistory': lazy(() =>
|
||||
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
||||
),
|
||||
'features/enforcement/EventList': lazy(() =>
|
||||
import('@features/enforcement').then((m) => ({ default: m.EventList })),
|
||||
),
|
||||
// ── 통계 ──
|
||||
'features/statistics/Statistics': lazy(() =>
|
||||
import('@features/statistics').then((m) => ({ default: m.Statistics })),
|
||||
),
|
||||
'features/statistics/ReportManagement': lazy(() =>
|
||||
import('@features/statistics').then((m) => ({ default: m.ReportManagement })),
|
||||
),
|
||||
'features/statistics/ExternalService': lazy(() =>
|
||||
import('@features/statistics').then((m) => ({ default: m.ExternalService })),
|
||||
),
|
||||
// ── 순찰 ──
|
||||
'features/patrol/PatrolRoute': lazy(() =>
|
||||
import('@features/patrol').then((m) => ({ default: m.PatrolRoute })),
|
||||
),
|
||||
'features/patrol/FleetOptimization': lazy(() =>
|
||||
import('@features/patrol').then((m) => ({ default: m.FleetOptimization })),
|
||||
),
|
||||
// ── 현장작전 ──
|
||||
'features/field-ops/AIAlert': lazy(() =>
|
||||
import('@features/field-ops').then((m) => ({ default: m.AIAlert })),
|
||||
),
|
||||
'features/field-ops/MobileService': lazy(() =>
|
||||
import('@features/field-ops').then((m) => ({ default: m.MobileService })),
|
||||
),
|
||||
'features/field-ops/ShipAgent': lazy(() =>
|
||||
import('@features/field-ops').then((m) => ({ default: m.ShipAgent })),
|
||||
),
|
||||
// ── AI 운영 ──
|
||||
'features/ai-operations/AIModelManagement': lazy(() =>
|
||||
import('@features/ai-operations').then((m) => ({ default: m.AIModelManagement })),
|
||||
),
|
||||
'features/ai-operations/MLOpsPage': lazy(() =>
|
||||
import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })),
|
||||
),
|
||||
'features/ai-operations/LLMOpsPage': lazy(() =>
|
||||
import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })),
|
||||
),
|
||||
'features/ai-operations/AIAssistant': lazy(() =>
|
||||
import('@features/ai-operations').then((m) => ({ default: m.AIAssistant })),
|
||||
),
|
||||
// ── 관리 ──
|
||||
'features/admin/AdminPanel': lazy(() =>
|
||||
import('@features/admin').then((m) => ({ default: m.AdminPanel })),
|
||||
),
|
||||
'features/admin/SystemConfig': lazy(() =>
|
||||
import('@features/admin').then((m) => ({ default: m.SystemConfig })),
|
||||
),
|
||||
'features/admin/DataHub': lazy(() =>
|
||||
import('@features/admin').then((m) => ({ default: m.DataHub })),
|
||||
),
|
||||
'features/admin/AccessControl': lazy(() =>
|
||||
import('@features/admin').then((m) => ({ default: m.AccessControl })),
|
||||
),
|
||||
'features/admin/NoticeManagement': lazy(() =>
|
||||
import('@features/admin').then((m) => ({ default: m.NoticeManagement })),
|
||||
),
|
||||
'features/admin/AuditLogs': lazy(() =>
|
||||
import('@features/admin/AuditLogs').then((m) => ({ default: m.AuditLogs })),
|
||||
),
|
||||
'features/admin/AccessLogs': lazy(() =>
|
||||
import('@features/admin/AccessLogs').then((m) => ({ default: m.AccessLogs })),
|
||||
),
|
||||
'features/admin/LoginHistoryView': lazy(() =>
|
||||
import('@features/admin/LoginHistoryView').then((m) => ({
|
||||
default: m.LoginHistoryView,
|
||||
})),
|
||||
),
|
||||
// ── 모선 워크플로우 ──
|
||||
'features/parent-inference/ParentReview': lazy(() =>
|
||||
import('@features/parent-inference/ParentReview').then((m) => ({
|
||||
default: m.ParentReview,
|
||||
})),
|
||||
),
|
||||
'features/parent-inference/ParentExclusion': lazy(() =>
|
||||
import('@features/parent-inference/ParentExclusion').then((m) => ({
|
||||
default: m.ParentExclusion,
|
||||
})),
|
||||
),
|
||||
'features/parent-inference/LabelSession': lazy(() =>
|
||||
import('@features/parent-inference/LabelSession').then((m) => ({
|
||||
default: m.LabelSession,
|
||||
})),
|
||||
),
|
||||
// ── 선박 (숨김 라우트) ──
|
||||
'features/vessel/VesselDetail': lazy(() =>
|
||||
import('@features/vessel').then((m) => ({ default: m.VesselDetail })),
|
||||
),
|
||||
};
|
||||
54
frontend/src/app/iconRegistry.ts
Normal file
54
frontend/src/app/iconRegistry.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import {
|
||||
LayoutDashboard, Activity, Radar, Map, Layers, Shield,
|
||||
EyeOff, Anchor, Ship, FileText, List, BarChart3,
|
||||
Navigation, Users, Send, Smartphone, Monitor,
|
||||
GitBranch, CheckSquare, Ban, Tag, Settings,
|
||||
Brain, Cpu, MessageSquare, Database, Wifi, Globe,
|
||||
Fingerprint, Megaphone, ScrollText, History, KeyRound,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* DB icon 문자열 → Lucide React 컴포넌트 매핑.
|
||||
* 사이드바에서 사용하는 아이콘만 포함.
|
||||
*/
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
Radar,
|
||||
Map,
|
||||
Layers,
|
||||
Shield,
|
||||
EyeOff,
|
||||
Anchor,
|
||||
Ship,
|
||||
FileText,
|
||||
List,
|
||||
BarChart3,
|
||||
Navigation,
|
||||
Users,
|
||||
Send,
|
||||
Smartphone,
|
||||
Monitor,
|
||||
GitBranch,
|
||||
CheckSquare,
|
||||
Ban,
|
||||
Tag,
|
||||
Settings,
|
||||
Brain,
|
||||
Cpu,
|
||||
MessageSquare,
|
||||
Database,
|
||||
Wifi,
|
||||
Globe,
|
||||
Fingerprint,
|
||||
Megaphone,
|
||||
ScrollText,
|
||||
History,
|
||||
KeyRound,
|
||||
};
|
||||
|
||||
export function resolveIcon(name: string | null): LucideIcon | null {
|
||||
if (!name) return null;
|
||||
return ICON_MAP[name] ?? null;
|
||||
}
|
||||
@ -2,18 +2,16 @@ import { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Map, List, Ship, Anchor, Radar,
|
||||
FileText, Settings, LogOut, ChevronLeft, ChevronRight,
|
||||
Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers,
|
||||
Download, FileSpreadsheet, Printer, Wifi, Brain, Activity,
|
||||
Navigation, Users, EyeOff, BarChart3, Globe,
|
||||
Smartphone, Monitor, Send, Cpu, MessageSquare,
|
||||
GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound,
|
||||
LogOut, ChevronLeft, ChevronRight,
|
||||
Shield, Bell, Search, Clock, Lock,
|
||||
Download, FileSpreadsheet, Printer,
|
||||
} from 'lucide-react';
|
||||
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
import { getRoleColorHex } from '@shared/constants/userRoles';
|
||||
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useMenuStore, getMenuLabel, type MenuConfigItem } from '@stores/menuStore';
|
||||
import { resolveIcon } from '@/app/iconRegistry';
|
||||
|
||||
/*
|
||||
* SFR-01 반영 사항:
|
||||
@ -34,74 +32,6 @@ const AUTH_METHOD_LABELS: Record<string, string> = {
|
||||
sso: 'SSO',
|
||||
};
|
||||
|
||||
interface NavItem { to: string; icon: React.ElementType; labelKey: string; }
|
||||
interface NavGroup { groupKey: string; icon: React.ElementType; items: NavItem[]; }
|
||||
type NavEntry = NavItem | NavGroup;
|
||||
|
||||
const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry;
|
||||
|
||||
const NAV_ENTRIES: NavEntry[] = [
|
||||
// ── 상황판·감시 ──
|
||||
{ to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
|
||||
{ to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' },
|
||||
{ to: '/events', icon: Radar, labelKey: 'nav.realtimeEvent' },
|
||||
{ to: '/map-control', icon: Map, labelKey: 'nav.riskMap' },
|
||||
// ── 위험도·단속 ──
|
||||
{ to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' },
|
||||
{ to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' },
|
||||
// ── 탐지 ──
|
||||
{ to: '/dark-vessel', icon: EyeOff, labelKey: 'nav.darkVessel' },
|
||||
{ to: '/gear-detection', icon: Anchor, labelKey: 'nav.gearDetection' },
|
||||
{ to: '/china-fishing', icon: Ship, labelKey: 'nav.chinaFishing' },
|
||||
// ── 이력·통계 ──
|
||||
{ to: '/enforcement-history', icon: FileText, labelKey: 'nav.enforcementHistory' },
|
||||
{ to: '/event-list', icon: List, labelKey: 'nav.eventList' },
|
||||
{ to: '/statistics', icon: BarChart3, labelKey: 'nav.statistics' },
|
||||
{ to: '/reports', icon: FileText, labelKey: 'nav.reports' },
|
||||
// ── 함정용 (그룹) ──
|
||||
{
|
||||
groupKey: 'group.fieldOps', icon: Ship,
|
||||
items: [
|
||||
{ to: '/patrol-route', icon: Navigation, labelKey: 'nav.patrolRoute' },
|
||||
{ to: '/fleet-optimization', icon: Users, labelKey: 'nav.fleetOptimization' },
|
||||
{ to: '/ai-alert', icon: Send, labelKey: 'nav.aiAlert' },
|
||||
{ to: '/mobile-service', icon: Smartphone, labelKey: 'nav.mobileService' },
|
||||
{ to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' },
|
||||
],
|
||||
},
|
||||
// ── 모선 워크플로우 (운영자 의사결정, 그룹) ──
|
||||
{
|
||||
groupKey: 'group.parentInference', icon: GitBranch,
|
||||
items: [
|
||||
{ to: '/parent-inference/review', icon: CheckSquare, labelKey: 'nav.parentReview' },
|
||||
{ to: '/parent-inference/exclusion', icon: Ban, labelKey: 'nav.parentExclusion' },
|
||||
{ to: '/parent-inference/label-session', icon: Tag, labelKey: 'nav.labelSession' },
|
||||
],
|
||||
},
|
||||
// ── 관리자 (그룹) ──
|
||||
{
|
||||
groupKey: 'group.admin', icon: Settings,
|
||||
items: [
|
||||
{ to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' },
|
||||
{ to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' },
|
||||
{ to: '/llm-ops', icon: Brain, labelKey: 'nav.llmOps' },
|
||||
{ to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' },
|
||||
{ to: '/external-service', icon: Globe, labelKey: 'nav.externalService' },
|
||||
{ to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' },
|
||||
{ to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' },
|
||||
{ to: '/notices', icon: Megaphone, labelKey: 'nav.notices' },
|
||||
{ to: '/admin', icon: Settings, labelKey: 'nav.admin' },
|
||||
{ to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' },
|
||||
{ to: '/admin/audit-logs', icon: ScrollText, labelKey: 'nav.auditLogs' },
|
||||
{ to: '/admin/access-logs', icon: History, labelKey: 'nav.accessLogs' },
|
||||
{ to: '/admin/login-history', icon: KeyRound, labelKey: 'nav.loginHistory' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// getPageLabel용 flat 목록
|
||||
const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? e.items : [e]);
|
||||
|
||||
function formatRemaining(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
@ -116,11 +46,13 @@ export function MainLayout() {
|
||||
const location = useLocation();
|
||||
const { user, logout, hasAccess, sessionRemaining } = useAuth();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const { getTopLevelEntries, getChildren } = useMenuStore();
|
||||
|
||||
// getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n)
|
||||
// getPageLabel: DB 메뉴에서 현재 라우트 페이지명 (DB labels 기반)
|
||||
const getPageLabel = (pathname: string): string => {
|
||||
const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to));
|
||||
return item ? t(item.labelKey) : '';
|
||||
const allItems = useMenuStore.getState().items.filter((i) => i.menuType === 'ITEM' && i.urlPath);
|
||||
const item = allItems.find((n) => pathname.startsWith(n.urlPath!));
|
||||
return item ? getMenuLabel(item, language) : '';
|
||||
};
|
||||
|
||||
// 공통 검색
|
||||
@ -251,64 +183,31 @@ export function MainLayout() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */}
|
||||
{/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}
|
||||
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
|
||||
{NAV_ENTRIES.map((entry) => {
|
||||
if (isGroup(entry)) {
|
||||
// 그룹 내 RBAC 필터링
|
||||
const groupItems = entry.items.filter((item) => hasAccess(item.to));
|
||||
if (groupItems.length === 0) return null;
|
||||
const GroupIcon = entry.icon;
|
||||
const isAnyActive = groupItems.some((item) => location.pathname.startsWith(item.to));
|
||||
{getTopLevelEntries().map((entry) => {
|
||||
if (entry.menuType === 'GROUP') {
|
||||
return (
|
||||
<div key={entry.groupKey}>
|
||||
{/* 그룹 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleGroup(entry.groupKey)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
|
||||
isAnyActive || openGroups.has(entry.groupKey)
|
||||
? 'text-foreground bg-surface-overlay'
|
||||
: 'text-hint hover:bg-surface-overlay hover:text-label'
|
||||
}`}
|
||||
>
|
||||
<GroupIcon className="w-4 h-4 shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.groupKey)}</span>
|
||||
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${openGroups.has(entry.groupKey) || isAnyActive ? 'rotate-90' : ''}`} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* 그룹 하위 메뉴 */}
|
||||
{(openGroups.has(entry.groupKey) || isAnyActive) && (
|
||||
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
|
||||
{groupItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
|
||||
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="w-3.5 h-3.5 shrink-0" />
|
||||
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<GroupMenu
|
||||
key={entry.menuCd}
|
||||
group={entry}
|
||||
children={getChildren(entry.menuCd)}
|
||||
collapsed={collapsed}
|
||||
hasAccess={hasAccess}
|
||||
openGroups={openGroups}
|
||||
toggleGroup={toggleGroup}
|
||||
location={location}
|
||||
language={language}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// 일반 메뉴 아이템
|
||||
if (!hasAccess(entry.to)) return null;
|
||||
// 일반 ITEM
|
||||
if (!entry.urlPath || !hasAccess(entry.urlPath)) return null;
|
||||
const Icon = resolveIcon(entry.icon);
|
||||
return (
|
||||
<NavLink
|
||||
key={entry.to}
|
||||
to={entry.to}
|
||||
key={entry.menuCd}
|
||||
to={entry.urlPath}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${
|
||||
isActive
|
||||
@ -317,8 +216,8 @@ export function MainLayout() {
|
||||
}`
|
||||
}
|
||||
>
|
||||
<entry.icon className="w-4 h-4 shrink-0" />
|
||||
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.labelKey)}</span>}
|
||||
{Icon && <Icon className="w-4 h-4 shrink-0" />}
|
||||
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(entry, language)}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
@ -503,3 +402,88 @@ export function MainLayout() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 그룹 메뉴 서브 컴포넌트 (DB 기반) ─── */
|
||||
function GroupMenu({
|
||||
group,
|
||||
children,
|
||||
collapsed,
|
||||
hasAccess,
|
||||
openGroups,
|
||||
toggleGroup,
|
||||
location,
|
||||
language,
|
||||
}: {
|
||||
group: MenuConfigItem;
|
||||
children: MenuConfigItem[];
|
||||
collapsed: boolean;
|
||||
hasAccess: (path: string) => boolean;
|
||||
openGroups: Set<string>;
|
||||
toggleGroup: (name: string) => void;
|
||||
location: { pathname: string };
|
||||
language: string;
|
||||
}) {
|
||||
const navItems = children.filter((c) => c.menuType === 'ITEM' && c.urlPath);
|
||||
const accessibleItems = navItems.filter((c) => hasAccess(c.urlPath!));
|
||||
if (accessibleItems.length === 0) return null;
|
||||
|
||||
const GroupIcon = resolveIcon(group.icon);
|
||||
const isAnyActive = accessibleItems.some((c) => location.pathname.startsWith(c.urlPath!));
|
||||
const isOpen = openGroups.has(group.menuCd) || isAnyActive;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.menuCd)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
|
||||
isOpen ? 'text-foreground bg-surface-overlay' : 'text-hint hover:bg-surface-overlay hover:text-label'
|
||||
}`}
|
||||
>
|
||||
{GroupIcon && <GroupIcon className="w-4 h-4 shrink-0" />}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{getMenuLabel(group, language)}
|
||||
</span>
|
||||
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
|
||||
{children.map((child) => {
|
||||
if (child.menuType === 'DIVIDER') {
|
||||
if (collapsed) return null;
|
||||
return (
|
||||
<div key={child.menuCd} className="pt-2 pb-0.5 px-2.5">
|
||||
<span className="text-[8px] font-bold text-hint uppercase tracking-wider">{child.dividerLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!child.urlPath || !hasAccess(child.urlPath)) return null;
|
||||
const ChildIcon = resolveIcon(child.icon);
|
||||
return (
|
||||
<NavLink
|
||||
key={child.menuCd}
|
||||
to={child.urlPath}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
|
||||
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{ChildIcon && <ChildIcon className="w-3.5 h-3.5 shrink-0" />}
|
||||
{!collapsed && (
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(child, language)}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Fragment, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown,
|
||||
ExternalLink, Layers,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
@ -14,6 +15,7 @@ import {
|
||||
type Operation, type TreeNode, type PermRow,
|
||||
} from '@/lib/permission/permResolver';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
|
||||
import { ColorPicker } from '@shared/components/common/ColorPicker';
|
||||
import { updateRole as apiUpdateRole } from '@/services/adminApi';
|
||||
@ -100,7 +102,7 @@ export function PermissionsPanel() {
|
||||
setDraftPerms(m);
|
||||
}, [selectedRole]);
|
||||
|
||||
// 트리 → 트리 인덱싱 (parent → children)
|
||||
// 트리 → 트리 인덱싱 (parent → children), nav_sort 기반 정렬 (메뉴 순서 일치)
|
||||
const childrenMap = useMemo(() => {
|
||||
const m = new Map<string | null, PermTreeNode[]>();
|
||||
for (const n of tree) {
|
||||
@ -109,6 +111,14 @@ export function PermissionsPanel() {
|
||||
arr.push(n);
|
||||
m.set(n.parentCd, arr);
|
||||
}
|
||||
// nav_sort > 0 우선 (메뉴 표시 항목), 그 다음 sort_ord — 좌측 메뉴 순서와 일치
|
||||
for (const [, arr] of m.entries()) {
|
||||
arr.sort((a, b) => {
|
||||
const aSort = (a.navSort > 0) ? a.navSort : 10000 + a.sortOrd;
|
||||
const bSort = (b.navSort > 0) ? b.navSort : 10000 + b.sortOrd;
|
||||
return aSort - bSort;
|
||||
});
|
||||
}
|
||||
return m;
|
||||
}, [tree]);
|
||||
|
||||
@ -280,9 +290,13 @@ export function PermissionsPanel() {
|
||||
const hasChildren = children.length > 0;
|
||||
const isExpanded = expanded.has(node.rsrcCd);
|
||||
|
||||
// DB labels JSONB에서 현재 언어 라벨 사용, 없으면 rsrcNm 폴백
|
||||
const lang = useSettingsStore.getState().language;
|
||||
const displayName = node.labels?.[lang] || node.labels?.ko || node.rsrcNm;
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr key={node.rsrcCd} className="border-t border-border hover:bg-surface-overlay/30">
|
||||
<Fragment key={node.rsrcCd}>
|
||||
<tr className="border-t border-border hover:bg-surface-overlay/30">
|
||||
<td className="py-1.5 pl-2" style={{ paddingLeft: 8 + depth * 20 }}>
|
||||
<div className="flex items-center gap-1">
|
||||
{hasChildren ? (
|
||||
@ -291,8 +305,14 @@ export function PermissionsPanel() {
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
) : <span className="w-4" />}
|
||||
<span className="text-[11px] text-heading font-medium">{node.rsrcNm}</span>
|
||||
{/* 페이지/패널 구분 아이콘 */}
|
||||
{node.urlPath
|
||||
? <span title="별도 페이지"><ExternalLink className="w-3 h-3 text-cyan-500/60 shrink-0" /></span>
|
||||
: depth > 0 ? <span title="페이지 내 패널"><Layers className="w-3 h-3 text-amber-500/50 shrink-0" /></span> : null
|
||||
}
|
||||
<span className="text-[11px] text-heading font-medium">{displayName}</span>
|
||||
<span className="text-[9px] text-hint font-mono">({node.rsrcCd})</span>
|
||||
{node.urlPath && <span className="text-[8px] text-cyan-500/70 font-mono">{node.urlPath}</span>}
|
||||
</div>
|
||||
</td>
|
||||
{OPERATIONS.map((op) => {
|
||||
@ -324,7 +344,7 @@ export function PermissionsPanel() {
|
||||
})}
|
||||
</tr>
|
||||
{isExpanded && children.map((c) => renderTreeRow(c, depth + 1))}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,45 +1,35 @@
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react';
|
||||
import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react';
|
||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import type { MarkerData } from '@lib/map';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
filterDarkVessels,
|
||||
type VesselAnalysisItem,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns';
|
||||
import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses';
|
||||
import { getRiskIntent } from '@shared/constants/statusIntent';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
||||
/* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */
|
||||
|
||||
interface Suspect { id: string; mmsi: string; name: string; flag: string; pattern: string; risk: number; lastAIS: string; status: string; label: string; lat: number; lng: number; [key: string]: unknown; }
|
||||
|
||||
const GAP_FULL_BLOCK_MIN = 1440;
|
||||
const GAP_LONG_LOSS_MIN = 60;
|
||||
const SPOOFING_THRESHOLD = 0.7;
|
||||
|
||||
function derivePattern(item: VesselAnalysisItem): string {
|
||||
const { gapDurationMin } = item.algorithms.darkVessel;
|
||||
const { spoofingScore } = item.algorithms.gpsSpoofing;
|
||||
if (gapDurationMin > GAP_FULL_BLOCK_MIN) return 'AIS 완전차단';
|
||||
if (spoofingScore > SPOOFING_THRESHOLD) return 'MMSI 변조 의심';
|
||||
if (gapDurationMin > GAP_LONG_LOSS_MIN) return '장기소실';
|
||||
return '신호 간헐송출';
|
||||
}
|
||||
|
||||
function deriveStatus(item: VesselAnalysisItem): string {
|
||||
const { score } = item.algorithms.riskScore;
|
||||
if (score >= 80) return '추적중';
|
||||
if (score >= 50) return '감시중';
|
||||
if (score >= 30) return '확인중';
|
||||
return '정상';
|
||||
interface Suspect {
|
||||
id: string;
|
||||
mmsi: string;
|
||||
name: string;
|
||||
flag: string;
|
||||
darkTier: string;
|
||||
darkScore: number;
|
||||
darkPatterns: string;
|
||||
risk: number;
|
||||
gap: number;
|
||||
lastAIS: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function deriveFlag(mmsi: string): string {
|
||||
@ -48,21 +38,32 @@ function deriveFlag(mmsi: string): string {
|
||||
return '미상';
|
||||
}
|
||||
|
||||
function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
|
||||
const risk = item.algorithms.riskScore.score;
|
||||
const status = deriveStatus(item);
|
||||
const TIER_HEX: Record<string, string> = {
|
||||
CRITICAL: '#ef4444',
|
||||
HIGH: '#f97316',
|
||||
WATCH: '#eab308',
|
||||
NONE: '#6b7280',
|
||||
};
|
||||
|
||||
function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
|
||||
const feat = v.features ?? {};
|
||||
const darkTier = (feat.dark_tier as string) ?? 'NONE';
|
||||
const darkScore = (feat.dark_suspicion_score as number) ?? 0;
|
||||
const patterns = (feat.dark_patterns as string[]) ?? [];
|
||||
|
||||
return {
|
||||
id: `DV-${String(idx + 1).padStart(3, '0')}`,
|
||||
mmsi: item.mmsi,
|
||||
name: item.classification.vesselType || item.mmsi,
|
||||
flag: deriveFlag(item.mmsi),
|
||||
pattern: derivePattern(item),
|
||||
risk,
|
||||
lastAIS: formatDateTime(item.timestamp),
|
||||
status,
|
||||
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-',
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
mmsi: v.mmsi,
|
||||
name: v.vesselType || v.mmsi,
|
||||
flag: deriveFlag(v.mmsi),
|
||||
darkTier,
|
||||
darkScore,
|
||||
darkPatterns: patterns.join(', ') || '-',
|
||||
risk: v.riskScore ?? 0,
|
||||
gap: v.gapDurationMin ?? 0,
|
||||
lastAIS: formatDateTime(v.analyzedAt),
|
||||
lat: v.lat ?? 0,
|
||||
lng: v.lon ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -70,25 +71,53 @@ export function DarkVesselDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [tierFilter, setTierFilter] = useState<string>('');
|
||||
|
||||
const cols: DataColumn<Suspect>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true,
|
||||
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'id', label: 'ID', width: '70px',
|
||||
render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
|
||||
render: (v) => {
|
||||
const tier = v as string;
|
||||
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
|
||||
} },
|
||||
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
|
||||
render: (v) => {
|
||||
const n = v as number;
|
||||
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>;
|
||||
} },
|
||||
{ key: 'name', label: '선박 유형', sortable: true,
|
||||
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px',
|
||||
render: (v) => {
|
||||
const mmsi = v as string;
|
||||
return (
|
||||
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
|
||||
{mmsi}
|
||||
</button>
|
||||
);
|
||||
} },
|
||||
{ key: 'flag', label: '국적', width: '50px' },
|
||||
{ key: 'gap', label: 'AIS 공백', width: '80px', align: 'right', sortable: true,
|
||||
render: (v) => {
|
||||
const min = v as number;
|
||||
return <span className="text-label font-mono text-[10px]">{min > 0 ? `${min}분` : '-'}</span>;
|
||||
} },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
|
||||
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => <Badge intent={getVesselSurveillanceIntent(v as string)} size="sm">{getVesselSurveillanceLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'label', label: '라벨', width: '60px', align: 'center',
|
||||
render: v => { const l = v as string; return l === '-' ? <button type="button" className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> 분류</button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } },
|
||||
], [tc, lang]);
|
||||
render: (v) => {
|
||||
const n = v as number;
|
||||
return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>;
|
||||
} },
|
||||
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
|
||||
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
|
||||
{ key: 'lastAIS', label: '분석시각', width: '90px',
|
||||
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
], [tc, lang, navigate]);
|
||||
|
||||
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [rawData, setRawData] = useState<VesselAnalysis[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@ -96,12 +125,10 @@ export function DarkVesselDetection() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetchVesselAnalysis();
|
||||
setServiceAvailable(res.serviceAvailable);
|
||||
setDarkItems(filterDarkVessels(res.items));
|
||||
const res = await getDarkVessels({ hours: 1, size: 500 });
|
||||
setRawData(res.content);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
||||
setServiceAvailable(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -109,15 +136,36 @@ export function DarkVesselDetection() {
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const DATA: Suspect[] = useMemo(
|
||||
() => darkItems.map((item, i) => mapItemToSuspect(item, i)),
|
||||
[darkItems],
|
||||
);
|
||||
// 30초 자동 갱신 (깜박임 없음 — loading 상태 변경 없이 데이터만 교체)
|
||||
useEffect(() => {
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const res = await getDarkVessels({ hours: 1, size: 500 });
|
||||
setRawData(res.content);
|
||||
} catch { /* silent */ }
|
||||
}, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const avgRisk = useMemo(
|
||||
() => DATA.length > 0 ? Math.round(DATA.reduce((s, d) => s + d.risk, 0) / DATA.length) : 0,
|
||||
[DATA],
|
||||
);
|
||||
const DATA: Suspect[] = useMemo(() => {
|
||||
let items = rawData.map((v, i) => mapToSuspect(v, i));
|
||||
if (tierFilter) {
|
||||
items = items.filter((d) => d.darkTier === tierFilter);
|
||||
}
|
||||
// 의심 점수 내림차순 정렬
|
||||
return items.sort((a, b) => b.darkScore - a.darkScore);
|
||||
}, [rawData, tierFilter]);
|
||||
|
||||
// KPI 카운트
|
||||
const tierCounts = useMemo(() => {
|
||||
const all = rawData.map((v) => ((v.features ?? {}).dark_tier as string) ?? 'NONE');
|
||||
return {
|
||||
total: all.length,
|
||||
CRITICAL: all.filter((t) => t === 'CRITICAL').length,
|
||||
HIGH: all.filter((t) => t === 'HIGH').length,
|
||||
WATCH: all.filter((t) => t === 'WATCH').length,
|
||||
};
|
||||
}, [rawData]);
|
||||
|
||||
const mapRef = useRef<MapHandle>(null);
|
||||
|
||||
@ -125,21 +173,18 @@ export function DarkVesselDetection() {
|
||||
...STATIC_LAYERS,
|
||||
createRadiusLayer(
|
||||
'dv-radius',
|
||||
DATA.filter(d => d.risk > 80).map(d => ({
|
||||
lat: d.lat,
|
||||
lng: d.lng,
|
||||
radius: 10000,
|
||||
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444',
|
||||
DATA.filter((d) => d.darkScore >= 70).map((d) => ({
|
||||
lat: d.lat, lng: d.lng, radius: 10000,
|
||||
color: TIER_HEX[d.darkTier] || '#ef4444',
|
||||
})),
|
||||
0.08,
|
||||
),
|
||||
createMarkerLayer(
|
||||
'dv-markers',
|
||||
DATA.map(d => ({
|
||||
lat: d.lat,
|
||||
lng: d.lng,
|
||||
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444',
|
||||
radius: d.risk > 80 ? 1200 : 800,
|
||||
DATA.filter((d) => d.lat !== 0).map((d) => ({
|
||||
lat: d.lat, lng: d.lng,
|
||||
color: TIER_HEX[d.darkTier] || '#6b7280',
|
||||
radius: d.darkScore >= 70 ? 1200 : 800,
|
||||
label: `${d.id} ${d.name}`,
|
||||
} as MarkerData)),
|
||||
),
|
||||
@ -154,15 +199,20 @@ export function DarkVesselDetection() {
|
||||
iconColor="text-red-400"
|
||||
title={t('darkVessel.title')}
|
||||
description={t('darkVessel.desc')}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
<Select size="sm" value={tierFilter} onChange={(e) => setTierFilter(e.target.value)}
|
||||
title="등급 필터" className="w-32">
|
||||
<option value="">전체 등급</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="WATCH">WATCH</option>
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{!serviceAvailable && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>iran 분석 서비스 미연결 - 실시간 Dark Vessel 데이터를 불러올 수 없습니다</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{loading && (
|
||||
@ -171,49 +221,51 @@ export function DarkVesselDetection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI — tier 기반 */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ l: 'Dark Vessel', v: DATA.length, c: 'text-red-400', i: AlertTriangle },
|
||||
{ l: 'AIS 완전차단', v: DATA.filter(d => d.pattern === 'AIS 완전차단').length, c: 'text-orange-400', i: EyeOff },
|
||||
{ l: 'MMSI 변조', v: DATA.filter(d => d.pattern === 'MMSI 변조 의심').length, c: 'text-yellow-400', i: Radio },
|
||||
{ l: `평균 위험도`, v: avgRisk, c: 'text-cyan-400', i: Tag },
|
||||
].map(k => (
|
||||
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||
{ l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' },
|
||||
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' },
|
||||
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' },
|
||||
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' },
|
||||
].map((k) => (
|
||||
<div key={k.l}
|
||||
onClick={() => setTierFilter(k.filter)}
|
||||
className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border cursor-pointer transition-colors ${
|
||||
tierFilter === k.filter ? 'bg-card border-blue-500/30' : 'bg-card border-border hover:border-border'
|
||||
}`}>
|
||||
<AlertTriangle className={`w-4 h-4 ${k.c}`} />
|
||||
<span className={`text-base font-bold ${k.c}`}>{k.v}</span>
|
||||
<span className="text-[9px] text-hint">{k.l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박유형, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" />
|
||||
<DataTable data={DATA} columns={cols} pageSize={10}
|
||||
searchPlaceholder="선박유형, MMSI, 패턴 검색..."
|
||||
searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']}
|
||||
exportFilename="Dark_Vessel_탐지" />
|
||||
|
||||
{/* 탐지 위치 지도 */}
|
||||
<Card>
|
||||
<CardContent className="p-0 relative">
|
||||
<BaseMap ref={mapRef} center={[36.5, 127.5]} zoom={7} height={450} className="rounded-lg overflow-hidden" />
|
||||
{/* 범례 */}
|
||||
{/* 범례 — tier 기반 */}
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">탐지 패턴</div>
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">Dark Tier</div>
|
||||
<div className="space-y-1">
|
||||
{(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => {
|
||||
const meta = getDarkVesselPatternMeta(p);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={p} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: meta.hex }} />
|
||||
<span className="text-[8px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
|
||||
{(['CRITICAL', 'HIGH', 'WATCH', 'NONE'] as const).map((tier) => (
|
||||
<div key={tier} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: TIER_HEX[tier] }} />
|
||||
<span className="text-[8px] text-muted-foreground">{tier}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[10px] text-red-400 font-bold">{DATA.filter(d => d.risk > 80).length}척</span>
|
||||
<span className="text-[9px] text-hint">고위험 Dark Vessel 탐지</span>
|
||||
<span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}척</span>
|
||||
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
@ -18,6 +19,8 @@ interface Record {
|
||||
date: string;
|
||||
zone: string;
|
||||
vessel: string;
|
||||
mmsi: string;
|
||||
eventId: number | null;
|
||||
violation: string;
|
||||
action: string;
|
||||
aiMatch: string;
|
||||
@ -29,7 +32,8 @@ export function EnforcementHistory() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const { records, loading, error, load } = useEnforcementStore();
|
||||
const navigate = useNavigate();
|
||||
const { records, rawRecords, loading, error, load } = useEnforcementStore();
|
||||
|
||||
const cols: DataColumn<Record>[] = useMemo(() => [
|
||||
{
|
||||
@ -55,9 +59,37 @@ export function EnforcementHistory() {
|
||||
key: 'vessel',
|
||||
label: '대상 선박',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-cyan-400 font-medium">{v as string}</span>
|
||||
),
|
||||
render: (_v, row) => {
|
||||
const mmsi = row.mmsi;
|
||||
const vessel = row.vessel as string;
|
||||
if (mmsi && mmsi !== '-') {
|
||||
return (
|
||||
<button type="button"
|
||||
className="text-cyan-400 hover:text-cyan-300 hover:underline font-medium"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
|
||||
{vessel}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <span className="text-cyan-400 font-medium">{vessel}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'eventId',
|
||||
label: '이벤트',
|
||||
width: '70px',
|
||||
render: (_v, row) => {
|
||||
const eid = row.eventId;
|
||||
if (!eid) return <span className="text-hint">-</span>;
|
||||
return (
|
||||
<button type="button"
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-[10px]"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/events?id=${eid}`); }}
|
||||
title={`이벤트 #${eid}`}>
|
||||
#{eid}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'violation',
|
||||
@ -119,7 +151,11 @@ export function EnforcementHistory() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const DATA: Record[] = records as Record[];
|
||||
const DATA: Record[] = records.map((r, idx) => ({
|
||||
...r,
|
||||
mmsi: rawRecords[idx]?.vesselMmsi ?? '-',
|
||||
eventId: rawRecords[idx]?.eventId ?? null,
|
||||
})) as Record[];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
@ -9,8 +10,11 @@ import { FileUpload } from '@shared/components/common/FileUpload';
|
||||
import {
|
||||
AlertTriangle, Eye, Anchor, Radar, Crosshair,
|
||||
Filter, Upload, X, Loader2,
|
||||
CheckCircle, Ship, Shield, Ban,
|
||||
} from 'lucide-react';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { ackEvent, updateEventStatus } from '@/services/event';
|
||||
import { createEnforcementRecord } from '@/services/enforcement';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses';
|
||||
@ -27,6 +31,7 @@ type AlertLevel = AlertLevelType;
|
||||
|
||||
interface EventRow {
|
||||
id: string;
|
||||
_eventId: number;
|
||||
time: string;
|
||||
level: AlertLevel;
|
||||
type: string;
|
||||
@ -45,14 +50,56 @@ export function EventList() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
events: storeEvents,
|
||||
rawEvents,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
silentRefresh,
|
||||
loadStats,
|
||||
} = useEventStore();
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
|
||||
const handleAck = useCallback(async (eventId: number) => {
|
||||
setActionLoading(eventId);
|
||||
try {
|
||||
await ackEvent(eventId);
|
||||
load({ level: '' });
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const handleFalsePositive = useCallback(async (eventId: number) => {
|
||||
setActionLoading(eventId);
|
||||
try {
|
||||
await updateEventStatus(eventId, 'FALSE_POSITIVE', '오탐 처리');
|
||||
load({ level: '' });
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const handleCreateEnforcement = useCallback(async (row: EventRow) => {
|
||||
setActionLoading(row._eventId);
|
||||
try {
|
||||
await createEnforcementRecord({
|
||||
eventId: row._eventId,
|
||||
enforcedAt: new Date().toISOString(),
|
||||
vesselMmsi: row.mmsi !== '-' ? row.mmsi : undefined,
|
||||
vesselName: row.vesselName !== '-' ? row.vesselName : undefined,
|
||||
zoneCode: row.area !== '-' ? row.area : undefined,
|
||||
violationType: row.type,
|
||||
action: 'PATROL_DISPATCH',
|
||||
});
|
||||
load({ level: '' });
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const columns: DataColumn<EventRow>[] = useMemo(() => [
|
||||
{
|
||||
@ -83,12 +130,24 @@ export function EventList() {
|
||||
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
|
||||
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
|
||||
render: (_val, row) => {
|
||||
const mmsi = row.mmsi;
|
||||
if (!mmsi || mmsi === '-') return <span className="text-hint">-</span>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}
|
||||
>
|
||||
{mmsi}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true },
|
||||
{ key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' },
|
||||
{
|
||||
key: 'status', label: '처리상태', minWidth: '80px', maxWidth: '120px', sortable: true,
|
||||
key: 'status', label: '처리상태', minWidth: '70px', maxWidth: '100px', sortable: true,
|
||||
render: (val) => {
|
||||
const s = val as string;
|
||||
return (
|
||||
@ -98,8 +157,46 @@ export function EventList() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' },
|
||||
], [tc, lang]);
|
||||
{
|
||||
key: '_eventId', label: '액션', minWidth: '120px', maxWidth: '180px',
|
||||
render: (_val, row) => {
|
||||
const eid = row._eventId;
|
||||
const isNew = row.status === 'NEW';
|
||||
const isActionable = row.status !== 'RESOLVED' && row.status !== 'FALSE_POSITIVE';
|
||||
const busy = actionLoading === eid;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{isNew && (
|
||||
<button type="button" aria-label="확인" title="확인(ACK)"
|
||||
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30"
|
||||
disabled={busy} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" aria-label="선박 상세" title="선박 상세"
|
||||
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400"
|
||||
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
|
||||
<Ship className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{isActionable && (
|
||||
<>
|
||||
<button type="button" aria-label="단속 등록" title="단속 등록"
|
||||
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30"
|
||||
disabled={busy} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button type="button" aria-label="오탐 처리" title="오탐 처리"
|
||||
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30"
|
||||
disabled={busy} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
|
||||
<Ban className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate]);
|
||||
|
||||
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
@ -114,9 +211,20 @@ export function EventList() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// store events -> EventRow 변환
|
||||
const EVENTS: EventRow[] = storeEvents.map((e) => ({
|
||||
// 30초 자동 갱신 (깜박임 없음 — silentRefresh 사용)
|
||||
useEffect(() => {
|
||||
const params = levelFilter ? { level: levelFilter } : undefined;
|
||||
const timer = setInterval(() => {
|
||||
silentRefresh(params);
|
||||
loadStats();
|
||||
}, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [levelFilter, silentRefresh, loadStats]);
|
||||
|
||||
// store events -> EventRow 변환 (rawEvents에서 numeric id 참조)
|
||||
const EVENTS: EventRow[] = storeEvents.map((e, idx) => ({
|
||||
id: e.id,
|
||||
_eventId: rawEvents[idx]?.id ?? 0,
|
||||
time: e.time,
|
||||
level: e.level as AlertLevel,
|
||||
type: e.type,
|
||||
|
||||
@ -9,6 +9,8 @@ import { useAuth } from '@/app/auth/AuthContext';
|
||||
import {
|
||||
fetchReviewList,
|
||||
reviewParent,
|
||||
createLabelSession,
|
||||
excludeForGroup,
|
||||
type ParentResolution,
|
||||
} from '@/services/parentInferenceApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
@ -70,6 +72,28 @@ export function ParentReview() {
|
||||
selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined,
|
||||
comment: `${action} via UI`,
|
||||
});
|
||||
|
||||
// CONFIRM → LabelSession 자동 생성 (학습 데이터 수집 시작)
|
||||
if (action === 'CONFIRM') {
|
||||
const mmsi = selectedMmsi || item.selectedParentMmsi;
|
||||
if (mmsi) {
|
||||
await createLabelSession(item.groupKey, item.subClusterId, {
|
||||
labelParentMmsi: mmsi,
|
||||
}).catch(() => { /* LabelSession 실패는 무시 — 리뷰 자체는 성공 */ });
|
||||
}
|
||||
}
|
||||
|
||||
// REJECT → Exclusion 자동 등록 (잘못된 후보 재추론 방지)
|
||||
if (action === 'REJECT') {
|
||||
const mmsi = item.selectedParentMmsi;
|
||||
if (mmsi) {
|
||||
await excludeForGroup(item.groupKey, item.subClusterId, {
|
||||
excludedMmsi: mmsi,
|
||||
reason: '운영자 거부',
|
||||
}).catch(() => { /* Exclusion 실패는 무시 */ });
|
||||
}
|
||||
}
|
||||
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'unknown';
|
||||
|
||||
@ -6,10 +6,14 @@ import { Button } from '@shared/components/ui/button';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent';
|
||||
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users } from 'lucide-react';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users, Loader2 } from 'lucide-react';
|
||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import type { MarkerData } from '@lib/map';
|
||||
import { getEnforcementPlans, type EnforcementPlan as EnforcementPlanApi } from '@/services/enforcement';
|
||||
import { getEvents, type PredictionEvent } from '@/services/event';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-06: 단속 계획·경보 연계(단속 우선지역 예보) */
|
||||
|
||||
@ -47,18 +51,25 @@ const cols: DataColumn<Plan>[] = [
|
||||
|
||||
export function EnforcementPlan() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [criticalEvents, setCriticalEvents] = useState<PredictionEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getEnforcementPlans({ size: 100 })
|
||||
.then((res) => {
|
||||
Promise.all([
|
||||
getEnforcementPlans({ size: 100 }),
|
||||
getEvents({ level: 'CRITICAL', status: 'NEW', size: 20 }).catch(() => null),
|
||||
])
|
||||
.then(([planRes, evtRes]) => {
|
||||
if (!cancelled) {
|
||||
setPlans(res.content.map(toPlan));
|
||||
setPlans(planRes.content.map(toPlan));
|
||||
setCriticalEvents(evtRes?.content ?? []);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
@ -154,6 +165,31 @@ export function EnforcementPlan() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 미배정 CRITICAL 이벤트 */}
|
||||
{criticalEvents.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-[12px] font-bold text-heading">미배정 CRITICAL 이벤트</span>
|
||||
<Badge intent="critical" size="xs">{criticalEvents.length}건</Badge>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{criticalEvents.map((evt) => (
|
||||
<div key={evt.id} className="flex items-center gap-2 px-3 py-2 bg-red-500/5 border border-red-500/20 rounded-lg">
|
||||
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
||||
{getAlertLevelLabel(evt.level, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
||||
<span className="text-[9px] text-hint shrink-0">{formatDateTime(evt.occurredAt)}</span>
|
||||
<span className="text-[9px] text-cyan-400 font-mono shrink-0">{evt.vesselMmsi ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[12px] font-bold text-heading mb-3">경보 임계값 설정</div>
|
||||
|
||||
@ -6,16 +6,15 @@ import {
|
||||
Search,
|
||||
Ship, AlertTriangle, Radar, MapPin, Printer,
|
||||
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain,
|
||||
Loader2, WifiOff, ShieldAlert,
|
||||
Loader2, ShieldAlert, Shield, EyeOff, FileText,
|
||||
} from 'lucide-react';
|
||||
import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
type VesselAnalysisItem,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getEvents, type PredictionEvent } from '@/services/event';
|
||||
import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
|
||||
import { getEnforcementRecords, type EnforcementRecord } from '@/services/enforcement';
|
||||
import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getRiskIntent } from '@shared/constants/statusIntent';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@ -57,27 +56,66 @@ const RIGHT_TOOLS = [
|
||||
{ icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' },
|
||||
];
|
||||
|
||||
// ─── 24h AIS 수신 막대 ───────────────
|
||||
function AisTimeline({ history }: { history: VesselAnalysis[] }) {
|
||||
// 최근 24시간을 1시간 단위 슬롯으로 분할
|
||||
const now = Date.now();
|
||||
const slots = Array.from({ length: 24 }, (_, i) => {
|
||||
const slotStart = now - (24 - i) * 3600_000;
|
||||
const slotEnd = slotStart + 3600_000;
|
||||
const hasData = history.some((h) => {
|
||||
const t = new Date(h.analyzedAt).getTime();
|
||||
return t >= slotStart && t < slotEnd;
|
||||
});
|
||||
return { hour: new Date(slotStart).getHours(), hasData };
|
||||
});
|
||||
const received = slots.filter((s) => s.hasData).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-hint">24h AIS 수신 이력</span>
|
||||
<span className="text-[10px] text-label font-mono">{received}/24h ({Math.round(received / 24 * 100)}%)</span>
|
||||
</div>
|
||||
<div className="flex gap-px h-3">
|
||||
{slots.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-sm ${s.hasData ? 'bg-green-500' : 'bg-red-500/40'}`}
|
||||
title={`${String(s.hour).padStart(2, '0')}시 — ${s.hasData ? '수신' : '소실'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-0.5">
|
||||
<span className="text-[7px] text-hint">-24h</span>
|
||||
<span className="text-[7px] text-hint">현재</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────
|
||||
|
||||
export function VesselDetail() {
|
||||
const { id: mmsiParam } = useParams<{ id: string }>();
|
||||
|
||||
// 데이터 상태
|
||||
const [vessel, setVessel] = useState<VesselAnalysisItem | null>(null);
|
||||
const [analysis, setAnalysis] = useState<VesselAnalysis | null>(null);
|
||||
const [history, setHistory] = useState<VesselAnalysis[]>([]);
|
||||
const [permit, setPermit] = useState<VesselPermitData | null>(null);
|
||||
const [events, setEvents] = useState<PredictionEvent[]>([]);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [enforcements, setEnforcements] = useState<EnforcementRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 검색 상태 (검색 패널용)
|
||||
// 검색 상태
|
||||
const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? '');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
const mapRef = useRef<MapHandle>(null);
|
||||
|
||||
// 데이터 로드
|
||||
// 데이터 로드 — prediction 직접 API
|
||||
useEffect(() => {
|
||||
if (!mmsiParam) {
|
||||
setLoading(false);
|
||||
@ -92,27 +130,21 @@ export function VesselDetail() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [analysisRes, permitRes, eventsRes] = await Promise.all([
|
||||
fetchVesselAnalysis().catch(() => null),
|
||||
const [analysisRes, historyRes, permitRes, eventsRes, enfRes] = await Promise.all([
|
||||
getAnalysisLatest(mmsiParam).catch(() => null),
|
||||
getAnalysisHistory(mmsiParam, 24).catch(() => []),
|
||||
fetchVesselPermit(mmsiParam),
|
||||
getEvents({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null),
|
||||
getEnforcementRecords({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!analysisRes) {
|
||||
setServiceAvailable(false);
|
||||
setPermit(permitRes);
|
||||
setEvents(eventsRes?.content ?? []);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setServiceAvailable(analysisRes.serviceAvailable);
|
||||
const found = analysisRes.items.find((item) => item.mmsi === mmsiParam) ?? null;
|
||||
setVessel(found);
|
||||
setAnalysis(analysisRes);
|
||||
setHistory(historyRes);
|
||||
setPermit(permitRes);
|
||||
setEvents(eventsRes?.content ?? []);
|
||||
setEnforcements(enfRes?.content ?? []);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : '데이터 로드 실패');
|
||||
@ -127,24 +159,17 @@ export function VesselDetail() {
|
||||
}, [mmsiParam]);
|
||||
|
||||
// 지도 레이어
|
||||
const buildLayers = useCallback(() => {
|
||||
const layers = [
|
||||
...STATIC_LAYERS,
|
||||
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({
|
||||
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
|
||||
})), 80000, 0.05),
|
||||
...DEPTH_CONTOURS.map((contour, i) =>
|
||||
createPolylineLayer(`depth-${i}`, contour.points as [number, number][], {
|
||||
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
// 선박 위치가 없으므로 분석 데이터의 zone 기반으로 대략적 위치 표시는 불가
|
||||
// vessel-analysis에는 좌표가 없으므로 마커 생략
|
||||
|
||||
return layers;
|
||||
}, []);
|
||||
const buildLayers = useCallback(() => [
|
||||
...STATIC_LAYERS,
|
||||
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({
|
||||
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
|
||||
})), 80000, 0.05),
|
||||
...DEPTH_CONTOURS.map((contour, i) =>
|
||||
createPolylineLayer(`depth-${i}`, contour.points as [number, number][], {
|
||||
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
|
||||
})
|
||||
),
|
||||
], []);
|
||||
|
||||
useMapLayers(mapRef, buildLayers, []);
|
||||
|
||||
@ -152,11 +177,20 @@ export function VesselDetail() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
// 위험도 점수 바
|
||||
const riskScore = vessel?.algorithms.riskScore.score ?? 0;
|
||||
const riskLevel = (vessel?.algorithms.riskScore.level ?? 'LOW') as AlertLevel;
|
||||
// 위험도
|
||||
const riskScore = analysis?.riskScore ?? 0;
|
||||
const riskLevel = (analysis?.riskLevel ?? 'LOW') as AlertLevel;
|
||||
const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW;
|
||||
|
||||
// features 추출
|
||||
const features = analysis?.features ?? {};
|
||||
const darkTier = features.dark_tier as string | undefined;
|
||||
const darkScore = features.dark_suspicion_score as number | undefined;
|
||||
const darkPatterns = features.dark_patterns as string[] | undefined;
|
||||
const darkHistory7d = features.dark_history_7d as number | undefined;
|
||||
const transshipTier = features.transship_tier as string | undefined;
|
||||
const transshipScore = features.transship_score as number | undefined;
|
||||
|
||||
return (
|
||||
<PageContainer fullBleed className="flex h-[calc(100vh-7.5rem)] gap-0">
|
||||
|
||||
@ -201,16 +235,6 @@ export function VesselDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!serviceAvailable && !loading && !error && (
|
||||
<div className="p-3 mx-3 mt-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<WifiOff className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-[11px] text-yellow-400 font-medium">분석 서비스 오프라인</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-hint mt-1">iran 백엔드가 연결되지 않아 분석 데이터를 표시할 수 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선박 정보 */}
|
||||
{!loading && !error && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
@ -223,17 +247,17 @@ export function VesselDetail() {
|
||||
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
||||
{[
|
||||
['MMSI', mmsiParam ?? '-'],
|
||||
['선박 유형', vessel?.classification.vesselType ?? permit?.vesselType ?? '-'],
|
||||
['선박 유형', analysis?.vesselType ?? permit?.vesselType ?? '-'],
|
||||
['국적', permit?.flagCountry ?? '-'],
|
||||
['선명', permit?.vesselName ?? '-'],
|
||||
['선명(중문)', permit?.vesselNameCn ?? '-'],
|
||||
['톤수', permit?.tonnage != null ? `${permit.tonnage}톤` : '-'],
|
||||
['길이', permit?.lengthM != null ? `${permit.lengthM}m` : '-'],
|
||||
['건조년도', permit?.buildYear != null ? String(permit.buildYear) : '-'],
|
||||
['구역', vessel?.algorithms.location.zone ?? '-'],
|
||||
['기선거리', vessel?.algorithms.location.distToBaselineNm != null
|
||||
? `${vessel.algorithms.location.distToBaselineNm.toFixed(1)}nm` : '-'],
|
||||
['시즌', vessel?.classification.season ?? '-'],
|
||||
['구역', analysis?.zoneCode ?? '-'],
|
||||
['기선거리', analysis?.distToBaselineNm != null
|
||||
? `${Number(analysis.distToBaselineNm).toFixed(1)}nm` : '-'],
|
||||
['시즌', analysis?.season ?? '-'],
|
||||
].map(([k, v], i) => (
|
||||
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
||||
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
||||
@ -268,8 +292,8 @@ export function VesselDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI 분석 결과 */}
|
||||
{vessel && (
|
||||
{/* AI 분석 결과 — prediction 직접 데이터 */}
|
||||
{analysis && (
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Brain className="w-4 h-4 text-purple-400" />
|
||||
@ -286,14 +310,14 @@ export function VesselDetail() {
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<span className={`text-xl font-bold ${riskMeta.classes.text}`}>
|
||||
{Math.round(riskScore * 100)}
|
||||
{riskScore}
|
||||
</span>
|
||||
<span className="text-[10px] text-hint">/100</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-1.5 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all"
|
||||
style={{ width: `${riskScore * 100}%` }}
|
||||
style={{ width: `${Math.min(riskScore, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -301,21 +325,17 @@ export function VesselDetail() {
|
||||
{/* 알고리즘 상세 */}
|
||||
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
||||
{[
|
||||
['활동 상태', vessel.algorithms.activity.state],
|
||||
['UCAF 점수', vessel.algorithms.activity.ucafScore.toFixed(2)],
|
||||
['UCFT 점수', vessel.algorithms.activity.ucftScore.toFixed(2)],
|
||||
['다크베셀', vessel.algorithms.darkVessel.isDark ? '예 (의심)' : '아니오'],
|
||||
['AIS 공백', vessel.algorithms.darkVessel.gapDurationMin > 0
|
||||
? `${vessel.algorithms.darkVessel.gapDurationMin}분` : '-'],
|
||||
['스푸핑 점수', vessel.algorithms.gpsSpoofing.spoofingScore.toFixed(2)],
|
||||
['BD09 오프셋', `${vessel.algorithms.gpsSpoofing.bd09OffsetM.toFixed(0)}m`],
|
||||
['속도 점프', `${vessel.algorithms.gpsSpoofing.speedJumpCount}회`],
|
||||
['클러스터', `#${vessel.algorithms.cluster.clusterId} (${vessel.algorithms.cluster.clusterSize}척)`],
|
||||
['선단 역할', vessel.algorithms.fleetRole.role],
|
||||
['환적 의심', vessel.algorithms.transship.isSuspect ? '예' : '아니오'],
|
||||
['환적 상대', vessel.algorithms.transship.pairMmsi || '-'],
|
||||
['환적 시간', vessel.algorithms.transship.durationMin > 0
|
||||
? `${vessel.algorithms.transship.durationMin}분` : '-'],
|
||||
['활동 상태', analysis.activityState ?? '-'],
|
||||
['다크베셀', analysis.isDark ? '예 (의심)' : '아니오'],
|
||||
['AIS 공백', analysis.gapDurationMin != null && analysis.gapDurationMin > 0
|
||||
? `${analysis.gapDurationMin}분` : '-'],
|
||||
['스푸핑 점수', analysis.spoofingScore != null ? Number(analysis.spoofingScore).toFixed(2) : '-'],
|
||||
['속도 점프', analysis.speedJumpCount != null ? `${analysis.speedJumpCount}회` : '-'],
|
||||
['선단 역할', analysis.fleetRole ?? '-'],
|
||||
['환적 의심', analysis.transshipSuspect ? '예' : '아니오'],
|
||||
['환적 상대', analysis.transshipPairMmsi || '-'],
|
||||
['환적 시간', analysis.transshipDurationMin != null && analysis.transshipDurationMin > 0
|
||||
? `${analysis.transshipDurationMin}분` : '-'],
|
||||
].map(([k, v], i) => (
|
||||
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
||||
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
||||
@ -329,8 +349,68 @@ export function VesselDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dark 패턴 시각화 — features 기반 */}
|
||||
{analysis?.isDark && darkTier && (
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<EyeOff className="w-4 h-4 text-red-400" />
|
||||
<span className="text-[11px] font-bold text-heading">Dark Vessel 분석</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{/* Dark tier + score */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge intent={getRiskIntent(darkScore ?? 0)} size="sm">{darkTier}</Badge>
|
||||
<span className="text-[10px] text-label font-mono">{darkScore ?? 0}점</span>
|
||||
{darkHistory7d != null && darkHistory7d > 0 && (
|
||||
<span className="text-[9px] text-red-400">7일간 {darkHistory7d}회 반복</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 의심 점수 바 */}
|
||||
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-1.5 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(darkScore ?? 0, 100)}%`,
|
||||
backgroundColor: (darkScore ?? 0) >= 70 ? '#ef4444' : (darkScore ?? 0) >= 50 ? '#f97316' : '#eab308',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Dark 패턴 태그 */}
|
||||
{darkPatterns && darkPatterns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{darkPatterns.map((p) => (
|
||||
<Badge key={p} intent="muted" size="xs">{p}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 환적 분석 — features 기반 */}
|
||||
{analysis?.transshipSuspect && transshipTier && (
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="w-4 h-4 text-orange-400" />
|
||||
<span className="text-[11px] font-bold text-heading">환적 의심 분석</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge intent={getRiskIntent(transshipScore ?? 0)} size="sm">{transshipTier}</Badge>
|
||||
<span className="text-[10px] text-label font-mono">{transshipScore ?? 0}점</span>
|
||||
<span className="text-[9px] text-hint">상대: {analysis.transshipPairMmsi ?? '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 24h AIS 수신 이력 */}
|
||||
{history.length > 0 && (
|
||||
<div className="p-3 border-b border-border">
|
||||
<AisTimeline history={history} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 관련 이벤트 이력 */}
|
||||
<div className="p-3">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-[11px] font-bold text-heading">관련 이벤트 이력</span>
|
||||
@ -340,27 +420,53 @@ export function VesselDetail() {
|
||||
<div className="text-[10px] text-hint text-center py-4">관련 이벤트가 없습니다.</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{events.map((evt) => {
|
||||
return (
|
||||
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
||||
{getAlertLevelLabel(evt.level, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
||||
<Badge intent="muted" size="xs" className="px-1.5 py-0">
|
||||
{evt.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">
|
||||
{evt.occurredAt} {evt.areaName ? `| ${evt.areaName}` : ''}
|
||||
</div>
|
||||
{evt.detail && (
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5 truncate">{evt.detail}</div>
|
||||
)}
|
||||
{events.map((evt) => (
|
||||
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
||||
{getAlertLevelLabel(evt.level, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
||||
<Badge intent="muted" size="xs" className="px-1.5 py-0">
|
||||
{evt.status}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="text-[9px] text-hint">
|
||||
{formatDateTime(evt.occurredAt)} {evt.areaName ? `| ${evt.areaName}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 단속 이력 */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-4 h-4 text-green-400" />
|
||||
<span className="text-[11px] font-bold text-heading">단속 이력</span>
|
||||
<span className="text-[9px] text-hint ml-auto">{enforcements.length}건</span>
|
||||
</div>
|
||||
{enforcements.length === 0 ? (
|
||||
<div className="text-[10px] text-hint text-center py-4">단속 이력이 없습니다.</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{enforcements.map((enf) => (
|
||||
<div key={enf.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge intent="info" size="xs">{enf.enfUid}</Badge>
|
||||
<span className="text-[10px] text-heading font-medium flex-1 truncate">
|
||||
{enf.violationType ?? '단속'}
|
||||
</span>
|
||||
<Badge intent={enf.result === 'PUNISHED' ? 'critical' : 'muted'} size="xs">
|
||||
{enf.result ?? '-'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">
|
||||
{formatDateTime(enf.enforcedAt)} {enf.areaName ? `| ${enf.areaName}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -376,7 +482,7 @@ export function VesselDetail() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Ship className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
|
||||
{vessel && (
|
||||
{analysis && (
|
||||
<Badge intent={riskMeta.intent} size="sm">
|
||||
위험도: {getAlertLevelLabel(riskLevel, tc, lang)}
|
||||
</Badge>
|
||||
@ -387,8 +493,11 @@ export function VesselDetail() {
|
||||
|
||||
<BaseMap
|
||||
ref={mapRef}
|
||||
center={[34.5, 126.5]}
|
||||
zoom={7}
|
||||
center={[
|
||||
analysis?.lat ?? 34.5,
|
||||
analysis?.lon ?? 126.5,
|
||||
]}
|
||||
zoom={analysis?.lat ? 9 : 7}
|
||||
height="100%"
|
||||
/>
|
||||
|
||||
@ -397,15 +506,15 @@ export function VesselDetail() {
|
||||
<span className="flex items-center gap-1 text-[8px]">
|
||||
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
||||
<span className="text-hint">위도</span>
|
||||
<span className="text-green-400 font-mono font-bold">34.5000</span>
|
||||
<span className="text-green-400 font-mono font-bold">{analysis?.lat?.toFixed(4) ?? '-'}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[8px]">
|
||||
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
||||
<span className="text-hint">경도</span>
|
||||
<span className="text-green-400 font-mono font-bold">126.5000</span>
|
||||
<span className="text-green-400 font-mono font-bold">{analysis?.lon?.toFixed(4) ?? '-'}</span>
|
||||
</span>
|
||||
<span className="text-[8px]">
|
||||
<span className="text-blue-400 font-bold">UTC</span>
|
||||
<span className="text-blue-400 font-bold">KST</span>
|
||||
<span className="text-label font-mono ml-1">{formatDateTime(new Date())}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -153,6 +153,50 @@
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/parent-inference/LabelSession.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.map_control",
|
||||
"label": "해역 관리",
|
||||
"shortDescription": "해역 구역 설정/관리 화면",
|
||||
"stage": "UI",
|
||||
"menu": "감시",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/surveillance/MapControl.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.risk_map",
|
||||
"label": "위험도 지도",
|
||||
"shortDescription": "해역별 위험도 히트맵",
|
||||
"stage": "UI",
|
||||
"menu": "위험평가",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/risk-assessment/RiskMap.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.patrol_route",
|
||||
"label": "순찰경로 추천",
|
||||
"shortDescription": "AI 기반 순찰 경로 최적화",
|
||||
"stage": "UI",
|
||||
"menu": "순찰",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/patrol/PatrolRoute.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.fleet_optimization",
|
||||
"label": "다함정 최적화",
|
||||
"shortDescription": "다수 함정 배치 최적화",
|
||||
"stage": "UI",
|
||||
"menu": "순찰",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/patrol/FleetOptimization.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.statistics",
|
||||
"label": "통계",
|
||||
@ -164,6 +208,28 @@
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/statistics/Statistics.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.report_management",
|
||||
"label": "보고서 관리",
|
||||
"shortDescription": "보고서 생성/조회",
|
||||
"stage": "UI",
|
||||
"menu": "통계",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/statistics/ReportManagement.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.external_service",
|
||||
"label": "외부 서비스",
|
||||
"shortDescription": "외부 연동 서비스 설정",
|
||||
"stage": "UI",
|
||||
"menu": "통계",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/statistics/ExternalService.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.ai_alert",
|
||||
"label": "현장 AI 경보",
|
||||
@ -185,5 +251,82 @@
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/ai-operations/AIAssistant.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.ai_model",
|
||||
"label": "AI 모델관리",
|
||||
"shortDescription": "AI 모델 배포/모니터링",
|
||||
"stage": "UI",
|
||||
"menu": "AI",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/ai-operations/AIModelManagement.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.mlops",
|
||||
"label": "MLOps",
|
||||
"shortDescription": "ML 파이프라인 운영",
|
||||
"stage": "UI",
|
||||
"menu": "AI",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/ai-operations/MLOpsPage.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.llm_ops",
|
||||
"label": "LLM 운영",
|
||||
"shortDescription": "LLM 모델 운영 관리",
|
||||
"stage": "UI",
|
||||
"menu": "AI",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/ai-operations/LLMOpsPage.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.mobile_service",
|
||||
"label": "모바일 서비스",
|
||||
"shortDescription": "현장 모바일 서비스",
|
||||
"stage": "UI",
|
||||
"menu": "현장",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/field-ops/MobileService.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.ship_agent",
|
||||
"label": "함정 Agent",
|
||||
"shortDescription": "함정 단말 에이전트",
|
||||
"stage": "UI",
|
||||
"menu": "현장",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/field-ops/ShipAgent.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.admin_panel",
|
||||
"label": "시스템 관리",
|
||||
"shortDescription": "사용자/역할/권한 관리",
|
||||
"stage": "UI",
|
||||
"menu": "관리",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/admin/AdminPanel.tsx"
|
||||
},
|
||||
{
|
||||
"id": "ui.permissions",
|
||||
"label": "권한 관리",
|
||||
"shortDescription": "RBAC 트리 권한 매트릭스",
|
||||
"stage": "UI",
|
||||
"menu": "관리",
|
||||
"kind": "ui",
|
||||
"trigger": "on_demand",
|
||||
"status": "implemented",
|
||||
"file": "frontend/src/features/admin/AccessControl.tsx"
|
||||
}
|
||||
]
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"monitoring": "Alert Monitor",
|
||||
"riskMap": "Risk Map",
|
||||
"mapControl": "Map Control",
|
||||
"enforcementPlan": "Enforcement Plan",
|
||||
"darkVessel": "Dark Vessel",
|
||||
"gearDetection": "Gear Detection",
|
||||
@ -132,6 +133,23 @@
|
||||
"INTERMITTENT": "Intermittent",
|
||||
"SPEED_ANOMALY": "Speed Anomaly"
|
||||
},
|
||||
"darkTier": {
|
||||
"CRITICAL": "Intentional Loss (Critical)",
|
||||
"HIGH": "Suspicious Loss",
|
||||
"WATCH": "Under Watch",
|
||||
"NONE": "Normal"
|
||||
},
|
||||
"transshipTier": {
|
||||
"CRITICAL": "Confirmed Transship",
|
||||
"HIGH": "Suspected Transship",
|
||||
"WATCH": "Under Watch"
|
||||
},
|
||||
"adminSubGroup": {
|
||||
"aiPlatform": "AI Platform",
|
||||
"systemOps": "System Operations",
|
||||
"userMgmt": "User Management",
|
||||
"auditSecurity": "Audit & Security"
|
||||
},
|
||||
"userAccountStatus": {
|
||||
"ACTIVE": "Active",
|
||||
"PENDING": "Pending",
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"dashboard": "종합 상황판",
|
||||
"monitoring": "경보 현황판",
|
||||
"riskMap": "위험도 지도",
|
||||
"mapControl": "해역 관리",
|
||||
"enforcementPlan": "단속 계획",
|
||||
"darkVessel": "다크베셀 탐지",
|
||||
"gearDetection": "어구 탐지",
|
||||
@ -132,6 +133,23 @@
|
||||
"INTERMITTENT": "신호 간헐송출",
|
||||
"SPEED_ANOMALY": "속도 이상"
|
||||
},
|
||||
"darkTier": {
|
||||
"CRITICAL": "고의 소실 (위험)",
|
||||
"HIGH": "의심 소실",
|
||||
"WATCH": "관찰 대상",
|
||||
"NONE": "정상"
|
||||
},
|
||||
"transshipTier": {
|
||||
"CRITICAL": "환적 확실",
|
||||
"HIGH": "환적 의심",
|
||||
"WATCH": "관찰 대상"
|
||||
},
|
||||
"adminSubGroup": {
|
||||
"aiPlatform": "AI 플랫폼",
|
||||
"systemOps": "시스템 운영",
|
||||
"userMgmt": "사용자 관리",
|
||||
"auditSecurity": "감사·보안"
|
||||
},
|
||||
"userAccountStatus": {
|
||||
"ACTIVE": "활성",
|
||||
"PENDING": "승인 대기",
|
||||
|
||||
@ -61,6 +61,12 @@ export interface PermTreeNode {
|
||||
rsrcLevel: number;
|
||||
sortOrd: number;
|
||||
useYn: string;
|
||||
/** V021: 메뉴 SSOT */
|
||||
labelKey: string | null;
|
||||
urlPath: string | null;
|
||||
navSort: number;
|
||||
/** V022: DB i18n JSONB {"ko":"...", "en":"..."} */
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RoleWithPermissions {
|
||||
|
||||
113
frontend/src/services/analysisApi.ts
Normal file
113
frontend/src/services/analysisApi.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* vessel_analysis_results 직접 조회 API 서비스.
|
||||
* 백엔드 /api/analysis/* 엔드포인트 연동.
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
export interface VesselAnalysis {
|
||||
id: number;
|
||||
mmsi: string;
|
||||
analyzedAt: string;
|
||||
vesselType: string | null;
|
||||
confidence: number | null;
|
||||
fishingPct: number | null;
|
||||
season: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
zoneCode: string | null;
|
||||
distToBaselineNm: number | null;
|
||||
activityState: string | null;
|
||||
isDark: boolean | null;
|
||||
gapDurationMin: number | null;
|
||||
darkPattern: string | null;
|
||||
spoofingScore: number | null;
|
||||
speedJumpCount: number | null;
|
||||
transshipSuspect: boolean | null;
|
||||
transshipPairMmsi: string | null;
|
||||
transshipDurationMin: number | null;
|
||||
fleetClusterId: number | null;
|
||||
fleetRole: string | null;
|
||||
fleetIsLeader: boolean | null;
|
||||
riskScore: number | null;
|
||||
riskLevel: string | null;
|
||||
gearCode: string | null;
|
||||
gearJudgment: string | null;
|
||||
permitStatus: string | null;
|
||||
features: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AnalysisPageResponse {
|
||||
content: VesselAnalysis[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** 분석 결과 목록 조회 */
|
||||
export async function getAnalysisVessels(params?: {
|
||||
mmsi?: string;
|
||||
zoneCode?: string;
|
||||
riskLevel?: string;
|
||||
isDark?: boolean;
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.mmsi) query.set('mmsi', params.mmsi);
|
||||
if (params?.zoneCode) query.set('zoneCode', params.zoneCode);
|
||||
if (params?.riskLevel) query.set('riskLevel', params.riskLevel);
|
||||
if (params?.isDark != null) query.set('isDark', String(params.isDark));
|
||||
query.set('hours', String(params?.hours ?? 1));
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 50));
|
||||
const res = await fetch(`${API_BASE}/analysis/vessels?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 특정 선박 최신 분석 결과 */
|
||||
export async function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> {
|
||||
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 특정 선박 분석 이력 */
|
||||
export async function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> {
|
||||
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}/history?hours=${hours}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 다크 베셀 목록 */
|
||||
export async function getDarkVessels(params?: {
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('hours', String(params?.hours ?? 1));
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 50));
|
||||
const res = await fetch(`${API_BASE}/analysis/dark?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 환적 의심 목록 */
|
||||
export async function getTransshipSuspects(params?: {
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('hours', String(params?.hours ?? 1));
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 50));
|
||||
const res = await fetch(`${API_BASE}/analysis/transship?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@ -6,6 +6,23 @@
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
export interface MenuConfigItem {
|
||||
menuCd: string;
|
||||
parentMenuCd: string | null;
|
||||
menuType: 'ITEM' | 'GROUP' | 'DIVIDER';
|
||||
urlPath: string | null;
|
||||
rsrcCd: string | null;
|
||||
componentKey: string | null;
|
||||
icon: string | null;
|
||||
labelKey: string | null;
|
||||
dividerLabel: string | null;
|
||||
menuLevel: number;
|
||||
sortOrd: number;
|
||||
useYn: string;
|
||||
/** DB i18n SSOT: {"ko":"종합 상황판","en":"Dashboard"} */
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BackendUser {
|
||||
id: string;
|
||||
account: string;
|
||||
@ -17,6 +34,8 @@ export interface BackendUser {
|
||||
roles: string[];
|
||||
/** rsrcCd → operCd[] (READ/CREATE/UPDATE/DELETE/EXPORT) */
|
||||
permissions: Record<string, string[]>;
|
||||
/** DB 메뉴 설정 SSOT */
|
||||
menuConfig: MenuConfigItem[];
|
||||
}
|
||||
|
||||
export class LoginError extends Error {
|
||||
|
||||
@ -81,11 +81,13 @@ export interface EnforcementPlan {
|
||||
|
||||
export async function getEnforcementRecords(params?: {
|
||||
violationType?: string;
|
||||
vesselMmsi?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<PageResponse<EnforcementRecord>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.violationType) query.set('violationType', params.violationType);
|
||||
if (params?.vesselMmsi) query.set('vesselMmsi', params.vesselMmsi);
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 20));
|
||||
const res = await fetch(`${API_BASE}/enforcement/records?${query}`, {
|
||||
|
||||
@ -30,6 +30,15 @@ export interface PredictionEvent {
|
||||
resolvedAt: string | null;
|
||||
resolutionNote: string | null;
|
||||
createdAt: string;
|
||||
features?: {
|
||||
dark_suspicion_score?: number;
|
||||
dark_tier?: string;
|
||||
dark_patterns?: string[];
|
||||
dark_history_7d?: number;
|
||||
transship_tier?: string;
|
||||
transship_score?: number;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface EventPageResponse {
|
||||
|
||||
@ -29,6 +29,8 @@ interface EventStore {
|
||||
loaded: boolean;
|
||||
/** API 호출 */
|
||||
load: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise<void>;
|
||||
/** 화면 깜박임 없는 백그라운드 갱신 (loading 상태 변경 없음) */
|
||||
silentRefresh: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise<void>;
|
||||
loadStats: () => Promise<void>;
|
||||
filterByLevel: (level: string | null) => LegacyEventRecord[];
|
||||
}
|
||||
@ -68,6 +70,23 @@ export const useEventStore = create<EventStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
silentRefresh: async (params) => {
|
||||
try {
|
||||
const res = await getEvents(params);
|
||||
const legacy = res.content.map(toLegacyEvent);
|
||||
set({
|
||||
rawEvents: res.content,
|
||||
events: legacy,
|
||||
totalElements: res.totalElements,
|
||||
totalPages: res.totalPages,
|
||||
currentPage: res.number,
|
||||
pageSize: res.size,
|
||||
});
|
||||
} catch {
|
||||
// silent: 에러 무시 — 다음 갱신에서 재시도
|
||||
}
|
||||
},
|
||||
|
||||
loadStats: async () => {
|
||||
try {
|
||||
const stats = await getEventStats();
|
||||
|
||||
66
frontend/src/stores/menuStore.ts
Normal file
66
frontend/src/stores/menuStore.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface MenuConfigItem {
|
||||
menuCd: string;
|
||||
parentMenuCd: string | null;
|
||||
menuType: 'ITEM' | 'GROUP' | 'DIVIDER';
|
||||
urlPath: string | null;
|
||||
rsrcCd: string | null;
|
||||
componentKey: string | null;
|
||||
icon: string | null;
|
||||
labelKey: string | null;
|
||||
dividerLabel: string | null;
|
||||
menuLevel: number;
|
||||
sortOrd: number;
|
||||
useYn: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
/** DB labels에서 현재 언어의 라벨을 반환 */
|
||||
export function getMenuLabel(item: MenuConfigItem, lang: string): string {
|
||||
return item.labels?.[lang] || item.labels?.ko || item.menuCd;
|
||||
}
|
||||
|
||||
interface MenuStore {
|
||||
items: MenuConfigItem[];
|
||||
loaded: boolean;
|
||||
setMenuConfig: (items: MenuConfigItem[]) => void;
|
||||
clear: () => void;
|
||||
|
||||
/** path → rsrcCd (longest-match, PATH_TO_RESOURCE 대체) */
|
||||
getResourceForPath: (path: string) => string | undefined;
|
||||
/** 최상위 항목 (menu_level=0, 사이드바 표시용) */
|
||||
getTopLevelEntries: () => MenuConfigItem[];
|
||||
/** 그룹 하위 항목 */
|
||||
getChildren: (parentMenuCd: string) => MenuConfigItem[];
|
||||
/** 라우팅 가능 항목 (ITEM + urlPath 보유) */
|
||||
getRoutableItems: () => MenuConfigItem[];
|
||||
}
|
||||
|
||||
export const useMenuStore = create<MenuStore>((set, get) => ({
|
||||
items: [],
|
||||
loaded: false,
|
||||
|
||||
setMenuConfig: (items) => set({ items, loaded: true }),
|
||||
clear: () => set({ items: [], loaded: false }),
|
||||
|
||||
getResourceForPath: (path) => {
|
||||
const { items } = get();
|
||||
// longest-match: 가장 구체적인 경로 우선 (삽입 순서 의존 버그 해결)
|
||||
const candidates = items.filter((i) => i.urlPath && i.rsrcCd);
|
||||
candidates.sort((a, b) => b.urlPath!.length - a.urlPath!.length);
|
||||
const match = candidates.find((i) => path.startsWith(i.urlPath!));
|
||||
return match?.rsrcCd ?? undefined;
|
||||
},
|
||||
|
||||
getTopLevelEntries: () =>
|
||||
get().items.filter(
|
||||
(i) => i.menuLevel === 0 && i.parentMenuCd === null && i.useYn !== 'H',
|
||||
),
|
||||
|
||||
getChildren: (parentMenuCd) =>
|
||||
get().items.filter((i) => i.parentMenuCd === parentMenuCd),
|
||||
|
||||
getRoutableItems: () =>
|
||||
get().items.filter((i) => i.menuType === 'ITEM' && i.urlPath),
|
||||
}));
|
||||
@ -6,6 +6,7 @@
|
||||
dedup: 동일 mmsi + category + 윈도우 내 중복 방지.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
@ -214,6 +215,10 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
|
||||
event_uid = _make_event_uid(now, seq)
|
||||
seq += 1
|
||||
|
||||
# features 추출: 이벤트에 연관된 핵심 특성만 저장
|
||||
raw_features = result.get('features')
|
||||
features_json = json.dumps(raw_features, ensure_ascii=False) if raw_features else None
|
||||
|
||||
events_to_insert.append((
|
||||
event_uid,
|
||||
now, # occurred_at
|
||||
@ -233,6 +238,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
|
||||
result.get('confidence') or result.get('risk_score', 0) / 100.0,
|
||||
'NEW', # status
|
||||
dedup_key,
|
||||
features_json,
|
||||
))
|
||||
generated += 1
|
||||
# break 제거: 한 분석결과가 여러 룰에 매칭되면 모두 생성
|
||||
@ -244,7 +250,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict:
|
||||
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)
|
||||
source_type, source_ref_id, ai_confidence, status, dedup_key, features)
|
||||
VALUES %s
|
||||
ON CONFLICT (event_uid) DO NOTHING""",
|
||||
events_to_insert,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user