diff --git a/build.gradle b/build.gradle index 98b67c8f..f1c2bf90 100644 --- a/build.gradle +++ b/build.gradle @@ -82,20 +82,10 @@ dependencies { //객체 간 매핑 처리 implementation 'org.modelmapper:modelmapper:3.1.0' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.4' - //객체 간 매핑 처리 - implementation 'org.modelmapper:modelmapper:3.1.0' - - - // OAUTH2 - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - - // MOCKITO - testImplementation "org.mockito:mockito-core:3.+" - //WIREMOCK (외부 의존성 테스트용) - implementation 'org.wiremock.integrations:wiremock-spring-boot:3.3.0' + // S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { diff --git a/src/main/java/com/example/api/account/repository/AccountRepository.java b/src/main/java/com/example/api/account/repository/AccountRepository.java index 37cbb752..cef6eae2 100644 --- a/src/main/java/com/example/api/account/repository/AccountRepository.java +++ b/src/main/java/com/example/api/account/repository/AccountRepository.java @@ -4,6 +4,8 @@ import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -16,4 +18,11 @@ public interface AccountRepository extends JpaRepository { Optional findByEmail(String email); Optional findUserByLoginId(String loginId); + + @Query("SELECT a.profileImage FROM Account a WHERE a.accountId = :accountId") + Optional findProfileImageByAccountId(@Param("accountId") Long accountId); + + @Query("update Account a set a.profileImage = :profileImage where a.accountId = :accountId") + @Modifying + void updateProfileImageByAccountId(@Param("profileImage") String profileImage, @Param("accountId") Long accountId); } \ No newline at end of file diff --git a/src/main/java/com/example/api/aws/controller/S3Controller.java b/src/main/java/com/example/api/aws/controller/S3Controller.java new file mode 100644 index 00000000..4bd0f652 --- /dev/null +++ b/src/main/java/com/example/api/aws/controller/S3Controller.java @@ -0,0 +1,23 @@ +package com.example.api.aws.controller; + +import com.example.api.aws.dto.UploadProfileRequest; +import com.example.api.aws.service.S3Service; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@AllArgsConstructor +@RestController +@RequestMapping("/api/v1") +public class S3Controller { + private final S3Service s3Service; + + @PostMapping(value = "/upload/profile", consumes = "multipart/form-data") + public ResponseEntity upload(@RequestParam("file") final MultipartFile file) { + UploadProfileRequest request = new UploadProfileRequest(1L, file); + return new ResponseEntity<>(s3Service.upload(request).path(), HttpStatus.OK); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/api/aws/dto/OldKeyRequest.java b/src/main/java/com/example/api/aws/dto/OldKeyRequest.java new file mode 100644 index 00000000..2f5ae4e6 --- /dev/null +++ b/src/main/java/com/example/api/aws/dto/OldKeyRequest.java @@ -0,0 +1,6 @@ +package com.example.api.aws.dto; + +import jakarta.validation.constraints.NotBlank; + +public record OldKeyRequest(@NotBlank String oldKey) { +} diff --git a/src/main/java/com/example/api/aws/dto/S3UploadRequest.java b/src/main/java/com/example/api/aws/dto/S3UploadRequest.java new file mode 100644 index 00000000..63083695 --- /dev/null +++ b/src/main/java/com/example/api/aws/dto/S3UploadRequest.java @@ -0,0 +1,8 @@ +package com.example.api.aws.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.web.multipart.MultipartFile; + +public record S3UploadRequest(@NotNull MultipartFile multipartFile, @NotBlank String key) { +} diff --git a/src/main/java/com/example/api/aws/dto/UploadProfileRequest.java b/src/main/java/com/example/api/aws/dto/UploadProfileRequest.java new file mode 100644 index 00000000..8458a66a --- /dev/null +++ b/src/main/java/com/example/api/aws/dto/UploadProfileRequest.java @@ -0,0 +1,9 @@ +package com.example.api.aws.dto; + +import jakarta.validation.constraints.NotNull; +import org.springframework.web.multipart.MultipartFile; + +public record UploadProfileRequest( + @NotNull Long userId, + @NotNull MultipartFile multipartFile) { +} \ No newline at end of file diff --git a/src/main/java/com/example/api/aws/dto/UploadProfileResponse.java b/src/main/java/com/example/api/aws/dto/UploadProfileResponse.java new file mode 100644 index 00000000..fe100682 --- /dev/null +++ b/src/main/java/com/example/api/aws/dto/UploadProfileResponse.java @@ -0,0 +1,6 @@ +package com.example.api.aws.dto; + +import jakarta.validation.constraints.NotNull; + +public record UploadProfileResponse(@NotNull String path) { +} diff --git a/src/main/java/com/example/api/aws/service/S3Service.java b/src/main/java/com/example/api/aws/service/S3Service.java new file mode 100644 index 00000000..2e0037f6 --- /dev/null +++ b/src/main/java/com/example/api/aws/service/S3Service.java @@ -0,0 +1,93 @@ +package com.example.api.aws.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.example.api.account.repository.AccountRepository; +import com.example.api.aws.dto.S3UploadRequest; +import com.example.api.aws.dto.OldKeyRequest; +import com.example.api.aws.dto.UploadProfileRequest; +import com.example.api.aws.dto.UploadProfileResponse; +import com.example.api.exception.BusinessException; +import com.example.api.exception.ErrorCode; +import com.example.api.global.config.AmazonConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.io.IOException; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class S3Service { + private final AmazonS3 amazonS3; + private final AmazonConfig amazonConfig; + private final AccountRepository accountRepository; + + @Transactional + public UploadProfileResponse upload(@Validated final UploadProfileRequest request) { + // 업로드 파일이 null 이라면 기본 프로필로 초기화 + if (initDefaultIfFileIsNull(request)) return new UploadProfileResponse(null); + + Optional userProfile = accountRepository.findProfileImageByAccountId(request.userId()); + userProfile.ifPresent(oldKey -> remove(new OldKeyRequest(oldKey))); + + String key = generateFileName(request); + String path = uploadToS3(new S3UploadRequest(request.multipartFile(), key)); + accountRepository.updateProfileImageByAccountId(key, request.userId()); // S3 업로드 이후 사용자 테이블 프로필 값 업데이트 + + return new UploadProfileResponse(path); + } + + private boolean initDefaultIfFileIsNull(final UploadProfileRequest request) { + if (request.multipartFile() == null || request.multipartFile().isEmpty()) { + accountRepository.updateProfileImageByAccountId(null, request.userId()); + String oldKey = "user-uploads/" + request.userId() + "/profile.png"; + remove(new OldKeyRequest(oldKey)); + return true; + } + return false; + } + + private String generateFileName(final UploadProfileRequest request) { + String contentType = request.multipartFile().getContentType(); + String fileExtension = contentType != null && contentType.contains("/") + ? "." + contentType.split("/")[1] + : ".png"; + return String.format("user-uploads/%d/profile%s", request.userId(), fileExtension); + } + + private String uploadToS3(final S3UploadRequest s3UploadRequest) { + try { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(s3UploadRequest.multipartFile().getSize()); + metadata.setContentType(s3UploadRequest.multipartFile().getContentType()); + + amazonS3.putObject( + new PutObjectRequest( + amazonConfig.getBucket(), + s3UploadRequest.key(), + s3UploadRequest.multipartFile().getInputStream(), + metadata + ) + ); + + return amazonS3.getUrl(amazonConfig.getBucket(), s3UploadRequest.key()).toString(); + + } catch (IOException e) { + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + + public void remove(final OldKeyRequest request) { + if (!amazonS3.doesObjectExist(amazonConfig.getBucket(), request.oldKey())) { + throw new AmazonS3Exception("Object " + request.oldKey() + " does not exist!"); + } + amazonS3.deleteObject(amazonConfig.getBucket(), request.oldKey()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/api/domain/Account.java b/src/main/java/com/example/api/domain/Account.java index 276424df..f8040442 100644 --- a/src/main/java/com/example/api/domain/Account.java +++ b/src/main/java/com/example/api/domain/Account.java @@ -6,17 +6,10 @@ import com.example.api.board.dto.update.UpdateOpenStatusRequest; import com.example.api.board.dto.update.UpdateUserInfoRequest; import jakarta.persistence.*; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.EqualsAndHashCode; import lombok.Getter; -import lombok.Setter; import java.util.Collection; -import static jakarta.persistence.FetchType.*; - - @Entity @Getter @AttributeOverride(name = "createdDate", column = @Column(name = "ACCOUNT_REGISTERED_DATETIME")) @@ -95,6 +88,24 @@ public Account(String loginId, String password, String email, String phoneNumber this.roles = roles; } + public Account(int age, boolean deleted, String email, String loginId, String name, Nationality nationality, String nickname, boolean openStatus, String password, String phoneNumber, String profileImage, Collection roles, String sex, float starPoint, int workCount) { + this.age = age; + this.deleted = deleted; + this.email = email; + this.loginId = loginId; + this.name = name; + this.nationality = nationality; + this.nickname = nickname; + this.openStatus = openStatus; + this.password = password; + this.phoneNumber = phoneNumber; + this.profileImage = profileImage; + this.roles = roles; + this.sex = sex; + this.starPoint = starPoint; + this.workCount = workCount; + } + public LoginUserRequest getLoginUser(){ return new LoginUserRequest(accountId); } diff --git a/src/main/java/com/example/api/exception/ErrorCode.java b/src/main/java/com/example/api/exception/ErrorCode.java index 494a7034..68878d72 100644 --- a/src/main/java/com/example/api/exception/ErrorCode.java +++ b/src/main/java/com/example/api/exception/ErrorCode.java @@ -38,7 +38,9 @@ public enum ErrorCode { BUSINESS_DOMAIN_EXCEPTION(HttpStatus.BAD_REQUEST, "-700", "비즈니스 도메인 에러"), - CONTRACT_EXCEPTION(HttpStatus.BAD_REQUEST, "-800", "계약 도메인 에러"); + CONTRACT_EXCEPTION(HttpStatus.BAD_REQUEST, "-800", "계약 도메인 에러"), + + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR,"F500", "이미지 업로드 실패"); private final HttpStatus httpStatus; private final String errorCode; @@ -49,5 +51,4 @@ public enum ErrorCode { this.errorCode = errorCodeResponse; this.errorDescription = errorDescription; } -} - +} \ No newline at end of file diff --git a/src/main/java/com/example/api/global/config/AmazonConfig.java b/src/main/java/com/example/api/global/config/AmazonConfig.java new file mode 100644 index 00000000..4d5a5a4c --- /dev/null +++ b/src/main/java/com/example/api/global/config/AmazonConfig.java @@ -0,0 +1,46 @@ +package com.example.api.global.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Getter +public class AmazonConfig { + private AWSCredentials awsCredentials; + @Value("${cloud.aws.s3.bucket}") + private String bucket; + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + @Value("${cloud.aws.region.static}") + private String region; + + @PostConstruct + public void init() { + this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + } + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + + @Bean + public AWSCredentialsProvider awsCredentialsProvider() { + return new AWSStaticCredentialsProvider(awsCredentials); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/api/global/config/WebConfig.java b/src/main/java/com/example/api/global/config/WebConfig.java new file mode 100644 index 00000000..4964abed --- /dev/null +++ b/src/main/java/com/example/api/global/config/WebConfig.java @@ -0,0 +1,15 @@ +package com.example.api.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.multipart.MultipartResolver; +import org.springframework.web.multipart.support.StandardServletMultipartResolver; + +@Configuration +public class WebConfig { + + @Bean + public MultipartResolver multipartResolver() { + return new StandardServletMultipartResolver(); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5c44b70a..a4bd2071 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -89,4 +89,14 @@ app.oauth2.authorized-redirect-uris=* # vendor vendor.api.base-url=https://api.odcloud.kr/api/nts-businessman/v1/validate -vendor.api.service-key=${VENDOR_API_SERVICE-KEY} \ No newline at end of file +vendor.api.service-key=${VENDOR_API_SERVICE-KEY} + +cloud.aws.s3.bucket=danpat +cloud.aws.region.static=ap-northeast-2 +cloud.aws.stack.auto=false +cloud.aws.credentials.accessKey=${AWS_CREDENTIALS_ACCESSKEY} +cloud.aws.credentials.secretKey=${AWS_CREDENTIALS_SECRETKEY} + +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB \ No newline at end of file diff --git a/src/test/java/com/example/api/aws/service/S3ServiceTest.java b/src/test/java/com/example/api/aws/service/S3ServiceTest.java new file mode 100644 index 00000000..18ebe55d --- /dev/null +++ b/src/test/java/com/example/api/aws/service/S3ServiceTest.java @@ -0,0 +1,88 @@ +package com.example.api.aws.service; + +import com.example.api.account.entity.Nationality; +import com.example.api.account.entity.UserRole; +import com.example.api.account.repository.AccountRepository; +import com.example.api.aws.dto.UploadProfileRequest; +import com.example.api.aws.dto.UploadProfileResponse; +import com.example.api.domain.Account; +import jakarta.annotation.PostConstruct; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@SpringBootTest +class S3ServiceTest { + @Autowired + private S3Service s3Service; + @Autowired + private AccountRepository accountRepository; + + @PostConstruct + void setUp() { + accountRepository.deleteAll(); + Account account = new Account( + 25, // age + false, // deleted + "alice@example.com", // email + "user01", // loginId + "Alice", // name + Nationality.KOREAN, // nationality + "nickname1", // nickname + true, // openStatus + "pass01", // password + "010-1234-5678", // phoneNumber + "user-uploads/1/profile.png", // profileImage + List.of(UserRole.EMPLOYEE), // roles + "F", // sex + 4.5f, // starPoint + 10 // workCount + ); + accountRepository.save(account); + } + + @Test + @Order(1) + @DisplayName("업로드 파일이 null일 경우 기본 프로필로 초기화") + void uploadProfileImage_NullFile_ShouldInitializeToDefaultImage() { + MultipartFile file = null; + + UploadProfileRequest request = new UploadProfileRequest(1L, file); + + UploadProfileResponse response = s3Service.upload(request); + String newFile = accountRepository.findProfileImageByAccountId(1L).orElse(null); + assertNull(response.path()); + assertNull(newFile); // null로 업데이트 되었는지 확인 + } + + @Test + @Order(2) + @DisplayName("정상 업로드 성공") + void upload_ShouldUploadFileSuccessfully() throws IOException { + ClassPathResource resource = new ClassPathResource("test-files/test-image.png"); + MultipartFile file = new MockMultipartFile( + "file", + resource.getFilename(), + "image/png", + resource.getInputStream() + ); + + UploadProfileRequest request = new UploadProfileRequest(1L, file); + + UploadProfileResponse response = s3Service.upload(request); + String newFile = accountRepository.findProfileImageByAccountId(1L).orElse(null); + + assertNotNull(response); + assertEquals("https://danpat.s3.ap-northeast-2.amazonaws.com/user-uploads/1/profile.png", response.path()); + assertNotEquals("oldProfile", newFile); // 새로운 파일 이름으로 업데이트 되었는지 확인 + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 7b320cb3..33d21693 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -32,4 +32,10 @@ spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver spring.security.oauth2.client.provider.naver.user-name-attribute=response # redirect allow path -app.oauth2.authorized-redirect-uris=* \ No newline at end of file +app.oauth2.authorized-redirect-uris=* + +cloud.aws.s3.bucket=danpat +cloud.aws.region.static=ap-northeast-2 +cloud.aws.stack.auto=false +cloud.aws.credentials.accessKey=${AWS_CREDENTIALS_ACCESSKEY} +cloud.aws.credentials.secretKey=${AWS_CREDENTIALS_SECRETKEY} \ No newline at end of file diff --git a/src/test/resources/test-files/test-image.png b/src/test/resources/test-files/test-image.png new file mode 100644 index 00000000..a22f0faa Binary files /dev/null and b/src/test/resources/test-files/test-image.png differ