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/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
new file mode 100644
index 0000000..cc6e163
--- /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/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