diff --git a/src/main/java/com/kamco/cd/kamcoback/common/download/DownloadExecutor.java b/src/main/java/com/kamco/cd/kamcoback/common/download/DownloadExecutor.java deleted file mode 100644 index eb778736..00000000 --- a/src/main/java/com/kamco/cd/kamcoback/common/download/DownloadExecutor.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.kamco.cd.kamcoback.common.download; - -import com.kamco.cd.kamcoback.common.download.dto.DownloadSpec; -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 { - - public ResponseEntity stream(DownloadSpec spec) throws IOException { - - if (!Files.isReadable(spec.filePath())) { - return ResponseEntity.notFound().build(); - } - - long fileSize = Files.size(spec.filePath()); - - 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) - .contentLength(fileSize) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"") - .body(body); - } -} diff --git a/src/main/java/com/kamco/cd/kamcoback/common/download/RangeDownloadResponder.java b/src/main/java/com/kamco/cd/kamcoback/common/download/RangeDownloadResponder.java new file mode 100644 index 00000000..a5a6cb5a --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/common/download/RangeDownloadResponder.java @@ -0,0 +1,132 @@ +package com.kamco.cd.kamcoback.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 java.util.Map; +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; + +/** + * Range(이어받기) 지원 파일 다운로드 응답 생성기. + * + *

- Range 헤더 없으면 200 + Resource - Range 헤더 있으면 206 + ResourceRegion - 잘못된 Range면 416 + + * Content-Range: bytes + * + *

주의: - 다중 Range는 첫 번째 Range만 처리(안정성 목적). - 파일이 이미 생성되어 있는 대용량(zip 등) 다운로드에 적합. + */ +@Component +public class RangeDownloadResponder { + + /** 기본(zip) 다운로드 응답 생성. */ + public ResponseEntity buildZipResponse( + Path filePath, String downloadFileName, HttpServletRequest request) throws IOException { + return buildResponse( + filePath, downloadFileName, MediaType.APPLICATION_OCTET_STREAM, request, Map.of()); + } + + /** 추가 헤더가 필요한 경우(예: X-Accel-Buffering) 사용. */ + public ResponseEntity buildZipResponse( + Path filePath, + String downloadFileName, + HttpServletRequest request, + Map extraHeaders) + throws IOException { + return buildResponse( + filePath, downloadFileName, MediaType.APPLICATION_OCTET_STREAM, request, extraHeaders); + } + + /** 콘텐츠 타입까지 지정하고 싶을 때 사용. */ + public ResponseEntity buildResponse( + Path filePath, + String downloadFileName, + MediaType contentType, + HttpServletRequest request, + Map extraHeaders) + throws IOException { + + if (!Files.isRegularFile(filePath)) { + return ResponseEntity.notFound().build(); + } + + long totalSize = Files.size(filePath); + Resource resource = new FileSystemResource(filePath); + + // 공통 헤더(200/206 공통) + String disposition = "attachment; filename=\"" + downloadFileName + "\""; + String rangeHeader = request.getHeader(HttpHeaders.RANGE); + + if (rangeHeader == null || rangeHeader.isBlank()) { + // 200 OK (전체 다운로드) + ResponseEntity.BodyBuilder ok = + ResponseEntity.ok() + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition) + .header(HttpHeaders.ACCEPT_RANGES, "bytes") + .contentLength(totalSize); + + applyExtraHeaders(ok, extraHeaders); + return ok.body(resource); + } + + // Range 파싱 + List ranges; + try { + ranges = HttpRange.parseRanges(rangeHeader); + } catch (IllegalArgumentException ex) { + // 416 Range Not Satisfiable + ResponseEntity.BodyBuilder badRange = + ResponseEntity.status(416).header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize); + applyExtraHeaders(badRange, extraHeaders); + return badRange.build(); + } + + // 실무에선 단일 Range만 처리하는 게 안전합니다. + HttpRange range = ranges.get(0); + + long start = range.getRangeStart(totalSize); + long end = range.getRangeEnd(totalSize); + + if (start >= totalSize) { + ResponseEntity.BodyBuilder badRange = + ResponseEntity.status(416).header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize); + applyExtraHeaders(badRange, extraHeaders); + return badRange.build(); + } + + long regionLength = end - start + 1; + ResourceRegion region = new ResourceRegion(resource, start, regionLength); + + // 206 Partial Content + ResponseEntity.BodyBuilder partial = + ResponseEntity.status(206) + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition) + .header(HttpHeaders.ACCEPT_RANGES, "bytes") + .header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + totalSize) + .contentLength(regionLength); + + applyExtraHeaders(partial, extraHeaders); + return partial.body(region); + } + + private void applyExtraHeaders( + ResponseEntity.HeadersBuilder builder, Map extraHeaders) { + if (extraHeaders == null || extraHeaders.isEmpty()) return; + + extraHeaders.forEach( + (k, v) -> { + if (k != null && !k.isBlank() && v != null) { + builder.header(k, v); + } + }); + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/common/download/dto/DownloadSpec.java b/src/main/java/com/kamco/cd/kamcoback/common/download/dto/DownloadSpec.java deleted file mode 100644 index bc3b5570..00000000 --- a/src/main/java/com/kamco/cd/kamcoback/common/download/dto/DownloadSpec.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.kamco.cd.kamcoback.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/kamcoback/inference/InferenceResultApiController.java b/src/main/java/com/kamco/cd/kamcoback/inference/InferenceResultApiController.java index 30fa5080..3fc6a081 100644 --- a/src/main/java/com/kamco/cd/kamcoback/inference/InferenceResultApiController.java +++ b/src/main/java/com/kamco/cd/kamcoback/inference/InferenceResultApiController.java @@ -1,5 +1,6 @@ package com.kamco.cd.kamcoback.inference; +import com.kamco.cd.kamcoback.common.download.RangeDownloadResponder; import com.kamco.cd.kamcoback.common.exception.CustomApiException; import com.kamco.cd.kamcoback.config.api.ApiResponseDto; import com.kamco.cd.kamcoback.inference.dto.InferenceDetailDto; @@ -24,9 +25,9 @@ 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 jakarta.validation.Valid; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; import java.util.List; @@ -34,10 +35,7 @@ import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -58,6 +56,7 @@ public class InferenceResultApiController { private final InferenceResultService inferenceResultService; private final MapSheetMngService mapSheetMngService; private final ModelMngService modelMngService; + private final RangeDownloadResponder rangeDownloadResponder; @Operation(summary = "추론관리 목록", description = "어드민 홈 > 추론관리 > 추론관리 > 추론관리 목록") @ApiResponses( @@ -357,9 +356,8 @@ public class InferenceResultApiController { @ApiResponse(responseCode = "404", description = "파일 없음", content = @Content), @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) }) - @GetMapping(value = "/download/{uuid}") - public ResponseEntity download( - @Parameter(example = "69c4e56c-e0bf-4742-9225-bba9aae39052") @PathVariable UUID uuid) + @GetMapping("/download/{uuid}") + public ResponseEntity download(@PathVariable UUID uuid, HttpServletRequest request) throws IOException { String path; @@ -374,18 +372,10 @@ public class InferenceResultApiController { } Path zipPath = Path.of(path); - long size = Files.size(zipPath); - Resource resource = new org.springframework.core.io.UrlResource(zipPath.toUri()); - log.info("shp download request path = {}", path); - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + uid + ".zip" + "\"") - .header(HttpHeaders.ACCEPT_RANGES, "bytes") - .header("X-Accel-Buffering", "no") // nginx/ingress 버퍼링 방지 힌트 - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .contentLength(size) - .body(resource); + // Range + 200/206/416 공통 처리 (추가 헤더 포함) + return rangeDownloadResponder.buildZipResponse( + zipPath, uid + ".zip", request, Map.of("X-Accel-Buffering", "no")); } @Operation(summary = "shp 파일 다운로드 이력 조회", description = "추론관리 분석결과 shp 파일 다운로드 이력 조회") diff --git a/src/main/java/com/kamco/cd/kamcoback/label/LabelAllocateApiController.java b/src/main/java/com/kamco/cd/kamcoback/label/LabelAllocateApiController.java index 575aedfa..7bc9b3eb 100644 --- a/src/main/java/com/kamco/cd/kamcoback/label/LabelAllocateApiController.java +++ b/src/main/java/com/kamco/cd/kamcoback/label/LabelAllocateApiController.java @@ -1,7 +1,6 @@ package com.kamco.cd.kamcoback.label; -import com.kamco.cd.kamcoback.common.download.DownloadExecutor; -import com.kamco.cd.kamcoback.common.download.dto.DownloadSpec; +import com.kamco.cd.kamcoback.common.download.RangeDownloadResponder; import com.kamco.cd.kamcoback.config.api.ApiResponseDto; import com.kamco.cd.kamcoback.label.dto.LabelAllocateDto; import com.kamco.cd.kamcoback.label.dto.LabelAllocateDto.InferenceDetail; @@ -23,17 +22,19 @@ 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 jakarta.validation.Valid; 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 lombok.extern.slf4j.Slf4j; +import org.apache.coyote.BadRequestException; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -42,7 +43,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; @Slf4j @Tag(name = "라벨링 작업 관리", description = "라벨링 작업 배정 및 통계 조회 API") @@ -52,7 +52,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo public class LabelAllocateApiController { private final LabelAllocateService labelAllocateService; - private final DownloadExecutor downloadExecutor; + private final RangeDownloadResponder rangeDownloadResponder; @Value("${file.dataset-response}") private String responsePath; @@ -381,15 +381,17 @@ public class LabelAllocateApiController { @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) }) @GetMapping("/download/{uuid}") - public ResponseEntity download( - @Parameter(example = "6d8d49dc-0c9d-4124-adc7-b9ca610cc394") @PathVariable UUID uuid) + public ResponseEntity download(@PathVariable UUID uuid, HttpServletRequest request) throws IOException { String uid = labelAllocateService.findLearnUid(uuid); Path zipPath = Paths.get(responsePath).resolve(uid + ".zip"); - return downloadExecutor.stream( - new DownloadSpec(uuid, zipPath, uid + ".zip", MediaType.APPLICATION_OCTET_STREAM)); + if (!Files.isRegularFile(zipPath)) { + throw new BadRequestException(); + } + + return rangeDownloadResponder.buildZipResponse(zipPath, uid + ".zip", request); } @Operation(summary = "라벨 파일 다운로드 이력 조회", description = "라벨 파일 다운로드 이력 조회")