Skip to content

Commit

Permalink
[BE] feat: Uploaded 상태로 오래되거나 Abandoned 상태의 UploadFile을 삭제하는 기능 추가 (#956
Browse files Browse the repository at this point in the history
) (#957)

* feat: UploadFile 삭제 기능 추가

* chore: 로그 띄어쓰기 삭제

* feat: 업로드 파일 삭제 어드민 API 추가

* refactor: AdminUploadV1Controller -> AdminUploadImageV1Controller 클래스명 변경

- 더 명확한 의미를 가지도록 변경
  • Loading branch information
seokjin8678 authored May 15, 2024
1 parent 20aef31 commit 13f7318
Show file tree
Hide file tree
Showing 12 changed files with 447 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.festago.admin.dto.upload;

import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;

public record AdminDeleteAbandonedPeriodUploadFileV1Request(
@NotNull LocalDateTime startTime,
@NotNull LocalDateTime endTime
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.festago.admin.presentation.v1;

import com.festago.admin.dto.upload.AdminDeleteAbandonedPeriodUploadFileV1Request;
import com.festago.upload.application.UploadFileDeleteService;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/admin/api/v1/upload/delete")
@RequiredArgsConstructor
@Hidden
public class AdminUploadFileDeleteV1Controller {

private final UploadFileDeleteService uploadFileDeleteService;

@DeleteMapping("/abandoned-period")
public ResponseEntity<Void> deleteAbandonedWithPeriod(
@RequestBody @Valid AdminDeleteAbandonedPeriodUploadFileV1Request request
) {
uploadFileDeleteService.deleteAbandonedStatusWithPeriod(request.startTime(), request.endTime());
return ResponseEntity.ok()
.build();
}

@DeleteMapping("/old-uploaded")
public ResponseEntity<Void> deleteOldUploaded() {
uploadFileDeleteService.deleteOldUploadedStatus();
return ResponseEntity.ok()
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@RequestMapping("/admin/api/v1/upload/images")
@RequiredArgsConstructor
@Hidden
public class AdminUploadV1Controller {
public class AdminUploadImageV1Controller {

private final ImageFileUploadService imageFileUploadService;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.festago.upload.application;

import com.festago.upload.domain.StorageClient;
import com.festago.upload.domain.UploadFile;
import com.festago.upload.domain.UploadStatus;
import com.festago.upload.repository.UploadFileRepository;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
@RequiredArgsConstructor
public class UploadFileDeleteService {

private final StorageClient storageClient;
private final UploadFileRepository uploadFileRepository;
private final Clock clock;

public void deleteAbandonedStatusWithPeriod(LocalDateTime startTime, LocalDateTime endTime) {
List<UploadFile> uploadFiles = uploadFileRepository.findByCreatedAtBetweenAndStatus(startTime, endTime, UploadStatus.ABANDONED);
deleteUploadFiles(uploadFiles);
}

private void deleteUploadFiles(List<UploadFile> uploadFiles) {
storageClient.delete(uploadFiles);
uploadFileRepository.deleteByIn(uploadFiles);
}

public void deleteOldUploadedStatus() {
LocalDateTime yesterday = LocalDateTime.now(clock).minusDays(1);
List<UploadFile> uploadFiles = uploadFileRepository.findByCreatedAtBeforeAndStatus(yesterday, UploadStatus.UPLOADED);
deleteUploadFiles(uploadFiles);
}
}
13 changes: 11 additions & 2 deletions backend/src/main/java/com/festago/upload/domain/StorageClient.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
package com.festago.upload.domain;

import java.util.List;
import org.springframework.web.multipart.MultipartFile;

public interface StorageClient {

/**
* MultipartFile을 보관(영속)하는 클래스 <br/> 업로드 작업이 끝나면, 업로드한 파일의 정보를 가진 UploadStatus.UPLOADED 상태의 UploadFile를 반환해야 한다.
* <br/> 반환된 UploadFile을 영속하는 책임은 해당 클래스를 사용하는 클라이언트가 구현해야 한다. <br/>
* MultipartFile을 보관(영속)하는 메서드 <br/> 업로드 작업이 끝나면, 업로드한 파일의 정보를 가진 UploadStatus.UPLOADED 상태의 UploadFile를 반환해야 한다.
* <br/> 반환된 UploadFile을 영속하는 책임은 해당 메서드를 사용하는 클라이언트가 구현해야 한다. <br/>
*
* @param file 업로드 할 MultipartFile
* @return UploadStatus.PENDING 상태의 영속되지 않은 UploadFile 엔티티
*/
UploadFile storage(MultipartFile file);

/**
* 업로드 파일을 삭제하는 메서드 <br/> 삭제 작업이 끝나면, UploadFile이 가진 정보에 대한 업로드 된 파일이 없으므로, 인자로 들어온 UploadFiles를 삭제해야 한다. <br/> 삭제가
* 끝나고 UploadFile을 삭제하는 책임은 해당 메서드를 사용하는 클라이언트가 구현해야 한다. <br/>
*
* @param uploadFiles 삭제하려는 업로드 된 파일의 정보가 담긴 UploadFile 목록
*/
void delete(List<UploadFile> uploadFiles);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import java.net.URI;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -20,7 +21,11 @@
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Error;

@Slf4j
@Component
Expand Down Expand Up @@ -71,12 +76,44 @@ private void upload(MultipartFile file, UploadFile uploadFile) {
String mimeType = uploadFile.getMimeType().toString();
RequestBody requestBody = RequestBody.fromContentProvider(() -> inputStream, fileSize, mimeType);
UUID uploadFileId = uploadFile.getId();
log.info("파일 업로드 시작. id = {}, uploadUri={}, size={}", uploadFileId, uploadFile.getUploadUri(), fileSize);
log.info("파일 업로드 시작. id={}, uploadUri={}, size={}", uploadFileId, uploadFile.getUploadUri(), fileSize);
s3Client.putObject(objectRequest, requestBody);
log.info("파일 업로드 완료. id = {}", uploadFileId);
log.info("파일 업로드 완료. id={}", uploadFileId);
} catch (IOException e) {
log.warn("파일 업로드 중 문제가 발생했습니다. id = {}", uploadFile.getId());
log.warn("파일 업로드 중 문제가 발생했습니다. id={}", uploadFile.getId());
throw new InternalServerException(ErrorCode.FILE_UPLOAD_ERROR, e);
}
}

@Override
public void delete(List<UploadFile> uploadFiles) {
if (uploadFiles.isEmpty()) {
log.info("삭제하려는 파일이 없습니다.");
return;
}
int fileSize = uploadFiles.size();
UUID firstFileId = uploadFiles.get(0).getId();
DeleteObjectsRequest deleteObjectsRequest = getDeleteObjectsRequest(uploadFiles);

log.info("{}개 파일 삭제 시작. 첫 번째 파일 식별자={}", fileSize, firstFileId);
DeleteObjectsResponse response = s3Client.deleteObjects(deleteObjectsRequest);
log.info("{}개 파일 삭제 완료. 첫 번째 파일 식별자={}", fileSize, firstFileId);

if (response.hasErrors()) {
List<S3Error> errors = response.errors();
log.warn("{}개 파일 삭제 중 에러가 발생했습니다. 첫 번째 파일 식별자={}, 에러 개수={}", fileSize, firstFileId, errors.size());
errors.forEach(error -> log.info("파일 삭제 중 에러가 발생했습니다. key={}, message={}", error.key(), error.message()));
}
}

private DeleteObjectsRequest getDeleteObjectsRequest(List<UploadFile> uploadFiles) {
List<ObjectIdentifier> objectIdentifiers = uploadFiles.stream()
.map(UploadFile::getName)
.map(name -> ObjectIdentifier.builder().key(name).build())
.toList();
return DeleteObjectsRequest.builder()
.bucket(bucket)
.delete(builder -> builder.objects(objectIdentifiers).build())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@

import com.festago.upload.domain.FileOwnerType;
import com.festago.upload.domain.UploadFile;
import com.festago.upload.domain.UploadStatus;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

public interface UploadFileRepository extends Repository<UploadFile, UUID> {

Expand All @@ -17,4 +22,13 @@ public interface UploadFileRepository extends Repository<UploadFile, UUID> {
List<UploadFile> findAllByOwnerIdAndOwnerType(Long ownerId, FileOwnerType ownerType);

List<UploadFile> findByIdIn(Collection<UUID> ids);

List<UploadFile> findByCreatedAtBetweenAndStatus(LocalDateTime startTime, LocalDateTime endTime,
UploadStatus status);

List<UploadFile> findByCreatedAtBeforeAndStatus(LocalDateTime createdAt, UploadStatus status);

@Modifying
@Query("delete from UploadFile uf where uf in :uploadFiles")
void deleteByIn(@Param("uploadFiles") List<UploadFile> uploadFiles);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.festago.admin.presentation.v1;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.festago.admin.dto.upload.AdminDeleteAbandonedPeriodUploadFileV1Request;
import com.festago.auth.domain.Role;
import com.festago.support.CustomWebMvcTest;
import com.festago.support.WithMockAuth;
import jakarta.servlet.http.Cookie;
import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

@CustomWebMvcTest
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
class AdminUploadFileDeleteV1ControllerTest {

private static final Cookie TOKEN_COOKIE = new Cookie("token", "token");

@Autowired
MockMvc mockMvc;

@Autowired
ObjectMapper objectMapper;

@Nested
class ABANDONED_상태와_기간에_포함되는_파일_삭제 {

final String uri = "/admin/api/v1/upload/delete/abandoned-period";

@Nested
@DisplayName("DELETE " + uri)
class 올바른_주소로 {

@Test
@WithMockAuth(role = Role.ADMIN)
void 요청을_보내면_200_응답이_반환된다() throws Exception {
// given
LocalDateTime now = LocalDateTime.now();
var request = new AdminDeleteAbandonedPeriodUploadFileV1Request(now, now);

// when & then
mockMvc.perform(delete(uri)
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
.cookie(TOKEN_COOKIE))
.andExpect(status().isOk());
}

@Test
void 토큰_없이_보내면_401_응답이_반환된다() throws Exception {
// when & then
mockMvc.perform(delete(uri))
.andExpect(status().isUnauthorized());
}

@Test
@WithMockAuth(role = Role.MEMBER)
void 토큰의_권한이_Admin_아니면_404_응답이_반환된다() throws Exception {
// when & then
mockMvc.perform(delete(uri)
.cookie(TOKEN_COOKIE))
.andExpect(status().isNotFound());
}
}
}

@Nested
class 오래된_UPLOADED_상태_파일_삭제 {

final String uri = "/admin/api/v1/upload/delete/old-uploaded";

@Nested
@DisplayName("DELETE " + uri)
class 올바른_주소로 {

@Test
@WithMockAuth(role = Role.ADMIN)
void 요청을_보내면_200_응답이_반환된다() throws Exception {
// given
// when & then
mockMvc.perform(delete(uri)
.contentType(MediaType.APPLICATION_JSON)
.cookie(TOKEN_COOKIE))
.andExpect(status().isOk());
}

@Test
void 토큰_없이_보내면_401_응답이_반환된다() throws Exception {
// when & then
mockMvc.perform(delete(uri))
.andExpect(status().isUnauthorized());
}

@Test
@WithMockAuth(role = Role.MEMBER)
void 토큰의_권한이_Admin_아니면_404_응답이_반환된다() throws Exception {
// when & then
mockMvc.perform(delete(uri)
.cookie(TOKEN_COOKIE))
.andExpect(status().isNotFound());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
@CustomWebMvcTest
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
class AdminUploadV1ControllerTest {
class AdminUploadImageV1ControllerTest {

private static final Cookie TOKEN_COOKIE = new Cookie("token", "token");

Expand Down
Loading

0 comments on commit 13f7318

Please sign in to comment.