-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 수원대 포털 연동 API 3종 및 에러 매핑 레이어 추가 #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.chukchukhaksa.mobile.data.portal.datasource | ||
|
|
||
| import com.chukchukhaksa.mobile.domain.portal.model.ScrapingResult | ||
|
|
||
| interface PortalRemoteDataSource { | ||
| suspend fun login(username: String, password: String) | ||
| suspend fun startScraping(): ScrapingResult | ||
| suspend fun refreshScraping(): ScrapingResult | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.chukchukhaksa.mobile.data.portal.di | ||
|
|
||
| import com.chukchukhaksa.mobile.data.portal.datasource.PortalRemoteDataSource | ||
| import com.chukchukhaksa.mobile.data.portal.repository.PortalRepositoryImpl | ||
| import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository | ||
| import com.chukchukhaksa.mobile.remote.portal.PortalRemoteDataSourceImpl | ||
| import org.koin.dsl.module | ||
|
|
||
| val portalRepositoryModule = module { | ||
| single<PortalRemoteDataSource> { PortalRemoteDataSourceImpl(get()) } | ||
| single<PortalRepository> { PortalRepositoryImpl(get()) } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.chukchukhaksa.mobile.data.portal.repository | ||
|
|
||
| import com.chukchukhaksa.mobile.data.portal.datasource.PortalRemoteDataSource | ||
| import com.chukchukhaksa.mobile.domain.portal.model.ScrapingResult | ||
| import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository | ||
|
|
||
| class PortalRepositoryImpl( | ||
| private val portalRemoteDataSource: PortalRemoteDataSource, | ||
| ) : PortalRepository { | ||
|
|
||
| override suspend fun login(username: String, password: String) { | ||
| portalRemoteDataSource.login(username, password) | ||
| } | ||
|
|
||
| override suspend fun startScraping(): ScrapingResult { | ||
| return portalRemoteDataSource.startScraping() | ||
| } | ||
|
|
||
| override suspend fun refreshScraping(): ScrapingResult { | ||
| return portalRemoteDataSource.refreshScraping() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,9 @@ import com.chukchukhaksa.mobile.domain.auth.usecase.AppleLoginUseCase | |
| import com.chukchukhaksa.mobile.domain.auth.usecase.CheckAuthStateUseCase | ||
| import com.chukchukhaksa.mobile.domain.auth.usecase.KakaoLoginUseCase | ||
| import com.chukchukhaksa.mobile.domain.config.usecase.CheckNeedForceUpdateUseCase | ||
| import com.chukchukhaksa.mobile.domain.portal.usecase.PortalLoginUseCase | ||
| import com.chukchukhaksa.mobile.domain.portal.usecase.RefreshPortalScrapingUseCase | ||
| import com.chukchukhaksa.mobile.domain.portal.usecase.StartPortalScrapingUseCase | ||
| import com.chukchukhaksa.mobile.domain.profile.usecase.GetProfileUseCase | ||
| import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableCellUseCase | ||
| import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableUseCase | ||
|
|
@@ -58,4 +61,9 @@ val domainModule = module { | |
| // Academic use cases | ||
| factory { GetAcademicSummaryUseCase(get()) } | ||
| factory { GetAcademicRecordUseCase(get()) } | ||
|
|
||
| // Portal use cases | ||
| factory { PortalLoginUseCase(get()) } | ||
| factory { StartPortalScrapingUseCase(get()) } | ||
| factory { RefreshPortalScrapingUseCase(get()) } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# 목적: 포털 UseCase 등록이 별도 feature 모듈로 분리되어 있는지 확인
# 기대결과: Portal 관련 UseCase factory가 전용 Module.kt에 존재하고, 중앙 모듈은 조합만 수행
fd -i 'Module.kt' composeApp/src | sort
rg -n --iglob '*Module.kt' 'PortalLoginUseCase|StartPortalScrapingUseCase|RefreshPortalScrapingUseCase'Repository: cchaksa/cchaksa-app Length of output: 2193 🏁 Script executed: cat -n composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt | head -80Repository: cchaksa/cchaksa-app Length of output: 3891 🏁 Script executed: # Search for portal-related domain modules
fd -i 'portal' composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile | grep -i 'di\|module'Repository: cchaksa/cchaksa-app Length of output: 161 🏁 Script executed: # Search for the UseCase files to understand their locations
fd -type f -i 'usecase.kt' composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile | head -20Repository: cchaksa/cchaksa-app Length of output: 233 🏁 Script executed: # Check if separate domain modules exist for other features
fd -i 'domain.*module' composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobileRepository: cchaksa/cchaksa-app Length of output: 140 🏁 Script executed: # Check which modules are imported in the main module/app setup
fd -i 'app.*module\|main.*module' composeApp/src | head -10Repository: cchaksa/cchaksa-app Length of output: 45 🏁 Script executed: # Look at the domain structure for portal vs academic to compare
tree -L 3 composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/
tree -L 3 composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/academic/Repository: cchaksa/cchaksa-app Length of output: 653 포털 도메인 UseCase를 전용 모듈로 분리하세요. 현재 포털 UseCase 등록이 중앙 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package com.chukchukhaksa.mobile.domain.portal.model | ||
|
|
||
| sealed class PortalScrapingError(val defaultMessage: String) { | ||
|
|
||
| data object InvalidCredentials : PortalScrapingError( | ||
| "아이디나 비밀번호가 일치하지 않습니다.\n학교 홈페이지에서 확인해주세요.", | ||
| ) | ||
|
|
||
| data object AccountLocked : PortalScrapingError( | ||
| "계정이 잠겼습니다. 포털사이트에서 비밀번호 재설정을 진행해주세요.", | ||
| ) | ||
|
|
||
| data object InvalidAcademicRecord : PortalScrapingError( | ||
| "입력하신 학적 정보로는 현재 처리가 불가능합니다.\n세부 사유를 확인해주세요.", | ||
| ) | ||
|
|
||
| data object AlreadyConnected : PortalScrapingError( | ||
| "이미 포털 연동된 학생 정보가 존재합니다.\n다른 계정으로 로그인했는지 확인해주세요.", | ||
| ) | ||
|
|
||
| data object GraduationDataNotFound : PortalScrapingError( | ||
| "사용자에게 맞는 졸업 요건 데이터가 존재하지 않습니다.\n학과/입학년도 정보를 확인해주세요.", | ||
| ) | ||
|
|
||
| data object DoubleMajorInfoMissing : PortalScrapingError( | ||
| "복수전공 이수 구분 정보가 존재하지 않아 처리할 수 없습니다.\n학사정보를 확인해주세요.", | ||
| ) | ||
|
|
||
| data object TransferStudentNotSupported : PortalScrapingError( | ||
| "편입생 학적 정보는 현재 지원되지 않습니다.\n추후 지원 예정입니다.", | ||
| ) | ||
|
|
||
| data class Unknown( | ||
| val httpStatus: Int?, | ||
| val appCode: String?, | ||
| ) : PortalScrapingError( | ||
| "알 수 없는 오류가 발생했어요.\n잠시 후 다시 시도해주세요.", | ||
| ) | ||
|
Comment on lines
+3
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial 도메인 계층에 사용자 문구를 고정하지 않는 편이 좋습니다.
🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.chukchukhaksa.mobile.domain.portal.model | ||
|
|
||
| class PortalScrapingException( | ||
| val error: PortalScrapingError, | ||
| val httpStatus: Int?, | ||
| val appCode: String?, | ||
| override val message: String = error.defaultMessage, | ||
| ) : RuntimeException(message) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.chukchukhaksa.mobile.domain.portal.model | ||
|
|
||
| data class ScrapingResult( | ||
| val taskId: String?, | ||
| val studentInfo: StudentInfo?, | ||
| val status: String?, | ||
| ) | ||
|
|
||
| data class StudentInfo( | ||
| val name: String?, | ||
| val school: String?, | ||
| val majorName: String?, | ||
| val studentCode: String?, | ||
| val gradeLevel: Int?, | ||
| val status: String?, | ||
| val completedSemesterType: Int?, | ||
| ) | ||
|
Comment on lines
+3
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial
Line 6, Line 15의 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.chukchukhaksa.mobile.domain.portal.repository | ||
|
|
||
| import com.chukchukhaksa.mobile.domain.portal.model.ScrapingResult | ||
|
|
||
| interface PortalRepository { | ||
| suspend fun login(username: String, password: String) | ||
| suspend fun startScraping(): ScrapingResult | ||
| suspend fun refreshScraping(): ScrapingResult | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.chukchukhaksa.mobile.domain.portal.usecase | ||
|
|
||
| import com.chukchukhaksa.mobile.domain.common.runCatchingIgnoreCancelled | ||
| import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository | ||
|
|
||
| class PortalLoginUseCase( | ||
| private val portalRepository: PortalRepository, | ||
| ) { | ||
| suspend operator fun invoke( | ||
| username: String, | ||
| password: String, | ||
| ): Result<Unit> = runCatchingIgnoreCancelled { | ||
| portalRepository.login(username, password) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.chukchukhaksa.mobile.domain.portal.usecase | ||
|
|
||
| import com.chukchukhaksa.mobile.domain.common.runCatchingIgnoreCancelled | ||
| import com.chukchukhaksa.mobile.domain.portal.model.ScrapingResult | ||
| import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository | ||
|
|
||
| class RefreshPortalScrapingUseCase( | ||
| private val portalRepository: PortalRepository, | ||
| ) { | ||
| suspend operator fun invoke(): Result<ScrapingResult> = runCatchingIgnoreCancelled { | ||
| portalRepository.refreshScraping() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.chukchukhaksa.mobile.domain.portal.usecase | ||
|
|
||
| import com.chukchukhaksa.mobile.domain.common.runCatchingIgnoreCancelled | ||
| import com.chukchukhaksa.mobile.domain.portal.model.ScrapingResult | ||
| import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository | ||
|
|
||
| class StartPortalScrapingUseCase( | ||
| private val portalRepository: PortalRepository, | ||
| ) { | ||
| suspend operator fun invoke(): Result<ScrapingResult> = runCatchingIgnoreCancelled { | ||
| portalRepository.startScraping() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| package com.chukchukhaksa.mobile.remote.portal | ||
|
|
||
| import com.chukchukhaksa.mobile.data.portal.datasource.PortalRemoteDataSource | ||
| import com.chukchukhaksa.mobile.domain.portal.model.PortalScrapingException | ||
| import com.chukchukhaksa.mobile.domain.portal.model.ScrapingResult | ||
| import com.chukchukhaksa.mobile.domain.portal.model.StudentInfo | ||
| import com.chukchukhaksa.mobile.remote.common.ApiResponse | ||
| import com.chukchukhaksa.mobile.remote.portal.model.ScrapingResponseDto | ||
| import com.chukchukhaksa.mobile.remote.portal.model.StudentInfoDto | ||
| import io.ktor.client.HttpClient | ||
| import io.ktor.client.call.body | ||
| import io.ktor.client.request.parameter | ||
| import io.ktor.client.request.post | ||
| import io.ktor.client.statement.HttpResponse | ||
| import kotlinx.serialization.json.JsonObject | ||
|
|
||
| class PortalRemoteDataSourceImpl( | ||
| private val httpClient: HttpClient, | ||
| ) : PortalRemoteDataSource { | ||
|
|
||
| override suspend fun login(username: String, password: String) { | ||
| val httpResponse = httpClient.post("suwon-scrape/login") { | ||
| parameter("username", username) | ||
| parameter("password", password) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| parseOrThrow<JsonObject>(httpResponse) | ||
| } | ||
|
|
||
| override suspend fun startScraping(): ScrapingResult { | ||
| val httpResponse = httpClient.post("suwon-scrape/start") | ||
| val dto = parseOrThrow<ScrapingResponseDto>(httpResponse) | ||
| return dto.toDomain() | ||
| } | ||
|
|
||
| override suspend fun refreshScraping(): ScrapingResult { | ||
| val httpResponse = httpClient.post("suwon-scrape/refresh") | ||
| val dto = parseOrThrow<ScrapingResponseDto>(httpResponse) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| return dto.toDomain() | ||
| } | ||
|
|
||
| private suspend inline fun <reified T> parseOrThrow(httpResponse: HttpResponse): T { | ||
| val status = httpResponse.status.value | ||
| val apiResponse = runCatching { | ||
| httpResponse.body<ApiResponse<T>>() | ||
| }.getOrElse { cause -> | ||
| throw PortalScrapingException( | ||
| error = mapToPortalScrapingError(httpStatus = status, appCode = null), | ||
| httpStatus = status, | ||
| appCode = null, | ||
| message = cause.message ?: "응답 파싱에 실패했습니다.", | ||
| ) | ||
| } | ||
|
|
||
| val data = apiResponse.data | ||
| if (apiResponse.success && data != null) { | ||
| return data | ||
| } | ||
|
|
||
| val appCode = apiResponse.error?.code | ||
| val error = mapToPortalScrapingError(httpStatus = status, appCode = appCode) | ||
| throw PortalScrapingException( | ||
| error = error, | ||
| httpStatus = status, | ||
| appCode = appCode, | ||
| message = apiResponse.error?.message ?: error.defaultMessage, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private fun ScrapingResponseDto.toDomain() = ScrapingResult( | ||
| taskId = taskId, | ||
| studentInfo = studentInfo?.toDomain(), | ||
| status = status, | ||
| ) | ||
|
|
||
| private fun StudentInfoDto.toDomain() = StudentInfo( | ||
| name = name, | ||
| school = school, | ||
| majorName = majorName, | ||
| studentCode = studentCode, | ||
| gradeLevel = gradeLevel, | ||
| status = status, | ||
| completedSemesterType = completedSemesterType, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package com.chukchukhaksa.mobile.remote.portal | ||
|
|
||
| import com.chukchukhaksa.mobile.domain.portal.model.PortalScrapingError | ||
|
|
||
| internal fun mapToPortalScrapingError( | ||
| httpStatus: Int?, | ||
| appCode: String?, | ||
| ): PortalScrapingError = when { | ||
| appCode == "S04" -> PortalScrapingError.AlreadyConnected | ||
| appCode == "D01" -> PortalScrapingError.DoubleMajorInfoMissing | ||
| appCode == "T13" -> PortalScrapingError.TransferStudentNotSupported | ||
| appCode == "G02" -> PortalScrapingError.GraduationDataNotFound | ||
| httpStatus == 401 -> PortalScrapingError.InvalidCredentials | ||
| httpStatus == 404 -> PortalScrapingError.GraduationDataNotFound | ||
| httpStatus == 409 -> PortalScrapingError.AlreadyConnected | ||
| httpStatus == 422 -> PortalScrapingError.InvalidAcademicRecord | ||
| httpStatus == 423 -> PortalScrapingError.AccountLocked | ||
| else -> PortalScrapingError.Unknown(httpStatus = httpStatus, appCode = appCode) | ||
|
Comment on lines
+9
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial 매핑 코드의 하드코딩 리터럴을 상수로 추출하면 유지보수가 쉬워집니다. 현재 appCode/HTTP 코드가 문자열·숫자 리터럴로 분산되어 있어 추후 확장 시 오타 리스크가 있습니다. 🔧 제안 코드+private const val APP_CODE_ALREADY_CONNECTED = "S04"
+private const val APP_CODE_DOUBLE_MAJOR_INFO_MISSING = "D01"
+private const val APP_CODE_TRANSFER_STUDENT_NOT_SUPPORTED = "T13"
+private const val APP_CODE_GRADUATION_DATA_NOT_FOUND = "G02"
+
+private const val HTTP_UNAUTHORIZED = 401
+private const val HTTP_NOT_FOUND = 404
+private const val HTTP_CONFLICT = 409
+private const val HTTP_UNPROCESSABLE_ENTITY = 422
+private const val HTTP_LOCKED = 423
+
internal fun mapToPortalScrapingError(
httpStatus: Int?,
appCode: String?,
): PortalScrapingError = when {
- appCode == "S04" -> PortalScrapingError.AlreadyConnected
- appCode == "D01" -> PortalScrapingError.DoubleMajorInfoMissing
- appCode == "T13" -> PortalScrapingError.TransferStudentNotSupported
- appCode == "G02" -> PortalScrapingError.GraduationDataNotFound
- httpStatus == 401 -> PortalScrapingError.InvalidCredentials
- httpStatus == 404 -> PortalScrapingError.GraduationDataNotFound
- httpStatus == 409 -> PortalScrapingError.AlreadyConnected
- httpStatus == 422 -> PortalScrapingError.InvalidAcademicRecord
- httpStatus == 423 -> PortalScrapingError.AccountLocked
+ appCode == APP_CODE_ALREADY_CONNECTED -> PortalScrapingError.AlreadyConnected
+ appCode == APP_CODE_DOUBLE_MAJOR_INFO_MISSING -> PortalScrapingError.DoubleMajorInfoMissing
+ appCode == APP_CODE_TRANSFER_STUDENT_NOT_SUPPORTED -> PortalScrapingError.TransferStudentNotSupported
+ appCode == APP_CODE_GRADUATION_DATA_NOT_FOUND -> PortalScrapingError.GraduationDataNotFound
+ httpStatus == HTTP_UNAUTHORIZED -> PortalScrapingError.InvalidCredentials
+ httpStatus == HTTP_NOT_FOUND -> PortalScrapingError.GraduationDataNotFound
+ httpStatus == HTTP_CONFLICT -> PortalScrapingError.AlreadyConnected
+ httpStatus == HTTP_UNPROCESSABLE_ENTITY -> PortalScrapingError.InvalidAcademicRecord
+ httpStatus == HTTP_LOCKED -> PortalScrapingError.AccountLocked
else -> PortalScrapingError.Unknown(httpStatus = httpStatus, appCode = appCode)
}🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.chukchukhaksa.mobile.remote.portal.model | ||
|
|
||
| import kotlinx.serialization.Serializable | ||
|
|
||
| @Serializable | ||
| data class ScrapingResponseDto( | ||
| val taskId: String? = null, | ||
| val studentInfo: StudentInfoDto? = null, | ||
| val status: String? = null, | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class StudentInfoDto( | ||
| val name: String? = null, | ||
| val school: String? = null, | ||
| val majorName: String? = null, | ||
| val studentCode: String? = null, | ||
| val gradeLevel: Int? = null, | ||
| val status: String? = null, | ||
| val completedSemesterType: Int? = null, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
🧩 Analysis chain
🏁 Script executed:
Repository: cchaksa/cchaksa-app
Length of output: 1790
참조 저장소를 프로젝트 외부에 복제하는 것을 권장합니다.
.gitignore에/chukchuk-haksa/디렉토리를 추가하는 것은 문법적으로 올바르지만, 참조용 저장소를 현재 프로젝트의 작업 디렉토리 내부에 복제하는 것은 권장되지 않습니다.더 나은 접근 방법:
../chukchuk-haksa/)에 복제하여 작업 공간을 깨끗하게 유지🤖 Prompt for AI Agents