From 455fa5509f8b856108aba95cc0a801128be17be8 Mon Sep 17 00:00:00 2001 From: JaeUk Date: Thu, 8 Aug 2024 16:18:28 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20#29=20AWS=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20S3=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ .../infrastructure/config/AmazonS3Config.kt | 26 +++++++++++++++++++ .../routebox/properties/AwsProperties.kt | 12 +++++++++ src/main/resources/application.yml | 6 +++++ 4 files changed, 46 insertions(+) create mode 100644 src/main/kotlin/com/routebox/routebox/infrastructure/config/AmazonS3Config.kt create mode 100644 src/main/kotlin/com/routebox/routebox/properties/AwsProperties.kt diff --git a/build.gradle.kts b/build.gradle.kts index 900ea51..820df6e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -50,6 +50,8 @@ dependencies { implementation("org.springframework.cloud:spring-cloud-starter-openfeign:4.1.3") + implementation("com.amazonaws:aws-java-sdk-s3:1.12.767") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-redis") diff --git a/src/main/kotlin/com/routebox/routebox/infrastructure/config/AmazonS3Config.kt b/src/main/kotlin/com/routebox/routebox/infrastructure/config/AmazonS3Config.kt new file mode 100644 index 0000000..b591e12 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/infrastructure/config/AmazonS3Config.kt @@ -0,0 +1,26 @@ +package com.routebox.routebox.infrastructure.config + +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 com.routebox.routebox.properties.AWSProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class AmazonS3Config(private val awsProperties: AWSProperties) { + @Bean + fun amazonS3Client(): AmazonS3 = + AmazonS3ClientBuilder.standard() + .withCredentials( + AWSStaticCredentialsProvider( + BasicAWSCredentials( + awsProperties.s3.accessKey, + awsProperties.s3.secretKey, + ), + ), + ).withRegion(Regions.AP_NORTHEAST_2).build() + ?: throw NoSuchElementException("AWS S3 Bucket에 엑세스 할 수 없습니다. 엑세스 정보와 bucket name을 다시 확인해주세요.") +} diff --git a/src/main/kotlin/com/routebox/routebox/properties/AwsProperties.kt b/src/main/kotlin/com/routebox/routebox/properties/AwsProperties.kt new file mode 100644 index 0000000..50785c5 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/properties/AwsProperties.kt @@ -0,0 +1,12 @@ +package com.routebox.routebox.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("aws") +data class AWSProperties(val s3: S3) { + data class S3( + val accessKey: String, + val secretKey: String, + val bucketName: String, + ) +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 746114d..268d9bd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,6 +50,12 @@ springdoc: swagger: server-url: ${SWAGGER_SERVER_URL} +aws: + s3: + bucket-name: ${AWS_S3_BUCKET_NAME} + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} + --- spring: config: From 15eba752fd062358e1580520129af18c1c6eff04 Mon Sep 17 00:00:00 2001 From: JaeUk Date: Thu, 8 Aug 2024 16:19:24 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs(schema.sql):=20`routes`=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=82=B4=EC=9D=98=20=EB=AC=BC=EB=A6=AC?= =?UTF-8?q?=EC=A0=81=20FK=20=EC=A0=9C=EA=B1=B0=20=ED=9B=84=20=EB=85=BC?= =?UTF-8?q?=EB=A6=AC=EC=A0=81=20FK=EB=A1=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema.sql | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index ef8f3c6..2dc6ef6 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -5,6 +5,7 @@ DROP TABLE IF EXISTS inquiry; DROP TABLE IF EXISTS user_profile_image; DROP TABLE IF EXISTS user_point_history; DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS routes; CREATE TABLE users ( @@ -100,20 +101,22 @@ CREATE TABLE inquiry_response ); CREATE INDEX idx__inquiry_response__inquiry_id ON inquiry_response (inquiry_id); -CREATE TABLE routes ( - route_id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT, - name VARCHAR(255) NOT NULL, - description TEXT NOT NULL, - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, - who_with VARCHAR(255) NOT NULL, - number_of_people INT NOT NULL, - number_of_days VARCHAR(255) NOT NULL, - style json, - transportation json, - is_public BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - CONSTRAINT FK_user_route FOREIGN KEY (user_id) REFERENCES users(user_id) +CREATE TABLE routes +( + route_id BIGINT AUTO_INCREMENT, + user_id BIGINT, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + who_with VARCHAR(255) NOT NULL, + number_of_people INT NOT NULL, + number_of_days VARCHAR(255) NOT NULL, + style JSON, + transportation JSON, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (route_id) ); +CREATE INDEX idx__routes__user_id ON routes (user_id); From 8756ff57b5ba968be4aea387bd1ec0b06df07b79 Mon Sep 17 00:00:00 2001 From: JaeUk Date: Thu, 15 Aug 2024 19:40:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20#29=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20API=EC=97=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=88=98=EC=A0=95=20=EA=B0=80=EB=8A=A5=ED=95=98?= =?UTF-8?q?=EA=B2=8C=EB=81=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/user/UpdateUserInfoUseCase.kt | 1 + .../user/dto/UpdateUserInfoCommand.kt | 2 + .../controller/user/UserController.kt | 19 ++--- .../user/dto/UpdateUserInfoRequest.kt | 26 +++++-- .../routebox/domain/common/FileEntity.kt | 4 + .../routebox/domain/common/FileManager.kt | 8 ++ .../routebox/domain/common/dto/FileInfo.kt | 6 ++ .../com/routebox/routebox/domain/user/User.kt | 4 + .../routebox/domain/user/UserProfileImage.kt | 3 +- .../routebox/domain/user/UserService.kt | 28 ++++++- .../common/AmazonS3FileManager.kt | 53 +++++++++++++ .../infrastructure/config/AmazonS3Config.kt | 4 +- .../user/UserProfileImageRepository.kt | 15 ++++ .../routebox/properties/AwsProperties.kt | 2 +- .../user/UpdateUserInfoUseCaseTest.kt | 20 ++++- .../controller/user/UserControllerTest.kt | 35 +++++---- .../routebox/domain/user/UserServiceTest.kt | 76 +++++++++++++++++++ 17 files changed, 265 insertions(+), 41 deletions(-) create mode 100644 src/main/kotlin/com/routebox/routebox/domain/common/FileManager.kt create mode 100644 src/main/kotlin/com/routebox/routebox/domain/common/dto/FileInfo.kt create mode 100644 src/main/kotlin/com/routebox/routebox/infrastructure/common/AmazonS3FileManager.kt create mode 100644 src/main/kotlin/com/routebox/routebox/infrastructure/user/UserProfileImageRepository.kt diff --git a/src/main/kotlin/com/routebox/routebox/application/user/UpdateUserInfoUseCase.kt b/src/main/kotlin/com/routebox/routebox/application/user/UpdateUserInfoUseCase.kt index 7c8a4e8..0e58beb 100644 --- a/src/main/kotlin/com/routebox/routebox/application/user/UpdateUserInfoUseCase.kt +++ b/src/main/kotlin/com/routebox/routebox/application/user/UpdateUserInfoUseCase.kt @@ -27,6 +27,7 @@ class UpdateUserInfoUseCase( gender = command.gender, birthDay = command.birthDay, introduction = command.introduction, + profileImage = command.profileImage, ) return UpdateUserInfoResult.from(updatedUser) } diff --git a/src/main/kotlin/com/routebox/routebox/application/user/dto/UpdateUserInfoCommand.kt b/src/main/kotlin/com/routebox/routebox/application/user/dto/UpdateUserInfoCommand.kt index 02b7c42..cbd3dfc 100644 --- a/src/main/kotlin/com/routebox/routebox/application/user/dto/UpdateUserInfoCommand.kt +++ b/src/main/kotlin/com/routebox/routebox/application/user/dto/UpdateUserInfoCommand.kt @@ -1,6 +1,7 @@ package com.routebox.routebox.application.user.dto import com.routebox.routebox.domain.user.constant.Gender +import org.springframework.web.multipart.MultipartFile import java.time.LocalDate data class UpdateUserInfoCommand( @@ -9,4 +10,5 @@ data class UpdateUserInfoCommand( val gender: Gender?, val birthDay: LocalDate?, val introduction: String?, + val profileImage: MultipartFile?, ) diff --git a/src/main/kotlin/com/routebox/routebox/controller/user/UserController.kt b/src/main/kotlin/com/routebox/routebox/controller/user/UserController.kt index 4fda6f1..be84422 100644 --- a/src/main/kotlin/com/routebox/routebox/controller/user/UserController.kt +++ b/src/main/kotlin/com/routebox/routebox/controller/user/UserController.kt @@ -3,7 +3,6 @@ package com.routebox.routebox.controller.user import com.routebox.routebox.application.user.CheckNicknameAvailabilityUseCase import com.routebox.routebox.application.user.GetUserProfileUseCase import com.routebox.routebox.application.user.UpdateUserInfoUseCase -import com.routebox.routebox.application.user.dto.UpdateUserInfoCommand import com.routebox.routebox.controller.user.dto.CheckNicknameAvailabilityResponse import com.routebox.routebox.controller.user.dto.UpdateUserInfoRequest import com.routebox.routebox.controller.user.dto.UserProfileResponse @@ -18,12 +17,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid +import org.springdoc.core.annotations.ParameterObject import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -94,7 +94,8 @@ class UserController( @Operation( summary = "내 정보 수정", description = "

내 정보(닉네임, 성별, 생일 등)를 수정합니다." + - "

Request body를 통해 전달된 항목들만 수정되며, request body에 존재하지 않거나 null로 설정된 항목들은 수정되지 않습니다.", + "

요청 시 content-type은 multipart/form-data로 설정하여 요청해야 합니다." + + "

Request body를 통해 전달된 항목들만 수정되며, 요청 데이터에 존재하지 않거나 null로 설정된 항목들은 수정되지 않습니다.", security = [SecurityRequirement(name = "access-token")], ) @ApiResponses( @@ -104,17 +105,9 @@ class UserController( @PatchMapping("/v1/users/me") fun updateUserInfo( @AuthenticationPrincipal userPrincipal: UserPrincipal, - @RequestBody @Valid request: UpdateUserInfoRequest, + @ParameterObject @ModelAttribute @Valid request: UpdateUserInfoRequest, ): UserResponse { - val updateUserInfo = updateUserInfoUseCase( - UpdateUserInfoCommand( - id = userPrincipal.userId, - nickname = request.nickname, - gender = request.gender, - birthDay = request.birthDay, - introduction = request.introduction, - ), - ) + val updateUserInfo = updateUserInfoUseCase(request.toCommand(userId = userPrincipal.userId)) return UserResponse.from(updateUserInfo) } } diff --git a/src/main/kotlin/com/routebox/routebox/controller/user/dto/UpdateUserInfoRequest.kt b/src/main/kotlin/com/routebox/routebox/controller/user/dto/UpdateUserInfoRequest.kt index add0e5f..57ea85b 100644 --- a/src/main/kotlin/com/routebox/routebox/controller/user/dto/UpdateUserInfoRequest.kt +++ b/src/main/kotlin/com/routebox/routebox/controller/user/dto/UpdateUserInfoRequest.kt @@ -1,24 +1,38 @@ package com.routebox.routebox.controller.user.dto +import com.routebox.routebox.application.user.dto.UpdateUserInfoCommand import com.routebox.routebox.domain.user.constant.Gender import com.routebox.routebox.domain.validation.Nickname import io.swagger.v3.oas.annotations.media.Schema import org.hibernate.validator.constraints.Length +import org.springframework.web.multipart.MultipartFile import java.time.LocalDate data class UpdateUserInfoRequest( - @Schema(description = "닉네임", example = "고작가") @field:Nickname - val nickname: String?, + var nickname: String?, @Schema(description = "성별") - val gender: Gender?, + var gender: Gender?, @Schema(description = "생일") - val birthDay: LocalDate?, + var birthDay: LocalDate?, @Schema(description = "한 줄 소개", example = "평범한 일상 속의 예술을 이야기합니다...") @field:Length(max = 25) - val introduction: String?, -) + var introduction: String?, + + @Schema(description = "유저 프로필 이미지") + var profileImage: MultipartFile?, +) { + fun toCommand(userId: Long): UpdateUserInfoCommand = + UpdateUserInfoCommand( + id = userId, + nickname = this.nickname, + gender = this.gender, + birthDay = this.birthDay, + introduction = this.introduction, + profileImage = this.profileImage, + ) +} diff --git a/src/main/kotlin/com/routebox/routebox/domain/common/FileEntity.kt b/src/main/kotlin/com/routebox/routebox/domain/common/FileEntity.kt index 6509d4c..998cfd3 100644 --- a/src/main/kotlin/com/routebox/routebox/domain/common/FileEntity.kt +++ b/src/main/kotlin/com/routebox/routebox/domain/common/FileEntity.kt @@ -17,4 +17,8 @@ abstract class FileEntity protected constructor( var deletedAt: LocalDateTime? = deletedAt protected set + + fun delete() { + this.deletedAt = LocalDateTime.now() + } } diff --git a/src/main/kotlin/com/routebox/routebox/domain/common/FileManager.kt b/src/main/kotlin/com/routebox/routebox/domain/common/FileManager.kt new file mode 100644 index 0000000..b680a46 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/domain/common/FileManager.kt @@ -0,0 +1,8 @@ +package com.routebox.routebox.domain.common + +import com.routebox.routebox.domain.common.dto.FileInfo +import org.springframework.web.multipart.MultipartFile + +interface FileManager { + fun upload(file: MultipartFile, uploadPath: String): FileInfo +} diff --git a/src/main/kotlin/com/routebox/routebox/domain/common/dto/FileInfo.kt b/src/main/kotlin/com/routebox/routebox/domain/common/dto/FileInfo.kt new file mode 100644 index 0000000..927b163 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/domain/common/dto/FileInfo.kt @@ -0,0 +1,6 @@ +package com.routebox.routebox.domain.common.dto + +data class FileInfo( + val storedFileName: String, + val fileUrl: String, +) diff --git a/src/main/kotlin/com/routebox/routebox/domain/user/User.kt b/src/main/kotlin/com/routebox/routebox/domain/user/User.kt index ce03135..8d7ca9a 100644 --- a/src/main/kotlin/com/routebox/routebox/domain/user/User.kt +++ b/src/main/kotlin/com/routebox/routebox/domain/user/User.kt @@ -95,4 +95,8 @@ class User( fun updateIntroduction(introduction: String) { this.introduction = introduction } + + fun updateProfileImageUrl(profileImageUrl: String) { + this.profileImageUrl = profileImageUrl + } } diff --git a/src/main/kotlin/com/routebox/routebox/domain/user/UserProfileImage.kt b/src/main/kotlin/com/routebox/routebox/domain/user/UserProfileImage.kt index 21d508f..3c0237c 100644 --- a/src/main/kotlin/com/routebox/routebox/domain/user/UserProfileImage.kt +++ b/src/main/kotlin/com/routebox/routebox/domain/user/UserProfileImage.kt @@ -2,17 +2,18 @@ package com.routebox.routebox.domain.user import com.routebox.routebox.domain.common.FileEntity import jakarta.persistence.Column +import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id +@Entity class UserProfileImage( userId: Long, storedFileName: String, fileUrl: String, id: Long = 0, ) : FileEntity(storedFileName, fileUrl) { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_profile_image_id") diff --git a/src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt b/src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt index f506d11..88c8243 100644 --- a/src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt +++ b/src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt @@ -1,18 +1,29 @@ package com.routebox.routebox.domain.user +import com.routebox.routebox.domain.common.FileManager import com.routebox.routebox.domain.user.constant.Gender import com.routebox.routebox.domain.user.constant.LoginType import com.routebox.routebox.exception.user.UserNicknameDuplicationException import com.routebox.routebox.exception.user.UserNotFoundException import com.routebox.routebox.exception.user.UserSocialLoginUidDuplicationException +import com.routebox.routebox.infrastructure.user.UserProfileImageRepository import com.routebox.routebox.infrastructure.user.UserRepository import org.apache.commons.lang3.RandomStringUtils import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile import java.time.LocalDate @Service -class UserService(private val userRepository: UserRepository) { +class UserService( + private val userRepository: UserRepository, + private val userProfileImageRepository: UserProfileImageRepository, + private val fileManager: FileManager, +) { + companion object { + const val USER_PROFILE_IMAGE_UPLOAD_PATH = "user-profile-images/" + } + /** * Id(PK)로 유저를 조회한다. * @@ -87,6 +98,7 @@ class UserService(private val userRepository: UserRepository) { gender: Gender? = null, birthDay: LocalDate? = null, introduction: String? = null, + profileImage: MultipartFile? = null, ): User { val user = getUserById(id) @@ -99,6 +111,20 @@ class UserService(private val userRepository: UserRepository) { gender?.let { user.updateGender(it) } birthDay?.let { user.updateBirthDay(it) } introduction?.let { user.updateIntroduction(it) } + profileImage?.let { image -> + val currentUserProfileImage = userProfileImageRepository.findByUserId(userId = id) + currentUserProfileImage?.delete() + + val (profileImageName, profileImageUrl) = fileManager.upload(image, USER_PROFILE_IMAGE_UPLOAD_PATH) + userProfileImageRepository.save( + UserProfileImage( + userId = id, + storedFileName = profileImageName, + fileUrl = profileImageUrl, + ), + ) + user.updateProfileImageUrl(profileImageUrl) + } return user } diff --git a/src/main/kotlin/com/routebox/routebox/infrastructure/common/AmazonS3FileManager.kt b/src/main/kotlin/com/routebox/routebox/infrastructure/common/AmazonS3FileManager.kt new file mode 100644 index 0000000..0de17d4 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/infrastructure/common/AmazonS3FileManager.kt @@ -0,0 +1,53 @@ +package com.routebox.routebox.infrastructure.common + +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.model.CannedAccessControlList +import com.amazonaws.services.s3.model.ObjectMetadata +import com.amazonaws.services.s3.model.PutObjectRequest +import com.routebox.routebox.domain.common.FileManager +import com.routebox.routebox.domain.common.dto.FileInfo +import com.routebox.routebox.properties.AwsProperties +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile +import java.util.UUID + +@Component +class AmazonS3FileManager( + private val s3Client: AmazonS3, + private val awsProperties: AwsProperties, +) : FileManager { + override fun upload(file: MultipartFile, uploadPath: String): FileInfo { + val originalFileName = file.originalFilename ?: "" + val storedFileName = generateStoredFileName(originalFileName, uploadPath) + + s3Client.putObject( + PutObjectRequest( + awsProperties.s3.bucketName, + storedFileName, + file.inputStream, + generateObjectMetadata(file), + ).withCannedAcl(CannedAccessControlList.PublicRead), + ) + val fileUrl = s3Client.getUrl(awsProperties.s3.bucketName, storedFileName).toString() + + return FileInfo(storedFileName, fileUrl) + } + + private fun generateStoredFileName(originalFileName: String, uploadPath: String): String { + val uuid = UUID.randomUUID().toString() + + val posOfExtension = originalFileName.lastIndexOf(".") + if (posOfExtension == -1) { + return uploadPath + uuid + } + + val extension = originalFileName.substring(posOfExtension + 1) + return "$uploadPath$uuid.$extension" + } + + private fun generateObjectMetadata(file: MultipartFile) = + ObjectMetadata().apply { + this.contentType = file.contentType + this.contentLength = file.size + } +} diff --git a/src/main/kotlin/com/routebox/routebox/infrastructure/config/AmazonS3Config.kt b/src/main/kotlin/com/routebox/routebox/infrastructure/config/AmazonS3Config.kt index b591e12..cc6e163 100644 --- a/src/main/kotlin/com/routebox/routebox/infrastructure/config/AmazonS3Config.kt +++ b/src/main/kotlin/com/routebox/routebox/infrastructure/config/AmazonS3Config.kt @@ -5,12 +5,12 @@ import com.amazonaws.auth.BasicAWSCredentials import com.amazonaws.regions.Regions import com.amazonaws.services.s3.AmazonS3 import com.amazonaws.services.s3.AmazonS3ClientBuilder -import com.routebox.routebox.properties.AWSProperties +import com.routebox.routebox.properties.AwsProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration -class AmazonS3Config(private val awsProperties: AWSProperties) { +class AmazonS3Config(private val awsProperties: AwsProperties) { @Bean fun amazonS3Client(): AmazonS3 = AmazonS3ClientBuilder.standard() diff --git a/src/main/kotlin/com/routebox/routebox/infrastructure/user/UserProfileImageRepository.kt b/src/main/kotlin/com/routebox/routebox/infrastructure/user/UserProfileImageRepository.kt new file mode 100644 index 0000000..2ba5cc4 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/infrastructure/user/UserProfileImageRepository.kt @@ -0,0 +1,15 @@ +package com.routebox.routebox.infrastructure.user + +import com.routebox.routebox.domain.user.UserProfileImage +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import java.util.Optional + +// UserProfileImage의 경우 soft delete가 적용되어 있으므로 query 작성 시 이를 고려해야 한다. +interface UserProfileImageRepository : JpaRepository { + @Query("SELECT upi FROM UserProfileImage upi WHERE upi.deletedAt != null") + override fun findById(id: Long): Optional + + @Query("SELECT upi FROM UserProfileImage upi WHERE upi.deletedAt != null AND upi.userId = :userId") + fun findByUserId(userId: Long): UserProfileImage? +} diff --git a/src/main/kotlin/com/routebox/routebox/properties/AwsProperties.kt b/src/main/kotlin/com/routebox/routebox/properties/AwsProperties.kt index 50785c5..5ed57cf 100644 --- a/src/main/kotlin/com/routebox/routebox/properties/AwsProperties.kt +++ b/src/main/kotlin/com/routebox/routebox/properties/AwsProperties.kt @@ -3,7 +3,7 @@ package com.routebox.routebox.properties import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties("aws") -data class AWSProperties(val s3: S3) { +data class AwsProperties(val s3: S3) { data class S3( val accessKey: String, val secretKey: String, diff --git a/src/test/kotlin/com/routebox/routebox/application/user/UpdateUserInfoUseCaseTest.kt b/src/test/kotlin/com/routebox/routebox/application/user/UpdateUserInfoUseCaseTest.kt index 418f486..ade3044 100644 --- a/src/test/kotlin/com/routebox/routebox/application/user/UpdateUserInfoUseCaseTest.kt +++ b/src/test/kotlin/com/routebox/routebox/application/user/UpdateUserInfoUseCaseTest.kt @@ -13,6 +13,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.given import org.mockito.kotlin.then +import org.springframework.mock.web.MockMultipartFile import java.time.LocalDate import kotlin.random.Random import kotlin.test.Test @@ -34,6 +35,7 @@ class UpdateUserInfoUseCaseTest { gender = Gender.MALE, birthDay = LocalDate.now(), introduction = RandomStringUtils.random(25, true, true), + profileImage = createMockImageFile(), ) val expectedResult = createUser( id = command.id, @@ -49,6 +51,7 @@ class UpdateUserInfoUseCaseTest { gender = command.gender, birthDay = command.birthDay, introduction = command.introduction, + profileImage = command.profileImage, ), ).willReturn(expectedResult) @@ -56,8 +59,14 @@ class UpdateUserInfoUseCaseTest { val actualResult = sut.invoke(command) // then - then(userService).should() - .updateUser(command.id, command.nickname, command.gender, command.birthDay, command.introduction) + then(userService).should().updateUser( + command.id, + command.nickname, + command.gender, + command.birthDay, + command.introduction, + command.profileImage, + ) verifyEveryMocksShouldHaveNoMoreInteractions() assertThat(actualResult.id).isEqualTo(expectedResult.id) assertThat(actualResult.nickname).isEqualTo(expectedResult.nickname) @@ -70,6 +79,13 @@ class UpdateUserInfoUseCaseTest { then(userService).shouldHaveNoMoreInteractions() } + private fun createMockImageFile() = MockMultipartFile( + "file", + "newImage.jpg", + "image/jpeg", + "new image content".toByteArray(), + ) + private fun createUser( id: Long, nickname: String, diff --git a/src/test/kotlin/com/routebox/routebox/controller/user/UserControllerTest.kt b/src/test/kotlin/com/routebox/routebox/controller/user/UserControllerTest.kt index 5bb30e1..17caabb 100644 --- a/src/test/kotlin/com/routebox/routebox/controller/user/UserControllerTest.kt +++ b/src/test/kotlin/com/routebox/routebox/controller/user/UserControllerTest.kt @@ -5,7 +5,6 @@ import com.routebox.routebox.application.user.CheckNicknameAvailabilityUseCase import com.routebox.routebox.application.user.GetUserProfileUseCase import com.routebox.routebox.application.user.UpdateUserInfoUseCase import com.routebox.routebox.application.user.dto.GetUserProfileResult -import com.routebox.routebox.application.user.dto.UpdateUserInfoCommand import com.routebox.routebox.application.user.dto.UpdateUserInfoResult import com.routebox.routebox.config.ControllerTestConfig import com.routebox.routebox.controller.user.dto.UpdateUserInfoRequest @@ -13,17 +12,19 @@ import com.routebox.routebox.domain.user.constant.Gender import com.routebox.routebox.domain.user.constant.UserRoleType import com.routebox.routebox.security.UserPrincipal import org.apache.commons.lang3.RandomStringUtils +import org.mockito.kotlin.any import org.mockito.kotlin.given import org.mockito.kotlin.then import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.annotation.Import -import org.springframework.http.MediaType +import org.springframework.http.HttpMethod +import org.springframework.mock.web.MockMultipartFile import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.LocalDate @@ -129,13 +130,7 @@ class UserControllerTest @Autowired constructor( gender = Gender.MALE, birthDay = LocalDate.now(), introduction = RandomStringUtils.random(25, true, true), - ) - val command = UpdateUserInfoCommand( - id = userId, - nickname = request.nickname, - gender = request.gender, - birthDay = request.birthDay, - introduction = request.introduction, + profileImage = createMockImageFile(), ) val expectedResult = UpdateUserInfoResult( id = userId, @@ -146,20 +141,23 @@ class UserControllerTest @Autowired constructor( introduction = request.introduction!!, point = Random.nextInt(), ) - given(updateUserInfoUseCase.invoke(command)).willReturn(expectedResult) + given(updateUserInfoUseCase.invoke(any())).willReturn(expectedResult) // when & then mvc.perform( - patch("/api/v1/users/me") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) + multipart(HttpMethod.PATCH, "/api/v1/users/me") + .file("profileImage", request.profileImage!!.bytes) + .param("nickname", request.nickname) + .param("gender", request.gender!!.name) + .param("birthDay", request.birthDay.toString()) + .param("introduction", request.introduction) .with(user(createUserPrincipal(userId))), ).andExpect(status().isOk) .andExpect(jsonPath("$.id").value(expectedResult.id)) .andExpect(jsonPath("$.nickname").value(expectedResult.nickname)) .andExpect(jsonPath("$.gender").value(expectedResult.gender.toString())) .andExpect(jsonPath("$.birthDay").value(expectedResult.birthDay.toString())) - then(updateUserInfoUseCase).should().invoke(command) + then(updateUserInfoUseCase).should().invoke(any()) verifyEveryMocksShouldHaveNoMoreInteractions() } @@ -168,6 +166,13 @@ class UserControllerTest @Autowired constructor( then(checkNicknameAvailabilityUseCase).shouldHaveNoMoreInteractions() } + private fun createMockImageFile() = MockMultipartFile( + "file", + "newImage.jpg", + "image/jpeg", + "new image content".toByteArray(), + ) + private fun createUserPrincipal(userId: Long) = UserPrincipal( userId = userId, socialLoginUid = userId.toString(), diff --git a/src/test/kotlin/com/routebox/routebox/domain/user/UserServiceTest.kt b/src/test/kotlin/com/routebox/routebox/domain/user/UserServiceTest.kt index 0011052..7319638 100644 --- a/src/test/kotlin/com/routebox/routebox/domain/user/UserServiceTest.kt +++ b/src/test/kotlin/com/routebox/routebox/domain/user/UserServiceTest.kt @@ -1,10 +1,13 @@ package com.routebox.routebox.domain.user +import com.routebox.routebox.domain.common.FileManager +import com.routebox.routebox.domain.common.dto.FileInfo import com.routebox.routebox.domain.user.constant.Gender import com.routebox.routebox.domain.user.constant.LoginType import com.routebox.routebox.exception.user.UserNicknameDuplicationException import com.routebox.routebox.exception.user.UserNotFoundException import com.routebox.routebox.exception.user.UserSocialLoginUidDuplicationException +import com.routebox.routebox.infrastructure.user.UserProfileImageRepository import com.routebox.routebox.infrastructure.user.UserRepository import org.apache.commons.lang3.RandomStringUtils import org.assertj.core.api.Assertions.assertThat @@ -20,6 +23,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.given import org.mockito.kotlin.then import org.mockito.kotlin.times +import org.springframework.mock.web.MockMultipartFile import java.time.LocalDate import java.util.Optional import kotlin.random.Random @@ -34,6 +38,12 @@ class UserServiceTest { @Mock private lateinit var userRepository: UserRepository + @Mock + private lateinit var userProfileImageRepository: UserProfileImageRepository + + @Mock + private lateinit var fileManager: FileManager + @Test fun `id가 주어지고, 주어진 id로 유저를 조회한다`() { // given @@ -231,10 +241,69 @@ class UserServiceTest { assertThat(result.introduction).isEqualTo(newIntroduction) } + @Test + fun `유저 id와 변경하고자 하는 프로필 이미지가 주어지고, 유저 프로필 이미지를 변경하면, 기존 프로필 이미지가 삭제되고 변경된 유저 정보가 반환된다`() { + // given + val userId = Random.nextLong() + val newProfileImage = createMockImageFile() + val imageExpectedToUploaded = FileInfo(storedFileName = "new-image.jpeg", fileUrl = "https://new-image") + given(userRepository.findById(userId)).willReturn(Optional.of(createUser(userId))) + given(userProfileImageRepository.findByUserId(userId)).willReturn(createUserProfileImage(userId)) + given(fileManager.upload(newProfileImage, UserService.USER_PROFILE_IMAGE_UPLOAD_PATH)) + .willReturn(imageExpectedToUploaded) + given(userProfileImageRepository.save(any(UserProfileImage::class.java))) + .willReturn(createUserProfileImage(userId)) + + // when + val result = sut.updateUser(id = userId, profileImage = newProfileImage) + + // then + then(userRepository).should().findById(userId) + then(userProfileImageRepository).should().findByUserId(userId) + then(fileManager).should().upload(newProfileImage, UserService.USER_PROFILE_IMAGE_UPLOAD_PATH) + then(userProfileImageRepository).should().save(any(UserProfileImage::class.java)) + verifyEveryMocksShouldHaveNoMoreInteractions() + assertThat(result.profileImageUrl).isEqualTo(imageExpectedToUploaded.fileUrl) + } + + @Test + fun `유저 id와 변경하고자 하는 프로필 이미지가 주어지고, 유저 프로필 이미지를 변경하면, 변경된 유저 정보가 반환된다`() { + // given + val userId = Random.nextLong() + val newProfileImage = createMockImageFile() + val imageExpectedToUploaded = FileInfo(storedFileName = "new-image.jpeg", fileUrl = "https://new-image") + given(userRepository.findById(userId)).willReturn(Optional.of(createUser(userId))) + given(userProfileImageRepository.findByUserId(userId)).willReturn(null) + given(fileManager.upload(newProfileImage, UserService.USER_PROFILE_IMAGE_UPLOAD_PATH)) + .willReturn(imageExpectedToUploaded) + given(userProfileImageRepository.save(any(UserProfileImage::class.java))) + .willReturn(createUserProfileImage(userId)) + + // when + val result = sut.updateUser(id = userId, profileImage = newProfileImage) + + // then + then(userRepository).should().findById(userId) + then(userProfileImageRepository).should().findByUserId(userId) + then(fileManager).should().upload(newProfileImage, UserService.USER_PROFILE_IMAGE_UPLOAD_PATH) + then(userProfileImageRepository).should().save(any(UserProfileImage::class.java)) + verifyEveryMocksShouldHaveNoMoreInteractions() + assertThat(result.profileImageUrl).isEqualTo(imageExpectedToUploaded.fileUrl) + } + private fun verifyEveryMocksShouldHaveNoMoreInteractions() { then(userRepository).shouldHaveNoMoreInteractions() + then(userProfileImageRepository).shouldHaveNoMoreInteractions() + then(fileManager).shouldHaveNoMoreInteractions() } + private fun createMockImageFile() = MockMultipartFile( + "file", + "newImage.jpg", + "image/jpeg", + "new image content".toByteArray(), + ) + private fun createUser(id: Long) = User( id = id, loginType = LoginType.KAKAO, @@ -243,4 +312,11 @@ class UserServiceTest { gender = Gender.PRIVATE, birthDay = LocalDate.of(2024, 1, 1), ) + + private fun createUserProfileImage(userId: Long) = UserProfileImage( + id = Random.nextLong(), + userId = userId, + storedFileName = "profileImage", + fileUrl = "https://profile-image", + ) }