diff --git a/backend/src/main/java/gc/mda/kcg/admin/AdminLogController.java b/backend/src/main/java/gc/mda/kcg/admin/AdminLogController.java new file mode 100644 index 0000000..b16b49a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/admin/AdminLogController.java @@ -0,0 +1,59 @@ +package gc.mda.kcg.admin; + +import gc.mda.kcg.audit.AccessLog; +import gc.mda.kcg.audit.AccessLogRepository; +import gc.mda.kcg.audit.AuditLog; +import gc.mda.kcg.audit.AuditLogRepository; +import gc.mda.kcg.auth.LoginHistory; +import gc.mda.kcg.auth.LoginHistoryRepository; +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 관리자 로그 조회 API. + * - 감사 로그 (auth_audit_log) + * - 접근 이력 (auth_access_log) + * - 로그인 이력 (auth_login_hist) + */ +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminLogController { + + private final AuditLogRepository auditLogRepository; + private final AccessLogRepository accessLogRepository; + private final LoginHistoryRepository loginHistoryRepository; + + @GetMapping("/audit-logs") + @RequirePermission(resource = "admin:audit-logs", operation = "READ") + public Page getAuditLogs( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return auditLogRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size)); + } + + @GetMapping("/access-logs") + @RequirePermission(resource = "admin:access-logs", operation = "READ") + public Page getAccessLogs( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return accessLogRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size)); + } + + @GetMapping("/login-history") + @RequirePermission(resource = "admin:login-history", operation = "READ") + public Page getLoginHistory( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return loginHistoryRepository.findAllByOrderByLoginDtmDesc(PageRequest.of(page, size)); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java new file mode 100644 index 0000000..446a334 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/IranBackendClient.java @@ -0,0 +1,54 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.config.AppProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.util.Map; + +/** + * iran 백엔드 REST 클라이언트. + * + * 현재는 호출 자체는 시도하되, 연결 불가 시 graceful degradation: + * - 503 또는 빈 응답을 반환하여 프론트에서 빈 UI 처리 + * + * 향후 운영 환경에서 iran 백엔드 base-url이 정확히 설정되면 그대로 사용 가능. + */ +@Slf4j +@Component +public class IranBackendClient { + + private final RestClient restClient; + private final boolean enabled; + + public IranBackendClient(AppProperties appProperties) { + String baseUrl = appProperties.getIranBackend().getBaseUrl(); + this.enabled = baseUrl != null && !baseUrl.isBlank(); + this.restClient = enabled + ? RestClient.builder().baseUrl(baseUrl).build() + : RestClient.create(); + log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl); + } + + public boolean isEnabled() { + return enabled; + } + + /** + * GET 호출 (Map 반환). 실패 시 null 반환. + */ + public Map getJson(String path) { + if (!enabled) return null; + try { + @SuppressWarnings("unchecked") + Map body = restClient.get().uri(path).retrieve().body(Map.class); + return body; + } catch (RestClientException e) { + log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage()); + return null; + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java new file mode 100644 index 0000000..d528687 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/PredictionProxyController.java @@ -0,0 +1,51 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * Prediction (Python FastAPI) 서비스 프록시. + * 현재는 stub - Phase 5에서 실 연결. + */ +@RestController +@RequestMapping("/api/prediction") +@RequiredArgsConstructor +public class PredictionProxyController { + + private final IranBackendClient iranClient; + + @GetMapping("/health") + public ResponseEntity health() { + Map data = iranClient.getJson("/api/prediction/health"); + if (data == null) { + return ResponseEntity.ok(Map.of( + "status", "DISCONNECTED", + "message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)" + )); + } + return ResponseEntity.ok(data); + } + + @GetMapping("/status") + @RequirePermission(resource = "monitoring", operation = "READ") + public ResponseEntity status() { + Map data = iranClient.getJson("/api/prediction/status"); + if (data == null) { + return ResponseEntity.ok(Map.of("status", "DISCONNECTED")); + } + return ResponseEntity.ok(data); + } + + @PostMapping("/trigger") + @RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE") + public ResponseEntity trigger() { + return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결")); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java new file mode 100644 index 0000000..03c5be0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java @@ -0,0 +1,58 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * iran 백엔드의 분석 데이터를 프록시 제공. + * + * 현재 단계: iran 백엔드 미연결 → 빈 응답 + serviceAvailable=false + * 향후 단계: 실 연결 + 자체 DB의 운영자 결정과 조합 (HYBRID) + */ +@RestController +@RequestMapping("/api/vessel-analysis") +@RequiredArgsConstructor +public class VesselAnalysisProxyController { + + private final IranBackendClient iranClient; + + @GetMapping + @RequirePermission(resource = "detection", operation = "READ") + public ResponseEntity getVesselAnalysis() { + Map data = iranClient.getJson("/api/vessel-analysis"); + if (data == null) { + return ResponseEntity.ok(Map.of( + "serviceAvailable", false, + "message", "iran 백엔드 미연결 (Phase 5에서 연결 예정)", + "results", List.of(), + "stats", Map.of() + )); + } + return ResponseEntity.ok(data); + } + + @GetMapping("/groups") + @RequirePermission(resource = "detection:gear-detection", operation = "READ") + public ResponseEntity getGroups() { + Map data = iranClient.getJson("/api/vessel-analysis/groups"); + if (data == null) { + return ResponseEntity.ok(Map.of("serviceAvailable", false, "groups", List.of())); + } + return ResponseEntity.ok(data); + } + + @GetMapping("/groups/{groupKey}/detail") + @RequirePermission(resource = "detection:gear-detection", operation = "READ") + public ResponseEntity getGroupDetail(@PathVariable String groupKey) { + Map data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail"); + if (data == null) { + return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey)); + } + return ResponseEntity.ok(data); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/CandidateExclusion.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/CandidateExclusion.java new file mode 100644 index 0000000..7ea8d6f --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/CandidateExclusion.java @@ -0,0 +1,63 @@ +package gc.mda.kcg.domain.fleet; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * 모선 후보 제외 (운영자 결정). + * scope_type: GROUP(그룹 한정) / GLOBAL(전역, 모든 그룹에 적용) + */ +@Entity +@Table(name = "gear_parent_candidate_exclusions", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class CandidateExclusion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "scope_type", nullable = false, length = 20) + private String scopeType; // GROUP, GLOBAL + + @Column(name = "group_key", length = 255) + private String groupKey; + + @Column(name = "sub_cluster_id") + private Integer subClusterId; + + @Column(name = "excluded_mmsi", nullable = false, length = 20) + private String excludedMmsi; + + @Column(name = "reason", columnDefinition = "text") + private String reason; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "actor") + private UUID actor; + + @Column(name = "actor_acnt", length = 50) + private String actorAcnt; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "released_at") + private OffsetDateTime releasedAt; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "released_by") + private UUID releasedBy; + + @Column(name = "released_by_acnt", length = 50) + private String releasedByAcnt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/LabelSession.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/LabelSession.java new file mode 100644 index 0000000..c246c99 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/LabelSession.java @@ -0,0 +1,73 @@ +package gc.mda.kcg.domain.fleet; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +/** + * 모선 추론 학습 세션 (운영자가 정답 라벨링). + */ +@Entity +@Table(name = "gear_parent_label_sessions", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class LabelSession { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "group_key", nullable = false, length = 255) + private String groupKey; + + @Column(name = "sub_cluster_id", nullable = false) + private Integer subClusterId; + + @Column(name = "label_parent_mmsi", nullable = false, length = 20) + private String labelParentMmsi; + + @Column(name = "status", nullable = false, length = 20) + private String status; // ACTIVE, CANCELLED, COMPLETED + + @Column(name = "active_from", nullable = false) + private OffsetDateTime activeFrom; + + @Column(name = "active_until") + private OffsetDateTime activeUntil; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "anchor_snapshot", columnDefinition = "jsonb") + private Map anchorSnapshot; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "created_by") + private UUID createdBy; + + @Column(name = "created_by_acnt", length = 50) + private String createdByAcnt; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "cancelled_by") + private UUID cancelledBy; + + @Column(name = "cancelled_at") + private OffsetDateTime cancelledAt; + + @Column(name = "cancel_reason", columnDefinition = "text") + private String cancelReason; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @PrePersist + void prePersist() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) createdAt = now; + if (activeFrom == null) activeFrom = now; + if (status == null) status = "ACTIVE"; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java new file mode 100644 index 0000000..1da357a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java @@ -0,0 +1,131 @@ +package gc.mda.kcg.domain.fleet; + +import gc.mda.kcg.domain.fleet.dto.*; +import gc.mda.kcg.permission.annotation.RequirePermission; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/parent-inference") +@RequiredArgsConstructor +public class ParentInferenceWorkflowController { + + private final ParentInferenceWorkflowService service; + + // ======================================================================== + // 검토 대기 / 결과 조회 + // ======================================================================== + + @GetMapping("/review") + @RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "READ") + public Page listReview( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return service.listReview(status, PageRequest.of(page, size)); + } + + // ======================================================================== + // 모선 확정/거부/리셋 + // ======================================================================== + + @PostMapping("/groups/{groupKey}/{subClusterId}/review") + @RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "UPDATE") + public ParentResolution review( + @PathVariable String groupKey, + @PathVariable Integer subClusterId, + @Valid @RequestBody ReviewRequest req + ) { + return service.review(groupKey, subClusterId, req); + } + + // ======================================================================== + // 후보 제외 (그룹 / 전역) + // ======================================================================== + + @PostMapping("/groups/{groupKey}/{subClusterId}/exclusions") + @RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "CREATE") + public CandidateExclusion excludeForGroup( + @PathVariable String groupKey, + @PathVariable Integer subClusterId, + @Valid @RequestBody ExclusionRequest req + ) { + return service.excludeForGroup(groupKey, subClusterId, req); + } + + @PostMapping("/exclusions/global") + @RequirePermission(resource = "parent-inference-workflow:exclusion-management", operation = "CREATE") + public CandidateExclusion excludeGlobal(@Valid @RequestBody GlobalExclusionRequest req) { + return service.excludeGlobal(req); + } + + @PostMapping("/exclusions/{exclusionId}/release") + @RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "UPDATE") + public CandidateExclusion releaseExclusion( + @PathVariable Long exclusionId, + @RequestBody(required = false) CancelRequest req + ) { + return service.releaseExclusion(exclusionId, req); + } + + @GetMapping("/exclusions") + @RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "READ") + public Page listExclusions( + @RequestParam(required = false) String scopeType, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return service.listExclusions(scopeType, PageRequest.of(page, size)); + } + + // ======================================================================== + // 학습 세션 + // ======================================================================== + + @PostMapping("/groups/{groupKey}/{subClusterId}/label-sessions") + @RequirePermission(resource = "parent-inference-workflow:label-session", operation = "CREATE") + public LabelSession createLabelSession( + @PathVariable String groupKey, + @PathVariable Integer subClusterId, + @Valid @RequestBody LabelSessionRequest req + ) { + return service.createLabelSession(groupKey, subClusterId, req); + } + + @PostMapping("/label-sessions/{sessionId}/cancel") + @RequirePermission(resource = "parent-inference-workflow:label-session", operation = "UPDATE") + public LabelSession cancelLabelSession( + @PathVariable Long sessionId, + @RequestBody(required = false) CancelRequest req + ) { + return service.cancelLabelSession(sessionId, req); + } + + @GetMapping("/label-sessions") + @RequirePermission(resource = "parent-inference-workflow:label-session", operation = "READ") + public Page listLabelSessions( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return service.listLabelSessions(status, PageRequest.of(page, size)); + } + + // ======================================================================== + // 도메인 로그 (운영자 액션 이력) + // ======================================================================== + + @GetMapping("/review-logs") + @RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "READ") + public Page listReviewLogs( + @RequestParam(required = false) String groupKey, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return service.listReviewLogs(groupKey, PageRequest.of(page, size)); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowService.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowService.java new file mode 100644 index 0000000..7d160f1 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowService.java @@ -0,0 +1,273 @@ +package gc.mda.kcg.domain.fleet; + +import gc.mda.kcg.audit.annotation.Auditable; +import gc.mda.kcg.auth.AuthPrincipal; +import gc.mda.kcg.domain.fleet.dto.*; +import gc.mda.kcg.domain.fleet.repository.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 모선 워크플로우 핵심 서비스 (HYBRID). + * - 후보 데이터: iran 백엔드 API 호출 (현재 stub) + * - 운영자 결정: 자체 DB (gear_group_parent_resolution 등) + * + * 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ParentInferenceWorkflowService { + + private final ParentResolutionRepository resolutionRepository; + private final ParentReviewLogRepository reviewLogRepository; + private final CandidateExclusionRepository exclusionRepository; + private final LabelSessionRepository labelSessionRepository; + + // ======================================================================== + // Resolution (모선 확정/거부/리셋) + // ======================================================================== + + @Transactional(readOnly = true) + public Page listReview(String status, Pageable pageable) { + if (status == null || status.isBlank()) { + return resolutionRepository.findAllByOrderByUpdatedAtDesc(pageable); + } + return resolutionRepository.findByStatusOrderByUpdatedAtDesc(status, pageable); + } + + @Auditable(action = "REVIEW_PARENT", resourceType = "GEAR_GROUP") + @Transactional + public ParentResolution review(String groupKey, Integer subClusterId, ReviewRequest req) { + AuthPrincipal principal = currentPrincipal(); + ParentResolution res = resolutionRepository + .findByGroupKeyAndSubClusterId(groupKey, subClusterId) + .orElseGet(() -> ParentResolution.builder() + .groupKey(groupKey) + .subClusterId(subClusterId) + .status("UNRESOLVED") + .build()); + + OffsetDateTime now = OffsetDateTime.now(); + switch (req.action().toUpperCase()) { + case "CONFIRM" -> { + res.setStatus("MANUAL_CONFIRMED"); + res.setSelectedParentMmsi(req.selectedParentMmsi()); + res.setApprovedBy(principal != null ? principal.getUserId() : null); + res.setApprovedAt(now); + res.setManualComment(req.comment()); + } + case "REJECT" -> { + res.setStatus("REVIEW_REQUIRED"); + res.setRejectedCandidateMmsi(req.selectedParentMmsi()); + res.setRejectedAt(now); + res.setManualComment(req.comment()); + } + case "RESET" -> { + res.setStatus("UNRESOLVED"); + res.setSelectedParentMmsi(null); + res.setRejectedCandidateMmsi(null); + res.setApprovedBy(null); + res.setApprovedAt(null); + res.setRejectedAt(null); + res.setManualComment(req.comment()); + } + default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action()); + } + + ParentResolution saved = resolutionRepository.save(res); + + reviewLogRepository.save(ParentReviewLog.builder() + .groupKey(groupKey) + .subClusterId(subClusterId) + .action(req.action().toUpperCase()) + .selectedParentMmsi(req.selectedParentMmsi()) + .actor(principal != null ? principal.getUserId() : null) + .actorAcnt(principal != null ? principal.getUserAcnt() : null) + .comment(req.comment()) + .build()); + + return saved; + } + + // ======================================================================== + // Exclusion (후보 제외) + // ======================================================================== + + @Auditable(action = "EXCLUDE_CANDIDATE_GROUP", resourceType = "GEAR_GROUP") + @Transactional + public CandidateExclusion excludeForGroup(String groupKey, Integer subClusterId, ExclusionRequest req) { + AuthPrincipal principal = currentPrincipal(); + CandidateExclusion exc = CandidateExclusion.builder() + .scopeType("GROUP") + .groupKey(groupKey) + .subClusterId(subClusterId) + .excludedMmsi(req.excludedMmsi()) + .reason(req.reason()) + .actor(principal != null ? principal.getUserId() : null) + .actorAcnt(principal != null ? principal.getUserAcnt() : null) + .build(); + CandidateExclusion saved = exclusionRepository.save(exc); + + reviewLogRepository.save(ParentReviewLog.builder() + .groupKey(groupKey) + .subClusterId(subClusterId) + .action("EXCLUDE_GROUP") + .selectedParentMmsi(req.excludedMmsi()) + .actor(principal != null ? principal.getUserId() : null) + .actorAcnt(principal != null ? principal.getUserAcnt() : null) + .comment(req.reason()) + .build()); + + return saved; + } + + @Auditable(action = "EXCLUDE_CANDIDATE_GLOBAL", resourceType = "GEAR_GROUP") + @Transactional + public CandidateExclusion excludeGlobal(GlobalExclusionRequest req) { + AuthPrincipal principal = currentPrincipal(); + CandidateExclusion exc = CandidateExclusion.builder() + .scopeType("GLOBAL") + .excludedMmsi(req.excludedMmsi()) + .reason(req.reason()) + .actor(principal != null ? principal.getUserId() : null) + .actorAcnt(principal != null ? principal.getUserAcnt() : null) + .build(); + CandidateExclusion saved = exclusionRepository.save(exc); + + reviewLogRepository.save(ParentReviewLog.builder() + .groupKey("__GLOBAL__") + .action("EXCLUDE_GLOBAL") + .selectedParentMmsi(req.excludedMmsi()) + .actor(principal != null ? principal.getUserId() : null) + .actorAcnt(principal != null ? principal.getUserAcnt() : null) + .comment(req.reason()) + .build()); + + return saved; + } + + @Auditable(action = "RELEASE_EXCLUSION", resourceType = "GEAR_GROUP") + @Transactional + public CandidateExclusion releaseExclusion(Long exclusionId, CancelRequest req) { + AuthPrincipal principal = currentPrincipal(); + CandidateExclusion exc = exclusionRepository.findById(exclusionId) + .orElseThrow(() -> new IllegalArgumentException("EXCLUSION_NOT_FOUND: " + exclusionId)); + exc.setReleasedAt(OffsetDateTime.now()); + exc.setReleasedBy(principal != null ? principal.getUserId() : null); + exc.setReleasedByAcnt(principal != null ? principal.getUserAcnt() : null); + CandidateExclusion saved = exclusionRepository.save(exc); + + reviewLogRepository.save(ParentReviewLog.builder() + .groupKey(exc.getGroupKey() != null ? exc.getGroupKey() : "__GLOBAL__") + .action("RELEASE_EXCLUSION") + .selectedParentMmsi(exc.getExcludedMmsi()) + .actor(principal != null ? principal.getUserId() : null) + .actorAcnt(principal != null ? principal.getUserAcnt() : null) + .comment(req != null ? req.reason() : null) + .build()); + + return saved; + } + + @Transactional(readOnly = true) + public Page listExclusions(String scopeType, Pageable pageable) { + if (scopeType == null || scopeType.isBlank()) { + return exclusionRepository.findActive(pageable); + } + return exclusionRepository.findActiveByScope(scopeType, pageable); + } + + // ======================================================================== + // Label Session (학습 세션) + // ======================================================================== + + @Auditable(action = "LABEL_PARENT_CREATE", resourceType = "GEAR_GROUP") + @Transactional + public LabelSession createLabelSession(String groupKey, Integer subClusterId, LabelSessionRequest req) { + AuthPrincipal principal = currentPrincipal(); + LabelSession session = LabelSession.builder() + .groupKey(groupKey) + .subClusterId(subClusterId) + .labelParentMmsi(req.labelParentMmsi()) + .anchorSnapshot(req.anchorSnapshot()) + .createdBy(principal != null ? principal.getUserId() : null) + .createdByAcnt(principal != null ? principal.getUserAcnt() : null) + .build(); + LabelSession saved = labelSessionRepository.save(session); + + reviewLogRepository.save(ParentReviewLog.builder() + .groupKey(groupKey) + .subClusterId(subClusterId) + .action("LABEL_PARENT") + .selectedParentMmsi(req.labelParentMmsi()) + .actor(principal != null ? principal.getUserId() : null) + .actorAcnt(principal != null ? principal.getUserAcnt() : null) + .build()); + + return saved; + } + + @Auditable(action = "LABEL_PARENT_CANCEL", resourceType = "GEAR_GROUP") + @Transactional + public LabelSession cancelLabelSession(Long sessionId, CancelRequest req) { + AuthPrincipal principal = currentPrincipal(); + LabelSession session = labelSessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("LABEL_SESSION_NOT_FOUND: " + sessionId)); + session.setStatus("CANCELLED"); + session.setCancelledAt(OffsetDateTime.now()); + session.setCancelledBy(principal != null ? principal.getUserId() : null); + session.setCancelReason(req != null ? req.reason() : null); + LabelSession saved = labelSessionRepository.save(session); + + reviewLogRepository.save(ParentReviewLog.builder() + .groupKey(session.getGroupKey()) + .subClusterId(session.getSubClusterId()) + .action("CANCEL_LABEL") + .selectedParentMmsi(session.getLabelParentMmsi()) + .actor(principal != null ? principal.getUserId() : null) + .actorAcnt(principal != null ? principal.getUserAcnt() : null) + .comment(req != null ? req.reason() : null) + .build()); + + return saved; + } + + @Transactional(readOnly = true) + public Page listLabelSessions(String status, Pageable pageable) { + if (status == null || status.isBlank()) { + return labelSessionRepository.findAllByOrderByCreatedAtDesc(pageable); + } + return labelSessionRepository.findByStatusOrderByCreatedAtDesc(status, pageable); + } + + // ======================================================================== + // 도메인 로그 조회 + // ======================================================================== + + @Transactional(readOnly = true) + public Page listReviewLogs(String groupKey, Pageable pageable) { + if (groupKey == null || groupKey.isBlank()) { + return reviewLogRepository.findAllByOrderByCreatedAtDesc(pageable); + } + return reviewLogRepository.findByGroupKeyOrderByCreatedAtDesc(groupKey, pageable); + } + + // ======================================================================== + // 헬퍼 + // ======================================================================== + + private AuthPrincipal currentPrincipal() { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p; + return null; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java new file mode 100644 index 0000000..968d1d7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentResolution.java @@ -0,0 +1,71 @@ +package gc.mda.kcg.domain.fleet; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * 모선 확정 결과 (운영자 의사결정). + * iran 백엔드의 후보 데이터(prediction이 생성)와 별도로 운영자 결정만 자체 DB에 저장. + */ +@Entity +@Table(name = "gear_group_parent_resolution", schema = "kcg", + uniqueConstraints = @UniqueConstraint(columnNames = {"group_key", "sub_cluster_id"})) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class ParentResolution { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "group_key", nullable = false, length = 255) + private String groupKey; + + @Column(name = "sub_cluster_id", nullable = false) + private Integer subClusterId; + + @Column(name = "status", nullable = false, length = 30) + private String status; // UNRESOLVED, MANUAL_CONFIRMED, REVIEW_REQUIRED + + @Column(name = "selected_parent_mmsi", length = 20) + private String selectedParentMmsi; + + @Column(name = "rejected_candidate_mmsi", length = 20) + private String rejectedCandidateMmsi; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "approved_by") + private UUID approvedBy; + + @Column(name = "approved_at") + private OffsetDateTime approvedAt; + + @Column(name = "rejected_at") + private OffsetDateTime rejectedAt; + + @Column(name = "manual_comment", columnDefinition = "text") + private String manualComment; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + void prePersist() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + if (status == null) status = "UNRESOLVED"; + } + + @PreUpdate + void preUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentReviewLog.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentReviewLog.java new file mode 100644 index 0000000..3b75658 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentReviewLog.java @@ -0,0 +1,53 @@ +package gc.mda.kcg.domain.fleet; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * 운영자 액션 로그 (도메인 컨텍스트 보존). + * audit_log와 별개로 group_key 등 도메인 정보를 직접 저장. + */ +@Entity +@Table(name = "gear_group_parent_review_log", schema = "kcg") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class ParentReviewLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "group_key", nullable = false, length = 255) + private String groupKey; + + @Column(name = "sub_cluster_id") + private Integer subClusterId; + + @Column(name = "action", nullable = false, length = 30) + private String action; // CONFIRM, REJECT, RESET, EXCLUDE_GROUP, EXCLUDE_GLOBAL, LABEL_PARENT, CANCEL_LABEL, RELEASE_EXCLUSION + + @Column(name = "selected_parent_mmsi", length = 20) + private String selectedParentMmsi; + + @JdbcTypeCode(SqlTypes.UUID) + @Column(name = "actor") + private UUID actor; + + @Column(name = "actor_acnt", length = 50) + private String actorAcnt; + + @Column(name = "comment", columnDefinition = "text") + private String comment; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = OffsetDateTime.now(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/CancelRequest.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/CancelRequest.java new file mode 100644 index 0000000..629d6b1 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/CancelRequest.java @@ -0,0 +1,3 @@ +package gc.mda.kcg.domain.fleet.dto; + +public record CancelRequest(String reason) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/ExclusionRequest.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/ExclusionRequest.java new file mode 100644 index 0000000..18ccd77 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/ExclusionRequest.java @@ -0,0 +1,8 @@ +package gc.mda.kcg.domain.fleet.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ExclusionRequest( + @NotBlank String excludedMmsi, + String reason +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/GlobalExclusionRequest.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/GlobalExclusionRequest.java new file mode 100644 index 0000000..764d525 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/GlobalExclusionRequest.java @@ -0,0 +1,8 @@ +package gc.mda.kcg.domain.fleet.dto; + +import jakarta.validation.constraints.NotBlank; + +public record GlobalExclusionRequest( + @NotBlank String excludedMmsi, + String reason +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/LabelSessionRequest.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/LabelSessionRequest.java new file mode 100644 index 0000000..642a6b7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/LabelSessionRequest.java @@ -0,0 +1,10 @@ +package gc.mda.kcg.domain.fleet.dto; + +import jakarta.validation.constraints.NotBlank; + +import java.util.Map; + +public record LabelSessionRequest( + @NotBlank String labelParentMmsi, + Map anchorSnapshot +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/ReviewRequest.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/ReviewRequest.java new file mode 100644 index 0000000..2cff56f --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/dto/ReviewRequest.java @@ -0,0 +1,13 @@ +package gc.mda.kcg.domain.fleet.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 모선 확정/거부/리셋 요청. + * action: CONFIRM, REJECT, RESET + */ +public record ReviewRequest( + @NotBlank String action, + String selectedParentMmsi, + String comment +) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/CandidateExclusionRepository.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/CandidateExclusionRepository.java new file mode 100644 index 0000000..332221f --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/CandidateExclusionRepository.java @@ -0,0 +1,22 @@ +package gc.mda.kcg.domain.fleet.repository; + +import gc.mda.kcg.domain.fleet.CandidateExclusion; +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.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CandidateExclusionRepository extends JpaRepository { + + @Query("SELECT e FROM CandidateExclusion e WHERE e.releasedAt IS NULL ORDER BY e.createdAt DESC") + Page findActive(Pageable pageable); + + @Query("SELECT e FROM CandidateExclusion e WHERE e.scopeType = :scopeType AND e.releasedAt IS NULL ORDER BY e.createdAt DESC") + Page findActiveByScope(@Param("scopeType") String scopeType, Pageable pageable); + + @Query("SELECT e FROM CandidateExclusion e WHERE e.groupKey = :groupKey AND e.releasedAt IS NULL ORDER BY e.createdAt DESC") + List findActiveByGroupKey(@Param("groupKey") String groupKey); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/LabelSessionRepository.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/LabelSessionRepository.java new file mode 100644 index 0000000..a2a0e4a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/LabelSessionRepository.java @@ -0,0 +1,14 @@ +package gc.mda.kcg.domain.fleet.repository; + +import gc.mda.kcg.domain.fleet.LabelSession; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface LabelSessionRepository extends JpaRepository { + Page findByStatusOrderByCreatedAtDesc(String status, Pageable pageable); + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + List findByGroupKeyAndStatus(String groupKey, String status); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/ParentResolutionRepository.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/ParentResolutionRepository.java new file mode 100644 index 0000000..af03827 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/ParentResolutionRepository.java @@ -0,0 +1,16 @@ +package gc.mda.kcg.domain.fleet.repository; + +import gc.mda.kcg.domain.fleet.ParentResolution; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ParentResolutionRepository extends JpaRepository { + Optional findByGroupKeyAndSubClusterId(String groupKey, Integer subClusterId); + List findByGroupKey(String groupKey); + Page findByStatusOrderByUpdatedAtDesc(String status, Pageable pageable); + Page findAllByOrderByUpdatedAtDesc(Pageable pageable); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/ParentReviewLogRepository.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/ParentReviewLogRepository.java new file mode 100644 index 0000000..efc4c0d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/repository/ParentReviewLogRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.fleet.repository; + +import gc.mda.kcg.domain.fleet.ParentReviewLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ParentReviewLogRepository extends JpaRepository { + Page findByGroupKeyOrderByCreatedAtDesc(String groupKey, Pageable pageable); + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java b/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java new file mode 100644 index 0000000..7b00647 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java @@ -0,0 +1,47 @@ +package gc.mda.kcg.permission; + +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 권한 트리 + 역할 조회 API. + * - GET /api/perm-tree: 모든 사용자 (메뉴/사이드바 구성용) + * - GET /api/roles: admin:permission-management 권한 필요 + */ +@RestController +@RequiredArgsConstructor +public class PermTreeController { + + private final PermTreeRepository permTreeRepository; + private final RoleRepository roleRepository; + private final PermRepository permRepository; + + @GetMapping("/api/perm-tree") + public List getPermTree() { + return permTreeRepository.findAllByOrderByRsrcLevelAscSortOrdAsc(); + } + + @GetMapping("/api/roles") + @RequirePermission(resource = "admin:role-management", operation = "READ") + public List> getRolesWithPermissions() { + List roles = roleRepository.findAllByOrderByRoleSnAsc(); + return roles.stream().>map(r -> { + List perms = permRepository.findByRoleSn(r.getRoleSn()); + return Map.of( + "roleSn", r.getRoleSn(), + "roleCd", r.getRoleCd(), + "roleNm", r.getRoleNm(), + "roleDc", r.getRoleDc() == null ? "" : r.getRoleDc(), + "dfltYn", r.getDfltYn(), + "builtinYn", r.getBuiltinYn(), + "permissions", perms + ); + }).toList(); + } +} diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index cd4c989..fa0618f 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -28,10 +28,43 @@ 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'; -function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { user } = useAuth(); +/** + * 권한 가드. + * - user 미인증 시 /login으로 리다이렉트 + * - resource 지정 시 hasPermission 체크 → 거부 시 403 표시 + */ +function ProtectedRoute({ + children, + resource, + operation = 'READ', +}: { + children: React.ReactNode; + resource?: string; + operation?: string; +}) { + const { user, loading, hasPermission } = useAuth(); + if (loading) return null; if (!user) return ; + if (resource && !hasPermission(resource, operation)) { + return ( +
+
🚫
+

접근 권한이 없습니다

+

+ 이 페이지에 접근하려면 {resource}::{operation} 권한이 필요합니다. +

+
+ ); + } return <>{children}; } @@ -44,46 +77,54 @@ export default function App() { }> } /> {/* SFR-12 대시보드 */} - } /> - } /> + } /> + } /> {/* SFR-05~06 위험도·단속계획 */} - } /> - } /> + } /> + } /> {/* SFR-09~10 탐지 */} - } /> - } /> - } /> + } /> + } /> + } /> {/* SFR-07~08 순찰경로 */} - } /> - } /> + } /> + } /> {/* SFR-11 이력 */} - } /> - } /> + } /> + } /> {/* SFR-15~17 현장 대응 */} - } /> - } /> - } /> + } /> + } /> + } /> {/* SFR-13~14 통계·외부연계 */} - } /> - } /> - } /> + } /> + } /> + } /> {/* SFR-04 AI 모델 */} - } /> + } /> {/* SFR-18~20 AI 운영 */} - } /> - } /> + } /> + } /> {/* SFR-03 데이터허브 */} - } /> + } /> {/* SFR-02 환경설정 */} - } /> - } /> + } /> + } /> {/* SFR-01 권한·시스템 */} - } /> - } /> + } /> + } /> + {/* Phase 4: 관리자 로그 */} + } /> + } /> + } /> + {/* Phase 4: 모선 워크플로우 */} + } /> + } /> + } /> {/* 기존 유지 */} - } /> - } /> - } /> + } /> + } /> + } /> diff --git a/frontend/src/app/auth/AuthContext.tsx b/frontend/src/app/auth/AuthContext.tsx index 4de13af..02b01a9 100644 --- a/frontend/src/app/auth/AuthContext.tsx +++ b/frontend/src/app/auth/AuthContext.tsx @@ -57,12 +57,20 @@ const PATH_TO_RESOURCE: Record = { '/mlops': 'ai-operations:mlops', '/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 { diff --git a/frontend/src/app/layout/MainLayout.tsx b/frontend/src/app/layout/MainLayout.tsx index e744535..65eff20 100644 --- a/frontend/src/app/layout/MainLayout.tsx +++ b/frontend/src/app/layout/MainLayout.tsx @@ -9,6 +9,7 @@ import { ChevronsLeft, ChevronsRight, Navigation, Users, EyeOff, BarChart3, Globe, Smartphone, Monitor, Send, Cpu, MessageSquare, + GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound, } from 'lucide-react'; import { useAuth, type UserRole } from '@/app/auth/AuthContext'; import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner'; @@ -75,6 +76,15 @@ const NAV_ENTRIES: NavEntry[] = [ { 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, @@ -88,6 +98,9 @@ const NAV_ENTRIES: NavEntry[] = [ { 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' }, ], }, ]; diff --git a/frontend/src/features/admin/AccessLogs.tsx b/frontend/src/features/admin/AccessLogs.tsx new file mode 100644 index 0000000..a6333f6 --- /dev/null +++ b/frontend/src/features/admin/AccessLogs.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Loader2, RefreshCw } from 'lucide-react'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { fetchAccessLogs, type AccessLog } from '@/services/adminApi'; + +/** + * 접근 이력 조회 (모든 HTTP 요청). + * 권한: admin:access-logs (READ) + * + * 백엔드 AccessLogFilter가 모든 요청을 비동기로 기록. + */ +export function AccessLogs() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + setLoading(true); setError(''); + try { + const res = await fetchAccessLogs(0, 100); + setItems(res.content); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'unknown'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const statusColor = (s: number) => s >= 500 ? 'bg-red-500/20 text-red-400' : s >= 400 ? 'bg-orange-500/20 text-orange-400' : 'bg-green-500/20 text-green-400'; + + return ( +
+
+
+

접근 이력

+

모든 HTTP 요청 (AccessLogFilter 비동기 기록)

+
+ +
+ + {error &&
에러: {error}
} + + {loading &&
} + + {!loading && ( + + + + + + + + + + + + + + + + + {items.length === 0 && } + {items.map((it) => ( + + + + + + + + + + + ))} + +
SN시각사용자메서드경로상태시간(ms)IP
접근 로그가 없습니다.
{it.accessSn}{new Date(it.createdAt).toLocaleString('ko-KR')}{it.userAcnt || '-'}{it.httpMethod}{it.requestPath} + {it.statusCode} + {it.durationMs}{it.ipAddress || '-'}
+
+
+ )} +
+ ); +} diff --git a/frontend/src/features/admin/AuditLogs.tsx b/frontend/src/features/admin/AuditLogs.tsx new file mode 100644 index 0000000..1360ad4 --- /dev/null +++ b/frontend/src/features/admin/AuditLogs.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Loader2, RefreshCw } from 'lucide-react'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { fetchAuditLogs, type AuditLog } from '@/services/adminApi'; + +/** + * 감사 로그 조회 화면. + * 권한: admin:audit-logs (READ) + * + * 모든 운영자 의사결정 액션 (CONFIRM/REJECT/EXCLUDE/LABEL/LOGIN/...) + * 이 백엔드 AuditAspect를 통해 자동 기록됨. + */ +export function AuditLogs() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + setLoading(true); setError(''); + try { + const res = await fetchAuditLogs(0, 100); + setItems(res.content); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'unknown'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + return ( +
+
+
+

감사 로그

+

모든 운영자 의사결정 액션 자동 기록 (LOGIN/REVIEW_PARENT/EXCLUDE/LABEL...)

+
+ +
+ + {error &&
에러: {error}
} + + {loading &&
} + + {!loading && ( + + + + + + + + + + + + + + + + + + {items.length === 0 && } + {items.map((it) => ( + + + + + + + + + + + + ))} + +
SN시각사용자액션리소스결과실패 사유IP상세
감사 로그가 없습니다.
{it.auditSn}{new Date(it.createdAt).toLocaleString('ko-KR')}{it.userAcnt || '-'}{it.actionCd}{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''} + + {it.result || '-'} + + {it.failReason || '-'}{it.ipAddress || '-'} + {it.detail ? JSON.stringify(it.detail) : '-'} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/features/admin/LoginHistoryView.tsx b/frontend/src/features/admin/LoginHistoryView.tsx new file mode 100644 index 0000000..1842de6 --- /dev/null +++ b/frontend/src/features/admin/LoginHistoryView.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Loader2, RefreshCw } from 'lucide-react'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { fetchLoginHistory, type LoginHistory } from '@/services/adminApi'; + +/** + * 로그인 이력 조회. + * 권한: admin:login-history (READ) + */ +export function LoginHistoryView() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + setLoading(true); setError(''); + try { + const res = await fetchLoginHistory(0, 100); + setItems(res.content); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'unknown'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const resultColor = (r: string) => { + if (r === 'SUCCESS') return 'bg-green-500/20 text-green-400'; + if (r === 'LOCKED') return 'bg-red-500/20 text-red-400'; + return 'bg-orange-500/20 text-orange-400'; + }; + + return ( +
+
+
+

로그인 이력

+

성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)

+
+ +
+ + {error &&
에러: {error}
} + + {loading &&
} + + {!loading && ( + + + + + + + + + + + + + + + + {items.length === 0 && } + {items.map((it) => ( + + + + + + + + + + ))} + +
SN시각계정결과실패 사유인증 방식IP
로그인 이력이 없습니다.
{it.histSn}{new Date(it.loginDtm).toLocaleString('ko-KR')}{it.userAcnt} + {it.result} + {it.failReason || '-'}{it.authProvider || '-'}{it.loginIp || '-'}
+
+
+ )} +
+ ); +} diff --git a/frontend/src/features/parent-inference/LabelSession.tsx b/frontend/src/features/parent-inference/LabelSession.tsx new file mode 100644 index 0000000..9b63ccc --- /dev/null +++ b/frontend/src/features/parent-inference/LabelSession.tsx @@ -0,0 +1,185 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Tag, X, Loader2 } from 'lucide-react'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { useAuth } from '@/app/auth/AuthContext'; +import { + fetchLabelSessions, + createLabelSession, + cancelLabelSession, + type LabelSession as LabelSessionType, +} from '@/services/parentInferenceApi'; + +/** + * 모선 추론 학습 세션 페이지. + * 운영자가 정답 라벨링 → prediction 모델 학습 데이터로 활용. + * + * 권한: parent-inference-workflow:label-session (READ + CREATE + UPDATE) + */ + +const STATUS_COLORS: Record = { + ACTIVE: 'bg-green-500/20 text-green-400', + CANCELLED: 'bg-gray-500/20 text-gray-400', + COMPLETED: 'bg-blue-500/20 text-blue-400', +}; + +export function LabelSession() { + const { hasPermission } = useAuth(); + const canCreate = hasPermission('parent-inference-workflow:label-session', 'CREATE'); + const canUpdate = hasPermission('parent-inference-workflow:label-session', 'UPDATE'); + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [filter, setFilter] = useState(''); + const [busy, setBusy] = useState(null); + + // 신규 세션 + const [groupKey, setGroupKey] = useState(''); + const [subCluster, setSubCluster] = useState('1'); + const [labelMmsi, setLabelMmsi] = useState(''); + + const load = useCallback(async () => { + setLoading(true); setError(''); + try { + const res = await fetchLabelSessions(filter || undefined, 0, 50); + setItems(res.content); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'unknown'); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { load(); }, [load]); + + const handleCreate = async () => { + if (!canCreate || !groupKey || !labelMmsi) return; + setBusy(-1); + try { + await createLabelSession(groupKey, parseInt(subCluster, 10), { + labelParentMmsi: labelMmsi, + anchorSnapshot: { source: 'manual', timestamp: new Date().toISOString() }, + }); + setGroupKey(''); setLabelMmsi(''); + await load(); + } catch (e: unknown) { + alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); + } finally { + setBusy(null); + } + }; + + const handleCancel = async (id: number) => { + if (!canUpdate) return; + if (!confirm('세션을 취소하시겠습니까?')) return; + setBusy(id); + try { + await cancelLabelSession(id, '운영자 취소'); + await load(); + } catch (e: unknown) { + alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); + } finally { + setBusy(null); + } + }; + + return ( +
+
+
+

학습 세션

+

정답 라벨링 → prediction 모델 학습 데이터로 활용

+
+
+ + +
+
+ + + +
+ 신규 학습 세션 등록 + {!canCreate && 권한 없음} +
+
+ setGroupKey(e.target.value)} placeholder="group_key" + className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} /> + setSubCluster(e.target.value)} placeholder="sub" + className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} /> + setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI" + className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} /> + +
+
+
+ + {error &&
에러: {error}
} + + {loading && ( +
+ +
+ )} + + {!loading && ( + + + + + + + + + + + + + + + + + {items.length === 0 && ( + + )} + {items.map((it) => ( + + + + + + + + + + + ))} + +
IDGroup KeySub정답 MMSI상태생성자시작액션
학습 세션이 없습니다.
{it.id}{it.groupKey}{it.subClusterId}{it.labelParentMmsi} + {it.status} + {it.createdByAcnt || '-'}{new Date(it.activeFrom).toLocaleString('ko-KR')} + {it.status === 'ACTIVE' && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/features/parent-inference/ParentExclusion.tsx b/frontend/src/features/parent-inference/ParentExclusion.tsx new file mode 100644 index 0000000..3c03330 --- /dev/null +++ b/frontend/src/features/parent-inference/ParentExclusion.tsx @@ -0,0 +1,230 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Ban, RotateCcw, Loader2, Globe, Layers } from 'lucide-react'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { useAuth } from '@/app/auth/AuthContext'; +import { + fetchExclusions, + excludeForGroup, + excludeGlobal, + releaseExclusion, + type CandidateExclusion, +} from '@/services/parentInferenceApi'; + +/** + * 모선 후보 제외 페이지. + * - GROUP scope: 특정 group_key + sub_cluster에서 후보 제외 + * - GLOBAL scope: 모든 그룹에서 영구 제외 (admin: exclusion-management 권한 필요) + * + * 권한: + * - parent-inference-workflow:parent-exclusion (CREATE/UPDATE/READ): GROUP scope + * - parent-inference-workflow:exclusion-management (CREATE): GLOBAL scope + */ + +export function ParentExclusion() { + const { hasPermission } = useAuth(); + const canCreateGroup = hasPermission('parent-inference-workflow:parent-exclusion', 'CREATE'); + const canRelease = hasPermission('parent-inference-workflow:parent-exclusion', 'UPDATE'); + const canCreateGlobal = hasPermission('parent-inference-workflow:exclusion-management', 'CREATE'); + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [filter, setFilter] = useState<'' | 'GROUP' | 'GLOBAL'>(''); + const [busy, setBusy] = useState(null); + + // 신규 GROUP 제외 폼 + const [grpKey, setGrpKey] = useState(''); + const [grpSub, setGrpSub] = useState('1'); + const [grpMmsi, setGrpMmsi] = useState(''); + const [grpReason, setGrpReason] = useState(''); + + // 신규 GLOBAL 제외 폼 + const [glbMmsi, setGlbMmsi] = useState(''); + const [glbReason, setGlbReason] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await fetchExclusions(filter || undefined, 0, 50); + setItems(res.content); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'unknown'; + setError(msg); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { load(); }, [load]); + + const handleAddGroup = async () => { + if (!canCreateGroup || !grpKey || !grpMmsi) return; + setBusy(-1); + try { + await excludeForGroup(grpKey, parseInt(grpSub, 10), { excludedMmsi: grpMmsi, reason: grpReason }); + setGrpKey(''); setGrpMmsi(''); setGrpReason(''); + await load(); + } catch (e: unknown) { + alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); + } finally { + setBusy(null); + } + }; + + const handleAddGlobal = async () => { + if (!canCreateGlobal || !glbMmsi) return; + setBusy(-2); + try { + await excludeGlobal({ excludedMmsi: glbMmsi, reason: glbReason }); + setGlbMmsi(''); setGlbReason(''); + await load(); + } catch (e: unknown) { + alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); + } finally { + setBusy(null); + } + }; + + const handleRelease = async (id: number) => { + if (!canRelease) return; + setBusy(id); + try { + await releaseExclusion(id, '운영자 해제'); + await load(); + } catch (e: unknown) { + alert('실패: ' + (e instanceof Error ? e.message : 'unknown')); + } finally { + setBusy(null); + } + }; + + return ( +
+
+
+

모선 후보 제외

+

GROUP/GLOBAL 스코프로 잘못된 후보를 차단합니다.

+
+
+ + +
+
+ + {/* 신규 등록: GROUP */} + + +
+ GROUP 제외 (특정 그룹 한정) + {!canCreateGroup && 권한 없음} +
+
+ setGrpKey(e.target.value)} placeholder="group_key" + className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> + setGrpSub(e.target.value)} placeholder="sub" + className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> + setGrpMmsi(e.target.value)} placeholder="excluded MMSI" + className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> + setGrpReason(e.target.value)} placeholder="사유" + className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> + +
+
+
+ + {/* 신규 등록: GLOBAL */} + + +
+ GLOBAL 제외 (모든 그룹 영구 차단, 관리자 권한) + {!canCreateGlobal && 권한 없음} +
+
+ setGlbMmsi(e.target.value)} placeholder="excluded MMSI" + className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} /> + setGlbReason(e.target.value)} placeholder="사유" + className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} /> + +
+
+
+ + {error &&
에러: {error}
} + + {loading && ( +
+ +
+ )} + + {!loading && ( + + + + + + + + + + + + + + + + + + {items.length === 0 && ( + + )} + {items.map((it) => ( + + + + + + + + + + + + ))} + +
ID스코프Group KeySub제외 MMSI사유등록자생성액션
활성 제외 항목이 없습니다.
{it.id} + + {it.scopeType} + + {it.groupKey || '-'}{it.subClusterId ?? '-'}{it.excludedMmsi}{it.reason || '-'}{it.actorAcnt || '-'}{new Date(it.createdAt).toLocaleString('ko-KR')} + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/features/parent-inference/ParentReview.tsx b/frontend/src/features/parent-inference/ParentReview.tsx new file mode 100644 index 0000000..4e1f651 --- /dev/null +++ b/frontend/src/features/parent-inference/ParentReview.tsx @@ -0,0 +1,278 @@ +import { useEffect, useState, useCallback } from 'react'; +import { CheckCircle, XCircle, RotateCcw, Loader2 } from 'lucide-react'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { useAuth } from '@/app/auth/AuthContext'; +import { + fetchReviewList, + reviewParent, + type ParentResolution, +} from '@/services/parentInferenceApi'; + +/** + * 모선 확정/거부/리셋 페이지. + * - 운영자가 prediction이 추론한 모선 후보를 확정/거부. + * - 권한: parent-inference-workflow:parent-review (READ + UPDATE) + * - 모든 액션은 백엔드에서 audit_log + review_log에 기록 + */ + +const STATUS_COLORS: Record = { + UNRESOLVED: 'bg-yellow-500/20 text-yellow-400', + MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400', + REVIEW_REQUIRED: 'bg-red-500/20 text-red-400', +}; + +const STATUS_LABELS: Record = { + UNRESOLVED: '미해결', + MANUAL_CONFIRMED: '확정됨', + REVIEW_REQUIRED: '검토필요', +}; + +export function ParentReview() { + const { hasPermission } = useAuth(); + const canUpdate = hasPermission('parent-inference-workflow:parent-review', 'UPDATE'); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [actionLoading, setActionLoading] = useState(null); + const [filter, setFilter] = useState(''); + + // 새 그룹 입력 폼 (테스트용) + const [newGroupKey, setNewGroupKey] = useState(''); + const [newSubCluster, setNewSubCluster] = useState('1'); + const [newMmsi, setNewMmsi] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await fetchReviewList(filter || undefined, 0, 50); + setItems(res.content); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'unknown'; + setError(msg); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { + load(); + }, [load]); + + const handleAction = async ( + item: ParentResolution, + action: 'CONFIRM' | 'REJECT' | 'RESET', + selectedMmsi?: string, + ) => { + if (!canUpdate) return; + setActionLoading(item.id); + try { + await reviewParent(item.groupKey, item.subClusterId, { + action, + selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined, + comment: `${action} via UI`, + }); + await load(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'unknown'; + alert('처리 실패: ' + msg); + } finally { + setActionLoading(null); + } + }; + + const handleCreate = async () => { + if (!canUpdate || !newGroupKey || !newMmsi) return; + setActionLoading(-1); + try { + await reviewParent(newGroupKey, parseInt(newSubCluster, 10), { + action: 'CONFIRM', + selectedParentMmsi: newMmsi, + comment: '운영자 직접 등록', + }); + setNewGroupKey(''); + setNewMmsi(''); + await load(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'unknown'; + alert('등록 실패: ' + msg); + } finally { + setActionLoading(null); + } + }; + + return ( +
+
+
+

모선 확정/거부

+

+ 추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE) +

+
+
+ + +
+
+ + {/* 신규 등록 폼 (테스트용) */} + {canUpdate && ( + + +
신규 모선 확정 등록 (테스트)
+
+ setNewGroupKey(e.target.value)} + placeholder="group_key (예: 渔船A)" + className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" + /> + setNewSubCluster(e.target.value)} + placeholder="sub_cluster_id" + className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" + /> + setNewMmsi(e.target.value)} + placeholder="parent MMSI" + className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" + /> + +
+
+
+ )} + + {!canUpdate && ( + + +
+ 조회 전용 모드 (UPDATE 권한 없음). 확정/거부/리셋 액션이 비활성화됩니다. +
+
+
+ )} + + {error && ( + + +
에러: {error}
+
+
+ )} + + {loading && ( +
+ +
+ )} + + {!loading && items.length === 0 && ( + + + 등록된 모선 결정이 없습니다. 위의 폼으로 테스트 등록하거나, prediction 백엔드 연결 후 데이터가 채워집니다. + + + )} + + {!loading && items.length > 0 && ( + + + + + + + + + + + + + + + + {items.map((it) => ( + + + + + + + + + + ))} + +
IDGroup KeySub상태선택 MMSI갱신 시각액션
{it.id}{it.groupKey}{it.subClusterId} + + {STATUS_LABELS[it.status] || it.status} + + {it.selectedParentMmsi || '-'} + {new Date(it.updatedAt).toLocaleString('ko-KR')} + +
+ + + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index 2944231..e73ee3c 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -24,6 +24,17 @@ "systemConfig": "Settings", "notices": "Notices", "accessControl": "Access", + "admin": "Admin", + "parentReview": "Parent Review", + "parentExclusion": "Exclusion", + "labelSession": "Label Session", + "auditLogs": "Audit Logs", + "accessLogs": "Access Logs", + "loginHistory": "Login History" + }, + "group": { + "fieldOps": "Field Ops", + "parentInference": "Parent Workflow", "admin": "Admin" }, "status": { diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 16cb7ee..3b11c36 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -24,7 +24,18 @@ "systemConfig": "환경설정", "notices": "공지사항", "accessControl": "권한 관리", - "admin": "시스템 관리" + "admin": "시스템 관리", + "parentReview": "모선 확정/거부", + "parentExclusion": "후보 제외", + "labelSession": "학습 세션", + "auditLogs": "감사 로그", + "accessLogs": "접근 이력", + "loginHistory": "로그인 이력" + }, + "group": { + "fieldOps": "함정·현장", + "parentInference": "모선 워크플로우", + "admin": "관리자" }, "status": { "active": "활성", diff --git a/frontend/src/services/adminApi.ts b/frontend/src/services/adminApi.ts new file mode 100644 index 0000000..4b76bd3 --- /dev/null +++ b/frontend/src/services/adminApi.ts @@ -0,0 +1,100 @@ +/** + * 관리자 API 클라이언트 (감사 로그, 접근 이력, 로그인 이력, 권한 트리, 역할). + */ + +const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; + +export interface PageResponse { + content: T[]; + totalElements: number; + totalPages: number; + number: number; + size: number; +} + +export interface AuditLog { + auditSn: number; + userId: string | null; + userAcnt: string | null; + actionCd: string; + resourceType: string | null; + resourceId: string | null; + detail: Record | null; + ipAddress: string | null; + result: string | null; + failReason: string | null; + createdAt: string; +} + +export interface AccessLog { + accessSn: number; + userId: string | null; + userAcnt: string | null; + httpMethod: string; + requestPath: string; + queryString: string | null; + statusCode: number; + durationMs: number; + ipAddress: string | null; + userAgent: string | null; + createdAt: string; +} + +export interface LoginHistory { + histSn: number; + userId: string | null; + userAcnt: string; + loginDtm: string; + loginIp: string | null; + userAgent: string | null; + result: string; + failReason: string | null; + authProvider: string | null; +} + +export interface PermTreeNode { + rsrcCd: string; + parentCd: string | null; + rsrcNm: string; + rsrcDesc: string | null; + icon: string | null; + rsrcLevel: number; + sortOrd: number; + useYn: string; +} + +export interface RoleWithPermissions { + roleSn: number; + roleCd: string; + roleNm: string; + roleDc: string; + dfltYn: string; + builtinYn: string; + permissions: { permSn: number; roleSn: number; rsrcCd: string; operCd: string; grantYn: string }[]; +} + +async function apiGet(path: string): Promise { + const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' }); + if (!res.ok) throw new Error(`API ${res.status}: ${path}`); + return res.json(); +} + +export function fetchAuditLogs(page = 0, size = 50) { + return apiGet>(`/admin/audit-logs?page=${page}&size=${size}`); +} + +export function fetchAccessLogs(page = 0, size = 50) { + return apiGet>(`/admin/access-logs?page=${page}&size=${size}`); +} + +export function fetchLoginHistory(page = 0, size = 50) { + return apiGet>(`/admin/login-history?page=${page}&size=${size}`); +} + +export function fetchPermTree() { + return apiGet('/perm-tree'); +} + +export function fetchRoles() { + return apiGet('/roles'); +} diff --git a/frontend/src/services/parentInferenceApi.ts b/frontend/src/services/parentInferenceApi.ts new file mode 100644 index 0000000..d5affdc --- /dev/null +++ b/frontend/src/services/parentInferenceApi.ts @@ -0,0 +1,185 @@ +/** + * 모선 워크플로우 API 클라이언트. + * - 후보/리뷰: 자체 백엔드 (자체 DB의 운영자 결정) + * - 향후: iran 백엔드의 후보 데이터와 조합 (HYBRID) + */ + +const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; + +export interface ParentResolution { + id: number; + groupKey: string; + subClusterId: number; + status: 'UNRESOLVED' | 'MANUAL_CONFIRMED' | 'REVIEW_REQUIRED'; + selectedParentMmsi: string | null; + rejectedCandidateMmsi: string | null; + approvedBy: string | null; + approvedAt: string | null; + rejectedAt: string | null; + manualComment: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CandidateExclusion { + id: number; + scopeType: 'GROUP' | 'GLOBAL'; + groupKey: string | null; + subClusterId: number | null; + excludedMmsi: string; + reason: string | null; + actor: string | null; + actorAcnt: string | null; + createdAt: string; + releasedAt: string | null; + releasedByAcnt: string | null; +} + +export interface LabelSession { + id: number; + groupKey: string; + subClusterId: number; + labelParentMmsi: string; + status: 'ACTIVE' | 'CANCELLED' | 'COMPLETED'; + activeFrom: string; + activeUntil: string | null; + createdByAcnt: string | null; + cancelledAt: string | null; + cancelReason: string | null; + createdAt: string; +} + +export interface ReviewLog { + id: number; + groupKey: string; + subClusterId: number | null; + action: string; + selectedParentMmsi: string | null; + actorAcnt: string | null; + comment: string | null; + createdAt: string; +} + +export interface PageResponse { + content: T[]; + totalElements: number; + totalPages: number; + number: number; + size: number; +} + +async function apiRequest(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path}`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + ...init, + }); + if (!res.ok) { + let errBody = ''; + try { errBody = await res.text(); } catch { /* ignore */ } + throw new Error(`API ${res.status}: ${path} ${errBody}`); + } + return res.json(); +} + +// ============================================================================ +// Resolution +// ============================================================================ + +export function fetchReviewList(status?: string, page = 0, size = 20) { + const qs = new URLSearchParams(); + if (status) qs.set('status', status); + qs.set('page', String(page)); + qs.set('size', String(size)); + return apiRequest>(`/parent-inference/review?${qs}`); +} + +export function reviewParent( + groupKey: string, + subClusterId: number, + payload: { action: 'CONFIRM' | 'REJECT' | 'RESET'; selectedParentMmsi?: string; comment?: string }, +) { + return apiRequest( + `/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/review`, + { method: 'POST', body: JSON.stringify(payload) }, + ); +} + +// ============================================================================ +// Exclusions +// ============================================================================ + +export function fetchExclusions(scopeType?: 'GROUP' | 'GLOBAL', page = 0, size = 20) { + const qs = new URLSearchParams(); + if (scopeType) qs.set('scopeType', scopeType); + qs.set('page', String(page)); + qs.set('size', String(size)); + return apiRequest>(`/parent-inference/exclusions?${qs}`); +} + +export function excludeForGroup( + groupKey: string, + subClusterId: number, + payload: { excludedMmsi: string; reason?: string }, +) { + return apiRequest( + `/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/exclusions`, + { method: 'POST', body: JSON.stringify(payload) }, + ); +} + +export function excludeGlobal(payload: { excludedMmsi: string; reason?: string }) { + return apiRequest(`/parent-inference/exclusions/global`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function releaseExclusion(exclusionId: number, reason?: string) { + return apiRequest(`/parent-inference/exclusions/${exclusionId}/release`, { + method: 'POST', + body: JSON.stringify({ reason }), + }); +} + +// ============================================================================ +// Label Sessions +// ============================================================================ + +export function fetchLabelSessions(status?: string, page = 0, size = 20) { + const qs = new URLSearchParams(); + if (status) qs.set('status', status); + qs.set('page', String(page)); + qs.set('size', String(size)); + return apiRequest>(`/parent-inference/label-sessions?${qs}`); +} + +export function createLabelSession( + groupKey: string, + subClusterId: number, + payload: { labelParentMmsi: string; anchorSnapshot?: Record }, +) { + return apiRequest( + `/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/label-sessions`, + { method: 'POST', body: JSON.stringify(payload) }, + ); +} + +export function cancelLabelSession(sessionId: number, reason?: string) { + return apiRequest(`/parent-inference/label-sessions/${sessionId}/cancel`, { + method: 'POST', + body: JSON.stringify({ reason }), + }); +} + +// ============================================================================ +// Review Logs (도메인 액션 이력) +// ============================================================================ + +export function fetchReviewLogs(groupKey?: string, page = 0, size = 50) { + const qs = new URLSearchParams(); + if (groupKey) qs.set('groupKey', groupKey); + qs.set('page', String(page)); + qs.set('size', String(size)); + return apiRequest>(`/parent-inference/review-logs?${qs}`); +}