Merge pull request '대용량 다운로드 테스트' (#73) from feat/infer_dev_260211 into develop
Reviewed-on: #73
This commit was merged in pull request #73.
This commit is contained in:
@@ -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<StreamingResponseBody> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(이어받기) 지원 파일 다운로드 응답 생성기.
|
||||||
|
*
|
||||||
|
* <p>- Range 헤더 없으면 200 + Resource - Range 헤더 있으면 206 + ResourceRegion - 잘못된 Range면 416 +
|
||||||
|
* Content-Range: bytes
|
||||||
|
*
|
||||||
|
* <p>주의: - 다중 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<String, String> 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<String, String> 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<HttpRange> 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<String, String> extraHeaders) {
|
||||||
|
if (extraHeaders == null || extraHeaders.isEmpty()) return;
|
||||||
|
|
||||||
|
extraHeaders.forEach(
|
||||||
|
(k, v) -> {
|
||||||
|
if (k != null && !k.isBlank() && v != null) {
|
||||||
|
builder.header(k, v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
) {}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.kamco.cd.kamcoback.inference;
|
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.common.exception.CustomApiException;
|
||||||
import com.kamco.cd.kamcoback.config.api.ApiResponseDto;
|
import com.kamco.cd.kamcoback.config.api.ApiResponseDto;
|
||||||
import com.kamco.cd.kamcoback.inference.dto.InferenceDetailDto;
|
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.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -34,10 +35,7 @@ import java.util.Map;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
import org.springframework.core.io.Resource;
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -58,6 +56,7 @@ public class InferenceResultApiController {
|
|||||||
private final InferenceResultService inferenceResultService;
|
private final InferenceResultService inferenceResultService;
|
||||||
private final MapSheetMngService mapSheetMngService;
|
private final MapSheetMngService mapSheetMngService;
|
||||||
private final ModelMngService modelMngService;
|
private final ModelMngService modelMngService;
|
||||||
|
private final RangeDownloadResponder rangeDownloadResponder;
|
||||||
|
|
||||||
@Operation(summary = "추론관리 목록", description = "어드민 홈 > 추론관리 > 추론관리 > 추론관리 목록")
|
@Operation(summary = "추론관리 목록", description = "어드민 홈 > 추론관리 > 추론관리 > 추론관리 목록")
|
||||||
@ApiResponses(
|
@ApiResponses(
|
||||||
@@ -357,9 +356,8 @@ public class InferenceResultApiController {
|
|||||||
@ApiResponse(responseCode = "404", description = "파일 없음", content = @Content),
|
@ApiResponse(responseCode = "404", description = "파일 없음", content = @Content),
|
||||||
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
})
|
})
|
||||||
@GetMapping(value = "/download/{uuid}")
|
@GetMapping("/download/{uuid}")
|
||||||
public ResponseEntity<Resource> download(
|
public ResponseEntity<?> download(@PathVariable UUID uuid, HttpServletRequest request)
|
||||||
@Parameter(example = "69c4e56c-e0bf-4742-9225-bba9aae39052") @PathVariable UUID uuid)
|
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
|
||||||
String path;
|
String path;
|
||||||
@@ -374,18 +372,10 @@ public class InferenceResultApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Path zipPath = Path.of(path);
|
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);
|
// Range + 200/206/416 공통 처리 (추가 헤더 포함)
|
||||||
|
return rangeDownloadResponder.buildZipResponse(
|
||||||
return ResponseEntity.ok()
|
zipPath, uid + ".zip", request, Map.of("X-Accel-Buffering", "no"));
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "shp 파일 다운로드 이력 조회", description = "추론관리 분석결과 shp 파일 다운로드 이력 조회")
|
@Operation(summary = "shp 파일 다운로드 이력 조회", description = "추론관리 분석결과 shp 파일 다운로드 이력 조회")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.kamco.cd.kamcoback.label;
|
package com.kamco.cd.kamcoback.label;
|
||||||
|
|
||||||
import com.kamco.cd.kamcoback.common.download.DownloadExecutor;
|
import com.kamco.cd.kamcoback.common.download.RangeDownloadResponder;
|
||||||
import com.kamco.cd.kamcoback.common.download.dto.DownloadSpec;
|
|
||||||
import com.kamco.cd.kamcoback.config.api.ApiResponseDto;
|
import com.kamco.cd.kamcoback.config.api.ApiResponseDto;
|
||||||
import com.kamco.cd.kamcoback.label.dto.LabelAllocateDto;
|
import com.kamco.cd.kamcoback.label.dto.LabelAllocateDto;
|
||||||
import com.kamco.cd.kamcoback.label.dto.LabelAllocateDto.InferenceDetail;
|
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.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.coyote.BadRequestException;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Tag(name = "라벨링 작업 관리", description = "라벨링 작업 배정 및 통계 조회 API")
|
@Tag(name = "라벨링 작업 관리", description = "라벨링 작업 배정 및 통계 조회 API")
|
||||||
@@ -52,7 +52,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
|
|||||||
public class LabelAllocateApiController {
|
public class LabelAllocateApiController {
|
||||||
|
|
||||||
private final LabelAllocateService labelAllocateService;
|
private final LabelAllocateService labelAllocateService;
|
||||||
private final DownloadExecutor downloadExecutor;
|
private final RangeDownloadResponder rangeDownloadResponder;
|
||||||
|
|
||||||
@Value("${file.dataset-response}")
|
@Value("${file.dataset-response}")
|
||||||
private String responsePath;
|
private String responsePath;
|
||||||
@@ -381,15 +381,17 @@ public class LabelAllocateApiController {
|
|||||||
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
@ApiResponse(responseCode = "500", description = "서버 오류", content = @Content)
|
||||||
})
|
})
|
||||||
@GetMapping("/download/{uuid}")
|
@GetMapping("/download/{uuid}")
|
||||||
public ResponseEntity<StreamingResponseBody> download(
|
public ResponseEntity<?> download(@PathVariable UUID uuid, HttpServletRequest request)
|
||||||
@Parameter(example = "6d8d49dc-0c9d-4124-adc7-b9ca610cc394") @PathVariable UUID uuid)
|
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
|
||||||
String uid = labelAllocateService.findLearnUid(uuid);
|
String uid = labelAllocateService.findLearnUid(uuid);
|
||||||
Path zipPath = Paths.get(responsePath).resolve(uid + ".zip");
|
Path zipPath = Paths.get(responsePath).resolve(uid + ".zip");
|
||||||
|
|
||||||
return downloadExecutor.stream(
|
if (!Files.isRegularFile(zipPath)) {
|
||||||
new DownloadSpec(uuid, zipPath, uid + ".zip", MediaType.APPLICATION_OCTET_STREAM));
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rangeDownloadResponder.buildZipResponse(zipPath, uid + ".zip", request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "라벨 파일 다운로드 이력 조회", description = "라벨 파일 다운로드 이력 조회")
|
@Operation(summary = "라벨 파일 다운로드 이력 조회", description = "라벨 파일 다운로드 이력 조회")
|
||||||
|
|||||||
Reference in New Issue
Block a user