diff --git a/core-api/build.gradle b/core-api/build.gradle index d66700b..f9661c1 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -1,6 +1,7 @@ dependencies { runtimeOnly(project(":storage")) runtimeOnly(project(":crawler")) + runtimeOnly(project(":modules:aws")) runtimeOnly("io.jsonwebtoken:jjwt-impl:$jsonWebTokenVersion") runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jsonWebTokenVersion") diff --git a/core-api/src/main/java/com/imlinker/coreapi/core/my/MyController.java b/core-api/src/main/java/com/imlinker/coreapi/core/my/MyController.java index 08b614e..296175e 100644 --- a/core-api/src/main/java/com/imlinker/coreapi/core/my/MyController.java +++ b/core-api/src/main/java/com/imlinker/coreapi/core/my/MyController.java @@ -11,7 +11,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -34,8 +36,19 @@ public ApiResponse getMyProfile( public ApiResponse updateMyInfo( @AuthenticatedUserContext AuthenticatedUserContextHolder userContext, @RequestBody UpdateMyInfoRequest request) { - System.out.println(request.interests()); OperationResult result = service.update(request.toParam(userContext.getId())); return ApiResponse.success(result); } + + @PutMapping( + value = "/profile-image", + consumes = {MediaType.ALL_VALUE}) + @Operation(summary = "내 프로필 이미지 수정하기") + public ApiResponse updateMyProfileImage( + @AuthenticatedUserContext AuthenticatedUserContextHolder userContext, + @RequestPart MultipartFile file) { + + OperationResult result = service.updateProfileImage(userContext.getId(), file); + return ApiResponse.success(result); + } } diff --git a/core-api/src/main/resources/application.yml b/core-api/src/main/resources/application.yml index ee896e1..85902ae 100644 --- a/core-api/src/main/resources/application.yml +++ b/core-api/src/main/resources/application.yml @@ -15,6 +15,7 @@ spring: - monitoring.yml - oauth/application-oauth-${spring.profiles.active}.yml - application-storage-${spring.profiles.active}.yml + - application-aws-${spring.profiles.active}.yml springdoc: swagger-ui: path: /swagger diff --git a/core-api/src/main/resources/oauth/application-oauth-local.yml b/core-api/src/main/resources/oauth/application-oauth-local.yml index 86e6cd6..0312d8f 100644 --- a/core-api/src/main/resources/oauth/application-oauth-local.yml +++ b/core-api/src/main/resources/oauth/application-oauth-local.yml @@ -4,8 +4,8 @@ spring: client: registration: kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} + client-id: ${KAKAO_CLIENT_ID:bb18b99ba9b7d0a13c2678c43f72b1a3} + client-secret: ${KAKAO_CLIENT_SECRET:0OZlNvip4h5OZjiJAYwSbdG5dyXTdY5b} redirect-uri: http://localhost:8080/login/oauth2/code/kakao authorization-grant-type: authorization_code client-authentication-method: client_secret_post diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2aee084..21016c4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,6 +10,9 @@ services: SPRING_PROFILES_ACTIVE: dev KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} + AWS_ACCESS_KEY: ${AWS_ACCESS_KEY} + AWS_SECRET_KEY: ${AWS_SECRET_KEY} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} ports: - "8080:8080" depends_on: diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 6fa3d8b..be3293c 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -11,6 +11,9 @@ services: SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD} KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} + AWS_ACCESS_KEY: ${AWS_ACCESS_KEY} + AWS_SECRET_KEY: ${AWS_SECRET_KEY} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} ports: - "8080:8080" depends_on: diff --git a/domain/src/main/java/com/imlinker/domain/common/URL.java b/domain/src/main/java/com/imlinker/domain/common/URL.java index 6fab4d1..ede00e2 100644 --- a/domain/src/main/java/com/imlinker/domain/common/URL.java +++ b/domain/src/main/java/com/imlinker/domain/common/URL.java @@ -12,7 +12,7 @@ public class URL { private static final String URL_REGEX = - "^((http|https)://)?(www\\.)?([a-zA-Z0-9가-힣.]+)\\.[a-z]{2,6}(:\\d{1,5})?(/[a-zA-Z0-9가-힣%._~/?#-]*)?$"; + "^((http|https)://)?(www\\.)?([a-zA-Z0-9가-힣-.]+)\\.[a-z]{2,6}(:\\d{1,5})?(/[a-zA-Z0-9가-힣%._~/?#-]*)?"; private static final Pattern URL_PATTERN = Pattern.compile(URL_REGEX); diff --git a/domain/src/main/java/com/imlinker/domain/common/file/FileType.java b/domain/src/main/java/com/imlinker/domain/common/file/FileType.java new file mode 100644 index 0000000..a538389 --- /dev/null +++ b/domain/src/main/java/com/imlinker/domain/common/file/FileType.java @@ -0,0 +1,17 @@ +package com.imlinker.domain.common.file; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum FileType { + IMAGE(List.of(new String[] {"image/jpeg", "image/png"})); + + private final List contentType; + + public boolean isSupported(String type) { + return contentType.contains(type); + } +} diff --git a/domain/src/main/java/com/imlinker/domain/common/file/FileUploader.java b/domain/src/main/java/com/imlinker/domain/common/file/FileUploader.java new file mode 100644 index 0000000..3d47520 --- /dev/null +++ b/domain/src/main/java/com/imlinker/domain/common/file/FileUploader.java @@ -0,0 +1,7 @@ +package com.imlinker.domain.common.file; + +import com.imlinker.domain.common.URL; + +public interface FileUploader { + URL uploadImage(UploadFileRequest request); +} diff --git a/domain/src/main/java/com/imlinker/domain/common/file/UploadFileRequest.java b/domain/src/main/java/com/imlinker/domain/common/file/UploadFileRequest.java new file mode 100644 index 0000000..6ef32ba --- /dev/null +++ b/domain/src/main/java/com/imlinker/domain/common/file/UploadFileRequest.java @@ -0,0 +1,13 @@ +package com.imlinker.domain.common.file; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@AllArgsConstructor +public class UploadFileRequest { + private final String fileName; + private final FileType fileType; + private final MultipartFile file; +} diff --git a/domain/src/main/java/com/imlinker/domain/user/UserService.java b/domain/src/main/java/com/imlinker/domain/user/UserService.java index 9be4547..53847e3 100644 --- a/domain/src/main/java/com/imlinker/domain/user/UserService.java +++ b/domain/src/main/java/com/imlinker/domain/user/UserService.java @@ -1,5 +1,9 @@ package com.imlinker.domain.user; +import com.imlinker.domain.common.URL; +import com.imlinker.domain.common.file.FileType; +import com.imlinker.domain.common.file.FileUploader; +import com.imlinker.domain.common.file.UploadFileRequest; import com.imlinker.domain.contacts.Contacts; import com.imlinker.domain.schedules.Schedules; import com.imlinker.domain.tag.Tag; @@ -13,11 +17,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Slf4j @Service @RequiredArgsConstructor public class UserService { + private final FileUploader fileUploader; private final UserReader userReader; private final UserUpdater userUpdater; private final UserContactReader userContactReader; @@ -52,4 +58,12 @@ public OperationResult update(UpdateUserParam param) { return OperationResult.SUCCESS; } + + public OperationResult updateProfileImage(Long userId, MultipartFile file) { + String fileName = String.format("user:%s-profile-%s", userId, LocalDateTime.now()); + URL fileUrl = fileUploader.uploadImage(new UploadFileRequest(fileName, FileType.IMAGE, file)); + + userUpdater.updateProfileImage(userId, fileUrl); + return OperationResult.SUCCESS; + } } diff --git a/domain/src/main/java/com/imlinker/domain/user/UserUpdater.java b/domain/src/main/java/com/imlinker/domain/user/UserUpdater.java index 47c1138..8d6b662 100644 --- a/domain/src/main/java/com/imlinker/domain/user/UserUpdater.java +++ b/domain/src/main/java/com/imlinker/domain/user/UserUpdater.java @@ -36,6 +36,11 @@ public User updateRefreshToken(Long id, String refreshToken) { return userRepository.save(updatedUser); } + public User updateProfileImage(Long userId, URL profileImgUrl) { + User updatedUser = fetch(userId).update(profileImgUrl); + return userRepository.save(updatedUser); + } + public User fetch(Long id) { return userRepository .findById(id) diff --git a/domain/src/test/java/com/imlinker/domain/.gitkeep b/domain/src/test/java/com/imlinker/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/domain/src/test/java/com/imlinker/domain/common/URLTest.java b/domain/src/test/java/com/imlinker/domain/common/URLTest.java index 5ab0d42..55f77c2 100644 --- a/domain/src/test/java/com/imlinker/domain/common/URLTest.java +++ b/domain/src/test/java/com/imlinker/domain/common/URLTest.java @@ -18,6 +18,7 @@ class URLTest { "http://www.example.com", "https://example.com", "http://example.com:8080", + "https://image.s3.ap-northeast-2.amazonaws.com/user%3A1-profile-2024-01-28T20%3A11%3A06.682821" }) public void URL_형식이라면_객체를_생성한다(String validURL) { URL url = assertDoesNotThrow(() -> URL.of(validURL)); diff --git a/gradle.properties b/gradle.properties index 2113249..163e33e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,3 +17,7 @@ caffeineVersion=3.1.5 # jwt jsonWebTokenVersion=0.10.7 jjwtApiVersion=0.10.5 + +# aws +awsVersion=2.15.0 +s3SdkVersion=1.12.174 diff --git a/modules/aws/build.gradle b/modules/aws/build.gradle new file mode 100644 index 0000000..f37ee5c --- /dev/null +++ b/modules/aws/build.gradle @@ -0,0 +1,14 @@ +dependencies { + implementation(project(":domain")) + implementation(project(":common:enums")) + implementation(project(":common:error")) + implementation("com.amazonaws:aws-java-sdk-s3:1.12.174") + implementation platform("software.amazon.awssdk:bom:$awsVersion") +} +bootJar { + enabled = false +} + +jar { + enabled = true +} \ No newline at end of file diff --git a/modules/aws/src/main/java/com/imlinker/s3/S3Configuration.java b/modules/aws/src/main/java/com/imlinker/s3/S3Configuration.java new file mode 100644 index 0000000..0dbb249 --- /dev/null +++ b/modules/aws/src/main/java/com/imlinker/s3/S3Configuration.java @@ -0,0 +1,32 @@ +package com.imlinker.s3; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Configuration { + + @Value("${aws.accessKey}") + private String accessKey; + + @Value("${aws.secretKey}") + private String secretKey; + + @Bean + public AmazonS3 awsS3Client() { + + AWSStaticCredentialsProvider credentialsProvider = + new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey)); + + return AmazonS3ClientBuilder.standard() + .withCredentials(credentialsProvider) + .withRegion(Regions.AP_NORTHEAST_2) + .build(); + } +} diff --git a/modules/aws/src/main/java/com/imlinker/s3/S3Uploader.java b/modules/aws/src/main/java/com/imlinker/s3/S3Uploader.java new file mode 100644 index 0000000..97f8ef8 --- /dev/null +++ b/modules/aws/src/main/java/com/imlinker/s3/S3Uploader.java @@ -0,0 +1,59 @@ +package com.imlinker.s3; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.imlinker.domain.common.URL; +import com.imlinker.domain.common.file.FileUploader; +import com.imlinker.domain.common.file.UploadFileRequest; +import com.imlinker.error.ApplicationException; +import com.imlinker.error.ErrorType; +import java.io.InputStream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class S3Uploader implements FileUploader { + + @Value("${aws.s3.bucket}") + private String bucket; + + private final AmazonS3 s3Client; + + @Override + public URL uploadImage(UploadFileRequest request) { + log.info( + "[S3][FileUpload][시작] (fileName={}, contentType={}", + request.getFileName(), + request.getFileType().getContentType()); + + if (!request.getFileType().isSupported(request.getFile().getContentType())) { + log.info( + "[S3][FileUpload][실패] 지원하지 않는 이미지 파일 형식(contentType={})", + request.getFile().getContentType()); + throw new ApplicationException(ErrorType.INVALID_REQUEST_PARAMETER); + } + + try { + InputStream fis = request.getFile().getInputStream(); + ObjectMetadata metaData = new ObjectMetadata(); + metaData.setContentLength(request.getFile().getSize()); + metaData.setContentType(request.getFile().getContentType()); + + s3Client.putObject(bucket, request.getFileName(), fis, metaData); + String responseUrl = s3Client.getUrl(bucket, request.getFileName()).toString(); + + return URL.of(responseUrl); + } catch (Exception e) { + log.info( + "[S3][FileUpload][실패] (fileName={}, contentType={}, cause={})", + request.getFileName(), + request.getFile().getContentType(), + e.getMessage()); + throw new ApplicationException(ErrorType.INTERNAL_PROCESSING_ERROR, null, e.getCause()); + } + } +} diff --git a/modules/aws/src/main/resources/application-aws-dev.yml b/modules/aws/src/main/resources/application-aws-dev.yml new file mode 100644 index 0000000..79179d7 --- /dev/null +++ b/modules/aws/src/main/resources/application-aws-dev.yml @@ -0,0 +1,5 @@ +aws: + accessKey: ${AWS_ACCESS_KEY} + secretKey: ${AWS_SECRET_KEY} + s3: + bucket: ${AWS_S3_BUCKET} \ No newline at end of file diff --git a/modules/aws/src/main/resources/application-aws-local.yml b/modules/aws/src/main/resources/application-aws-local.yml new file mode 100644 index 0000000..79179d7 --- /dev/null +++ b/modules/aws/src/main/resources/application-aws-local.yml @@ -0,0 +1,5 @@ +aws: + accessKey: ${AWS_ACCESS_KEY} + secretKey: ${AWS_SECRET_KEY} + s3: + bucket: ${AWS_S3_BUCKET} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index af6d564..99d2f29 100644 --- a/settings.gradle +++ b/settings.gradle @@ -25,6 +25,7 @@ include(":storage") include(":common:enums") include(":common:error") +include(":modules:aws") include(":modules:logging") include(":modules:monitoring") include(":modules:local-cache") \ No newline at end of file