Merge pull request '주석 추가, 패스워드변경 권한 수정' (#116) from feat/infer_dev_260211 into develop

Reviewed-on: #116
This commit was merged in pull request #116.
This commit is contained in:
2026-02-27 13:41:32 +09:00
13 changed files with 191 additions and 329 deletions

View File

@@ -14,23 +14,56 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
/**
* Range(부분 다운로드) 지원 파일 다운로드 응답 생성기.
*
* <p>브라우저/다운로드 매니저가 Range 헤더를 보내면 206 Partial Content로 일부 구간만 내려주고, Range 헤더가 없으면 200 OK로 전체 파일을
* 내려준다.
*
* <p>대용량 ZIP(또는 바이너리) 파일 다운로드 시: - 메모리에 파일 전체를 올리지 않고(Resource/FileSystemResource 스트리밍) -
* 이어받기(Resume) 및 병렬 다운로드(일부 클라이언트) 지원 - 잘못된 Range에 대해 416 Range Not Satisfiable 처리
*/
@Component
public class RangeDownloadResponder {
/**
* ZIP(또는 바이너리) 파일 다운로드 응답을 생성한다.
*
* @param filePath 실제 서버 파일 경로
* @param downloadFileName 사용자에게 노출될 다운로드 파일명
* @param request Range 헤더 확인용 HttpServletRequest
* @return Range 유무에 따라 200(전체) 또는 206(부분) ResponseEntity 반환
* @throws IOException 파일 접근/조회 실패 시
*/
public ResponseEntity<?> buildZipResponse(
Path filePath, String downloadFileName, HttpServletRequest request) throws IOException {
// 1) 파일 존재/정상 파일 여부 확인
// - 일반 파일(regular file)이 아니면 404 반환 (디렉토리/없는 파일/특수 파일 등 방지)
if (!Files.isRegularFile(filePath)) {
return ResponseEntity.notFound().build();
}
// 2) 파일 전체 크기 및 Spring Resource 래핑
// - Files.size: 전체 파일 크기(Content-Range 계산/검증에 필요)
// - FileSystemResource: 스프링이 스트리밍 형태로 파일을 응답 바디로 내려줄 수 있게 함
long totalSize = Files.size(filePath);
Resource resource = new FileSystemResource(filePath);
// 3) 다운로드 강제(Content-Disposition)
// - attachment; filename="xxx.zip" 형태로 브라우저가 저장 대화상자/다운로드로 처리
String disposition = "attachment; filename=\"" + downloadFileName + "\"";
// 4) Range 헤더 조회
// - Range: bytes=0-1023 (일부 구간 요청)
// - Range가 없으면 전체 다운로드로 처리
String rangeHeader = request.getHeader(HttpHeaders.RANGE);
// 🔥 공통 헤더 (여기 고정)
// 5) 공통 헤더(전체/부분 다운로드 공통으로 넣을 것)
// - Content-Type: 바이너리(필요시 application/zip 으로 바꿔도 됨)
// - Content-Disposition: 다운로드 강제
// - Accept-Ranges: bytes -> 서버가 Range(이어받기/부분요청) 지원함을 알림
// - X-Accel-Buffering: no -> Nginx 사용 시 버퍼링 비활성화(스트리밍/대용량에 유리)
ResponseEntity.BodyBuilder base =
ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
@@ -38,10 +71,15 @@ public class RangeDownloadResponder {
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header("X-Accel-Buffering", "no");
// 6) Range 헤더가 없으면 전체 파일 반환 (200 OK)
if (rangeHeader == null || rangeHeader.isBlank()) {
// Content-Length를 전체 크기로 설정하고 Resource를 그대로 바디에 담아 스트리밍
return base.contentLength(totalSize).body(resource);
}
// 7) Range 헤더 파싱
// - 잘못된 Range 헤더 형식이면 parseRanges에서 IllegalArgumentException 발생 가능
// - RFC에 따라 416 응답 + Content-Range: bytes */{total} 형태로 알려줌
List<HttpRange> ranges;
try {
ranges = HttpRange.parseRanges(rangeHeader);
@@ -52,11 +90,18 @@ public class RangeDownloadResponder {
.build();
}
// 8) 다중 Range 요청이 와도(예: bytes=0-99,200-299) 여기서는 첫 번째 Range만 처리
// - 대부분 브라우저는 단일 Range를 사용
// - 멀티파트/byteranges 처리를 하려면 별도 구현 필요
HttpRange range = ranges.get(0);
// 9) 실제 시작/끝 범위 계산
// - bytes=500- : end를 파일 끝으로 해석
// - bytes=-500 : 마지막 500바이트로 해석
long start = range.getRangeStart(totalSize);
long end = range.getRangeEnd(totalSize);
// 10) 시작점이 파일 크기 이상이면 만족 불가 -> 416
if (start >= totalSize) {
return ResponseEntity.status(416)
.header(HttpHeaders.CONTENT_RANGE, "bytes */" + totalSize)
@@ -64,9 +109,18 @@ public class RangeDownloadResponder {
.build();
}
// 11) 요청 구간 길이 계산
// - end/start는 inclusive이므로 +1 필요
long regionLength = end - start + 1;
// 12) ResourceRegion 생성
// - resource의 start부터 regionLength 만큼만 응답으로 내려줄 수 있게 함
// - 파일 전체를 메모리에 올리지 않고 필요한 부분만 스트리밍
ResourceRegion region = new ResourceRegion(resource, start, regionLength);
// 13) 206 Partial Content로 응답 구성
// - Content-Range: bytes start-end/total
// - Content-Length: regionLength(부분 크기)
return ResponseEntity.status(206)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, disposition)

View File

@@ -111,7 +111,6 @@ public class SecurityConfig {
.requestMatchers(
"/api/user/**",
"/api/my/menus",
"/api/members/*/password",
"/api/training-data/label/**",
"/api/training-data/review/**")
.authenticated()

View File

@@ -340,6 +340,7 @@ public class InferenceResultApiController {
}
/** 추론결과 화면에서 호출 */
/** 다운로드는 a 링크로 받는걸로 변경되어 사번을 파라미터로 받아서 로그에 저장하는걸로 변경함 */
@Operation(summary = "shp 파일 다운로드", description = "추론관리 분석결과 shp 파일 다운로드")
@ApiResponses(
value = {
@@ -373,7 +374,6 @@ public class InferenceResultApiController {
Path zipPath = Path.of(path);
// Range + 200/206/416 공통 처리 (추가 헤더 포함)
return rangeDownloadResponder.buildZipResponse(zipPath, uid + ".zip", request);
}

View File

@@ -780,11 +780,17 @@ public class InferenceResultService {
return inferenceResultCoreService.listGetScenes5k(id);
}
/**
* 추론 서버 현황 cpu, gpu 확인
*
* @return 서버 정보
*/
public List<InferenceServerStatusDto> getInferenceServerStatusList() {
String[] serverNames = inferenceServerName.split(",");
int serveCnt = serverNames.length;
// 서버정보 조회
List<InferenceServerStatusDto> dtoList =
inferenceResultCoreService.getInferenceServerStatusList();
int size = dtoList.size();
@@ -794,6 +800,7 @@ public class InferenceResultService {
System.out.println("size =" + size);
if (size == 0) {
// 서버 정보가 없을때
for (int k = 0; k < serveCnt; k++) {
InferenceServerStatusDto dto = new InferenceServerStatusDto();
dto.setServerName(serverNames[k]);
@@ -861,17 +868,35 @@ public class InferenceResultService {
return inferenceResultCoreService.getInferenceResultInfo(uuid);
}
/**
* 분류별 탐지건수 조회
*
* @param uuid 추론 uuid
* @return 분류별 탐지건수 정보
*/
public List<Dashboard> getInferenceClassCountList(UUID uuid) {
return inferenceResultCoreService.getInferenceClassCountList(uuid);
}
/**
* 추론결과 geom 목록 조회
*
* @param uuid 추론 uuid
* @param searchGeoReq 추론 결과 상세화면 geom 조회 조건
* @return geom 목록 정보
*/
public Page<Geom> getInferenceGeomList(UUID uuid, SearchGeoReq searchGeoReq) {
return inferenceResultCoreService.getInferenceGeomList(uuid, searchGeoReq);
}
/** 추론 종료 */
/**
* 추론 종료
*
* @return 호출한 uuid
*/
@Transactional
public UUID deleteInferenceEnd() {
// 추론 진행중인지 확인
SaveInferenceAiDto dto = inferenceResultCoreService.getProcessing();
if (dto == null) {
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND);
@@ -883,13 +908,15 @@ public class InferenceResultService {
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
// 종료 api 호출
ExternalCallResult<String> result =
externalHttpClient.call(url, HttpMethod.DELETE, dto, headers, String.class);
externalHttpClient.callLong(url, HttpMethod.DELETE, dto, headers, String.class);
if (!result.success()) {
throw new CustomApiException("BAD_GATEWAY", HttpStatus.BAD_GATEWAY);
}
// 추론 정보 테이블 update
SaveInferenceAiDto request = new SaveInferenceAiDto();
request.setStatus(Status.FORCED_END.getId());
request.setUuid(dto.getUuid());
@@ -910,8 +937,11 @@ public class InferenceResultService {
* @return 32자 추론 uid, shp 파일 경로
*/
public Map<String, Object> shpDownloadPath(UUID uuid) {
// 추론정보 조회
InferenceLearnDto dto = inferenceResultCoreService.getInferenceUid(uuid);
String uid = dto.getUid();
// 파일 경로 생성
Path path = Path.of(datasetDir).resolve(uid).resolve("merge").resolve(uid + ".zip");
Map<String, Object> downloadMap = new HashMap<>();

View File

@@ -387,6 +387,8 @@ public class MapSheetMngService {
}
/**
* 연도 목록 조회
*
* @return
*/
public List<MngYyyyDto> findMapSheetMngDoneYyyyList() {

View File

@@ -41,6 +41,16 @@ public class ModelMngService {
@Value("${file.pt-FileName}")
private String ptFileName;
/**
* 모델조회
*
* @param searchReq 페이징
* @param startDate 시작날짜
* @param endDate 종료날짜
* @param modelType 모델 타입 G1, G2, G3
* @param searchVal 모델 ver
* @return 모델 목록
*/
public Page<ModelMngDto.ModelList> findModelMgmtList(
ModelMngDto.searchReq searchReq,
LocalDate startDate,

View File

@@ -453,17 +453,30 @@ public class InferenceResultCoreService {
* @return
*/
public AnalResultInfo getInferenceResultInfo(UUID uuid) {
// 추론 결과 정보조회
AnalResultInfo resultInfo = mapSheetLearnRepository.getInferenceResultInfo(uuid);
// bbox, point 조회
BboxPointDto bboxPointDto = mapSheetLearnRepository.getBboxPoint(uuid);
resultInfo.setBboxGeom(bboxPointDto.getBboxGeom());
resultInfo.setBboxCenterPoint(bboxPointDto.getBboxCenterPoint());
return resultInfo;
}
/**
* 분류별 탐지건수 조회
*
* @param uuid 추론 uuid
* @return 분류별 탐지건수 정보
*/
public List<Dashboard> getInferenceClassCountList(UUID uuid) {
return mapSheetLearnRepository.getInferenceClassCountList(uuid);
}
/**
* @param uuid 추론 uuid
* @param searchGeoReq 추론 결과 상세화면 geom 조회 조건
* @return geom 목록 정보
*/
public Page<Geom> getInferenceGeomList(UUID uuid, SearchGeoReq searchGeoReq) {
return mapSheetLearnRepository.getInferenceGeomList(uuid, searchGeoReq);
}

View File

@@ -291,40 +291,6 @@ public class MapSheetMngCoreService {
return sceneInference;
}
/**
* 변화탐지 실행 가능 기준 년도 조회
*
* @param compareYear 비교 연도
* @param targetYear 기준 연도
* @param mapSheetScope 부분실행, 전체실행
* @param mapSheetNums 도엽번호 목록
* @return 실행가능한 도엽번호 목록
*/
public List<MngListDto> findExecutableSheets(
Integer compareYear, Integer targetYear, String mapSheetScope, List<String> mapSheetNums) {
return mapSheetMngRepository.findExecutableSheets(
compareYear, targetYear, mapSheetScope, mapSheetNums);
}
public List<MngListDto> fetchBaseWithCompare(
Integer compareYear, Integer targetYear, String mapSheetScope, List<String> mapSheetNums) {
return mapSheetMngRepository.fetchBaseWithCompare(
compareYear, targetYear, mapSheetScope, mapSheetNums);
}
/**
* 실행가능 도엽 count
*
* @param year 연도
* @param mapSheetScope 부분, 전체 구분
* @param mapSheetNums 도엽 목록
* @return count
*/
public int countExecutableSheetsDistinct(
Integer year, String mapSheetScope, List<String> mapSheetNums) {
return mapSheetMngRepository.countExecutableSheetsDistinct(year, mapSheetScope, mapSheetNums);
}
public void updateMapSheetMngHstUploadId(Long hstUid, UUID uuid, String uploadId) {
mapSheetMngRepository.updateMapSheetMngHstUploadId(hstUid, uuid, uploadId);
}

View File

@@ -17,6 +17,16 @@ public class ModelMngCoreService {
private final ModelMngRepository modelMngRepository;
/**
* 모델조회
*
* @param searchReq 페이징
* @param startDate 시작날짜
* @param endDate 종료날짜
* @param modelType 모델 타입 G1, G2, G3
* @param searchVal 모델 ver
* @return 모델 목록
*/
public Page<ModelMngDto.ModelList> findModelMgmtList(
ModelMngDto.searchReq searchReq,
LocalDate startDate,

View File

@@ -62,40 +62,6 @@ public interface MapSheetMngRepositoryCustom {
List<MapSheetMngDto.MngFilesDto> findByHstUidMapSheetFileList(Long hstUid);
/**
* 추론 실행 가능 도엽 조회 (추론 제외)
*
* @param compareYear 비교 연도
* @param targetYear 기준 연도
* @param mapSheetScope 부분실행, 전체실행
* @param mapSheetNums 도엽번호 목록
* @return 실행가능한 도엽번호 목록
*/
List<MngListDto> findExecutableSheets(
Integer compareYear, Integer targetYear, String mapSheetScope, List<String> mapSheetNums);
/**
* 추론 실행 가능 도엽 조회 (이전년도 사용가능)
*
* @param targetYear
* @param compareYear
* @param mapSheetScope
* @param mapSheetNums
* @return
*/
List<MngListDto> fetchBaseWithCompare(
Integer targetYear, Integer compareYear, String mapSheetScope, List<String> mapSheetNums);
/**
* 실행가능 도엽 count
*
* @param year 연도
* @param mapSheetScope 부분, 전체 구분
* @param mapSheetNums 도엽 목록
* @return count
*/
int countExecutableSheetsDistinct(Integer year, String mapSheetScope, List<String> mapSheetNums);
MapSheetMngDto.MngFilesDto findByFileUidMapSheetFile(Long fileUid);
void updateHstFileSizes(Long hstUid, long tifSizeBytes, long tfwSizeBytes, long totalSizeBytes);

View File

@@ -22,7 +22,6 @@ import com.kamco.cd.kamcoback.postgres.entity.QMapSheetMngHstEntity;
import com.kamco.cd.kamcoback.postgres.entity.QYearEntity;
import com.kamco.cd.kamcoback.postgres.entity.YearEntity;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.CaseBuilder;
@@ -35,11 +34,8 @@ import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.validation.Valid;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -577,204 +573,6 @@ public class MapSheetMngRepositoryImpl extends QuerydslRepositorySupport
return foundContent;
}
@Override
public List<MngListDto> findExecutableSheets(
Integer compareYear, Integer targetYear, String mapSheetScope, List<String> mapSheetNums) {
QMapSheetMngHstEntity t = new QMapSheetMngHstEntity("t");
QMapSheetMngHstEntity c = new QMapSheetMngHstEntity("c");
QMapInkx5kEntity ti = new QMapInkx5kEntity("ti");
QMapInkx5kEntity ci = new QMapInkx5kEntity("ci");
QMapSheetMngFileEntity tf = new QMapSheetMngFileEntity("tf");
QMapSheetMngFileEntity cf = new QMapSheetMngFileEntity("cf");
BooleanBuilder whereBuilderere = new BooleanBuilder();
whereBuilderere.and(t.dataState.eq("DONE"));
whereBuilderere.and(t.syncState.eq("DONE").or(t.syncCheckState.eq("DONE")));
whereBuilderere.and(t.useInference.eq("USE"));
whereBuilderere.and(t.mngYyyy.eq(targetYear));
// target: tif + DONE 파일 존재
whereBuilderere.and(
JPAExpressions.selectOne()
.from(tf)
.where(tf.hstUid.eq(t.hstUid).and(tf.fileExt.eq("tif")).and(tf.fileState.eq("DONE")))
.exists());
// PART scope면 prefix like 조건 추가
BooleanBuilder likeBuilder = new BooleanBuilder();
if (MapSheetScope.PART.getId().equals(mapSheetScope)) {
if (mapSheetNums == null || mapSheetNums.isEmpty()) {
return List.of();
}
for (String prefix : mapSheetNums) {
if (prefix == null || prefix.isBlank()) continue;
likeBuilder.or(t.mapSheetNum.like(prefix.trim() + "%"));
}
if (likeBuilder.hasValue()) {
whereBuilderere.and(likeBuilder);
}
}
// compare 조건은 EXISTS 서브쿼리 안에서 체크
BooleanExpression compareExists =
JPAExpressions.selectOne()
.from(c)
.join(ci)
.on(ci.mapidcdNo.eq(c.mapSheetNum).and(ci.useInference.eq(CommonUseStatus.USE)))
.where(
c.mapSheetNum
.eq(t.mapSheetNum) // 핵심: 같은 도엽번호
.and(c.dataState.eq("DONE"))
.and(c.syncState.eq("DONE").or(c.syncCheckState.eq("DONE")))
.and(c.useInference.eq("USE"))
.and(c.mngYyyy.eq(compareYear))
.and(
JPAExpressions.selectOne()
.from(cf)
.where(
cf.hstUid
.eq(c.hstUid)
.and(cf.fileExt.eq("tif"))
.and(cf.fileState.eq("DONE")))
.exists()))
.exists();
return queryFactory
.select(
Projections.constructor(
MngListDto.class,
t.mngYyyy, // int mngYyyy
t.mapSheetNum, // String mapSheetNum
t.mapSheetName, // String mapSheetName
Expressions.nullExpression(Integer.class),
Expressions.nullExpression(Boolean.class)))
.from(t)
.join(ti)
.on(ti.mapidcdNo.eq(t.mapSheetNum).and(ti.useInference.eq(CommonUseStatus.USE)))
.where(whereBuilderere.and(compareExists))
.distinct()
.fetch();
}
@Override
public List<MngListDto> fetchBaseWithCompare(
Integer compareYear, // 예: 2024 (비교 상한)
Integer targetYear, // 예: 2026 (기준 고정)
String mapSheetScope,
List<String> mapSheetNums) {
QMapSheetMngHstEntity h = QMapSheetMngHstEntity.mapSheetMngHstEntity; // base
QMapSheetMngHstEntity h2 = new QMapSheetMngHstEntity("h2"); // cmp
QMapInkx5kEntity i = QMapInkx5kEntity.mapInkx5kEntity;
QMapSheetMngFileEntity f = QMapSheetMngFileEntity.mapSheetMngFileEntity;
QMapSheetMngFileEntity f2 = new QMapSheetMngFileEntity("f2");
// -----------------------
// 공통: tif DONE exists
// -----------------------
BooleanExpression baseTifExists =
JPAExpressions.selectOne()
.from(f)
.where(f.hstUid.eq(h.hstUid), f.fileExt.eq("tif"), f.fileState.eq("DONE"))
.exists();
BooleanExpression cmpTifExists =
JPAExpressions.selectOne()
.from(f2)
.where(f2.hstUid.eq(h2.hstUid), f2.fileExt.eq("tif"), f2.fileState.eq("DONE"))
.exists();
// -----------------------
// 1) base 조회 (CTE base)
// -----------------------
BooleanBuilder baseWhere =
new BooleanBuilder()
.and(h.mngYyyy.eq(targetYear))
.and(h.dataState.eq("DONE"))
.and(h.useInference.eq("USE"))
.and(h.syncState.eq("DONE").or(h.syncCheckState.eq("DONE")))
.and(baseTifExists);
// PART 조건
if (MapSheetScope.PART.getId().equals(mapSheetScope)) {
if (mapSheetNums == null || mapSheetNums.isEmpty()) return List.of();
BooleanBuilder like = new BooleanBuilder();
for (String prefix : mapSheetNums) {
if (prefix == null || prefix.isBlank()) continue;
like.or(h.mapSheetNum.like(prefix.trim() + "%"));
}
if (!like.hasValue()) return List.of();
baseWhere.and(like);
}
// base: distinct map_sheet_num, mng_yyyy, map_sheet_name
List<Tuple> baseTuples =
queryFactory
.select(h.mapSheetNum, h.mngYyyy, h.mapSheetName)
.distinct()
.from(h)
.join(i)
.on(i.mapidcdNo.eq(h.mapSheetNum), i.useInference.eq(CommonUseStatus.USE))
.where(baseWhere)
.fetch();
if (baseTuples.isEmpty()) return List.of();
List<String> baseNums = baseTuples.stream().map(t -> t.get(h.mapSheetNum)).toList();
// -----------------------
// 2) cmp 집계 (CTE cmp)
// map_sheet_num별 max(mng_yyyy <= 2024)
// -----------------------
List<Tuple> cmpTuples =
queryFactory
.select(h2.mapSheetNum, h2.mngYyyy.max())
.from(h2)
.where(
h2.mapSheetNum.in(baseNums),
h2.mngYyyy.loe(compareYear),
h2.dataState.eq("DONE"),
h2.useInference.eq("USE"),
h2.syncState.eq("DONE").or(h2.syncCheckState.eq("DONE")),
cmpTifExists)
.groupBy(h2.mapSheetNum)
.fetch();
Map<String, Integer> beforeYearMap = new HashMap<>(cmpTuples.size());
for (Tuple t : cmpTuples) {
String num = t.get(h2.mapSheetNum);
Integer beforeYear = t.get(h2.mngYyyy.max());
beforeYearMap.put(num, beforeYear);
}
// -----------------------
// 3) base JOIN cmp (메모리)
// - SQL에서 join cmp c on c.map_sheet_num = b.map_sheet_num 동일
// -----------------------
List<MngListDto> result = new ArrayList<>();
for (Tuple bt : baseTuples) {
String num = bt.get(h.mapSheetNum);
Integer beforeYear = beforeYearMap.get(num);
if (beforeYear == null) continue; // SQL의 inner join cmp 효과
result.add(
new MngListDto(
bt.get(h.mngYyyy), // 2026
num,
bt.get(h.mapSheetName),
beforeYear, // <=2024 max
null // isSuccess
));
}
return result;
}
@Override
public List<MapSheetMngDto.MngFilesDto> findHstUidToMapSheetFileList(Long hstUid) {
BooleanBuilder whereBuilder = new BooleanBuilder();
@@ -1340,57 +1138,4 @@ public class MapSheetMngRepositoryImpl extends QuerydslRepositorySupport
.exists())))
.fetch();
}
@Override
public int countExecutableSheetsDistinct(
Integer year, String mapSheetScope, List<String> mapSheetNum) {
QMapSheetMngHstEntity h = QMapSheetMngHstEntity.mapSheetMngHstEntity;
QMapInkx5kEntity i = QMapInkx5kEntity.mapInkx5kEntity;
QMapSheetMngFileEntity f = QMapSheetMngFileEntity.mapSheetMngFileEntity;
BooleanBuilder where = new BooleanBuilder();
// 실행가능 조건
where.and(h.dataState.eq("DONE"));
where.and(h.syncState.eq("DONE").or(h.syncCheckState.eq("DONE")));
where.and(h.useInference.eq("USE"));
where.and(h.mngYyyy.eq(year));
// tif + DONE 파일 존재 조건 AND EXISTS
where.and(
JPAExpressions.selectOne()
.from(f)
.where(f.hstUid.eq(h.hstUid).and(f.fileExt.eq("tif")).and(f.fileState.eq("DONE")))
.exists());
// PART scope prefix 조건
if (MapSheetScope.PART.getId().equals(mapSheetScope)) {
if (mapSheetNum == null || mapSheetNum.isEmpty()) {
return 0;
}
BooleanBuilder likeBuilder = new BooleanBuilder();
for (String prefix : mapSheetNum) {
if (prefix == null || prefix.isBlank()) continue;
likeBuilder.or(h.mapSheetNum.like(prefix.trim() + "%"));
}
if (likeBuilder.hasValue()) {
where.and(likeBuilder);
}
}
// DISTINCT mapSheetNum 기준 카운트
Long count =
queryFactory
.select(h.mapSheetNum.countDistinct())
.from(h)
.join(i)
.on(i.mapidcdNo.eq(h.mapSheetNum).and(i.useInference.eq(CommonUseStatus.USE)))
.where(where)
.fetchOne();
return count != null ? count.intValue() : 0;
}
}

View File

@@ -9,6 +9,16 @@ import org.springframework.data.domain.Page;
public interface ModelMngRepositoryCustom {
/**
* 모델조회
*
* @param searchReq 페이징
* @param startDate 시작날짜
* @param endDate 종료날짜
* @param modelType 모델 타입 G1, G2, G3
* @param searchVal 모델 ver
* @return 모델 목록
*/
Page<ModelMngDto.ModelList> findModelMgmtList(
ModelMngDto.searchReq searchReq,
LocalDate startDate,

View File

@@ -7,19 +7,76 @@ import org.springframework.stereotype.Component;
@Component
public class ShpKeyLock {
/**
* key별 Lock 객체를 저장하는 맵
*
* <p>- key: 예) shp 파일 경로, uuid, 도엽번호 등 - value: 해당 key 전용 ReentrantLock
*
* <p>ConcurrentHashMap을 사용하여 멀티스레드 환경에서도 안전하게 접근 가능
*/
private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();
/**
* 특정 key에 대한 Lock 시도
*
* @param key 동시성 제어 대상 식별자
* @return true → 락 획득 성공 false → 이미 다른 스레드가 사용 중
*/
public boolean tryLock(String key) {
/**
* computeIfAbsent 설명:
*
* <p>- 해당 key가 이미 존재하면 기존 Lock 반환 - 없으면 새 ReentrantLock 생성 후 저장
*
* <p>동시성 환경에서도 원자적으로 실행됨
*/
ReentrantLock lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
/**
* tryLock():
*
* <p>- 대기하지 않고 즉시 시도 - 이미 다른 스레드가 점유 중이면 false 반환
*
* <p>→ 다운로드 중복 방지에 적합
*/
return lock.tryLock();
}
/**
* 특정 key에 대한 Lock 해제
*
* <p>반드시 tryLock 성공한 동일 스레드에서 호출해야 함
*/
public void unlock(String key) {
// 해당 key에 등록된 Lock 조회
ReentrantLock lock = locks.get(key);
/**
* isHeldByCurrentThread():
*
* <p>- 현재 스레드가 해당 락을 보유 중인지 확인 - 다른 스레드가 unlock 호출하는 것 방지
*/
if (lock != null && lock.isHeldByCurrentThread()) {
// 실제 락 해제
lock.unlock();
// 메모리 누수 방지(락이 비어있으면 제거)
/**
* 메모리 누수 방지 처리
*
* <p>hasQueuedThreads(): - 현재 이 락을 기다리는 스레드가 있는지 확인
*
* <p>대기 스레드가 없다면 locks 맵에서 제거하여 불필요한 Lock 객체 정리
*/
if (!lock.hasQueuedThreads()) {
/**
* remove(key, lock):
*
* <p>- 현재 맵에 등록된 값이 'lock'일 경우에만 제거 - 동시성 안전 (CAS 방식)
*/
locks.remove(key, lock);
}
}