diff --git a/docs/admin/quiz/approve_not_approved_quiz.md b/docs/admin/quiz/approve_not_approved_quiz.md new file mode 100644 index 0000000..9186473 --- /dev/null +++ b/docs/admin/quiz/approve_not_approved_quiz.md @@ -0,0 +1,27 @@ +# Approve not approved quiz + +미승인 퀴즈를 승인하여 승인 퀴즈로 반영합니다. + +> [!WARN] +> 요청 시 `AdminCallDetected` 이벤트가 발행됩니다. + +## Request +### HTTP METHOD : `POST` +### url : `https://api.gitanimals.org/admin/quizs/not-approved/{quizId}/approve` +### RequestHeader +- Admin-Secret: `{발급받은 어드민 토큰을 넘겨주세요.}` +- Authorization: `{어드민 요청자의 인증토큰을 넘겨주세요.}` + +### Path Variable +- quizId: `{승인할 미승인 퀴즈 ID}` + +### Request Body +```json +{ + "reason": "review completed" +} +``` + +## Response + +200 OK diff --git a/docs/admin/quiz/delete_approved_quiz.md b/docs/admin/quiz/delete_approved_quiz.md new file mode 100644 index 0000000..9423803 --- /dev/null +++ b/docs/admin/quiz/delete_approved_quiz.md @@ -0,0 +1,27 @@ +# Delete approved quiz + +승인된 퀴즈를 삭제합니다. + +> [!WARN] +> 요청 시 `AdminCallDetected` 이벤트가 발행됩니다. + +## Request +### HTTP METHOD : `DELETE` +### url : `https://api.gitanimals.org/admin/quizs/approved/{quizId}` +### RequestHeader +- Admin-Secret: `{발급받은 어드민 토큰을 넘겨주세요.}` +- Authorization: `{어드민 요청자의 인증토큰을 넘겨주세요.}` + +### Path Variable +- quizId: `{삭제할 승인 퀴즈 ID}` + +### Request Body +```json +{ + "reason": "policy violation" +} +``` + +## Response + +200 OK diff --git a/docs/admin/quiz/delete_not_approved_quiz.md b/docs/admin/quiz/delete_not_approved_quiz.md new file mode 100644 index 0000000..bcf6c76 --- /dev/null +++ b/docs/admin/quiz/delete_not_approved_quiz.md @@ -0,0 +1,27 @@ +# Delete not approved quiz + +미승인 퀴즈를 삭제합니다. 내부적으로 포인트 회수 로직이 함께 수행될 수 있습니다. + +> [!WARN] +> 요청 시 `AdminCallDetected` 이벤트가 발행됩니다. + +## Request +### HTTP METHOD : `DELETE` +### url : `https://api.gitanimals.org/admin/quizs/not-approved/{quizId}` +### RequestHeader +- Admin-Secret: `{발급받은 어드민 토큰을 넘겨주세요.}` +- Authorization: `{어드민 요청자의 인증토큰을 넘겨주세요.}` + +### Path Variable +- quizId: `{삭제할 미승인 퀴즈 ID}` + +### Request Body +```json +{ + "reason": "duplicate quiz" +} +``` + +## Response + +200 OK diff --git a/docs/admin/quiz/scroll_approved_quizs.md b/docs/admin/quiz/scroll_approved_quizs.md new file mode 100644 index 0000000..f92d1f5 --- /dev/null +++ b/docs/admin/quiz/scroll_approved_quizs.md @@ -0,0 +1,46 @@ +# Scroll approved quizs + +승인된 퀴즈를 `id` 기반 no-offset 방식으로 조회합니다. + +> [!WARN] +> 조회는 항상 `id` 커서를 기준으로 내려갑니다. + +## Request +### HTTP METHOD : `GET` +### url : `https://api.gitanimals.org/admin/quizs/approved` +### RequestHeader +- Admin-Secret: `{발급받은 어드민 토큰을 넘겨주세요.}` +- Authorization: `{어드민 요청자의 인증토큰을 넘겨주세요.}` + +### Query Parameter +- lastId: `{optional, 다음 페이지 조회를 위한 커서}` +- level: `{optional, EASY | MEDIUM | DIFFICULT}` +- category: `{optional, FRONTEND | BACKEND}` +- language: `{optional, KOREA | ENGLISH}` + +## Response + +200 OK + +```json +{ + "quizs": [ + { + "id": "912345678901234567", + "userId": "1234", + "level": "EASY", + "category": "BACKEND", + "language": "ENGLISH", + "problem": "Spring Bean scope의 기본값은 singleton이다.", + "expectedAnswer": "YES", + "createdAt": "2026-03-21 01:00:00", + "modifiedAt": "2026-03-21 01:00:00" + } + ], + "nextId": "912345678901234567" +} +``` + +### Response Field +- quizs: 최대 20개의 승인 퀴즈 목록 +- nextId: 다음 페이지가 없으면 `null` diff --git a/docs/admin/quiz/scroll_not_approved_quizs.md b/docs/admin/quiz/scroll_not_approved_quizs.md new file mode 100644 index 0000000..d1c6470 --- /dev/null +++ b/docs/admin/quiz/scroll_not_approved_quizs.md @@ -0,0 +1,46 @@ +# Scroll not approved quizs + +미승인 퀴즈를 `id` 기반 no-offset 방식으로 조회합니다. + +> [!WARN] +> 조회는 항상 `id` 커서를 기준으로 내려갑니다. + +## Request +### HTTP METHOD : `GET` +### url : `https://api.gitanimals.org/admin/quizs/not-approved` +### RequestHeader +- Admin-Secret: `{발급받은 어드민 토큰을 넘겨주세요.}` +- Authorization: `{어드민 요청자의 인증토큰을 넘겨주세요.}` + +### Query Parameter +- lastId: `{optional, 다음 페이지 조회를 위한 커서}` +- level: `{optional, EASY | MEDIUM | DIFFICULT}` +- category: `{optional, FRONTEND | BACKEND}` +- language: `{optional, KOREA | ENGLISH}` + +## Response + +200 OK + +```json +{ + "quizs": [ + { + "id": "712345678901234567", + "userId": "1234", + "level": "MEDIUM", + "category": "FRONTEND", + "language": "KOREA", + "problem": "브라우저의 reflow는 layout 계산과 관련이 있다.", + "expectedAnswer": "YES", + "createdAt": "2026-03-21 01:00:00", + "modifiedAt": "2026-03-21 01:00:00" + } + ], + "nextId": "712345678901234567" +} +``` + +### Response Field +- quizs: 최대 20개의 미승인 퀴즈 목록 +- nextId: 다음 페이지가 없으면 `null` diff --git a/docs/admin/quiz/scroll_quiz_solve_contexts_by_user_id.md b/docs/admin/quiz/scroll_quiz_solve_contexts_by_user_id.md new file mode 100644 index 0000000..7540c37 --- /dev/null +++ b/docs/admin/quiz/scroll_quiz_solve_contexts_by_user_id.md @@ -0,0 +1,52 @@ +# Scroll quiz solve contexts by user id + +특정 `userId`의 `QuizSolveContext`를 `id` 기반 no-offset 방식으로 조회합니다. + +> [!WARN] +> `userId`는 반드시 입력해야 하며, 한 번에 최대 20개를 응답합니다. + +## Request +### HTTP METHOD : `GET` +### url : `https://api.gitanimals.org/admin/quizs/contexts` +### RequestHeader +- Admin-Secret: `{발급받은 어드민 토큰을 넘겨주세요.}` +- Authorization: `{어드민 요청자의 인증토큰을 넘겨주세요.}` + +### Query Parameter +- userId: `{required, 조회할 유저 ID}` +- lastId: `{optional, 다음 페이지 조회를 위한 커서}` + +## Response + +200 OK + +```json +{ + "quizSolveContexts": [ + { + "id": "812345678901234567", + "userId": "1234", + "category": "BACKEND", + "round": { + "total": 3, + "current": 1, + "timeoutAt": "2026-03-21 01:00:10" + }, + "prize": 2000, + "solvedAt": "2026-03-21", + "status": "SUCCESS", + "createdAt": "2026-03-21 01:00:00", + "modifiedAt": "2026-03-21 01:00:05" + } + ], + "nextId": "812345678901234567" +} +``` + +### Response Field +- quizSolveContexts: 최대 20개의 풀이 컨텍스트 목록 +- round.total: 전체 문제 수 +- round.current: 현재 라운드 +- round.timeoutAt: 현재 라운드 제한시간, 없으면 `null` +- status: `NOT_STARTED | SOLVING | SUCCESS | FAIL | DONE` +- nextId: 다음 페이지가 없으면 `null` diff --git a/src/main/kotlin/org/gitanimals/core/CoroutineScope.kt b/src/main/kotlin/org/gitanimals/core/CoroutineScope.kt index 6f0ec99..ae7227e 100644 --- a/src/main/kotlin/org/gitanimals/core/CoroutineScope.kt +++ b/src/main/kotlin/org/gitanimals/core/CoroutineScope.kt @@ -1,23 +1,37 @@ package org.gitanimals.core import jakarta.annotation.PreDestroy -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch import kotlinx.coroutines.slf4j.MDCContext -import org.gitanimals.core.GracefulShutdownDispatcher.executorService +import org.gitanimals.core.GracefulShutdownDispatcher.graceFulShutdownExecutorServices import org.slf4j.LoggerFactory import org.springframework.stereotype.Component +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit object GracefulShutdownDispatcher { - val executorService = Executors.newFixedThreadPool(10) { runnable -> + val graceFulShutdownExecutorServices: MutableList = mutableListOf() + + private val executorService = Executors.newFixedThreadPool(10) { runnable -> Thread(runnable, "gitanimals-gracefulshutdown").apply { isDaemon = false } - } + }.withGracefulShutdown() - val dispatcher: CoroutineDispatcher = executorService.asCoroutineDispatcher() + private val defaultDispatcher: CoroutineDispatcher = executorService.asCoroutineDispatcher() - fun gracefulLaunch(block: suspend CoroutineScope.() -> Unit) { + fun ExecutorService.withGracefulShutdown(): ExecutorService { + graceFulShutdownExecutorServices.add(this) + return this + } + + fun gracefulLaunch( + dispatcher: CoroutineDispatcher = defaultDispatcher, + block: suspend CoroutineScope.() -> Unit + ) { CoroutineScope(dispatcher + MDCContext()).launch(block = block) } } @@ -30,18 +44,28 @@ class GracefulShutdownHook { @PreDestroy fun tryGracefulShutdown() { logger.info("Shutting down dispatcher...") - executorService.shutdown() + graceFulShutdownExecutorServices.forEach { + it.shutdown() + } runCatching { - if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) { + if ( + graceFulShutdownExecutorServices.any { + it.awaitTermination(60, TimeUnit.SECONDS).not() + } + ) { logger.warn("Forcing shutdown...") - executorService.shutdownNow() + graceFulShutdownExecutorServices.forEach { + it.shutdown() + } } else { logger.info("Shutdown completed gracefully.") } }.onFailure { if (it is InterruptedException) { logger.warn("Shutdown interrupted. Forcing shutdown...") - executorService.shutdownNow() + graceFulShutdownExecutorServices.forEach { + it.shutdownNow() + } Thread.currentThread().interrupt() } } diff --git a/src/main/kotlin/org/gitanimals/quiz/controller/admin/QuizAdminController.kt b/src/main/kotlin/org/gitanimals/quiz/controller/admin/QuizAdminController.kt new file mode 100644 index 0000000..67a65b2 --- /dev/null +++ b/src/main/kotlin/org/gitanimals/quiz/controller/admin/QuizAdminController.kt @@ -0,0 +1,227 @@ +package org.gitanimals.quiz.controller.admin + +import org.gitanimals.core.admin.AdminCallDetected +import org.gitanimals.core.admin.AdminConst.ADMIN_SECRET_KEY +import org.gitanimals.identity.app.Token +import org.gitanimals.identity.app.UserFacade +import org.gitanimals.quiz.app.ApproveQuizFacade +import org.gitanimals.quiz.app.DeleteQuizFacade +import org.gitanimals.quiz.app.DenyQuizFacade +import org.gitanimals.quiz.controller.admin.request.QuizAdminActionRequest +import org.gitanimals.quiz.controller.admin.response.AdminQuizSolveContextsResponse +import org.gitanimals.quiz.controller.admin.response.AdminQuizsResponse +import org.gitanimals.quiz.domain.approved.QuizService +import org.gitanimals.quiz.domain.context.QuizSolveContextService +import org.gitanimals.quiz.domain.core.Category +import org.gitanimals.quiz.domain.core.Language +import org.gitanimals.quiz.domain.core.Level +import org.gitanimals.quiz.domain.not_approved.NotApprovedQuizService +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +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.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/admin/quizs") +class QuizAdminController( + private val quizService: QuizService, + private val quizSolveContextService: QuizSolveContextService, + private val notApprovedQuizService: NotApprovedQuizService, + private val approveQuizFacade: ApproveQuizFacade, + private val denyQuizFacade: DenyQuizFacade, + private val deleteQuizFacade: DeleteQuizFacade, + private val userFacade: UserFacade, + private val eventPublisher: ApplicationEventPublisher, + @Value("\${gitanimals.admin.token}") private val adminToken: String, + @Value("\${quiz.approve.token}") private val approveToken: String, +) { + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/approved") + fun scrollApprovedQuizs( + @RequestParam(name = "lastId", required = false) lastId: Long?, + @RequestParam(name = "level", required = false) level: Level?, + @RequestParam(name = "category", required = false) category: Category?, + @RequestParam(name = "language", required = false) language: Language?, + @RequestHeader(ADMIN_SECRET_KEY) adminSecret: String, + @RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String, + ): AdminQuizsResponse { + validateAdminAccess(adminSecret, authorization) + + return AdminQuizsResponse.fromApprovedQuizs( + quizs = quizService.scrollApprovedQuizs( + lastId = lastId ?: Long.MAX_VALUE, + level = level, + category = category, + language = language, + size = SCROLL_QUERY_SIZE + 1, + ), + size = SCROLL_QUERY_SIZE, + ) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/contexts") + fun scrollQuizSolveContexts( + @RequestParam("userId") userId: Long, + @RequestParam(name = "lastId", required = false) lastId: Long?, + @RequestHeader(ADMIN_SECRET_KEY) adminSecret: String, + @RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String, + ): AdminQuizSolveContextsResponse { + validateAdminAccess(adminSecret, authorization) + + return AdminQuizSolveContextsResponse.from( + quizSolveContexts = quizSolveContextService.scrollQuizSolveContexts( + userId = userId, + lastId = lastId ?: Long.MAX_VALUE, + size = SCROLL_QUERY_SIZE + 1, + ), + size = SCROLL_QUERY_SIZE, + ) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping("/not-approved") + fun scrollNotApprovedQuizs( + @RequestParam(name = "lastId", required = false) lastId: Long?, + @RequestParam(name = "level", required = false) level: Level?, + @RequestParam(name = "category", required = false) category: Category?, + @RequestParam(name = "language", required = false) language: Language?, + @RequestHeader(ADMIN_SECRET_KEY) adminSecret: String, + @RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String, + ): AdminQuizsResponse { + validateAdminAccess(adminSecret, authorization) + + return AdminQuizsResponse.fromNotApprovedQuizs( + quizs = notApprovedQuizService.scrollNotApprovedQuizs( + lastId = lastId ?: Long.MAX_VALUE, + level = level, + category = category, + language = language, + size = SCROLL_QUERY_SIZE + 1, + ), + size = SCROLL_QUERY_SIZE, + ) + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping("/not-approved/{quizId}") + fun deleteNotApprovedQuiz( + @PathVariable("quizId") quizId: Long, + @RequestHeader(ADMIN_SECRET_KEY) adminSecret: String, + @RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String, + @RequestBody request: QuizAdminActionRequest, + ) { + val adminUser = getAdminUser(adminSecret, authorization) + + denyQuizFacade.notApprovedQuiz( + approveToken = approveToken, + notApprovedQuizId = quizId, + ) + publishAdminCallDetected( + username = adminUser.getName(), + reason = request.reason, + path = DELETE_NOT_APPROVED_QUIZ_PATH, + description = "미승인 퀴즈 $quizId 삭제 및 포인트 회수", + ) + } + + @ResponseStatus(HttpStatus.OK) + @PostMapping("/not-approved/{quizId}/approve") + fun approveNotApprovedQuiz( + @PathVariable("quizId") quizId: Long, + @RequestHeader(ADMIN_SECRET_KEY) adminSecret: String, + @RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String, + @RequestBody request: QuizAdminActionRequest, + ) { + val adminUser = getAdminUser(adminSecret, authorization) + + approveQuizFacade.approveQuiz( + approveToken = approveToken, + notApprovedQuizId = quizId, + ) + publishAdminCallDetected( + username = adminUser.getName(), + reason = request.reason, + path = APPROVE_NOT_APPROVED_QUIZ_PATH, + description = "미승인 퀴즈 $quizId 승인", + ) + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping("/approved/{quizId}") + fun deleteApprovedQuiz( + @PathVariable("quizId") quizId: Long, + @RequestHeader(ADMIN_SECRET_KEY) adminSecret: String, + @RequestHeader(HttpHeaders.AUTHORIZATION) authorization: String, + @RequestBody request: QuizAdminActionRequest, + ) { + val adminUser = getAdminUser(adminSecret, authorization) + + deleteQuizFacade.deleteQuizById( + approveToken = approveToken, + quizId = quizId, + ) + publishAdminCallDetected( + username = adminUser.getName(), + reason = request.reason, + path = DELETE_APPROVED_QUIZ_PATH, + description = "승인 퀴즈 $quizId 삭제", + ) + } + + private fun validateAdminAccess( + adminSecret: String, + authorization: String, + ) { + requireAdminSecret(adminSecret) + userFacade.getUserByToken(Token.from(authorization)) + } + + private fun getAdminUser( + adminSecret: String, + authorization: String, + ) = run { + requireAdminSecret(adminSecret) + userFacade.getUserByToken(Token.from(authorization)) + } + + private fun requireAdminSecret(adminSecret: String) { + require(adminSecret == adminToken) { + "WRONG TOKEN" + } + } + + private fun publishAdminCallDetected( + username: String, + reason: String, + path: String, + description: String, + ) { + eventPublisher.publishEvent( + AdminCallDetected( + username = username, + reason = reason, + path = path, + description = description, + ) + ) + } + + companion object { + private const val SCROLL_QUERY_SIZE = 20 + private const val DELETE_NOT_APPROVED_QUIZ_PATH = "/admin/quizs/not-approved/{quizId}" + private const val APPROVE_NOT_APPROVED_QUIZ_PATH = "/admin/quizs/not-approved/{quizId}/approve" + private const val DELETE_APPROVED_QUIZ_PATH = "/admin/quizs/approved/{quizId}" + } +} diff --git a/src/main/kotlin/org/gitanimals/quiz/controller/admin/request/QuizAdminActionRequest.kt b/src/main/kotlin/org/gitanimals/quiz/controller/admin/request/QuizAdminActionRequest.kt new file mode 100644 index 0000000..0522bae --- /dev/null +++ b/src/main/kotlin/org/gitanimals/quiz/controller/admin/request/QuizAdminActionRequest.kt @@ -0,0 +1,7 @@ +package org.gitanimals.quiz.controller.admin.request + +import org.gitanimals.core.admin.AbstractAdminRequest + +data class QuizAdminActionRequest( + override val reason: String, +) : AbstractAdminRequest diff --git a/src/main/kotlin/org/gitanimals/quiz/controller/admin/response/AdminQuizResponses.kt b/src/main/kotlin/org/gitanimals/quiz/controller/admin/response/AdminQuizResponses.kt new file mode 100644 index 0000000..16e456d --- /dev/null +++ b/src/main/kotlin/org/gitanimals/quiz/controller/admin/response/AdminQuizResponses.kt @@ -0,0 +1,188 @@ +package org.gitanimals.quiz.controller.admin.response + +import com.fasterxml.jackson.annotation.JsonFormat +import org.gitanimals.quiz.domain.approved.Quiz +import org.gitanimals.quiz.domain.context.QuizSolveContext +import org.gitanimals.quiz.domain.context.QuizSolveContextStatus +import org.gitanimals.quiz.domain.core.Category +import org.gitanimals.quiz.domain.core.Language +import org.gitanimals.quiz.domain.core.Level +import org.gitanimals.quiz.domain.not_approved.NotApprovedQuiz +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset + +data class AdminQuizsResponse( + val quizs: List, + val nextId: String?, +) { + + companion object { + fun fromApprovedQuizs( + quizs: List, + size: Int, + ): AdminQuizsResponse { + val pageQuizs = quizs.take(size) + + return AdminQuizsResponse( + quizs = pageQuizs.map { AdminQuizResponse.from(it) }, + nextId = getNextId(quizs, pageQuizs, size) { it.id }, + ) + } + + fun fromNotApprovedQuizs( + quizs: List, + size: Int, + ): AdminQuizsResponse { + val pageQuizs = quizs.take(size) + + return AdminQuizsResponse( + quizs = pageQuizs.map { AdminQuizResponse.from(it) }, + nextId = getNextId(quizs, pageQuizs, size) { it.id }, + ) + } + } +} + +data class AdminQuizResponse( + val id: String, + val userId: String, + val level: Level, + val category: Category, + val language: Language, + val problem: String, + val expectedAnswer: String, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "UTC", + ) + val createdAt: LocalDateTime, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "UTC", + ) + val modifiedAt: LocalDateTime, +) { + + companion object { + fun from(quiz: Quiz): AdminQuizResponse { + return AdminQuizResponse( + id = quiz.id.toString(), + userId = quiz.userId.toString(), + level = quiz.level, + category = quiz.category, + language = quiz.language, + problem = quiz.problem, + expectedAnswer = quiz.expectedAnswer, + createdAt = quiz.createdAt.toUtcLocalDateTime(), + modifiedAt = (quiz.modifiedAt ?: quiz.createdAt).toUtcLocalDateTime(), + ) + } + + fun from(quiz: NotApprovedQuiz): AdminQuizResponse { + return AdminQuizResponse( + id = quiz.id.toString(), + userId = quiz.userId.toString(), + level = quiz.level, + category = quiz.category, + language = quiz.language, + problem = quiz.problem, + expectedAnswer = quiz.expectedAnswer, + createdAt = quiz.createdAt.toUtcLocalDateTime(), + modifiedAt = (quiz.modifiedAt ?: quiz.createdAt).toUtcLocalDateTime(), + ) + } + } +} + +data class AdminQuizSolveContextsResponse( + val quizSolveContexts: List, + val nextId: String?, +) { + + companion object { + fun from( + quizSolveContexts: List, + size: Int, + ): AdminQuizSolveContextsResponse { + val pageQuizSolveContexts = quizSolveContexts.take(size) + + return AdminQuizSolveContextsResponse( + quizSolveContexts = pageQuizSolveContexts.map { AdminQuizSolveContextResponse.from(it) }, + nextId = getNextId(quizSolveContexts, pageQuizSolveContexts, size) { it.id }, + ) + } + } +} + +data class AdminQuizSolveContextResponse( + val id: String, + val userId: String, + val category: Category, + val round: Round, + val prize: Int, + val solvedAt: LocalDate, + val status: QuizSolveContextStatus, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "UTC", + ) + val createdAt: LocalDateTime, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "UTC", + ) + val modifiedAt: LocalDateTime, +) { + + data class Round( + val total: Int, + val current: Int, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "UTC", + ) + val timeoutAt: LocalDateTime?, + ) + + companion object { + fun from(quizSolveContext: QuizSolveContext): AdminQuizSolveContextResponse { + return AdminQuizSolveContextResponse( + id = quizSolveContext.id.toString(), + userId = quizSolveContext.userId.toString(), + category = quizSolveContext.category, + round = Round( + total = quizSolveContext.solveStage.maxSolveStage, + current = quizSolveContext.solveStage.getCurrentStage(), + timeoutAt = quizSolveContext.solveStage.getCurrentStageTimeout()?.toUtcLocalDateTime(), + ), + prize = quizSolveContext.getPrize(), + solvedAt = quizSolveContext.solvedAt, + status = quizSolveContext.getStatus(), + createdAt = quizSolveContext.createdAt.toUtcLocalDateTime(), + modifiedAt = (quizSolveContext.modifiedAt ?: quizSolveContext.createdAt).toUtcLocalDateTime(), + ) + } + } +} + +private fun Instant.toUtcLocalDateTime(): LocalDateTime = LocalDateTime.ofInstant(this, ZoneOffset.UTC) + +private fun getNextId( + allItems: List, + pageItems: List, + size: Int, + idSelector: (T) -> Long, +): String? { + if (allItems.size <= size || pageItems.isEmpty()) { + return null + } + + return idSelector(pageItems.last()).toString() +} diff --git a/src/main/kotlin/org/gitanimals/quiz/domain/approved/QuizRepository.kt b/src/main/kotlin/org/gitanimals/quiz/domain/approved/QuizRepository.kt index 82ac373..cde93eb 100644 --- a/src/main/kotlin/org/gitanimals/quiz/domain/approved/QuizRepository.kt +++ b/src/main/kotlin/org/gitanimals/quiz/domain/approved/QuizRepository.kt @@ -1,5 +1,30 @@ package org.gitanimals.quiz.domain.approved +import org.gitanimals.quiz.domain.core.Category +import org.gitanimals.quiz.domain.core.Language +import org.gitanimals.quiz.domain.core.Level +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query -interface QuizRepository : JpaRepository +interface QuizRepository : JpaRepository { + + @Query( + """ + select q + from Quiz q + where q.id < :lastId + and (:level is null or q.level = :level) + and (:category is null or q.category = :category) + and (:language is null or q.language = :language) + order by q.id desc + """ + ) + fun findAllByCursor( + lastId: Long, + level: Level?, + category: Category?, + language: Language?, + pageable: Pageable, + ): List +} diff --git a/src/main/kotlin/org/gitanimals/quiz/domain/approved/QuizService.kt b/src/main/kotlin/org/gitanimals/quiz/domain/approved/QuizService.kt index e832cb2..d57a078 100644 --- a/src/main/kotlin/org/gitanimals/quiz/domain/approved/QuizService.kt +++ b/src/main/kotlin/org/gitanimals/quiz/domain/approved/QuizService.kt @@ -7,6 +7,7 @@ import org.slf4j.LoggerFactory import org.springframework.data.domain.PageRequest import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional @Service class QuizService( @@ -119,6 +120,23 @@ class QuizService( updateQuizCountCacheScheduled() } + @Transactional(readOnly = true) + fun scrollApprovedQuizs( + lastId: Long, + level: Level?, + category: Category?, + language: Language?, + size: Int, + ): List { + return quizRepository.findAllByCursor( + lastId = lastId, + level = level, + category = category, + language = language, + pageable = PageRequest.of(0, size), + ) + } + companion object { private const val EVERY_1_HOURS = "0 0 * * * *" } diff --git a/src/main/kotlin/org/gitanimals/quiz/domain/context/QuizSolveContextRepository.kt b/src/main/kotlin/org/gitanimals/quiz/domain/context/QuizSolveContextRepository.kt index c420cb4..f9aaf73 100644 --- a/src/main/kotlin/org/gitanimals/quiz/domain/context/QuizSolveContextRepository.kt +++ b/src/main/kotlin/org/gitanimals/quiz/domain/context/QuizSolveContextRepository.kt @@ -1,6 +1,7 @@ package org.gitanimals.quiz.domain.context import jakarta.persistence.LockModeType +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query @@ -25,4 +26,19 @@ interface QuizSolveContextRepository : JpaRepository { id: Long, userId: Long, ): QuizSolveContext? + + @Query( + """ + select q + from QuizSolveContext q + where q.userId = :userId + and q.id < :lastId + order by q.id desc + """ + ) + fun findAllByUserIdAndCursor( + userId: Long, + lastId: Long, + pageable: Pageable, + ): List } diff --git a/src/main/kotlin/org/gitanimals/quiz/domain/context/QuizSolveContextService.kt b/src/main/kotlin/org/gitanimals/quiz/domain/context/QuizSolveContextService.kt index 8fcc719..aa437aa 100644 --- a/src/main/kotlin/org/gitanimals/quiz/domain/context/QuizSolveContextService.kt +++ b/src/main/kotlin/org/gitanimals/quiz/domain/context/QuizSolveContextService.kt @@ -3,6 +3,7 @@ package org.gitanimals.quiz.domain.context import org.gitanimals.core.instant import org.gitanimals.quiz.domain.approved.Quiz import org.gitanimals.quiz.domain.core.Category +import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -78,4 +79,17 @@ class QuizSolveContextService( return quizSolveContextRepository.findByIdAndUserId(id, userId) ?: throw IllegalArgumentException("Cannot find quizContext by id: \"$id\" and userId: \"$userId\"") } + + @Transactional(readOnly = true) + fun scrollQuizSolveContexts( + userId: Long, + lastId: Long, + size: Int, + ): List { + return quizSolveContextRepository.findAllByUserIdAndCursor( + userId = userId, + lastId = lastId, + pageable = PageRequest.of(0, size), + ) + } } diff --git a/src/main/kotlin/org/gitanimals/quiz/domain/not_approved/NotApprovedQuizRepository.kt b/src/main/kotlin/org/gitanimals/quiz/domain/not_approved/NotApprovedQuizRepository.kt index 8a5977c..3d83f2f 100644 --- a/src/main/kotlin/org/gitanimals/quiz/domain/not_approved/NotApprovedQuizRepository.kt +++ b/src/main/kotlin/org/gitanimals/quiz/domain/not_approved/NotApprovedQuizRepository.kt @@ -1,8 +1,32 @@ package org.gitanimals.quiz.domain.not_approved +import org.gitanimals.quiz.domain.core.Category +import org.gitanimals.quiz.domain.core.Language +import org.gitanimals.quiz.domain.core.Level +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query interface NotApprovedQuizRepository : JpaRepository { fun findAllByUserId(userId: Long): List + + @Query( + """ + select q + from NotApprovedQuiz q + where q.id < :lastId + and (:level is null or q.level = :level) + and (:category is null or q.category = :category) + and (:language is null or q.language = :language) + order by q.id desc + """ + ) + fun findAllByCursor( + lastId: Long, + level: Level?, + category: Category?, + language: Language?, + pageable: Pageable, + ): List } diff --git a/src/main/kotlin/org/gitanimals/quiz/domain/not_approved/NotApprovedQuizService.kt b/src/main/kotlin/org/gitanimals/quiz/domain/not_approved/NotApprovedQuizService.kt index 263efa0..faed100 100644 --- a/src/main/kotlin/org/gitanimals/quiz/domain/not_approved/NotApprovedQuizService.kt +++ b/src/main/kotlin/org/gitanimals/quiz/domain/not_approved/NotApprovedQuizService.kt @@ -1,8 +1,10 @@ package org.gitanimals.quiz.domain.not_approved import org.gitanimals.quiz.domain.core.Category +import org.gitanimals.quiz.domain.core.Language import org.gitanimals.quiz.domain.core.Level import org.slf4j.LoggerFactory +import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -46,4 +48,21 @@ class NotApprovedQuizService( fun deleteQuizById(notApprovedQuizId: Long) = notApprovedQuizRepository.deleteById(notApprovedQuizId) + + @Transactional(readOnly = true) + fun scrollNotApprovedQuizs( + lastId: Long, + level: Level?, + category: Category?, + language: Language?, + size: Int, + ): List { + return notApprovedQuizRepository.findAllByCursor( + lastId = lastId, + level = level, + category = category, + language = language, + pageable = PageRequest.of(0, size), + ) + } }