From 5b64bd76aacae2682b83eebb4b5253ce023d1156 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Thu, 3 Sep 2026 19:45:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=B0=B8=EC=A1=B0=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/file/FileJpaRepository.java | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/heddy/adapter/out/persistence/file/FileJpaRepository.java b/src/main/java/com/heddy/adapter/out/persistence/file/FileJpaRepository.java index 726d885..1ef0fd9 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/file/FileJpaRepository.java +++ b/src/main/java/com/heddy/adapter/out/persistence/file/FileJpaRepository.java @@ -80,10 +80,9 @@ List findReclaimTargets( /** * READY 고아 판정 분기다. 네이티브 SQL 은 바인드 파라미터로 테이블 이름을 받을 수 없어, - * "파일을 참조하는 테이블" 목록이 이 상수 안에 하드코딩된다. 현재 READY 파일을 참조하는 - * 도메인은 시술기록 사진({@code treatment_record_photos}) 하나뿐이다. 새 도메인이 READY - * 파일을 참조하게 되면 여기에 조건을 덧대지 않으면 그 도메인이 붙인 파일까지 고아로 - * 판정돼 사진이 통째로 정리된다 — 파일 참조 기능을 추가할 때는 반드시 이 상수를 함께 고친다. + * "파일을 참조하는 테이블" 목록이 이 상수 안에 하드코딩된다. 새 도메인이 READY 파일을 + * 참조하게 되면 여기에 조건을 덧대지 않을 경우 사용 중인 파일까지 고아로 오판해 스토리지 + * 객체를 지운다. 파일 참조 기능을 추가할 때는 반드시 이 상수를 함께 고친다. */ String READY_ORPHAN_CLAUSE = """ OR ( @@ -93,6 +92,20 @@ AND NOT EXISTS ( SELECT 1 FROM treatment_record_photos photo WHERE photo.file_id = f.file_id ) + AND NOT EXISTS ( + SELECT 1 FROM analysis_overlays overlay + WHERE overlay.file_id = f.file_id + ) + AND NOT EXISTS ( + SELECT 1 FROM hairstyle_assets hairstyle + WHERE hairstyle.thumbnail_file_id = f.file_id + OR hairstyle.base_file_id = f.file_id + OR hairstyle.mask_file_id = f.file_id + ) + AND NOT EXISTS ( + SELECT 1 FROM saved_styles saved_style + WHERE saved_style.capture_id = f.file_id + ) )"""; /** From f0bed535a908af88cb377ae7bcb48a66d9e8becc Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Thu, 3 Sep 2026 19:45:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20READY=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=EB=B3=84=20=EC=A0=95=EB=A6=AC=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...FilePersistenceAdapterIntegrationTest.java | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/test/java/com/heddy/adapter/out/persistence/file/FilePersistenceAdapterIntegrationTest.java b/src/test/java/com/heddy/adapter/out/persistence/file/FilePersistenceAdapterIntegrationTest.java index 4ef8a6e..5bec5a4 100644 --- a/src/test/java/com/heddy/adapter/out/persistence/file/FilePersistenceAdapterIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/out/persistence/file/FilePersistenceAdapterIntegrationTest.java @@ -4,6 +4,7 @@ import com.heddy.domain.file.exception.FileException; import com.heddy.domain.file.model.FilePurpose; import com.heddy.domain.file.model.FileStatus; +import com.heddy.domain.file.model.StorageObject; import com.heddy.domain.file.model.StoredFile; import com.heddy.domain.file.model.VerifiedContent; import com.heddy.support.PostgresIntegrationTest; @@ -247,6 +248,87 @@ void findsExpiredPendingUnlinkedReadyAndExpiredDeletedCleanupCandidates() { .doesNotContain(freshPending.fileId(), freshDeleted.fileId()); } + /** + * READY 고아 판정은 파일을 직접 참조하는 모든 테이블을 알아야 한다. 참조 누락은 DB 행은 + * READY 로 남긴 채 스토리지 객체만 먼저 지우므로, API 가 발급한 정상 서명 URL이 + * {@code NoSuchKey} 로 깨지는 운영 장애가 된다. + */ + @Test + void excludesEveryReferencedReadyFileFromCleanupCandidates() { + Instant now = Instant.now(); + StoredFile treatmentPhoto = oldReadyUserFile( + "referenced-treatment.jpg", FilePurpose.TREATMENT_PHOTO, "image/jpeg", now); + StoredFile analysisOverlay = oldReadySystemFile( + "referenced-overlay.png", FilePurpose.ANALYSIS_OVERLAY_INTERNAL, "image/png", now); + StoredFile thumbnail = oldReadySystemFile( + "referenced-thumbnail.jpg", FilePurpose.HAIRSTYLE_THUMBNAIL, "image/jpeg", now); + StoredFile arBase = oldReadySystemFile( + "referenced-ar-base.png", FilePurpose.HAIRSTYLE_AR_BASE, "image/png", now); + StoredFile arMask = oldReadySystemFile( + "referenced-ar-mask.png", FilePurpose.HAIRSTYLE_AR_MASK, "image/png", now); + StoredFile savedCapture = oldReadyUserFile( + "referenced-capture.jpg", FilePurpose.AR_CAPTURE, "image/jpeg", now); + StoredFile orphan = oldReadyUserFile( + "unreferenced.jpg", FilePurpose.TREATMENT_PHOTO, "image/jpeg", now); + + UUID recordId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO treatment_records (record_id, user_id, service_types, performed_at) + VALUES (?, ?, '[]'::jsonb, now()) + """, recordId, USER_ID); + jdbcTemplate.update(""" + INSERT INTO treatment_record_photos + (photo_id, record_id, file_id, image_type, sort_order) + VALUES (?, ?, ?, 'BEFORE', 0) + """, UUID.randomUUID(), recordId, treatmentPhoto.fileId()); + + UUID jobId = UUID.randomUUID(); + UUID analysisId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO analysis_jobs + (job_id, user_id, record_id, status, progress, attempt_count) + VALUES (?, ?, ?, 'SUCCEEDED', 100, 1) + """, jobId, USER_ID, recordId); + jdbcTemplate.update(""" + INSERT INTO analysis_results ( + analysis_id, job_id, user_id, record_id, + color_uniformity_score, color_uniformity_grade, + shape_symmetry_score, shape_symmetry_grade, + volume_balance_score, volume_balance_grade, + roughness_score, roughness_grade, + confidence_score, confidence_grade, model_version, analyzed_at + ) VALUES ( + ?, ?, ?, ?, 80, 'HIGH', 80, 'HIGH', 80, 'HIGH', + 80, 'HIGH', 80, 'HIGH', 'test', now() + ) + """, analysisId, jobId, USER_ID, recordId); + jdbcTemplate.update(""" + INSERT INTO analysis_overlays (overlay_id, analysis_id, overlay_type, file_id) + VALUES (?, ?, 'HEATMAP', ?) + """, UUID.randomUUID(), analysisId, analysisOverlay.fileId()); + + UUID hairstyleId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO hairstyle_assets ( + hairstyle_id, style_name, category, thumbnail_file_id, + base_file_id, mask_file_id, asset_version + ) VALUES (?, '정리 참조 테스트', 'TEST', ?, ?, ?, 'test') + """, hairstyleId, thumbnail.fileId(), arBase.fileId(), arMask.fileId()); + jdbcTemplate.update(""" + INSERT INTO saved_styles (saved_style_id, user_id, style_name, capture_id) + VALUES (?, ?, '저장 후보 정리 테스트', ?) + """, UUID.randomUUID(), USER_ID, savedCapture.fileId()); + + List candidates = adapter.findCleanupCandidates( + now.minusSeconds(60), now.minusSeconds(86_400), 100); + + assertThat(candidates).extracting(StoredFile::fileId) + .contains(orphan.fileId()) + .doesNotContain( + treatmentPhoto.fileId(), analysisOverlay.fileId(), thumbnail.fileId(), + arBase.fileId(), arMask.fileId(), savedCapture.fileId()); + } + // ------------------------------------------------------------------ 스키마 대조 @Test @@ -331,6 +413,36 @@ private static StoredFile expiredPendingPhoto(String name) { Instant.now().minus(1, ChronoUnit.MINUTES)); } + private StoredFile oldReadyUserFile( + String name, FilePurpose purpose, String contentType, Instant now + ) { + StoredFile pending = adapter.insert(StoredFile.pending( + USER_ID, purpose, purpose + "/" + USER_ID + "/" + name, + contentType, name, 1_024, DECLARED_SHA256, + now.plus(5, ChronoUnit.MINUTES))); + StoredFile ready = adapter.transition( + pending.markReady(new StorageObject(contentType, 1_024)), FileStatus.PENDING); + makeOld(ready.fileId(), now); + return ready; + } + + private StoredFile oldReadySystemFile( + String name, FilePurpose purpose, String contentType, Instant now + ) { + StoredFile pending = adapter.insert(StoredFile.pendingSystem( + purpose, purpose + "/system/" + name, contentType, name, 1_024, + DECLARED_SHA256, now.plus(5, ChronoUnit.MINUTES))); + StoredFile ready = adapter.transition( + pending.markReady(new StorageObject(contentType, 1_024)), FileStatus.PENDING); + makeOld(ready.fileId(), now); + return ready; + } + + private void makeOld(UUID fileId, Instant now) { + jdbcTemplate.update("UPDATE files SET created_at = ? WHERE file_id = ?", + Timestamp.from(now.minusSeconds(172_800)), fileId); + } + private void insertUser(UUID userId, String email) { jdbcTemplate.update(""" INSERT INTO users (