Skip to content

feat: 수원대 포털 연동 API 3종 및 에러 매핑 레이어 추가 - #83

Merged
lluke0 merged 2 commits into
developfrom
feature/#82-link-portal-api
Apr 11, 2026
Merged

feat: 수원대 포털 연동 API 3종 및 에러 매핑 레이어 추가#83
lluke0 merged 2 commits into
developfrom
feature/#82-link-portal-api

Conversation

@lluke0

@lluke0 lluke0 commented Apr 10, 2026

Copy link
Copy Markdown
Member

Resolved: #82

Summary

수원대 포털 연동을 위한 suwon-scrape/login, start, refresh 세 가지 API를 호출하는 Remote/Data/Domain 레이어를 신설했습니다. HTTP status와 서버 appCode(S04/D01/T13/G02 등)를 우선 매핑하는 PortalScrapingError sealed class를 도입해 포털 연동 중 발생하는 에러를 구조적으로 분류·전달할 수 있게 했습니다.

Features

  • 수원대 포털 연동 API 3종 및 에러 매핑 레이어 추가 (6436cee)
    • 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에 추가

Summary by CodeRabbit

새 기능

  • 포털 계정 연동 기능 추가
    • 포털 계정 연동 및 학생 정보 자동 수집 기능 추가
    • 실시간 진행 상황 알림 및 작업 상태 추적
    • 유효하지 않은 인증정보, 계정 잠금, 중복 연동 등 주요 오류에 대한 처리 개선

- 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>
@coderabbitai

coderabbitai Bot commented Apr 10, 2026

Copy link
Copy Markdown

개요

이 변경 사항은 학교 포탈 연동을 위한 원격 데이터 소스, 저장소 구현, 도메인 모델(오류/결과/진행 상태), 사용 사례 및 API 통신을 위한 DTO를 포함한 포탈 스크래핑 기능을 추가합니다.

변경 사항

종류 / 파일(들) 요약
Git 설정
.gitignore
최상위 /chukchuk-haksa/ 디렉토리를 버전 관리에서 제외하는 항목 추가
포탈 원격 데이터 소스
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/datasource/PortalRemoteDataSource.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalRemoteDataSourceImpl.kt
포탈 작업 API 호출을 위한 인터페이스 정의 및 Ktor HttpClient를 사용한 구현 추가. 작업 생성, 상태 조회, 요약 조회 메서드 포함
포탈 저장소
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/repository/PortalRepository.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.kt
포탈 연동 인터페이스 정의 및 폴링 로직이 있는 구현 추가. 작업 상태를 주기적으로 확인하고 완료/실패 처리
도메인 모델
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingError.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingException.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingProgress.kt
포탈 스크래핑 오류 타입, 예외 클래스, 결과 및 진행 상태 데이터 모델 정의
원격 DTO 모델
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/AcceptedResponseDto.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobStatusResponseDto.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobSummaryResponseDto.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/LinkRequestDto.kt
API 요청/응답을 위한 직렬화 가능한 DTO 클래스 추가
오류 매핑
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalScrapingErrorMapper.kt
HTTP 상태 코드 및 앱 코드를 도메인 오류 타입으로 매핑하는 함수 추가
사용 사례
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/LinkPortalUseCase.kt
포탈 연동을 시작하고 진행 상태를 반환하는 사용 사례 클래스 추가
의존성 주입
composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/di/PortalRepositoryModule.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt, composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt
Koin 모듈에 포탈 저장소, 원격 데이터 소스, 사용 사례 등록 추가

시퀀스 다이어그램

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>
Loading

예상 코드 리뷰 노력

🎯 3 (보통) | ⏱️ ~25 분

추천 검토자

  • kimmandoo
  • BEEEAM-J

🐰 포탈 연동이 추가되니 반가워,
학교 정보를 쉽게 얻게 되지,
폴링로직에 오류 처리까지 완벽하네,
진행 상태를 흐르는 Flow로 넘겨주고,
우리의 앱이 한발 더 나아가길! 🚀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 변경사항의 주요 내용을 명확하게 요약하고 있습니다. 수원대 포털 연동 API 3종과 에러 매핑 레이어 추가라는 핵심 기능을 정확히 반영합니다.
Description check ✅ Passed PR 설명은 리포지토리 템플릿과 부분적으로 일치합니다. 요약과 기능이 포함되어 있으나, 스크린샷과 관련 이슈 섹션이 템플릿 형식을 정확히 따르지 않습니다.
Linked Issues check ✅ Passed 연결된 이슈 #82는 학교 연동 관련 API 추가를 요청하고 있으며, PR에서 수원대 포털 연동 API 3종(createLinkJob, getJobStatus, getJobSummary)을 구현하여 요구사항을 충족합니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 수원대 포털 연동 API 구현, 에러 매핑, DI 모듈 등록, 그리고 .gitignore 업데이트로 일관되게 연관 이슈의 범위 내에 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/#82-link-portal-api

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lluke0
lluke0 requested a review from BEEEAM-J April 10, 2026 09:09
@lluke0 lluke0 self-assigned this Apr 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 161f3a2 and 6436cee.

📒 Files selected for processing (16)
  • .gitignore
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/datasource/PortalRemoteDataSource.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/di/PortalRepositoryModule.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingError.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingException.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/repository/PortalRepository.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/PortalLoginUseCase.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/RefreshPortalScrapingUseCase.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/StartPortalScrapingUseCase.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalRemoteDataSourceImpl.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalScrapingErrorMapper.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/ScrapingResponseDto.kt

Comment thread .gitignore
Comment on lines +50 to +52

# Cloned reference repo
/chukchuk-haksa/

Copy link
Copy Markdown

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:

#!/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"
fi

Repository: 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.

Comment on lines +65 to +68
// Portal use cases
factory { PortalLoginUseCase(get()) }
factory { StartPortalScrapingUseCase(get()) }
factory { RefreshPortalScrapingUseCase(get()) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 -80

Repository: 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 -20

Repository: 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/mobile

Repository: 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 -10

Repository: 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)과 함께 조합 모듈에서 불러오도록 통합하세요.

Comment on lines +3 to +38
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잠시 후 다시 시도해주세요.",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Comment on lines +3 to +17
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?,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Comment on lines +9 to +18
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 6436cee and 67caa82.

📒 Files selected for processing (13)
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/datasource/PortalRemoteDataSource.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingException.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingProgress.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/repository/PortalRepository.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/LinkPortalUseCase.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalRemoteDataSourceImpl.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/AcceptedResponseDto.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobStatusResponseDto.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobSummaryResponseDto.kt
  • composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/LinkRequestDto.kt

Comment on lines +75 to +80
throw PortalScrapingException(
error = PortalScrapingError.Unknown(httpStatus = null, appCode = TIMEOUT_APP_CODE),
httpStatus = null,
appCode = TIMEOUT_APP_CODE,
retryable = null,
message = "포털 연동이 시간 내에 완료되지 않았습니다.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

타임아웃 예외에 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.

Suggested change
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).

Comment on lines +13 to +23
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,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

UseCase 시그니처와 에러 처리 패턴을 가이드라인에 맞춰 주세요.

현재 invokesuspend가 아니고 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.

Suggested change
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()).

@lluke0
lluke0 merged commit d056e6e into develop Apr 11, 2026
1 of 2 checks passed
@lluke0
lluke0 deleted the feature/#82-link-portal-api branch April 11, 2026 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] 학교 연동 관련 API 추가

2 participants