Skip to content

Commit

Permalink
[YS-130] feat: 기간이 지난 공고 state 변경 스케줄링 구현 (#35)
Browse files Browse the repository at this point in the history
* feat: implement experiment post lists filtering API with pagination

- 필터링, offset-based 페이지네이션 위한 도메인 모델 정의:
  `CustomFilter` `Pagination`
- querydsl 라이브러리 사용하여 동적 쿼리 작성, 다양한 필터 조건 처리
- entity.enum → entity.enums: 예약어 인식 이슈로 패키지 명 변경
- ExperimentPostMapper에서 입출력값 dto ↔ 유즈케이스 input/output으로
  전환

* test: add test codes for experiment post filtering API usecase

- 실험공고 필터링 API 유즈케이스에 대한 유닛 테스트코드 작성

* fix: add unreflected source files

* fix: update '_ALL' option to return all posts on that region

* fix: update code review's feedbacks

- 코드 리뷰 피드백 반영
- import` 문 활용  → 유즈케이스 내부 변환 로직의 경우 중복 네이밍 이슈로 필요
- 검증 로직 service로 책임 이전 → SRP 원칙 준수
- 필터링 중 `method` → `matchType` 으로 변수명 변경
- 유즈케이스 내부의 `@Schema` 어노테이션 제거

* refact: delegate domain converter to ExperimentMapper

* refact: rename usecase's class to suffix

* refact: rename API endpoint query parameter  →

* test: update test codes for refactoring

* refact: update misunderstood requirement to meet the busniess logic

- '실험 방법' = '대면/비대면/전체'로 필터링하도록 수정
- '전체' 선택 시 쿼리에 미반영되도록 쿼리부분 수정

* fix: add ExperimentMapperTest codes to meet the test coverage

* refact: refactor validator codes to upgrade readability

* feat: add WebSecurityConfig codes to allow permit without authentication

* fix: fix QueryDSL generation issues with entity and enum

* test: add test codes to meet the test coverage guage

* feat: implement experiment state scheudler using Quartz libraries

- 만료된 게시글의 상태를 1일 주기로 정기적 업데이트하기 위해 Quartz
  스케줄러 구현
- `ExpiredExperimentPostJob` 에서 `ExperimentPostCustomRepository` 직접
  호출→ `recruitDone` 상태 업데이트

* feat: add scheduling jobs to application layer for clean architecture

- `ExpiredExperimentPostJob` 에서 `ExperimentPostService` 를 호출하여
  로직 처리
- 재사용성, API 확장성과 비즈니스 로직 캡슐화를 위한 코드 리팩터링
- 로깅 추가: 스케줄링 작업 완료 시 recuritDone = true 된 ExperimentPost
  수 기록

* test: add test codes for experiment post status scheduler

* refact: delete final lines to specify eof line

* feat: specify Quartz time zone `Asia/Seoul`

- 서버의 JVM 시간대와 Quartz 라이브러리의 시간대를 `Asia/Seoul` 로
  명시화하였습니다.

---------

Co-authored-by: jisu <[email protected]>
  • Loading branch information
chock-cho and Ji-soo708 authored Jan 17, 2025
1 parent b539ffa commit 5158eae
Show file tree
Hide file tree
Showing 11 changed files with 182 additions and 4 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ dependencies {
implementation("org.mariadb.jdbc:mariadb-java-client:2.7.3")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("org.springframework.boot:spring-boot-starter-quartz")
implementation("io.awspring.cloud:spring-cloud-starter-aws:2.4.4")
compileOnly("org.projectlombok:lombok")
runtimeOnly("com.mysql:mysql-connector-j")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.dobby.backend.domain.exception.ExperimentAreaOverflowException
import com.dobby.backend.infrastructure.database.entity.enums.areaInfo.Area
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import java.time.LocalDate

@Service
class ExperimentPostService(
Expand All @@ -19,7 +20,8 @@ class ExperimentPostService(
private val getExperimentPostCountsByRegionUseCase: GetExperimentPostCountsByRegionUseCase,
private val getExperimentPostCountsByAreaUseCase: GetExperimentPostCountsByAreaUseCase,
private val getExperimentPostApplyMethodUseCase: GetExperimentPostApplyMethodUseCase,
private val generateExperimentPostPreSignedUrlUseCase: GenerateExperimentPostPreSignedUrlUseCase
private val generateExperimentPostPreSignedUrlUseCase: GenerateExperimentPostPreSignedUrlUseCase,
private val updateExpiredExperimentPostUseCase: UpdateExpiredExperimentPostUseCase
) {
@Transactional
fun createNewExperimentPost(input: CreateExperimentPostUseCase.Input): CreateExperimentPostUseCase.Output {
Expand All @@ -42,6 +44,11 @@ class ExperimentPostService(
return getExperimentPostApplyMethodUseCase.execute(input)
}

@Transactional
fun updateExpiredExperimentPosts(input: UpdateExpiredExperimentPostUseCase.Input): UpdateExpiredExperimentPostUseCase.Output {
return updateExpiredExperimentPostUseCase.execute(input)
}

fun getExperimentPostCounts(input: Any): Any {
return when (input) {
is GetExperimentPostCountsByRegionUseCase.Input -> getExperimentPostCountsByRegionUseCase.execute(input)
Expand Down Expand Up @@ -69,7 +76,6 @@ class ExperimentPostService(
throw ExperimentAreaInCorrectException()
}
}

fun generatePreSignedUrl(input: GenerateExperimentPostPreSignedUrlUseCase.Input): GenerateExperimentPostPreSignedUrlUseCase.Output {
return generateExperimentPostPreSignedUrlUseCase.execute(input)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.dobby.backend.application.usecase.experiment

import com.dobby.backend.application.usecase.UseCase
import com.dobby.backend.domain.gateway.experiment.ExperimentPostGateway
import java.time.LocalDate

class UpdateExpiredExperimentPostUseCase(
private val experimentPostGateway: ExperimentPostGateway
) : UseCase<UpdateExpiredExperimentPostUseCase.Input, UpdateExpiredExperimentPostUseCase.Output> {
data class Input(
val currentDate : LocalDate
)
data class Output(
val affectedRowsCount: Long
)

override fun execute(input: Input): Output {
return Output(
experimentPostGateway.updateExperimentPostStatus(input.currentDate)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.dobby.backend.domain.model.experiment.Pagination
import com.dobby.backend.domain.model.experiment.ExperimentPost
import com.dobby.backend.infrastructure.database.entity.enums.areaInfo.Region
import jakarta.persistence.Tuple
import java.time.LocalDate

interface ExperimentPostGateway {
fun save(experimentPost: ExperimentPost): ExperimentPost
Expand All @@ -14,6 +15,7 @@ interface ExperimentPostGateway {
fun countExperimentPosts(): Int
fun countExperimentPostByRegionGroupedByArea(region: Region): List<Tuple>
fun countExperimentPostGroupedByRegion(): List<Tuple>
fun updateExperimentPostStatus(todayDate : LocalDate) : Long
fun findExperimentPostsByMemberIdWithPagination(memberId: Long, pagination: Pagination, order: String): List<ExperimentPost>?
fun countExperimentPostsByMemberId(memberId: Long): Int
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.dobby.backend.infrastructure.config

import com.dobby.backend.infrastructure.scheduler.ExpiredExperimentPostJob
import org.quartz.*
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.*

@Configuration
class SchedulerConfig {
@Bean
fun expiredExperimentPostJobDetail(): JobDetail {
return JobBuilder.newJob(ExpiredExperimentPostJob::class.java)
.withIdentity("expired_experiment_post_job")
.storeDurably()
.build()
}

@Bean
fun expiredExperimentPostTrigger(): Trigger {
return TriggerBuilder.newTrigger()
.forJob(expiredExperimentPostJobDetail())
.withIdentity("expired_experiment_post_trigger")
.withSchedule(
CronScheduleBuilder.dailyAtHourAndMinute(0, 0)
.inTimeZone(TimeZone.getTimeZone("Asia/Seoul"))
)
.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.dobby.backend.domain.model.experiment.CustomFilter
import com.dobby.backend.domain.model.experiment.Pagination
import com.dobby.backend.infrastructure.database.entity.experiment.ExperimentPostEntity
import org.springframework.stereotype.Repository
import java.time.LocalDate

@Repository
interface ExperimentPostCustomRepository {
Expand All @@ -12,6 +13,7 @@ interface ExperimentPostCustomRepository {
pagination: Pagination
): List<ExperimentPostEntity>?

fun updateExperimentPostStatus(currentDate : LocalDate): Long
fun findExperimentPostsByMemberIdWithPagination(
memberId: Long,
pagination: Pagination,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.querydsl.core.types.OrderSpecifier
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.stereotype.Repository
import java.time.LocalDate

@Repository
class ExperimentPostCustomRepositoryImpl (
Expand Down Expand Up @@ -87,6 +88,19 @@ class ExperimentPostCustomRepositoryImpl (
return recruitDone?.let { post.recruitDone.eq(it) }
}

@Override
override fun updateExperimentPostStatus(currentDate: LocalDate): Long {
val experimentPost = QExperimentPostEntity.experimentPostEntity

return jpaQueryFactory.update(experimentPost)
.set(experimentPost.recruitDone, true)
.where(
experimentPost.endDate.lt(currentDate)
.and(experimentPost.recruitDone.eq(false))
)
.execute()
}

private fun getOrderClause(order: String): OrderSpecifier<*> {
val post = QExperimentPostEntity.experimentPostEntity
return if (order == "ASC") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.dobby.backend.infrastructure.database.repository.ExperimentPostCustom
import com.dobby.backend.infrastructure.database.repository.ExperimentPostRepository
import jakarta.persistence.Tuple
import org.springframework.stereotype.Component
import java.time.LocalDate

@Component
class ExperimentPostGatewayImpl(
Expand Down Expand Up @@ -56,6 +57,9 @@ class ExperimentPostGatewayImpl(
return experimentPostRepository.countExperimentPostGroupedByRegion()
}

override fun updateExperimentPostStatus(currentDate: LocalDate): Long {
return experimentPostCustomRepository.updateExperimentPostStatus(currentDate)
}
override fun findExperimentPostsByMemberIdWithPagination(
memberId: Long,
pagination: Pagination,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.dobby.backend.infrastructure.scheduler

import com.dobby.backend.application.service.ExperimentPostService
import com.dobby.backend.application.usecase.experiment.UpdateExpiredExperimentPostUseCase
import org.quartz.Job
import org.quartz.JobExecutionContext
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.time.LocalDate

@Component
class ExpiredExperimentPostJob(
private val experimentPostService: ExperimentPostService
) : Job{
companion object {
private val logger : Logger = LoggerFactory.getLogger(ExpiredExperimentPostJob::class.java)
}

override fun execute(context: JobExecutionContext) {
val input = UpdateExpiredExperimentPostUseCase.Input(
LocalDate.now()
)
val output = experimentPostService.updateExpiredExperimentPosts(input)
logger.info("${output.affectedRowsCount} expired posts have been updated during scheduling jobs.")
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ object ExperimentPostMapper {
)
}


private fun toApplyMethodInfo(dto: ApplyMethodInfo): CreateExperimentPostUseCase.ApplyMethodInfo {
return CreateExperimentPostUseCase.ApplyMethodInfo(
content = dto.content,
Expand All @@ -55,6 +56,7 @@ object ExperimentPostMapper {
)
}


private fun toImageListInfo(dto: ImageListInfo): CreateExperimentPostUseCase.ImageListInfo {
return CreateExperimentPostUseCase.ImageListInfo(
images = dto.images
Expand Down Expand Up @@ -204,8 +206,9 @@ object ExperimentPostMapper {
univName = output.postInfo.univName,
reward = output.postInfo.reward,
durationInfo = DurationInfo(
startDate = output.postInfo.durationInfo.startDate,
endDate = output.postInfo.durationInfo.endDate
startDate = output.postInfo.durationInfo?.startDate,
endDate = output.postInfo.durationInfo?.endDate

)
),
recuritDone = output.postInfo.recruitDone
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import com.dobby.backend.application.usecase.experiment.UpdateExpiredExperimentPostUseCase
import com.dobby.backend.domain.gateway.experiment.ExperimentPostGateway
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import java.time.LocalDate

class UpdateExperimentPostUseCaseTest : BehaviorSpec({

val experimentPostGateway = mockk<ExperimentPostGateway>()
val updateExpiredExperimentPostUseCase = UpdateExpiredExperimentPostUseCase(experimentPostGateway)

given("게시글이 만료되어 상태가 업데이트되는 경우") {
val currentDate = LocalDate.of(2025, 1, 16)
val input = UpdateExpiredExperimentPostUseCase.Input(currentDate)
every { experimentPostGateway.updateExperimentPostStatus(any()) } returns 5L

`when`("Execute를 호출하면") {
then("UseCase는 업데이트된 게시글 수를 반환해야 한다") {
val output = updateExpiredExperimentPostUseCase.execute(input)
output.affectedRowsCount shouldBe 5L
verify(exactly = 1) { experimentPostGateway.updateExperimentPostStatus(currentDate) }
}
}
}

given("업데이트할 게시글이 없는 경우") {
val currentDate = LocalDate.of(2025, 1, 17)
val input = UpdateExpiredExperimentPostUseCase.Input(currentDate)

// Mock 설정을 각 테스트에서 바로 적용하지 않고 초기화 시 설정
every { experimentPostGateway.updateExperimentPostStatus(currentDate) } returns 0L

`when`("Execute를 호출하면") {
then("UseCase는 0을 반환해야 한다") {
val output = updateExpiredExperimentPostUseCase.execute(input)
output.affectedRowsCount shouldBe 0L
verify(exactly = 1) { experimentPostGateway.updateExperimentPostStatus(currentDate) }
}
}
}

given("Gateway에서 예외를 던지면") {
val currentDate = LocalDate.of(2025, 1, 18)
val input = UpdateExpiredExperimentPostUseCase.Input(currentDate)
val exceptionMessage = "Database error"

// 예외를 던지는 Mock 설정
every { experimentPostGateway.updateExperimentPostStatus(currentDate) } throws RuntimeException(exceptionMessage)

`when`("Execute를 호출하면") {
then("UseCase는 예외를 상위로 전달해야 한다") {
val exception = shouldThrow<RuntimeException> {
updateExpiredExperimentPostUseCase.execute(input)
}

exception.message shouldBe exceptionMessage
verify(exactly = 1) { experimentPostGateway.updateExperimentPostStatus(currentDate) }
}
}
}
})

0 comments on commit 5158eae

Please sign in to comment.