From 02f43b4bef0b63998c9614c2979a62b1351ec901 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Tue, 1 Sep 2026 23:22:10 +0900 Subject: [PATCH 1/3] =?UTF-8?q?hotfix:=20=EA=B3=B5=EC=9C=A0=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=84=20heddy.site?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.prod.yml | 2 ++ docs/deploy-setup.md | 3 +++ src/main/resources/application.yml | 2 +- .../adapter/in/web/sharing/PublicShareApiIntegrationTest.java | 2 +- .../heddy/application/sharing/service/ShareServiceTest.java | 2 +- 5 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b41a6bd..7ad8909 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -29,6 +29,8 @@ services: # 실제 S3 를 쓰므로 운영 값은 빈 문자열이다. unset 만 아니면 통과시킨다. AWS_S3_ENDPOINT: ${AWS_S3_ENDPOINT:-} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:?CORS_ALLOWED_ORIGINS is required} + # QR/공유 버튼이 발급하는 URL의 앞부분. 운영 기본 도메인은 heddy.site 다. + SHARE_PUBLIC_BASE_URL: ${SHARE_PUBLIC_BASE_URL:-https://heddy.site/s} ports: - "8080:8080" healthcheck: diff --git a/docs/deploy-setup.md b/docs/deploy-setup.md index a9eed56..feabbff 100644 --- a/docs/deploy-setup.md +++ b/docs/deploy-setup.md @@ -42,6 +42,7 @@ AWS_S3_ACCESS_KEY= # 인스턴스 역할 쓰면 비움, 아니면 IAM AWS_S3_SECRET_KEY= # 위와 항상 함께 (둘 다 비움 or 둘 다 채움) AWS_S3_ENDPOINT= CORS_ALLOWED_ORIGINS=https://<실제 클라이언트 도메인> +SHARE_PUBLIC_BASE_URL=https://heddy.site/s # 선택: 미지정 시에도 이 값이 기본 적용됨 ``` | 변수 | 규칙 | @@ -52,6 +53,7 @@ CORS_ALLOWED_ORIGINS=https://<실제 클라이언트 도메인> | `AWS_S3_ACCESS_KEY` · `AWS_S3_SECRET_KEY` | 둘 다 비우면 인스턴스 역할로 인증한다. 배포 전에 인스턴스에 역할이 붙어 있는지 확인한다 | | `AWS_S3_ENDPOINT` | **빈 값 유지** — 실제 AWS S3 를 쓴다 (MinIO 엔드포인트를 넣지 않는다) | | `JWT_SECRET` | 기본값이 없는 유일한 변수. 없으면 부팅 자체가 실패한다 | +| `SHARE_PUBLIC_BASE_URL` | QR을 찍었을 때 열 프론트 공유 페이지의 URL 앞부분. 기본값은 `https://heddy.site/s`이며 다른 환경에서만 재정의한다 | 기능별 선택 변수(소셜 로그인 `KAKAO_APP_ID` · `GOOGLE_CLIENT_ID` · `APPLE_CLIENT_ID`, SMS `SOLAPI_*` 등)는 해당 기능을 켤 때 같은 파일에 추가하면 `.env` 째로 컨테이너에 전달된다. @@ -74,6 +76,7 @@ curl -s http://localhost:8080/actuator/health # {"status":"UP"} 이어야 한 ```bash docker exec heddy-v2-app printenv REDIS_HOST # localhost 가 아니라 redis 여야 한다 docker exec heddy-v2-app printenv DB_URL # postgres:5432 여야 한다 +docker exec heddy-v2-app printenv SHARE_PUBLIC_BASE_URL # https://heddy.site/s 여야 한다 ``` ## 4. v1 정리 (데이터 백업 없음) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4c78c10..b5009f7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -66,7 +66,7 @@ app: batch-size: ${FILE_CLEANUP_BATCH_SIZE:100} share: # 공유 링크의 앞부분. 발급한 토큰 원문을 붙여 201 응답으로 딱 한 번 내보낸다. - public-base-url: ${SHARE_PUBLIC_BASE_URL:https://heddy.example.com/s} + public-base-url: ${SHARE_PUBLIC_BASE_URL:https://heddy.site/s} account-deletion: worker-delay-ms: ${ACCOUNT_DELETION_WORKER_DELAY_MS:5000} worker-initial-delay-ms: ${ACCOUNT_DELETION_WORKER_INITIAL_DELAY_MS:60000} diff --git a/src/test/java/com/heddy/adapter/in/web/sharing/PublicShareApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/sharing/PublicShareApiIntegrationTest.java index 7005a10..180a642 100644 --- a/src/test/java/com/heddy/adapter/in/web/sharing/PublicShareApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/sharing/PublicShareApiIntegrationTest.java @@ -226,7 +226,7 @@ INSERT INTO treatment_records ( .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); rawToken = new ObjectMapper().readTree(created).path("data").path("share_url") - .asText().substring("https://heddy.example.com/s/".length()); + .asText().substring("https://heddy.site/s/".length()); lastRecordId = recordId; } diff --git a/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java b/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java index 931d5aa..83be2f8 100644 --- a/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java +++ b/src/test/java/com/heddy/application/sharing/service/ShareServiceTest.java @@ -50,7 +50,7 @@ class ShareServiceTest { private static final UUID USER_ID = UUID.randomUUID(); private static final String RAW_TOKEN = "raw-share-token"; private static final String TOKEN_HASH = "hash-" + RAW_TOKEN; - private static final String BASE_URL = "https://heddy.example.com/s"; + private static final String BASE_URL = "https://heddy.site/s"; @Mock ShareRepositoryPort shareRepositoryPort; @Mock TreatmentRecordRepositoryPort treatmentRecordRepositoryPort; From 5b64bd76aacae2682b83eebb4b5253ce023d1156 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Thu, 3 Sep 2026 19:45:26 +0900 Subject: [PATCH 2/3] =?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 3/3] =?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 (