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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ load(":deps.bzl", "MODULE_DEPS", "TEST_DEPS")

package(default_visibility = ["//visibility:public"])

kt_jvm_test(
name = "kakao_distance_test",
srcs = ["src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt"],
javac_opts = "//:javac_options",
kotlinc_opts = "//:kotlinc_options",
test_class = "hs.kr.entrydsm.application.administration.adapterout.distance.KakaoDistanceAdapterTest",
deps = MODULE_DEPS + TEST_DEPS + [":main"],
)

kt_jvm_test(
name = "first_screening_mysql_concurrency_test",
tags = ["manual"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ KOTLIN_DEPS = [
]

TEST_DEPS = [
"@maven//:ch_qos_logback_logback_classic",
"@maven//:ch_qos_logback_logback_core",
"//systems/application/application-administration-application:main",
"@maven//:org_springframework_spring_test",
"@maven//:org_springframework_boot_spring_boot_data_jpa_test",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ package hs.kr.entrydsm.application.administration.adapterout.distance
import hs.kr.entrydsm.admin.domain.enum.ErrorCode
import hs.kr.entrydsm.admin.domain.exception.AdminDomainException
import hs.kr.entrydsm.admin.domain.port.out.DistancePort
import java.io.IOException
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.http.HttpTimeoutException
import java.nio.charset.StandardCharsets
import java.time.Duration
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import tools.jackson.core.JacksonException
import tools.jackson.databind.ObjectMapper

@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = ["application.administration.enabled"], havingValue = "true")
Expand All @@ -24,54 +28,98 @@ class KakaoDistanceAdapter(
@Value("\${admin.maps.school-address}") private val schoolAddress: String,
@Value("\${admin.maps.timeout-ms}") private val timeoutMs: Long,
) : DistancePort {
private val logger = LoggerFactory.getLogger(javaClass)
private val client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(timeoutMs)).build()
private val schoolCoordinates by lazy { geocode(schoolAddress) }
private val schoolCoordinates by lazy { geocode(schoolAddress, "SCHOOL_GEOCODING") }

override fun distanceFromSchool(address: String): Long =
try {
val origin = geocode(address)
// 외부 응답과 예외 메시지 대신 단계와 안전한 원인만 남긴다.
override fun distanceFromSchool(address: String): Long {
var stage = "APPLICANT_GEOCODING"
return try {
val origin = geocode(address, stage)
stage = "SCHOOL_GEOCODING"
val destination = schoolCoordinates
stage = "DIRECTIONS"
val response = send(
"$directionsUrl?origin=${origin.x},${origin.y}&destination=${destination.x},${destination.y}&summary=true",
stage,
)
parseDistance(response)
} catch (_: Exception) {
unavailable()
} catch (exception: AdminDomainException) {
throw exception
} catch (exception: Exception) {
val reason = when (exception) {
is JacksonException -> "JSON_PARSING"
is HttpTimeoutException -> "TIMEOUT"
is IOException -> "NETWORK"
else -> "UNEXPECTED"
}
unavailable(stage, reason, exceptionClass = exception.javaClass.name)
}
}

// 카카오의 경로 결과 코드와 거리 필드 실패를 구분한다.
fun parseDistance(body: String): Long {
val route = objectMapper.readTree(body).path("routes").path(0)
if (route.path("result_code").asInt(-1) != 0 || !route.path("summary").path("distance").canConvertToLong()) {
unavailable()
val resultCode = route.path("result_code").asInt(-1)
// 출발지와 도착지가 가까워 경로가 없는 경우 정상 거리 0m로 처리한다.
if (resultCode == 104) return 0L
if (resultCode != 0) {
unavailable("DIRECTIONS", "ROUTE_RESULT", resultCode = resultCode)
}
if (!route.path("summary").path("distance").canConvertToLong()) {
unavailable("DIRECTIONS", "DISTANCE_MISSING")
}
return route.path("summary").path("distance").asLong()
}

fun parseCoordinates(body: String): Coordinates {
val document = objectMapper.readTree(body).path("documents").path(0)
val x = document.path("x").asText().toDoubleOrNull() ?: unavailable()
val y = document.path("y").asText().toDoubleOrNull() ?: unavailable()
// 기존 좌표 파싱 호출 인터페이스를 유지한다.
fun parseCoordinates(body: String): Coordinates = parseCoordinates(body, "APPLICANT_GEOCODING")

// 검색 결과 없음과 좌표 형식 오류를 구분한다.
private fun parseCoordinates(body: String, stage: String): Coordinates {
val documents = objectMapper.readTree(body).path("documents")
if (documents.isArray && documents.isEmpty) unavailable(stage, "EMPTY_SEARCH")
val document = documents.path(0)
val x = document.path("x").asText().toDoubleOrNull() ?: unavailable(stage, "COORDINATE_FORMAT")
val y = document.path("y").asText().toDoubleOrNull() ?: unavailable(stage, "COORDINATE_FORMAT")
return Coordinates(x, y)
}

private fun geocode(address: String): Coordinates =
parseCoordinates(send("$geocodingUrl?query=${encode(address)}"))
// 주소는 요청에만 사용하며 로그에 남기지 않는다.
private fun geocode(address: String, stage: String): Coordinates =
parseCoordinates(send("$geocodingUrl?query=${encode(address)}", stage), stage)

private fun send(url: String): String {
// HTTP 실패 시 본문과 URL을 제외한 상태 코드만 기록한다.
private fun send(url: String, stage: String): String {
val request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(timeoutMs))
.header("Authorization", "KakaoAK $apiKey")
.GET()
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() !in 200..299) unavailable()
if (response.statusCode() !in 200..299) unavailable(stage, "HTTP_FAILURE", httpStatus = response.statusCode())
return response.body()
}

// 기존 검색어 인코딩을 유지한다.
private fun encode(value: String) = URLEncoder.encode(value, StandardCharsets.UTF_8)

private fun unavailable(): Nothing = throw AdminDomainException(ErrorCode.DISTANCE_SERVICE_UNAVAILABLE)
// 허용한 진단 필드만 기록하고 기존 외부 오류 계약을 유지한다.
private fun unavailable(
stage: String,
reason: String,
httpStatus: Int? = null,
resultCode: Int? = null,
exceptionClass: String? = null,
): Nothing {
logger.error(
"Kakao distance failure [stage={}, reason={}, httpStatus={}, resultCode={}, exceptionClass={}]",
stage, reason, httpStatus, resultCode, exceptionClass,
)
throw AdminDomainException(ErrorCode.DISTANCE_SERVICE_UNAVAILABLE)
}

data class Coordinates(val x: Double, val y: Double)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import hs.kr.entrydsm.admin.domain.exception.AdminDomainException
import hs.kr.entrydsm.admin.domain.port.out.AdmissionTicketPort
import hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPdfs
import hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPort
import hs.kr.entrydsm.admin.domain.port.out.EssayPdfMergePort
import hs.kr.entrydsm.application.administration.domain.document.exception.ApplicantNotFoundException
import hs.kr.entrydsm.application.administration.domain.document.port.`in`.ApplicantFileUseCase
import hs.kr.entrydsm.application.application.exception.EvaluationValidationException
import java.io.OutputStream
import org.apache.pdfbox.Loader
import org.apache.pdfbox.multipdf.PDFMergerUtility
import org.apache.pdfbox.pdmodel.PDDocument
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Component

Expand All @@ -31,6 +36,23 @@ class LocalApplicationEssayAdapter(private val documents: ApplicantFileUseCase)
}
}

@Component
@ConditionalOnProperty(name = ["application.administration.enabled"], havingValue = "true")
class PdfBoxEssayMergeAdapter : EssayPdfMergePort {
// 원본을 닫아도 글꼴과 리소스가 유지되도록 두 문서 전체를 복사한다.
override fun writeTo(documents: List<ApplicationEssayPdfs>, output: OutputStream) {
PDDocument().use { merged ->
val merger = PDFMergerUtility()
documents.forEach { pdfs ->
listOfNotNull(pdfs.introduction, pdfs.studyPlan).forEach { pdf ->
Loader.loadPDF(pdf).use { source -> merger.appendDocument(merged, source) }
}
}
merged.save(output)
}
}
}

private fun renderFailure(error: Exception, fallback: ErrorCode, targets: List<Long>): AdminDomainException {
val causes = generateSequence<Throwable>(error) { it.cause }.toList()
val code = when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ class PoiXlsxAdapter : XlsxRenderPort {
applicant.volunteerScore,
).forEachIndexed { column, value -> cell(8, column + 1, value) }

cell(10, 7, applicant.subjectBaseScore)
// 검정고시는 교과성적에 원점수 평균을 표시한다.
val isGed = applicant.graduationStatus == "검정고시"
cell(10, 7, if (isGed) applicant.gedAverage else applicant.subjectBaseScore)

val semesters = listOf(
applicant.thirdGradeSecondSemester,
Expand All @@ -102,7 +104,8 @@ class PoiXlsxAdapter : XlsxRenderPort {
cell(13, 7, applicant.additionalScore)

listOf(
applicant.thirdGradeSecondSemesterScore,
// 검정고시 과목이 표시되는 첫 열에는 원점수 평균을 표시한다.
if (isGed) applicant.gedAverage else applicant.thirdGradeSecondSemesterScore,
applicant.thirdGradeFirstSemesterScore,
applicant.previousSemesterScore,
applicant.secondPreviousSemesterScore,
Expand Down
Loading
Loading