Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/7 : 상품 등록 api #21

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
implementation("com.amazonaws:aws-java-sdk-s3:1.12.773")
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/com/get_offer/common/AuditingTimeEntity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener
@MappedSuperclass
abstract class AuditingTimeEntity {
@CreatedDate
private var createdAt: LocalDateTime = LocalDateTime.now()
val createdAt: LocalDateTime = LocalDateTime.now()

@LastModifiedDate
private var updatedAt: LocalDateTime = LocalDateTime.now()
var updatedAt: LocalDateTime = LocalDateTime.now()
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ package com.get_offer.common.exception
* custom exception 처리를 위해 남겨 놓았습니다.
*/
class NotFoundException(override val message: String) : RuntimeException(message)
class UnAuthorizationException : RuntimeException()
class UnAuthorizationException : RuntimeException()

class UnsupportedFileExtensionException : RuntimeException()
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ class ExceptionControllerAdvice {
fun handleUnAuthorizationException(ex: UnAuthorizationException): ResponseEntity<ApiResponse<Any>> {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("인가되지 않은 사용자입니다"))
}

@ExceptionHandler
fun handleUnsupportedFileExtensionException(ex: UnsupportedFileExtensionException): ResponseEntity<ApiResponse<Any>> {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(ApiResponse.error("파일의 확장자가 유효하지 않습니다."))
}
}
28 changes: 28 additions & 0 deletions src/main/kotlin/com/get_offer/common/s3/S3Config.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.get_offer.common.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
class S3Config(
@Value("\${aws.credentials.accessKey}")
private val accessKey: String,
@Value("\${aws.credentials.secretKey}")
private val secretKey: String,
) {
@Bean
fun amazonS3Client(): AmazonS3 {
val credentials = BasicAWSCredentials(accessKey, secretKey)
return AmazonS3ClientBuilder
.standard()
.withCredentials(AWSStaticCredentialsProvider(credentials))
.withRegion(Regions.AP_NORTHEAST_2)
.build()
}
}
62 changes: 62 additions & 0 deletions src/main/kotlin/com/get_offer/common/s3/S3FileManagement.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.get_offer.common.s3

import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.model.ObjectMetadata
import com.get_offer.multipart.FileValidate
import java.util.*
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.multipart.MultipartFile

@Component
class S3FileManagement(
@Value("\${aws.s3.bucket}")
private val bucket: String,
private val amazonS3: AmazonS3,
) {
companion object {
const val TYPE_IMAGE = "image"
}

fun uploadImages(multipartFiles: List<MultipartFile>): List<String> {
return multipartFiles.map { uploadImage(it) }
}

fun uploadImage(multipartFile: MultipartFile): String {
val originalFilename = multipartFile.originalFilename
?: throw IllegalStateException()
FileValidate.checkImageFormat(originalFilename)
val fileName = "${UUID.randomUUID()}-${originalFilename}"
val objectMetadata = setFileDateOption(
type = TYPE_IMAGE,
file = getFileExtension(originalFilename),
multipartFile = multipartFile
)
amazonS3.putObject(bucket, fileName, multipartFile.inputStream, objectMetadata)
return fileName
}

fun getFile(fileName: String): String {
return amazonS3.getUrl(bucket, fileName).toString()
}

fun delete(fileName: String) {
amazonS3.deleteObject(bucket, fileName)
}

private fun getFileExtension(fileName: String): String {
val extensionIndex = fileName.lastIndexOf('.')
return fileName.substring(extensionIndex + 1)
}

private fun setFileDateOption(
type: String,
file: String,
multipartFile: MultipartFile
): ObjectMetadata {
val objectMetadata = ObjectMetadata()
objectMetadata.contentType = "/${type}/${getFileExtension(file)}"
objectMetadata.contentLength = multipartFile.inputStream.available().toLong()
return objectMetadata
}
}
20 changes: 20 additions & 0 deletions src/main/kotlin/com/get_offer/multipart/FileValidate.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.get_offer.multipart

import com.get_offer.common.exception.UnsupportedFileExtensionException

class FileValidate {
companion object {
private val IMAGE_EXTENSIONS: List<String> = listOf("jpg", "png")

fun checkImageFormat(fileName: String) {
val extensionIndex = fileName.lastIndexOf('.')
if (extensionIndex == -1) {
throw UnsupportedFileExtensionException()
}
val extension = fileName.substring(extensionIndex + 1)
require(IMAGE_EXTENSIONS.contains(extension)) {
throw UnsupportedFileExtensionException()
}
}
}
}
18 changes: 18 additions & 0 deletions src/main/kotlin/com/get_offer/multipart/ImageService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.get_offer.multipart

import com.get_offer.common.s3.S3FileManagement
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile

@Service
class ImageService(
private val s3FileManagement: S3FileManagement,
) {
fun saveImages(images: List<MultipartFile>): List<String> {
return s3FileManagement.uploadImages(images)
}

fun deleteImage(imageUrl: String) {
val file = s3FileManagement.delete(imageUrl)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@ package com.get_offer.product.controller
import ApiResponse
import com.get_offer.product.service.ProductDetailDto
import com.get_offer.product.service.ProductListDto
import com.get_offer.product.service.ProductSaveDto
import com.get_offer.product.service.ProductService
import org.springframework.data.domain.PageRequest
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile

@RestController
@RequestMapping("/products")
class ProductController(
private val productService: ProductService
private val productService: ProductService,
) {
@GetMapping
fun getProductList(
Expand All @@ -33,4 +37,13 @@ class ProductController(
fun getProductDetail(@PathVariable id: String, @RequestParam userId: String): ApiResponse<ProductDetailDto> {
return ApiResponse.success(productService.getProductDetail(id.toLong(), userId.toLong()))
}

@PostMapping
fun postProduct(
@RequestParam userId: String,
@RequestPart("images") images: List<MultipartFile>,
@RequestPart productReqDto: ProductPostReqDto
): ApiResponse<ProductSaveDto> {
return ApiResponse.success(productService.postProduct(productReqDto, userId.toLong(), images))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.get_offer.product.controller

import com.get_offer.product.domain.Category
import java.time.LocalDateTime

class ProductPostReqDto(
val title: String,
val category: Category,
val description: String,
val startPrice: Int,
val startDate: LocalDateTime,
val endDate: LocalDateTime,
)
11 changes: 4 additions & 7 deletions src/main/kotlin/com/get_offer/product/domain/Product.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,9 @@ class Product(

val title: String,

@Enumerated(EnumType.STRING)
val category: Category,
@Enumerated(EnumType.STRING) val category: Category,

@Convert(converter = ProductImagesConverter::class)
@Column(name = "IMAGES")
@Convert(converter = ProductImagesConverter::class) @Column(name = "IMAGES")
val images: ProductImagesVo,

val description: String,
Expand All @@ -39,7 +37,6 @@ class Product(

var endDate: LocalDateTime,

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0L,
) : AuditingTimeEntity()
22 changes: 22 additions & 0 deletions src/main/kotlin/com/get_offer/product/service/ProductSaveDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.get_offer.product.service

import com.get_offer.product.domain.Product
import java.time.LocalDateTime

data class ProductSaveDto(
val title: String,
val id: Long,
val writerId: Long,
val createdTime: LocalDateTime
) {
companion object {
fun of(product: Product): ProductSaveDto {
return ProductSaveDto(
title = product.title,
id = product.id,
writerId = product.writerId,
createdTime = product.createdAt
)
}
}
}
56 changes: 54 additions & 2 deletions src/main/kotlin/com/get_offer/product/service/ProductService.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
package com.get_offer.product.service

import com.get_offer.common.exception.NotFoundException
import com.get_offer.multipart.ImageService
import com.get_offer.product.controller.ProductPostReqDto
import com.get_offer.product.domain.Product
import com.get_offer.product.domain.ProductImagesVo
import com.get_offer.product.domain.ProductStatus
import com.get_offer.product.repository.ProductRepository
import com.get_offer.user.repository.UserRepository
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import org.apache.coyote.BadRequestException
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile

@Service
@Transactional(readOnly = true)
class ProductService(
private val imageService: ImageService,
private val productRepository: ProductRepository,
private val userRepository: UserRepository,

) {
) {

fun getProductList(userId: Long, pageRequest: PageRequest): Page<ProductListDto> {
val productList: Page<Product> = productRepository.findAllByStatusInOrderByEndDateDesc(
Expand All @@ -30,4 +39,47 @@ class ProductService(

return ProductDetailDto.of(product, writer, userId)
}

@Transactional
fun postProduct(req: ProductPostReqDto, userId: Long, images: List<MultipartFile>): ProductSaveDto {

validateStartPrice(req.startPrice)
validateDateRange(req.startDate, req.endDate)

val imageUrls = imageService.saveImages(images)

val product = productRepository.save(
Product(
title = req.title,
category = req.category,
writerId = userId,
images = ProductImagesVo(imageUrls),
description = req.description,
startPrice = req.startPrice,
currentPrice = req.startPrice,
startDate = req.startDate,
endDate = req.endDate,
status = checkStatus(req.startDate)
)
)
return ProductSaveDto.of(product)
}

private fun validateStartPrice(startPrice: Int) {
if (startPrice < 0) {
throw BadRequestException("startPrice가 0보다 작을 수 없습니다.")
}
}

private fun validateDateRange(startDate: LocalDateTime, endDate: LocalDateTime) {
if (startDate.isAfter(endDate)) throw BadRequestException("시작 날짜가 유효하지 않습니다.")
if (ChronoUnit.DAYS.between(startDate, endDate) > 7) throw BadRequestException("경매 기간은 7일을 넘길 수 없습니다.")
}

private fun checkStatus(startDate: LocalDateTime): ProductStatus {
if (startDate.isAfter(LocalDateTime.now())) {
return ProductStatus.IN_PROGRESS
}
return ProductStatus.WAIT
}
}
8 changes: 7 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ spring:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
format_sql: true
show_sql: true
show_sql: true
aws:
s3:
bucket: get-offer-bucket
stack:
auto: false
credentials:
Loading
Loading