diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..d553c82 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,47 @@ +name: Check PR + +on: + pull_request + +permissions: + checks: write + pull-requests: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: 'temurin' + cache: 'gradle' + + - name: Check Build + run: ./gradlew clean build -x test + + - name: Run test + run: ./gradlew test + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + junit_files: '**/build/test-results/test/TEST-*.xml' + comment_mode: off + + - name: Add coverage to PR + id: jacoco + uses: madrapps/jacoco-report@v1.6.1 + with: + paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 40 + min-coverage-changed-files: 60 + update-comment: true + title: Test Coverage + pass-emoji: ':green_circle:' + fail-emoji: ':red_circle:' diff --git a/.gitignore b/.gitignore index 5a979af..71c8831 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ out/ ### Kotlin ### .kotlin + +### Secret ### +.env +src/main/resources/application* \ No newline at end of file diff --git a/README.md b/README.md index dd43ca2..f43d380 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # Tiketeer-Waiting 티켓팅 시스템에서 트래픽 제어를 위한 대기 큐 레포지토리 + +```dtd +spring: + application: + name: TiketeerWaiting + data: + redis: + repositories: + enabled: false + redis: + host: + port: + +waiting: + entry-size: +``` \ No newline at end of file diff --git a/build.gradle b/build.gradle index a35c755..1851aa7 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,13 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test' + + // DB + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation("it.ozimov:embedded-redis:0.7.2") + + // logger + implementation 'io.github.oshai:kotlin-logging-jvm:5.1.0' } tasks.withType(KotlinCompile) { diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/kotlin/com/tiketeer/TiketeerWaiting/configuration/RedisConfig.kt b/src/main/kotlin/com/tiketeer/TiketeerWaiting/configuration/RedisConfig.kt new file mode 100644 index 0000000..c5b0805 --- /dev/null +++ b/src/main/kotlin/com/tiketeer/TiketeerWaiting/configuration/RedisConfig.kt @@ -0,0 +1,42 @@ +package com.tiketeer.TiketeerWaiting.configuration + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.context.annotation.Profile +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory +import org.springframework.data.redis.connection.RedisConfiguration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.ReactiveRedisOperations +import org.springframework.data.redis.core.ReactiveRedisTemplate +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.RedisSerializationContext + +@Configuration +@Profile("!test") +class RedisConfig { + @Value("\${spring.redis.host}") + private lateinit var host: String + + @Value("\${spring.redis.port}") + private lateinit var port: Number + + @Value("\${spring.redis.password}") + private lateinit var password: String + + @Bean + fun redisConnectionFactory() : LettuceConnectionFactory { + val redisStandaloneConfiguration = RedisStandaloneConfiguration(host, port.toInt()) + redisStandaloneConfiguration.setPassword(password) + return LettuceConnectionFactory(redisStandaloneConfiguration) + } + + @Bean + fun redisTemplate(connectionFactory: ReactiveRedisConnectionFactory) : ReactiveRedisTemplate { + return ReactiveRedisTemplate(connectionFactory, RedisSerializationContext.string()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/controller/WaitingController.kt b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/controller/WaitingController.kt index 7d83234..5c8144b 100644 --- a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/controller/WaitingController.kt +++ b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/controller/WaitingController.kt @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono import java.util.UUID @RestController @@ -15,10 +16,10 @@ class WaitingController( private val getRankAndTokenUseCase: GetRankAndToken ) { @GetMapping - fun getRankAndToken(@RequestParam(required = true) ticketingId: UUID): GetRankAndTokenResponseDto { + fun getRankAndToken(@RequestParam(required = true) ticketingId: UUID): Mono { // TODO: JWT 디코딩 필터 적용 후 JWT 내에서 가져오도록 수정 val email = "test@test.com" - val result = getRankAndTokenUseCase.getRankAndToken(GetRankAndTokenCommandDto(email, ticketingId)) + val result = getRankAndTokenUseCase.getRankAndToken(GetRankAndTokenCommandDto(email, ticketingId, System.currentTimeMillis())) return GetRankAndTokenResponseDto.convertFromDto(result) } } \ No newline at end of file diff --git a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/controller/dto/GetRankAndTokenResponseDto.kt b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/controller/dto/GetRankAndTokenResponseDto.kt index f000bd2..b7320bc 100644 --- a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/controller/dto/GetRankAndTokenResponseDto.kt +++ b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/controller/dto/GetRankAndTokenResponseDto.kt @@ -1,14 +1,17 @@ package com.tiketeer.TiketeerWaiting.domain.waiting.controller.dto import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenResultDto +import reactor.core.publisher.Mono data class GetRankAndTokenResponseDto( - val rank: Int, + val rank: Long, val token: String? = null, ) { companion object { - fun convertFromDto(dto: GetRankAndTokenResultDto): GetRankAndTokenResponseDto { - return GetRankAndTokenResponseDto(dto.rank, dto.token) + fun convertFromDto(dto: Mono): Mono { + return dto.flatMap { r -> + Mono.just(GetRankAndTokenResponseDto(r.rank, r.token)) + } } } } diff --git a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndToken.kt b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndToken.kt index 77810c6..0e3e27b 100644 --- a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndToken.kt +++ b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndToken.kt @@ -2,7 +2,8 @@ package com.tiketeer.TiketeerWaiting.domain.waiting.usecase import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenCommandDto import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenResultDto +import reactor.core.publisher.Mono interface GetRankAndToken { - fun getRankAndToken(dto: GetRankAndTokenCommandDto): GetRankAndTokenResultDto + fun getRankAndToken(dto: GetRankAndTokenCommandDto): Mono } \ No newline at end of file diff --git a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndTokenUseCase.kt b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndTokenUseCase.kt index 6b21d64..a782c20 100644 --- a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndTokenUseCase.kt +++ b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndTokenUseCase.kt @@ -2,10 +2,40 @@ package com.tiketeer.TiketeerWaiting.domain.waiting.usecase import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenCommandDto import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenResultDto +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.redis.core.ReactiveRedisTemplate +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.util.UUID -class GetRankAndTokenUseCase: GetRankAndToken { - override fun getRankAndToken(dto: GetRankAndTokenCommandDto): GetRankAndTokenResultDto { - TODO("Not yet implemented") +@Service +class GetRankAndTokenUseCase @Autowired constructor(private val redisTemplate: ReactiveRedisTemplate) : GetRankAndToken { + @Value("\${waiting.entry-size}") + private lateinit var entrySize: Number + + override fun getRankAndToken(dto: GetRankAndTokenCommandDto): Mono { + val currentTime = dto.entryTime + val token = generateToken(dto.email, dto.ticketingId, currentTime) + val mono = redisTemplate.opsForZSet().rank(dto.ticketingId.toString(), token) + .switchIfEmpty( + redisTemplate.opsForZSet().add(dto.ticketingId.toString(), token, currentTime.toDouble()) + .then(redisTemplate.opsForZSet().rank(dto.ticketingId.toString(), token)) + ) + + val ret: Mono = mono + .flatMap { l -> + if (l < entrySize.toInt()) { + Mono.just(GetRankAndTokenResultDto(l, generateToken(dto.email, dto.ticketingId, currentTime))) + } else { + Mono.just(GetRankAndTokenResultDto(l)) + } + } + + return ret } + private fun generateToken(email: String, ticketingId: UUID, entryTime: Long) : String { + return "${email}:${ticketingId}" + } } \ No newline at end of file diff --git a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/dto/GetRankAndTokenCommandDto.kt b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/dto/GetRankAndTokenCommandDto.kt index b99a22e..04644c0 100644 --- a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/dto/GetRankAndTokenCommandDto.kt +++ b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/dto/GetRankAndTokenCommandDto.kt @@ -4,5 +4,6 @@ import java.util.UUID data class GetRankAndTokenCommandDto( val email: String, - val ticketingId: UUID + val ticketingId: UUID, + val entryTime: Long ) diff --git a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/dto/GetRankAndTokenResultDto.kt b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/dto/GetRankAndTokenResultDto.kt index 1636e15..9dcaad5 100644 --- a/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/dto/GetRankAndTokenResultDto.kt +++ b/src/main/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/dto/GetRankAndTokenResultDto.kt @@ -1,6 +1,6 @@ package com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto data class GetRankAndTokenResultDto( - val rank: Int, + val rank: Long, val token: String? = null, ) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 87d2028..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=TiketeerWaiting diff --git a/src/test/kotlin/com/tiketeer/TiketeerWaiting/configuration/EmbeddedRedisConfig.kt b/src/test/kotlin/com/tiketeer/TiketeerWaiting/configuration/EmbeddedRedisConfig.kt new file mode 100644 index 0000000..3b832d8 --- /dev/null +++ b/src/test/kotlin/com/tiketeer/TiketeerWaiting/configuration/EmbeddedRedisConfig.kt @@ -0,0 +1,62 @@ +package com.tiketeer.TiketeerWaiting.configuration + +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import org.junit.jupiter.api.DisplayName +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.ReactiveRedisTemplate +import org.springframework.data.redis.serializer.RedisSerializationContext +import redis.embedded.RedisServer +import java.io.IOException + +private val logger = KotlinLogging.logger {} + +@DisplayName("Embedded Redis 설정") +@TestConfiguration +class EmbeddedRedisConfig { + @Value("\${spring.data.redis.host}") + private lateinit var host: String + + @Value("\${spring.data.redis.port}") + private lateinit var port: Number + + @Value("\${spring.redis.maxmemory}") + private lateinit var maxmemorySize: Number + + private lateinit var redisServer: RedisServer + + @PostConstruct + @Throws(IOException::class) + fun startRedis() { + this.redisServer = RedisServer.builder().port(port.toInt()).setting("maxmemory " + maxmemorySize + "M").build() + try { + this.redisServer.start() + logger.info {"레디스 서버 시작 성공" } + } catch (e: Exception) { + logger.error (e) { "레디스 서버 시작 실패: ${e.message}" } + } + } + + @PreDestroy + fun stopRedis() { + redisServer.stop() + } + + @Bean + fun redisConnectionFactory() : LettuceConnectionFactory { + val redisStandaloneConfiguration = RedisStandaloneConfiguration(host, port.toInt()) + return LettuceConnectionFactory(redisStandaloneConfiguration) + } + + @Bean + fun redisTemplate(connectionFactory: ReactiveRedisConnectionFactory) : ReactiveRedisTemplate { + return ReactiveRedisTemplate(connectionFactory, RedisSerializationContext.string()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndTokenUseCaseTest.kt b/src/test/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndTokenUseCaseTest.kt new file mode 100644 index 0000000..6c3024c --- /dev/null +++ b/src/test/kotlin/com/tiketeer/TiketeerWaiting/domain/waiting/usecase/GetRankAndTokenUseCaseTest.kt @@ -0,0 +1,67 @@ +package com.tiketeer.TiketeerWaiting.domain.waiting.usecase + +import com.tiketeer.TiketeerWaiting.configuration.EmbeddedRedisConfig +import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenCommandDto +import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenResultDto +import org.junit.jupiter.api.BeforeEach + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory +import reactor.test.StepVerifier +import java.util.UUID + +@Import(EmbeddedRedisConfig::class) +@SpringBootTest +class GetRankAndTokenUseCaseTest { + @Autowired + lateinit var getRankAndTokenUseCase: GetRankAndTokenUseCase + + @Autowired + lateinit var redisConnectionFactory: ReactiveRedisConnectionFactory + + @Value("\${waiting.entry-size}") + lateinit var entrySize: Number + + @BeforeEach + fun init() { + val flushDb = redisConnectionFactory.reactiveConnection.serverCommands().flushDb() + flushDb.block() + } + + @Test + fun `유저 정보 생성 - 빈 대기열에 요청 - 결과 검증`() { + val email = "test@test.com" + val ticketingId = UUID.randomUUID() + val entryTime = System.currentTimeMillis() + val result = getRankAndTokenUseCase.getRankAndToken(GetRankAndTokenCommandDto(email, ticketingId, entryTime)) + + StepVerifier.create(result) + .expectNext(GetRankAndTokenResultDto(0, "${email}:${ticketingId}")) + .expectComplete() + .verify() + } + + @Test + fun `대기열 길이만큼 유저 생성 - 대기열을 모두 채우도록 요청 후 한 명 더 요청 - 빈 토큰 결과 반환`() { + val ticketingId = UUID.randomUUID() + for (i in 1..entrySize.toInt()) { + val email = "test${i}@test.com" + val entryTime = System.currentTimeMillis() + val result = getRankAndTokenUseCase.getRankAndToken(GetRankAndTokenCommandDto(email, ticketingId, entryTime)) + result.block() + } + + val email = "test@test.com" + val entryTime = System.currentTimeMillis() + val result = getRankAndTokenUseCase.getRankAndToken(GetRankAndTokenCommandDto(email, ticketingId, entryTime)) + + StepVerifier.create(result) + .expectNext(GetRankAndTokenResultDto(entrySize.toLong())) + .expectComplete() + .verify() + } +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..94448e0 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,17 @@ +spring: + profiles: + active: test + application: + name: TiketeerWaiting + data: + redis: + repositories: + enabled: false + host: 127.0.0.1 + port: 6379 + password: 1q2w3e4r@@Q + redis: + maxmemory: 10 + +waiting: + entry-size: 3 \ No newline at end of file