diff --git a/api/src/main/kotlin/controller/AuthController.kt b/api/src/main/kotlin/controller/AuthController.kt index a8b19083..8462e8b9 100644 --- a/api/src/main/kotlin/controller/AuthController.kt +++ b/api/src/main/kotlin/controller/AuthController.kt @@ -92,7 +92,8 @@ class AuthController( suspend fun verifyResetPasswordCode( @RequestBody body: VerificationCodeRequest, ): OkResponse { - userService.verifyResetPasswordCode(body.userId!!, body.code) + val user = userService.getUser(body.userId!!) + userService.verifyResetPasswordCode(user, body.code) return OkResponse() } diff --git a/api/src/main/kotlin/controller/DiaryController.kt b/api/src/main/kotlin/controller/DiaryController.kt index ce2254ed..3415db74 100644 --- a/api/src/main/kotlin/controller/DiaryController.kt +++ b/api/src/main/kotlin/controller/DiaryController.kt @@ -1,11 +1,14 @@ package com.wafflestudio.snutt.controller import com.wafflestudio.snutt.common.dto.OkResponse +import com.wafflestudio.snutt.common.enums.Semester +import com.wafflestudio.snutt.common.exception.DiaryTargetLectureNotFoundException import com.wafflestudio.snutt.config.CurrentUser import com.wafflestudio.snutt.diary.dto.DiaryDailyClassTypeDto import com.wafflestudio.snutt.diary.dto.DiaryQuestionnaireDto import com.wafflestudio.snutt.diary.dto.DiarySubmissionSummaryDto import com.wafflestudio.snutt.diary.dto.DiarySubmissionsOfYearSemesterDto +import com.wafflestudio.snutt.diary.dto.DiaryTargetLectureDto import com.wafflestudio.snutt.diary.dto.request.DiaryQuestionnaireRequestDto import com.wafflestudio.snutt.diary.dto.request.DiarySubmissionRequestDto import com.wafflestudio.snutt.diary.service.DiaryService @@ -18,6 +21,7 @@ import org.springframework.web.bind.annotation.PathVariable 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.RequestParam import org.springframework.web.bind.annotation.RestController @RestController @@ -38,6 +42,17 @@ class DiaryController( diaryService.generateQuestionnaire(user.id!!, body.lectureId, body.dailyClassTypes), ) + @GetMapping("/target") + suspend fun getRandomTargetLecture( + @CurrentUser user: User, + @RequestParam year: Int, + @RequestParam semester: Semester, + ): DiaryTargetLectureDto { + val targetLecture = + diaryService.getDiaryTargetLecture(user.id!!, year, semester, listOf()) ?: throw DiaryTargetLectureNotFoundException + return DiaryTargetLectureDto(targetLecture) + } + @GetMapping("/my") suspend fun getMySubmissions( @CurrentUser user: User, diff --git a/batch/src/main/kotlin/pre2025category/api/GoogleDocsApi.kt b/batch/src/main/kotlin/pre2025category/api/GoogleDocsApi.kt deleted file mode 100644 index 8523e99a..00000000 --- a/batch/src/main/kotlin/pre2025category/api/GoogleDocsApi.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.wafflestudio.snutt.pre2025category.api - -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.http.client.reactive.ReactorClientHttpConnector -import org.springframework.web.reactive.function.client.ExchangeStrategies -import org.springframework.web.reactive.function.client.WebClient -import reactor.netty.http.client.HttpClient - -@Configuration -class GoogleDocsApiConfig { - companion object { - const val GOOGLE_DOCS_BASE_URL = "https://docs.google.com" - } - - @Bean - fun googleDocsApi(): GoogleDocsApi { - val exchangeStrategies: ExchangeStrategies = - ExchangeStrategies - .builder() - .codecs { it.defaultCodecs().maxInMemorySize(-1) } // to unlimited memory size - .build() - - val httpClient = - HttpClient - .create() - .followRedirect(true) - .compress(true) - - return WebClient - .builder() - .baseUrl(GOOGLE_DOCS_BASE_URL) - .clientConnector(ReactorClientHttpConnector(httpClient)) - .exchangeStrategies(exchangeStrategies) - .build() - .let(::GoogleDocsApi) - } -} - -class GoogleDocsApi( - webClient: WebClient, -) : WebClient by webClient diff --git a/batch/src/main/kotlin/pre2025category/repository/CategoryPre2025Repository.kt b/batch/src/main/kotlin/pre2025category/repository/CategoryPre2025Repository.kt deleted file mode 100644 index 43586764..00000000 --- a/batch/src/main/kotlin/pre2025category/repository/CategoryPre2025Repository.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.wafflestudio.snutt.pre2025category.repository - -import com.wafflestudio.snutt.pre2025category.api.GoogleDocsApi -import org.springframework.core.io.buffer.PooledDataBuffer -import org.springframework.http.MediaType -import org.springframework.stereotype.Component -import org.springframework.web.reactive.function.client.awaitBody -import org.springframework.web.reactive.function.client.awaitExchange -import org.springframework.web.reactive.function.client.createExceptionAndAwait - -@Component -class CategoryPre2025Repository( - private val googleDocsApi: GoogleDocsApi, -) { - companion object { - const val SPREADSHEET_PATH = "/spreadsheets/d" - const val SPREADSHEET_KEY = "/1Ok2gu7rW1VYlKmC_zSjNmcljef0kstm19P9zJ_5s_QA" - } - - suspend fun fetchCategoriesPre2025(): PooledDataBuffer = - googleDocsApi - .get() - .uri { builder -> - builder.run { - path(SPREADSHEET_PATH) - path(SPREADSHEET_KEY) - path("/export") - queryParam("format", "xlsx") - build() - } - }.accept(MediaType.TEXT_HTML) - .awaitExchange { - if (it.statusCode().is2xxSuccessful) { - it.awaitBody() - } else { - throw it.createExceptionAndAwait() - } - } -} diff --git a/batch/src/main/kotlin/pre2025category/service/CategoryPre2025FetchService.kt b/batch/src/main/kotlin/pre2025category/service/CategoryPre2025FetchService.kt deleted file mode 100644 index 76fa073c..00000000 --- a/batch/src/main/kotlin/pre2025category/service/CategoryPre2025FetchService.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.wafflestudio.snutt.pre2025category.service - -import com.wafflestudio.snutt.pre2025category.repository.CategoryPre2025Repository -import org.apache.poi.ss.usermodel.WorkbookFactory -import org.springframework.core.io.buffer.PooledDataBuffer -import org.springframework.stereotype.Service - -@Service -class CategoryPre2025FetchService( - private val categoryPre2025Repository: CategoryPre2025Repository, -) { - suspend fun getCategoriesPre2025(): Map { - val oldCategoriesXlsx: PooledDataBuffer = categoryPre2025Repository.fetchCategoriesPre2025() - - try { - val workbook = WorkbookFactory.create(oldCategoriesXlsx.asInputStream()) - return workbook - .sheetIterator() - .asSequence() - .flatMap { sheet -> - sheet - .rowIterator() - .asSequence() - .drop(4) - .mapNotNull { row -> - runCatching { - val currentCourseNumber = row.getCell(7).stringCellValue - val oldCategory = row.getCell(1).stringCellValue - check(currentCourseNumber.isNotBlank() && oldCategory.isNotBlank()) - currentCourseNumber to oldCategory - }.getOrNull() - } - }.toMap() - } finally { - oldCategoriesXlsx.release() - } - } -} diff --git a/batch/src/main/kotlin/sugangsnu/common/service/SugangSnuFetchService.kt b/batch/src/main/kotlin/sugangsnu/common/service/SugangSnuFetchService.kt index 348e404c..e63971ce 100644 --- a/batch/src/main/kotlin/sugangsnu/common/service/SugangSnuFetchService.kt +++ b/batch/src/main/kotlin/sugangsnu/common/service/SugangSnuFetchService.kt @@ -2,12 +2,12 @@ package com.wafflestudio.snutt.sugangsnu.common.service import com.wafflestudio.snutt.common.enums.Semester import com.wafflestudio.snutt.lectures.data.Lecture -import com.wafflestudio.snutt.pre2025category.service.CategoryPre2025FetchService import com.wafflestudio.snutt.sugangsnu.common.SugangSnuRepository import com.wafflestudio.snutt.sugangsnu.common.utils.SugangSnuClassTimeUtils import org.apache.poi.hssf.usermodel.HSSFWorkbook import org.apache.poi.ss.usermodel.Cell import org.slf4j.LoggerFactory +import org.springframework.core.io.ResourceLoader import org.springframework.stereotype.Service interface SugangSnuFetchService { @@ -20,16 +20,27 @@ interface SugangSnuFetchService { @Service class SugangSnuFetchServiceImpl( private val sugangSnuRepository: SugangSnuRepository, - private val categoryPre2025FetchService: CategoryPre2025FetchService, + private val resourceLoader: ResourceLoader, ) : SugangSnuFetchService { private val log = LoggerFactory.getLogger(javaClass) private val quotaRegex = """(?\d+)(\s*\((?\d+)\))?""".toRegex() + private val courseNumberCategoryPre2025Map: Map by lazy { + resourceLoader + .getResource("classpath:categoryPre2025.txt") + .inputStream + .bufferedReader() + .lineSequence() + .filter { it.contains(":") } + .associate { line -> + val (courseNumber, category) = line.split(":", limit = 2) + courseNumber to category + } + } override suspend fun getSugangSnuLectures( year: Int, semester: Semester, ): List { - val courseNumberCategoryPre2025Map = categoryPre2025FetchService.getCategoriesPre2025() val koreanLectureXlsx = sugangSnuRepository.getSugangSnuLectures(year, semester, "ko") val englishLectureXlsx = sugangSnuRepository.getSugangSnuLectures(year, semester, "en") val koreanSheet = HSSFWorkbook(koreanLectureXlsx.asInputStream()).getSheetAt(0) 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 a0cbcc83..c0d3435d 100644 --- a/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt +++ b/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt @@ -1,6 +1,7 @@ package com.wafflestudio.snutt.sugangsnu.job.sync.service import com.wafflestudio.snutt.bookmark.repository.BookmarkRepository +import com.wafflestudio.snutt.common.exception.CoursebookRecentThanSugangSnuException import com.wafflestudio.snutt.coursebook.data.Coursebook import com.wafflestudio.snutt.coursebook.repository.CoursebookRepository import com.wafflestudio.snutt.lecturebuildings.data.Campus @@ -133,7 +134,7 @@ class SugangSnuSyncServiceImpl( old, new, Lecture::class.memberProperties.filter { - it != Lecture::id && it.get(old) != it.get(new) + it != Lecture::id && it != Lecture::evInfo && it.get(old) != it.get(new) }, ) } @@ -211,7 +212,10 @@ class SugangSnuSyncServiceImpl( private suspend fun syncLectures(compareResult: SugangSnuLectureCompareResult) { val updatedLectures = compareResult.updatedLectureList.map { diff -> - diff.newData.apply { id = diff.oldData.id } + diff.newData.apply { + id = diff.oldData.id + evInfo = diff.oldData.evInfo + } } lectureService.upsertLectures(compareResult.createdLectureList) @@ -412,8 +416,18 @@ class SugangSnuSyncServiceImpl( } } - private fun Coursebook.isSyncedToSugangSnu(sugangSnuCoursebookCondition: SugangSnuCoursebookCondition): Boolean = - this.year == sugangSnuCoursebookCondition.latestYear && this.semester == sugangSnuCoursebookCondition.latestSemester + private fun Coursebook.isSyncedToSugangSnu(sugangSnuCoursebookCondition: SugangSnuCoursebookCondition): Boolean { + val sugangSnuCoursebook = + Coursebook( + year = sugangSnuCoursebookCondition.latestYear, + semester = sugangSnuCoursebookCondition.latestSemester, + ) + return when { + sugangSnuCoursebook > this -> false + sugangSnuCoursebook < this -> throw CoursebookRecentThanSugangSnuException + else -> true + } + } } data class ParsedTags( 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 6ad9245f..0daaa164 100644 --- a/batch/src/main/kotlin/sugangsnu/job/vacancynotification/service/VacancyNotifierService.kt +++ b/batch/src/main/kotlin/sugangsnu/job/vacancynotification/service/VacancyNotifierService.kt @@ -105,7 +105,8 @@ class VacancyNotifierServiceImpl( } lectureService.upsertLectures(updated) - val currentMinute = Instant.now().atZone(ZoneId.of("Asia/Seoul")).minute + val now = Instant.now().atZone(ZoneId.of("Asia/Seoul")) + val currentMinute = now.hour * 60 + now.minute val targetTimeString = vacantSeatRegistrationTimes .filter { currentMinute < it.endMinute } diff --git a/batch/src/main/resources/META-INF/native-image/resource-config.json b/batch/src/main/resources/META-INF/native-image/resource-config.json index df875fcb..4c287310 100644 --- a/batch/src/main/resources/META-INF/native-image/resource-config.json +++ b/batch/src/main/resources/META-INF/native-image/resource-config.json @@ -2,7 +2,8 @@ "resources": { "includes": [ { "pattern": "org/apache/xmlbeans/.*\\.properties" }, - { "pattern": "org/apache/.*\\.xsb" } + { "pattern": "org/apache/.*\\.xsb" }, + { "pattern": "categoryPre2025.txt" } ] } } diff --git a/batch/src/main/resources/application.yml b/batch/src/main/resources/application.yml index b97a947e..e160394b 100644 --- a/batch/src/main/resources/application.yml +++ b/batch/src/main/resources/application.yml @@ -6,6 +6,10 @@ spring: name: ${job.name:EMPTY} main: web-application-type: none + task: + scheduling: + pool: + size: 0 --- spring: config: diff --git a/batch/src/main/resources/categoryPre2025.txt b/batch/src/main/resources/categoryPre2025.txt new file mode 100644 index 00000000..cc76a8de --- /dev/null +++ b/batch/src/main/resources/categoryPre2025.txt @@ -0,0 +1,555 @@ +F11.101:사고와 표현 +F11.201:사고와 표현 +F11.202:사고와 표현 +F11.203:사고와 표현 +F12.101:사고와 표현 +F12.102:사고와 표현 +F12.103:사고와 표현 +F21.100:외국어 +F21.101:외국어 +F21.201:외국어 +F21.202:외국어 +F21.301:외국어 +F21.302:외국어 +F21.303:외국어 +F21.304:외국어 +F21.305:외국어 +F21.306:외국어 +F21.307:외국어 +F22.101:외국어 +F22.102:외국어 +F22.201:외국어 +F22.202:외국어 +F22.301:외국어 +F22.302:외국어 +F22.303:외국어 +F23.101:외국어 +F23.102:외국어 +F23.201:외국어 +F23.202:외국어 +F23.301:외국어 +F23.302:외국어 +F23.303:외국어 +F24.101:외국어 +F24.102:외국어 +F24.201:외국어 +F24.202:외국어 +F24.301:외국어 +F24.302:외국어 +F24.303:외국어 +F25.101:외국어 +F25.102:외국어 +F25.201:외국어 +F25.202:외국어 +F25.301:외국어 +F25.302:외국어 +F25.303:외국어 +F26.101:외국어 +F26.102:외국어 +F26.201:외국어 +F26.202:외국어 +F26.301:외국어 +F26.302:외국어 +F26.303:외국어 +F27.101:외국어 +F27.201:외국어 +F27.202:외국어 +F27.301:외국어 +F28.101:외국어 +F28.102:외국어 +F28.201:외국어 +F28.301:외국어 +F28.302:외국어 +F29.101:외국어 +F29.102:외국어 +F29.103:외국어 +F29.104:외국어 +F29.105:외국어 +F29.106:외국어 +F29.107:외국어 +F29.108:외국어 +F29.109:외국어 +F29.110:외국어 +F29.111:외국어 +F29.112:외국어 +F29.113:외국어 +F29.114:외국어 +F29.115:외국어 +F29.116:외국어 +F29.117:외국어 +F29.118:외국어 +F29.119:외국어 +F29.120:외국어 +F29.121:외국어 +F29.122:외국어 +F29.123:외국어 +F29.124:외국어 +F29.125:외국어 +F29.126:외국어 +F29.127:외국어 +F31.101:수량적 분석과 추론 +F31.102:수량적 분석과 추론 +F31.103:수량적 분석과 추론 +F31.104:수량적 분석과 추론 +F31.105:수량적 분석과 추론 +F31.104L:수량적 분석과 추론 +F31.105L:수량적 분석과 추론 +F31.106:수량적 분석과 추론 +F31.107:수량적 분석과 추론 +F31.106L:수량적 분석과 추론 +F31.107L:수량적 분석과 추론 +F31.108:수량적 분석과 추론 +F31.109:수량적 분석과 추론 +F31.110:수량적 분석과 추론 +F31.109L:수량적 분석과 추론 +F31.110L:수량적 분석과 추론 +F31.111:수량적 분석과 추론 +F31.112:수량적 분석과 추론 +F31.113:수량적 분석과 추론 +F31.114:수량적 분석과 추론 +F31.115:수량적 분석과 추론 +F31.201:수량적 분석과 추론 +F31.202:수량적 분석과 추론 +F32.101:수량적 분석과 추론 +F32.102:수량적 분석과 추론 +F32.102L:수량적 분석과 추론 +F32.103:수량적 분석과 추론 +F32.103L:수량적 분석과 추론 +F33.101:과학적 사고와 실험 +F33.102:과학적 사고와 실험 +F33.103:과학적 사고와 실험 +F33.104:과학적 사고와 실험 +F33.105:과학적 사고와 실험 +F33.106:과학적 사고와 실험 +F33.107:과학적 사고와 실험 +F33.108:과학적 사고와 실험 +F33.109:과학적 사고와 실험 +F33.105L:과학적 사고와 실험 +F33.106L:과학적 사고와 실험 +F33.107L:과학적 사고와 실험 +F33.110:과학적 사고와 실험 +F33.111:과학적 사고와 실험 +F33.111L:과학적 사고와 실험 +F34.101:과학적 사고와 실험 +F34.102:과학적 사고와 실험 +F34.103:과학적 사고와 실험 +F34.104:과학적 사고와 실험 +F34.105:과학적 사고와 실험 +F34.106:과학적 사고와 실험 +F34.103L:과학적 사고와 실험 +F34.104L:과학적 사고와 실험 +F34.105L:과학적 사고와 실험 +F35.101:과학적 사고와 실험 +F35.102:과학적 사고와 실험 +F35.103:과학적 사고와 실험 +F35.104:과학적 사고와 실험 +F35.105:과학적 사고와 실험 +F35.103L:과학적 사고와 실험 +F35.104L:과학적 사고와 실험 +F35.105L:과학적 사고와 실험 +F35.106:과학적 사고와 실험 +F36.101:과학적 사고와 실험 +F36.101L:과학적 사고와 실험 +F36.102:과학적 사고와 실험 +F36.102L:과학적 사고와 실험 +F36.103:과학적 사고와 실험 +F36.103L:과학적 사고와 실험 +F36.104:과학적 사고와 실험 +F36.104L:과학적 사고와 실험 +F36.105:과학적 사고와 실험 +F36.105L:과학적 사고와 실험 +F37.101:컴퓨터와 정보 활용 +F37.201:컴퓨터와 정보 활용 +F37.202:컴퓨터와 정보 활용 +F37.203:컴퓨터와 정보 활용 +F37.204:컴퓨터와 정보 활용 +F37.301:컴퓨터와 정보 활용 +F37.302:컴퓨터와 정보 활용 +F37.303:컴퓨터와 정보 활용 +F37.304:컴퓨터와 정보 활용 +C10.101:언어와 문학 +C10.102:문화와 예술 +C10.103:언어와 문학 +C10.104:언어와 문학 +C10.105:역사와 철학 +C10.106:문화와 예술 +C10.107:언어와 문학 +C10.108:역사와 철학 +C10.109:언어와 문학 +C10.110:언어와 문학 +C10.111:문화와 예술 +C10.112:역사와 철학 +C10.113:언어와 문학 +C10.114:문화와 예술 +C10.115:언어와 문학 +C10.116:문화와 예술 +C10.117:언어와 문학 +C10.118:언어와 문학 +C10.119:언어와 문학 +C10.120:언어와 문학 +C10.121:언어와 문학 +C10.122:언어와 문학 +C10.124:문화와 예술 +C10.125:언어와 문학 +C10.126:문화와 예술 +C10.127:문화와 예술 +C10.128:언어와 문학 +C10.129:역사와 철학 +C10.130:문화와 예술 +C10.131:역사와 철학 +C10.132:언어와 문학 +C10.133:문화와 예술 +C10.134:언어와 문학 +C10.135:문화와 예술 +C10.136:역사와 철학 +C10.137:언어와 문학 +C10.138:인간과 사회 +C10.139:언어와 문학 +C10.140:언어와 문학 +C10.141:문화와 예술 +C10.142:문화와 예술 +C10.143:언어와 문학 +C10.144:문화와 예술 +C10.145:문화와 예술 +C10.146:문화와 예술 +C10.147:문화와 예술 +C10.148:언어와 문학 +C10.149:인간과 사회 +C10.150:역사와 철학 +C10.151:인간과 사회 +C10.152:문화와 예술 +C10.153:문화와 예술 +C10.154:언어와 문학 +C10.155:문화와 예술 +C10.156:언어와 문학 +C10.157:문화와 예술 +C10.158:언어와 문학 +C10.159:언어와 문학 +C10.160:언어와 문학 +C10.161:언어와 문학 +C10.162:역사와 철학 +C10.163:언어와 문학 +C10.164:문화와 예술 +C10.165:언어와 문학 +C10.166:언어와 문학 +C10.167:언어와 문학 +C10.168:언어와 문학 +C10.169:문화와 예술 +C10.170:문화와 예술 +C10.171:문화와 예술 +C10.123:언어와 문학 +C20.101:역사와 철학 +C20.102:역사와 철학 +C20.103:역사와 철학 +C20.104:역사와 철학 +C20.105:역사와 철학 +C20.106:역사와 철학 +C20.107:역사와 철학 +C20.108:역사와 철학 +C20.109:역사와 철학 +C20.110:역사와 철학 +C20.111:문화와 예술 +C20.112:역사와 철학 +C20.113:역사와 철학 +C20.114:역사와 철학 +C20.115:역사와 철학 +C20.116:역사와 철학 +C20.117:역사와 철학 +C20.118:역사와 철학 +C20.119:역사와 철학 +C20.120:역사와 철학 +C20.121:역사와 철학 +C20.122:역사와 철학 +C20.123:역사와 철학 +C20.124:역사와 철학 +C20.125:역사와 철학 +C20.126:역사와 철학 +C20.127:역사와 철학 +C20.128:문화와 예술 +C20.129:문화와 예술 +C20.130:역사와 철학 +C20.131:역사와 철학 +C20.132:역사와 철학 +C20.133:역사와 철학 +C20.134:역사와 철학 +C20.135:역사와 철학 +C20.136:역사와 철학 +C20.137:역사와 철학 +C20.138:역사와 철학 +C20.139:역사와 철학 +C20.140:역사와 철학 +C20.141:역사와 철학 +C20.142:역사와 철학 +C20.143:역사와 철학 +C20.144:문화와 예술 +C20.145:역사와 철학 +C20.146:역사와 철학 +C20.147:역사와 철학 +C20.148:역사와 철학 +C20.149:역사와 철학 +C20.150:역사와 철학 +C20.151:역사와 철학 +C20.152:역사와 철학 +C20.153:역사와 철학 +C20.154:역사와 철학 +C20.155:역사와 철학 +C30.101:정치와 경제 +C30.102:정치와 경제 +C30.103:인간과 사회 +C30.104:정치와 경제 +C30.105:정치와 경제 +C30.106:정치와 경제 +C30.107:정치와 경제 +C30.108:정치와 경제 +C30.109:문화와 예술 +C30.110:인간과 사회 +C30.111:언어와 문학 +C30.112:문화와 예술 +C30.113:인간과 사회 +C30.114:정치와 경제 +C30.115:정치와 경제 +C30.116:정치와 경제 +C30.117:인간과 사회 +C30.118:인간과 사회 +C30.119:인간과 사회 +C30.120:인간과 사회 +C30.121:인간과 사회 +C30.122:역사와 철학 +C30.123:인간과 사회 +C30.124:정치와 경제 +C30.126:언어와 문학 +C30.127:정치와 경제 +C30.128:정치와 경제 +C30.129:인간과 사회 +C30.130:인간과 사회 +C30.132:정치와 경제 +C30.133:정치와 경제 +C30.134:인간과 사회 +C30.135:정치와 경제 +C30.136:정치와 경제 +C30.137:정치와 경제 +C30.138:정치와 경제 +C30.139:인간과 사회 +C30.140:정치와 경제 +C30.141:언어와 문학 +C30.142:정치와 경제 +C30.143:인간과 사회 +C30.144:정치와 경제 +C30.145:인간과 사회 +C30.146:인간과 사회 +C40.101:인간과 사회 +C40.102:역사와 철학 +C40.103:역사와 철학 +C40.104:자연과 기술 +C40.105:언어와 문학 +C40.106:자연과 기술 +C40.107:생명과 환경 +C40.108:자연과 기술 +C40.109:자연과 기술 +C40.110:생명과 환경 +C40.112:생명과 환경 +C40.113:자연과 기술 +C40.114:인간과 사회 +C40.116:생명과 환경 +C40.117:생명과 환경 +C40.118:자연과 기술 +C40.119:자연과 기술 +C40.120:자연과 기술 +C40.121:생명과 환경 +C40.122:자연과 기술 +C40.123:자연과 기술 +C40.124:자연과 기술 +C40.125:생명과 환경 +C40.126:역사와 철학 +C40.127:생명과 환경 +C40.128:자연과 기술 +C40.129:자연과 기술 +C40.130:인간과 사회 +C40.131:자연과 기술 +C40.132:생명과 환경 +C40.133:자연과 기술 +C40.134:자연과 기술 +C40.136:창의와 융합 +E11.101:자연과 기술 +E11.102:언어와 문학 +E11.103:정치와 경제 +E11.105:생명과 환경 +E11.106:창의와 융합 +E11.107:창의와 융합 +E11.108:창의와 융합 +E11.109:창의와 융합 +E11.110:창의와 융합 +E11.111:인간과 사회 +E11.112:대학과 리더십 +E11.114:생명과 환경 +E11.115:창의와 융합 +E11.116:역사와 철학 +E11.117:문화와 예술 +E11.118:역사와 철학 +E11.119:역사와 철학 +E11.120:자연과 기술 +E11.121:자연과 기술 +E11.122:인간과 사회 +E11.123:창의와 융합 +E11.124:문화와 예술 +E11.125:역사와 철학 +E11.126:정치와 경제 +E11.128:정치와 경제 +E11.129:역사와 철학 +E11.130:생명과 환경 +E11.131:창의와 융합 +E11.132:생명과 환경 +E11.133:창의와 융합 +E11.135:역사와 철학 +E11.136:창의와 융합 +E11.137:생명과 환경 +E11.138:문화와 예술 +E11.139:문화와 예술 +E11.140:역사와 철학 +E11.141:창의와 융합 +E11.142:정치와 경제 +E11.143:정치와 경제 +E11.144:창의와 융합 +E11.145:역사와 철학 +E11.146:인간과 사회 +E11.148:언어와 문학 +E11.150:정치와 경제 +E11.151:생명과 환경 +E11.153:창의와 융합 +E11.154:창의와 융합 +E11.155:창의와 융합 +E11.156:창의와 융합 +E11.157:창의와 융합 +E11.158:창의와 융합 +E11.159:창의와 융합 +E11.160:문화와 예술 +E11.161:문화와 예술 +E11.162:역사와 철학 +E11.163:인간과 사회 +E11.164:생명과 환경 +E11.165:정치와 경제 +E11.166:인간과 사회 +E11.167:창의와 융합 +E11.169:자연과 기술 +E11.170:인간과 사회 +E11.171:정치와 경제 +E11.172:문화와 예술 +E11.173:역사와 철학 +E11.174:생명과 환경 +E11.175:대학과 리더십 +E11.176:인간과 사회 +E11.177:생명과 환경 +E11.178:창의와 융합 +E11.181:창의와 융합 +E11.182:창의와 융합 +E11.183:창의와 융합 +E11.184:문화와 예술 +E11.185:인간과 사회 +E11.186:정치와 경제 +E11.187:창의와 융합 +E11.188:문화와 예술 +E11.189:문화와 예술 +E11.190:생명과 환경 +E11.191:생명과 환경 +E11.192:언어와 문학 +E11.193:정치와 경제 +E11.194:자연과 기술 +E11.195:생명과 환경 +E11.104:창의와 융합 +E11.134:창의와 융합 +E11.147:창의와 융합 +E11.149:창의와 융합 +E11.152:창의와 융합 +E11.168:창의와 융합 +E11.180:창의와 융합 +E12.101:한국의 이해 +E12.102:한국의 이해 +E12.103:한국의 이해 +E12.104:한국의 이해 +E12.105:한국의 이해 +E12.106:한국의 이해 +E12.107:한국의 이해 +E12.108:한국의 이해 +E12.109:한국의 이해 +E12.110:한국의 이해 +E12.111:한국의 이해 +E12.112:한국의 이해 +E12.113:한국의 이해 +E20.101:대학과 리더십 +E20.102:대학과 리더십 +E20.103:대학과 리더십 +E20.104:대학과 리더십 +E20.105:대학과 리더십 +E20.106:대학과 리더십 +E20.107:대학과 리더십 +E31.101:창의와 융합 +E31.102:창의와 융합 +E31.103:창의와 융합 +E31.104:창의와 융합 +E31.105:창의와 융합 +E32.101:창의와 융합 +E32.102:창의와 융합 +E41.101:예술 실기 +E41.102:예술 실기 +E41.103:예술 실기 +E41.104:예술 실기 +E41.105:예술 실기 +E42.101:예술 실기 +E42.102:예술 실기 +E42.103:예술 실기 +E42.104:예술 실기 +E42.105:예술 실기 +E42.106:예술 실기 +E42.107:예술 실기 +E43.101:체육 +E43.102:체육 +E43.103:체육 +E43.104:체육 +E43.105:체육 +E43.106:체육 +E43.107:체육 +E43.108:체육 +E43.109:체육 +E43.110:체육 +E43.111:체육 +E43.112:체육 +E43.113:체육 +E43.114:체육 +E43.115:체육 +E43.116:체육 +E43.117:체육 +E43.118:체육 +E43.119:체육 +E43.120:체육 +E43.121:체육 +E43.122:체육 +E43.123:체육 +E43.124:체육 +E43.125:체육 +E43.126:체육 +E43.127:체육 +E43.128:체육 +E43.129:체육 +E43.130:체육 +E43.131:체육 +E51.102:대학과 리더십 +E52.101:대학과 리더십 +E52.102:대학과 리더십 +E52.103:창의와 융합 +E52.104:창의와 융합 +V10.101:창의와 융합 +V10.102:창의와 융합 +V10.103:창의와 융합 +V10.104:창의와 융합 +V10.105:창의와 융합 +V30.101:창의와 융합 +V30.102:창의와 융합 +V30.103:창의와 융합 +V30.104:창의와 융합 +V30.105:창의와 융합 +V30.106:창의와 융합 +V30.107:창의와 융합 +V30.108:창의와 융합 +V30.109:창의와 융합 +V30.110:창의와 융합 +V30.111:창의와 융합 diff --git a/core/src/main/kotlin/auth/apple/AppleClient.kt b/core/src/main/kotlin/auth/apple/AppleClient.kt index 6989cdad..7dd79b7b 100644 --- a/core/src/main/kotlin/auth/apple/AppleClient.kt +++ b/core/src/main/kotlin/auth/apple/AppleClient.kt @@ -2,54 +2,36 @@ package com.wafflestudio.snutt.auth.apple import com.wafflestudio.snutt.auth.OAuth2Client import com.wafflestudio.snutt.auth.OAuth2UserResponse +import com.wafflestudio.snutt.auth.oidc.OidcJwtVerifier +import com.wafflestudio.snutt.auth.oidc.OidcVerificationOptions import com.wafflestudio.snutt.common.exception.InvalidAppleLoginTokenException -import com.wafflestudio.snutt.common.extension.get -import io.jsonwebtoken.Jwts -import org.springframework.aot.hint.annotation.RegisterReflectionForBinding -import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component -import org.springframework.web.reactive.function.client.WebClient -import reactor.netty.http.client.HttpClient -import tools.jackson.databind.ObjectMapper -import java.math.BigInteger -import java.security.KeyFactory -import java.security.PublicKey -import java.security.spec.RSAPublicKeySpec -import java.time.Duration -import java.util.Base64 @Component("APPLE") -@RegisterReflectionForBinding(AppleJwk::class) class AppleClient( - private val objectMapper: ObjectMapper, + private val oidcJwtVerifier: OidcJwtVerifier, + @param:Value("\${oidc.apple.app-id:}") private val appleAppId: String, ) : OAuth2Client { - private val webClient = - WebClient - .builder() - .clientConnector( - ReactorClientHttpConnector( - HttpClient.create().responseTimeout( - Duration.ofSeconds(3), - ), - ), - ).build() - companion object { private const val APPLE_JWK_URI = "https://appleid.apple.com/auth/keys" + private const val APPLE_ISSUER = "https://appleid.apple.com" } override suspend fun getMe(token: String): OAuth2UserResponse? { - val jwtHeader = extractJwtHeader(token) - val appleJwk = - webClient - .get>>(uri = APPLE_JWK_URI) - .getOrNull() - ?.get("keys") - ?.find { - it.kid == jwtHeader.kid && it.alg == jwtHeader.alg - } ?: return null - val publicKey = convertJwkToPublicKey(appleJwk) - val jwtPayload = verifyAndDecodeToken(token, publicKey) + if (!oidcJwtVerifier.looksLikeJwt(token)) throw InvalidAppleLoginTokenException + + val jwtPayload = + oidcJwtVerifier.verifyAndDecodeToken( + token = token, + options = + OidcVerificationOptions( + jwksUri = APPLE_JWK_URI, + expectedIssuer = APPLE_ISSUER, + expectedAudience = appleAppId, + ), + ) ?: return null + val appleUserInfo = AppleUserInfo(jwtPayload) return OAuth2UserResponse( socialId = appleUserInfo.sub, @@ -59,37 +41,4 @@ class AppleClient( transferInfo = appleUserInfo.transferSub, ) } - - private suspend fun extractJwtHeader(token: String): AppleJwtHeader { - val headerJson = Base64.getDecoder().decode(token.substringBefore(".")).toString(Charsets.UTF_8) - val headerMap = objectMapper.readValue(headerJson, Map::class.java) - val kid = headerMap["kid"] as? String ?: throw InvalidAppleLoginTokenException - val alg = headerMap["alg"] as? String ?: throw InvalidAppleLoginTokenException - return AppleJwtHeader( - kid = kid, - alg = alg, - ) - } - - private suspend fun convertJwkToPublicKey(jwk: AppleJwk): PublicKey { - val modulus = BigInteger(1, Base64.getUrlDecoder().decode(jwk.n)) - val exponent = BigInteger(1, Base64.getUrlDecoder().decode(jwk.e)) - val spec = RSAPublicKeySpec(modulus, exponent) - return KeyFactory.getInstance("RSA").generatePublic(spec) - } - - private suspend fun verifyAndDecodeToken( - token: String, - publicKey: PublicKey, - ) = Jwts - .parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(token) - .payload } - -private data class AppleJwtHeader( - val kid: String, - val alg: String, -) diff --git a/core/src/main/kotlin/auth/facebook/FacebookClient.kt b/core/src/main/kotlin/auth/facebook/FacebookClient.kt index 393dc6f6..43dc5e95 100644 --- a/core/src/main/kotlin/auth/facebook/FacebookClient.kt +++ b/core/src/main/kotlin/auth/facebook/FacebookClient.kt @@ -2,9 +2,12 @@ package com.wafflestudio.snutt.auth.facebook import com.wafflestudio.snutt.auth.OAuth2Client import com.wafflestudio.snutt.auth.OAuth2UserResponse +import com.wafflestudio.snutt.auth.oidc.OidcJwtVerifier +import com.wafflestudio.snutt.auth.oidc.OidcVerificationOptions import com.wafflestudio.snutt.common.extension.get import org.slf4j.LoggerFactory import org.springframework.aot.hint.annotation.RegisterReflectionForBinding +import org.springframework.beans.factory.annotation.Value import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient @@ -13,7 +16,10 @@ import java.time.Duration @Component("FACEBOOK") @RegisterReflectionForBinding(FacebookOAuth2UserResponse::class) -class FacebookClient : OAuth2Client { +class FacebookClient( + private val oidcJwtVerifier: OidcJwtVerifier, + @param:Value("\${oidc.facebook.app-id:}") private val facebookAppId: String, +) : OAuth2Client { private val log = LoggerFactory.getLogger(javaClass) private val httpClient = HttpClient.create().responseTimeout(Duration.ofSeconds(3)) @@ -25,9 +31,18 @@ class FacebookClient : OAuth2Client { companion object { private const val USER_INFO_URI = "https://graph.facebook.com/me" + private const val FACEBOOK_JWK_URI = "https://www.facebook.com/.well-known/oauth/openid/jwks/" + private const val FACEBOOK_ISSUER = "https://www.facebook.com" } override suspend fun getMe(token: String): OAuth2UserResponse? { + if (oidcJwtVerifier.looksLikeJwt(token)) { + getMeFromAuthenticationToken(token)?.let { return it } + } + return getMeFromAccessToken(token) + } + + private suspend fun getMeFromAccessToken(token: String): OAuth2UserResponse? { val facebookUserResponse = webClient .get( @@ -46,4 +61,24 @@ class FacebookClient : OAuth2Client { ) } } + + private suspend fun getMeFromAuthenticationToken(token: String): OAuth2UserResponse? { + val claims = + oidcJwtVerifier.verifyAndDecodeToken( + token = token, + options = + OidcVerificationOptions( + jwksUri = FACEBOOK_JWK_URI, + expectedIssuer = FACEBOOK_ISSUER, + expectedAudience = facebookAppId, + ), + ) ?: return null + + return OAuth2UserResponse( + socialId = claims["sub"] as? String ?: return null, + name = claims["name"] as? String, + email = claims["email"] as? String, + isEmailVerified = claims["email_verified"] as? Boolean ?: true, + ) + } } diff --git a/core/src/main/kotlin/auth/apple/AppleJwk.kt b/core/src/main/kotlin/auth/oidc/OidcJwk.kt similarity index 66% rename from core/src/main/kotlin/auth/apple/AppleJwk.kt rename to core/src/main/kotlin/auth/oidc/OidcJwk.kt index 2620f216..792287ff 100644 --- a/core/src/main/kotlin/auth/apple/AppleJwk.kt +++ b/core/src/main/kotlin/auth/oidc/OidcJwk.kt @@ -1,6 +1,6 @@ -package com.wafflestudio.snutt.auth.apple +package com.wafflestudio.snutt.auth.oidc -data class AppleJwk( +data class OidcJwk( val kty: String, val kid: String, val use: String, diff --git a/core/src/main/kotlin/auth/oidc/OidcJwkSet.kt b/core/src/main/kotlin/auth/oidc/OidcJwkSet.kt new file mode 100644 index 00000000..874665cc --- /dev/null +++ b/core/src/main/kotlin/auth/oidc/OidcJwkSet.kt @@ -0,0 +1,5 @@ +package com.wafflestudio.snutt.auth.oidc + +data class OidcJwkSet( + val keys: List, +) diff --git a/core/src/main/kotlin/auth/oidc/OidcJwtVerifier.kt b/core/src/main/kotlin/auth/oidc/OidcJwtVerifier.kt new file mode 100644 index 00000000..77e45a18 --- /dev/null +++ b/core/src/main/kotlin/auth/oidc/OidcJwtVerifier.kt @@ -0,0 +1,147 @@ +package com.wafflestudio.snutt.auth.oidc + +import com.wafflestudio.snutt.common.extension.get +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import org.slf4j.LoggerFactory +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import tools.jackson.databind.ObjectMapper +import tools.jackson.module.kotlin.readValue +import java.math.BigInteger +import java.security.KeyFactory +import java.security.PublicKey +import java.security.spec.RSAPublicKeySpec +import java.time.Duration +import java.util.Base64 +import java.util.Date + +data class OidcVerificationOptions( + val jwksUri: String, + val expectedIssuer: String? = null, + val expectedAudience: String? = null, +) + +@Component +@RegisterReflectionForBinding( + OidcJwkSet::class, + OidcJwk::class, +) +class OidcJwtVerifier( + private val objectMapper: ObjectMapper, +) { + private val log = LoggerFactory.getLogger(javaClass) + + private val webClient = + WebClient + .builder() + .clientConnector( + ReactorClientHttpConnector( + HttpClient.create().responseTimeout( + Duration.ofSeconds(3), + ), + ), + ).build() + + suspend fun verifyAndDecodeToken( + token: String, + options: OidcVerificationOptions, + ): Claims? = + runCatching { + val jwtHeader = extractJwtHeader(token) ?: return null + val oidcJwk = fetchJwk(jwtHeader, options.jwksUri) ?: return null + val publicKey = convertJwkToPublicKey(oidcJwk) + val claims = parseSignedClaims(token, publicKey) + + if (!isValidIssuer(claims, options.expectedIssuer)) return null + if (!isValidAudience(claims, options.expectedAudience)) return null + if (!isNotExpired(claims)) return null + + claims + }.onFailure { + log.warn("failed to verify oidc token {}: {}", token, it.message) + }.getOrNull() + + fun looksLikeJwt(token: String): Boolean { + val parts = token.split(".") + return parts.size == 3 && parts.none { it.isBlank() } + } + + private suspend fun fetchJwk( + jwtHeader: OidcJwtHeader, + jwksUri: String, + ): OidcJwk? = + webClient + .get(uri = jwksUri) + .getOrNull() + ?.keys + ?.find { + it.kid == jwtHeader.kid && (it.alg == jwtHeader.alg || it.alg.isBlank()) + } ?: return null + + private fun extractJwtHeader(token: String): OidcJwtHeader? { + if (!looksLikeJwt(token)) return null + + val headerJson = Base64.getUrlDecoder().decode(token.substringBefore(".")).toString(Charsets.UTF_8) + val headerMap: Map = objectMapper.readValue(headerJson) + val kid = headerMap["kid"] ?: return null + val alg = headerMap["alg"] ?: return null + + return OidcJwtHeader( + kid = kid, + alg = alg, + ) + } + + private fun convertJwkToPublicKey(jwk: OidcJwk): PublicKey { + if (jwk.kty != "RSA") throw IllegalArgumentException("unsupported kty: ${jwk.kty}") + + val modulus = BigInteger(1, Base64.getUrlDecoder().decode(jwk.n)) + val exponent = BigInteger(1, Base64.getUrlDecoder().decode(jwk.e)) + val spec = RSAPublicKeySpec(modulus, exponent) + return KeyFactory.getInstance("RSA").generatePublic(spec) + } + + private fun parseSignedClaims( + token: String, + publicKey: PublicKey, + ) = Jwts + .parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .payload + + private fun isValidIssuer( + claims: Claims, + expectedIssuer: String?, + ): Boolean = expectedIssuer == null || (claims["iss"] as? String) == expectedIssuer + + private fun isValidAudience( + claims: Claims, + expectedAudience: String?, + ): Boolean { + if (expectedAudience == null) return true + + val audience = claims["aud"] ?: return false + + return when (audience) { + is String -> audience == expectedAudience + is Collection<*> -> audience.any { it == expectedAudience } + else -> false + } + } + + private fun isNotExpired(claims: Claims): Boolean { + val expiration = claims.expiration ?: return false + return expiration.after(Date()) + } +} + +private data class OidcJwtHeader( + val kid: String, + val alg: String, +) diff --git a/core/src/main/kotlin/common/exception/ErrorType.kt b/core/src/main/kotlin/common/exception/ErrorType.kt index 9d86ce4c..c781b425 100644 --- a/core/src/main/kotlin/common/exception/ErrorType.kt +++ b/core/src/main/kotlin/common/exception/ErrorType.kt @@ -89,6 +89,7 @@ enum class ErrorType( DIARY_QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, 40411, "강의 일기장 질문이 유효하지 않습니다", "강의 일기장 질문이 유효하지 않습니다"), DIARY_DAILY_CLASS_TYPE_NOT_FOUND(HttpStatus.NOT_FOUND, 40412, "강의 일기장 오늘 한 일이 유효하지 않습니다", "강의 일기장 오늘 한 일이 유효하지 않습니다"), DIARY_SUBMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, 40412, "강의 일기장 기록이 유효하지 않습니다", "강의 일기장 기록이 유효하지 않습니다"), + DIARY_TARGET_LECTURE_NOT_FOUND(HttpStatus.NOT_FOUND, 40413, "강의 일기장을 작성할 강의가 없습니다", "강의 일기장을 작성할 강의가 없습니다"), DUPLICATE_VACANCY_NOTIFICATION(HttpStatus.CONFLICT, 40900, "빈자리 알림 중복", "이미 빈자리 알림을 받고 있는 강좌입니다"), DUPLICATE_EMAIL(HttpStatus.CONFLICT, 40901, "이미 사용 중인 이메일입니다", "이미 사용 중인 이메일입니다", "회원가입 실패"), DUPLICATE_FRIEND(HttpStatus.CONFLICT, 40902, "이미 친구 관계이거나 친구 요청을 보냈습니다", "이미 친구 관계이거나 친구 요청을 보냈습니다"), @@ -101,5 +102,11 @@ enum class ErrorType( CANNOT_REMOVE_LAST_AUTH_PROVIDER(HttpStatus.CONFLICT, 40909, "최소 한 개의 로그인 수단은 유지해야 합니다", "최소 한 개의 로그인 수단은 유지해야 합니다"), DYNAMIC_LINK_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 50001, "링크 생성 실패", "링크 생성에 실패했습니다. 잠시 후 다시 시도해주세요"), + COURSEBOOK_RECENT_THAN_SUGANGSNU( + HttpStatus.INTERNAL_SERVER_ERROR, + 50002, + "현재 Coursebook이 수강신청 사이트보다 최근입니다.", + "현재 Coursebook이 수강신청 사이트보다 최근입니다.", + ), 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 b81abb04..6a8cf9c5 100644 --- a/core/src/main/kotlin/common/exception/SnuttException.kt +++ b/core/src/main/kotlin/common/exception/SnuttException.kt @@ -139,6 +139,8 @@ object DiaryDailyClassTypeNotFoundException : SnuttException(ErrorType.DIARY_DAI object DiarySubmissionNotFoundException : SnuttException(ErrorType.DIARY_SUBMISSION_NOT_FOUND) +object DiaryTargetLectureNotFoundException : SnuttException(ErrorType.DIARY_TARGET_LECTURE_NOT_FOUND) + object UserNotFoundByNicknameException : SnuttException(ErrorType.USER_NOT_FOUND_BY_NICKNAME) object ThemeNotFoundException : SnuttException(ErrorType.THEME_NOT_FOUND) @@ -182,4 +184,6 @@ object CannotRemoveLastAuthProviderException : SnuttException(ErrorType.CANNOT_R object DynamicLinkGenerationFailedException : SnuttException(ErrorType.DYNAMIC_LINK_GENERATION_FAILED) +object CoursebookRecentThanSugangSnuException : SnuttException(ErrorType.COURSEBOOK_RECENT_THAN_SUGANGSNU) + object RegistrationPeriodNotSetException : SnuttException(ErrorType.REGISTRATION_PERIOD_NOT_SET) diff --git a/core/src/main/kotlin/coursebook/data/Coursebook.kt b/core/src/main/kotlin/coursebook/data/Coursebook.kt index 1dc00546..54e08fda 100644 --- a/core/src/main/kotlin/coursebook/data/Coursebook.kt +++ b/core/src/main/kotlin/coursebook/data/Coursebook.kt @@ -18,4 +18,9 @@ class Coursebook( val semester: Semester, @Field("updated_at") var updatedAt: Instant = Instant.now(), -) +) : Comparable { + override fun compareTo(other: Coursebook): Int = + compareBy { it.year } + .thenBy { it.semester } + .compare(this, other) +} diff --git a/core/src/main/kotlin/diary/data/DiaryQuestionnaire.kt b/core/src/main/kotlin/diary/data/DiaryQuestionnaire.kt index a7fc87a4..a07c8e6d 100644 --- a/core/src/main/kotlin/diary/data/DiaryQuestionnaire.kt +++ b/core/src/main/kotlin/diary/data/DiaryQuestionnaire.kt @@ -1,8 +1,9 @@ package com.wafflestudio.snutt.diary.data +import com.wafflestudio.snutt.timetables.data.TimetableLecture + data class DiaryQuestionnaire( - val lectureTitle: String, + val courseTitle: String, val questions: List, - val nextLectureId: String?, - val nextLectureTitle: String?, + val nextLecture: TimetableLecture?, ) diff --git a/core/src/main/kotlin/diary/dto/DiaryQuestionnaireDto.kt b/core/src/main/kotlin/diary/dto/DiaryQuestionnaireDto.kt index e752ae5e..92a47f14 100644 --- a/core/src/main/kotlin/diary/dto/DiaryQuestionnaireDto.kt +++ b/core/src/main/kotlin/diary/dto/DiaryQuestionnaireDto.kt @@ -3,19 +3,17 @@ package com.wafflestudio.snutt.diary.dto import com.wafflestudio.snutt.diary.data.DiaryQuestionnaire data class DiaryQuestionnaireDto( - val lectureTitle: String, + val courseTitle: String, val questions: List, - val nextLectureId: String?, - val nextLectureTitle: String?, + val nextLecture: DiaryTargetLectureDto?, ) fun DiaryQuestionnaireDto(diaryQuestionnaire: DiaryQuestionnaire) = DiaryQuestionnaireDto( - lectureTitle = diaryQuestionnaire.lectureTitle, + courseTitle = diaryQuestionnaire.courseTitle, questions = diaryQuestionnaire.questions.map { DiaryQuestionDto(it) }, - nextLectureId = diaryQuestionnaire.nextLectureId, - nextLectureTitle = diaryQuestionnaire.nextLectureTitle, + nextLecture = diaryQuestionnaire.nextLecture?.let { DiaryTargetLectureDto(it) }, ) diff --git a/core/src/main/kotlin/diary/dto/DiarySubmissionSummaryDto.kt b/core/src/main/kotlin/diary/dto/DiarySubmissionSummaryDto.kt index e7791f50..b4b3a1db 100644 --- a/core/src/main/kotlin/diary/dto/DiarySubmissionSummaryDto.kt +++ b/core/src/main/kotlin/diary/dto/DiarySubmissionSummaryDto.kt @@ -7,7 +7,7 @@ data class DiarySubmissionSummaryDto( val id: String, val lectureId: String, val date: LocalDateTime, - val lectureTitle: String, + val courseTitle: String, val shortQuestionReplies: List, val comment: String, ) @@ -25,7 +25,7 @@ fun DiarySubmissionSummaryDto( id = submission.id!!, lectureId = submission.lectureId, date = submission.createdAt, - lectureTitle = submission.courseTitle, + courseTitle = submission.courseTitle, shortQuestionReplies = shortQuestionReplies, comment = submission.comment, ) diff --git a/core/src/main/kotlin/diary/dto/DiaryTargetLectureDto.kt b/core/src/main/kotlin/diary/dto/DiaryTargetLectureDto.kt new file mode 100644 index 00000000..9d1854fd --- /dev/null +++ b/core/src/main/kotlin/diary/dto/DiaryTargetLectureDto.kt @@ -0,0 +1,14 @@ +package com.wafflestudio.snutt.diary.dto + +import com.wafflestudio.snutt.timetables.data.TimetableLecture + +data class DiaryTargetLectureDto( + val lectureId: String, + val courseTitle: String, +) + +fun DiaryTargetLectureDto(timetableLecture: TimetableLecture): DiaryTargetLectureDto = + DiaryTargetLectureDto( + lectureId = timetableLecture.lectureId!!, + courseTitle = timetableLecture.courseTitle, + ) diff --git a/core/src/main/kotlin/diary/service/DiaryService.kt b/core/src/main/kotlin/diary/service/DiaryService.kt index 48c04add..2c3892cd 100644 --- a/core/src/main/kotlin/diary/service/DiaryService.kt +++ b/core/src/main/kotlin/diary/service/DiaryService.kt @@ -1,5 +1,6 @@ package com.wafflestudio.snutt.diary.service +import com.wafflestudio.snutt.common.enums.Semester import com.wafflestudio.snutt.common.exception.DiaryDailyClassTypeNotFoundException import com.wafflestudio.snutt.common.exception.DiaryQuestionNotFoundException import com.wafflestudio.snutt.common.exception.DiarySubmissionNotFoundException @@ -16,6 +17,7 @@ import com.wafflestudio.snutt.diary.repository.DiaryDailyClassTypeRepository import com.wafflestudio.snutt.diary.repository.DiaryQuestionRepository import com.wafflestudio.snutt.diary.repository.DiarySubmissionRepository import com.wafflestudio.snutt.lectures.service.LectureService +import com.wafflestudio.snutt.timetables.data.TimetableLecture import com.wafflestudio.snutt.timetables.repository.TimetableRepository import kotlinx.coroutines.flow.toList import org.springframework.stereotype.Service @@ -28,6 +30,13 @@ interface DiaryService { dailyClassTypeNames: List, ): DiaryQuestionnaire + suspend fun getDiaryTargetLecture( + userId: String, + year: Int, + semester: Semester, + idsToExclude: List, + ): TimetableLecture? + suspend fun getActiveDailyClassTypes(): List suspend fun getAllDailyClassTypes(): List @@ -72,12 +81,30 @@ class DiaryServiceImpl( ): DiaryQuestionnaire { val dailyClassTypeIds = diaryDailyClassTypeRepository.findAllByNameIn(dailyClassTypeNames).map { it.id!! } val availableQuestions = diaryQuestionRepository.findByTargetDailyClassTypeIdsInAndActiveTrue(dailyClassTypeIds) + val questions = + availableQuestions + .shuffled() + .take(3) + val lecture = lectureService.getByIdOrNull(lectureId) ?: throw LectureNotFoundException + val nextLecture = getDiaryTargetLecture(userId, lecture.year, lecture.semester, listOf(lecture.id!!)) + + return DiaryQuestionnaire( + courseTitle = lecture.courseTitle, + questions = questions, + nextLecture = nextLecture, + ) + } + override suspend fun getDiaryTargetLecture( + userId: String, + year: Int, + semester: Semester, + idsToExclude: List, + ): TimetableLecture? { val userTimetable = - timetableRepository.findByUserIdAndYearAndSemesterAndIsPrimaryTrue(userId, lecture.year, lecture.semester) + timetableRepository.findByUserIdAndYearAndSemesterAndIsPrimaryTrue(userId, year, semester) ?: throw TimetableNotFoundException - val recentlySubmittedIds = diarySubmissionRepository .findAllByUserIdAndCreatedAtIsAfter( @@ -85,20 +112,16 @@ class DiaryServiceImpl( LocalDateTime.now().minusDays(1), ).map { it.lectureId } val nextLectureCandidates = - userTimetable.lectures.filterNot { it.lectureId == lectureId || recentlySubmittedIds.contains(it.lectureId) } - val nextLecture = nextLectureCandidates.randomOrNull() - - val questions = - availableQuestions - .shuffled() - .take(3) + userTimetable.lectures + .filterNot { it.lectureId == null } + .filterNot { it.lectureId in idsToExclude } + .let { candidates -> + candidates + .filterNot { recentlySubmittedIds.contains(it.lectureId) } + .ifEmpty { candidates } + } - return DiaryQuestionnaire( - lectureTitle = lecture.courseTitle, - questions = questions, - nextLectureId = nextLecture?.lectureId, - nextLectureTitle = nextLecture?.courseTitle, - ) + return nextLectureCandidates.randomOrNull() } override suspend fun getActiveDailyClassTypes(): List = diaryDailyClassTypeRepository.findAllByActiveTrue() diff --git a/core/src/main/kotlin/lectures/data/Lecture.kt b/core/src/main/kotlin/lectures/data/Lecture.kt index 771fc2f8..e505ec9b 100644 --- a/core/src/main/kotlin/lectures/data/Lecture.kt +++ b/core/src/main/kotlin/lectures/data/Lecture.kt @@ -36,7 +36,7 @@ data class Lecture( var courseTitle: String, var registrationCount: Int = 0, var wasFull: Boolean = false, - val evInfo: EvInfo? = null, + var evInfo: EvInfo? = null, var categoryPre2025: String?, ) { infix fun equalsMetadata(other: Lecture): Boolean = diff --git a/core/src/main/kotlin/users/service/UserService.kt b/core/src/main/kotlin/users/service/UserService.kt index 55c19def..c94b37e7 100644 --- a/core/src/main/kotlin/users/service/UserService.kt +++ b/core/src/main/kotlin/users/service/UserService.kt @@ -118,7 +118,7 @@ interface UserService { suspend fun sendResetPasswordCode(email: String) suspend fun verifyResetPasswordCode( - localId: String, + user: User, code: String, ) @@ -567,13 +567,12 @@ class UserServiceImpl( } override suspend fun verifyResetPasswordCode( - localId: String, + user: User, code: String, ) { - val user = userRepository.findByCredentialLocalIdAndActiveTrue(localId) ?: throw UserNotFoundException val key = RESET_PASSWORD_CODE_PREFIX + user.id checkVerificationValue(key, code) - redisTemplate.expire(key, Duration.ofMinutes(3)).subscribe() + redisTemplate.expire(key, Duration.ofHours(1)).subscribe() } override suspend fun getMaskedEmail(localId: String): String { @@ -589,7 +588,7 @@ class UserServiceImpl( code: String, ) { val user = userRepository.findByCredentialLocalIdAndActiveTrue(localId) ?: throw UserNotFoundException - verifyResetPasswordCode(localId, code) + verifyResetPasswordCode(user, code) if (!authService.isValidPassword(newPassword)) throw InvalidPasswordException user.apply { credential.localPw = authService.buildLocalCredential(user.credential.localId!!, newPassword).localPw diff --git a/core/src/main/resources/application-common.yml b/core/src/main/resources/application-common.yml index 60f38931..e36507a5 100644 --- a/core/src/main/resources/application-common.yml +++ b/core/src/main/resources/application-common.yml @@ -29,6 +29,12 @@ google: bundle-id: app-store-id: +oidc: + facebook: + app-id: + apple: + app-id: + http: response-timeout: 3s diff --git a/core/src/testFixtures/resources/application.yaml b/core/src/testFixtures/resources/application.yaml index 18032249..fe51c41d 100644 --- a/core/src/testFixtures/resources/application.yaml +++ b/core/src/testFixtures/resources/application.yaml @@ -23,6 +23,12 @@ google: bundle-id: app-store-id: +oidc: + facebook: + app-id: test-facebook-app-id + apple: + app-id: test-apple-app-id + http: responseTimeout: 3s