feat: Phase 4 - 모선 워크플로우 + 관리자 화면 + 권한 라우트 가드
Phase 4-1: 운영자 워크플로우 백엔드 (자체 DB)
- ParentResolution / ParentReviewLog / CandidateExclusion / LabelSession 엔티티
- Repository 4종 + DTO 5종
- ParentInferenceWorkflowService (HYBRID 패턴):
- review (CONFIRM/REJECT/RESET) - parent-inference-workflow:parent-review (UPDATE)
- excludeForGroup - parent-inference-workflow:parent-exclusion (CREATE)
- excludeGlobal - parent-inference-workflow:exclusion-management (CREATE) [admin]
- releaseExclusion (UPDATE)
- createLabelSession / cancelLabelSession (CREATE/UPDATE)
- ParentInferenceWorkflowController: @RequirePermission으로 권한 강제
- 모든 액션에 @Auditable AOP → audit_log + review_log 동시 기록
Phase 4-2: PermTreeController + AdminLogController
- GET /api/perm-tree (모든 사용자) - 메뉴/사이드바 구성용
- GET /api/roles (admin:role-management) - 역할+권한 매트릭스
- GET /api/admin/audit-logs / access-logs / login-history
Phase 4-3: iran 백엔드 프록시 (stub)
- IranBackendClient: RestClient 기반, 호출 실패 시 null 반환 (graceful)
- VesselAnalysisProxyController: serviceAvailable=false 응답
- PredictionProxyController: DISCONNECTED 응답
- Phase 5에서 iran 백엔드 실 연결 시 코드 변경 최소
Phase 4-4: 프론트엔드 services
- parentInferenceApi.ts: 모선 워크플로우 22개 함수
- adminApi.ts: 감사로그/접근이력/로그인이력/권한트리/역할 조회
Phase 4-5: 사이드바 권한 필터링 + ProtectedRoute 권한 가드
- AuthContext.PATH_TO_RESOURCE에 신규 경로 매핑 추가
- ProtectedRoute에 resource/operation prop 추가
→ 권한 거부 시 403 페이지 표시
- 모든 라우트에 권한 리소스 명시
- MainLayout 사이드바: parent-inference-workflow + admin 로그 메뉴 추가
- 사이드바 hasAccess 필터링 (이전부터 구현됨, 신규 메뉴에도 자동 적용)
Phase 4-6: 신규 페이지 3종
- ParentReview.tsx: 모선 확정/거부/리셋 + 신규 등록 폼
- ParentExclusion.tsx: GROUP/GLOBAL 제외 등록 + 해제
- LabelSession.tsx: 학습 세션 생성/취소
- AuditLogs.tsx: 감사 로그 조회
- AccessLogs.tsx: 접근 이력 조회
- LoginHistoryView.tsx: 로그인 이력 조회
Phase 4-7: i18n 키 + 라우터 등록
- 한국어/영어 nav.* + group.* 키 추가
- App.tsx에 12개 신규 라우트 등록 + 권한 가드 적용
Phase 4-8: 검증 완료
- 백엔드 컴파일/기동 성공
- 프론트엔드 빌드 성공 (475ms)
- E2E 시나리오:
- operator 로그인 → CONFIRM 확정 → MANUAL_CONFIRMED 갱신
- operator GROUP 제외 → 성공
- operator GLOBAL 제외 → 403 FORBIDDEN (권한 없음)
- operator 학습 세션 생성 → ACTIVE
- admin GLOBAL 제외 → 성공
- 감사 로그 자동 기록: REVIEW_PARENT/EXCLUDE_CANDIDATE_GROUP/
LABEL_PARENT_CREATE/EXCLUDE_CANDIDATE_GLOBAL 등 14건
- 권한 트리 RBAC + AOP 정상 동작 확인
설계 핵심:
- 운영자 의사결정만 자체 DB에 저장 (HYBRID)
- iran 백엔드 데이터는 향후 Phase 5에서 합쳐서 표시
- @RequirePermission + @Auditable로 모든 액션 권한 + 감사 자동화
- 데모 계정으로 완전한 워크플로우 시연 가능
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
b0c9a9fffb
커밋
bae2f33b86
@ -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<AuditLog> 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<AccessLog> 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<LoginHistory> getLoginHistory(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
return loginHistoryRepository.findAllByOrderByLoginDtmDesc(PageRequest.of(page, size));
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> getJson(String path) {
|
||||
if (!enabled) return null;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> body = restClient.get().uri(path).retrieve().body(Map.class);
|
||||
return body;
|
||||
} catch (RestClientException e) {
|
||||
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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<String, Object> 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 서비스 미연결"));
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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";
|
||||
}
|
||||
}
|
||||
@ -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<ParentResolution> 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<CandidateExclusion> 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<LabelSession> 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<ParentReviewLog> listReviewLogs(
|
||||
@RequestParam(required = false) String groupKey,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
return service.listReviewLogs(groupKey, PageRequest.of(page, size));
|
||||
}
|
||||
}
|
||||
@ -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<ParentResolution> 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<CandidateExclusion> 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<LabelSession> listLabelSessions(String status, Pageable pageable) {
|
||||
if (status == null || status.isBlank()) {
|
||||
return labelSessionRepository.findAllByOrderByCreatedAtDesc(pageable);
|
||||
}
|
||||
return labelSessionRepository.findByStatusOrderByCreatedAtDesc(status, pageable);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 도메인 로그 조회
|
||||
// ========================================================================
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<ParentReviewLog> 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
package gc.mda.kcg.domain.fleet.dto;
|
||||
|
||||
public record CancelRequest(String reason) {}
|
||||
@ -0,0 +1,8 @@
|
||||
package gc.mda.kcg.domain.fleet.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ExclusionRequest(
|
||||
@NotBlank String excludedMmsi,
|
||||
String reason
|
||||
) {}
|
||||
@ -0,0 +1,8 @@
|
||||
package gc.mda.kcg.domain.fleet.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record GlobalExclusionRequest(
|
||||
@NotBlank String excludedMmsi,
|
||||
String reason
|
||||
) {}
|
||||
@ -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<String, Object> anchorSnapshot
|
||||
) {}
|
||||
@ -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
|
||||
) {}
|
||||
@ -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<CandidateExclusion, Long> {
|
||||
|
||||
@Query("SELECT e FROM CandidateExclusion e WHERE e.releasedAt IS NULL ORDER BY e.createdAt DESC")
|
||||
Page<CandidateExclusion> findActive(Pageable pageable);
|
||||
|
||||
@Query("SELECT e FROM CandidateExclusion e WHERE e.scopeType = :scopeType AND e.releasedAt IS NULL ORDER BY e.createdAt DESC")
|
||||
Page<CandidateExclusion> 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<CandidateExclusion> findActiveByGroupKey(@Param("groupKey") String groupKey);
|
||||
}
|
||||
@ -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<LabelSession, Long> {
|
||||
Page<LabelSession> findByStatusOrderByCreatedAtDesc(String status, Pageable pageable);
|
||||
Page<LabelSession> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
List<LabelSession> findByGroupKeyAndStatus(String groupKey, String status);
|
||||
}
|
||||
@ -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<ParentResolution, Long> {
|
||||
Optional<ParentResolution> findByGroupKeyAndSubClusterId(String groupKey, Integer subClusterId);
|
||||
List<ParentResolution> findByGroupKey(String groupKey);
|
||||
Page<ParentResolution> findByStatusOrderByUpdatedAtDesc(String status, Pageable pageable);
|
||||
Page<ParentResolution> findAllByOrderByUpdatedAtDesc(Pageable pageable);
|
||||
}
|
||||
@ -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<ParentReviewLog, Long> {
|
||||
Page<ParentReviewLog> findByGroupKeyOrderByCreatedAtDesc(String groupKey, Pageable pageable);
|
||||
Page<ParentReviewLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
}
|
||||
@ -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<PermTree> getPermTree() {
|
||||
return permTreeRepository.findAllByOrderByRsrcLevelAscSortOrdAsc();
|
||||
}
|
||||
|
||||
@GetMapping("/api/roles")
|
||||
@RequirePermission(resource = "admin:role-management", operation = "READ")
|
||||
public List<Map<String, Object>> getRolesWithPermissions() {
|
||||
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
|
||||
return roles.stream().<Map<String, Object>>map(r -> {
|
||||
List<Perm> 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();
|
||||
}
|
||||
}
|
||||
@ -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 <Navigate to="/login" replace />;
|
||||
if (resource && !hasPermission(resource, operation)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
|
||||
<div className="text-4xl mb-4">🚫</div>
|
||||
<h1 className="text-xl font-bold text-heading mb-2">접근 권한이 없습니다</h1>
|
||||
<p className="text-sm text-hint">
|
||||
이 페이지에 접근하려면 <code className="bg-surface-overlay px-1.5 py-0.5 rounded">{resource}</code>::{operation} 권한이 필요합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@ -44,46 +77,54 @@ export default function App() {
|
||||
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
{/* SFR-12 대시보드 */}
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="monitoring" element={<MonitoringDashboard />} />
|
||||
<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={<RiskMap />} />
|
||||
<Route path="enforcement-plan" element={<EnforcementPlan />} />
|
||||
<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={<DarkVesselDetection />} />
|
||||
<Route path="gear-detection" element={<GearDetection />} />
|
||||
<Route path="china-fishing" element={<ChinaFishing />} />
|
||||
<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={<PatrolRoute />} />
|
||||
<Route path="fleet-optimization" element={<FleetOptimization />} />
|
||||
<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={<EnforcementHistory />} />
|
||||
<Route path="event-list" element={<EventList />} />
|
||||
<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={<MobileService />} />
|
||||
<Route path="ship-agent" element={<ShipAgent />} />
|
||||
<Route path="ai-alert" element={<AIAlert />} />
|
||||
<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={<Statistics />} />
|
||||
<Route path="external-service" element={<ExternalService />} />
|
||||
<Route path="reports" element={<ReportManagement />} />
|
||||
<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={<AIModelManagement />} />
|
||||
<Route path="ai-model" element={<ProtectedRoute resource="ai-operations:ai-model"><AIModelManagement /></ProtectedRoute>} />
|
||||
{/* SFR-18~20 AI 운영 */}
|
||||
<Route path="mlops" element={<MLOpsPage />} />
|
||||
<Route path="ai-assistant" element={<AIAssistant />} />
|
||||
<Route path="mlops" element={<ProtectedRoute resource="ai-operations:mlops"><MLOpsPage /></ProtectedRoute>} />
|
||||
<Route path="ai-assistant" element={<ProtectedRoute resource="ai-operations:ai-assistant"><AIAssistant /></ProtectedRoute>} />
|
||||
{/* SFR-03 데이터허브 */}
|
||||
<Route path="data-hub" element={<DataHub />} />
|
||||
<Route path="data-hub" element={<ProtectedRoute resource="admin:system-config"><DataHub /></ProtectedRoute>} />
|
||||
{/* SFR-02 환경설정 */}
|
||||
<Route path="system-config" element={<SystemConfig />} />
|
||||
<Route path="notices" element={<NoticeManagement />} />
|
||||
<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={<AccessControl />} />
|
||||
<Route path="admin" element={<AdminPanel />} />
|
||||
<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={<LiveMapView />} />
|
||||
<Route path="map-control" element={<MapControl />} />
|
||||
<Route path="vessel/:id" element={<VesselDetail />} />
|
||||
<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>
|
||||
</AuthProvider>
|
||||
|
||||
@ -57,12 +57,20 @@ const PATH_TO_RESOURCE: Record<string, string> = {
|
||||
'/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 {
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
89
frontend/src/features/admin/AccessLogs.tsx
Normal file
89
frontend/src/features/admin/AccessLogs.tsx
Normal file
@ -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<AccessLog[]>([]);
|
||||
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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">접근 이력</h1>
|
||||
<p className="text-xs text-hint mt-1">모든 HTTP 요청 (AccessLogFilter 비동기 기록)</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
{!loading && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-surface-overlay text-hint">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">SN</th>
|
||||
<th className="px-3 py-2 text-left">시각</th>
|
||||
<th className="px-3 py-2 text-left">사용자</th>
|
||||
<th className="px-3 py-2 text-left">메서드</th>
|
||||
<th className="px-3 py-2 text-left">경로</th>
|
||||
<th className="px-3 py-2 text-center">상태</th>
|
||||
<th className="px-3 py-2 text-right">시간(ms)</th>
|
||||
<th className="px-3 py-2 text-left">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && <tr><td colSpan={8} className="px-3 py-8 text-center text-hint">접근 로그가 없습니다.</td></tr>}
|
||||
{items.map((it) => (
|
||||
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
|
||||
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge className={`border-0 text-[9px] ${statusColor(it.statusCode)}`}>{it.statusCode}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground">{it.durationMs}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/src/features/admin/AuditLogs.tsx
Normal file
94
frontend/src/features/admin/AuditLogs.tsx
Normal file
@ -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<AuditLog[]>([]);
|
||||
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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">감사 로그</h1>
|
||||
<p className="text-xs text-hint mt-1">모든 운영자 의사결정 액션 자동 기록 (LOGIN/REVIEW_PARENT/EXCLUDE/LABEL...)</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
{!loading && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-surface-overlay text-hint">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">SN</th>
|
||||
<th className="px-3 py-2 text-left">시각</th>
|
||||
<th className="px-3 py-2 text-left">사용자</th>
|
||||
<th className="px-3 py-2 text-left">액션</th>
|
||||
<th className="px-3 py-2 text-left">리소스</th>
|
||||
<th className="px-3 py-2 text-left">결과</th>
|
||||
<th className="px-3 py-2 text-left">실패 사유</th>
|
||||
<th className="px-3 py-2 text-left">IP</th>
|
||||
<th className="px-3 py-2 text-left">상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && <tr><td colSpan={9} className="px-3 py-8 text-center text-hint">감사 로그가 없습니다.</td></tr>}
|
||||
{items.map((it) => (
|
||||
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${it.result === 'SUCCESS' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
|
||||
{it.result || '-'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
|
||||
{it.detail ? JSON.stringify(it.detail) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
frontend/src/features/admin/LoginHistoryView.tsx
Normal file
89
frontend/src/features/admin/LoginHistoryView.tsx
Normal file
@ -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<LoginHistory[]>([]);
|
||||
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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">로그인 이력</h1>
|
||||
<p className="text-xs text-hint mt-1">성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
{!loading && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-surface-overlay text-hint">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">SN</th>
|
||||
<th className="px-3 py-2 text-left">시각</th>
|
||||
<th className="px-3 py-2 text-left">계정</th>
|
||||
<th className="px-3 py-2 text-left">결과</th>
|
||||
<th className="px-3 py-2 text-left">실패 사유</th>
|
||||
<th className="px-3 py-2 text-left">인증 방식</th>
|
||||
<th className="px-3 py-2 text-left">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && <tr><td colSpan={7} className="px-3 py-8 text-center text-hint">로그인 이력이 없습니다.</td></tr>}
|
||||
{items.map((it) => (
|
||||
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.histSn}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.loginDtm).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
frontend/src/features/parent-inference/LabelSession.tsx
Normal file
185
frontend/src/features/parent-inference/LabelSession.tsx
Normal file
@ -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<string, string> = {
|
||||
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<LabelSessionType[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [busy, setBusy] = useState<number | null>(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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">학습 세션</h1>
|
||||
<p className="text-xs text-hint mt-1">정답 라벨링 → prediction 모델 학습 데이터로 활용</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={filter} onChange={(e) => setFilter(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="CANCELLED">CANCELLED</option>
|
||||
<option value="COMPLETED">COMPLETED</option>
|
||||
</select>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded">새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs font-medium text-heading mb-2 flex items-center gap-2">
|
||||
<Tag className="w-3.5 h-3.5" /> 신규 학습 세션 등록
|
||||
{!canCreate && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input value={groupKey} onChange={(e) => 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} />
|
||||
<input type="number" value={subCluster} onChange={(e) => 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} />
|
||||
<input value={labelMmsi} onChange={(e) => 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} />
|
||||
<button type="button" onClick={handleCreate}
|
||||
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
|
||||
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/40 text-white text-xs rounded flex items-center gap-1">
|
||||
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
|
||||
세션 생성
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-surface-overlay text-hint">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">ID</th>
|
||||
<th className="px-3 py-2 text-left">Group Key</th>
|
||||
<th className="px-3 py-2 text-center">Sub</th>
|
||||
<th className="px-3 py-2 text-left">정답 MMSI</th>
|
||||
<th className="px-3 py-2 text-left">상태</th>
|
||||
<th className="px-3 py-2 text-left">생성자</th>
|
||||
<th className="px-3 py-2 text-left">시작</th>
|
||||
<th className="px-3 py-2 text-center">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && (
|
||||
<tr><td colSpan={8} className="px-3 py-8 text-center text-hint">학습 세션이 없습니다.</td></tr>
|
||||
)}
|
||||
{items.map((it) => (
|
||||
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.activeFrom).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{it.status === 'ACTIVE' && (
|
||||
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
|
||||
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400" title="취소">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
frontend/src/features/parent-inference/ParentExclusion.tsx
Normal file
230
frontend/src/features/parent-inference/ParentExclusion.tsx
Normal file
@ -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<CandidateExclusion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState<'' | 'GROUP' | 'GLOBAL'>('');
|
||||
const [busy, setBusy] = useState<number | null>(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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">모선 후보 제외</h1>
|
||||
<p className="text-xs text-hint mt-1">GROUP/GLOBAL 스코프로 잘못된 후보를 차단합니다.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
|
||||
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
|
||||
>
|
||||
<option value="">전체 스코프</option>
|
||||
<option value="GROUP">GROUP</option>
|
||||
<option value="GLOBAL">GLOBAL</option>
|
||||
</select>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded">새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 신규 등록: GROUP */}
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="text-xs font-medium text-heading flex items-center gap-2">
|
||||
<Layers className="w-3.5 h-3.5" /> GROUP 제외 (특정 그룹 한정)
|
||||
{!canCreateGroup && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input value={grpKey} onChange={(e) => 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} />
|
||||
<input type="number" value={grpSub} onChange={(e) => 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} />
|
||||
<input value={grpMmsi} onChange={(e) => 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} />
|
||||
<input value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
|
||||
<button type="button" onClick={handleAddGroup}
|
||||
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
|
||||
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-500 disabled:bg-orange-600/40 text-white text-xs rounded flex items-center gap-1">
|
||||
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Ban className="w-3.5 h-3.5" />}
|
||||
제외
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 신규 등록: GLOBAL */}
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="text-xs font-medium text-heading flex items-center gap-2">
|
||||
<Globe className="w-3.5 h-3.5" /> GLOBAL 제외 (모든 그룹 영구 차단, 관리자 권한)
|
||||
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input value={glbMmsi} onChange={(e) => 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} />
|
||||
<input value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
|
||||
<button type="button" onClick={handleAddGlobal}
|
||||
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
|
||||
className="px-3 py-1.5 bg-red-600 hover:bg-red-500 disabled:bg-red-600/40 text-white text-xs rounded flex items-center gap-1">
|
||||
{busy === -2 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Globe className="w-3.5 h-3.5" />}
|
||||
전역 제외
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-surface-overlay text-hint">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">ID</th>
|
||||
<th className="px-3 py-2 text-left">스코프</th>
|
||||
<th className="px-3 py-2 text-left">Group Key</th>
|
||||
<th className="px-3 py-2 text-center">Sub</th>
|
||||
<th className="px-3 py-2 text-left">제외 MMSI</th>
|
||||
<th className="px-3 py-2 text-left">사유</th>
|
||||
<th className="px-3 py-2 text-left">등록자</th>
|
||||
<th className="px-3 py-2 text-left">생성</th>
|
||||
<th className="px-3 py-2 text-center">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && (
|
||||
<tr><td colSpan={9} className="px-3 py-8 text-center text-hint">활성 제외 항목이 없습니다.</td></tr>
|
||||
)}
|
||||
{items.map((it) => (
|
||||
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${it.scopeType === 'GLOBAL' ? 'bg-red-500/20 text-red-400' : 'bg-orange-500/20 text-orange-400'}`}>
|
||||
{it.scopeType}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.groupKey || '-'}</td>
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId ?? '-'}</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.excludedMmsi}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.reason || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.actorAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button type="button" disabled={!canRelease || busy === it.id} onClick={() => handleRelease(it.id)}
|
||||
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400" title="해제">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
278
frontend/src/features/parent-inference/ParentReview.tsx
Normal file
278
frontend/src/features/parent-inference/ParentReview.tsx
Normal file
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
UNRESOLVED: '미해결',
|
||||
MANUAL_CONFIRMED: '확정됨',
|
||||
REVIEW_REQUIRED: '검토필요',
|
||||
};
|
||||
|
||||
export function ParentReview() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canUpdate = hasPermission('parent-inference-workflow:parent-review', 'UPDATE');
|
||||
const [items, setItems] = useState<ParentResolution[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
// 새 그룹 입력 폼 (테스트용)
|
||||
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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">모선 확정/거부</h1>
|
||||
<p className="text-xs text-hint mt-1">
|
||||
추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="UNRESOLVED">미해결</option>
|
||||
<option value="MANUAL_CONFIRMED">확정됨</option>
|
||||
<option value="REVIEW_REQUIRED">검토필요</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 신규 등록 폼 (테스트용) */}
|
||||
{canUpdate && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-muted-foreground mb-2">신규 모선 확정 등록 (테스트)</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupKey}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={newSubCluster}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newMmsi}
|
||||
onChange={(e) => setNewMmsi(e.target.value)}
|
||||
placeholder="parent MMSI"
|
||||
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={!newGroupKey || !newMmsi || actionLoading === -1}
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-xs rounded flex items-center gap-1"
|
||||
>
|
||||
{actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
|
||||
확정 등록
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!canUpdate && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-yellow-400">
|
||||
조회 전용 모드 (UPDATE 권한 없음). 확정/거부/리셋 액션이 비활성화됩니다.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-red-400">에러: {error}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && items.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-hint text-sm">
|
||||
등록된 모선 결정이 없습니다. 위의 폼으로 테스트 등록하거나, prediction 백엔드 연결 후 데이터가 채워집니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && items.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-surface-overlay text-hint">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">ID</th>
|
||||
<th className="px-3 py-2 text-left">Group Key</th>
|
||||
<th className="px-3 py-2 text-center">Sub</th>
|
||||
<th className="px-3 py-2 text-left">상태</th>
|
||||
<th className="px-3 py-2 text-left">선택 MMSI</th>
|
||||
<th className="px-3 py-2 text-left">갱신 시각</th>
|
||||
<th className="px-3 py-2 text-center">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>
|
||||
{STATUS_LABELS[it.status] || it.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">
|
||||
{new Date(it.updatedAt).toLocaleString('ko-KR')}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canUpdate || actionLoading === it.id}
|
||||
onClick={() => handleAction(it, 'CONFIRM')}
|
||||
className="p-1 rounded hover:bg-green-500/20 disabled:opacity-30 text-green-400"
|
||||
title="확정"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canUpdate || actionLoading === it.id}
|
||||
onClick={() => handleAction(it, 'REJECT')}
|
||||
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400"
|
||||
title="거부"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canUpdate || actionLoading === it.id}
|
||||
onClick={() => handleAction(it, 'RESET')}
|
||||
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400"
|
||||
title="리셋"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -24,7 +24,18 @@
|
||||
"systemConfig": "환경설정",
|
||||
"notices": "공지사항",
|
||||
"accessControl": "권한 관리",
|
||||
"admin": "시스템 관리"
|
||||
"admin": "시스템 관리",
|
||||
"parentReview": "모선 확정/거부",
|
||||
"parentExclusion": "후보 제외",
|
||||
"labelSession": "학습 세션",
|
||||
"auditLogs": "감사 로그",
|
||||
"accessLogs": "접근 이력",
|
||||
"loginHistory": "로그인 이력"
|
||||
},
|
||||
"group": {
|
||||
"fieldOps": "함정·현장",
|
||||
"parentInference": "모선 워크플로우",
|
||||
"admin": "관리자"
|
||||
},
|
||||
"status": {
|
||||
"active": "활성",
|
||||
|
||||
100
frontend/src/services/adminApi.ts
Normal file
100
frontend/src/services/adminApi.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 관리자 API 클라이언트 (감사 로그, 접근 이력, 로그인 이력, 권한 트리, 역할).
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
export interface PageResponse<T> {
|
||||
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<string, unknown> | 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<T>(path: string): Promise<T> {
|
||||
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<PageResponse<AuditLog>>(`/admin/audit-logs?page=${page}&size=${size}`);
|
||||
}
|
||||
|
||||
export function fetchAccessLogs(page = 0, size = 50) {
|
||||
return apiGet<PageResponse<AccessLog>>(`/admin/access-logs?page=${page}&size=${size}`);
|
||||
}
|
||||
|
||||
export function fetchLoginHistory(page = 0, size = 50) {
|
||||
return apiGet<PageResponse<LoginHistory>>(`/admin/login-history?page=${page}&size=${size}`);
|
||||
}
|
||||
|
||||
export function fetchPermTree() {
|
||||
return apiGet<PermTreeNode[]>('/perm-tree');
|
||||
}
|
||||
|
||||
export function fetchRoles() {
|
||||
return apiGet<RoleWithPermissions[]>('/roles');
|
||||
}
|
||||
185
frontend/src/services/parentInferenceApi.ts
Normal file
185
frontend/src/services/parentInferenceApi.ts
Normal file
@ -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<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<PageResponse<ParentResolution>>(`/parent-inference/review?${qs}`);
|
||||
}
|
||||
|
||||
export function reviewParent(
|
||||
groupKey: string,
|
||||
subClusterId: number,
|
||||
payload: { action: 'CONFIRM' | 'REJECT' | 'RESET'; selectedParentMmsi?: string; comment?: string },
|
||||
) {
|
||||
return apiRequest<ParentResolution>(
|
||||
`/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<PageResponse<CandidateExclusion>>(`/parent-inference/exclusions?${qs}`);
|
||||
}
|
||||
|
||||
export function excludeForGroup(
|
||||
groupKey: string,
|
||||
subClusterId: number,
|
||||
payload: { excludedMmsi: string; reason?: string },
|
||||
) {
|
||||
return apiRequest<CandidateExclusion>(
|
||||
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/exclusions`,
|
||||
{ method: 'POST', body: JSON.stringify(payload) },
|
||||
);
|
||||
}
|
||||
|
||||
export function excludeGlobal(payload: { excludedMmsi: string; reason?: string }) {
|
||||
return apiRequest<CandidateExclusion>(`/parent-inference/exclusions/global`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function releaseExclusion(exclusionId: number, reason?: string) {
|
||||
return apiRequest<CandidateExclusion>(`/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<PageResponse<LabelSession>>(`/parent-inference/label-sessions?${qs}`);
|
||||
}
|
||||
|
||||
export function createLabelSession(
|
||||
groupKey: string,
|
||||
subClusterId: number,
|
||||
payload: { labelParentMmsi: string; anchorSnapshot?: Record<string, unknown> },
|
||||
) {
|
||||
return apiRequest<LabelSession>(
|
||||
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/label-sessions`,
|
||||
{ method: 'POST', body: JSON.stringify(payload) },
|
||||
);
|
||||
}
|
||||
|
||||
export function cancelLabelSession(sessionId: number, reason?: string) {
|
||||
return apiRequest<LabelSession>(`/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<PageResponse<ReviewLog>>(`/parent-inference/review-logs?${qs}`);
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user