From 4f94c99b64d89a67a14e92336e3d453616a8cdd1 Mon Sep 17 00:00:00 2001 From: teddy Date: Thu, 12 Feb 2026 22:09:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=9D=B4=EC=96=B4=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/kamco/cd/training/train/service/TrainJobService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java b/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java index d3f11c7..66a1851 100644 --- a/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java +++ b/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java @@ -6,6 +6,7 @@ import com.kamco.cd.training.model.dto.ModelTrainMngDto; import com.kamco.cd.training.model.service.TmpDatasetService; import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService; import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService; +import com.kamco.cd.training.train.dto.ModelTrainJobDto; import com.kamco.cd.training.train.dto.ModelTrainJobQueuedEvent; import com.kamco.cd.training.train.dto.TrainRunRequest; import java.io.IOException; @@ -139,7 +140,7 @@ public class TrainJobService { throw new IllegalStateException("이미 진행중입니다."); } - var lastJob = + ModelTrainJobDto lastJob = modelTrainJobCoreService .findLatestByModelId(modelId) .orElseThrow(() -> new IllegalStateException("이전 실행 이력이 없습니다.")); -- 2.49.1 From 4219b88fb3090e1372de1d7180fbbfefc9913b7a Mon Sep 17 00:00:00 2001 From: "gayoun.park" Date: Thu, 12 Feb 2026 22:25:55 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=ED=95=99=EC=8A=B5=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/download/DownloadExecutor.java | 48 ++++++++++ .../common/download/DownloadPaths.java | 19 ++++ .../download/RangeDownloadResponder.java | 79 +++++++++++++++++ .../common/download/dto/DownloadSpec.java | 12 +++ .../cd/training/config/SecurityConfig.java | 3 +- .../model/ModelTrainDetailApiController.java | 60 +++++++++++++ .../static/download_progress_test.html | 87 +++++++++++++++++++ 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/kamco/cd/training/common/download/DownloadExecutor.java create mode 100644 src/main/java/com/kamco/cd/training/common/download/DownloadPaths.java create mode 100644 src/main/java/com/kamco/cd/training/common/download/RangeDownloadResponder.java create mode 100644 src/main/java/com/kamco/cd/training/common/download/dto/DownloadSpec.java create mode 100644 src/main/resources/static/download_progress_test.html diff --git a/src/main/java/com/kamco/cd/training/common/download/DownloadExecutor.java b/src/main/java/com/kamco/cd/training/common/download/DownloadExecutor.java new file mode 100644 index 0000000..e08bdbf --- /dev/null +++ b/src/main/java/com/kamco/cd/training/common/download/DownloadExecutor.java @@ -0,0 +1,48 @@ +package com.kamco.cd.training.common.download; + +import com.kamco.cd.training.common.download.dto.DownloadSpec; +import com.kamco.cd.training.common.utils.UserUtil; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +@Service +@RequiredArgsConstructor +public class DownloadExecutor { + + private final UserUtil userUtil; + + public ResponseEntity stream(DownloadSpec spec) throws IOException { + + if (!Files.isReadable(spec.filePath())) { + return ResponseEntity.notFound().build(); + } + + StreamingResponseBody body = + os -> { + try (InputStream in = Files.newInputStream(spec.filePath())) { + in.transferTo(os); + os.flush(); + } catch (Exception e) { + // 고용량은 중간 끊김 흔하니까 throw 금지 + } + }; + + String fileName = + spec.downloadName() != null + ? spec.downloadName() + : spec.filePath().getFileName().toString(); + + return ResponseEntity.ok() + .contentType( + spec.contentType() != null ? spec.contentType() : MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"") + .body(body); + } +} diff --git a/src/main/java/com/kamco/cd/training/common/download/DownloadPaths.java b/src/main/java/com/kamco/cd/training/common/download/DownloadPaths.java new file mode 100644 index 0000000..fb3f363 --- /dev/null +++ b/src/main/java/com/kamco/cd/training/common/download/DownloadPaths.java @@ -0,0 +1,19 @@ +package com.kamco.cd.training.common.download; + +import org.springframework.util.AntPathMatcher; + +public final class DownloadPaths { + private DownloadPaths() {} + + public static final String[] PATTERNS = { + "/api/inference/download/**", "/api/training-data/stage/download/**" + }; + + public static boolean matches(String uri) { + AntPathMatcher m = new AntPathMatcher(); + for (String p : PATTERNS) { + if (m.match(p, uri)) return true; + } + return false; + } +} diff --git a/src/main/java/com/kamco/cd/training/common/download/RangeDownloadResponder.java b/src/main/java/com/kamco/cd/training/common/download/RangeDownloadResponder.java new file mode 100644 index 0000000..686741a --- /dev/null +++ b/src/main/java/com/kamco/cd/training/common/download/RangeDownloadResponder.java @@ -0,0 +1,79 @@ +package com.kamco.cd.training.common.download; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourceRegion; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRange; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@Component +public class RangeDownloadResponder { + + public ResponseEntity buildZipResponse( + Path filePath, String downloadFileName, HttpServletRequest request) throws IOException { + + if (!Files.isRegularFile(filePath)) { + return ResponseEntity.notFound().build(); + } + + long totalSize = Files.size(filePath); + Resource resource = new FileSystemResource(filePath); + + String disposition = "attachment; filename=\"" + downloadFileName + "\""; + String rangeHeader = request.getHeader(HttpHeaders.RANGE); + + // 🔥 공통 헤더 (여기 고정) + ResponseEntity.BodyBuilder base = + ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition) + .header(HttpHeaders.ACCEPT_RANGES, "bytes") + .header("X-Accel-Buffering", "no"); + + if (rangeHeader == null || rangeHeader.isBlank()) { + return base.contentLength(totalSize).body(resource); + } + + List ranges; + try { + ranges = HttpRange.parseRanges(rangeHeader); + } catch (IllegalArgumentException ex) { + return ResponseEntity.status(416) + .header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize) + .header("X-Accel-Buffering", "no") + .build(); + } + + HttpRange range = ranges.get(0); + + long start = range.getRangeStart(totalSize); + long end = range.getRangeEnd(totalSize); + + if (start >= totalSize) { + return ResponseEntity.status(416) + .header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize) + .header("X-Accel-Buffering", "no") + .build(); + } + + long regionLength = end - start + 1; + ResourceRegion region = new ResourceRegion(resource, start, regionLength); + + return ResponseEntity.status(206) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition) + .header(HttpHeaders.ACCEPT_RANGES, "bytes") + .header("X-Accel-Buffering", "no") + .header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + totalSize) + .contentLength(regionLength) + .body(region); + } +} diff --git a/src/main/java/com/kamco/cd/training/common/download/dto/DownloadSpec.java b/src/main/java/com/kamco/cd/training/common/download/dto/DownloadSpec.java new file mode 100644 index 0000000..435bc6d --- /dev/null +++ b/src/main/java/com/kamco/cd/training/common/download/dto/DownloadSpec.java @@ -0,0 +1,12 @@ +package com.kamco.cd.training.common.download.dto; + +import java.nio.file.Path; +import java.util.UUID; +import org.springframework.http.MediaType; + +public record DownloadSpec( + UUID uuid, // 다운로드 식별(로그/정책용) + Path filePath, // 실제 파일 경로 + String downloadName, // 사용자에게 보일 파일명 + MediaType contentType // 보통 OCTET_STREAM + ) {} diff --git a/src/main/java/com/kamco/cd/training/config/SecurityConfig.java b/src/main/java/com/kamco/cd/training/config/SecurityConfig.java index e27cbaa..dce8157 100644 --- a/src/main/java/com/kamco/cd/training/config/SecurityConfig.java +++ b/src/main/java/com/kamco/cd/training/config/SecurityConfig.java @@ -78,7 +78,8 @@ public class SecurityConfig { "/v3/api-docs/**", "/api/members/*/password", "/api/upload/chunk-upload-dataset", - "/api/upload/chunk-upload-complete") + "/api/upload/chunk-upload-complete", + "/download_progress_test.html") .permitAll() // default diff --git a/src/main/java/com/kamco/cd/training/model/ModelTrainDetailApiController.java b/src/main/java/com/kamco/cd/training/model/ModelTrainDetailApiController.java index 8cf8a34..9118d2e 100644 --- a/src/main/java/com/kamco/cd/training/model/ModelTrainDetailApiController.java +++ b/src/main/java/com/kamco/cd/training/model/ModelTrainDetailApiController.java @@ -1,5 +1,6 @@ package com.kamco.cd.training.model; +import com.kamco.cd.training.common.download.RangeDownloadResponder; import com.kamco.cd.training.config.api.ApiResponseDto; import com.kamco.cd.training.model.dto.ModelTrainDetailDto; import com.kamco.cd.training.model.dto.ModelTrainDetailDto.MappingDataset; @@ -10,17 +11,27 @@ import com.kamco.cd.training.model.dto.ModelTrainDetailDto.ModelValidationMetric import com.kamco.cd.training.model.dto.ModelTrainDetailDto.TransferDetailDto; import com.kamco.cd.training.model.dto.ModelTrainMngDto.Basic; import com.kamco.cd.training.model.service.ModelTrainDetailService; +import com.kamco.cd.training.model.service.ModelTrainMngService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.apache.coyote.BadRequestException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -32,6 +43,11 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/models") public class ModelTrainDetailApiController { private final ModelTrainDetailService modelTrainDetailService; + private final ModelTrainMngService modelTrainMngService; + private final RangeDownloadResponder rangeDownloadResponder; + + @Value("${train.docker.responseDir}") + private String responseDir; @Operation(summary = "모델학습관리> 모델관리 > 상세정보탭 > 학습 진행정보", description = "학습 진행정보, 모델학습 정보 API") @ApiResponses( @@ -222,4 +238,48 @@ public class ModelTrainDetailApiController { UUID uuid) { return ApiResponseDto.ok(modelTrainDetailService.getModelTrainBestEpoch(uuid)); } + + @Operation( + summary = "학습데이터 파일 다운로드", + description = "학습데이터 파일 다운로드", + 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("/download/{uuid}") + public ResponseEntity download(@PathVariable UUID uuid, HttpServletRequest request) + throws IOException { + + Basic info = modelTrainDetailService.findByModelByUUID(uuid); + Path zipPath = + Paths.get(responseDir) + .resolve(String.valueOf(info.getUuid())) + .resolve(info.getModelVer() + ".zip"); + + if (!Files.isRegularFile(zipPath)) { + throw new BadRequestException(); + } + + return rangeDownloadResponder.buildZipResponse(zipPath, info.getModelVer() + ".zip", request); + } } diff --git a/src/main/resources/static/download_progress_test.html b/src/main/resources/static/download_progress_test.html new file mode 100644 index 0000000..b5488f2 --- /dev/null +++ b/src/main/resources/static/download_progress_test.html @@ -0,0 +1,87 @@ + + + + + 학습데이터 ZIP 다운로드 + + +

학습데이터 ZIP 다운로드

+ +UUID: + +

+modelVer: + +

+ +JWT Token: + +

+ + + +

+ +
+ + + + -- 2.49.1