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
20 changes: 18 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ ktlint {
version = properties["ktlintVersion"] as String
}

tasks.withType<Test> {
useJUnitPlatform()
tasks.test {
useJUnitPlatform {
excludeTags("network")
}
}

// 외부 상태에 결과가 달려 있어 기본 test에서 뺀다. 네트워크나 GitHub이 흔들리면 우리 잘못 없이 빨간불이 된다.
// 캐시도 끈다 — 지난번 통과했다고 건너뛰면 정작 확인하려던 것을 확인하지 않는다.
tasks.register<Test>("networkTest") {
testClassesDirs =
sourceSets.test
.get()
.output.classesDirs
classpath = sourceSets.test.get().runtimeClasspath
useJUnitPlatform {
includeTags("network")
}
outputs.upToDateWhen { false }
}
78 changes: 78 additions & 0 deletions src/main/kotlin/com/nexters/gitit/application/RegisterProject.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.nexters.gitit.application

import com.nexters.gitit.domain.exception.BaseException
import com.nexters.gitit.domain.exception.ErrorCode
import com.nexters.gitit.domain.project.Project
import com.nexters.gitit.domain.project.ProjectRepository
import com.nexters.gitit.domain.project.QuizLevel
import com.nexters.gitit.domain.quizrepo.GithubRepositoryResolver
import com.nexters.gitit.domain.quizrepo.QuizGenerationRequested
import com.nexters.gitit.domain.quizrepo.QuizRepo
import com.nexters.gitit.domain.quizrepo.QuizRepoRepository
import com.nexters.gitit.domain.quizrepo.QuizRepoStatus
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service

@Service
class RegisterProject(
private val quizRepoRepository: QuizRepoRepository,
private val projectRepository: ProjectRepository,
private val githubRepositoryResolver: GithubRepositoryResolver,
private val eventPublisher: ApplicationEventPublisher,
) {
/**
* GitHub 저장소를 받아 회원의 프로젝트로 등록합니다. 다른 회원이 이미 등록해 둔 저장소면 문제 세트를 새로
* 만들지 않고 그대로 함께 씁니다.
*
* 문제 생성을 기다리지 않고 이벤트로 넘기므로, 갓 등록한 저장소의 결과 상태는 아직 완료가 아닙니다.
*
* 등록할 수 없는 저장소는 결과가 아니라 예외로 알립니다. GitHub에 없으면 도큐먼트를 남기지 않는데, 식별자를
* 얻지 못해 유니크 키를 만들 수 없고 오타 URL마다 레코드가 쌓이기 때문입니다. 문제를 낼 수 없다고 이미
* 판정된 저장소는 그때 기록해 둔 사유를 그대로 던집니다.
*/
operator fun invoke(command: Command): Result {
val githubRepoId =
githubRepositoryResolver.resolve(command.githubRepoUrl)
?: throw BaseException(ErrorCode.INVALID_INPUT, "유효하지 않은 GitHub 저장소입니다")

val quizRepo = registerQuizRepo(githubRepoId, command.githubRepoUrl)
if (quizRepo.status == QuizRepoStatus.REJECTED) {
// reject()가 상태와 사유를 함께 세팅하므로 사유가 빌 수 없지만, 타입이 nullable이라 기본값을 둔다.
throw BaseException(quizRepo.rejectedReason ?: ErrorCode.INVALID_INPUT)
}

val project = projectRepository.saveIfAbsent(Project(command.memberId, quizRepo.id, command.quizLevel))

return Result(project, quizRepo.status)
}

/**
* 넘긴 객체가 그대로 돌아왔을 때만 이번 요청이 저장소를 만든 것이고, 그때만 문제 생성을 겁니다.
* 이미 있던 저장소까지 이벤트를 내면 같은 저장소에 수 분짜리 생성 작업이 중복으로 돕니다.
*/
private fun registerQuizRepo(
githubRepoId: String,
githubRepoUrl: String,
): QuizRepo {
val requested = QuizRepo(githubRepoId = githubRepoId, githubRepoUrl = githubRepoUrl)
val quizRepo = quizRepoRepository.saveIfAbsent(requested)
if (quizRepo.id == requested.id) {
eventPublisher.publishEvent(QuizGenerationRequested(quizRepo.id))
}

return quizRepo
}

data class Command(
val memberId: String,
val githubRepoUrl: String,
val quizLevel: QuizLevel,
)

data class Result(
val projectId: String,
val status: QuizRepoStatus,
) {
constructor(project: Project, status: QuizRepoStatus) : this(project.id, status)
}
}
27 changes: 27 additions & 0 deletions src/main/kotlin/com/nexters/gitit/domain/project/Project.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.nexters.gitit.domain.project

import com.nexters.gitit.domain.common.BaseEntity
import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document

/**
* 회원 한 명이 문제 저장소 하나를 학습하는 단위. 사용자에게 보이는 "내 프로젝트"가 이것입니다.
*
* 문제는 `QuizRepo`에 공용으로 모아두고 여기서는 그 참조와 회원별 난이도만 갖습니다. 회원이나 저장소 어느
* 한쪽에 배열로 품지 않고 따로 둔 이유는 조회 방향이 둘 다 필요해서입니다 — "내 프로젝트 목록"은 [memberId]로
* 찾고, 문제 생성이 끝났을 때 알릴 대상은 [quizRepoId]로 찾습니다. 두 방향 모두 인덱스로 풀리게 걸어 둡니다.
*/
@Document(collection = "projects")
@CompoundIndex(
name = "uk_member_quiz_repo",
def = "{'memberId': 1, 'quizRepoId': 1}",
unique = true,
partialFilter = "{'deletedAt': null}",
)
class Project(
val memberId: String,
@Indexed(name = "idx_quiz_repo_id")
val quizRepoId: String,
val quizLevel: QuizLevel,
) : BaseEntity()
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.nexters.gitit.domain.project

interface ProjectRepository {
/**
* 그 회원이 이미 학습 중인 저장소면 기존 프로젝트를, 아니면 새로 저장한 것을 돌려줍니다.
*
* 이미 있을 때 난이도를 덮어쓰지 않는 것이 이 메서드의 계약입니다. 난이도 변경은 등록과 구분되는 별도 행위입니다.
*/
fun saveIfAbsent(project: Project): Project
}
13 changes: 13 additions & 0 deletions src/main/kotlin/com/nexters/gitit/domain/project/QuizLevel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.nexters.gitit.domain.project

/**
* 회원이 고른 문제 난이도.
*
* 직급(주니어·시니어)이 아니라 프로젝트를 얼마나 깊이 파고들지로만 나눕니다.
* [L1] 오리엔테이션급 · [L2] 동작 이해 · [L3] 설계 이해.
*/
enum class QuizLevel {
L1,
L2,
L3,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.nexters.gitit.domain.quizrepo

interface GithubRepositoryResolver {
/**
* URL을 파싱하고 GitHub에 실재하는지 확인해 그 저장소의 GitHub id를 반환합니다. 둘을 한 호출로 묶은 것은
* id를 얻었다는 사실 자체가 곧 등록 가능하다는 뜻이 되게 하려는 것입니다. 파싱 실패든 GitHub에 없음이든
* 구분 없이 null이라, 호출부는 "파싱은 됐는데 없는 저장소" 같은 중간 상태를 다루지 않습니다.
*
* `owner/name`이 아니라 id인 것은 리네임·소유자 이전을 견디기 위해서입니다. 이름을 키로 잡으면 리네임
* 한 번에 같은 저장소의 문제 세트가 둘로 갈라집니다.
*/
fun resolve(githubRepoUrl: String): String?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.nexters.gitit.domain.quizrepo

/**
* 문제를 채워 넣어야 할 저장소가 새로 생겼음을 알립니다.
*
* 생성은 수 분이 걸려 요청 스레드에서 끝낼 수 없으므로, 등록은 여기서 끊고 실제 생성은 이 이벤트를 받는 쪽이 맡습니다.
* 스냅숏 대신 식별자만 싣는 이유는 수신 시점에 상태가 이미 달라져 있을 수 있어, 받는 쪽이 다시 읽는 편이 안전해서입니다.
*/
data class QuizGenerationRequested(
val quizRepoId: String,
)
41 changes: 41 additions & 0 deletions src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.nexters.gitit.domain.quizrepo

import com.nexters.gitit.domain.common.BaseEntity
import com.nexters.gitit.domain.exception.ErrorCode
import org.springframework.data.mongodb.core.index.CompoundIndex
import org.springframework.data.mongodb.core.mapping.Document

/**
* GitHub 저장소 하나에서 뽑아낸 문제를 모아두는 곳.
*
* 회원이 아니라 저장소가 주인공인 공용 애그리거트라, 같은 저장소를 여러 회원이 등록해도 문제 세트는 하나만
* 만들고 나눠 씁니다. 누가 이걸 학습하는지와 회원별 난이도는 `Project`가 들고 있어, 회원 수와 무관하게
* 이 도큐먼트의 크기가 고정됩니다.
*/
@Document(collection = "quiz_repos")
@CompoundIndex(
name = "uk_github_repo_id",
def = "{'githubRepoId': 1}",
unique = true,
partialFilter = "{'deletedAt': null}",
)
class QuizRepo(
val githubRepoId: String,
val githubRepoUrl: String,
) : BaseEntity() {
// 생성 파이프라인이 최종 상태를 결정하므로 등록 시점에는 항상 시작 상태다.
var status: QuizRepoStatus = QuizRepoStatus.READY
private set

// 전용 enum을 만들지 않고 ErrorCode를 재사용하는 것은, 어차피 클라이언트에게 같은 코드로 알려줘야 해서 목록이 두 벌이 되기 때문이다.
var rejectedReason: ErrorCode? = null
private set

/**
* 상태와 사유를 함께 바꿔 사유 없는 [QuizRepoStatus.REJECTED]가 생기지 않게 합니다.
*/
fun reject(reason: ErrorCode) {
status = QuizRepoStatus.REJECTED
rejectedReason = reason
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.nexters.gitit.domain.quizrepo

interface QuizRepoRepository {
/**
* 같은 저장소가 이미 있으면 그것을, 없으면 새로 저장한 것을 돌려줍니다.
*
* 여러 요청이 같은 저장소를 동시에 등록해도 하나만 남습니다. 그래서 호출부는 넘긴 객체가 그대로 돌아왔는지로
* 자기가 만든 것인지 알 수 있습니다.
*/
fun saveIfAbsent(quizRepo: QuizRepo): QuizRepo
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.nexters.gitit.domain.quizrepo

/**
* 문제 저장소의 문제 생성이 어디까지 왔는지. 등록 직후가 [READY], 문제가 다 만들어지면 [COMPLETED],
* 문제를 낼 수 없다고 판정되면 [REJECTED]입니다.
*
* 값이 셋뿐인 것은 생성 중간 단계(문서 분석·앵커·생성 등)에 아직 이름을 붙이지 않았기 때문입니다.
* 파이프라인이 붙으면 [READY]와 [COMPLETED] 사이에 값이 여럿 끼어들 예정이라, 상태를 판정할 때는
* 해당하는 값을 나열하기보다 "무엇이 아닌가"로 거르는 쪽이 새 값에 안 깨집니다.
*/
enum class QuizRepoStatus {
READY,
REJECTED,
COMPLETED,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.nexters.gitit.infrastructure.github

import com.nexters.gitit.domain.quizrepo.GithubRepositoryResolver
import org.springframework.stereotype.Component
import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestClient
import org.springframework.web.client.body

@Component
class GithubApiRepositoryResolver(
private val githubRestClient: RestClient,
) : GithubRepositoryResolver {
/**
* 404만 null로 접고 나머지는 그대로 던집니다. 5xx·네트워크 오류까지 "없음"으로 묻으면 멀쩡한 저장소가
* 등록을 거부당하고, 사용자는 다시 시도하면 되는 상황인 줄 모릅니다.
*/
override fun resolve(githubRepoUrl: String): String? {
val (owner, name) = parseOwnerAndName(githubRepoUrl) ?: return null

return try {
githubRestClient
.get()
.uri("/repos/{owner}/{name}", owner, name)
.retrieve()
.body<GithubRepositoryResponse>()
?.id
?.toString()
} catch (_: HttpClientErrorException.NotFound) {
// 비공개 저장소도 404다. 토큰 없이 못 읽는다는 점에서 없는 것과 결과가 같아 구분하지 않는다.
null
}
}

/** 이름에 점이 들어가는 경우(`socket.io`)와 `.git` 접미사를 모두 받으려고 이름을 최소 일치로 잡습니다. */
private fun parseOwnerAndName(githubRepoUrl: String): Pair<String, String>? {
val match = REPO_URL_PATTERN.matchEntire(githubRepoUrl.trim()) ?: return null

return match.groupValues[1] to match.groupValues[2]
}

// 나머지 필드는 Spring Boot 기본 설정(FAIL_ON_UNKNOWN_PROPERTIES=false)이 무시한다.
private data class GithubRepositoryResponse(
val id: Long,
)

companion object {
/**
* 부분 일치로 찾으면 `notgithub.com/o/n`처럼 호스트가 다른 URL도 통과합니다. 호출 대상이 api.github.com으로
* 고정이라 다른 곳을 찌를 수는 없지만, 사용자가 준 적 없는 저장소가 등록됩니다. 그래서 문자열 전체를 맞춥니다.
*
* 소유자·이름을 GitHub 허용 문자로 좁힌 것도 쿼리스트링이 이름에 묻어 들어가지 않게 하려는 것입니다.
*/
private val REPO_URL_PATTERN = Regex("""(?:https?://)?(?:www\.)?github\.com/([\w.-]+)/([\w.-]+?)(?:\.git)?/?""")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.nexters.gitit.infrastructure.github

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.retry.RetryPolicy
import org.springframework.core.retry.RetryTemplate
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.web.client.RestClient
import java.io.IOException
import java.time.Duration

/**
* GitHub API 호출용 클라이언트를 빈으로 둡니다.
*
* 호출부에서 직접 만들면 인증 헤더나 타임아웃 같은 공통 설정을 나중에 붙일 때 손댈 자리가 여러 곳으로
* 흩어지고, 테스트에서 가짜 클라이언트로 바꿔 끼울 수도 없습니다.
*/
@Configuration
class GithubClientConfiguration {
@Bean
fun githubRestClient(): RestClient =
RestClient
.builder()
.baseUrl(GITHUB_API_BASE_URL)
.requestFactory(timeoutBoundRequestFactory())
.requestInterceptor { request, body, execution -> RETRY.execute { execution.execute(request, body) } }
.build()

/**
* 저장소 하나를 읽는 단건 조회라 정상이면 수백 ms 안에 끝납니다.
*
* 타임아웃은 재시도 횟수와 곱해져 그대로 사용자 대기 시간이 됩니다 — `(연결 + 읽기) × (재시도 + 1) + 지연`.
* 값을 올릴 때는 이 곱을 같이 보고 정해야 합니다.
*/
private fun timeoutBoundRequestFactory() =
SimpleClientHttpRequestFactory().apply {
setConnectTimeout(CONNECT_TIMEOUT)
setReadTimeout(READ_TIMEOUT)
}

companion object {
private const val GITHUB_API_BASE_URL = "https://api.github.com"

private val CONNECT_TIMEOUT = Duration.ofSeconds(1)
private val READ_TIMEOUT = Duration.ofSeconds(1)
private val RETRY_DELAY = Duration.ofMillis(200)
private const val MAX_RETRIES = 1L

// 조회뿐이라 몇 번을 보내도 부작용이 없다. 연결이 한 번 끊겼다고 등록이 실패하지 않을 만큼만 짧게 둔다.
// 응답을 받아낸 뒤의 5xx는 여기서 재시도하지 않는다 — 그건 GitHub이 실제로 답을 준 상태라 성격이 다르다.
private val RETRY =
RetryTemplate(
RetryPolicy
.builder()
.maxRetries(MAX_RETRIES)
.delay(RETRY_DELAY)
.includes(IOException::class.java)
.build(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.nexters.gitit.infrastructure.mongo

import com.nexters.gitit.domain.project.Project
import com.nexters.gitit.domain.project.ProjectRepository
import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Repository

@Repository
class MongoProjectRepository(
private val projectRepository: SpringDataProjectRepository,
) : ProjectRepository {
/**
* 회원과 저장소 조합에 걸린 유니크 인덱스를 최종 판정자로 씁니다. 요청이 연달아 들어와도 프로젝트가 둘로 늘지 않고,
* 밀린 쪽은 이긴 쪽 도큐먼트를 다시 읽어 돌려줍니다.
*/
override fun saveIfAbsent(project: Project): Project =
findByMemberIdAndQuizRepoId(project.memberId, project.quizRepoId)
?: try {
projectRepository.save(project)
} catch (e: DuplicateKeyException) {
findByMemberIdAndQuizRepoId(project.memberId, project.quizRepoId) ?: throw e
}

private fun findByMemberIdAndQuizRepoId(
memberId: String,
quizRepoId: String,
): Project? = projectRepository.findByMemberIdAndQuizRepoIdAndDeletedAtIsNull(memberId, quizRepoId)
}
Loading
Loading