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
33 changes: 33 additions & 0 deletions api/src/main/kotlin/controller/AdminController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.wafflestudio.snutt.clientconfig.dto.PatchConfigRequest
import com.wafflestudio.snutt.clientconfig.dto.PostConfigRequest
import com.wafflestudio.snutt.clientconfig.service.ClientConfigService
import com.wafflestudio.snutt.common.dto.OkResponse
import com.wafflestudio.snutt.common.enums.Semester
import com.wafflestudio.snutt.common.storage.StorageService
import com.wafflestudio.snutt.common.storage.StorageSource
import com.wafflestudio.snutt.common.storage.dto.FileUploadUriDto
Expand All @@ -17,6 +18,9 @@ import com.wafflestudio.snutt.notification.service.NotificationAdminService
import com.wafflestudio.snutt.popup.dto.PopupResponse
import com.wafflestudio.snutt.popup.dto.PostPopupRequest
import com.wafflestudio.snutt.popup.service.PopupService
import com.wafflestudio.snutt.registrationperiod.data.RegistrationDate
import com.wafflestudio.snutt.registrationperiod.data.SemesterRegistrationPeriod
import com.wafflestudio.snutt.registrationperiod.service.SemesterRegistrationPeriodService
import notification.dto.InsertNotificationRequest
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.DeleteMapping
Expand All @@ -42,6 +46,7 @@ class AdminController(
private val storageService: StorageService,
private val popupService: PopupService,
private val diaryService: DiaryService,
private val semesterRegistrationPeriodService: SemesterRegistrationPeriodService,
) {
@PostMapping("/insert_noti")
suspend fun insertNotification(
Expand Down Expand Up @@ -142,4 +147,32 @@ class AdminController(
diaryService.removeQuestion(id)
return OkResponse()
}

@GetMapping("/registrationPeriods")
suspend fun getSemesterRegistrationPeriods(): List<SemesterRegistrationPeriod> = semesterRegistrationPeriodService.getAll()

@GetMapping("/registrationPeriods/{year}/{semester}")
suspend fun getSemesterRegistrationPeriod(
@PathVariable year: Int,
@PathVariable semester: Semester,
): SemesterRegistrationPeriod? = semesterRegistrationPeriodService.getByYearAndSemester(year, semester)

@PatchMapping("/registrationPeriods/{year}/{semester}")
suspend fun patchSemesterRegistrationPeriod(
@PathVariable year: Int,
@PathVariable semester: Semester,
@RequestBody registrationPeriods: List<RegistrationDate>,
): OkResponse {
semesterRegistrationPeriodService.upsert(year, semester, registrationPeriods)
return OkResponse()
}

@DeleteMapping("/registrationPeriods/{year}/{semester}")
suspend fun deleteSemesterRegistrationPeriod(
@PathVariable year: Int,
@PathVariable semester: Semester,
): OkResponse {
semesterRegistrationPeriodService.delete(year, semester)
return OkResponse()
}
}
15 changes: 15 additions & 0 deletions batch/src/main/kotlin/sugangsnu/common/SugangSnuRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class SugangSnuRepository(
private val objectMapper: ObjectMapper,
) {
companion object {
const val MAIN_PAGE_PATH = "/sugang/co/co010.action"
const val SUGANG_SNU_COURSEBOOK_PATH = "/sugang/cc/cc100ajax.action"
const val DEFAULT_COURSEBOOK_PARAMS = "openUpDeptCd=&openDeptCd="
const val SUGANG_SNU_SEARCH_PATH = "/sugang/cc/cc100InterfaceSrch.action"
Expand Down Expand Up @@ -127,4 +128,18 @@ class SugangSnuRepository(
throw it.createExceptionAndAwait()
}
}

suspend fun getSugangSnuMainPage(): PooledDataBuffer =
sugangSnuApi
.get()
.uri { builder ->
builder.path(MAIN_PAGE_PATH).build()
}.accept(MediaType.TEXT_HTML)
.awaitExchange {
if (it.statusCode().is2xxSuccessful) {
it.awaitBody()
} else {
throw it.createExceptionAndAwait()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.wafflestudio.snutt.sugangsnu.common.utils

import com.wafflestudio.snutt.registrationperiod.data.RegistrationDate
import com.wafflestudio.snutt.registrationperiod.data.RegistrationPhase
import com.wafflestudio.snutt.registrationperiod.data.RegistrationTimeSlot
import org.jsoup.nodes.Element
import java.time.LocalDate

object RegistrationPeriodParseUtils {
fun parseRegistrationDates(table: Element): Map<LocalDate, RegistrationDate> =
table
.select("tbody > tr")
.mapNotNull { row ->
val typeText = row.select("th[data-th=구분] span").text().trim()
val phase = parseRegistrationPhase(typeText) ?: return@mapNotNull null
val (startDate, endDate) = parseDateRange(row.select("td[data-th=일자]").text())

(startDate.toEpochDay()..endDate.toEpochDay()).map { epochDay ->
LocalDate.ofEpochDay(epochDay) to
RegistrationDate(
date = LocalDate.ofEpochDay(epochDay),
vacantSeatRegistrationTimes = getVacantSeatRegistrationTimes(typeText),
phase = phase,
)
}
}.flatten()
.toMap()

private fun parseRegistrationPhase(typeText: String): RegistrationPhase? =
when {
typeText.contains("전산확정") -> null
typeText.contains("정원외") -> null
typeText.contains("수강취소") -> null
typeText.contains("장바구니") -> null
typeText.contains("신입생") && typeText.contains("선착순") -> RegistrationPhase.FRESHMAN
typeText.contains("수강신청변경") -> RegistrationPhase.COURSE_CHANGE
typeText.contains("선착순") -> RegistrationPhase.CURRENT_STUDENT
else -> null
}

private fun getVacantSeatRegistrationTimes(typeText: String): List<RegistrationTimeSlot> =
if (typeText.contains("수강신청변경")) {
// 수강신청변경: 10~11시, 13~14시, 17~18시
listOf(
RegistrationTimeSlot(startMinute = 10 * 60, endMinute = 11 * 60),
RegistrationTimeSlot(startMinute = 13 * 60, endMinute = 14 * 60),
RegistrationTimeSlot(startMinute = 17 * 60, endMinute = 18 * 60),
)
} else {
// 선착순수강신청: 10~11시, 13~14시, 15~16시
listOf(
RegistrationTimeSlot(startMinute = 10 * 60, endMinute = 11 * 60),
RegistrationTimeSlot(startMinute = 13 * 60, endMinute = 14 * 60),
RegistrationTimeSlot(startMinute = 15 * 60, endMinute = 16 * 60),
)
}

private fun parseDateRange(dateText: String): Pair<LocalDate, LocalDate> {
// Format: "2026-01-30(금) ~ 2026-01-30(금)"
val dates = Regex("""(\d{4}-\d{2}-\d{2})""").findAll(dateText).map { it.value }.toList()
val startDate = LocalDate.parse(dates[0])
val endDate = LocalDate.parse(dates.getOrElse(1) { dates[0] })
return startDate to endDate
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@ class SugangSnuSyncJobConfig {
runBlocking {
val existingCoursebook = coursebookService.getLatestCoursebook()
if (sugangSnuSyncService.isSyncWithSugangSnu(existingCoursebook)) {
sugangSnuSyncService.extractRegistrationPeriod(existingCoursebook)
val updateResult = sugangSnuSyncService.updateCoursebook(existingCoursebook)
sugangSnuNotificationService.notifyUserLectureChanges(updateResult)
} else {
val newCoursebook = existingCoursebook.nextCoursebook()
vacancyNotificationService.deleteAll()
sugangSnuSyncService.addCoursebook(newCoursebook)
sugangSnuSyncService.extractRegistrationPeriod(newCoursebook)
sugangSnuNotificationService.notifyCoursebookUpdate(newCoursebook)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import com.wafflestudio.snutt.lecturebuildings.service.LectureBuildingService
import com.wafflestudio.snutt.lectures.data.Lecture
import com.wafflestudio.snutt.lectures.service.LectureService
import com.wafflestudio.snutt.lectures.utils.ClassTimeUtils
import com.wafflestudio.snutt.registrationperiod.data.SemesterRegistrationPeriod
import com.wafflestudio.snutt.registrationperiod.repository.SemesterRegistrationPeriodRepository
import com.wafflestudio.snutt.sugangsnu.common.SugangSnuRepository
import com.wafflestudio.snutt.sugangsnu.common.data.SugangSnuCoursebookCondition
import com.wafflestudio.snutt.sugangsnu.common.service.SugangSnuFetchService
import com.wafflestudio.snutt.sugangsnu.common.utils.RegistrationPeriodParseUtils
import com.wafflestudio.snutt.sugangsnu.job.sync.data.BookmarkLectureDeleteResult
import com.wafflestudio.snutt.sugangsnu.job.sync.data.BookmarkLectureUpdateResult
import com.wafflestudio.snutt.sugangsnu.job.sync.data.SugangSnuLectureCompareResult
Expand All @@ -31,6 +34,8 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.toList
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
Expand All @@ -43,6 +48,8 @@ interface SugangSnuSyncService {
suspend fun addCoursebook(coursebook: Coursebook)

suspend fun isSyncWithSugangSnu(latestCoursebook: Coursebook): Boolean

suspend fun extractRegistrationPeriod(coursebook: Coursebook)
}

@Service
Expand All @@ -54,6 +61,7 @@ class SugangSnuSyncServiceImpl(
private val coursebookRepository: CoursebookRepository,
private val bookmarkRepository: BookmarkRepository,
private val tagListRepository: TagListRepository,
private val semesterRegistrationPeriodRepository: SemesterRegistrationPeriodRepository,
private val lectureBuildingService: LectureBuildingService,
private val eventPublisher: ApplicationEventPublisher,
) : SugangSnuSyncService {
Expand Down Expand Up @@ -89,6 +97,25 @@ class SugangSnuSyncServiceImpl(
return latestCoursebook.isSyncedToSugangSnu(sugangSnuLatestCoursebook)
}

override suspend fun extractRegistrationPeriod(coursebook: Coursebook) {
val table = getRegistrationPeriodTable()
val newDatesMap = RegistrationPeriodParseUtils.parseRegistrationDates(table)

val existing = semesterRegistrationPeriodRepository.findByYearAndSemester(coursebook.year, coursebook.semester)
val existingDatesMap = existing?.registrationPeriods?.associateBy { it.date } ?: emptyMap()

val mergedRegistrationDates = (existingDatesMap + newDatesMap).values.sortedBy { it.date }

semesterRegistrationPeriodRepository.save(
SemesterRegistrationPeriod(
id = existing?.id,
year = coursebook.year,
semester = coursebook.semester,
registrationPeriods = mergedRegistrationDates,
),
)
}

private fun compareLectures(
newLectures: Iterable<Lecture>,
oldLectures: Iterable<Lecture>,
Expand Down Expand Up @@ -373,6 +400,18 @@ class SugangSnuSyncServiceImpl(
lectureBuildingService.updateLectureBuildings(updatedPlaceInfos)
}

private suspend fun getRegistrationPeriodTable(): Element {
val webPageDataBuffer = sugangSnuRepository.getSugangSnuMainPage()
return try {
Jsoup
.parse(webPageDataBuffer.asInputStream(), Charsets.UTF_8.name(), "")
.select(".table-con table")
.first()!!
} finally {
webPageDataBuffer.release()
}
}

private fun Coursebook.isSyncedToSugangSnu(sugangSnuCoursebookCondition: SugangSnuCoursebookCondition): Boolean =
this.year == sugangSnuCoursebookCondition.latestYear && this.semester == sugangSnuCoursebookCondition.latestSemester
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.wafflestudio.snutt.sugangsnu.job.vacancynotification

import com.wafflestudio.snutt.common.JobFailureLoggingListener
import com.wafflestudio.snutt.common.exception.RegistrationPeriodNotSetException
import com.wafflestudio.snutt.coursebook.service.CoursebookService
import com.wafflestudio.snutt.registrationperiod.service.SemesterRegistrationPeriodService
import com.wafflestudio.snutt.sugangsnu.common.service.SugangSnuNotificationService
import com.wafflestudio.snutt.sugangsnu.job.vacancynotification.data.VacancyNotificationJobResult
import com.wafflestudio.snutt.sugangsnu.job.vacancynotification.service.VacancyNotifierService
Expand Down Expand Up @@ -38,14 +40,29 @@ class VacancyNotificationJobConfig {
vacancyNotifierService: VacancyNotifierService,
coursebookService: CoursebookService,
sugangSnuNotificationService: SugangSnuNotificationService,
semesterRegistrationPeriodService: SemesterRegistrationPeriodService,
): Step =
StepBuilder("vacancyNotificationStep", jobRepository)
.tasklet(
{ _, _ ->
runBlocking {
val latestCoursebook = coursebookService.getLatestCoursebook()
val updateResult = vacancyNotifierService.noti(latestCoursebook)
if (Instant.now().atZone(ZoneId.of("Asia/Seoul")).hour == 18) return@runBlocking RepeatStatus.FINISHED
val registrationPeriods =
semesterRegistrationPeriodService
.getByYearAndSemester(latestCoursebook.year, latestCoursebook.semester)
?.registrationPeriods ?: throw RegistrationPeriodNotSetException
if (Instant.now().atZone(KST).hour >= 18) return@runBlocking RepeatStatus.FINISHED
val currentDate = Instant.now().atZone(KST).toLocalDate()
val registrationDay =
registrationPeriods.find {
currentDate == it.date
} ?: return@runBlocking RepeatStatus.FINISHED
val updateResult =
vacancyNotifierService.notify(
latestCoursebook,
registrationDay.phase,
registrationDay.vacantSeatRegistrationTimes,
)
when (updateResult) {
VacancyNotificationJobResult.REGISTRATION_IS_NOT_STARTED -> RepeatStatus.FINISHED
VacancyNotificationJobResult.OVERLOAD_PERIOD -> RepeatStatus.CONTINUABLE
Expand All @@ -55,4 +72,8 @@ class VacancyNotificationJobConfig {
},
transactionManager,
).build()

companion object {
private val KST = ZoneId.of("Asia/Seoul")
}
}
Loading