Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ app/.idea/
/composeApp/google-services.json
/iosApp/iosApp/GoogleService-Info.plist
/iosApp/Configuration/Config.xcconfig

# Cloned reference repo
/chukchuk-haksa/
Comment on lines +50 to +52

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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.chukchukhaksa.mobile.data.portal.datasource

import com.chukchukhaksa.mobile.remote.portal.model.AcceptedResponseDto
import com.chukchukhaksa.mobile.remote.portal.model.JobStatusResponseDto
import com.chukchukhaksa.mobile.remote.portal.model.JobSummaryResponseDto

interface PortalRemoteDataSource {
suspend fun createLinkJob(
portalType: String,
username: String,
password: String,
idempotencyKey: String,
): AcceptedResponseDto

suspend fun getJobStatus(jobId: String): JobStatusResponseDto

suspend fun getJobSummary(jobId: String): JobSummaryResponseDto
}
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,124 @@
package com.chukchukhaksa.mobile.data.portal.repository

import com.chukchukhaksa.mobile.data.portal.datasource.PortalRemoteDataSource
import com.chukchukhaksa.mobile.domain.portal.model.PortalScrapingError
import com.chukchukhaksa.mobile.domain.portal.model.PortalScrapingException
import com.chukchukhaksa.mobile.domain.portal.model.ScrapingProgress
import com.chukchukhaksa.mobile.domain.portal.model.ScrapingResult
import com.chukchukhaksa.mobile.domain.portal.model.StudentInfo
import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository
import com.chukchukhaksa.mobile.remote.portal.mapToPortalScrapingError
import com.chukchukhaksa.mobile.remote.portal.model.JobStatusResponseDto
import com.chukchukhaksa.mobile.remote.portal.model.JobSummaryResponseDto
import com.chukchukhaksa.mobile.remote.portal.model.StudentInfoDto
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

class PortalRepositoryImpl(
private val portalRemoteDataSource: PortalRemoteDataSource,
) : PortalRepository {

override fun linkPortal(
portalType: String,
username: String,
password: String,
idempotencyKey: String,
): Flow<ScrapingProgress> = flow {
val accepted = portalRemoteDataSource.createLinkJob(
portalType = portalType,
username = username,
password = password,
idempotencyKey = idempotencyKey,
)
val jobId = accepted.jobId ?: throw PortalScrapingException(
error = PortalScrapingError.Unknown(httpStatus = null, appCode = null),
httpStatus = null,
appCode = null,
retryable = null,
message = "포털 연동 job id가 비어있습니다.",
)

emit(ScrapingProgress.Accepted(jobId = jobId))

repeat(MAX_POLL_COUNT) {
delay(POLL_INTERVAL_MS)

val statusDto = portalRemoteDataSource.getJobStatus(jobId)
val jobStatus = statusDto.status.orEmpty()

when {
jobStatus.isTerminalSuccess() -> {
val summary = portalRemoteDataSource.getJobSummary(jobId)
emit(
ScrapingProgress.Completed(
jobId = jobId,
result = summary.toDomain(),
),
)
return@flow
}

jobStatus.isTerminalFailure() -> {
throw statusDto.toException()
}

else -> emit(
ScrapingProgress.InProgress(
jobId = jobId,
status = jobStatus,
),
)
}
}

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

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

)
}

companion object {
private const val POLL_INTERVAL_MS = 2_000L
private const val MAX_POLL_COUNT = 60
private const val TIMEOUT_APP_CODE = "PORTAL_POLL_TIMEOUT"
}
}

private fun String.isTerminalSuccess(): Boolean =
equals("succeeded", ignoreCase = true)

private fun String.isTerminalFailure(): Boolean =
equals("failed", ignoreCase = true)

private fun JobStatusResponseDto.toException(): PortalScrapingException {
val appCode = errorCode
val error = mapToPortalScrapingError(httpStatus = null, appCode = appCode)
return PortalScrapingException(
error = error,
httpStatus = null,
appCode = appCode,
retryable = retryable,
message = errorMessage ?: error.defaultMessage,
)
}

private fun JobSummaryResponseDto.toDomain(): ScrapingResult = ScrapingResult(
jobId = jobId,
studentInfo = studentInfo?.toDomain(),
status = status,
finishedAt = finishedAt,
)

private fun StudentInfoDto.toDomain(): StudentInfo = 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
Expand Up @@ -6,6 +6,7 @@ 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.LinkPortalUseCase
import com.chukchukhaksa.mobile.domain.profile.usecase.GetProfileUseCase
import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableCellUseCase
import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableUseCase
Expand Down Expand Up @@ -58,4 +59,7 @@ val domainModule = module {
// Academic use cases
factory { GetAcademicSummaryUseCase(get()) }
factory { GetAcademicRecordUseCase(get()) }

// Portal use cases
factory { LinkPortalUseCase(get()) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.chukchukhaksa.mobile.data.auth.di.authRepositoryModule
import com.chukchukhaksa.mobile.data.config.di.appConfigRepositoryModule
import com.chukchukhaksa.mobile.data.openlecture.di.openLectureRepositoryModule
import com.chukchukhaksa.mobile.data.openmajor.di.openMajorRepositoryModule
import com.chukchukhaksa.mobile.data.portal.di.portalRepositoryModule
import com.chukchukhaksa.mobile.data.profile.di.ProfileRepositoryModule
import com.chukchukhaksa.mobile.data.timetable.di.timetableRepositoryModule
import com.chukchukhaksa.mobile.local.database.common.di.openLectureDatabaseModule
Expand Down Expand Up @@ -48,6 +49,7 @@ fun initKoin(config: KoinAppDeclaration? = null) {
authRepositoryModule,
ProfileRepositoryModule,
AcademicRepositoryModule,
portalRepositoryModule,
)
}
}
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

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.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.chukchukhaksa.mobile.domain.portal.model

class PortalScrapingException(
val error: PortalScrapingError,
val httpStatus: Int?,
val appCode: String?,
val retryable: Boolean? = null,
override val message: String = error.defaultMessage,
) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.chukchukhaksa.mobile.domain.portal.model

sealed interface ScrapingProgress {
val jobId: String

data class Accepted(
override val jobId: String,
) : ScrapingProgress

data class InProgress(
override val jobId: String,
val status: String,
) : ScrapingProgress

data class Completed(
override val jobId: String,
val result: ScrapingResult,
) : ScrapingProgress
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.chukchukhaksa.mobile.domain.portal.model

data class ScrapingResult(
val jobId: String?,
val studentInfo: StudentInfo?,
val status: String?,
val finishedAt: 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

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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.chukchukhaksa.mobile.domain.portal.repository

import com.chukchukhaksa.mobile.domain.portal.model.ScrapingProgress
import kotlinx.coroutines.flow.Flow

interface PortalRepository {
fun linkPortal(
portalType: String,
username: String,
password: String,
idempotencyKey: String,
): Flow<ScrapingProgress>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.chukchukhaksa.mobile.domain.portal.usecase

import com.chukchukhaksa.mobile.domain.portal.model.ScrapingProgress
import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository
import kotlinx.coroutines.flow.Flow
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid

class LinkPortalUseCase(
private val portalRepository: PortalRepository,
) {
@OptIn(ExperimentalUuidApi::class)
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,
)
Comment on lines +13 to +23

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


companion object {
const val DEFAULT_PORTAL_TYPE = "suwon"
}
}
Loading
Loading