diff --git a/src/main/java/com/back/domain/file/entity/FileAttachment.java b/src/main/java/com/back/domain/file/entity/FileAttachment.java index faf1e0fb..92733671 100644 --- a/src/main/java/com/back/domain/file/entity/FileAttachment.java +++ b/src/main/java/com/back/domain/file/entity/FileAttachment.java @@ -26,9 +26,6 @@ public class FileAttachment extends BaseEntity { @JoinColumn(name = "uploaded_by") private User user; - @OneToOne(mappedBy = "fileAttachment", fetch = FetchType.LAZY) - private AttachmentMapping attachmentMapping; - public FileAttachment( String storedName, MultipartFile multipartFile, diff --git a/src/main/java/com/back/domain/file/entity/MimeType.java b/src/main/java/com/back/domain/file/entity/MimeType.java deleted file mode 100644 index e3ad7c5e..00000000 --- a/src/main/java/com/back/domain/file/entity/MimeType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.back.domain.file.entity; - -public enum MimeType { - IMAGE, PNG -} diff --git a/src/main/java/com/back/domain/file/service/AttachmentMappingService.java b/src/main/java/com/back/domain/file/service/AttachmentMappingService.java new file mode 100644 index 00000000..6b38b8d7 --- /dev/null +++ b/src/main/java/com/back/domain/file/service/AttachmentMappingService.java @@ -0,0 +1,116 @@ +package com.back.domain.file.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.back.domain.file.entity.AttachmentMapping; +import com.back.domain.file.entity.EntityType; +import com.back.domain.file.entity.FileAttachment; +import com.back.domain.file.repository.AttachmentMappingRepository; +import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AttachmentMappingService { + @Value("${cloud.aws.s3.bucket}") + private String bucket; + private final AmazonS3 amazonS3; + private final AttachmentMappingRepository attachmentMappingRepository; + private final FileAttachmentRepository fileAttachmentRepository; + + /** + * 특정 엔티티의 첨부파일 매핑 갱신 (게시글, 프로필 등 공통 사용) + * 기존 매핑 및 파일 삭제 후, 새 첨부파일 목록으로 교체 + * + * @param entityType 엔티티 종류 (POST, PROFILE 등) + * @param entityId 엔티티 ID + * @param userId 파일 업로더 검증용 + * @param newAttachmentIds 새 파일 ID 리스트 (null 또는 빈 리스트면 삭제만 수행) + */ + @Transactional + public void replaceAttachments( + EntityType entityType, + Long entityId, + Long userId, + List newAttachmentIds + ) { + // 기존 매핑 및 파일 삭제 + deleteAttachments(entityType, entityId, userId); + + if(newAttachmentIds == null || newAttachmentIds.isEmpty()) { + return; + } + + List attachments = fileAttachmentRepository.findAllById(newAttachmentIds); + if(attachments.size() != newAttachmentIds.size()) { + throw new CustomException(ErrorCode.FILE_NOT_FOUND); + } + + for (FileAttachment attachment : attachments) { + if (!attachment.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); + } + attachmentMappingRepository.save(new AttachmentMapping(attachment, entityType, entityId)); + } + } + + // URL로 갱신하는 경우 + @Transactional + public void replaceAttachmentByUrl( + EntityType entityType, + Long entityId, + Long userId, + String newImageUrl + ) { + deleteAttachments(entityType, entityId, userId); + + if (newImageUrl == null || newImageUrl.isBlank()) return; + + FileAttachment attachment = fileAttachmentRepository + .findByPublicURL(newImageUrl) + .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND)); + + if (!attachment.getUser().getId().equals(userId)) { + throw new CustomException(ErrorCode.FILE_ACCESS_DENIED); + } + + attachmentMappingRepository.save(new AttachmentMapping(attachment, entityType, entityId)); + } + + /** + * 특정 EntityType과 entityId에 연결된 첨부 파일을 모두 삭제 + * - S3 객체 삭제 + * - 매핑 테이블 + 파일 정보 삭제 + */ + @Transactional + public void deleteAttachments(EntityType entityType, Long entityId, Long userId) { + List mappings = attachmentMappingRepository.findAllByEntityTypeAndEntityId( + entityType, + entityId + ); + + for(AttachmentMapping mapping : mappings) { + FileAttachment attachment = mapping.getFileAttachment(); + + if(attachment != null) { + // S3 오브젝트 삭제 + s3Delete(attachment.getStoredName()); + } + } + + // 매핑 테이블 + 파일 정보 삭제 + attachmentMappingRepository.deleteAllByEntityTypeAndEntityId(entityType, entityId); + } + + private void s3Delete(String fileName) { + amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName)); + } +} + diff --git a/src/test/java/com/back/domain/file/repository/AttachmentMappingRepositoryTest.java b/src/test/java/com/back/domain/file/repository/AttachmentMappingRepositoryTest.java index 2ad89066..3f1cff91 100644 --- a/src/test/java/com/back/domain/file/repository/AttachmentMappingRepositoryTest.java +++ b/src/test/java/com/back/domain/file/repository/AttachmentMappingRepositoryTest.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +16,7 @@ @SpringBootTest @Transactional +@ActiveProfiles("test") class AttachmentMappingRepositoryTest { @Autowired private FileAttachmentRepository fileAttachmentRepository; @@ -45,9 +47,6 @@ class AttachmentMappingRepositoryTest { // when attachmentMappingRepository.deleteAllByEntityTypeAndEntityId(EntityType.POST, 1L); - em.flush(); // 즉시 DB에 변경사항 반영 - em.clear(); // 영속성 컨텍스트 초기화 - // then assertThat(fileAttachmentRepository.findAll().size()).isEqualTo(0); } diff --git a/src/test/java/com/back/domain/file/service/AttachmentMappingServiceTest.java b/src/test/java/com/back/domain/file/service/AttachmentMappingServiceTest.java new file mode 100644 index 00000000..4ea94925 --- /dev/null +++ b/src/test/java/com/back/domain/file/service/AttachmentMappingServiceTest.java @@ -0,0 +1,157 @@ +package com.back.domain.file.service; + +import com.back.domain.file.config.S3MockConfig; +import com.back.domain.file.dto.FileUploadResponseDto; +import com.back.domain.file.entity.AttachmentMapping; +import com.back.domain.file.entity.EntityType; +import com.back.domain.file.entity.FileAttachment; +import com.back.domain.file.repository.AttachmentMappingRepository; +import com.back.domain.file.repository.FileAttachmentRepository; +import com.back.domain.user.common.entity.User; +import com.back.domain.user.common.entity.UserProfile; +import com.back.domain.user.common.enums.UserStatus; +import com.back.domain.user.common.repository.UserRepository; +import io.findify.s3mock.S3Mock; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(S3MockConfig.class) +@SpringBootTest +@Transactional +@ActiveProfiles("test") +class AttachmentMappingServiceTest { + @Autowired + private S3Mock s3Mock; + + @Autowired + private FileService fileService; + + @Autowired + private AttachmentMappingService attachmentMappingService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private FileAttachmentRepository fileAttachmentRepository; + + @Autowired + private AttachmentMappingRepository attachmentMappingRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @AfterEach + public void tearDown() { + s3Mock.stop(); + } + + @Test + void deleteAttachments_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String path = "test.png"; + String contentType = "image/png"; + + MockMultipartFile file = new MockMultipartFile("test", path, contentType, "test".getBytes()); + + FileUploadResponseDto res = fileService.uploadFile(file, user.getId()); + FileAttachment fileAttachment = fileAttachmentRepository.findById(res.getAttachmentId()).orElse(null); + + AttachmentMapping attachmentMapping = new AttachmentMapping(fileAttachment, EntityType.POST, 1L); + attachmentMappingRepository.save(attachmentMapping); + + // when + attachmentMappingService.deleteAttachments(EntityType.POST, 1L, user.getId()); + + // then + assertThat(attachmentMappingRepository.findAllByEntityTypeAndEntityId(EntityType.POST, 1L) + .size()).isEqualTo(0); + assertThat(fileAttachmentRepository.findAll().size()).isEqualTo(0); + } + + @Test + void replaceAttachments_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 기존(삭제할) 파일 정보 + String path = "test.png"; + String contentType = "image/png"; + MockMultipartFile oldFile = new MockMultipartFile("test", path, contentType, "test".getBytes()); + Long oldAttachmentId = fileService.uploadFile(oldFile, user.getId()).getAttachmentId(); + + // 새 파일 정보 + String newPath = "newTest.png"; + MockMultipartFile newFile = new MockMultipartFile("newTest", newPath, contentType, "newTest".getBytes()); + Long newAttachmentId = fileService.uploadFile(newFile, user.getId()).getAttachmentId(); + + FileAttachment fileAttachment = fileAttachmentRepository.findById(oldAttachmentId).orElse(null); + AttachmentMapping attachmentMapping = new AttachmentMapping(fileAttachment, EntityType.POST, 1L); + attachmentMappingRepository.save(attachmentMapping); + + // when + attachmentMappingService.replaceAttachments(EntityType.POST, 1L, user.getId(), List.of(newAttachmentId)); + + // then + AttachmentMapping findMapping = attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.POST, 1L).orElse(null); + assertThat(findMapping.getFileAttachment().getId()).isEqualTo(newAttachmentId); + } + + @Test + void replaceAttachmentsUrl_success() throws Exception { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, "소개글", LocalDate.of(2000, 1, 1), 1000)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 기존(삭제할) 파일 정보 + String path = "test.png"; + String contentType = "image/png"; + MockMultipartFile oldFile = new MockMultipartFile("test", path, contentType, "test".getBytes()); + Long oldAttachmentId = fileService.uploadFile(oldFile, user.getId()).getAttachmentId(); + + // 새 파일 정보 + String newPath = "newTest.png"; + MockMultipartFile newFile = new MockMultipartFile("newTest", newPath, contentType, "newTest".getBytes()); + Long newAttachmentId = fileService.uploadFile(newFile, user.getId()).getAttachmentId(); + + FileAttachment fileAttachment = fileAttachmentRepository.findById(oldAttachmentId).orElse(null); + AttachmentMapping attachmentMapping = new AttachmentMapping(fileAttachment, EntityType.POST, 1L); + attachmentMappingRepository.save(attachmentMapping); + + // when + String newPublicURL = fileAttachmentRepository + .findById(newAttachmentId) + .orElse(null) + .getPublicURL(); + + attachmentMappingService.replaceAttachmentByUrl(EntityType.POST, 1L, user.getId(), newPublicURL); + + // then + AttachmentMapping findMapping = attachmentMappingRepository.findByEntityTypeAndEntityId(EntityType.POST, 1L).orElse(null); + assertThat(findMapping.getFileAttachment().getId()).isEqualTo(newAttachmentId); + } + +} \ No newline at end of file