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/8 : 이미지 추가 및 수정 #22

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
implementation platform('software.amazon.awssdk:bom:2.17.230')
implementation 'software.amazon.awssdk:s3'
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
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()

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외가 발생할 때마다 클래스를 만들 수 없으니 응답 코드, 메세지를 받는 예외 클래스를 만들어보시죠

class UnsupportedFileExtensionException : RuntimeException()
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.get_offer.common.exception

import ApiResponse
import org.apache.coyote.BadRequestException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
Expand All @@ -13,6 +14,11 @@ class ExceptionControllerAdvice {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(ex.message ?: "DEFAULT ERROR"))
}

@ExceptionHandler
fun handleBadRequestException(ex: BadRequestException): ResponseEntity<ApiResponse<Any>> {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(ex.message ?: "DEFAULT ERROR"))
}

@ExceptionHandler
fun handleNotFoundException(ex: NotFoundException): ResponseEntity<ApiResponse<Any>> {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(ex.message))
Expand All @@ -22,4 +28,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("파일의 확장자가 유효하지 않습니다."))
}
}
65 changes: 65 additions & 0 deletions src/main/kotlin/com/get_offer/common/oauth/OAuth2LoginConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.get_offer.common.oauth

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest
import org.springframework.security.oauth2.client.registration.ClientRegistration
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
import org.springframework.security.oauth2.core.AuthorizationGrantType
import org.springframework.security.oauth2.core.ClientAuthenticationMethod
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames
import org.springframework.security.web.SecurityFilterChain


@Configuration
class OAuth2LoginConfig(
@Value("\${spring.security.oauth2.client.registration.google-login.client-id}")
private val clientId: String,
@Value("\${spring.security.oauth2.client.registration.google-login.client-secret}")
private val clientSecret: String,
) {
@Bean
fun clientRegistrationRepository(): ClientRegistrationRepository {
return InMemoryClientRegistrationRepository(googleClientRegistration())
}

private fun googleClientRegistration(): ClientRegistration {
val baseUrl = "http://localhost:8080"
val registrationId = "google"
return ClientRegistration.withRegistrationId("google").clientId(clientId).clientSecret(clientSecret)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("${baseUrl}/login/oauth2/code/${registrationId}")
.scope("openid", "profile", "email", "address", "phone")
.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
.tokenUri("https://www.googleapis.com/oauth2/v4/token")
.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo").userNameAttributeName(IdTokenClaimNames.SUB)
.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs").clientName("Google").build()
}

@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.authorizeHttpRequests { authorizeHttpRequests ->
authorizeHttpRequests.requestMatchers("/oauth_login", "/loginSuccess").permitAll()
.anyRequest()
.authenticated()
}.oauth2Login { it ->
it.loginPage("/oauth_login")
it.defaultSuccessUrl("/loginSuccess", true)
it.tokenEndpoint {
it.accessTokenResponseClient(accessTokenResponseClient())
}
}
return http.build()
}

@Bean
fun accessTokenResponseClient(): OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
return DefaultAuthorizationCodeTokenResponseClient()
}
}
26 changes: 26 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,26 @@
package com.get_offer.common.s3


import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3Client

@Configuration
class S3Config(
@Value("\${aws.credentials.accessKey}")
private val accessKey: String,
@Value("\${aws.credentials.secretKey}")
private val secretKey: String,
) {
@Bean
fun amazonS3Client(): S3Client {
val credentials = AwsBasicCredentials.create(accessKey, secretKey)
return S3Client.builder().region(Region.AP_NORTHEAST_2)
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build()
}
}
60 changes: 60 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,60 @@
package com.get_offer.common.s3

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
import software.amazon.awssdk.core.sync.RequestBody
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest
import software.amazon.awssdk.services.s3.model.PutObjectRequest

@Component
class S3FileManagement(
@Value("\${aws.s3.bucket}")
private val bucket: String,
private val amazonS3: S3Client,
) {
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 putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.contentType("/${TYPE_IMAGE}/${getFileExtension(getFileExtension(originalFilename))}")
.contentLength(multipartFile.inputStream.available().toLong())
.build()

amazonS3.putObject(putObjectRequest, RequestBody.fromBytes(multipartFile.bytes))
return fileName
}

fun delete(fileNames: List<String>) {
fileNames.forEach { delete(it) }
}

private fun delete(fileName: String) {
val deleteObjectRequest = DeleteObjectRequest.builder()
.bucket(bucket)
.key(fileName)
.build()
amazonS3.deleteObject(deleteObjectRequest)
}

private fun getFileExtension(fileName: String): String {
val extensionIndex = fileName.lastIndexOf('.')
return fileName.substring(extensionIndex + 1)
}
}
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", "jpeg", "JPG", "PNG", "JPEG")

fun checkImageFormat(fileName: String) {
val extensionIndex = fileName.lastIndexOf('.')
if (extensionIndex == -1) {
throw UnsupportedFileExtensionException()
}
val extension = fileName.substring(extensionIndex + 1)
require(IMAGE_EXTENSIONS.contains(extension)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확장자가 대문자로 오는 경우도 예외 발생 안하게 해주세요

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 deleteImages(imageUrls: List<String>) {
s3FileManagement.delete(imageUrls)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@ package com.get_offer.product.controller

import ApiResponse
import com.get_offer.product.service.ProductDetailDto
import com.get_offer.product.service.ProductEditDto
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.PutMapping
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 +39,27 @@ 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))
}

@PutMapping("{productId}")
fun editProduct(
@PathVariable productId: Long,
@RequestParam userId: String,
@RequestPart("images") images: List<MultipartFile>?,
@RequestPart productReqDto: ProductEditReqDto
): ApiResponse<ProductSaveDto> {
return ApiResponse.success(
productService.editProduct(
ProductEditDto.of(productId, 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

data class ProductEditReqDto(
val title: String?,
val category: Category?,
val description: String?,
val startPrice: Int?,
val startDate: LocalDateTime?,
val endDate: LocalDateTime?,
)
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

data class ProductPostReqDto(
val title: String,
val category: Category,
val description: String,
val startPrice: Int,
val startDate: LocalDateTime,
val endDate: LocalDateTime,
)
Loading
Loading