diff --git a/src/main/java/com/kamco/cd/training/filemanager/dto/FileManagerDto.java b/src/main/java/com/kamco/cd/training/filemanager/dto/FileManagerDto.java new file mode 100644 index 0000000..87ec29f --- /dev/null +++ b/src/main/java/com/kamco/cd/training/filemanager/dto/FileManagerDto.java @@ -0,0 +1,134 @@ +package com.kamco.cd.training.filemanager.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +public class FileManagerDto { + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "파일 정보") + public static class FileInfo { + @Schema(description = "파일명", example = "dataset.zip") + private String fileName; + + @Schema(description = "파일 전체 경로", example = "/data/request/dataset.zip") + private String filePath; + + @Schema(description = "파일 크기 (bytes)", example = "1024000") + private Long fileSize; + + @Schema(description = "파일인지 디렉토리인지 여부", example = "true") + private Boolean isFile; + + @Schema(description = "디렉토리인지 여부", example = "false") + private Boolean isDirectory; + + @Schema(description = "마지막 수정 시간", example = "2026-04-06T15:30:00") + private LocalDateTime lastModified; + + @Schema(description = "읽기 권한", example = "true") + private Boolean readable; + + @Schema(description = "쓰기 권한", example = "true") + private Boolean writable; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "디렉토리 목록 조회 요청") + public static class ListFilesReq { + @Schema(description = "조회할 디렉토리 경로 (기본값: /data)", example = "/data/request") + private String directoryPath; + + @Schema(description = "하위 디렉토리 포함 여부", example = "false") + private Boolean recursive; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "디렉토리 목록 조회 응답") + public static class ListFilesRes { + @Schema(description = "조회된 디렉토리 경로", example = "/data/request") + private String directoryPath; + + @Schema(description = "파일 목록") + private List files; + + @Schema(description = "총 파일 개수", example = "10") + private Integer totalCount; + + @Schema(description = "총 파일 크기 (bytes)", example = "10240000") + private Long totalSize; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "파일 삭제 요청") + public static class DeleteFileReq { + @Schema( + description = "삭제할 파일 또는 디렉토리 경로 목록", + example = "[\"/data/request/old_file.zip\", \"/data/tmp/test_folder\"]") + private List filePaths; + + @Schema(description = "디렉토리일 경우 하위 파일 포함 삭제 여부", example = "true") + private Boolean recursive; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "파일 삭제 응답") + public static class DeleteFileRes { + @Schema(description = "삭제 성공한 파일 경로 목록") + private List deletedFiles; + + @Schema(description = "삭제 실패한 파일 경로 목록") + private List failedFiles; + + @Schema(description = "삭제 성공 개수", example = "5") + private Integer successCount; + + @Schema(description = "삭제 실패 개수", example = "0") + private Integer failureCount; + + @Schema(description = "전체 메시지", example = "5개 파일 삭제 성공") + private String message; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "디스크 사용량 조회 응답") + public static class DiskUsageRes { + @Schema(description = "디렉토리 경로", example = "/data") + private String directoryPath; + + @Schema(description = "총 용량 (bytes)", example = "1000000000000") + private Long totalSpace; + + @Schema(description = "사용 가능 용량 (bytes)", example = "500000000000") + private Long usableSpace; + + @Schema(description = "사용 중인 용량 (bytes)", example = "500000000000") + private Long usedSpace; + + @Schema(description = "사용률 (%)", example = "50.0") + private Double usagePercentage; + } +} diff --git a/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java b/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java new file mode 100644 index 0000000..1dd2a53 --- /dev/null +++ b/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java @@ -0,0 +1,234 @@ +package com.kamco.cd.training.filemanager.service; + +import com.kamco.cd.training.filemanager.dto.FileManagerDto; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FileManagerService { + + private static final String BASE_DATA_PATH = "/data"; + private static final long MAX_PATH_LENGTH = 500; + + /** + * 디렉토리 내 파일 목록 조회 + * + * @param request 조회 요청 정보 + * @return 파일 목록 응답 + */ + public FileManagerDto.ListFilesRes listFiles(FileManagerDto.ListFilesReq request) { + String targetPath = + request.getDirectoryPath() != null ? request.getDirectoryPath() : BASE_DATA_PATH; + + boolean recursive = request.getRecursive() != null && request.getRecursive(); + + validatePath(targetPath); + + Path directory = Paths.get(targetPath); + if (!Files.exists(directory)) { + throw new IllegalArgumentException("디렉토리가 존재하지 않습니다: " + targetPath); + } + + if (!Files.isDirectory(directory)) { + throw new IllegalArgumentException("디렉토리 경로가 아닙니다: " + targetPath); + } + + List files = new ArrayList<>(); + long totalSize = 0; + + try { + if (recursive) { + // 재귀적으로 모든 하위 파일 조회 + Files.walkFileTree( + directory, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + files.add(createFileInfo(file)); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (!dir.equals(directory)) { + files.add(createFileInfo(dir)); + } + return FileVisitResult.CONTINUE; + } + }); + } else { + // 현재 디렉토리의 파일만 조회 + try (Stream stream = Files.list(directory)) { + stream.forEach(path -> files.add(createFileInfo(path))); + } + } + + // 총 파일 크기 계산 + for (FileManagerDto.FileInfo file : files) { + if (file.getIsFile() && file.getFileSize() != null) { + totalSize += file.getFileSize(); + } + } + + } catch (IOException e) { + log.error("파일 목록 조회 중 오류 발생: {}", targetPath, e); + throw new RuntimeException("파일 목록 조회에 실패했습니다: " + e.getMessage()); + } + + return FileManagerDto.ListFilesRes.builder() + .directoryPath(targetPath) + .files(files) + .totalCount(files.size()) + .totalSize(totalSize) + .build(); + } + + /** + * 파일 또는 디렉토리 삭제 + * + * @param request 삭제 요청 정보 + * @return 삭제 결과 + */ + public FileManagerDto.DeleteFileRes deleteFiles(FileManagerDto.DeleteFileReq request) { + List deletedFiles = new ArrayList<>(); + List failedFiles = new ArrayList<>(); + + boolean recursive = request.getRecursive() != null && request.getRecursive(); + + for (String filePath : request.getFilePaths()) { + try { + validatePath(filePath); + Path path = Paths.get(filePath); + + if (!Files.exists(path)) { + log.warn("삭제하려는 파일이 존재하지 않습니다: {}", filePath); + failedFiles.add(filePath + " (파일이 존재하지 않음)"); + continue; + } + + if (Files.isDirectory(path)) { + if (recursive) { + // 디렉토리 및 하위 파일 모두 삭제 + deleteDirectoryRecursively(path); + deletedFiles.add(filePath); + } else { + // 빈 디렉토리만 삭제 + if (isDirectoryEmpty(path)) { + Files.delete(path); + deletedFiles.add(filePath); + } else { + failedFiles.add(filePath + " (디렉토리가 비어있지 않음)"); + } + } + } else { + // 파일 삭제 + Files.delete(path); + deletedFiles.add(filePath); + } + + log.info("파일 삭제 성공: {}", filePath); + + } catch (Exception e) { + log.error("파일 삭제 실패: {}", filePath, e); + failedFiles.add(filePath + " (" + e.getMessage() + ")"); + } + } + + String message = + String.format("%d개 파일 삭제 성공, %d개 파일 삭제 실패", deletedFiles.size(), failedFiles.size()); + + return FileManagerDto.DeleteFileRes.builder() + .deletedFiles(deletedFiles) + .failedFiles(failedFiles) + .successCount(deletedFiles.size()) + .failureCount(failedFiles.size()) + .message(message) + .build(); + } + + /** FileInfo 객체 생성 */ + private FileManagerDto.FileInfo createFileInfo(Path path) { + try { + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + + return FileManagerDto.FileInfo.builder() + .fileName(path.getFileName().toString()) + .filePath(path.toString()) + .fileSize(attrs.isRegularFile() ? attrs.size() : null) + .isFile(attrs.isRegularFile()) + .isDirectory(attrs.isDirectory()) + .lastModified( + LocalDateTime.ofInstant( + Instant.ofEpochMilli(attrs.lastModifiedTime().toMillis()), + ZoneId.systemDefault())) + .readable(Files.isReadable(path)) + .writable(Files.isWritable(path)) + .build(); + } catch (IOException e) { + log.warn("파일 정보 조회 실패: {}", path, e); + return FileManagerDto.FileInfo.builder() + .fileName(path.getFileName().toString()) + .filePath(path.toString()) + .build(); + } + } + + /** 디렉토리 재귀 삭제 */ + private void deleteDirectoryRecursively(Path directory) throws IOException { + Files.walkFileTree( + directory, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } + + /** 디렉토리가 비어있는지 확인 */ + private boolean isDirectoryEmpty(Path directory) throws IOException { + try (Stream stream = Files.list(directory)) { + return stream.findFirst().isEmpty(); + } + } + + /** 경로 검증 (보안) */ + private void validatePath(String path) { + if (path == null || path.trim().isEmpty()) { + throw new IllegalArgumentException("경로가 비어있습니다"); + } + + if (path.length() > MAX_PATH_LENGTH) { + throw new IllegalArgumentException("경로가 너무 깁니다"); + } + + // 경로 순회 공격 방지 - 상대경로 패턴만 제한 + if (path.contains("..")) { + throw new IllegalArgumentException("상대 경로(..)는 사용할 수 없습니다"); + } + } +}