Skip to content

Commit

Permalink
feat: 과목 생성 api 구현 (#26)
Browse files Browse the repository at this point in the history
## Key changes
- 과목 생성 api 구현
- course 테이블에서 course_code 컬럼 삭제
- prod.yml 작성

## To Reviewers
- 테스트코드도 없고 코드도 좀 더러운데, 우선 로컬에서 테스트 케이스 설정 후 수동 테스트는 진행했습니다. 1차 배포 후,
테스트 코드 작성 & 리팩토링 진행하겠습니다.
  • Loading branch information
chaeyeon0130 authored Feb 5, 2025
2 parents 3024a6d + 194db6e commit dda4217
Show file tree
Hide file tree
Showing 26 changed files with 534 additions and 31 deletions.
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
id("io.spring.dependency-management") version "1.1.7"
kotlin("plugin.jpa") version "1.9.25"
kotlin("kapt") version "1.9.25"
kotlin("plugin.serialization") version "1.5.0"
}

group = "com.yourssu"
Expand Down Expand Up @@ -71,6 +72,8 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:$kotlinVersion")
testImplementation("org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:$junitPlatformVersion")

implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.0")
}

kotlin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ package com.yourssu.soongpt.domain.course.application
import com.yourssu.soongpt.common.business.dto.Response
import com.yourssu.soongpt.domain.course.application.dto.GeneralRequiredCourseRequest
import com.yourssu.soongpt.domain.course.application.dto.MajorElectiveCourseRequest
import com.yourssu.soongpt.domain.course.application.dto.CreateCourseRequest
import com.yourssu.soongpt.domain.course.application.dto.MajorRequiredCourseRequest
import com.yourssu.soongpt.domain.course.business.CourseService
import com.yourssu.soongpt.domain.course.business.dto.CourseResponse
import io.swagger.v3.oas.annotations.Hidden
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

Expand All @@ -35,4 +39,11 @@ class CourseController(
val response = courseService.findByDepartmentNameInGeneralRequired(request.department)
return ResponseEntity.ok().body(Response(result = response))
}

@Hidden
@PostMapping
fun createCourses(@RequestBody courses: List<CreateCourseRequest>): ResponseEntity<String> {
courseService.createCourses(courses)
return ResponseEntity.ok("과목 정보 저장 완료")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.yourssu.soongpt.domain.course.application.dto

data class CreateCourseRequest(
val syllabus: String?,
val category: String?,
val sub_category: String?,
val abeek_info: String?,
val field: String?,
val code: String?,
val name: String,
val division: String?,
val professor: String?,
val department: String?,
val time_points: String?,
val personeel: String?,
val remaining_seats: String?,
val schedule_room: String?,
val target: String?
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package com.yourssu.soongpt.domain.course.business

import com.yourssu.soongpt.domain.course.implement.Classification
import com.yourssu.soongpt.domain.courseTime.implement.CourseTime
import com.yourssu.soongpt.domain.courseTime.implement.Time
import com.yourssu.soongpt.domain.courseTime.implement.Week
import com.yourssu.soongpt.domain.department.implement.DepartmentReader
import org.springframework.stereotype.Component

@Component
class CourseParser(
private val departmentReader: DepartmentReader
) {
fun parseClassifications(input: String): Map<Classification, List<Long>> {
val mapping = mutableMapOf<Classification, MutableList<Long>>()
val groups = input.split("/").map { it.trim() }.filter { it.isNotEmpty() }
for (group in groups) {
val tokens = group.split("-").map { it.trim() }
if (tokens.isEmpty()) continue
val classificationStr = tokens[0]
val classification = when (classificationStr) {
"전필", "전기" -> Classification.MAJOR_REQUIRED
"전선" -> Classification.MAJOR_ELECTIVE
"교필" -> Classification.GENERAL_REQUIRED
"교선" -> Classification.GENERAL_ELECTIVE
"채플" -> Classification.CHAPEL
else -> continue
}
val deptIds = if (tokens.size > 1) {
departmentReader.getMatchingDepartments(tokens[1]).mapNotNull { it.id }
} else {
emptyList()
}
mapping.getOrPut(classification) { mutableListOf() }.addAll(deptIds)
}
return mapping
}

fun parseProfessorNames(professor: String?): String {
return professor?.split("\n")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.joinToString(", ")
?.let { "$it 교수님" }
?: ""
}

fun parseCredit(timePoints: String?): Int {
if (timePoints.isNullOrEmpty()) throw IllegalArgumentException("time_points 값이 비어있음")
val parts = timePoints.split("/")
return parts[1].toDoubleOrNull()?.toInt() ?: 0
}

fun parseCourseTimes(scheduleRoom: String, courseId: Long): List<CourseTime> {
val courseTimes = mutableListOf<CourseTime>()
val schedules = scheduleRoom.split("\n").map { it.trim() }.filter { it.isNotEmpty() }
schedules.forEach { sch ->
try {
val tokens = sch.split(" ").filter { it.isNotEmpty() }

val timePattern = Regex("\\d{1,2}:\\d{2}-\\d{1,2}:\\d{2}")
val timeTokenIndex = tokens.indexOfFirst { token -> timePattern.matches(token) }
if (timeTokenIndex == -1) {
throw IllegalArgumentException("시간 범위 형식 오류 : $sch")
}

val dayTokens = tokens.subList(0, timeTokenIndex)
if (dayTokens.isEmpty()) {
throw IllegalArgumentException("요일 정보 누락 : $sch")
}

val timeToken = tokens[timeTokenIndex]
val times = timeToken.split("-")
if (times.size != 2) {
throw IllegalArgumentException("시간 범위 형식 오류 : $sch")
}
val startTime = Time.of(times[0])
val endTime = Time.of(times[1])

val classroom = parseClassroom(sch)

dayTokens.forEach { day ->
val week = parseWeek(day)
val courseTime = CourseTime(
week = week,
startTime = startTime,
endTime = endTime,
classroom = classroom,
courseId = courseId
)
courseTimes.add(courseTime)
}
} catch (e: Exception) {
throw IllegalArgumentException("schedule_room 파싱 실패 : $sch / ${e.message}")
}
}
return courseTimes
}

fun parseTarget(input: String): List<ParsedTarget> {
val results = mutableListOf<ParsedTarget>()
if (input.trim().isEmpty()) return results

val lines = input.split("\n").map { it.trim() }.filter { it.isNotEmpty() }

val gradeRegex = Regex("^(전체학년|전체|(\\d+)학년)") // ex. group 0: 1학년, group 1: 1학년, group 2: 1
val exclusionRegex = Regex("^(.*)\\((.*제외.*)\\)$")
for (line in lines) {
var grade = 0
var remaining = line
val gradeMatch = gradeRegex.find(line)
if (gradeMatch != null) {
val gradePart = gradeMatch.value.trim()
remaining = line.substring(gradeMatch.range.last + 1).trim()
grade = if (gradePart.equals("전체학년") || gradePart.equals("전체")) {
0
} else {
gradeMatch.groupValues[2].toIntOrNull() ?: 0
}
}

// ex. 1학년 -> 학과는 전체학과로 처리
if (remaining.isEmpty()) {
results.add(ParsedTarget(grade, setOf("전체"), emptySet()))
continue
}

var includedStr = remaining
var excludedStr = remaining
val exclusionMatch = exclusionRegex.find(remaining)
if (exclusionMatch != null) { // group 0: (중문 제외), group 1: "", group 2: 중문 제외
includedStr = exclusionMatch.groupValues[1].trim()
excludedStr = exclusionMatch.groupValues[2].trim().replace("제외", "").trim()
}

val includedDepartments = if (includedStr.isEmpty() || includedStr.equals("전체")) {
setOf("전체")
} else {
includedStr.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
}

val excludedDepartments = if (exclusionMatch != null) {
excludedStr.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
} else {
emptySet()
}

results.add(ParsedTarget(grade, includedDepartments, excludedDepartments))
}
return results
}

private fun parseWeek(day: String): Week {
return when(day) {
"" -> Week.MONDAY
"" -> Week.TUESDAY
"" -> Week.WEDNESDAY
"" -> Week.THURSDAY
"" -> Week.FRIDAY
"" -> Week.SATURDAY
"" -> Week.SUNDAY
else -> throw IllegalArgumentException("알 수 없는 요일 : $day")
}
}

private fun parseClassroom(schedule: String): String? {
val startIdx = schedule.indexOf("(")
val endIdx = schedule.indexOf(")")
if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) {
val content = schedule.substring(startIdx + 1, endIdx)
// 만약 내용에 "("가 포함된다면, 그 앞부분만 사용
// ex. (정보과학관 21203 (김재상강의실)-김익수)
val roomPart = content.split("(").first()
return roomPart.split("-").first().trim()
}
return null
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
package com.yourssu.soongpt.domain.course.business

import com.yourssu.soongpt.domain.course.application.dto.CreateCourseRequest
import com.yourssu.soongpt.domain.course.business.dto.CourseResponse
import com.yourssu.soongpt.domain.course.implement.Classification
import com.yourssu.soongpt.domain.course.implement.Course
import com.yourssu.soongpt.domain.course.implement.CourseReader
import com.yourssu.soongpt.domain.course.implement.CourseWriter
import com.yourssu.soongpt.domain.courseTime.implement.CourseTimeReader
import com.yourssu.soongpt.domain.courseTime.implement.CourseTimeWriter
import com.yourssu.soongpt.domain.department.implement.DepartmentReader
import com.yourssu.soongpt.domain.departmentGrade.implement.DepartmentGradeReader
import com.yourssu.soongpt.domain.target.implement.TargetReader
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import com.yourssu.soongpt.domain.target.implement.Target
import com.yourssu.soongpt.domain.target.implement.TargetWriter

@Service
class CourseService(
private val courseReader: CourseReader,
private val courseTimeReader: CourseTimeReader,
private val departmentReader: DepartmentReader,
private val targetReader: TargetReader,
private val courseParser: CourseParser,
private val courseWriter: CourseWriter,
private val courseTimeWriter: CourseTimeWriter,
private val departmentGradeReader: DepartmentGradeReader,
private val targetWriter: TargetWriter,
private val targetMapper: TargetMapper
) {
fun findByDepartmentNameInMajorRequired(departmentName: String): List<CourseResponse> {
val department = departmentReader.getByName(departmentName)
Expand Down Expand Up @@ -43,4 +58,72 @@ class CourseService(
CourseResponse.from(course = it, target = targets, courseTimes = courseTimes)
}
}

@Transactional
fun createCourses(courses: List<CreateCourseRequest>) {
courses.forEach { course ->
try {
val classification = course.category?.let { targetMapper.getMappedClassification(it) } ?: throw
IllegalArgumentException("변환한 이수구분" +
"이 null임, 원본 이수구분 : ${course.category.orEmpty()}")
val parsedClassifications = courseParser.parseClassifications(classification)
if (parsedClassifications.isEmpty()) {
println("허용되지 않는 이수구분 : ${course.category.orEmpty()}")
return@forEach
}

val professorName = courseParser.parseProfessorNames(course.professor)
val credit = courseParser.parseCredit(course.time_points)

val createdCourses = mutableListOf<Course>()
val classificationToCourse = mutableMapOf<Classification, Course>()
parsedClassifications.forEach { (classification, _) ->
val courseDomain = Course(
courseName = course.name,
professorName = professorName,
classification = classification,
credit = credit
)
val savedCourse = courseWriter.save(courseDomain)
createdCourses.add(savedCourse)
classificationToCourse[classification] = savedCourse
}

createdCourses.forEach { courseDomain ->
val courseTimes = courseParser.parseCourseTimes(course.schedule_room.orEmpty(), courseDomain.id!!)
courseTimes.forEach { courseTime ->
courseTimeWriter.save(courseTime)
}
}

val target = course.target?.let { targetMapper.getMappedTarget(it) } ?: throw
IllegalArgumentException("변환한 타겟" +
"이 null임, 원본 타겟 : ${course.target.orEmpty()}")
val parsedTargets = courseParser.parseTarget(target)
parsedTargets.forEach { parsedTarget ->
val deptGrades = departmentGradeReader.getByDepartmentIdsAndGrades(parsedTarget)
deptGrades.forEach { deptGrade ->
val deptId = deptGrade.departmentId
val matchedClassification = if (parsedClassifications.size == 1) {
parsedClassifications.keys.first()
} else {
parsedClassifications.entries.find { (_, deptIds) ->
if (deptIds.isEmpty()) true else deptIds.contains(deptId)
}?.key ?: throw IllegalArgumentException("이수구분 매칭이 되지 않은 타겟 : 이수구분 - ${course.category.orEmpty()}, 타겟 학과 ID -" +
" ${deptId}")
}
val assignedCourseId = classificationToCourse[matchedClassification]?.id ?:
throw IllegalStateException("매칭된 이수구분-${matchedClassification}에 해당하는 과목이 존재하지 않음")
val targetDomain = Target(
departmentGradeId = deptGrade.id!!,
courseId = assignedCourseId
)
targetWriter.save(targetDomain)
}
}
} catch (e: Exception) {
println("예외 발생 : ${course.name} - ${e.message}")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.yourssu.soongpt.domain.course.business

data class ParsedTarget(
val grade: Int,
val includedDepartments: Set<String>,
val excludedDepartments: Set<String>,
)
Loading

0 comments on commit dda4217

Please sign in to comment.