Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/deploy-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 선택: 미지정 시에도 이 값이 기본 적용됨
```

| 변수 | 규칙 |
Expand All @@ -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` 째로 컨테이너에 전달된다.
Expand All @@ -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 정리 (데이터 백업 없음)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,9 @@ List<FileEntity> findReclaimTargets(

/**
* READY 고아 판정 분기다. 네이티브 SQL 은 바인드 파라미터로 테이블 이름을 받을 수 없어,
* "파일을 참조하는 테이블" 목록이 이 상수 안에 하드코딩된다. 현재 READY 파일을 참조하는
* 도메인은 시술기록 사진({@code treatment_record_photos}) 하나뿐이다. 새 도메인이 READY
* 파일을 참조하게 되면 여기에 조건을 덧대지 않으면 그 도메인이 붙인 파일까지 고아로
* 판정돼 사진이 통째로 정리된다 — 파일 참조 기능을 추가할 때는 반드시 이 상수를 함께 고친다.
* "파일을 참조하는 테이블" 목록이 이 상수 안에 하드코딩된다. 새 도메인이 READY 파일을
* 참조하게 되면 여기에 조건을 덧대지 않을 경우 사용 중인 파일까지 고아로 오판해 스토리지
* 객체를 지운다. 파일 참조 기능을 추가할 때는 반드시 이 상수를 함께 고친다.
*/
String READY_ORPHAN_CLAUSE = """
OR (
Expand All @@ -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
)
)""";

/**
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<StoredFile> 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
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,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;
Expand Down
Loading