feat: 수원대 포털 연동 API 3종 및 에러 매핑 레이어 추가 - #83
Conversation
- suwon-scrape/login, start, refresh 호출용 Remote/Data/Domain 레이어 신설 - HttpResponse를 먼저 받아 HTTP status와 ApiResponse body를 분리 추출하여 status 보존 - PortalScrapingError sealed class 및 appCode 우선 매핑(S04/D01/T13/G02 → HTTP status fallback) 구현 - 포털 로그인/크롤링 시작/재연동 UseCase 3종 및 Koin DI 모듈 등록 - 참조용 웹 레포 클론 디렉토리를 .gitignore에 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
개요이 변경 사항은 학교 포탈 연동을 위한 원격 데이터 소스, 저장소 구현, 도메인 모델(오류/결과/진행 상태), 사용 사례 및 API 통신을 위한 DTO를 포함한 포탈 스크래핑 기능을 추가합니다. 변경 사항
시퀀스 다이어그램sequenceDiagram
participant Client
participant UseCase as LinkPortalUseCase
participant Repository as PortalRepository
participant RemoteDS as PortalRemoteDataSource
participant HttpClient
Client->>UseCase: invoke(username, password, ...)
UseCase->>Repository: linkPortal(...)
Repository->>RemoteDS: createLinkJob(...)
RemoteDS->>HttpClient: POST /portal/link
HttpClient-->>RemoteDS: AcceptedResponseDto
RemoteDS-->>Repository: AcceptedResponseDto
Repository->>Repository: emit(ScrapingProgress.Accepted)
loop Poll until terminal state (max iterations)
Repository->>RemoteDS: getJobStatus(jobId)
RemoteDS->>HttpClient: GET /portal/link/jobs/{jobId}
HttpClient-->>RemoteDS: JobStatusResponseDto
RemoteDS-->>Repository: JobStatusResponseDto
Repository->>Repository: check status
alt Status == "succeeded"
Repository->>RemoteDS: getJobSummary(jobId)
RemoteDS->>HttpClient: GET /portal/link/jobs/{jobId}/summary
HttpClient-->>RemoteDS: JobSummaryResponseDto
RemoteDS-->>Repository: JobSummaryResponseDto
Repository->>Repository: emit(ScrapingProgress.Completed)
break Terminal success
end
else Status == "failed"
Repository->>Repository: throw PortalScrapingException
break Terminal failure
end
else In progress
Repository->>Repository: emit(ScrapingProgress.InProgress)
end
end
Repository-->>UseCase: Flow<ScrapingProgress>
UseCase-->>Client: Flow<ScrapingProgress>
예상 코드 리뷰 노력🎯 3 (보통) | ⏱️ ~25 분 추천 검토자
시
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.gitignore:
- Around line 50-52: The .gitignore currently contains an entry
'/chukchuk-haksa/' which indicates a cloned reference repo inside the project;
remove that entry and do not keep a copy of the reference repository inside the
project tree — instead clone the reference repo outside the project (e.g.,
../chukchuk-haksa/) or, if you need it tracked/linked, add it as a Git
submodule; update repository docs/README to document how to obtain the reference
repo rather than ignoring '/chukchuk-haksa/' in .gitignore.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt`:
- Around line 65-68: 포털 도메인 UseCase들이 중앙 DomainModules.kt에 직접 등록되어 있어
feature-based 모듈화 원칙을 위반하므로 PortalLoginUseCase, StartPortalScrapingUseCase,
RefreshPortalScrapingUseCase에 대한 팩토리 등록을 새로운 portalDomainModule로 분리하고 기존
DomainModules.kt(또는 조합 모듈)에서는 해당 portalDomainModule을 포함하도록 변경하세요; 구체적으로
PortalLoginUseCase/StartPortalScrapingUseCase/RefreshPortalScrapingUseCase 팩토리들을
새로운 파일에 정의한 Koin 모듈인 portalDomainModule로 옮기고 기존의 포털 리포지토리
모듈(PortalRepositoryModule.kt)과 함께 조합 모듈에서 불러오도록 통합하세요.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingError.kt`:
- Around line 3-38: The domain model PortalScrapingError currently embeds
user-facing text via the defaultMessage constructor parameter; remove that UI
text by deleting the defaultMessage property from the sealed class and from all
subclasses (InvalidCredentials, AccountLocked, InvalidAcademicRecord,
AlreadyConnected, GraduationDataNotFound, DoubleMajorInfoMissing,
TransferStudentNotSupported, and Unknown) so each variant only represents an
error type and carries domain-relevant data (e.g., Unknown keeps httpStatus and
appCode); then map these error types to localized/user-facing strings in the
presentation/resource layer (not in this domain file). Ensure all call sites
constructing or pattern-matching PortalScrapingError are updated to stop
supplying/expecting defaultMessage and instead use the new presentation-layer
mapper to obtain messages.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt`:
- Around line 3-17: The two nullable String status fields on
ScrapingResult.status and StudentInfo.status should be replaced with a
strongly-typed status (e.g., an enum or sealed type) to avoid fragile string
comparisons: add a ScrapingStatus enum (or sealed class) enumerating the allowed
states, change ScrapingResult.status: String? -> ScrapingStatus? and
StudentInfo.status: String? -> ScrapingStatus?, and update all
parsers/serializers and call sites to map incoming string values into
ScrapingStatus (handling unknown/null values safely) and to use ScrapingStatus
in comparisons instead of raw strings.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalRemoteDataSourceImpl.kt`:
- Around line 22-37: The post calls in startScraping, refreshScraping (and the
earlier login and the refreshTokens logic in HttpClientModule.kt) rely on Ktor's
default expectSuccess=true so non-2xx responses throw before parseOrThrow can
run; update each httpClient.post(...) invocation in PortalRemoteDataSourceImpl
(e.g., in startScraping, refreshScraping and the login call) to include
expectSuccess = false in the request builder so the response body is returned
and parseOrThrow(...) can perform its error mapping, or alternatively catch
ClientRequestException/ServerResponseException around the post and pass the
contained response to parseOrThrow(...) to ensure consistent error mapping.
- Around line 22-25: The POST call in PortalRemoteDataSourceImpl (the
httpClient.post block) is adding credentials as URL parameters via
parameter("username", ...) and parameter("password", ...) which exposes
sensitive data; change the request to put credentials in the POST body by using
setBody(FormDataContent(Parameters.build { append("username", username);
append("password", password) })) inside the same httpClient.post lambda so
username/password are sent in the request body rather than the query string.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalScrapingErrorMapper.kt`:
- Around line 9-18: The mapping in PortalScrapingErrorMapper.kt hardcodes
appCode strings ("S04","D01","T13","G02") and HTTP status numbers
(401,404,409,422,423); extract these literals into named constants (e.g., const
val APP_S04 = "S04", const val HTTP_401 = 401) placed in the same file
(companion object or top-level object) and replace the inline literals in the
when block that returns PortalScrapingError (the branches referencing appCode
and httpStatus) with those constants to reduce typos and ease future
maintenance.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: d5f3075c-9fe5-407f-b224-9042bad80cc9
📒 Files selected for processing (16)
.gitignorecomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/datasource/PortalRemoteDataSource.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/di/PortalRepositoryModule.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingError.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingException.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/repository/PortalRepository.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/PortalLoginUseCase.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/RefreshPortalScrapingUseCase.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/StartPortalScrapingUseCase.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalRemoteDataSourceImpl.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalScrapingErrorMapper.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/ScrapingResponseDto.kt
|
|
||
| # Cloned reference repo | ||
| /chukchuk-haksa/ |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Verify if /chukchuk-haksa/ exists and is a Git repository
# Check if the directory exists
if [ -d "chukchuk-haksa" ]; then
echo "Directory exists"
# Check if it's a Git repository
if [ -d "chukchuk-haksa/.git" ]; then
echo "It's a Git repository"
cd chukchuk-haksa
git remote -v
else
echo "Not a Git repository"
fi
else
echo "Directory does not exist yet"
fiRepository: cchaksa/cchaksa-app
Length of output: 1790
참조 저장소를 프로젝트 외부에 복제하는 것을 권장합니다.
.gitignore에 /chukchuk-haksa/ 디렉토리를 추가하는 것은 문법적으로 올바르지만, 참조용 저장소를 현재 프로젝트의 작업 디렉토리 내부에 복제하는 것은 권장되지 않습니다.
더 나은 접근 방법:
- 참조 저장소를 프로젝트 디렉토리 외부(예:
../chukchuk-haksa/)에 복제하여 작업 공간을 깨끗하게 유지 - 실제로 통합이 필요한 경우 Git 서브모듈 사용 검토
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.gitignore around lines 50 - 52, The .gitignore currently contains an entry
'/chukchuk-haksa/' which indicates a cloned reference repo inside the project;
remove that entry and do not keep a copy of the reference repository inside the
project tree — instead clone the reference repo outside the project (e.g.,
../chukchuk-haksa/) or, if you need it tracked/linked, add it as a Git
submodule; update repository docs/README to document how to obtain the reference
repo rather than ignoring '/chukchuk-haksa/' in .gitignore.
| // Portal use cases | ||
| factory { PortalLoginUseCase(get()) } | ||
| factory { StartPortalScrapingUseCase(get()) } | ||
| factory { RefreshPortalScrapingUseCase(get()) } |
There was a problem hiding this comment.
🛠️ 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 등록이 중앙 DomainModules.kt에 직접 포함되어 있어 feature-based 모듈 구성 원칙을 위반합니다. 포털 Repository는 이미 PortalRepositoryModule.kt로 분리되어 있으나, 도메인 계층 UseCase는 여전히 중앙화되어 있습니다. portalDomainModule을 생성하고 조합 모듈에서 통합하는 방식으로 개선해 주세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt`
around lines 65 - 68, 포털 도메인 UseCase들이 중앙 DomainModules.kt에 직접 등록되어 있어
feature-based 모듈화 원칙을 위반하므로 PortalLoginUseCase, StartPortalScrapingUseCase,
RefreshPortalScrapingUseCase에 대한 팩토리 등록을 새로운 portalDomainModule로 분리하고 기존
DomainModules.kt(또는 조합 모듈)에서는 해당 portalDomainModule을 포함하도록 변경하세요; 구체적으로
PortalLoginUseCase/StartPortalScrapingUseCase/RefreshPortalScrapingUseCase 팩토리들을
새로운 파일에 정의한 Koin 모듈인 portalDomainModule로 옮기고 기존의 포털 리포지토리
모듈(PortalRepositoryModule.kt)과 함께 조합 모듈에서 불러오도록 통합하세요.
| 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잠시 후 다시 시도해주세요.", | ||
| ) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
도메인 계층에 사용자 문구를 고정하지 않는 편이 좋습니다.
defaultMessage를 도메인 모델에 넣으면 i18n/카피 변경 시 도메인까지 수정해야 합니다. 도메인에는 에러 타입/코드만 두고, 메시지 매핑은 프레젠테이션(또는 리소스) 계층으로 분리하는 구조를 권장합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingError.kt`
around lines 3 - 38, The domain model PortalScrapingError currently embeds
user-facing text via the defaultMessage constructor parameter; remove that UI
text by deleting the defaultMessage property from the sealed class and from all
subclasses (InvalidCredentials, AccountLocked, InvalidAcademicRecord,
AlreadyConnected, GraduationDataNotFound, DoubleMajorInfoMissing,
TransferStudentNotSupported, and Unknown) so each variant only represents an
error type and carries domain-relevant data (e.g., Unknown keeps httpStatus and
appCode); then map these error types to localized/user-facing strings in the
presentation/resource layer (not in this domain file). Ensure all call sites
constructing or pattern-matching PortalScrapingError are updated to stop
supplying/expecting defaultMessage and instead use the new presentation-layer
mapper to obtain messages.
| 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?, | ||
| ) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
status를 문자열 대신 타입으로 제한하는 것을 권장합니다.
Line 6, Line 15의 String? 상태값은 호출부에서 문자열 비교 분기를 유도해 오타/누락 리스크가 큽니다. 상태가 정해진 값 집합이라면 enum class 또는 sealed 타입으로 올리는 편이 안전합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt`
around lines 3 - 17, The two nullable String status fields on
ScrapingResult.status and StudentInfo.status should be replaced with a
strongly-typed status (e.g., an enum or sealed type) to avoid fragile string
comparisons: add a ScrapingStatus enum (or sealed class) enumerating the allowed
states, change ScrapingResult.status: String? -> ScrapingStatus? and
StudentInfo.status: String? -> ScrapingStatus?, and update all
parsers/serializers and call sites to map incoming string values into
ScrapingStatus (handling unknown/null values safely) and to use ScrapingStatus
in comparisons instead of raw strings.
| 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) |
There was a problem hiding this comment.
🧹 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
Verify each finding against the current code and only fix it if needed.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalScrapingErrorMapper.kt`
around lines 9 - 18, The mapping in PortalScrapingErrorMapper.kt hardcodes
appCode strings ("S04","D01","T13","G02") and HTTP status numbers
(401,404,409,422,423); extract these literals into named constants (e.g., const
val APP_S04 = "S04", const val HTTP_401 = 401) placed in the same file
(companion object or top-level object) and replace the inline literals in the
when block that returns PortalScrapingError (the branches referencing appCode
and httpStatus) with those constants to reduce typos and ease future
maintenance.
- suwon-scrape/{login,start,refresh} 3종 호출을 제거하고 POST /portal/link
→ GET /portal/link/jobs/{jobId} 폴링 → /summary 흐름으로 교체
- PortalRemoteDataSource를 createLinkJob/getJobStatus/getJobSummary로 재구성하고
Ktor url { path(...) } DSL로 BASE_URL의 /api/ prefix 우회
- Idempotency-Key를 LinkPortalUseCase invoke 파라미터 기본값(Uuid.random())으로
외부화하여 같은 Flow 재collect 시 동일 키를 유지하고 caller가 override 가능
- PortalRepository.linkPortal을 Flow<ScrapingProgress>로 노출, 2초 간격 60회
폴링 후 succeeded/failed 상태로 종료 판정
- PortalScrapingException에 retryable 필드 추가하여 실패 job 응답의
retryable 값을 호출자로 전달
- 기존 PortalLoginUseCase/StartPortalScrapingUseCase/RefreshPortalScrapingUseCase
3종을 LinkPortalUseCase 하나로 통합
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (2)
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt (1)
63-64: 🛠️ Refactor suggestion | 🟠 Major포털 UseCase 등록을 feature 모듈로 분리해 주세요.
LinkPortalUseCase를 중앙domainModule에 직접 등록하면 포털 도메인 구성이 다시 중앙화됩니다.portalDomainModule로 분리하고 조합 모듈에서 포함하는 구조로 맞춰 주세요.Based on learnings: Applies to composeApp/src/**/di/**Module.kt : Use Koin 4.1.0-Beta10 with feature-based modules for dependency injection
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt` around lines 63 - 64, Remove the direct registration of LinkPortalUseCase from the central domainModule and instead create a dedicated portalDomainModule that contains factory { LinkPortalUseCase(get()) }; then include that new portalDomainModule from the composition/feature-aggregation module (the module that composes feature modules) so the portal use-case is registered as a feature-module (use Koin module { ... } / portalDomainModule and include it where modules are combined).composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt (1)
3-18: 🧹 Nitpick | 🔵 Trivial
status를 도메인 타입으로 제한해 주세요.Line 6과 Line 16이
String?라서 호출부가 문자열 비교에 의존하게 됩니다.enum class나 sealed 타입으로 올리고 DTO→Domain 변환에서 한 번만 매핑하는 편이 안전합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt` around lines 3 - 18, The two nullable String status properties (ScrapingResult.status and StudentInfo.status) should be replaced with explicit domain types (e.g., an enum class ScrapingStatus and/or StudentStatus or sealed classes) and populated once during DTO→Domain mapping; modify the ScrapingResult and StudentInfo declarations to use those domain types instead of String?, add the new enum/sealed types representing allowed states, and update the conversion/mapping logic (wherever DTO→Domain conversion occurs) to map raw DTO strings into the new enums with a safe fallback for unknown/null values.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.kt`:
- Around line 75-80: The thrown PortalScrapingException sets retryable = null
which prevents upper layers from deciding retries; update the exception
construction in PortalRepositoryImpl to set retryable to a concrete boolean
(e.g., retryable = true for client-side polling timeout) and ensure the embedded
PortalScrapingError (PortalScrapingError.Unknown) and the
PortalScrapingException fields consistently reflect that boolean (use
TIMEOUT_APP_CODE unchanged).
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/LinkPortalUseCase.kt`:
- Around line 13-23: Change the use case signature to a suspend operator fun
invoke that returns Result<Flow<ScrapingProgress>> and wrap the call to
portalRepository.linkPortal(...) inside runCatchingIgnoreCancelled to follow the
error-handling guideline; i.e., update LinkPortalUseCase.invoke(...) to be
suspend and return Result<Flow<ScrapingProgress>>, call
runCatchingIgnoreCancelled { portalRepository.linkPortal(portalType =
portalType, username = username, password = password, idempotencyKey =
idempotencyKey) }, and ensure runCatchingIgnoreCancelled is imported/available
and keep the same parameter defaults (Uuid.random().toString()).
---
Duplicate comments:
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt`:
- Around line 63-64: Remove the direct registration of LinkPortalUseCase from
the central domainModule and instead create a dedicated portalDomainModule that
contains factory { LinkPortalUseCase(get()) }; then include that new
portalDomainModule from the composition/feature-aggregation module (the module
that composes feature modules) so the portal use-case is registered as a
feature-module (use Koin module { ... } / portalDomainModule and include it
where modules are combined).
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt`:
- Around line 3-18: The two nullable String status properties
(ScrapingResult.status and StudentInfo.status) should be replaced with explicit
domain types (e.g., an enum class ScrapingStatus and/or StudentStatus or sealed
classes) and populated once during DTO→Domain mapping; modify the ScrapingResult
and StudentInfo declarations to use those domain types instead of String?, add
the new enum/sealed types representing allowed states, and update the
conversion/mapping logic (wherever DTO→Domain conversion occurs) to map raw DTO
strings into the new enums with a safe fallback for unknown/null values.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: da579340-873b-43cf-93c2-e9ac9c10bc8e
📒 Files selected for processing (13)
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/datasource/PortalRemoteDataSource.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingException.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingProgress.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/repository/PortalRepository.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/LinkPortalUseCase.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalRemoteDataSourceImpl.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/AcceptedResponseDto.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobStatusResponseDto.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobSummaryResponseDto.ktcomposeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/LinkRequestDto.kt
| throw PortalScrapingException( | ||
| error = PortalScrapingError.Unknown(httpStatus = null, appCode = TIMEOUT_APP_CODE), | ||
| httpStatus = null, | ||
| appCode = TIMEOUT_APP_CODE, | ||
| retryable = null, | ||
| message = "포털 연동이 시간 내에 완료되지 않았습니다.", |
There was a problem hiding this comment.
타임아웃 예외에 retryable을 명시해 주세요.
Line 79에서 retryable = null로 던지면 상위 레이어가 재시도 가능 여부를 판단할 수 없습니다. 이 경로는 클라이언트 측 polling timeout이므로 정책에 맞는 boolean을 채우는 편이 좋습니다.
🔧 제안 수정안
throw PortalScrapingException(
error = PortalScrapingError.Unknown(httpStatus = null, appCode = TIMEOUT_APP_CODE),
httpStatus = null,
appCode = TIMEOUT_APP_CODE,
- retryable = null,
+ retryable = true,
message = "포털 연동이 시간 내에 완료되지 않았습니다.",
)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| throw PortalScrapingException( | |
| error = PortalScrapingError.Unknown(httpStatus = null, appCode = TIMEOUT_APP_CODE), | |
| httpStatus = null, | |
| appCode = TIMEOUT_APP_CODE, | |
| retryable = null, | |
| message = "포털 연동이 시간 내에 완료되지 않았습니다.", | |
| throw PortalScrapingException( | |
| error = PortalScrapingError.Unknown(httpStatus = null, appCode = TIMEOUT_APP_CODE), | |
| httpStatus = null, | |
| appCode = TIMEOUT_APP_CODE, | |
| retryable = true, | |
| message = "포털 연동이 시간 내에 완료되지 않았습니다.", | |
| ) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.kt`
around lines 75 - 80, The thrown PortalScrapingException sets retryable = null
which prevents upper layers from deciding retries; update the exception
construction in PortalRepositoryImpl to set retryable to a concrete boolean
(e.g., retryable = true for client-side polling timeout) and ensure the embedded
PortalScrapingError (PortalScrapingError.Unknown) and the
PortalScrapingException fields consistently reflect that boolean (use
TIMEOUT_APP_CODE unchanged).
| operator fun invoke( | ||
| username: String, | ||
| password: String, | ||
| portalType: String = DEFAULT_PORTAL_TYPE, | ||
| idempotencyKey: String = Uuid.random().toString(), | ||
| ): Flow<ScrapingProgress> = portalRepository.linkPortal( | ||
| portalType = portalType, | ||
| username = username, | ||
| password = password, | ||
| idempotencyKey = idempotencyKey, | ||
| ) |
There was a problem hiding this comment.
UseCase 시그니처와 에러 처리 패턴을 가이드라인에 맞춰 주세요.
현재 invoke가 suspend가 아니고 Result<T>를 반환하지 않으며, runCatchingIgnoreCancelled도 적용되지 않았습니다.
🔧 제안 변경안
- `@OptIn`(ExperimentalUuidApi::class)
- operator fun invoke(
+ `@OptIn`(ExperimentalUuidApi::class)
+ suspend operator fun invoke(
username: String,
password: String,
portalType: String = DEFAULT_PORTAL_TYPE,
idempotencyKey: String = Uuid.random().toString(),
- ): Flow<ScrapingProgress> = portalRepository.linkPortal(
- portalType = portalType,
- username = username,
- password = password,
- idempotencyKey = idempotencyKey,
- )
+ ): Result<Flow<ScrapingProgress>> = runCatchingIgnoreCancelled {
+ portalRepository.linkPortal(
+ portalType = portalType,
+ username = username,
+ password = password,
+ idempotencyKey = idempotencyKey,
+ )
+ }As per coding guidelines: Use cases must use suspend operator fun invoke() that returns Result; Use runCatchingIgnoreCancelled for error handling in use cases
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| operator fun invoke( | |
| username: String, | |
| password: String, | |
| portalType: String = DEFAULT_PORTAL_TYPE, | |
| idempotencyKey: String = Uuid.random().toString(), | |
| ): Flow<ScrapingProgress> = portalRepository.linkPortal( | |
| portalType = portalType, | |
| username = username, | |
| password = password, | |
| idempotencyKey = idempotencyKey, | |
| ) | |
| `@OptIn`(ExperimentalUuidApi::class) | |
| suspend operator fun invoke( | |
| username: String, | |
| password: String, | |
| portalType: String = DEFAULT_PORTAL_TYPE, | |
| idempotencyKey: String = Uuid.random().toString(), | |
| ): Result<Flow<ScrapingProgress>> = runCatchingIgnoreCancelled { | |
| portalRepository.linkPortal( | |
| portalType = portalType, | |
| username = username, | |
| password = password, | |
| idempotencyKey = idempotencyKey, | |
| ) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/LinkPortalUseCase.kt`
around lines 13 - 23, Change the use case signature to a suspend operator fun
invoke that returns Result<Flow<ScrapingProgress>> and wrap the call to
portalRepository.linkPortal(...) inside runCatchingIgnoreCancelled to follow the
error-handling guideline; i.e., update LinkPortalUseCase.invoke(...) to be
suspend and return Result<Flow<ScrapingProgress>>, call
runCatchingIgnoreCancelled { portalRepository.linkPortal(portalType =
portalType, username = username, password = password, idempotencyKey =
idempotencyKey) }, and ensure runCatchingIgnoreCancelled is imported/available
and keep the same parameter defaults (Uuid.random().toString()).
Resolved: #82
Summary
수원대 포털 연동을 위한
suwon-scrape/login,start,refresh세 가지 API를 호출하는 Remote/Data/Domain 레이어를 신설했습니다. HTTP status와 서버appCode(S04/D01/T13/G02 등)를 우선 매핑하는PortalScrapingErrorsealed class를 도입해 포털 연동 중 발생하는 에러를 구조적으로 분류·전달할 수 있게 했습니다.Features
suwon-scrape/login,start,refresh호출용 Remote/Data/Domain 레이어 신설HttpResponse를 먼저 받아 HTTP status와ApiResponsebody를 분리 추출하여 status 보존PortalScrapingErrorsealed class 및appCode우선 매핑(S04/D01/T13/G02 → HTTP status fallback).gitignore에 추가Summary by CodeRabbit
새 기능