Skip to content

Commit

Permalink
feature: 상품입양 후기 작성 api (#127)
Browse files Browse the repository at this point in the history
* build: s3 sdk 추가

* feat: s3 config 추가

* feat: ImageType 추가

* feat: yaml s3 이미지 파일 업로드 관련 설정 추가

* feat: ImageStorageService 추가

* feat: ProductReviewContent 추가

* feat: ProductReviewContent 추가 반영

* feat: ImageType 추가

* test: S3 가짜 객체 추가

* feat: ProductReviewFacadeService 후기 작성 기능 추가

* feat: ProductReviewController 후기 작성 api 추가

* feat: swagger 요청 처리를 위한 converter 추가

* refactor: 사용하지 않는 코드 삭제

* feat: swagger api의 파라미터 설명 추가

* style: 주석 추가

* refactor: 이미지 업로드 시 @ModelAttribute 를 사용하도록 변경

* refactor: ImageType 패키지 및 구현 코드 변경

* refactor: UrlConverter 에게 url 변환 로직 위임

* refactor: 이미지 업로드 시 이름 표기 방식 변경
  • Loading branch information
Combi153 authored May 3, 2024
1 parent 1390f7f commit 35f447a
Show file tree
Hide file tree
Showing 29 changed files with 1,853 additions and 27 deletions.
2 changes: 1 addition & 1 deletion backend-submodule
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ dependencies {
implementation("ch.qos.logback:logback-access")
implementation("org.codehaus.janino:janino:3.1.6")

// s3
implementation("com.amazonaws:aws-java-sdk-s3:1.12.693")

runtimeOnly("com.mysql:mysql-connector-j")
runtimeOnly("com.h2database:h2")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.petqua.application.image

import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.model.ObjectMetadata
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile

@Service
class ImageStorageService(
private val amazonS3: AmazonS3,
private val urlConverter: UrlConverter,

@Value("\${cloud.aws.s3.bucket}")
private val bucket: String,
) {

fun upload(path: String, image: MultipartFile): String {
val metadata = ObjectMetadata()
metadata.contentType = image.contentType
metadata.contentLength = image.size

amazonS3.putObject(bucket, path, image.inputStream, metadata)

val storedUrl = amazonS3.getUrl(bucket, path).toString()
return urlConverter.convertToAccessibleUrl(path, storedUrl)
}
}
26 changes: 26 additions & 0 deletions src/main/kotlin/com/petqua/application/image/UrlConverter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.petqua.application.image

import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component

@Component
class UrlConverter(
@Value("\${image.common.domain}")
private val domain: String,
) {

fun convertToAccessibleUrl(filePath: String, storedUrl: String): String {
/*
* example)
* filePath = "root/directory/image.jpeg"
* storedUrl = "https://storedUrl.com/root/directory/image.jpeg"
*
* pathIndex = 21
* storedPath = "/root/directory/image.jpeg"
* return "https://domain.com/root/directory/image.jpeg"
* */
val pathIndex = storedUrl.indexOf("/$filePath")
val storedPath = storedUrl.substring(pathIndex)
return "$domain$storedPath"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,33 @@ import com.petqua.common.domain.dto.PAGING_LIMIT_CEILING
import com.petqua.domain.auth.LoginMemberOrGuest
import com.petqua.domain.product.dto.ProductReviewReadCondition
import com.petqua.domain.product.dto.ProductReviewWithMemberResponse
import com.petqua.domain.product.review.ProductReview
import com.petqua.domain.product.review.ProductReviewSorter
import com.petqua.domain.product.review.ProductReviewSorter.REVIEW_DATE_DESC
import com.petqua.domain.product.review.ProductReviewStatistics
import io.swagger.v3.oas.annotations.media.Schema
import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime

data class ProductReviewCreateCommand(
val memberId: Long,
val productId: Long,
val score: Int,
val content: String,
val images: List<MultipartFile>,
) {

fun toProductReview(): ProductReview {
return ProductReview.of(
memberId = memberId,
productId = productId,
score = score,
content = content,
images = images,
)
}
}

data class ProductReviewReadQuery(
val productId: Long,
val loginMemberOrGuest: LoginMemberOrGuest,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.petqua.application.product.review

import com.petqua.application.product.dto.ProductReviewCreateCommand
import com.petqua.application.product.dto.ProductReviewReadQuery
import com.petqua.application.product.dto.ProductReviewStatisticsResponse
import com.petqua.application.product.dto.ProductReviewsResponse
import com.petqua.application.product.dto.UpdateReviewRecommendationCommand
import org.springframework.stereotype.Service

@Service
class ProductReviewFacadeService(
private val productReviewService: ProductReviewService,
private val productReviewImageUploader: ProductReviewImageUploader,
) {

fun create(command: ProductReviewCreateCommand): Long {
val productReview = command.toProductReview()
val reviewImageUrls = productReviewImageUploader.uploadAll(command.images)
return productReviewService.create(productReview, reviewImageUrls)
}

fun readAll(query: ProductReviewReadQuery): ProductReviewsResponse {
return productReviewService.readAll(query)
}

fun readReviewCountStatistics(productId: Long): ProductReviewStatisticsResponse {
return productReviewService.readReviewCountStatistics(productId)
}

fun updateReviewRecommendation(command: UpdateReviewRecommendationCommand) {
productReviewService.updateReviewRecommendation(command)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.petqua.application.product.review

import com.petqua.application.image.ImageStorageService
import com.petqua.domain.product.review.ProductReviewImageType
import com.petqua.exception.product.review.ProductReviewException
import com.petqua.exception.product.review.ProductReviewExceptionType.FAILED_REVIEW_IMAGE_UPLOAD
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.multipart.MultipartFile
import java.util.UUID

@Component
class ProductReviewImageUploader(
private val imageStorageService: ImageStorageService,

@Value("\${image.product-review.directory}")
private val directory: String,
) {

fun uploadAll(images: List<MultipartFile>): List<String> {
if (images.isEmpty()) {
return listOf()
}
return images.map { upload(it) }
}

private fun upload(image: MultipartFile): String {
ProductReviewImageType.validateSupported(image.contentType)
val fileName = UUID.randomUUID()
val path = "$directory$fileName${parseFileExtension(image)}"

return uploadOrThrow(path, image)
}

private fun parseFileExtension(image: MultipartFile): String {
return image.originalFilename?.let {
".${it.substringAfterLast(FILE_EXTENSION_DELIMITER)}"
} ?: EMPTY_EXTENSION
}

private fun uploadOrThrow(path: String, image: MultipartFile): String {
try {
return imageStorageService.upload(path, image)
} catch (e: Exception) {
throw ProductReviewException(FAILED_REVIEW_IMAGE_UPLOAD)
}
}

companion object {
private const val FILE_EXTENSION_DELIMITER = '.'
private const val EMPTY_EXTENSION = ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import com.petqua.application.product.dto.ProductReviewsResponse
import com.petqua.application.product.dto.UpdateReviewRecommendationCommand
import com.petqua.common.domain.findByIdOrThrow
import com.petqua.domain.product.dto.ProductReviewWithMemberResponse
import com.petqua.domain.product.review.ProductReview
import com.petqua.domain.product.review.ProductReviewImage
import com.petqua.domain.product.review.ProductReviewImageRepository
import com.petqua.domain.product.review.ProductReviewRecommendation
import com.petqua.domain.product.review.ProductReviewRecommendationRepository
Expand All @@ -25,6 +27,15 @@ class ProductReviewService(
private val productReviewRecommendationRepository: ProductReviewRecommendationRepository,
) {

fun create(productReview: ProductReview, reviewImageUrls: List<String>): Long {
val savedProductReview = productReviewRepository.save(productReview)
val images = reviewImageUrls.map {
ProductReviewImage(imageUrl = it, productReviewId = savedProductReview.id)
}
productReviewImageRepository.saveAll(images)
return savedProductReview.id
}

@Transactional(readOnly = true)
fun readAll(query: ProductReviewReadQuery): ProductReviewsResponse {
val reviewsByCondition = productReviewRepository.findAllByCondition(
Expand Down Expand Up @@ -65,10 +76,10 @@ class ProductReviewService(
productReviewRecommendationRepository.findByProductReviewIdAndMemberId(
command.productReviewId,
command.memberId,
)?.let { delete(it) } ?: save(command.toReviewRecommendation())
)?.let { delete(it) } ?: saveReviewRecommendation(command.toReviewRecommendation())
}

private fun save(productReviewRecommendation: ProductReviewRecommendation) {
private fun saveReviewRecommendation(productReviewRecommendation: ProductReviewRecommendation) {
productReviewRecommendationRepository.save(productReviewRecommendation)
val productReview = productReviewRepository.findByIdOrThrow(productReviewRecommendation.productReviewId) {
ProductReviewException(NOT_FOUND_PRODUCT_REVIEW)
Expand Down
12 changes: 6 additions & 6 deletions src/main/kotlin/com/petqua/common/config/DataInitializer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import com.petqua.domain.keyword.ProductKeywordRepository
import com.petqua.domain.member.FishLifeYear
import com.petqua.domain.member.Member
import com.petqua.domain.member.MemberRepository
import com.petqua.domain.notification.Notification
import com.petqua.domain.notification.NotificationRepository
import com.petqua.domain.member.nickname.Nickname
import com.petqua.domain.member.nickname.NicknameWord
import com.petqua.domain.member.nickname.NicknameWordRepository
import com.petqua.domain.notification.Notification
import com.petqua.domain.notification.NotificationRepository
import com.petqua.domain.order.ShippingAddress
import com.petqua.domain.order.ShippingAddressRepository
import com.petqua.domain.policy.bannedword.BannedWord
Expand Down Expand Up @@ -55,6 +55,7 @@ import com.petqua.domain.product.option.Sex.FEMALE
import com.petqua.domain.product.option.Sex.HERMAPHRODITE
import com.petqua.domain.product.option.Sex.MALE
import com.petqua.domain.product.review.ProductReview
import com.petqua.domain.product.review.ProductReviewContent
import com.petqua.domain.product.review.ProductReviewImage
import com.petqua.domain.product.review.ProductReviewImageRepository
import com.petqua.domain.product.review.ProductReviewRepository
Expand All @@ -63,14 +64,13 @@ import com.petqua.domain.recommendation.ProductRecommendation
import com.petqua.domain.recommendation.ProductRecommendationRepository
import com.petqua.domain.store.Store
import com.petqua.domain.store.StoreRepository
import java.math.BigDecimal
import java.time.LocalDateTime
import kotlin.random.Random
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.annotation.Profile
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import kotlin.random.Random

@Component
@Profile("local", "prod")
Expand Down Expand Up @@ -387,7 +387,7 @@ class DataInitializer(
ProductReview(
productId = product.id,
memberId = memberId,
content = "좋아요 ${product.name}",
content = ProductReviewContent("진짜 좋아요 ${product.name} 꼭 다시 살 거예요"),
score = ProductReviewScore(it % 5 + 1),
hasPhotos = hasPhotos
)
Expand Down
22 changes: 22 additions & 0 deletions src/main/kotlin/com/petqua/common/config/S3Config.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.petqua.common.config

import com.amazonaws.auth.EC2ContainerCredentialsProviderWrapper
import com.amazonaws.regions.Regions.AP_NORTHEAST_2
import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.AmazonS3ClientBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile

@Configuration
@Profile("!test")
class S3Config {

@Bean
fun amazonS3(): AmazonS3 {
return AmazonS3ClientBuilder.standard()
.withRegion(AP_NORTHEAST_2)
.withCredentials(EC2ContainerCredentialsProviderWrapper())
.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.petqua.common.converter

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.MediaType
import org.springframework.http.MediaType.APPLICATION_OCTET_STREAM
import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter
import org.springframework.stereotype.Component
import java.lang.reflect.Type

@Component
class MultipartJackson2HttpMessageConverter(objectMapper: ObjectMapper) :
AbstractJackson2HttpMessageConverter(objectMapper, APPLICATION_OCTET_STREAM) {

override fun canWrite(clazz: Class<*>, mediaType: MediaType?): Boolean {
return false
}

override fun canWrite(type: Type?, clazz: Class<*>, mediaType: MediaType?): Boolean {
return false
}

override fun canWrite(mediaType: MediaType?): Boolean {
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ data class ProductReviewWithMemberResponse(
id = productReview.id,
productId = productReview.productId,
score = productReview.score.value,
content = productReview.content,
content = productReview.content.value,
createdAt = productReview.createdAt,
hasPhotos = productReview.hasPhotos,
recommendCount = productReview.recommendCount,
Expand Down
32 changes: 30 additions & 2 deletions src/main/kotlin/com/petqua/domain/product/review/ProductReview.kt
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
package com.petqua.domain.product.review

import com.petqua.common.domain.BaseEntity
import com.petqua.common.util.throwExceptionWhen
import com.petqua.exception.product.review.ProductReviewException
import com.petqua.exception.product.review.ProductReviewExceptionType.EXCEEDED_REVIEW_IMAGES_COUNT_LIMIT
import jakarta.persistence.AttributeOverride
import jakarta.persistence.Column
import jakarta.persistence.Embedded
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import org.springframework.web.multipart.MultipartFile

@Entity
class ProductReview(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L,

@Column(nullable = false)
val content: String,
@Embedded
@AttributeOverride(name = "value", column = Column(name = "content"))
val content: ProductReviewContent,

@Column(nullable = false)
val productId: Long,
Expand All @@ -41,4 +46,27 @@ class ProductReview(
fun decreaseRecommendCount() {
recommendCount -= 1
}

companion object {
private const val MAX_REVIEW_IMAGES_COUNT = 10

fun of(
memberId: Long,
productId: Long,
content: String,
score: Int,
images: List<MultipartFile>,
): ProductReview {
throwExceptionWhen(images.size > MAX_REVIEW_IMAGES_COUNT) {
ProductReviewException(EXCEEDED_REVIEW_IMAGES_COUNT_LIMIT)
}
return ProductReview(
memberId = memberId,
productId = productId,
content = ProductReviewContent(content),
score = ProductReviewScore(score),
hasPhotos = images.isNotEmpty()
)
}
}
}
Loading

0 comments on commit 35f447a

Please sign in to comment.