From 7d79dcf93abcac507591c78a236e0831427c7942 Mon Sep 17 00:00:00 2001 From: bjkim Date: Tue, 23 Sep 2025 14:25:04 +0900 Subject: [PATCH] =?UTF-8?q?[ADD]=20Experiments=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=97=94=ED=8B=B0=ED=8B=B0,=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4,=20=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84=20(ExperimentsController)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/ExperimentsController.java | 79 ++++++++++++++++++ .../KubeflowExperimentsController.java | 2 +- .../autoflow/entity/ExperimentsEntity.java | 74 +++++++++++++++++ .../repository/ExperimentsRepository.java | 9 +++ .../autoflow/service/ExperimentsService.java | 80 +++++++++++++++++++ .../ExperimentsSpecification.java | 66 +++++++++++++++ 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 src/main/java/kr/re/etri/autoflow/controllers/ExperimentsController.java create mode 100644 src/main/java/kr/re/etri/autoflow/entity/ExperimentsEntity.java create mode 100644 src/main/java/kr/re/etri/autoflow/repository/ExperimentsRepository.java create mode 100644 src/main/java/kr/re/etri/autoflow/service/ExperimentsService.java create mode 100644 src/main/java/kr/re/etri/autoflow/specification/ExperimentsSpecification.java diff --git a/src/main/java/kr/re/etri/autoflow/controllers/ExperimentsController.java b/src/main/java/kr/re/etri/autoflow/controllers/ExperimentsController.java new file mode 100644 index 0000000..bd23661 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/controllers/ExperimentsController.java @@ -0,0 +1,79 @@ +package kr.re.etri.autoflow.controllers; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.re.etri.autoflow.entity.ExperimentsEntity; +import kr.re.etri.autoflow.payload.request.ProjectBaseSearchRequest; +import kr.re.etri.autoflow.service.ExperimentsService; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Experiments", description = "Kubeflow 및 MLflow Experiment API") +@RestController +@RequestMapping("/api/experiments") +@RequiredArgsConstructor +public class ExperimentsController { + + private final ExperimentsService experimentsService; + + @Operation(summary = "모든 Experiments 조회") + @GetMapping + public ResponseEntity> getAllExperiments() { + return ResponseEntity.ok(experimentsService.findAll()); + } + + @Operation(summary = "Experiment 단건 조회") + @GetMapping("/{id}") + public ResponseEntity getExperiment( + @Parameter(description = "Experiment ID", example = "1") @PathVariable("id") Long id) { + + return experimentsService.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @Operation(summary = "Experiment 검색 및 페이지네이션") + @GetMapping("/search") + public ResponseEntity> searchExperiments( + @ParameterObject @ModelAttribute ProjectBaseSearchRequest request) { + Page page = experimentsService.search(request); + return ResponseEntity.ok(page); + } + + @Operation(summary = "Experiment 등록") + @PostMapping + public ResponseEntity createExperiment(@RequestBody ExperimentsEntity experiment) { + return ResponseEntity.ok(experimentsService.save(experiment)); + } + + @Operation(summary = "Experiment 수정") + @PutMapping("/{id}") + public ResponseEntity updateExperiment( + @Parameter(description = "Experiment ID", example = "1") @PathVariable("id") Long id, + @RequestBody ExperimentsEntity experiment) { + + return experimentsService.findById(id) + .map(existing -> { + experiment.setId(id); + return ResponseEntity.ok(experimentsService.save(experiment)); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @Operation(summary = "Experiment 삭제") + @DeleteMapping("/{id}") + public ResponseEntity deleteExperiment( + @Parameter(description = "Experiment ID", example = "1") @PathVariable("id") Long id) { + + if (experimentsService.deleteById(id)) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.notFound().build(); + } +} diff --git a/src/main/java/kr/re/etri/autoflow/controllers/KubeflowExperimentsController.java b/src/main/java/kr/re/etri/autoflow/controllers/KubeflowExperimentsController.java index 8c0f34b..2d6fbfc 100644 --- a/src/main/java/kr/re/etri/autoflow/controllers/KubeflowExperimentsController.java +++ b/src/main/java/kr/re/etri/autoflow/controllers/KubeflowExperimentsController.java @@ -16,7 +16,7 @@ import java.util.Map; @Slf4j @RestController -@RequestMapping("/api") +@RequestMapping("/api/kubeflow") @RequiredArgsConstructor @CrossOrigin(origins = "*") @io.swagger.v3.oas.annotations.tags.Tag(name = "Kubeflow Pipeline", description = "Kubeflow 파이프라인 API") diff --git a/src/main/java/kr/re/etri/autoflow/entity/ExperimentsEntity.java b/src/main/java/kr/re/etri/autoflow/entity/ExperimentsEntity.java new file mode 100644 index 0000000..2af84b9 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/entity/ExperimentsEntity.java @@ -0,0 +1,74 @@ +package kr.re.etri.autoflow.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@EntityListeners(AuditingEntityListener.class) +@Table(name = "tb_experiments") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Kubeflow 및 MLflow Experiment 엔티티") +public class ExperimentsEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "DB PK") + private Long id; + + @Schema(description = "Kubeflow / MLflow Experiment ID") + private String kubeFlowId; + + @Schema(description = "Kubeflow / MLflow Experiment ID") + private String mlFlowId; + + @Schema(description = "Experiment 이름 / Kubeflow: name, MLflow: name") + private String name; + + @Schema(description = "Experiment 표시 이름 / Kubeflow: display_name, MLflow: name or display_name") + private String displayName; + + @Schema(description = "Experiment 설명 / Kubeflow: description, MLflow: optional description") + private String description; + + @Schema(description = "MLflow Artifact 경로 / Kubeflow에는 해당 없음") + private String artifactLocation; + + @Schema(description = "MLflow 상태 / lifecycle_stage, Kubeflow에는 해당 없음") + private String lifecycleStage; + + @Schema(description = "Kubeflow 저장 상태 / storage_state, MLflow에는 해당 없음") + private String storageState; + + @CreatedDate + @Schema(description = "Experiment 생성 일시 / Kubeflow: ISO 8601 created_at, MLflow: timestamp(ms) → LocalDateTime") + private LocalDateTime kubeflowCreatedAt; + + @CreatedDate + @Schema(description = "Experiment 생성 일시 / Kubeflow: ISO 8601 created_at, MLflow: timestamp(ms) → LocalDateTime") + private LocalDateTime mlflowCreatedAt; + + + @LastModifiedDate + @Schema(description = "Experiment 마지막 업데이트 일시 / MLflow: last_update_time(ms) → LocalDateTime, Kubeflow에는 해당 없음") + private LocalDateTime lastUpdateTime; + + @Schema(description = "Kubeflow 마지막 Run 생성 일시 / last_run_created_at") + private LocalDateTime lastRunCreatedAt; + + @Schema(description = "등록자 ID") + private String regUserId; + + @Schema(description = "프로젝트 ID / nullable 아님") + @Column(nullable = false) + private Long projectId; +} diff --git a/src/main/java/kr/re/etri/autoflow/repository/ExperimentsRepository.java b/src/main/java/kr/re/etri/autoflow/repository/ExperimentsRepository.java new file mode 100644 index 0000000..70188d2 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/repository/ExperimentsRepository.java @@ -0,0 +1,9 @@ +package kr.re.etri.autoflow.repository; + +import kr.re.etri.autoflow.entity.ExperimentsEntity; +import kr.re.etri.autoflow.entity.WorkflowEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface ExperimentsRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/src/main/java/kr/re/etri/autoflow/service/ExperimentsService.java b/src/main/java/kr/re/etri/autoflow/service/ExperimentsService.java new file mode 100644 index 0000000..d3d56a6 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/service/ExperimentsService.java @@ -0,0 +1,80 @@ +package kr.re.etri.autoflow.service; + +import jakarta.transaction.Transactional; +import kr.re.etri.autoflow.entity.ExperimentsEntity; +import kr.re.etri.autoflow.payload.request.ProjectBaseSearchRequest; +import kr.re.etri.autoflow.repository.ExperimentsRepository; +import kr.re.etri.autoflow.specification.ExperimentsSpecification; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ExperimentsService { + + private final ExperimentsRepository experimentsRepository; + private final ExperimentsSpecification experimentsSpecification; + + public List findAll() { + return experimentsRepository.findAll(); + } + + public Optional findById(Long id) { + return experimentsRepository.findById(id); + } + + @Transactional + public ExperimentsEntity save(ExperimentsEntity experiment) { + if (experiment.getId() == null) { + // 신규 생성 + // 버전 관리가 필요하다면 필드 추가 후 처리 가능 + } else { + // 업데이트 시 기존 엔티티 확인 + experimentsRepository.findById(experiment.getId()) + .orElseThrow(() -> new IllegalArgumentException("Experiment가 존재하지 않습니다. id=" + experiment.getId())); + } + return experimentsRepository.save(experiment); + } + + @Transactional + public boolean deleteById(Long id) { + if (experimentsRepository.existsById(id)) { + experimentsRepository.deleteById(id); + return true; + } + return false; + } + + @Transactional + public Page search(ProjectBaseSearchRequest request) { + int pageIndex = request.getPage() > 0 ? request.getPage() - 1 : 0; + + Pageable pageable = PageRequest.of( + pageIndex, + request.getSize(), + Sort.by(Sort.Direction.fromString(request.getSortDirection()), request.getSortField()) + ); + + Specification spec = experimentsSpecification.searchByConditions( + request.getSearchType(), + request.getKeyword() + ); + + // projectId가 있으면 조건 추가 + if (request.getProjectId() != null) { + spec = spec.and((root, query, cb) -> + cb.equal(root.get("projectId"), request.getProjectId()) + ); + } + + return experimentsRepository.findAll(spec, pageable); + } +} diff --git a/src/main/java/kr/re/etri/autoflow/specification/ExperimentsSpecification.java b/src/main/java/kr/re/etri/autoflow/specification/ExperimentsSpecification.java new file mode 100644 index 0000000..7c4b249 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/specification/ExperimentsSpecification.java @@ -0,0 +1,66 @@ +package kr.re.etri.autoflow.specification; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.Metamodel; +import kr.re.etri.autoflow.entity.ExperimentsEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class ExperimentsSpecification { + + private final EntityManager entityManager; + private Set stringFields; + + public ExperimentsSpecification(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @PostConstruct + public void init() { + Metamodel metamodel = entityManager.getMetamodel(); + EntityType entityType = metamodel.entity(ExperimentsEntity.class); + + // 문자열 타입 필드명만 추출 + stringFields = entityType.getAttributes().stream() + .filter(attr -> attr.getJavaType().equals(String.class)) + .map(Attribute::getName) + .collect(Collectors.toSet()); + + log.info("ExperimentsEntity string fields: {}", stringFields); + } + + public Specification searchByConditions(String searchType, String keyword) { + return (root, query, cb) -> { + Predicate predicate = cb.conjunction(); + + if (keyword != null && !keyword.isEmpty()) { + if (searchType == null || searchType.isEmpty() || + "전체".equalsIgnoreCase(searchType) || "all".equalsIgnoreCase(searchType)) { + Predicate orPredicate = cb.disjunction(); + for (String field : stringFields) { + orPredicate = cb.or(orPredicate, + cb.like(cb.lower(root.get(field)), "%" + keyword.toLowerCase() + "%")); + } + predicate = cb.and(predicate, orPredicate); + + } else if (stringFields.contains(searchType)) { + predicate = cb.and(predicate, + cb.like(cb.lower(root.get(searchType)), "%" + keyword.toLowerCase() + "%")); + } + // 날짜 타입 필드 검색 시 무시 + } + + return predicate; + }; + } +}