Merge pull request 'feat/infer_dev_260211' (#74) from feat/infer_dev_260211 into develop

Reviewed-on: #74
This commit was merged in pull request #74.
This commit is contained in:
2026-02-11 16:23:42 +09:00
6 changed files with 176 additions and 150 deletions

View File

@@ -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<MenuDto.Basic> 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;
}
}
}

View File

@@ -5,7 +5,6 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourceRegion; import org.springframework.core.io.support.ResourceRegion;
@@ -15,43 +14,11 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/**
* Range(이어받기) 지원 파일 다운로드 응답 생성기.
*
* <p>- Range 헤더 없으면 200 + Resource - Range 헤더 있으면 206 + ResourceRegion - 잘못된 Range면 416 +
* Content-Range: bytes
*
* <p>주의: - 다중 Range는 첫 번째 Range만 처리(안정성 목적). - 파일이 이미 생성되어 있는 대용량(zip 등) 다운로드에 적합.
*/
@Component @Component
public class RangeDownloadResponder { public class RangeDownloadResponder {
/** 기본(zip) 다운로드 응답 생성. */
public ResponseEntity<?> buildZipResponse( public ResponseEntity<?> buildZipResponse(
Path filePath, String downloadFileName, HttpServletRequest request) throws IOException { 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)) { if (!Files.isRegularFile(filePath)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
@@ -60,73 +27,53 @@ public class RangeDownloadResponder {
long totalSize = Files.size(filePath); long totalSize = Files.size(filePath);
Resource resource = new FileSystemResource(filePath); Resource resource = new FileSystemResource(filePath);
// 공통 헤더(200/206 공통)
String disposition = "attachment; filename=\"" + downloadFileName + "\""; String disposition = "attachment; filename=\"" + downloadFileName + "\"";
String rangeHeader = request.getHeader(HttpHeaders.RANGE); String rangeHeader = request.getHeader(HttpHeaders.RANGE);
if (rangeHeader == null || rangeHeader.isBlank()) { // 🔥 공통 헤더 (여기 고정)
// 200 OK (전체 다운로드) ResponseEntity.BodyBuilder base =
ResponseEntity.BodyBuilder ok = ResponseEntity.ok()
ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentType(contentType) .header(HttpHeaders.CONTENT_DISPOSITION, disposition)
.header(HttpHeaders.CONTENT_DISPOSITION, disposition) .header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header(HttpHeaders.ACCEPT_RANGES, "bytes") .header("X-Accel-Buffering", "no");
.contentLength(totalSize);
applyExtraHeaders(ok, extraHeaders); if (rangeHeader == null || rangeHeader.isBlank()) {
return ok.body(resource); return base.contentLength(totalSize).body(resource);
} }
// Range 파싱
List<HttpRange> ranges; List<HttpRange> ranges;
try { try {
ranges = HttpRange.parseRanges(rangeHeader); ranges = HttpRange.parseRanges(rangeHeader);
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
// 416 Range Not Satisfiable return ResponseEntity.status(416)
ResponseEntity.BodyBuilder badRange = .header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize)
ResponseEntity.status(416).header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize); .header("X-Accel-Buffering", "no")
applyExtraHeaders(badRange, extraHeaders); .build();
return badRange.build();
} }
// 실무에선 단일 Range만 처리하는 게 안전합니다.
HttpRange range = ranges.get(0); HttpRange range = ranges.get(0);
long start = range.getRangeStart(totalSize); long start = range.getRangeStart(totalSize);
long end = range.getRangeEnd(totalSize); long end = range.getRangeEnd(totalSize);
if (start >= totalSize) { if (start >= totalSize) {
ResponseEntity.BodyBuilder badRange = return ResponseEntity.status(416)
ResponseEntity.status(416).header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize); .header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize)
applyExtraHeaders(badRange, extraHeaders); .header("X-Accel-Buffering", "no")
return badRange.build(); .build();
} }
long regionLength = end - start + 1; long regionLength = end - start + 1;
ResourceRegion region = new ResourceRegion(resource, start, regionLength); ResourceRegion region = new ResourceRegion(resource, start, regionLength);
// 206 Partial Content return ResponseEntity.status(206)
ResponseEntity.BodyBuilder partial = .contentType(MediaType.APPLICATION_OCTET_STREAM)
ResponseEntity.status(206) .header(HttpHeaders.CONTENT_DISPOSITION, disposition)
.contentType(contentType) .header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header(HttpHeaders.CONTENT_DISPOSITION, disposition) .header("X-Accel-Buffering", "no")
.header(HttpHeaders.ACCEPT_RANGES, "bytes") .header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + totalSize)
.header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + totalSize) .contentLength(regionLength)
.contentLength(regionLength); .body(region);
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);
}
});
} }
} }

View File

@@ -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) {}

View File

@@ -1,98 +1,64 @@
package com.kamco.cd.kamcoback.config; package com.kamco.cd.kamcoback.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.kamco.cd.kamcoback.common.download.dto.DownloadAuditEvent;
import com.kamco.cd.kamcoback.common.utils.HeaderUtil;
import com.kamco.cd.kamcoback.common.utils.UserUtil; import com.kamco.cd.kamcoback.common.utils.UserUtil;
import com.kamco.cd.kamcoback.config.api.ApiLogFunction; import com.kamco.cd.kamcoback.config.api.ApiLogFunction;
import com.kamco.cd.kamcoback.menu.dto.MenuDto; import jakarta.servlet.DispatcherType;
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.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; 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 java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerInterceptor;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor
public class FileDownloadInteceptor implements HandlerInterceptor { public class FileDownloadInteceptor implements HandlerInterceptor {
private final AuditLogRepository auditLogRepository; private final ApplicationEventPublisher publisher;
private final MenuService menuService;
private final UserUtil userUtil; 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 @Override
public boolean preHandle( public void afterCompletion(
HttpServletRequest request, HttpServletResponse response, Object handler) { 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) { Long userId;
return true; try {
} userId = userUtil.getId();
if (userId == null) return; // userId null 불가면 스킵
saveLog(request, response); } catch (Exception e) {
log.warn("Download audit userId resolve failed. uri={}, err={}", uri, e.toString());
return true;
}
private void saveLog(HttpServletRequest request, HttpServletResponse response) {
// 파일 다운로드 API만 필터링
if (!request.getRequestURI().contains("/download")) {
return; return;
} }
Long userId = userUtil.getId();
String ip = ApiLogFunction.getClientIp(request); String ip = ApiLogFunction.getClientIp(request);
int status = response.getStatus();
String normalizedUri = uri.replace("/api", "");
List<?> list = menuService.getFindAll(); UUID downloadUuid = extractUuidFromUri(uri);
List<MenuDto.Basic> result = if (downloadUuid == null) {
list.stream() log.warn("Download UUID parse failed. uri={}", uri);
.map( return; // downloadUuid null 불가 -> 스킵
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();
String normalizedUri = request.getRequestURI().replace("/api", ""); publisher.publishEvent(
MenuDto.Basic basic = new DownloadAuditEvent(userId, uri, normalizedUri, ip, status, downloadUuid));
result.stream() }
.filter(
menu -> menu.getMenuUrl() != null && normalizedUri.startsWith(menu.getMenuUrl()))
.max(Comparator.comparingInt(m -> m.getMenuUrl().length()))
.orElse(null);
AuditLogEntity log = private UUID extractUuidFromUri(String uri) {
AuditLogEntity.forFileDownload( try {
userId, String[] parts = uri.split("/");
request.getRequestURI(), String last = parts[parts.length - 1];
Objects.requireNonNull(basic).getMenuUid(), return UUID.fromString(last);
ip, } catch (Exception e) {
response.getStatus(), return null;
UUID.fromString(HeaderUtil.get(request, "kamco-download-uuid"))); }
auditLogRepository.save(log);
} }
} }

View File

@@ -374,8 +374,7 @@ public class InferenceResultApiController {
Path zipPath = Path.of(path); Path zipPath = Path.of(path);
// Range + 200/206/416 공통 처리 (추가 헤더 포함) // Range + 200/206/416 공통 처리 (추가 헤더 포함)
return rangeDownloadResponder.buildZipResponse( return rangeDownloadResponder.buildZipResponse(zipPath, uid + ".zip", request);
zipPath, uid + ".zip", request, Map.of("X-Accel-Buffering", "no"));
} }
@Operation(summary = "shp 파일 다운로드 이력 조회", description = "추론관리 분석결과 shp 파일 다운로드 이력 조회") @Operation(summary = "shp 파일 다운로드 이력 조회", description = "추론관리 분석결과 shp 파일 다운로드 이력 조회")
@@ -415,7 +414,7 @@ public class InferenceResultApiController {
downloadReq.setStartDate(strtDttm); downloadReq.setStartDate(strtDttm);
downloadReq.setEndDate(endDttm); downloadReq.setEndDate(endDttm);
downloadReq.setSearchValue(searchValue); downloadReq.setSearchValue(searchValue);
downloadReq.setRequestUri("/api/inference/download-audit/download/" + uuid); downloadReq.setRequestUri("/api/inference/download/" + uuid);
return ApiResponseDto.ok(inferenceResultService.getDownloadAudit(searchReq, downloadReq)); return ApiResponseDto.ok(inferenceResultService.getDownloadAudit(searchReq, downloadReq));
} }

View File

@@ -20,4 +20,15 @@ public class AsyncConfig {
ex.initialize(); ex.initialize();
return ex; 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;
}
} }