Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
16 changes: 14 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,25 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-security")


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

// mockk
testImplementation("com.ninja-squad:springmockk:4.0.2")
testImplementation("io.mockk:mockk:1.13.8")

// kotest
val kotestVersion = "5.5.4"
testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion")
testImplementation("io.kotest:kotest-assertions-core:$kotestVersion")

// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("io.rest-assured:rest-assured:5.4.0")

// Jasypt
implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5")
Expand All @@ -65,6 +74,9 @@ dependencies {
compileOnly("io.jsonwebtoken:jjwt-api:0.11.2")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2")

// Swagger
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
}

kotlin {
Expand Down Expand Up @@ -99,6 +111,6 @@ tasks.named("clean") {
}
}

tasks.withType<Test> {
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
1 change: 0 additions & 1 deletion src/main/kotlin/com/study/core/DokiApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.study.core

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.core.env.get

@SpringBootApplication
class DokiApplication
Expand Down
23 changes: 23 additions & 0 deletions src/main/kotlin/com/study/core/global/config/AsyncConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.study.core.global.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import java.util.concurrent.Executor

@Configuration
@EnableAsync
class AsyncConfig {

@Bean(name = ["imageStorageExecutor"])
fun imageStorageExecutor(): Executor {
val executor = ThreadPoolTaskExecutor()
executor.corePoolSize = 2
executor.maxPoolSize = 4
executor.queueCapacity = 50
executor.setThreadNamePrefix("image-storage-")
executor.initialize()
return executor
}
}
24 changes: 17 additions & 7 deletions src/main/kotlin/com/study/core/global/config/SecurityConfig.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package com.study.core.global.config;

import com.study.core.auth.oauth.CustomOAuth2UserService
import com.study.core.auth.oauth.OAuth2SuccessHandler
import com.study.core.global.security.JwtFilter
import com.study.core.global.security.JwtProvider
import com.study.core.auth.oauth.OAuth2SuccessHandler
import org.springframework.boot.autoconfigure.security.servlet.PathRequest
import org.springframework.context.annotation.Bean
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

Expand Down Expand Up @@ -47,16 +47,17 @@ class SecurityConfig(
}
.authorizeHttpRequests {
it.requestMatchers("/health/check").permitAll()
it.requestMatchers(*SWAGGER_WHITE_LIST).permitAll() // **/swagger-ui/index.html
it.requestMatchers(
"/auth/login", // 로그인 시작점
"/auth/reissue",
"/oauth2/**",
"/login/oauth2/**" // 콜백
).permitAll()
.anyRequest().authenticated()
.anyRequest().authenticated()
}
.oauth2Login { oauth ->
oauth.userInfoEndpoint{ endpoint ->
oauth.userInfoEndpoint { endpoint ->
endpoint.userService(customOAuth2UserService)
}
oauth.successHandler(oAuth2SuccessHandler)
Expand All @@ -68,4 +69,13 @@ class SecurityConfig(

return http.build()
}

companion object {
private val SWAGGER_WHITE_LIST = arrayOf(
"/swagger-ui/**",
"/v3/api-docs/**",
"/v3/api-docs.yaml",
"/swagger-ui.html"
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.study.core.image.application

import com.study.core.image.application.event.ImageCreateEvent
import com.study.core.image.application.event.ImageDeleteEvent
import com.study.core.image.domain.domainservice.ImageStorage
import com.study.core.image.domain.Image
import com.study.core.image.domain.ImageRepository
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component

@Component
class ImageEventListener(
private val imageRepository: ImageRepository,
private val imageStorage: ImageStorage
) {

@Async("imageStorageExecutor")
@EventListener
fun handleImageCreate(event: ImageCreateEvent) {
require(event.content.isNotEmpty()) { "content must not be empty." }

val image = Image.createAndStore(
aggregateType = event.aggregateType,
aggregateId = event.aggregateId,
originalFilename = event.originalFilename,
content = event.content,
sortOrder = event.sortOrder,
imageStorage = imageStorage,
)
imageRepository.save(image)
}

@Async("imageStorageExecutor")
@EventListener
fun handleImageDelete(event: ImageDeleteEvent) {
imageRepository.deleteAllByAggregate(
aggregateType = event.aggregateType,
aggregateId = event.aggregateId,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.study.core.image.application

import com.study.core.image.application.event.ImageCreateEvent
import com.study.core.image.application.event.ImageDeleteEvent
import com.study.core.image.domain.vo.ImageAggregateType
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Component

@Component
class ImageEventPublisher(
private val applicationEventPublisher: ApplicationEventPublisher
) {

fun publishCreate(
aggregateType: ImageAggregateType,
aggregateId: Long,
originalFilename: String,
content: ByteArray,
sortOrder: Int = 0
) {
publishCreate(
ImageCreateEvent(
aggregateType = aggregateType,
aggregateId = aggregateId,
originalFilename = originalFilename,
content = content,
sortOrder = sortOrder
)
)
}

private fun publishCreate(event: ImageCreateEvent) {
applicationEventPublisher.publishEvent(event)
}

fun publishDelete(
aggregateType: ImageAggregateType,
aggregateId: Long,
) {
publishDelete(
ImageDeleteEvent(
aggregateType = aggregateType,
aggregateId = aggregateId,
)
)
}

private fun publishDelete(event: ImageDeleteEvent) {
applicationEventPublisher.publishEvent(event)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.study.core.image.application.event

import com.study.core.image.domain.vo.ImageAggregateType

data class ImageCreateEvent(
val aggregateType: ImageAggregateType,
val aggregateId: Long,
val originalFilename: String,
val content: ByteArray,
val sortOrder: Int = 0,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.study.core.image.application.event

import com.study.core.image.domain.vo.ImageAggregateType

data class ImageDeleteEvent(
val aggregateType: ImageAggregateType,
val aggregateId: Long,
)
110 changes: 110 additions & 0 deletions src/main/kotlin/com/study/core/image/domain/Image.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.study.core.image.domain

import com.study.core.image.domain.domainservice.ImageStorage
import com.study.core.global.BaseEntity
import com.study.core.image.domain.vo.ImageAggregateType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.util.UUID

@Entity
@Table(name = "images")
class Image(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L,

@Enumerated(EnumType.STRING)
@Column(name = "aggregate_type", nullable = false, length = 30)
val aggregateType: ImageAggregateType,

@Column(name = "aggregate_id", nullable = false)
val aggregateId: Long,

@Column(name = "original_filename", nullable = false, length = 255)
val originalFilename: String,

@Column(name = "unique_filename", nullable = false, length = 255)
val uniqueFilename: String,

@Column(name = "extension", nullable = false, length = 20)
val extension: String,

@Column(name = "file_size", nullable = false)
val fileSize: Long,

@Column(name = "url", nullable = false, length = 500)
val url: String,

@Column(name = "sort_order", nullable = false)
var sortOrder: Int = 0,
) : BaseEntity() {

fun changeSortOrder(targetSortOrder: Int): Image {
require(targetSortOrder >= 0) { "sortOrder must be zero or positive." }
sortOrder = targetSortOrder
return this
}

companion object {
fun createAndStore(
aggregateType: ImageAggregateType,
aggregateId: Long,
originalFilename: String,
content: ByteArray,
sortOrder: Int = 0,
imageStorage: ImageStorage,
): Image {
require(aggregateId > 0) { "aggregateId must be positive." }
require(content.isNotEmpty()) { "content must not be empty." }
require(sortOrder >= 0) { "sortOrder must be zero or positive." }

val normalizedOriginal = originalFilename.trim()
require(normalizedOriginal.isNotEmpty()) { "originalFilename must not be blank." }

val generatedFilename = generateUniqueFilename(normalizedOriginal)
val storedImage = imageStorage.store(generatedFilename.uniqueFilename, content)

require(storedImage.url.isNotBlank()) { "url must not be blank." }
require(storedImage.fileSize > 0) { "fileSize must be greater than zero." }

return Image(
aggregateType = aggregateType,
aggregateId = aggregateId,
originalFilename = normalizedOriginal,
uniqueFilename = generatedFilename.uniqueFilename,
extension = generatedFilename.extension,
fileSize = storedImage.fileSize,
url = storedImage.url,
sortOrder = sortOrder,
)
}

fun generateUniqueFilename(originalFilename: String): GeneratedFilename {
val normalizedOriginal = originalFilename.trim()
require(normalizedOriginal.isNotEmpty()) { "originalFilename must not be blank." }
val extension = extractExtension(normalizedOriginal)
return GeneratedFilename(
uniqueFilename = "${UUID.randomUUID()}.$extension",
extension = extension
)
}

private fun extractExtension(filename: String): String {
val extension = filename.substringAfterLast('.', "").lowercase()
require(extension.isNotEmpty()) { "filename must contain an extension." }
return extension
}
}
}

data class GeneratedFilename(
val uniqueFilename: String,
val extension: String
)
16 changes: 16 additions & 0 deletions src/main/kotlin/com/study/core/image/domain/ImageRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.study.core.image.domain

import com.study.core.image.domain.vo.ImageAggregateType

interface ImageRepository {

fun save(image: Image): Image

fun saveAll(images: Collection<Image>): List<Image>

fun findByAggregate(aggregateType: ImageAggregateType, aggregateId: Long): List<Image>

fun findByAggregates(aggregateType: ImageAggregateType, aggregateIds: Collection<Long>): List<Image>

fun deleteAllByAggregate(aggregateType: ImageAggregateType, aggregateId: Long)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.study.core.image.domain.domainservice

interface ImageStorage {
fun store(uniqueFilename: String, content: ByteArray): StoredImage
}

data class StoredImage(
val url: String,
val fileSize: Long
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.study.core.image.domain.vo

enum class ImageAggregateType {
PACKAGE,
PRODUCT,
USER,
PACKAGE_REVIEW
}
Loading