diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..056f9d0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.kt] +ktlint_standard_package-name = disabled +ktlint_standard_filename = disabled \ No newline at end of file diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts deleted file mode 100644 index a6e17cc..0000000 --- a/build-logic/build.gradle.kts +++ /dev/null @@ -1,11 +0,0 @@ -plugins { - `kotlin-dsl` - id("casper.documentation-convention") -} - -group = "io.casper.build" -version = "1.0.0" - -repositories { - mavenCentral() -} \ No newline at end of file diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts deleted file mode 100644 index 2af0ef2..0000000 --- a/build-logic/settings.gradle.kts +++ /dev/null @@ -1,17 +0,0 @@ -rootProject.name = "build-logic" - -pluginManagement { - repositories { - gradlePluginPortal() - mavenCentral() - } - - // convention 모듈 참조 - includeBuild("../casper-convention") -} - -dependencyResolutionManagement { - repositories { - mavenCentral() - } -} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/io/casper/build/TestClass.kt b/build-logic/src/main/kotlin/io/casper/build/TestClass.kt deleted file mode 100644 index 6a5992d..0000000 --- a/build-logic/src/main/kotlin/io/casper/build/TestClass.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.casper.build - -/** - * 이 클래스는 KDoc 주석 검사 테스트를 위한 용도입니다. - * 이제 올바른 KDoc 주석 형식을 사용합니다. - */ -class TestClass { - - /** - * 이 함수는 빌드 로직에서 사용하는 테스트 함수입니다. - */ - fun testFunction() { - println("이 함수는 문서화 검사를 테스트하기 위한 용도입니다.") - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7822ed8..f338d44 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,71 +1,34 @@ -import org.gradle.kotlin.dsl.withType -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -import kotlin.collections.plus - plugins { - kotlin("jvm") version "1.9.23" - kotlin("plugin.spring") version "1.9.23" - id("org.springframework.boot") version "3.4.4" - id("io.spring.dependency-management") version "1.1.7" - id("org.jlleitschuh.gradle.ktlint").version("12.1.1") - id("io.gitlab.arturbosch.detekt") version "1.23.6" - id("casper.documentation-convention") + id(Plugin.KOTLIN_JVM) version PluginVersion.KOTLIN_VERSION apply false + id(Plugin.DETEKT) version PluginVersion.DETEKT_VERSION + id(Plugin.KTLINT) version PluginVersion.KTLINT_VERSION apply false } -// 서브프로젝트 설정 subprojects { - // 서브프로젝트에 공통 설정 적용 - repositories { - mavenCentral() - } + group = "hs.kr.entrydsm" + version = "0.0.1" + + apply(plugin = Plugin.KTLINT) + } tasks.register("checkAll") { group = "verification" description = "모든 모듈(includeBuild 포함)에 대해 check 태스크를 실행합니다" - // 루트 프로젝트의 check 태스크에 의존 - dependsOn(tasks.named("check")) - // 모든 서브프로젝트의 check 태스크에 의존 subprojects.forEach { subproject -> dependsOn(subproject.tasks.matching { it.name.startsWith("check") }) } - // build-logic, convention 등 includeBuild 모듈의 check 태스크에 의존 - dependsOn(gradle.includedBuilds.map { it.task(":check") }) -} - -group = "hs.kr.entrydsm" -version = "0.0.1-SNAPSHOT" - -dependencies { - - implementation("org.springframework.boot:spring-boot-starter") - implementation("org.jetbrains.kotlin:kotlin-reflect") - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} -kotlin { - jvmToolchain(17) -} - -tasks.withType { - kotlinOptions { - freeCompilerArgs += "-Xjsr305=strict" - } -} - -tasks.withType { - useJUnitPlatform() + dependsOn(gradle.includedBuilds.map { it.task(":check") }) } detekt { config.setFrom(files("detekt.yml")) - buildUponDefaultConfig = false // yml에서 설정한 룰만 허용 - parallel = true // 병렬 실행으로 성능 최적화 + buildUponDefaultConfig = false + parallel = true } tasks.withType().configureEach { @@ -74,5 +37,8 @@ tasks.withType().configureEach { txt.required.set(false) } - jvmTarget = ("17") // Detekt가 사용하는 JVM 타겟을 Java 17로 지정 + jvmTarget = "17" } + + + diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..3753e18 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} + + diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt new file mode 100644 index 0000000..59974f5 --- /dev/null +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -0,0 +1,54 @@ +object Dependencies { + // Spring Boot + const val SPRING_BOOT_STARTER = "org.springframework.boot:spring-boot-starter" + const val SPRING_BOOT_STARTER_WEB = "org.springframework.boot:spring-boot-starter-web" + const val SPRING_BOOT_STARTER_DATA_JPA = "org.springframework.boot:spring-boot-starter-data-jpa" + const val SPRING_BOOT_STARTER_DATA_REDIS = "org.springframework.boot:spring-boot-starter-data-redis" + const val SPRING_BOOT_STARTER_SECURITY = "org.springframework.boot:spring-boot-starter-security" + const val SPRING_BOOT_STARTER_VALIDATION = "org.springframework.boot:spring-boot-starter-validation" + const val SPRING_BOOT_STARTER_TEST = "org.springframework.boot:spring-boot-starter-test" + + // Kotlin + const val KOTLIN_REFLECT = "org.jetbrains.kotlin:kotlin-reflect" + const val KOTLIN_TEST_JUNIT5 = "org.jetbrains.kotlin:kotlin-test-junit5" + + // Database + const val MYSQL_CONNECTOR = "com.mysql:mysql-connector-j" + + // JSON + const val JACKSON_MODULE_KOTLIN = "com.fasterxml.jackson.module:jackson-module-kotlin" + const val ORG_JSON = "org.json:json:${DependencyVersion.ORG_JSON}" + + // JWT + const val JWT_API = "io.jsonwebtoken:jjwt-api:${DependencyVersion.JWT}" + const val JWT_IMPL = "io.jsonwebtoken:jjwt-impl:${DependencyVersion.JWT}" + const val JWT_JACKSON = "io.jsonwebtoken:jjwt-jackson:${DependencyVersion.JWT}" + + // MapStruct + const val MAPSTRUCT = "org.mapstruct:mapstruct:${DependencyVersion.MAPSTRUCT}" + const val MAPSTRUCT_PROCESSOR = "org.mapstruct:mapstruct-processor:${DependencyVersion.MAPSTRUCT}" + + // Test + const val JUNIT_PLATFORM_LAUNCHER = "org.junit.platform:junit-platform-launcher" + + // gRPC + const val GRPC_NETTY_SHADED = "io.grpc:grpc-netty-shaded:${DependencyVersion.GRPC}" + const val GRPC_PROTOBUF = "io.grpc:grpc-protobuf:${DependencyVersion.GRPC}" + const val GRPC_STUB = "io.grpc:grpc-stub:${DependencyVersion.GRPC}" + const val GRPC_KOTLIN_STUB = "io.grpc:grpc-kotlin-stub:${DependencyVersion.GRPC_KOTLIN}" + const val PROTOBUF_KOTLIN = "com.google.protobuf:protobuf-kotlin:${DependencyVersion.PROTOBUF}" + const val GRPC_TESTING = "io.grpc:grpc-testing:${DependencyVersion.GRPC}" + const val GRPC_SERVER_SPRING_BOOT_STARTER = "net.devh:grpc-server-spring-boot-starter:${DependencyVersion.GRPC_SPRING_BOOT_STARTER}" + + + //swagger + const val SWAGGER = "org.springdoc:springdoc-openapi-starter-webmvc-ui:${DependencyVersion.SWAGGER}" + + // Sentry + const val SENTRY_SPRING_BOOT_STARTER = "io.sentry:sentry-spring-boot-starter-jakarta:${DependencyVersion.SENTRY}" + + //resilience4j + const val RESILIENCE4J = "io.github.resilience4j:resilience4j-circuitbreaker:${DependencyVersion.RESILIENCE4J}" + const val RESILIENCE4J_SPRING_BOOT = "io.github.resilience4j:resilience4j-spring-boot3:${DependencyVersion.RESILIENCE4J}" + +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/DependencyVersion.kt b/buildSrc/src/main/kotlin/DependencyVersion.kt new file mode 100644 index 0000000..919f162 --- /dev/null +++ b/buildSrc/src/main/kotlin/DependencyVersion.kt @@ -0,0 +1,22 @@ +object DependencyVersion { + const val KOTLIN = "1.9.25" + const val SPRING_BOOT = "3.4.4" + const val SPRING_DEPENDENCY_MANAGEMENT = "1.1.7" + const val DETEKT = "1.23.6" + const val KTLINT = "12.1.1" + + const val JWT = "0.11.5" + const val ORG_JSON = "20230227" + const val MAPSTRUCT = "1.6.0" + + const val GRPC = "1.61.1" + const val GRPC_KOTLIN = "1.4.1" + const val PROTOBUF = "3.25.3" + const val GRPC_SPRING_BOOT_STARTER = "3.1.0.RELEASE" + + const val SWAGGER = "2.7.0" + + const val SENTRY = "7.14.0" + + const val RESILIENCE4J = "2.2.0" +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Plugin.kt b/buildSrc/src/main/kotlin/Plugin.kt new file mode 100644 index 0000000..84e3329 --- /dev/null +++ b/buildSrc/src/main/kotlin/Plugin.kt @@ -0,0 +1,11 @@ +object Plugin { + const val KOTLIN_JVM = "org.jetbrains.kotlin.jvm" + const val KOTLIN_SPRING = "org.jetbrains.kotlin.plugin.spring" + const val KOTLIN_KAPT = "org.jetbrains.kotlin.kapt" + const val SPRING_BOOT = "org.springframework.boot" + const val SPRING_DEPENDENCY_MANAGEMENT = "io.spring.dependency-management" + const val DETEKT = "io.gitlab.arturbosch.detekt" + const val KTLINT = "org.jlleitschuh.gradle.ktlint" + const val CASPER_DOCUMENTATION = "casper.documentation-convention" + const val PROTOBUF = "com.google.protobuf" +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/PluginVersion.kt b/buildSrc/src/main/kotlin/PluginVersion.kt new file mode 100644 index 0000000..9b16506 --- /dev/null +++ b/buildSrc/src/main/kotlin/PluginVersion.kt @@ -0,0 +1,8 @@ +object PluginVersion { + const val KOTLIN_VERSION = "1.9.25" + const val SPRING_BOOT_VERSION = "3.4.4" + const val SPRING_DEPENDENCY_MANAGEMENT_VERSION = "1.1.7" + const val DETEKT_VERSION = "1.23.6" + const val KTLINT_VERSION = "12.1.1" + const val PROTOBUF_VERSION = "0.9.4" +} \ No newline at end of file diff --git a/casper-status/build.gradle.kts b/casper-status/build.gradle.kts new file mode 100644 index 0000000..b5f5a7c --- /dev/null +++ b/casper-status/build.gradle.kts @@ -0,0 +1,117 @@ +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id(Plugin.KOTLIN_JVM) version PluginVersion.KOTLIN_VERSION + id(Plugin.KOTLIN_SPRING) version PluginVersion.KOTLIN_VERSION + id(Plugin.KOTLIN_KAPT) + id(Plugin.SPRING_BOOT) version PluginVersion.SPRING_BOOT_VERSION + id(Plugin.SPRING_DEPENDENCY_MANAGEMENT) version PluginVersion.SPRING_DEPENDENCY_MANAGEMENT_VERSION + id(Plugin.CASPER_DOCUMENTATION) + id(Plugin.PROTOBUF) version PluginVersion.PROTOBUF_VERSION +} + +dependencies { + // 스프링 부트 기본 기능 + implementation(Dependencies.SPRING_BOOT_STARTER) + + // 코틀린 리플렉션 + implementation(Dependencies.KOTLIN_REFLECT) + + // 스프링 부트 테스트 도구 + testImplementation(Dependencies.SPRING_BOOT_STARTER_TEST) + + // 코틀린 + JUnit5 테스트 + testImplementation(Dependencies.KOTLIN_TEST_JUNIT5) + + // JUnit5 실행 런처 + testRuntimeOnly(Dependencies.JUNIT_PLATFORM_LAUNCHER) + + // 웹 관련 + implementation(Dependencies.SPRING_BOOT_STARTER_WEB) + + // 데이터베이스 + implementation(Dependencies.SPRING_BOOT_STARTER_DATA_JPA) + implementation(Dependencies.SPRING_BOOT_STARTER_DATA_REDIS) + runtimeOnly(Dependencies.MYSQL_CONNECTOR) + + // 보안 + implementation(Dependencies.SPRING_BOOT_STARTER_SECURITY) + + // 검증 + implementation(Dependencies.SPRING_BOOT_STARTER_VALIDATION) + + // JSON 처리 + implementation(Dependencies.JACKSON_MODULE_KOTLIN) + implementation(Dependencies.ORG_JSON) + + // JWT + implementation(Dependencies.JWT_API) + implementation(Dependencies.JWT_IMPL) + runtimeOnly(Dependencies.JWT_JACKSON) + + // mapStruct + implementation(Dependencies.MAPSTRUCT) + + // kapt + kapt(Dependencies.MAPSTRUCT_PROCESSOR) + + // grpc + implementation(Dependencies.GRPC_NETTY_SHADED) + implementation(Dependencies.GRPC_PROTOBUF) + implementation(Dependencies.GRPC_STUB) + implementation(Dependencies.GRPC_KOTLIN_STUB) + implementation(Dependencies.PROTOBUF_KOTLIN) + implementation(Dependencies.GRPC_SERVER_SPRING_BOOT_STARTER) + testImplementation(Dependencies.GRPC_TESTING) + + // swagger + implementation(Dependencies.SWAGGER) + + // Sentry + implementation(Dependencies.SENTRY_SPRING_BOOT_STARTER) + + // Resilience4j + implementation(Dependencies.RESILIENCE4J) + implementation(Dependencies.RESILIENCE4J_SPRING_BOOT) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${DependencyVersion.PROTOBUF}" + } + plugins { + create("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:${DependencyVersion.GRPC}" + } + create("grpckt") { + artifact = "io.grpc:protoc-gen-grpc-kotlin:${DependencyVersion.GRPC_KOTLIN}:jdk8@jar" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + create("grpc") + create("grpckt") + } + } + } +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(17) +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + } +} + +tasks.withType { + useJUnitPlatform() +} \ No newline at end of file diff --git a/src/main/kotlin/hs/kr/entrydsm/status/CasperStatusApplication.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/CasperStatusApplication.kt similarity index 100% rename from src/main/kotlin/hs/kr/entrydsm/status/CasperStatusApplication.kt rename to casper-status/src/main/kotlin/hs/kr/entrydsm/status/CasperStatusApplication.kt diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/config/ConfigurationPropertiesConfig.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/config/ConfigurationPropertiesConfig.kt new file mode 100644 index 0000000..b927e8c --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/config/ConfigurationPropertiesConfig.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.status.global.config + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.context.annotation.Configuration + +/** + * Configuration Properties 스캔 설정 클래스입니다. + * 애플리케이션 전반의 설정 프로퍼티들을 자동으로 스캔하여 등록합니다. + */ +@ConfigurationPropertiesScan("hs.kr.entrydsm.status") +@Configuration +class ConfigurationPropertiesConfig diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/document/status/AdminStatusApiDocument.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/document/status/AdminStatusApiDocument.kt new file mode 100644 index 0000000..c685cc1 --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/document/status/AdminStatusApiDocument.kt @@ -0,0 +1,100 @@ +package hs.kr.entrydsm.status.global.document.status + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.PathVariable + +/** + * Admin Status API 문서화를 위한 인터페이스입니다. + */ +@Tag(name = "Admin Status", description = "관리자용 지원 상태 관리 API") +interface AdminStatusApiDocument { + + @Operation( + summary = "지원서 제출 취소", + description = "제출된 지원서를 작성 중 상태로 되돌립니다. 관리자 전용 기능입니다." + ) + @ApiResponses( + ApiResponse( + responseCode = "204", + description = "지원서 제출 취소 성공", + content = arrayOf(Content()) + ), + ApiResponse( + responseCode = "404", + description = "상태를 찾을 수 없음 - Status Not Found", + content = arrayOf(Content()) + ) + ) + fun cancelApplicationSubmit( + @Parameter(description = "접수번호", required = true) + @PathVariable("receipt-code") receiptCode: Long + ) + + @Operation( + summary = "서류 도착 확인", + description = "등기우편으로 제출된 서류의 도착을 확인하여 서류 접수 완료 상태로 변경합니다." + ) + @ApiResponses( + ApiResponse( + responseCode = "204", + description = "서류 도착 확인 성공", + content = arrayOf(Content()) + ), + ApiResponse( + responseCode = "404", + description = "상태를 찾을 수 없음 - Status Not Found", + content = arrayOf(Content()) + ) + ) + fun updateIsPrintsArrived( + @Parameter(description = "접수번호", required = true) + @PathVariable("receipt-code") receiptCode: Long + ) + + @Operation( + summary = "전형 시작", + description = "서류 검토가 완료된 후 1차 또는 2차 전형을 시작합니다." + ) + @ApiResponses( + ApiResponse( + responseCode = "204", + description = "전형 시작 성공", + content = arrayOf(Content()) + ), + ApiResponse( + responseCode = "404", + description = "상태를 찾을 수 없음 - Status Not Found", + content = arrayOf(Content()) + ) + ) + fun startScreening( + @Parameter(description = "접수번호", required = true) + @PathVariable("receipt-code") receiptCode: Long + ) + + @Operation( + summary = "합격 결과 발표", + description = "최종 전형 결과를 발표하고 합격 여부 확인 상태로 변경합니다." + ) + @ApiResponses( + ApiResponse( + responseCode = "204", + description = "합격 결과 발표 성공", + content = arrayOf(Content()) + ), + ApiResponse( + responseCode = "404", + description = "상태를 찾을 수 없음 - Status Not Found", + content = arrayOf(Content()) + ) + ) + fun announceResult( + @Parameter(description = "접수번호", required = true) + @PathVariable("receipt-code") receiptCode: Long + ) +} \ No newline at end of file diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/document/status/InternalStatusApiDocument.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/document/status/InternalStatusApiDocument.kt new file mode 100644 index 0000000..d3095bd --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/document/status/InternalStatusApiDocument.kt @@ -0,0 +1,76 @@ +package hs.kr.entrydsm.status.global.document.status + +import hs.kr.entrydsm.status.infrastructure.grpc.server.dto.response.InternalStatusResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestParam + +/** + * Internal Status API 문서화를 위한 인터페이스입니다. + */ +@Tag(name = "Internal Status", description = "내부 시스템용 지원 상태 API") +interface InternalStatusApiDocument { + + @Operation( + summary = "접수번호로 상태 조회", + description = "특정 접수번호의 지원 상태 정보를 조회합니다. 캐시를 활용하여 성능을 최적화합니다." + ) + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "상태 조회 성공", + content = arrayOf(Content(schema = Schema(implementation = InternalStatusResponse::class))) + ), // todo: 나중에 머지 했을 때도 오류 났을 경우 수정하기 + ApiResponse( + responseCode = "404", + description = "상태를 찾을 수 없음 - Status Not Found", + content = arrayOf(Content()) + ) + ) + fun getStatusByReceiptCode( + @Parameter(description = "조회할 접수번호", required = true) + @PathVariable("receipt-code") receiptCode: Long + ): InternalStatusResponse + + @Operation( + summary = "모든 상태 조회", + description = "전체 지원자의 상태 정보 목록을 조회합니다." + ) + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "전체 상태 조회 성공", + content = arrayOf(Content(schema = Schema(implementation = Array::class))) + ) + ) + fun getAllStatus(): List + + @Operation( + summary = "수험번호 업데이트", + description = "특정 접수번호의 수험번호를 업데이트합니다." + ) + @ApiResponses( + ApiResponse( + responseCode = "204", + description = "수험번호 업데이트 성공", + content = arrayOf(Content()) + ), + ApiResponse( + responseCode = "404", + description = "상태를 찾을 수 없음 - Status Not Found", + content = arrayOf(Content()) + ) + ) + fun updateExamCode( + @Parameter(description = "접수번호", required = true) + @PathVariable("receipt-code") receiptCode: Long, + @Parameter(description = "새로운 수험번호", required = true) + @RequestParam examCode: String + ) +} \ No newline at end of file diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/ErrorResponse.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/ErrorResponse.kt new file mode 100644 index 0000000..d75be67 --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/ErrorResponse.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.status.global.error + +/** + * API 오류 응답을 나타내는 데이터 클래스입니다. + * 클라이언트에게 일관된 형식의 오류 정보를 제공합니다. + * + * @property status HTTP 상태 코드 + * @property message 오류 메시지 + */ +data class ErrorResponse( + val status: Int, + val message: String? +) diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/GlobalExceptionFilter.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/GlobalExceptionFilter.kt new file mode 100644 index 0000000..5de9cff --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/GlobalExceptionFilter.kt @@ -0,0 +1,66 @@ +package hs.kr.entrydsm.status.global.error + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.status.global.error.exception.CasperException +import hs.kr.entrydsm.status.global.error.exception.ErrorCode +import io.sentry.Sentry +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.web.filter.OncePerRequestFilter +import java.io.IOException +import java.nio.charset.StandardCharsets + +/** + * 전역 예외 처리 필터 클래스입니다. + * Spring Security 필터 체인에서 발생하는 예외를 처리하여 일관된 오류 응답을 제공합니다. + * + * @property objectMapper JSON 직렬화를 위한 ObjectMapper + */ +class GlobalExceptionFilter( + private val objectMapper: ObjectMapper +) : OncePerRequestFilter() { + + /** + * 필터 체인에서 발생하는 예외를 처리합니다. + * CasperException과 일반 Exception을 구분하여 처리하고, Sentry로 예외를 추적합니다. + * + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param filterChain 필터 체인 + */ + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + try { + filterChain.doFilter(request, response) + } catch (e: CasperException) { + println(e.errorCode) + Sentry.captureException(e) + writerErrorCode(response, e.errorCode) + } catch (e: Exception) { + e.printStackTrace() + Sentry.captureException(e) + writerErrorCode(response, ErrorCode.INTERNAL_SERVER_ERROR) + } + } + + /** + * 에러 코드를 HTTP 응답으로 작성합니다. + * + * @param response HTTP 응답 객체 + * @param errorCode 발생한 에러 코드 + * @throws IOException 응답 작성 중 IO 오류 발생 시 + */ + @Throws(IOException::class) + private fun writerErrorCode(response: HttpServletResponse, errorCode: ErrorCode) { + val errorResponse = ErrorResponse(errorCode.status, errorCode.message) + response.status = errorCode.status + response.characterEncoding = StandardCharsets.UTF_8.name() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.writer.write(objectMapper.writeValueAsString(errorResponse)) + } +} diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/GlobalExceptionHandler.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/GlobalExceptionHandler.kt new file mode 100644 index 0000000..5667633 --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/GlobalExceptionHandler.kt @@ -0,0 +1,68 @@ +package hs.kr.entrydsm.status.global.error + +import hs.kr.entrydsm.status.global.error.exception.CasperException +import io.github.resilience4j.circuitbreaker.CallNotPermittedException +import org.springframework.context.MessageSource +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +/** + * 애플리케이션의 전역 예외 처리를 담당하는 클래스입니다. + * 모든 컨트롤러에서 발생하는 예외를 처리하여 일관된 오류 응답을 제공합니다. + * + * @property messageSource 국제화 메시지 소스 + */ +@RestControllerAdvice +class GlobalExceptionHandler( + private val messageSource: MessageSource +) { + + /** + * Casper 애플리케이션의 커스텀 예외를 처리합니다. + * + * @param e CasperException 인스턴스 + * @return 에러 코드에 따른 응답 엔티티 + */ + @ExceptionHandler(CasperException::class) + fun handlingCasperException(e: CasperException): ResponseEntity { + val code = e.errorCode + return ResponseEntity( + ErrorResponse(code.status, code.message), + HttpStatus.valueOf(code.status) + ) + } + + /** + * 호출 불가 예외를 처리합니다. + * + * @param e CallNotPermittedException 인스턴스 + * @return 500 에러 응답 + */ + @ExceptionHandler(CallNotPermittedException::class) + fun handleCallNotPermittedException(e: CallNotPermittedException): ResponseEntity { + return ResponseEntity( + ErrorResponse(500, "server error"), + HttpStatus.valueOf(500) + ) + } + + /** + * 유효성 검증 실패 예외를 처리합니다. + * + * @param e MethodArgumentNotValidException 인스턴스 + * @return 400 에러 응답 + */ + @ExceptionHandler(MethodArgumentNotValidException::class) + fun validatorExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity { + return ResponseEntity( + ErrorResponse( + 400, + e.bindingResult.allErrors[0].defaultMessage + ), + HttpStatus.BAD_REQUEST + ) + } +} diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/exception/CasperException.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/exception/CasperException.kt new file mode 100644 index 0000000..22ff3e1 --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/exception/CasperException.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.status.global.error.exception + +import java.lang.RuntimeException + +/** + * Casper 애플리케이션의 모든 커스텀 예외의 기본 클래스입니다. + * 에러 코드를 포함하여 일관된 예외 처리를 제공합니다. + * + * @property errorCode 발생한 오류의 에러 코드 + */ +abstract class CasperException( + val errorCode: ErrorCode +) : RuntimeException() diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/exception/ErrorCode.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/exception/ErrorCode.kt new file mode 100644 index 0000000..057b826 --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/error/exception/ErrorCode.kt @@ -0,0 +1,25 @@ +package hs.kr.entrydsm.status.global.error.exception + +/** + * 애플리케이션에서 발생하는 오류 코드를 정의하는 열거형 클래스입니다. + * HTTP 상태 코드와 에러 메시지를 함께 관리합니다. + * + * @property status HTTP 상태 코드 + * @property message 에러 메시지 + */ +enum class ErrorCode( + val status: Int, + val message: String +) { + /** 내부 서버 오류 */ + INTERNAL_SERVER_ERROR(500, "Internal Server Error"), + + /** 유효하지 않은 토큰 */ + INVALID_TOKEN(401, "Invalid Token"), + + /** 만료된 토큰 */ + EXPIRED_TOKEN(401, "Expired Token"), + + /** 상태를 찾을 수 없음 */ + STATUS_NOT_FOUND(404, "Status Not Found") +} diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/exception/ExpiredTokenException.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/exception/ExpiredTokenException.kt new file mode 100644 index 0000000..231d922 --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/exception/ExpiredTokenException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.status.global.exception + +import hs.kr.entrydsm.status.global.error.exception.CasperException +import hs.kr.entrydsm.status.global.error.exception.ErrorCode + +/** + * 토큰이 만료되었을 때 발생하는 예외입니다. + * JWT 토큰의 유효 기간이 만료된 경우 사용됩니다. + */ +object ExpiredTokenException : CasperException( + ErrorCode.EXPIRED_TOKEN +) diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/exception/InternalServerErrorException.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/exception/InternalServerErrorException.kt new file mode 100644 index 0000000..d980daa --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/exception/InternalServerErrorException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.status.global.exception + +import hs.kr.entrydsm.status.global.error.exception.CasperException +import hs.kr.entrydsm.status.global.error.exception.ErrorCode + +/** + * 내부 서버 오류가 발생했을 때 사용하는 예외입니다. + * 예상치 못한 서버 측 오류가 발생한 경우 사용됩니다. + */ +object InternalServerErrorException : CasperException( + ErrorCode.INTERNAL_SERVER_ERROR +) diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/exception/InvalidTokenException.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/exception/InvalidTokenException.kt new file mode 100644 index 0000000..aa1659f --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/exception/InvalidTokenException.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.status.global.exception + +import hs.kr.entrydsm.status.global.error.exception.CasperException +import hs.kr.entrydsm.status.global.error.exception.ErrorCode + +/** + * 토큰이 유효하지 않을 때 발생하는 예외입니다. + * JWT 토큰의 형식이 잘못되었거나 서명이 일치하지 않는 경우 사용됩니다. + */ +object InvalidTokenException : CasperException( + ErrorCode.INVALID_TOKEN +) diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/mapper/GenericMapper.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/mapper/GenericMapper.kt new file mode 100644 index 0000000..b8141f2 --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/mapper/GenericMapper.kt @@ -0,0 +1,36 @@ +package hs.kr.entrydsm.status.global.mapper + +/** + * 엔티티와 도메인 모델 간의 변환을 위한 제네릭 매퍼 인터페이스입니다. + * MapStruct와 함께 사용하여 일관된 변환 로직을 제공합니다. + * + * @param E 엔티티 타입 + * @param D 도메인 모델 타입 + */ +interface GenericMapper { + + /** + * 도메인 모델을 엔티티로 변환합니다. + * + * @param model 변환할 도메인 모델 + * @return 변환된 엔티티 + */ + fun toEntity(model: D): E + + /** + * 엔티티를 도메인 모델로 변환합니다. + * + * @param entity 변환할 엔티티 (null 가능) + * @return 변환된 도메인 모델 (null 가능) + */ + fun toModel (entity: E?): D? + + /** + * 엔티티를 도메인 모델로 변환합니다. + * null이 아닌 엔티티를 받아 null이 아닌 도메인 모델을 반환합니다. + * + * @param entity 변환할 엔티티 (null이 아님) + * @return 변환된 도메인 모델 (null이 아님) + */ + fun toModelNotNull(entity: E): D +} diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/FilterConfig.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/FilterConfig.kt new file mode 100644 index 0000000..c412d1f --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/FilterConfig.kt @@ -0,0 +1,36 @@ +package hs.kr.entrydsm.status.global.security + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.status.global.error.GlobalExceptionFilter +import hs.kr.entrydsm.status.global.security.jwt.JwtFilter +import org.springframework.security.config.annotation.SecurityConfigurerAdapter +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.stereotype.Component + +/** + * Spring Security 필터 설정을 담당하는 클래스입니다. + * JWT 필터와 예외 처리 필터를 Spring Security 필터 체인에 추가합니다. + * + * @property objectMapper JSON 직렬화/역직렬화를 위한 ObjectMapper + */ +@Component +class FilterConfig( + private val objectMapper: ObjectMapper +) : SecurityConfigurerAdapter() { + + /** + * Spring Security 필터 체인에 커스텀 필터들을 추가합니다. + * JWT 필터를 인증 필터 앞에, 예외 처리 필터를 JWT 필터 앞에 배치합니다. + * + * @param builder HttpSecurity 빌더 + */ + override fun configure(builder: HttpSecurity) { + builder.addFilterBefore( + JwtFilter(), + UsernamePasswordAuthenticationFilter::class.java + ) + builder.addFilterBefore(GlobalExceptionFilter(objectMapper), JwtFilter::class.java) + } +} diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/SecurityConfig.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/SecurityConfig.kt new file mode 100644 index 0000000..da2f6bc --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/SecurityConfig.kt @@ -0,0 +1,54 @@ +package hs.kr.entrydsm.status.global.security + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.status.global.security.jwt.UserRole +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain + +/** + * Spring Security 설정 클래스입니다. + * 애플리케이션의 보안 정책과 인증/인가 규칙을 정의합니다. + * + * @property objectMapper JSON 직렬화/역직렬화를 위한 ObjectMapper + */ +@Configuration +class SecurityConfig( + private val objectMapper: ObjectMapper +) { + companion object { + const val ADMIN_ROLE = "ADMIN" + } + + /** + * Spring Security 필터 체인을 구성합니다. + * HTTP 보안 설정 및 경로별 접근 권한을 정의합니다. + * + * @param http HttpSecurity 객체 + * @return 구성된 SecurityFilterChain + */ + @Bean + protected fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .cors { it.disable() } + .formLogin { it.disable() } + .sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + + .authorizeHttpRequests { + it + .requestMatchers("/").permitAll() + .requestMatchers("/internal/**").hasRole(UserRole.ROOT.name) + .requestMatchers("/admin").hasRole(UserRole.ADMIN.name) + .anyRequest().authenticated() + } + .with(FilterConfig(objectMapper)) { } + + return http.build() + + } +} diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/jwt/JwtFilter.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/jwt/JwtFilter.kt new file mode 100644 index 0000000..a822498 --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/jwt/JwtFilter.kt @@ -0,0 +1,63 @@ +package hs.kr.entrydsm.status.global.security.jwt + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.context.SecurityContextHolder.clearContext +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.web.filter.OncePerRequestFilter + +/** + * JWT 토큰 인증을 처리하는 필터 클래스입니다. + * HTTP 요청 헤더에서 사용자 정보를 추출하여 Spring Security 컨텍스트에 설정합니다. + */ +class JwtFilter : OncePerRequestFilter() { + + /** + * 요청마다 실행되는 필터 로직입니다. + * 요청 헤더에서 사용자 ID와 역할을 추출하여 인증 컨텍스트를 설정합니다. + * + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param filterChain 필터 체인 + */ + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val userId: String? = request.getHeader("Request-User-Id") + val role: UserRole? = request.getHeader("Request-User-Role")?.let { UserRole.valueOf(it) } + + if ((userId == null) || (role == null)) { + filterChain.doFilter(request, response) + return + } + + val authorities = mutableListOf(SimpleGrantedAuthority("ROLE_${role.name}")) + val userDetails: UserDetails = User(userId, "", authorities) + val authentication: Authentication = + UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities) + + clearContext() + SecurityContextHolder.getContext().authentication = authentication + filterChain.doFilter(request, response) + } +} + +/** + * 사용자 역할을 나타내는 열거형 클래스입니다. + */ +enum class UserRole { + /** 최고 관리자 */ + ROOT, + /** 관리자 */ + ADMIN, + /** 일반 사용자 */ + USER +} diff --git a/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/jwt/JwtProperties.kt b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/jwt/JwtProperties.kt new file mode 100644 index 0000000..01f84c6 --- /dev/null +++ b/casper-status/src/main/kotlin/hs/kr/entrydsm/status/global/security/jwt/JwtProperties.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.status.global.security.jwt + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * JWT 토큰 관련 설정 프로퍼티 클래스입니다. + * application.yml에서 auth.jwt 하위 설정값들을 바인딩합니다. + * + * @property secretKey JWT 서명에 사용할 비밀키 + * @property header JWT 토큰이 포함될 HTTP 헤더명 + * @property prefix JWT 토큰 앞에 붙을 접두사 + */ +@ConfigurationProperties("auth.jwt") +class JwtProperties( + val secretKey: String, + val header: String, + val prefix: String +) diff --git a/settings.gradle.kts b/settings.gradle.kts index 7badf9a..b4c9293 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,7 +2,6 @@ rootProject.name = "Casper-Status" pluginManagement { includeBuild("casper-convention") - includeBuild("build-logic") repositories { gradlePluginPortal() mavenCentral() @@ -15,3 +14,5 @@ dependencyResolutionManagement { } } + +include("casper-status") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index a2a7e30..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=Casper-Status diff --git a/src/test/kotlin/hs/kr/entrydsm/status/CasperStatusApplicationTests.kt b/src/test/kotlin/hs/kr/entrydsm/status/CasperStatusApplicationTests.kt deleted file mode 100644 index 0a1bbe6..0000000 --- a/src/test/kotlin/hs/kr/entrydsm/status/CasperStatusApplicationTests.kt +++ /dev/null @@ -1,13 +0,0 @@ -package hs.kr.entrydsm.status - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class CasperStatusApplicationTests { - - @Test - fun contextLoads() { - } - -}