From ac13f366638deadf5eb780fc4d9707b614d7405d Mon Sep 17 00:00:00 2001 From: teddy Date: Wed, 11 Feb 2026 15:55:27 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=8C=80=EC=9A=A9=EB=9F=89=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../download/RangeDownloadResponder.java | 103 +++++------------- .../InferenceResultApiController.java | 3 +- 2 files changed, 26 insertions(+), 80 deletions(-) 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 index a5a6cb5a..01cd0d24 100644 --- a/src/main/java/com/kamco/cd/kamcoback/common/download/RangeDownloadResponder.java +++ b/src/main/java/com/kamco/cd/kamcoback/common/download/RangeDownloadResponder.java @@ -5,7 +5,6 @@ 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; @@ -15,43 +14,11 @@ 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(); @@ -60,73 +27,53 @@ public class RangeDownloadResponder { 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); + // 🔥 공통 헤더 (여기 고정) + 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"); - applyExtraHeaders(ok, extraHeaders); - return ok.body(resource); + if (rangeHeader == null || rangeHeader.isBlank()) { + return base.contentLength(totalSize).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(); + return ResponseEntity.status(416) + .header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize) + .header("X-Accel-Buffering", "no") + .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(); + 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); - // 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); - } - }); + 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/kamcoback/inference/InferenceResultApiController.java b/src/main/java/com/kamco/cd/kamcoback/inference/InferenceResultApiController.java index 3fc6a081..91ab9525 100644 --- a/src/main/java/com/kamco/cd/kamcoback/inference/InferenceResultApiController.java +++ b/src/main/java/com/kamco/cd/kamcoback/inference/InferenceResultApiController.java @@ -374,8 +374,7 @@ public class InferenceResultApiController { Path zipPath = Path.of(path); // Range + 200/206/416 공통 처리 (추가 헤더 포함) - return rangeDownloadResponder.buildZipResponse( - zipPath, uid + ".zip", request, Map.of("X-Accel-Buffering", "no")); + return rangeDownloadResponder.buildZipResponse(zipPath, uid + ".zip", request); } @Operation(summary = "shp 파일 다운로드 이력 조회", description = "추론관리 분석결과 shp 파일 다운로드 이력 조회") From 2d2b55efcde3833a2e86425852d9813af556b6e2 Mon Sep 17 00:00:00 2001 From: teddy Date: Wed, 11 Feb 2026 16:23:24 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EB=8C=80=EC=9A=A9=EB=9F=89=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../download/DownloadAuditEventListener.java | 92 ++++++++++++++++ .../download/dto/DownloadAuditEvent.java | 11 ++ .../config/FileDownloadInteceptor.java | 104 ++++++------------ .../InferenceResultApiController.java | 2 +- .../scheduler/config/AsyncConfig.java | 11 ++ 5 files changed, 150 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/kamco/cd/kamcoback/common/download/DownloadAuditEventListener.java create mode 100644 src/main/java/com/kamco/cd/kamcoback/common/download/dto/DownloadAuditEvent.java diff --git a/src/main/java/com/kamco/cd/kamcoback/common/download/DownloadAuditEventListener.java b/src/main/java/com/kamco/cd/kamcoback/common/download/DownloadAuditEventListener.java new file mode 100644 index 00000000..c0aeb418 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/common/download/DownloadAuditEventListener.java @@ -0,0 +1,92 @@ +package com.kamco.cd.kamcoback.common.download; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kamco.cd.kamcoback.common.download.dto.DownloadAuditEvent; +import com.kamco.cd.kamcoback.menu.dto.MenuDto; +import com.kamco.cd.kamcoback.menu.service.MenuService; +import com.kamco.cd.kamcoback.postgres.entity.AuditLogEntity; +import com.kamco.cd.kamcoback.postgres.repository.log.AuditLogRepository; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DownloadAuditEventListener { + + private final AuditLogRepository auditLogRepository; + private final MenuService menuService; + private final ObjectMapper objectMapper; + + @Async("auditLogExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + @EventListener + public void onDownloadAudit(DownloadAuditEvent ev) { + try { + String menuUid = resolveMenuUid(ev.normalizedUri()); + if (menuUid == null) { + // menuUid null 불가 -> 스킵 + log.warn( + "MenuUid not resolved. skip audit. uri={}, normalized={}", + ev.requestUri(), + ev.normalizedUri()); + return; + } + + AuditLogEntity logEntity = + AuditLogEntity.forFileDownload( + ev.userId(), ev.requestUri(), menuUid, ev.ip(), ev.status(), ev.downloadUuid()); + + auditLogRepository.save(logEntity); + + } catch (Exception e) { + // 본 요청과 분리되어야 함 + log.warn("Download audit save failed. uri={}, err={}", ev.requestUri(), e.toString()); + } + } + + private String resolveMenuUid(String normalizedUri) { + try { + List list = menuService.getFindAll(); + + List basics = + list.stream() + .map( + item -> { + if (item instanceof LinkedHashMap map) { + return objectMapper.convertValue(map, MenuDto.Basic.class); + } else if (item instanceof MenuDto.Basic dto) { + return dto; + } + return null; + }) + .filter(Objects::nonNull) + .toList(); + + MenuDto.Basic basic = + basics.stream() + .filter(m -> m.getMenuUrl() != null && normalizedUri.startsWith(m.getMenuUrl())) + .max(Comparator.comparingInt(m -> m.getMenuUrl().length())) + .orElse(null); + + if (basic == null) return null; + + String menuUidStr = basic.getMenuUid(); // ← String + if (menuUidStr == null || menuUidStr.isBlank()) return null; + + return menuUidStr; // ← Long 변환 + + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/kamco/cd/kamcoback/common/download/dto/DownloadAuditEvent.java b/src/main/java/com/kamco/cd/kamcoback/common/download/dto/DownloadAuditEvent.java new file mode 100644 index 00000000..c19c8925 --- /dev/null +++ b/src/main/java/com/kamco/cd/kamcoback/common/download/dto/DownloadAuditEvent.java @@ -0,0 +1,11 @@ +package com.kamco.cd.kamcoback.common.download.dto; + +import java.util.UUID; + +public record DownloadAuditEvent( + Long userId, + String requestUri, + String normalizedUri, + String ip, + int status, + UUID downloadUuid) {} diff --git a/src/main/java/com/kamco/cd/kamcoback/config/FileDownloadInteceptor.java b/src/main/java/com/kamco/cd/kamcoback/config/FileDownloadInteceptor.java index 62687e9e..dde74774 100644 --- a/src/main/java/com/kamco/cd/kamcoback/config/FileDownloadInteceptor.java +++ b/src/main/java/com/kamco/cd/kamcoback/config/FileDownloadInteceptor.java @@ -1,98 +1,64 @@ package com.kamco.cd.kamcoback.config; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.kamco.cd.kamcoback.common.utils.HeaderUtil; +import com.kamco.cd.kamcoback.common.download.dto.DownloadAuditEvent; import com.kamco.cd.kamcoback.common.utils.UserUtil; import com.kamco.cd.kamcoback.config.api.ApiLogFunction; -import com.kamco.cd.kamcoback.menu.dto.MenuDto; -import com.kamco.cd.kamcoback.menu.service.MenuService; -import com.kamco.cd.kamcoback.postgres.entity.AuditLogEntity; -import com.kamco.cd.kamcoback.postgres.repository.log.AuditLogRepository; +import jakarta.servlet.DispatcherType; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Objects; import java.util.UUID; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @Slf4j @Component +@RequiredArgsConstructor public class FileDownloadInteceptor implements HandlerInterceptor { - private final AuditLogRepository auditLogRepository; - private final MenuService menuService; + private final ApplicationEventPublisher publisher; private final UserUtil userUtil; - @Autowired private ObjectMapper objectMapper; - - public FileDownloadInteceptor( - AuditLogRepository auditLogRepository, MenuService menuService, UserUtil userUtil) { - this.auditLogRepository = auditLogRepository; - this.menuService = menuService; - this.userUtil = userUtil; - } - @Override - public boolean preHandle( - HttpServletRequest request, HttpServletResponse response, Object handler) { + public void afterCompletion( + HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { - if (!request.getRequestURI().contains("/download")) return true; + String uri = request.getRequestURI(); + if (uri == null || !uri.contains("/download")) return; + if (request.getDispatcherType() != DispatcherType.REQUEST) return; - if (request.getDispatcherType() != jakarta.servlet.DispatcherType.REQUEST) { - return true; - } - - saveLog(request, response); - - return true; - } - - private void saveLog(HttpServletRequest request, HttpServletResponse response) { - // 파일 다운로드 API만 필터링 - if (!request.getRequestURI().contains("/download")) { + Long userId; + try { + userId = userUtil.getId(); + if (userId == null) return; // userId null 불가면 스킵 + } catch (Exception e) { + log.warn("Download audit userId resolve failed. uri={}, err={}", uri, e.toString()); return; } - Long userId = userUtil.getId(); String ip = ApiLogFunction.getClientIp(request); + int status = response.getStatus(); + String normalizedUri = uri.replace("/api", ""); - List list = menuService.getFindAll(); - List result = - list.stream() - .map( - item -> { - if (item instanceof LinkedHashMap map) { - return objectMapper.convertValue(map, MenuDto.Basic.class); - } else if (item instanceof MenuDto.Basic dto) { - return dto; - } else { - throw new IllegalStateException("Unsupported cache type: " + item.getClass()); - } - }) - .toList(); + UUID downloadUuid = extractUuidFromUri(uri); + if (downloadUuid == null) { + log.warn("Download UUID parse failed. uri={}", uri); + return; // downloadUuid null 불가 -> 스킵 + } - String normalizedUri = request.getRequestURI().replace("/api", ""); - MenuDto.Basic basic = - result.stream() - .filter( - menu -> menu.getMenuUrl() != null && normalizedUri.startsWith(menu.getMenuUrl())) - .max(Comparator.comparingInt(m -> m.getMenuUrl().length())) - .orElse(null); + publisher.publishEvent( + new DownloadAuditEvent(userId, uri, normalizedUri, ip, status, downloadUuid)); + } - AuditLogEntity log = - AuditLogEntity.forFileDownload( - userId, - request.getRequestURI(), - Objects.requireNonNull(basic).getMenuUid(), - ip, - response.getStatus(), - UUID.fromString(HeaderUtil.get(request, "kamco-download-uuid"))); - - auditLogRepository.save(log); + private UUID extractUuidFromUri(String uri) { + try { + String[] parts = uri.split("/"); + String last = parts[parts.length - 1]; + return UUID.fromString(last); + } catch (Exception e) { + return null; + } } } 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 91ab9525..c985ee11 100644 --- a/src/main/java/com/kamco/cd/kamcoback/inference/InferenceResultApiController.java +++ b/src/main/java/com/kamco/cd/kamcoback/inference/InferenceResultApiController.java @@ -414,7 +414,7 @@ public class InferenceResultApiController { downloadReq.setStartDate(strtDttm); downloadReq.setEndDate(endDttm); downloadReq.setSearchValue(searchValue); - downloadReq.setRequestUri("/api/inference/download-audit/download/" + uuid); + downloadReq.setRequestUri("/api/inference/download/" + uuid); return ApiResponseDto.ok(inferenceResultService.getDownloadAudit(searchReq, downloadReq)); } diff --git a/src/main/java/com/kamco/cd/kamcoback/scheduler/config/AsyncConfig.java b/src/main/java/com/kamco/cd/kamcoback/scheduler/config/AsyncConfig.java index 77b99a93..e2eebdac 100644 --- a/src/main/java/com/kamco/cd/kamcoback/scheduler/config/AsyncConfig.java +++ b/src/main/java/com/kamco/cd/kamcoback/scheduler/config/AsyncConfig.java @@ -20,4 +20,15 @@ public class AsyncConfig { ex.initialize(); return ex; } + + @Bean(name = "auditLogExecutor") + public Executor auditLogExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setCorePoolSize(2); + exec.setMaxPoolSize(8); + exec.setQueueCapacity(2000); + exec.setThreadNamePrefix("auditlog-"); + exec.initialize(); + return exec; + } }