diff --git a/api/src/main/kotlin/controller/AdminController.kt b/api/src/main/kotlin/controller/AdminController.kt index e5bb041d..03e76887 100644 --- a/api/src/main/kotlin/controller/AdminController.kt +++ b/api/src/main/kotlin/controller/AdminController.kt @@ -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 @@ -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 @@ -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( @@ -142,4 +147,32 @@ class AdminController( diaryService.removeQuestion(id) return OkResponse() } + + @GetMapping("/registrationPeriods") + suspend fun getSemesterRegistrationPeriods(): List = 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, + ): 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() + } } diff --git a/batch/src/main/kotlin/sugangsnu/common/SugangSnuRepository.kt b/batch/src/main/kotlin/sugangsnu/common/SugangSnuRepository.kt index 404d6701..a521d8e8 100644 --- a/batch/src/main/kotlin/sugangsnu/common/SugangSnuRepository.kt +++ b/batch/src/main/kotlin/sugangsnu/common/SugangSnuRepository.kt @@ -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" @@ -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() + } + } } diff --git a/batch/src/main/kotlin/sugangsnu/common/utils/RegistrationPeriodParseUtils.kt b/batch/src/main/kotlin/sugangsnu/common/utils/RegistrationPeriodParseUtils.kt new file mode 100644 index 00000000..187b57bf --- /dev/null +++ b/batch/src/main/kotlin/sugangsnu/common/utils/RegistrationPeriodParseUtils.kt @@ -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 = + 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 = + 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 { + // 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 + } +} diff --git a/batch/src/main/kotlin/sugangsnu/job/sync/SugangSnuSyncJobConfig.kt b/batch/src/main/kotlin/sugangsnu/job/sync/SugangSnuSyncJobConfig.kt index bb283f6c..25de6616 100644 --- a/batch/src/main/kotlin/sugangsnu/job/sync/SugangSnuSyncJobConfig.kt +++ b/batch/src/main/kotlin/sugangsnu/job/sync/SugangSnuSyncJobConfig.kt @@ -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) } } diff --git a/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt b/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt index 055acf3d..a0cbcc83 100644 --- a/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt +++ b/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt @@ -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 @@ -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 @@ -43,6 +48,8 @@ interface SugangSnuSyncService { suspend fun addCoursebook(coursebook: Coursebook) suspend fun isSyncWithSugangSnu(latestCoursebook: Coursebook): Boolean + + suspend fun extractRegistrationPeriod(coursebook: Coursebook) } @Service @@ -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 { @@ -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, oldLectures: Iterable, @@ -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 } diff --git a/batch/src/main/kotlin/sugangsnu/job/vacancynotification/VacancyNotificationJobConfig.kt b/batch/src/main/kotlin/sugangsnu/job/vacancynotification/VacancyNotificationJobConfig.kt index 38482d85..c4c22665 100644 --- a/batch/src/main/kotlin/sugangsnu/job/vacancynotification/VacancyNotificationJobConfig.kt +++ b/batch/src/main/kotlin/sugangsnu/job/vacancynotification/VacancyNotificationJobConfig.kt @@ -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 @@ -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 @@ -55,4 +72,8 @@ class VacancyNotificationJobConfig { }, transactionManager, ).build() + + companion object { + private val KST = ZoneId.of("Asia/Seoul") + } } diff --git a/batch/src/main/kotlin/sugangsnu/job/vacancynotification/service/VacancyNotifierService.kt b/batch/src/main/kotlin/sugangsnu/job/vacancynotification/service/VacancyNotifierService.kt index a2096937..6ad9245f 100644 --- a/batch/src/main/kotlin/sugangsnu/job/vacancynotification/service/VacancyNotifierService.kt +++ b/batch/src/main/kotlin/sugangsnu/job/vacancynotification/service/VacancyNotifierService.kt @@ -6,7 +6,11 @@ import com.wafflestudio.snutt.common.push.dto.PushMessage import com.wafflestudio.snutt.coursebook.data.Coursebook import com.wafflestudio.snutt.lectures.data.Lecture import com.wafflestudio.snutt.lectures.service.LectureService +import com.wafflestudio.snutt.lectures.utils.minuteToString +import com.wafflestudio.snutt.notification.data.NotificationType import com.wafflestudio.snutt.notification.service.PushWithNotificationService +import com.wafflestudio.snutt.registrationperiod.data.RegistrationPhase +import com.wafflestudio.snutt.registrationperiod.data.RegistrationTimeSlot import com.wafflestudio.snutt.sugangsnu.common.SugangSnuRepository import com.wafflestudio.snutt.sugangsnu.job.vacancynotification.data.RegistrationStatus import com.wafflestudio.snutt.sugangsnu.job.vacancynotification.data.VacancyNotificationJobResult @@ -25,11 +29,16 @@ import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.slf4j.LoggerFactory import org.springframework.stereotype.Service -import java.util.Calendar +import java.time.Instant +import java.time.ZoneId import kotlin.time.Duration.Companion.seconds interface VacancyNotifierService { - suspend fun noti(coursebook: Coursebook): VacancyNotificationJobResult + suspend fun notify( + coursebook: Coursebook, + registrationPhase: RegistrationPhase, + vacantSeatRegistrationTimes: List, + ): VacancyNotificationJobResult } @Service @@ -47,15 +56,13 @@ class VacancyNotifierServiceImpl( private val log = LoggerFactory.getLogger(javaClass) private val courseNumberRegex = """(?.*)\((?.+)\)""".toRegex() - private val isFreshmanRegistrationCompleted = - Calendar.getInstance() > - Calendar.getInstance().apply { - set(Calendar.MONTH, Calendar.FEBRUARY) - set(Calendar.DAY_OF_MONTH, 14) - } - override suspend fun noti(coursebook: Coursebook): VacancyNotificationJobResult { - log.info("시작") + override suspend fun notify( + coursebook: Coursebook, + registrationPhase: RegistrationPhase, + vacantSeatRegistrationTimes: List, + ): VacancyNotificationJobResult { + log.info("시작: ${registrationPhase.name}") val pageCount = runCatching { getPageCount() @@ -82,7 +89,8 @@ class VacancyNotifierServiceImpl( val notiTargetLectures = lectureAndRegistrationStatus - .filter { (lecture, _) -> lecture.isFull() } + .filter { (lecture, _) -> lecture.isFull(registrationPhase) } + .filter { (_, status) -> status.wasFull } .filter { (lecture, status) -> lecture.registrationCount > status.registrationCount } .map { (lecture, _) -> lecture } @@ -97,6 +105,13 @@ class VacancyNotifierServiceImpl( } lectureService.upsertLectures(updated) + val currentMinute = Instant.now().atZone(ZoneId.of("Asia/Seoul")).minute + val targetTimeString = + vacantSeatRegistrationTimes + .filter { currentMinute < it.endMinute } + .minOfOrNull { it.startMinute } + ?.let { minuteToString(it) } ?: "다음 수강신청 일자" + pushCoroutineScope.launch { notiTargetLectures.forEach { lecture -> log.info( @@ -111,33 +126,35 @@ class VacancyNotifierServiceImpl( val pushMessage = PushMessage( title = "빈자리 알림", - body = """"${lecture.courseTitle} (${lecture.lectureNumber})" 강의에 빈자리가 생겼습니다. 수강신청 사이트를 확인해보세요!""", + body = + """ + "${lecture.courseTitle} (${lecture.lectureNumber})" 강의에 빈자리가 생겼습니다. + ${targetTimeString}에 수강신청 사이트를 확인해보세요! + """.trimIndent(), urlScheme = DeeplinkType.VACANCY.build(), isUrgentOnAndroid = true, ) - /* + pushWithNotificationService.sendPushesAndNotifications( pushMessage, NotificationType.LECTURE_VACANCY, userIds, ) - */ } } } + delay(DELAY_PER_CHUNK) } return VacancyNotificationJobResult.SUCCESS } - private fun Lecture.isFull(): Boolean { - val isCurrentStudentRegistrationPeriod = this.semester == Semester.SPRING && !isFreshmanRegistrationCompleted - return if (isCurrentStudentRegistrationPeriod) { + private fun Lecture.isFull(registrationPhase: RegistrationPhase): Boolean = + if (this.semester == Semester.SPRING && registrationPhase == RegistrationPhase.CURRENT_STUDENT) { this.quota - (this.freshmanQuota ?: 0) == this.registrationCount } else { this.quota == this.registrationCount } - } private suspend fun getPageCount(): Int { val firstPageContent = getSugangSnuSearchContent(1) diff --git a/core/src/main/kotlin/common/exception/ErrorType.kt b/core/src/main/kotlin/common/exception/ErrorType.kt index 6592496c..9d86ce4c 100644 --- a/core/src/main/kotlin/common/exception/ErrorType.kt +++ b/core/src/main/kotlin/common/exception/ErrorType.kt @@ -101,4 +101,5 @@ enum class ErrorType( CANNOT_REMOVE_LAST_AUTH_PROVIDER(HttpStatus.CONFLICT, 40909, "최소 한 개의 로그인 수단은 유지해야 합니다", "최소 한 개의 로그인 수단은 유지해야 합니다"), DYNAMIC_LINK_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 50001, "링크 생성 실패", "링크 생성에 실패했습니다. 잠시 후 다시 시도해주세요"), + REGISTRATION_PERIOD_NOT_SET(HttpStatus.INTERNAL_SERVER_ERROR, 50003, "학기에 대한 수강신청 기간이 설정되지 않았습니다", "학기에 대한 수강신청 기간이 설정되지 않았습니다"), } diff --git a/core/src/main/kotlin/common/exception/SnuttException.kt b/core/src/main/kotlin/common/exception/SnuttException.kt index 855d1813..b81abb04 100644 --- a/core/src/main/kotlin/common/exception/SnuttException.kt +++ b/core/src/main/kotlin/common/exception/SnuttException.kt @@ -181,3 +181,5 @@ object DuplicateSocialAccountException : SnuttException(ErrorType.DUPLICATE_SOCI object CannotRemoveLastAuthProviderException : SnuttException(ErrorType.CANNOT_REMOVE_LAST_AUTH_PROVIDER) object DynamicLinkGenerationFailedException : SnuttException(ErrorType.DYNAMIC_LINK_GENERATION_FAILED) + +object RegistrationPeriodNotSetException : SnuttException(ErrorType.REGISTRATION_PERIOD_NOT_SET) diff --git a/core/src/main/kotlin/registrationperiod/data/RegistrationPhase.kt b/core/src/main/kotlin/registrationperiod/data/RegistrationPhase.kt new file mode 100644 index 00000000..3bd87525 --- /dev/null +++ b/core/src/main/kotlin/registrationperiod/data/RegistrationPhase.kt @@ -0,0 +1,7 @@ +package com.wafflestudio.snutt.registrationperiod.data + +enum class RegistrationPhase { + CURRENT_STUDENT, + FRESHMAN, + COURSE_CHANGE, +} diff --git a/core/src/main/kotlin/registrationperiod/data/SemesterRegistrationPeriod.kt b/core/src/main/kotlin/registrationperiod/data/SemesterRegistrationPeriod.kt new file mode 100644 index 00000000..ea279dad --- /dev/null +++ b/core/src/main/kotlin/registrationperiod/data/SemesterRegistrationPeriod.kt @@ -0,0 +1,31 @@ +package com.wafflestudio.snutt.registrationperiod.data + +import com.wafflestudio.snutt.common.enums.Semester +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.index.CompoundIndex +import org.springframework.data.mongodb.core.mapping.Document +import org.springframework.data.mongodb.core.mapping.Field +import org.springframework.data.mongodb.core.mapping.FieldType +import java.time.LocalDate + +@Document +@CompoundIndex(def = "{ 'year': 1, 'semester': 1 }") +data class SemesterRegistrationPeriod( + @Id + val id: String?, + val year: Int, + val semester: Semester, + val registrationPeriods: List, +) + +data class RegistrationDate( + val date: LocalDate, + val vacantSeatRegistrationTimes: List, + @Field(targetType = FieldType.STRING) + val phase: RegistrationPhase, +) + +data class RegistrationTimeSlot( + val startMinute: Int, + val endMinute: Int, +) diff --git a/core/src/main/kotlin/registrationperiod/repository/SemesterRegistrationPeriodRepository.kt b/core/src/main/kotlin/registrationperiod/repository/SemesterRegistrationPeriodRepository.kt new file mode 100644 index 00000000..f7602ac0 --- /dev/null +++ b/core/src/main/kotlin/registrationperiod/repository/SemesterRegistrationPeriodRepository.kt @@ -0,0 +1,17 @@ +package com.wafflestudio.snutt.registrationperiod.repository + +import com.wafflestudio.snutt.common.enums.Semester +import com.wafflestudio.snutt.registrationperiod.data.SemesterRegistrationPeriod +import org.springframework.data.repository.kotlin.CoroutineCrudRepository + +interface SemesterRegistrationPeriodRepository : CoroutineCrudRepository { + suspend fun findByYearAndSemester( + year: Int, + semester: Semester, + ): SemesterRegistrationPeriod? + + suspend fun deleteByYearAndSemester( + year: Int, + semester: Semester, + ) +} diff --git a/core/src/main/kotlin/registrationperiod/service/SemesterRegistrationPeriodService.kt b/core/src/main/kotlin/registrationperiod/service/SemesterRegistrationPeriodService.kt new file mode 100644 index 00000000..0b0c875c --- /dev/null +++ b/core/src/main/kotlin/registrationperiod/service/SemesterRegistrationPeriodService.kt @@ -0,0 +1,63 @@ +package com.wafflestudio.snutt.registrationperiod.service + +import com.wafflestudio.snutt.common.enums.Semester +import com.wafflestudio.snutt.registrationperiod.data.RegistrationDate +import com.wafflestudio.snutt.registrationperiod.data.SemesterRegistrationPeriod +import com.wafflestudio.snutt.registrationperiod.repository.SemesterRegistrationPeriodRepository +import kotlinx.coroutines.flow.toList +import org.springframework.stereotype.Service + +interface SemesterRegistrationPeriodService { + suspend fun getAll(): List + + suspend fun getByYearAndSemester( + year: Int, + semester: Semester, + ): SemesterRegistrationPeriod? + + suspend fun upsert( + year: Int, + semester: Semester, + registrationPeriods: List, + ): SemesterRegistrationPeriod + + suspend fun delete( + year: Int, + semester: Semester, + ) +} + +@Service +class SemesterRegistrationPeriodServiceImpl( + private val semesterRegistrationPeriodRepository: SemesterRegistrationPeriodRepository, +) : SemesterRegistrationPeriodService { + override suspend fun getAll(): List = semesterRegistrationPeriodRepository.findAll().toList() + + override suspend fun getByYearAndSemester( + year: Int, + semester: Semester, + ): SemesterRegistrationPeriod? = semesterRegistrationPeriodRepository.findByYearAndSemester(year, semester) + + override suspend fun upsert( + year: Int, + semester: Semester, + registrationPeriods: List, + ): SemesterRegistrationPeriod { + val existing = semesterRegistrationPeriodRepository.findByYearAndSemester(year, semester) + val semesterRegistrationPeriod = + SemesterRegistrationPeriod( + id = existing?.id, + year = year, + semester = semester, + registrationPeriods = registrationPeriods, + ) + return semesterRegistrationPeriodRepository.save(semesterRegistrationPeriod) + } + + override suspend fun delete( + year: Int, + semester: Semester, + ) { + semesterRegistrationPeriodRepository.deleteByYearAndSemester(year, semester) + } +} diff --git a/core/src/main/kotlin/vacancynotification/service/VacancyNotificationService.kt b/core/src/main/kotlin/vacancynotification/service/VacancyNotificationService.kt index c6b72640..8ccce6b6 100644 --- a/core/src/main/kotlin/vacancynotification/service/VacancyNotificationService.kt +++ b/core/src/main/kotlin/vacancynotification/service/VacancyNotificationService.kt @@ -1,12 +1,15 @@ package com.wafflestudio.snutt.vacancynotification.service import com.wafflestudio.snutt.common.exception.DuplicateVacancyNotificationException -import com.wafflestudio.snutt.common.exception.VacancyNotificationTemporarilyUnavailableException +import com.wafflestudio.snutt.common.exception.InvalidRegistrationForPreviousSemesterCourseException +import com.wafflestudio.snutt.common.exception.LectureNotFoundException import com.wafflestudio.snutt.coursebook.service.CoursebookService import com.wafflestudio.snutt.lectures.data.Lecture import com.wafflestudio.snutt.lectures.repository.LectureRepository import com.wafflestudio.snutt.vacancynotification.data.VacancyNotification import com.wafflestudio.snutt.vacancynotification.repository.VacancyNotificationRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import org.springframework.dao.DuplicateKeyException @@ -54,9 +57,19 @@ class VacancyNotificationServiceImpl( override suspend fun addVacancyNotification( userId: String, lectureId: String, - ): VacancyNotification { - throw VacancyNotificationTemporarilyUnavailableException // 2025-11-06 정보화본부 취소여석 정책이 바뀜에 따라 빈자리알림을 대응 전까지 일시 중단한다 - } + ): VacancyNotification = + coroutineScope { + val deferredLecture = async { lectureRepository.findById(lectureId) } + val deferredLatestCoursebook = async { coursebookService.getLatestCoursebook() } + val (lecture, latestCoursebook) = deferredLecture.await() to deferredLatestCoursebook.await() + + if (lecture == null) throw LectureNotFoundException + if (!(lecture.year == latestCoursebook.year && lecture.semester == latestCoursebook.semester)) { + throw InvalidRegistrationForPreviousSemesterCourseException + } + + trySave(VacancyNotification(userId = userId, lectureId = lectureId)) + } override suspend fun deleteVacancyNotification( userId: String,