Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -680,11 +680,30 @@ private String resolveStoredImageKey(String rawReference, BattleStatus targetSta
return null;
}
if (targetStatus == BattleStatus.PUBLISHED && localDraftFileStorageService.isLocalDraftReference(normalized)) {
return localDraftFileStorageService.promoteLocalDraftToS3(normalized, fallbackCategory, s3UploadService);
// 실제 S3 업로드/로컬 draft 삭제는 커밋 이후로 미룬다.
// 여기서 바로 승격시키면, 이후 같은 트랜잭션에서 DB 저장이 실패해 롤백돼도
// 이미 올라간 S3 파일과 지워진 draft는 되돌릴 수 없어 orphan이 된다.
String s3Key = localDraftFileStorageService.resolveS3Key(normalized, fallbackCategory);
promoteDraftAfterCommit(normalized, s3Key);
return s3Key;
}
return normalized;
}

private void promoteDraftAfterCommit(String localReference, String s3Key) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
localDraftFileStorageService.promoteToS3(localReference, s3Key, s3UploadService);
}
});
return;
}

localDraftFileStorageService.promoteToS3(localReference, s3Key, s3UploadService);
}

private String normalizeStoredImageReference(String rawReference, FileCategory fallbackCategory) {
if (rawReference == null || rawReference.isBlank()) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ public String toPublicUrl(String localKey) {
return safeBaseUrl + LOCAL_RESOURCE_PREFIX + extractFileName(normalizeLocalDraftKey(localKey));
}

public String promoteLocalDraftToS3(String rawReference, FileCategory category, S3UploadService s3UploadService) {
/**
* draft가 승격될 목적지 S3 key를 계산만 한다(업로드/삭제 같은 부수효과 없음).
* DB 트랜잭션 중간에 엔티티에 최종 key를 미리 채워 넣어야 할 때 쓰고,
* 실제 업로드는 트랜잭션 커밋 후 {@link #promoteToS3}로 수행해서
* "S3엔 올라갔는데 DB 저장은 롤백됨" 같은 불일치를 막는다.
*/
public String resolveS3Key(String rawReference, FileCategory category) {
String normalized = normalizeLocalDraftKey(rawReference);
if (!isLocalDraftReference(normalized)) {
return normalized;
Expand All @@ -102,10 +108,28 @@ public String promoteLocalDraftToS3(String rawReference, FileCategory category,
}

String fileName = extractFileName(normalized);
String s3Key = category.getPath() + "/" + fileName;
return category.getPath() + "/" + fileName;
}

/**
* {@link #resolveS3Key}로 미리 계산해둔 key로 실제 업로드하고 로컬 draft를 지운다.
* DB 트랜잭션 커밋 이후에만 호출해야 한다 — 롤백 시에는 호출되지 않아야
* draft 파일이 남아있어서 다시 시도할 수 있다.
*/
public void promoteToS3(String rawReference, String s3Key, S3UploadService s3UploadService) {
String normalized = normalizeLocalDraftKey(rawReference);
if (!isLocalDraftReference(normalized)) {
return;
}

Path localPath = resolvePath(normalized);
if (!Files.exists(localPath)) {
// 이미 승격되었거나 삭제됨 - 할 일 없음
return;
}

s3UploadService.uploadFile(s3Key, localPath.toFile());
deleteIfLocalReference(normalized);
return s3Key;
}

public void deleteIfLocalReference(String rawReference) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

import java.time.LocalDate;
import java.util.LinkedHashMap;
Expand All @@ -40,9 +38,6 @@
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
Expand Down Expand Up @@ -349,12 +344,16 @@ void pending_local_images_are_promoted_to_s3_on_publish() throws Exception {
.content(objectMapper.writeValueAsString(publishPayload)))
.andExpect(status().isOk());

// 실제 S3 업로드/로컬 draft 삭제는 DB 트랜잭션 커밋 이후로 미뤄진다
// (트랜잭션 중간에 실패해도 이미 올라간 파일이 orphan되는 걸 막기 위함 — [[s3_railway_bucket_migration]] 참고).
// 이 테스트는 클래스 레벨 @Transactional로 실제 커밋 없이 끝에 롤백되므로,
// "커밋 이후에만 실제로 승격됨"을 여기서 직접 증명하지는 않는다.
// 그 배선(afterCommit 등록 여부)은 LocalDraftFileStorageServiceTest/단위테스트에서 검증한다.
// 여기서는 요청 처리 중 동기적으로 결정되는 최종 저장 키(목적지 경로)만 확인한다.
Battle publishedBattle = battleRepository.findById(battleId).orElseThrow();
assertThat(publishedBattle.getThumbnailUrl()).startsWith("images/battles/");
List<BattleOption> publishedOptions = battleOptionRepository.findByBattle(publishedBattle);
assertThat(publishedOptions).allMatch(option -> option.getImageUrl().startsWith("images/philosophers/"));

verify(s3Client, atLeastOnce()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}

private String uploadLocalDraftKey(String adminToken, String fileName, String content) throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest;
Expand All @@ -27,17 +30,20 @@
import com.swyp.picke.domain.user.service.UserBattleService;
import com.swyp.picke.domain.vote.repository.BattleVoteRepository;
import com.swyp.picke.global.infra.local.service.LocalDraftFileStorageService;
import com.swyp.picke.global.infra.s3.enums.FileCategory;
import com.swyp.picke.global.infra.s3.service.S3UploadService;
import com.swyp.picke.domain.user.repository.UserRepository;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@ExtendWith(MockitoExtension.class)
class BattleServiceImplTest {
Expand Down Expand Up @@ -122,4 +128,49 @@ private BattleOption option(Long id, Battle battle) {
private AdminBattleOptionRequest optionRequest2() {
return new AdminBattleOptionRequest("두번째 옵션", null, null, null, 2, List.of());
}

@AfterEach
void tearDownSynchronization() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}

@Test
void 발행_상태로_바뀌며_local_draft_썸네일이면_실제_S3_승격은_커밋_이후로_미룬다() {
Battle battle = battle(1L);
String localDraftKey = "local/drafts/uuid_thumb.png";
String s3Key = "images/battles/uuid_thumb.png";

when(battleRepository.findById(1L)).thenReturn(Optional.of(battle));
when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of());
lenient().when(localDraftFileStorageService.normalizeLocalDraftKey(localDraftKey)).thenReturn(localDraftKey);
when(localDraftFileStorageService.isLocalDraftReference(localDraftKey)).thenReturn(true);
when(localDraftFileStorageService.resolveS3Key(localDraftKey, FileCategory.BATTLE)).thenReturn(s3Key);
lenient().when(battleOptionRepository.save(any(BattleOption.class)))
.thenAnswer(inv -> inv.getArgument(0));
lenient().when(battleOptionTagRepository.findByBattleWithTags(battle)).thenReturn(List.of());

AdminBattleUpdateRequest request = new AdminBattleUpdateRequest(
"제목", null, null, localDraftKey, null, null, null, BattleStatus.PUBLISHED,
null, List.of(
new AdminBattleOptionRequest("옵션1", null, null, null, 1, null),
new AdminBattleOptionRequest("옵션2", null, null, null, 2, null)));

TransactionSynchronizationManager.initSynchronization();
try {
service.updateBattle(1L, request);

// 요청 처리 도중(커밋 전)에는 실제 업로드/삭제가 절대 일어나면 안 된다.
verify(localDraftFileStorageService, never()).promoteToS3(anyString(), anyString(), any());
org.assertj.core.api.Assertions.assertThat(battle.getThumbnailUrl()).isEqualTo(s3Key);

// 커밋 이후에야 실제 승격이 일어난다.
TransactionSynchronizationManager.getSynchronizations()
.forEach(org.springframework.transaction.support.TransactionSynchronization::afterCommit);
verify(localDraftFileStorageService).promoteToS3(localDraftKey, s3Key, s3UploadService);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.swyp.picke.global.infra.local.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.swyp.picke.global.common.exception.CustomException;
import com.swyp.picke.global.infra.s3.enums.FileCategory;
import com.swyp.picke.global.infra.s3.service.S3UploadService;
import java.io.IOException;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;

class LocalDraftFileStorageServiceTest {

private LocalDraftFileStorageService service;
private S3UploadService s3UploadService;

@BeforeEach
void setUp(@TempDir Path tempDir) {
service = new LocalDraftFileStorageService();
ReflectionTestUtils.setField(service, "localStorageRoot", tempDir.toString());
ReflectionTestUtils.setField(service, "baseUrl", "https://dev.picke.store");
s3UploadService = mock(S3UploadService.class);
}

private String saveDraft() throws IOException {
MockMultipartFile file = new MockMultipartFile("file", "rousseau.png", "image/png", new byte[]{1, 2, 3});
return service.saveDraftFile(file);
}

@Test
void resolveS3Key는_실제_업로드_없이_목적지_key만_계산한다() throws IOException {
String localKey = saveDraft();

String s3Key = service.resolveS3Key(localKey, FileCategory.PHILOSOPHER);

assertThat(s3Key).startsWith("images/philosophers/").endsWith("rousseau.png");
// 부수효과 없음: 로컬 파일 그대로 남아있고 S3 호출 없음
verify(s3UploadService, never()).uploadFile(anyString(), any());
}

@Test
void resolveS3Key는_draft_파일이_없으면_예외를_던진다() {
assertThatThrownBy(() -> service.resolveS3Key("local/drafts/missing_file.png", FileCategory.PHILOSOPHER))
.isInstanceOf(CustomException.class);
}

@Test
void promoteToS3는_업로드하고_로컬_draft를_삭제한다() throws IOException {
String localKey = saveDraft();
String s3Key = service.resolveS3Key(localKey, FileCategory.PHILOSOPHER);
when(s3UploadService.uploadFile(eq(s3Key), any()))
.thenReturn(s3Key);

service.promoteToS3(localKey, s3Key, s3UploadService);

verify(s3UploadService).uploadFile(eq(s3Key), any());
assertThat(service.isLocalDraftReference(localKey)).isTrue(); // 여전히 local draft 형식의 키지만
// 실제 파일은 삭제됐어야 한다 -> 다시 promote 시도해도 재업로드 없이 조용히 스킵
service.promoteToS3(localKey, s3Key, s3UploadService);
verify(s3UploadService, times(1))
.uploadFile(eq(s3Key), any());
}

@Test
void promoteToS3는_local_draft가_아니면_아무일도_하지_않는다() {
service.promoteToS3("images/philosophers/already-s3.png", "images/philosophers/already-s3.png", s3UploadService);

verify(s3UploadService, never()).uploadFile(anyString(), any());
}
}
Loading