Skip to content

Commit

Permalink
Merge pull request #43 from Route-Box/feature/#29
Browse files Browse the repository at this point in the history
내 정보 수정 API - 유저 프로필 이미지 수정 기능 추가
  • Loading branch information
Wo-ogie authored Aug 15, 2024
2 parents 29cec27 + 8756ff5 commit fcd1562
Show file tree
Hide file tree
Showing 20 changed files with 327 additions and 54 deletions.
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class UpdateUserInfoUseCase(
gender = command.gender,
birthDay = command.birthDay,
introduction = command.introduction,
profileImage = command.profileImage,
)
return UpdateUserInfoResult.from(updatedUser)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -9,4 +10,5 @@ data class UpdateUserInfoCommand(
val gender: Gender?,
val birthDay: LocalDate?,
val introduction: String?,
val profileImage: MultipartFile?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -94,7 +94,8 @@ class UserController(
@Operation(
summary = "내 정보 수정",
description = "<p>내 정보(닉네임, 성별, 생일 등)를 수정합니다." +
"<p>Request body를 통해 전달된 항목들만 수정되며, request body에 존재하지 않거나 <code>null</code>로 설정된 항목들은 수정되지 않습니다.",
"<p>요청 시 content-type은 <code>multipart/form-data</code>로 설정하여 요청해야 합니다." +
"<p>Request body를 통해 전달된 항목들만 수정되며, 요청 데이터에 존재하지 않거나 <code>null</code>로 설정된 항목들은 수정되지 않습니다.",
security = [SecurityRequirement(name = "access-token")],
)
@ApiResponses(
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ abstract class FileEntity protected constructor(

var deletedAt: LocalDateTime? = deletedAt
protected set

fun delete() {
this.deletedAt = LocalDateTime.now()
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.routebox.routebox.domain.common.dto

data class FileInfo(
val storedFileName: String,
val fileUrl: String,
)
4 changes: 4 additions & 0 deletions src/main/kotlin/com/routebox/routebox/domain/user/User.kt
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,8 @@ class User(
fun updateIntroduction(introduction: String) {
this.introduction = introduction
}

fun updateProfileImageUrl(profileImageUrl: String) {
this.profileImageUrl = profileImageUrl
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
28 changes: 27 additions & 1 deletion src/main/kotlin/com/routebox/routebox/domain/user/UserService.kt
Original file line number Diff line number Diff line change
@@ -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)로 유저를 조회한다.
*
Expand Down Expand Up @@ -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)

Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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을 다시 확인해주세요.")
}
Original file line number Diff line number Diff line change
@@ -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<UserProfileImage, Long> {
@Query("SELECT upi FROM UserProfileImage upi WHERE upi.deletedAt != null")
override fun findById(id: Long): Optional<UserProfileImage>

@Query("SELECT upi FROM UserProfileImage upi WHERE upi.deletedAt != null AND upi.userId = :userId")
fun findByUserId(userId: Long): UserProfileImage?
}
12 changes: 12 additions & 0 deletions src/main/kotlin/com/routebox/routebox/properties/AwsProperties.kt
Original file line number Diff line number Diff line change
@@ -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,
)
}
6 changes: 6 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading

0 comments on commit fcd1562

Please sign in to comment.