daniel 작업 내용 커밋

This commit is contained in:
2026-04-09 09:16:42 +09:00
parent 501b4a6f51
commit 59d39a79c3
10 changed files with 1189 additions and 2 deletions

View File

@@ -37,6 +37,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -366,4 +367,42 @@ public class ModelTrainDetailApiController {
@Parameter(description = "모델 uuid") @PathVariable UUID uuid) {
return ApiResponseDto.ok(modelTrainDetailService.cleanup(uuid));
}
@Operation(
summary = "학습 결과 ZIP 파일 전체 다운로드",
description =
"모델 UUID에 해당하는 모든 ZIP 파일 목록을 조회하고" + "생성된 모든 학습데이터의 ZIP 파일을 하나의 ZIP 파일로 압축하여 다운로드",
parameters = {
@Parameter(
name = "kamco-download-uuid",
in = ParameterIn.HEADER,
required = true,
description = "다운로드 요청 UUID",
schema =
@Schema(
type = "string",
format = "uuid",
example = "6d8d49dc-0c9d-4124-adc7-b9ca610cc394"))
})
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "학습데이터 zip파일 다운로드",
content =
@Content(
mediaType = "application/octet-stream",
schema = @Schema(type = "string", format = "binary"))),
@ApiResponse(responseCode = "404", description = "모델 또는 파일 없음", content = @Content),
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
})
@GetMapping("/downloadzip/{uuid}")
public ResponseEntity<?> downloadZip(
@Parameter(description = "모델 UUID") @PathVariable UUID uuid,
@Parameter(hidden = true) @RequestHeader("kamco-download-uuid") String downloadUuid,
HttpServletRequest request)
throws IOException {
return modelTrainDetailService.downloadZipFile(uuid, downloadUuid, request);
}
}

View File

@@ -266,4 +266,73 @@ public class ModelTrainDetailDto {
private Boolean fileExistsYn;
private String fileName;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(name = "ZipFileListResponse", description = "ZIP 파일 목록 조회 응답")
public static class ZipFileListResponse {
@Schema(description = "모델 UUID", example = "2c5cdd07-5b9b-44ad-a063-8dead628b45f")
private String modelUuid;
@Schema(description = "모델 번호", example = "G2")
private String modelNo;
@Schema(description = "현재 모델 버전", example = "G2_000001")
private String modelVer;
@Schema(
description = "기본 경로",
example = "/home/kcomu/data/response/2c5cdd07-5b9b-44ad-a063-8dead628b45f")
private String basePath;
@Schema(description = "ZIP 파일 목록")
private List<ZipFileInfo> zipFiles;
@Schema(description = "총 파일 개수", example = "3")
private Integer totalFiles;
@Schema(description = "전체 파일 크기 (bytes)", example = "4048640000")
private Long totalSize;
@Schema(description = "전체 파일 크기 (포맷)", example = "3.77 GB")
private String totalSizeFormatted;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(name = "ZipFileInfo", description = "ZIP 파일 상세 정보")
public static class ZipFileInfo {
@Schema(description = "파일명", example = "G2.G2_000001.{uuid}.zip")
private String fileName;
@Schema(description = "파일 전체 경로")
private String filePath;
@Schema(description = "모델 버전", example = "G2_000001")
private String version;
@Schema(description = "파일 크기 (bytes)", example = "1349516895")
private Long fileSize;
@Schema(description = "파일 크기 (포맷)", example = "1.26 GB")
private String fileSizeFormatted;
@Schema(description = "최종 수정 시간", example = "2026-03-10T23:19:24.347+09:00")
@JsonFormatDttm
private ZonedDateTime lastModified;
@Schema(description = "현재 모델 버전 여부", example = "true")
private Boolean isCurrent;
@Schema(description = "다운로드 URL")
private String downloadUrl;
}
}

View File

@@ -1,5 +1,6 @@
package com.kamco.cd.training.model.service;
import com.kamco.cd.training.common.download.RangeDownloadResponder;
import com.kamco.cd.training.common.enums.ModelType;
import com.kamco.cd.training.common.enums.TrainStatusType;
import com.kamco.cd.training.common.exception.CustomApiException;
@@ -16,12 +17,15 @@ import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelTrainMetrics;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelValidationMetrics;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferDetailDto;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferHyperSummary;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ZipFileInfo;
import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ZipFileListResponse;
import com.kamco.cd.training.model.dto.ModelTrainMngDto;
import com.kamco.cd.training.model.dto.ModelTrainMngDto.Basic;
import com.kamco.cd.training.model.dto.ModelTrainMngDto.CleanupResult;
import com.kamco.cd.training.model.dto.ModelTrainMngDto.ModelProgressStepDto;
import com.kamco.cd.training.postgres.core.ModelTrainDetailCoreService;
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.file.AccessDeniedException;
import java.nio.file.FileVisitOption;
@@ -32,7 +36,10 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.UUID;
@@ -41,6 +48,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -52,6 +60,7 @@ public class ModelTrainDetailService {
private final ModelTrainDetailCoreService modelTrainDetailCoreService;
private final ModelTrainMngCoreService mngCoreService;
private final RangeDownloadResponder rangeDownloadResponder;
@Value("${train.docker.response_dir}")
private String responseDir;
@@ -434,4 +443,363 @@ public class ModelTrainDetailService {
}
});
}
/**
* 모델 UUID로 ZIP 파일 목록 조회 및 다운로드 링크 생성
*
* @param uuid 모델 UUID
* @param downloadUuid 다운로드 추적 UUID
* @return ZIP 파일 목록 및 다운로드 링크
*/
public ZipFileListResponse getZipFileList(UUID uuid, String downloadUuid) {
log.info("ZIP 파일 목록 조회 시작: modelUuid={}, downloadUuid={}", uuid, downloadUuid);
// 1. 모델 정보 조회
Basic modelInfo;
try {
modelInfo = findByModelByUUID(uuid);
if (modelInfo == null) {
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다: " + uuid);
}
} catch (NullPointerException e) {
log.error("모델 조회 실패: {}", uuid, e);
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다: " + uuid);
}
// 2. 실제 디렉토리 경로 찾기 (uuid 또는 uuid-out)
Path baseDir = findActualBasePath(uuid);
if (baseDir == null || !Files.exists(baseDir)) {
log.warn(
"디렉토리를 찾을 수 없음: modelUuid={}, 시도한 경로: {} 또는 {}-out",
uuid,
responseDir + "/" + uuid,
responseDir + "/" + uuid);
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 결과 디렉토리가 존재하지 않습니다.");
}
log.info("디렉토리 발견: basePath={}", baseDir.toString());
// 3. ZIP 파일 목록 검색
List<ZipFileInfo> zipFiles = new ArrayList<>();
long totalSize = 0L;
try (Stream<Path> stream = Files.list(baseDir)) {
List<Path> files =
stream
.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".zip"))
.filter(p -> p.getFileName().toString().contains(uuid.toString()))
.sorted(
Comparator.comparing(p -> p.getFileName().toString(), Comparator.reverseOrder()))
.toList();
for (Path file : files) {
String fileName = file.getFileName().toString();
long fileSize = Files.size(file);
totalSize += fileSize;
// 파일명에서 버전 추출
String version = extractVersionFromZipFileName(fileName);
boolean isCurrent = version.equals(modelInfo.getModelVer());
// 다운로드 URL 생성
String downloadUrl = String.format("/api/models/download/%s?file=%s", uuid, fileName);
zipFiles.add(
ZipFileInfo.builder()
.fileName(fileName)
.filePath(file.toString())
.version(version)
.fileSize(fileSize)
.fileSizeFormatted(formatFileSize(fileSize))
.lastModified(
Files.getLastModifiedTime(file).toInstant().atZone(ZoneId.systemDefault()))
.isCurrent(isCurrent)
.downloadUrl(downloadUrl)
.build());
}
log.info("ZIP 파일 {}개 발견", zipFiles.size());
} catch (IOException e) {
log.error("ZIP 파일 목록 조회 실패: {}", baseDir, e);
throw new CustomApiException(
"INTERNAL_SERVER_ERROR", HttpStatus.INTERNAL_SERVER_ERROR, "ZIP 파일 목록 조회 실패");
}
return ZipFileListResponse.builder()
.modelUuid(uuid.toString())
.modelNo(modelInfo.getModelNo())
.modelVer(modelInfo.getModelVer())
.basePath(baseDir.toString())
.zipFiles(zipFiles)
.totalFiles(zipFiles.size())
.totalSize(totalSize)
.totalSizeFormatted(formatFileSize(totalSize))
.build();
}
/**
* 실제 디렉토리 경로 찾기 (uuid 또는 uuid-out)
*
* @param uuid 모델 UUID
* @return 실제 경로 또는 null
*/
private Path findActualBasePath(UUID uuid) {
// 1순위: {uuid}-out
Path pathWithSuffix = Paths.get(responseDir, uuid + "-out");
if (Files.exists(pathWithSuffix) && Files.isDirectory(pathWithSuffix)) {
log.debug("경로 발견: {} (suffix -out 포함)", pathWithSuffix);
return pathWithSuffix;
}
// 2순위: {uuid}
Path pathWithoutSuffix = Paths.get(responseDir, uuid.toString());
if (Files.exists(pathWithoutSuffix) && Files.isDirectory(pathWithoutSuffix)) {
log.debug("경로 발견: {} (suffix 없음)", pathWithoutSuffix);
return pathWithoutSuffix;
}
return null;
}
/**
* ZIP 파일명에서 버전 추출 예: G2.G2_000001.{uuid}.zip → G2_000001
*
* @param fileName ZIP 파일명
* @return 모델 버전
*/
private String extractVersionFromZipFileName(String fileName) {
// 패턴: {modelNo}.{modelVer}.{uuid}.zip
// 예: G2.G2_000001.d7dd54e9-1f20-46a7-9c26-89287a7d61b0.zip
String[] parts = fileName.split("\\.");
// parts[0] = G2 (modelNo)
// parts[1] = G2_000001 (modelVer)
// parts[2-7] = uuid parts
// parts[8] = zip
if (parts.length >= 2) {
return parts[1]; // G2_000001
}
// fallback: 전체 파일명에서 .zip 제거
return fileName.replace(".zip", "");
}
/**
* 파일 크기를 읽기 쉬운 형식으로 변환
*
* @param size 파일 크기 (bytes)
* @return 포맷된 문자열 (예: "1.26 GB")
*/
private String formatFileSize(long size) {
if (size < 1024) {
return size + " B";
} else if (size < 1024 * 1024) {
return String.format("%.2f KB", size / 1024.0);
} else if (size < 1024 * 1024 * 1024) {
return String.format("%.2f MB", size / (1024.0 * 1024.0));
} else {
return String.format("%.2f GB", size / (1024.0 * 1024.0 * 1024.0));
}
}
/**
* 모델 UUID로 모든 ZIP 파일 다운로드 (여러 파일이 있으면 하나의 zip으로 묶어서)
*
* @param uuid 모델 UUID
* @param downloadUuid 다운로드 추적 UUID
* @param request HTTP 요청
* @return ZIP 파일 다운로드 응답
*/
public ResponseEntity<?> downloadZipFile(
UUID uuid, String downloadUuid, HttpServletRequest request) throws IOException {
log.info("ZIP 파일 다운로드 시작: modelUuid={}, downloadUuid={}", uuid, downloadUuid);
// 1. 모델 정보 조회
Basic modelInfo;
try {
modelInfo = findByModelByUUID(uuid);
if (modelInfo == null) {
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다: " + uuid);
}
} catch (NullPointerException e) {
log.warn("모델 조회 실패: {}", uuid);
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델을 찾을 수 없습니다: " + uuid);
}
// 2. 실제 디렉토리 경로 찾기 (uuid 또는 uuid-out)
Path baseDir = findActualBasePath(uuid);
if (baseDir == null || !Files.exists(baseDir)) {
log.warn(
"디렉토리를 찾을 수 없음: modelUuid={}, 시도한 경로: {} 또는 {}-out",
uuid,
responseDir + "/" + uuid,
responseDir + "/" + uuid);
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 결과 디렉토리가 존재하지 않습니다.");
}
log.info("디렉토리 발견: basePath={}", baseDir);
// 3. 모든 ZIP 파일 찾기
List<Path> zipFiles = findAllZipFiles(baseDir, uuid);
if (zipFiles.isEmpty()) {
log.warn("ZIP 파일을 찾을 수 없음: modelUuid={}, basePath={}", uuid, baseDir);
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "ZIP 파일을 찾을 수 없습니다.");
}
log.info(
"ZIP 파일 {}개 발견: basePath={}, files={}",
zipFiles.size(),
baseDir,
zipFiles.stream().map(p -> p.getFileName().toString()).toList());
// 4. 파일이 1개면 바로 다운로드
if (zipFiles.size() == 1) {
Path zipPath = zipFiles.get(0);
log.info(
"ZIP 파일 1개 다운로드: fileName={}, fileSize={} bytes, basePath={}",
zipPath.getFileName(),
Files.size(zipPath),
baseDir);
String downloadFileName = zipPath.getFileName().toString();
return rangeDownloadResponder.buildZipResponse(zipPath, downloadFileName, request);
}
// 5. 파일이 여러 개면 하나의 zip으로 묶어서 다운로드
log.info("여러 ZIP 파일을 하나로 묶어서 다운로드: 총 {}개 파일", zipFiles.size());
String combinedZipName = modelInfo.getModelNo() + "." + modelInfo.getModelVer() + ".all.zip";
Path tempZipPath = createCombinedZipFile(zipFiles, combinedZipName, uuid, baseDir);
try {
long totalSize = Files.size(tempZipPath);
log.info(
"통합 ZIP 파일 생성 완료: fileName={}, fileSize={} bytes, basePath={}",
tempZipPath.getFileName(),
totalSize,
baseDir);
ResponseEntity<?> response =
rangeDownloadResponder.buildZipResponse(tempZipPath, combinedZipName, request);
// 다운로드 완료 후 임시 파일 삭제 (비동기)
deleteTempFileAsync(tempZipPath);
return response;
} catch (IOException e) {
log.warn("통합 ZIP 파일 처리 실패: {}", tempZipPath, e);
// 에러 발생 시 임시 파일 삭제
try {
Files.deleteIfExists(tempZipPath);
Path tempDir = tempZipPath.getParent();
if (tempDir != null && Files.exists(tempDir)) {
Files.deleteIfExists(tempDir);
}
} catch (IOException ex) {
log.warn("임시 파일 삭제 실패: {}", tempZipPath, ex);
}
throw new CustomApiException(
"INTERNAL_SERVER_ERROR", HttpStatus.INTERNAL_SERVER_ERROR, "ZIP 파일 생성 실패");
}
}
/**
* 모든 ZIP 파일 찾기
*
* @param baseDir 기본 디렉토리
* @param uuid 모델 UUID
* @return ZIP 파일 목록 (최신순 정렬)
*/
private List<Path> findAllZipFiles(Path baseDir, UUID uuid) {
try (Stream<Path> stream = Files.list(baseDir)) {
return stream
.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".zip"))
.filter(p -> p.getFileName().toString().contains(uuid.toString()))
.sorted(
Comparator.comparing(
p -> {
try {
return Files.getLastModifiedTime(p);
} catch (IOException e) {
return FileTime.fromMillis(0);
}
},
Comparator.reverseOrder()))
.toList();
} catch (IOException e) {
log.warn("ZIP 파일 검색 실패: basePath={}", baseDir, e);
return new ArrayList<>();
}
}
/**
* 여러 ZIP 파일을 하나의 ZIP으로 묶기
*
* @param zipFiles 원본 ZIP 파일 목록
* @param combinedZipName 통합 ZIP 파일명
* @param uuid 모델 UUID
* @param baseDir 기본 디렉토리 (로그용)
* @return 통합 ZIP 파일 경로
*/
private Path createCombinedZipFile(
List<Path> zipFiles, String combinedZipName, UUID uuid, Path baseDir) throws IOException {
// 임시 디렉토리에 통합 ZIP 생성
Path tempDir = Files.createTempDirectory("kamco-download-" + uuid);
Path combinedZipPath = tempDir.resolve(combinedZipName);
log.debug("통합 ZIP 생성 시작: tempPath={}, 원본 파일 {}개", combinedZipPath, zipFiles.size());
try (java.util.zip.ZipOutputStream zos =
new java.util.zip.ZipOutputStream(Files.newOutputStream(combinedZipPath))) {
for (Path zipFile : zipFiles) {
String entryName = zipFile.getFileName().toString();
log.debug("ZIP에 파일 추가: {}", entryName);
java.util.zip.ZipEntry entry = new java.util.zip.ZipEntry(entryName);
zos.putNextEntry(entry);
Files.copy(zipFile, zos);
zos.closeEntry();
}
zos.finish();
}
log.debug("통합 ZIP 생성 완료: {}", combinedZipPath);
return combinedZipPath;
}
/**
* 임시 파일 비동기 삭제
*
* @param tempFile 삭제할 임시 파일
*/
private void deleteTempFileAsync(Path tempFile) {
new Thread(
() -> {
try {
// 다운로드 완료 후 대기
Thread.sleep(10000); // 10초 대기
Files.deleteIfExists(tempFile);
// 임시 디렉토리도 삭제
Path tempDir = tempFile.getParent();
if (tempDir != null && Files.exists(tempDir)) {
Files.deleteIfExists(tempDir);
}
log.debug("임시 파일 삭제 완료: {}", tempFile);
} catch (Exception e) {
log.warn("임시 파일 삭제 실패: {}", tempFile, e);
}
})
.start();
}
}