Skip to content
Merged
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
27 changes: 27 additions & 0 deletions docs/admin/quiz/approve_not_approved_quiz.md
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions docs/admin/quiz/delete_approved_quiz.md
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions docs/admin/quiz/delete_not_approved_quiz.md
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions docs/admin/quiz/scroll_approved_quizs.md
Original file line number Diff line number Diff line change
@@ -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`
46 changes: 46 additions & 0 deletions docs/admin/quiz/scroll_not_approved_quizs.md
Original file line number Diff line number Diff line change
@@ -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`
52 changes: 52 additions & 0 deletions docs/admin/quiz/scroll_quiz_solve_contexts_by_user_id.md
Original file line number Diff line number Diff line change
@@ -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`
44 changes: 34 additions & 10 deletions src/main/kotlin/org/gitanimals/core/CoroutineScope.kt
Original file line number Diff line number Diff line change
@@ -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<ExecutorService> = 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)
}
}
Expand All @@ -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()
}
}
Expand Down
Loading