Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
@@ -0,0 +1,7 @@
package com.codeit.team5.mopl.binarycontent.dto;

public record UploadedBinaryContent(
String key,
String url
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.codeit.team5.mopl.binarycontent.event;

import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent;

// 업로드 후 후속 작업 실패 시, 업로드된 스토리지 객체를 비동기로 삭제(롤백)하기 위한 이벤트
public record BinaryContentDeleteEvent(
UploadedBinaryContent uploaded
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.codeit.team5.mopl.binarycontent.eventlistener;

import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent;
import com.codeit.team5.mopl.binarycontent.service.BinaryContentService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class BinaryContentDeleteEventListener {

private final BinaryContentService binaryContentService;

@Async
Comment thread
dstle marked this conversation as resolved.
Outdated
@EventListener
public void handle(BinaryContentDeleteEvent event) {
binaryContentService.deleteQuietly(event.uploaded());
}
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,53 @@
package com.codeit.team5.mopl.binarycontent.service;

import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
import com.codeit.team5.mopl.binarycontent.storage.BinaryContentStorage;
import com.codeit.team5.mopl.binarycontent.storage.GeneratedKey;
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.binarycontent.storage.StorageKeyFactory;
import com.codeit.team5.mopl.global.dto.FileRequest;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Slf4j
public class BinaryContentService {

private final BinaryContentStorage binaryContentStorage;
private final StorageKeyFactory storageKeyFactory;
private final BinaryContentRepository binaryContentRepository;

@Transactional
public BinaryContent upload(StorageDirectory directory, UUID ownerId, FileRequest image) {
GeneratedKey generated = storageKeyFactory.generate(directory, ownerId, image.filename());
public BinaryContent upload(StorageDirectory directory, FileRequest image) {
Comment thread
dstle marked this conversation as resolved.
Outdated
GeneratedKey generated = storageKeyFactory.generate(directory, image.filename());
binaryContentStorage.store(generated.key(), image.bytes(), generated.contentType());
return binaryContentRepository.save(
BinaryContent.completed(binaryContentStorage.toUrl(generated.key())));
}

public UploadedBinaryContent uploadToStorage(StorageDirectory directory, FileRequest image) {
GeneratedKey generated = storageKeyFactory.generate(directory, image.filename());
binaryContentStorage.store(generated.key(), image.bytes(), generated.contentType());
return new UploadedBinaryContent(generated.key(), binaryContentStorage.toUrl(generated.key()));
}

@Transactional
public BinaryContent saveCompleted(UploadedBinaryContent uploaded) {
return binaryContentRepository.save(BinaryContent.completed(uploaded.url()));
}

public void deleteQuietly(UploadedBinaryContent uploaded) {
if (uploaded == null) {
return;
}
try {
binaryContentStorage.delete(uploaded.key());
} catch (RuntimeException e) {
log.warn("업로드 보상 삭제 실패: key={}", uploaded.key(), e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.codeit.team5.mopl.binarycontent.service;

import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent;
import com.codeit.team5.mopl.binarycontent.event.BinaryContentDeleteEvent;
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.global.dto.FileRequest;
import java.util.function.Function;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

/**
* 스토리지 업로드(트랜잭션 밖)를 수행한 뒤 후속 작업을 실행하고,
* 후속 작업이 실패하면 업로드한 객체를 비동기로 삭제해 롤백한다.
*/
@Component
@RequiredArgsConstructor
public class UploadWithRollback {

private final BinaryContentService binaryContentService;
private final ApplicationEventPublisher eventPublisher;

public <T> T execute(
StorageDirectory directory,
FileRequest image,
Function<UploadedBinaryContent, T> persist
) {
UploadedBinaryContent uploaded = null;
try {
if (image != null) {
uploaded = binaryContentService.uploadToStorage(directory, image);
}
return persist.apply(uploaded);
} catch (RuntimeException e) {
if (uploaded != null) {
eventPublisher.publishEvent(new BinaryContentDeleteEvent(uploaded));
}
throw e;
}
}
}
Comment thread
dstle marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ public interface BinaryContentStorage {
String toUrl(String key);

void store(String key, byte[] bytes, String contentType);

void delete(String key);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@

/**
* 저장소 키 생성 책임을 담당한다.
* 확장자 검증 → 폴더 prefix/소유자/난수 조합 → Content-Type 결정을 한 곳에서 처리한다.
* 확장자 검증 → 폴더 prefix/난수 조합 → Content-Type 결정을 한 곳에서 처리한다.
*/
@Component
public class StorageKeyFactory {

public GeneratedKey generate(StorageDirectory directory, UUID ownerId, String originalFilename) {
public GeneratedKey generate(StorageDirectory directory, String originalFilename) {
String extension = StringUtils.getFilenameExtension(originalFilename);
ImageExtension imageExtension = ImageExtension.from(extension)
.orElseThrow(() -> new InvalidImageExtensionException(extension));
String key = directory.value() + "/" + ownerId + "/" + UUID.randomUUID()
String key = directory.value() + "/" + UUID.randomUUID()
+ "." + imageExtension.name().toLowerCase(Locale.ROOT);
return new GeneratedKey(key, imageExtension.contentType());
}
Comment thread
dstle marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ public void store(String key, byte[] bytes, String contentType) {
log.debug("파일 저장 완료: {}", destination);
}

@Override
public void delete(String key) {
Path destination = uploadDir.resolve(key);
try {
Files.deleteIfExists(destination);
} catch (IOException e) {
throw new FileStorageException(key, e);
}
log.debug("파일 삭제 완료: {}", destination);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

private void init() {
try {
Files.createDirectories(uploadDir);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

@Slf4j
Expand Down Expand Up @@ -45,4 +46,18 @@ public void store(String key, byte[] bytes, String contentType) {
HttpStatus.INTERNAL_SERVER_ERROR, "S3 업로드 실패: key=" + key, e);
}
}

@Override
public void delete(String key) {
try {
s3Client.deleteObject(DeleteObjectRequest.builder()
.bucket(properties.bucketName())
.key(key)
.build());
log.debug("S3 객체 삭제 완료: key={}", key);
} catch (SdkException e) {
throw new BinaryContentStorageException(
HttpStatus.INTERNAL_SERVER_ERROR, "S3 객체 삭제 실패: key=" + key, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
package com.codeit.team5.mopl.binarycontent.storage.s3;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "mopl.storage.s3")
public record S3StorageProperties(
String accessKey,
String secretKey,
String bucketName,
String region
String region,
Duration apiCallTimeout,
Duration apiCallAttemptTimeout
) {

public S3StorageProperties {
if (apiCallTimeout == null) {
apiCallTimeout = Duration.ofSeconds(10);
}
if (apiCallAttemptTimeout == null) {
apiCallAttemptTimeout = Duration.ofSeconds(5);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
5 changes: 5 additions & 0 deletions src/main/java/com/codeit/team5/mopl/config/StorageConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

Expand Down Expand Up @@ -40,6 +41,10 @@ public S3Client s3Client(S3StorageProperties props) {
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(credentialsProvider)
.overrideConfiguration(ClientOverrideConfiguration.builder()
.apiCallTimeout(props.apiCallTimeout())
.apiCallAttemptTimeout(props.apiCallAttemptTimeout())
.build())
.build();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.content.entity.ContentSortByType;
import com.codeit.team5.mopl.content.entity.ContentType;
import com.codeit.team5.mopl.content.facade.ContentFacade;
import com.codeit.team5.mopl.content.service.ContentService;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import jakarta.validation.Valid;
Expand Down Expand Up @@ -38,6 +39,7 @@
public class ContentController implements ContentApi {

private final ContentService contentService;
private final ContentFacade contentFacade;

@InitBinder
public void initBinder(WebDataBinder binder) {
Expand All @@ -63,7 +65,7 @@ public ResponseEntity<ContentResponse> postContent(
@RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail
) {
log.info("Content Create request: POST /api/contents");
ContentResponse response = contentService.create(request, MultipartFiles.toImageResource(thumbnail));
ContentResponse response = contentFacade.create(request, MultipartFiles.toImageResource(thumbnail));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

Expand All @@ -76,7 +78,7 @@ public ResponseEntity<ContentResponse> patchContent(
@RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail
) {
log.info("Content Update request: PATCH /api/contents/{}", contentId);
ContentResponse response = contentService.update(contentId, request, MultipartFiles.toImageResource(thumbnail));
ContentResponse response = contentFacade.update(contentId, request, MultipartFiles.toImageResource(thumbnail));
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.codeit.team5.mopl.content.facade;

import com.codeit.team5.mopl.binarycontent.service.UploadWithRollback;
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.content.service.ContentService;
import com.codeit.team5.mopl.global.dto.FileRequest;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ContentFacade {

private final ContentService contentService;
private final UploadWithRollback uploadWithRollback;

public ContentResponse create(ContentCreateRequest request, FileRequest image) {
return uploadWithRollback.execute(StorageDirectory.THUMBNAIL, image,
uploaded -> contentService.create(request, uploaded));
}

public ContentResponse update(UUID contentId, ContentUpdateRequest request, FileRequest image) {
return uploadWithRollback.execute(StorageDirectory.THUMBNAIL, image,
uploaded -> contentService.update(contentId, request, uploaded));
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.codeit.team5.mopl.content.service;

import com.codeit.team5.mopl.binarycontent.dto.UploadedBinaryContent;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContent;
import com.codeit.team5.mopl.binarycontent.entity.BinaryContentUploadStatus;
import com.codeit.team5.mopl.binarycontent.service.BinaryContentService;
import com.codeit.team5.mopl.binarycontent.storage.StorageDirectory;
import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest;
import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
Expand All @@ -18,7 +18,6 @@
import com.codeit.team5.mopl.content.repository.ContentRepository;
import com.codeit.team5.mopl.content.repository.ContentStatsRepository;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import com.codeit.team5.mopl.global.dto.FileRequest;
import com.codeit.team5.mopl.tag.entity.Tag;
import com.codeit.team5.mopl.tag.repository.TagRepository;
import java.util.HashSet;
Expand Down Expand Up @@ -61,7 +60,7 @@ public class ContentService {
* @return 생성된 콘텐츠 응답 DTO
*/
@Transactional
public ContentResponse create(ContentCreateRequest request, FileRequest image) {
public ContentResponse create(ContentCreateRequest request, UploadedBinaryContent thumbnail) {
Content content = contentRepository.save(Content.createByAdmin(
request.type(),
request.title(),
Expand All @@ -73,8 +72,8 @@ public ContentResponse create(ContentCreateRequest request, FileRequest image) {
ContentStats stats = contentStatsRepository.save(ContentStats.create(content));
content.attachStats(stats);

if (image != null) {
content.attachThumbnail(binaryContentService.upload(StorageDirectory.THUMBNAIL, content.getId(), image));
if (thumbnail != null) {
content.attachThumbnail(binaryContentService.saveCompleted(thumbnail));
}

return contentMapper.toDto(content);
Expand All @@ -90,7 +89,7 @@ public ContentResponse create(ContentCreateRequest request, FileRequest image) {
* @throws ContentNotFoundException 콘텐츠가 존재하지 않을 때
*/
@Transactional
public ContentResponse update(UUID contentId, ContentUpdateRequest request, FileRequest image) {
public ContentResponse update(UUID contentId, ContentUpdateRequest request, UploadedBinaryContent thumbnail) {
Content content = contentRepository.findWithStatsAndTagsById(contentId)
.orElseThrow(() -> new ContentNotFoundException(contentId));

Expand All @@ -99,8 +98,8 @@ public ContentResponse update(UUID contentId, ContentUpdateRequest request, File
content.update(request.title(), request.description());
updateTags(content, tagNames);

if (image != null) {
content.attachThumbnail(binaryContentService.upload(StorageDirectory.THUMBNAIL, content.getId(), image));
if (thumbnail != null) {
content.attachThumbnail(binaryContentService.saveCompleted(thumbnail));
}

return contentMapper.toDto(content);
Expand Down
Loading
Loading