diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java index 416ba8fb8..7f96f3c4e 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java @@ -67,6 +67,7 @@ public AdminHostUniversityDetailResponse getHostUniversity(Long id) { ) public AdminHostUniversityDetailResponse createHostUniversity(AdminHostUniversityCreateRequest request) { validateKoreanNameNotExists(request.koreanName()); + validateEnglishNameNotExists(request.englishName()); Country country = findCountryByCode(request.countryCode()); Region region = findRegionByCode(request.regionCode()); @@ -97,6 +98,13 @@ private void validateKoreanNameNotExists(String koreanName) { }); } + private void validateEnglishNameNotExists(String englishName) { + hostUniversityRepository.findByEnglishName(englishName) + .ifPresent(existingUniversity -> { + throw new CustomException(HOST_UNIVERSITY_ALREADY_EXISTS); + }); + } + @Transactional @DefaultCacheOut( key = {"univApplyInfoTextSearch", "university:recommend:general"}, @@ -108,6 +116,7 @@ public AdminHostUniversityDetailResponse updateHostUniversity(Long id, AdminHost .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); validateKoreanNameNotDuplicated(request.koreanName(), id); + validateEnglishNameNotDuplicated(request.englishName(), id); Country country = findCountryByCode(request.countryCode()); Region region = findRegionByCode(request.regionCode()); @@ -140,6 +149,15 @@ private void validateKoreanNameNotDuplicated(String koreanName, Long excludeId) }); } + private void validateEnglishNameNotDuplicated(String englishName, Long excludeId) { + hostUniversityRepository.findByEnglishName(englishName) + .ifPresent(existingUniversity -> { + if (!existingUniversity.getId().equals(excludeId)) { + throw new CustomException(HOST_UNIVERSITY_ALREADY_EXISTS); + } + }); + } + private Country findCountryByCode(String countryCode) { return countryRepository.findByCode(countryCode) .orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND)); diff --git a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java index 8e98c863b..3b5832718 100644 --- a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java +++ b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java @@ -1,10 +1,13 @@ package com.example.solidconnection.s3.controller; import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.s3.domain.UploadDirectoryName; import com.example.solidconnection.s3.domain.UploadPath; import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; import com.example.solidconnection.s3.dto.UrlPrefixResponse; import com.example.solidconnection.s3.service.S3Service; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -77,6 +80,38 @@ public ResponseEntity> uploadChatFile( return ResponseEntity.ok(chatImageUrls); } + @RequireRoleAccess(roles = Role.ADMIN) + @PostMapping("/admin/university/logo") + public ResponseEntity uploadAdminUniversityLogo( + @AuthorizedUser long adminId, + @RequestParam("file") MultipartFile imageFile, + @RequestParam("englishName") String englishName + ) { + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + UploadedFileUrlResponse logoImageUrl = s3Service.uploadFile( + imageFile, + UploadPath.ADMIN_UNIVERSITY_LOGO, + directoryName + ); + return ResponseEntity.ok(logoImageUrl); + } + + @RequireRoleAccess(roles = Role.ADMIN) + @PostMapping("/admin/university/background") + public ResponseEntity uploadAdminUniversityBackground( + @AuthorizedUser long adminId, + @RequestParam("file") MultipartFile imageFile, + @RequestParam("englishName") String englishName + ) { + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + UploadedFileUrlResponse backgroundImageUrl = s3Service.uploadFile( + imageFile, + UploadPath.ADMIN_UNIVERSITY_BACKGROUND, + directoryName + ); + return ResponseEntity.ok(backgroundImageUrl); + } + @GetMapping("/s3-url-prefix") public ResponseEntity getS3UrlPrefix() { return ResponseEntity.ok(new UrlPrefixResponse(s3Default, s3Uploaded, cloudFrontDefault, cloudFrontUploaded)); diff --git a/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java b/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java new file mode 100644 index 000000000..4b53396dc --- /dev/null +++ b/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.s3.domain; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public final class UploadDirectoryName { + + private static final int HASH_PREFIX_LENGTH = 12; + + private UploadDirectoryName() { + } + + public static String fromUniversityEnglishName(String englishName) { + if (englishName == null || englishName.isBlank()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + String directoryName = englishName.trim() + .toLowerCase() + .replaceAll("\\s*&\\s*", "_and_") + .replaceAll("\\s+", "_") + .replaceAll("_+", "_") + .replaceAll("[^a-z0-9_-]", ""); + + if (directoryName.isBlank()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + return directoryName + "_" + hash(englishName.trim()); + } + + private static String hash(String value) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + byte[] digest = messageDigest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest).substring(0, HASH_PREFIX_LENGTH); + } catch (NoSuchAlgorithmException e) { + throw new CustomException(ErrorCode.NOT_DEFINED_ERROR); + } + } +} diff --git a/src/main/java/com/example/solidconnection/s3/domain/UploadPath.java b/src/main/java/com/example/solidconnection/s3/domain/UploadPath.java index d94ed1fb8..b7afabbcc 100644 --- a/src/main/java/com/example/solidconnection/s3/domain/UploadPath.java +++ b/src/main/java/com/example/solidconnection/s3/domain/UploadPath.java @@ -3,23 +3,28 @@ import com.example.solidconnection.common.constant.FileConstants; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; +import java.util.List; import lombok.Getter; @Getter public enum UploadPath { - PROFILE("profile"), - GPA("gpa"), - LANGUAGE_TEST("language"), - COMMUNITY("community"), - NEWS("news"), - CHAT("chat/files"), - MENTOR_PROOF("mentor-proof"), + PROFILE("profile", true), + GPA("gpa", false), + LANGUAGE_TEST("language", false), + COMMUNITY("community", true), + NEWS("news", true), + CHAT("chat/files", false), + MENTOR_PROOF("mentor-proof", false), + ADMIN_UNIVERSITY_LOGO("admin/logo", true), + ADMIN_UNIVERSITY_BACKGROUND("admin/background", true) ; private final String type; + private final boolean imageOnly; - UploadPath(String type) { + UploadPath(String type, boolean imageOnly) { this.type = type; + this.imageOnly = imageOnly; } public boolean isResizable(long fileSize, String extension, long maxSizeBytes) { @@ -35,7 +40,7 @@ public boolean isResizable(long fileSize, String extension, long maxSizeBytes) { } public void validateExtension(String extension) { - if (extension == null || !FileConstants.ALL_ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) { + if (extension == null || !getAllowedExtensions().contains(extension.toLowerCase())) { throw new CustomException(ErrorCode.NOT_ALLOWED_FILE_EXTENSIONS, "허용된 형식: " + getAllowedExtensionsMessage()); } @@ -46,6 +51,13 @@ public boolean isImage(String extension) { } public String getAllowedExtensionsMessage() { - return String.join(", ", FileConstants.ALL_ALLOWED_EXTENSIONS); + return String.join(", ", getAllowedExtensions()); + } + + private List getAllowedExtensions() { + if (imageOnly) { + return FileConstants.IMAGE_EXTENSIONS; + } + return FileConstants.ALL_ALLOWED_EXTENSIONS; } } diff --git a/src/main/java/com/example/solidconnection/s3/service/S3Service.java b/src/main/java/com/example/solidconnection/s3/service/S3Service.java index 6dc3004e6..b5ad88d85 100644 --- a/src/main/java/com/example/solidconnection/s3/service/S3Service.java +++ b/src/main/java/com/example/solidconnection/s3/service/S3Service.java @@ -50,12 +50,20 @@ public class S3Service { * - 5mb 미만의 파일은 바로 업로드한다. * */ public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, UploadPath uploadPath) { + return uploadFile(multipartFile, uploadPath, null); + } + + public UploadedFileUrlResponse uploadFile( + MultipartFile multipartFile, + UploadPath uploadPath, + String subDirectory + ) { validateFile(multipartFile, uploadPath); UUID randomUUID = UUID.randomUUID(); String extension = getFileExtension(Objects.requireNonNull(multipartFile.getOriginalFilename())); String baseFileName = randomUUID + "." + extension; - String fileName = uploadPath.getType() + "/" + baseFileName; + String fileName = createFileName(uploadPath, subDirectory, baseFileName); final boolean shouldResize = uploadPath.isResizable( multipartFile.getSize(), extension, MAX_FILE_SIZE_MB); @@ -73,6 +81,13 @@ public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, UploadPat return new UploadedFileUrlResponse(returnPath); } + private String createFileName(UploadPath uploadPath, String subDirectory, String baseFileName) { + if (subDirectory == null || subDirectory.isBlank()) { + return uploadPath.getType() + "/" + baseFileName; + } + return uploadPath.getType() + "/" + subDirectory + "/" + baseFileName; + } + private byte[] extractBytes(MultipartFile file) { try { return file.getBytes(); diff --git a/src/main/java/com/example/solidconnection/university/domain/HostUniversity.java b/src/main/java/com/example/solidconnection/university/domain/HostUniversity.java index 6f816a23c..de80a281f 100644 --- a/src/main/java/com/example/solidconnection/university/domain/HostUniversity.java +++ b/src/main/java/com/example/solidconnection/university/domain/HostUniversity.java @@ -28,7 +28,7 @@ public class HostUniversity extends BaseEntity { @Column(name = "korean_name", nullable = false, unique = true, length = 100) private String koreanName; - @Column(name = "english_name", nullable = false, length = 100) + @Column(name = "english_name", nullable = false, unique = true, length = 100) private String englishName; @Column(name = "format_name", nullable = false, length = 100) diff --git a/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java b/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java index 3fa80629a..4264ed04d 100644 --- a/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java @@ -16,4 +16,6 @@ default HostUniversity getHostUniversityById(Long id) { } Optional findByKoreanName(String koreanName); + + Optional findByEnglishName(String englishName); } diff --git a/src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql b/src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql new file mode 100644 index 000000000..3dc3a5d66 --- /dev/null +++ b/src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE host_university + ADD CONSTRAINT uk_host_university_english_name UNIQUE (english_name); diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java index 5e2bbbf1d..a97303465 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java @@ -256,6 +256,31 @@ class 생성 { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); } + + @Test + void 이미_존재하는_영문명으로_생성하면_예외_응답을_반환한다() { + // given + HostUniversity existing = universityFixture.괌_대학(); + Country country = countryFixture.미국(); + Region region = regionFixture.영미권(); + + AdminHostUniversityCreateRequest request = new AdminHostUniversityCreateRequest( + "신규 대학", + existing.getEnglishName(), + "표시명", + null, null, null, + "https://logo.com/image.png", + "https://background.com/image.png", + null, + country.getCode(), + region.getCode() + ); + + // when & then + assertThatCode(() -> adminHostUniversityService.createHostUniversity(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); + } } @Nested @@ -341,6 +366,30 @@ class 수정 { .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); } + @Test + void 다른_대학의_영문명으로_수정하면_예외_응답을_반환한다() { + // given + HostUniversity university1 = universityFixture.괌_대학(); + HostUniversity university2 = universityFixture.메이지_대학(); + + AdminHostUniversityUpdateRequest request = new AdminHostUniversityUpdateRequest( + university1.getKoreanName(), + university2.getEnglishName(), + "수정된 표시명", + null, null, null, + "https://logo.com/image.png", + "https://background.com/image.png", + null, + university1.getCountry().getCode(), + university1.getRegion().getCode() + ); + + // when & then + assertThatCode(() -> adminHostUniversityService.updateHostUniversity(university1.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); + } + @Test void 같은_대학의_한글명으로_수정하면_성공한다() { // given diff --git a/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java b/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java new file mode 100644 index 000000000..041b7c42f --- /dev/null +++ b/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java @@ -0,0 +1,69 @@ +package com.example.solidconnection.s3.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.example.solidconnection.common.exception.CustomException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("업로드 디렉토리명 테스트") +class UploadDirectoryNameTest { + + @Nested + class 대학_영문명_변환_테스트 { + + @Test + void 대학_영문명의_공백을_언더스코어로_변환한다() { + // given + String englishName = "University of Tokyo"; + + // when + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + + // then + assertThat(directoryName) + .startsWith("university_of_tokyo_") + .matches("university_of_tokyo_[0-9a-f]{12}"); + } + + @Test + void 특수문자를_제거하고_앰퍼샌드는_and로_변환한다() { + // given + String englishName = "Texas A&M University, Austin"; + + // when + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + + // then + assertThat(directoryName) + .startsWith("texas_a_and_m_university_austin_") + .matches("texas_a_and_m_university_austin_[0-9a-f]{12}"); + } + + @Test + void 같은_slug로_변환되는_서로_다른_영문명은_다른_디렉토리명을_반환한다() { + // given + String englishName = "Texas A&M University"; + String normalizedCollisionName = "Texas A and M University"; + + // when + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + String collisionDirectoryName = UploadDirectoryName.fromUniversityEnglishName(normalizedCollisionName); + + // then + assertThat(directoryName).isNotEqualTo(collisionDirectoryName); + } + + @Test + void 공백_문자열이면_예외가_발생한다() { + // given + String blankName = " "; + + // when & then + assertThatThrownBy(() -> UploadDirectoryName.fromUniversityEnglishName(blankName)) + .isInstanceOf(CustomException.class); + } + } +} diff --git a/src/test/java/com/example/solidconnection/s3/service/S3ServiceDynamicPathTest.java b/src/test/java/com/example/solidconnection/s3/service/S3ServiceDynamicPathTest.java new file mode 100644 index 000000000..679d9ef77 --- /dev/null +++ b/src/test/java/com/example/solidconnection/s3/service/S3ServiceDynamicPathTest.java @@ -0,0 +1,57 @@ +package com.example.solidconnection.s3.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.s3.domain.UploadPath; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import software.amazon.awssdk.services.s3.S3Client; + +@DisplayName("S3 서비스 동적 업로드 경로 테스트") +@ExtendWith(MockitoExtension.class) +class S3ServiceDynamicPathTest { + + @InjectMocks + private S3Service s3Service; + + @Mock + private S3Client s3Client; + + @Mock + private SiteUserRepository siteUserRepository; + + @Mock + private FileUploadService fileUploadService; + + @Nested + class 동적_하위_디렉토리_업로드_테스트 { + + @Test + void 업로드_경로와_파일명_사이에_동적_하위_디렉토리를_포함한다() { + // given + MockMultipartFile file = new MockMultipartFile("file", "logo.png", "image/png", new byte[100]); + + // when + UploadedFileUrlResponse response = s3Service.uploadFile( + file, + UploadPath.ADMIN_UNIVERSITY_LOGO, + "university_of_tokyo" + ); + + // then + assertAll( + () -> assertThat(response.fileUrl()).startsWith("admin/logo/university_of_tokyo/"), + () -> assertThat(response.fileUrl()).endsWith(".png") + ); + } + } +} diff --git a/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java b/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java index c4485858a..543aa924f 100644 --- a/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java +++ b/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java @@ -110,5 +110,45 @@ class 파일_검증 { .doesNotThrowAnyException() ); } + + @Test + void 이미지_전용_업로드_경로는_문서_확장자를_허용하지_않는다() { + // given + MockMultipartFile pdfFile = createMockFile("logo.pdf", 100); + + // when & then + assertThatThrownBy(() -> s3Service.uploadFile( + pdfFile, + UploadPath.ADMIN_UNIVERSITY_LOGO, + "university_of_tokyo" + )) + .isInstanceOf(CustomException.class) + .hasMessageContaining("허용된 형식"); + } + + @Test + void 멘토_증빙_업로드는_이미지_외의_허용된_문서_확장자도_검증을_통과한다() { + // given + MockMultipartFile pdfFile = createMockFile("proof.pdf", 100); + + // when & then + assertThatCode(() -> s3Service.uploadFile(pdfFile, UploadPath.MENTOR_PROOF)) + .doesNotThrowAnyException(); + } + + @Test + void 성적_증빙_업로드는_이미지_외의_허용된_문서_확장자도_검증을_통과한다() { + // given + MockMultipartFile gpaPdfFile = createMockFile("gpa.pdf", 100); + MockMultipartFile languageTestPdfFile = createMockFile("language-test.pdf", 100); + + // when & then + assertAll( + () -> assertThatCode(() -> s3Service.uploadFile(gpaPdfFile, UploadPath.GPA)) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> s3Service.uploadFile(languageTestPdfFile, UploadPath.LANGUAGE_TEST)) + .doesNotThrowAnyException() + ); + } } }