diff --git a/src/main/java/kr/re/etri/autoflow/controllers/TrainingScriptController.java b/src/main/java/kr/re/etri/autoflow/controllers/TrainingScriptController.java new file mode 100644 index 0000000..7b92f18 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/controllers/TrainingScriptController.java @@ -0,0 +1,109 @@ +package kr.re.etri.autoflow.controllers; + +import io.minio.*; +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.TrainingScriptAttachmentEntity; +import kr.re.etri.autoflow.payload.request.BaseSearchRequest; +import kr.re.etri.autoflow.repository.TrainingScriptAttachmentRepository; +import kr.re.etri.autoflow.service.TrainingScriptService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.*; + +@RestController +@RequestMapping("/api/trainingscript") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "트레이닝 스크립트", description = "트레이닝 스크립트 MinIO 버킷/파일 관리 API") +public class TrainingScriptController { + private final MinioClient minioClient; + private final TrainingScriptAttachmentRepository trainingScriptAttachmentRepository; + private final TrainingScriptService trainingScriptService; + + + + @Value("${minio.bucket}") + private String bucketName; + + @Value("${minio.endpoint}") + private String minioEndpoint; + + @Operation(summary = "파일 업로드", description = "MultipartFile을 MinIO 버킷에 업로드하고 DB에 기록합니다.") + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadFile( + @Parameter(description = "업로드할 파일") + @RequestPart("file") MultipartFile file, + @Parameter(description = "저장 경로 (선택)") + @RequestPart(value = "path", required = false) String path, + @RequestParam(value = "title", required = false, defaultValue = "배터리 퍼센트 데이터 셋") String title, + @RequestParam(value = "description", required = false, defaultValue = "배터리 퍼센트 데이터 모음") String description, + @RequestParam(value = "version", required = false, defaultValue = "1") Integer version, + @RequestParam(value = "regUserId") String regUserId + ) { + try (InputStream is = file.getInputStream()) { + String storedName = UUID.randomUUID() + "-" + file.getOriginalFilename(); + String objectName = (path == null || path.isEmpty()) + ? storedName + : path + "/" + storedName; + + // MinIO 업로드 + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucketName) + .object(objectName) + .stream(is, is.available(), -1) + .contentType(file.getContentType()) + .build() + ); + + // DB에 저장 + TrainingScriptAttachmentEntity attachment = TrainingScriptAttachmentEntity.builder() + .originalName(file.getOriginalFilename()) + .storedName(storedName) + .contentType(file.getContentType()) + .size(file.getSize()) + .storagePath(objectName) + .title(title != null ? title : file.getOriginalFilename()) + .version(version) + .description(description) + .regUserId(regUserId) + .build(); + + TrainingScriptAttachmentEntity saved = trainingScriptAttachmentRepository.save(attachment); + + // MinIO URL 생성 + String minioUrl = String.format("%s/%s/%s", minioEndpoint, bucketName, objectName); + + Map response = new HashMap<>(); + response.put("attachment", saved); + response.put("minioUrl", minioUrl); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("파일 업로드 실패", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @Operation(summary = "검색 및 페이지네이션 프로젝트 목록 조회") + @GetMapping("/search") + public ResponseEntity> searchProjects( + @ParameterObject @ModelAttribute BaseSearchRequest request) { + Page page = trainingScriptService.search(request); + return ResponseEntity.ok(page); + } +} \ No newline at end of file diff --git a/src/main/java/kr/re/etri/autoflow/entity/TrainingScriptAttachmentEntity.java b/src/main/java/kr/re/etri/autoflow/entity/TrainingScriptAttachmentEntity.java new file mode 100644 index 0000000..46833d0 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/entity/TrainingScriptAttachmentEntity.java @@ -0,0 +1,80 @@ +package kr.re.etri.autoflow.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.Comment; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Schema(description = "MinIO 전용 첨부파일") +@Comment("MinIO 전용 첨부파일") +@Entity +@EntityListeners(AuditingEntityListener.class) +@Table(name = "tb_trainingscript_attachment") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TrainingScriptAttachmentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "첨부파일 ID", example = "1") + @Comment("첨부파일 ID") + private Long id; + + @Schema(description = "원본 파일명", example = "step1.yaml") + @Comment("원본 파일명") + @Column(nullable = false, length = 255) + private String originalName; + + @Schema(description = "저장된 파일명(UUID + ver)", example = "a1b2c3d4-step1-ver.1.yaml") + @Comment("저장된 파일명") + @Column(nullable = false, length = 255) + private String storedName; + + @Schema(description = "MIME 타입", example = "application/x-yaml") + @Comment("MIME 타입") + @Column(nullable = false, length = 100) + private String contentType; + + @Schema(description = "파일 크기(byte)", example = "2048") + @Comment("파일 크기") + @Column(nullable = false) + private Long size; + + @Schema(description = "스토리지 경로", example = "/uploads/step1-ver.1.yaml") + @Comment("스토리지 경로") + @Column(nullable = false, length = 500) + private String storagePath; + + @Schema(description = "업로더 ID", example = "admin") + @Comment("업로더 ID") + @Column(nullable = false, length = 50) + private String regUserId; + + @Schema(description = "업로드 일시", example = "2025-09-17T15:00:00") + @CreatedDate + @Comment("업로드 일시") + @Column(nullable = false, updatable = false) + private LocalDateTime regDt; + + @Schema(description = "파일 제목", example = "자율주행차량 데이터 셋") + @Comment("파일 제목") + @Column(length = 200) + private String title; + + @Schema(description = "파일 버전", example = "1") + @Comment("파일 버전") + @Column(nullable = false) + private Integer version; + + @Schema(description = "파일 설명", example = "자율주행차량 데이터 모음집입니다.") + @Comment("파일 설명") + @Column(length = 1000) + private String description; +} diff --git a/src/main/java/kr/re/etri/autoflow/repository/TrainingScriptAttachmentRepository.java b/src/main/java/kr/re/etri/autoflow/repository/TrainingScriptAttachmentRepository.java new file mode 100644 index 0000000..7714b74 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/repository/TrainingScriptAttachmentRepository.java @@ -0,0 +1,7 @@ +package kr.re.etri.autoflow.repository; +import kr.re.etri.autoflow.entity.TrainingScriptAttachmentEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface TrainingScriptAttachmentRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/src/main/java/kr/re/etri/autoflow/service/TrainingScriptService.java b/src/main/java/kr/re/etri/autoflow/service/TrainingScriptService.java new file mode 100644 index 0000000..f750b8f --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/service/TrainingScriptService.java @@ -0,0 +1,96 @@ +package kr.re.etri.autoflow.service; + +import kr.re.etri.autoflow.entity.TrainingScriptAttachmentEntity; +import kr.re.etri.autoflow.payload.request.BaseSearchRequest; +import kr.re.etri.autoflow.payload.request.ProjectRequest; +import kr.re.etri.autoflow.repository.TrainingScriptAttachmentRepository; +import kr.re.etri.autoflow.specification.TrainingScriptAttachmentSpecification; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +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 org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TrainingScriptService { + + private final TrainingScriptAttachmentRepository trainingScriptAttachmentRepository; + + private final TrainingScriptAttachmentSpecification trainingScriptAttachmentSpecification; + + private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + public List findAll() { + return trainingScriptAttachmentRepository.findAll(); + } + + public Optional findById(Long id) { + return trainingScriptAttachmentRepository.findById(id); + } + + public Page search(BaseSearchRequest 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()) + ); + + LocalDate startDate = parseDate(request.getStartDate()); + LocalDate endDate = parseDate(request.getEndDate()); + + Specification spec = trainingScriptAttachmentSpecification.searchByConditions( + request.getSearchType(), + request.getKeyword(), + startDate, + endDate + ); + + return trainingScriptAttachmentRepository.findAll(spec, pageable); + } + + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) return null; + try { + return LocalDate.parse(dateStr, formatter); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("날짜 형식이 잘못되었습니다. yyyy-MM-dd 형식이어야 합니다: " + dateStr); + } + } + + @Transactional + public TrainingScriptAttachmentEntity create(TrainingScriptAttachmentEntity project) { + return trainingScriptAttachmentRepository.save(project); + } + + @Transactional + public Optional update(Long id, ProjectRequest dto) { + return trainingScriptAttachmentRepository.findById(id) + .map(project -> { + BeanUtils.copyProperties(dto, project); + return trainingScriptAttachmentRepository.save(project); + }); + } + + @Transactional + public boolean delete(Long id) { + if (!trainingScriptAttachmentRepository.existsById(id)) { + return false; + } + trainingScriptAttachmentRepository.deleteById(id); + return true; + } +} diff --git a/src/main/java/kr/re/etri/autoflow/specification/TrainingScriptAttachmentSpecification.java b/src/main/java/kr/re/etri/autoflow/specification/TrainingScriptAttachmentSpecification.java new file mode 100644 index 0000000..b927984 --- /dev/null +++ b/src/main/java/kr/re/etri/autoflow/specification/TrainingScriptAttachmentSpecification.java @@ -0,0 +1,80 @@ +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.TrainingScriptAttachmentEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class TrainingScriptAttachmentSpecification { + + private final EntityManager entityManager; + + private Set stringFields; + + public TrainingScriptAttachmentSpecification(EntityManager entityManager) { + this.entityManager = entityManager; + } + + // 스프링 빈 초기화 후 실행 + @PostConstruct + public void init() { + Metamodel metamodel = entityManager.getMetamodel(); + EntityType entityType = metamodel.entity(TrainingScriptAttachmentEntity.class); + + // 문자열 타입 필드명만 추출 + stringFields = entityType.getAttributes().stream() + .filter(attr -> attr.getJavaType().equals(String.class)) + .map(Attribute::getName) + .collect(Collectors.toSet()); + + log.info("TrainingScriptAttachmentEntity string fields: {}", stringFields); + } + + public Specification searchByConditions( + String searchType, String keyword, + LocalDate startDate, LocalDate endDate) { + + 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() + "%")); + } + // 날짜 타입 필드 검색 시 무시 + } + + if (startDate != null) { + predicate = cb.and(predicate, cb.greaterThanOrEqualTo(root.get("regDt"), startDate.atStartOfDay())); + } + + if (endDate != null) { + predicate = cb.and(predicate, cb.lessThanOrEqualTo(root.get("regDt"), endDate.atTime(23, 59, 59))); + } + + return predicate; + }; + } +}