diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index 50b24c9689..dae52d0198 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -49,7 +49,7 @@ import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.model.AfterNoteListItem import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.DeliveryVerification @@ -344,7 +344,7 @@ class ReceiverRuntimeCompletionAndroidTest { val authRepository = CompletionReceiverAuthRepository() authRepository.verifyEmailResults.addLast( Result.failure( - ReceiverEmailAuthException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호가 만료되었습니다. 다시 발급해 주세요.", serverCode = 1902, diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt index faec6f0eef..0b4cef98fd 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt @@ -12,7 +12,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl @@ -51,7 +51,7 @@ class ReceiverVerificationAndroidTest { val auth = FakeReceiverAuthRepository() auth.verifyEmailResults.addLast( Result.failure( - ReceiverEmailAuthException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호가 일치하지 않습니다.", serverCode = 1903, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt index 2fe449f416..6a03f2ebd8 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt @@ -1,7 +1,7 @@ package com.afternote.feature.afternote.presentation.reporting import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException +import com.afternote.feature.receiver.domain.error.ReceiverFailure /** * 애프터노트 흐름에서 실패가 발생한 지점. @@ -76,11 +76,6 @@ enum class AfternoteFailureStage( /** 발신자 상세의 열람 인증 상태 조회. */ SENDER_STATUS_LOAD("sender_status_load"), - - // RECEIVED_EXPORT_DOWNLOAD·RECEIVED_EXPORT_SAVE 는 수신자 흐름 이전(#615)과 함께 - // feature:receiver 의 ReceiverFailureStage(`receiver_stage` 키)로 이동 — home_stage 와의 - // 이중 기록(#546 참고)이 그 키로 수렴된다. 위 수신자 단계들(RECEIVED_*·RECEIVER_* 등)의 - // 이동은 열람 실패 처리 통일(#611·#614 계열)과 함께 별도 판단. } /** @@ -104,30 +99,17 @@ fun ErrorReporter.recordAfternoteFailure( * `Receiver` 는 타입이 아니라 적용 맥락이다 — `Throwable` 확장이라 네트워크 타임아웃처럼 수신자와 * 무관한 예외도 들어오고, 그런 실패는 기록 대상(true)이다. 타입으로 좁히는 건 아래 제외 판정뿐이다. * - * 유일한 제외 대상은 "서버가 예상하고 처리한 거절"(이메일 미등록·인증번호 만료·마스터 키 오타 등)이다 — - * 앱이 정상 동작한 결과라 고칠 것이 없고, 무엇보다 보관 한도를 사용자 오류가 차지해 실제 장애를 밀어낸다. - * Crashlytics 는 non-fatal 을 **최근 8건만 보관하고 초과분은 오래된 것부터 버린다** — 이 수치가 코드 곳곳의 - * "보관 한도(최근 8건)" 서술의 근거다. + * 제외 대상은 "서버가 예상하고 처리한 거절" 하나다 — 앱이 정상 동작한 결과라 고칠 것이 없고, 무엇보다 + * Crashlytics 가 non-fatal 을 **최근 8건만 보관하고 초과분은 오래된 것부터 버려서**(코드 곳곳의 + * "보관 한도(최근 8건)" 서술이 이 수치를 가리킨다) 사용자 오류가 실제 장애를 밀어낸다. * https://firebase.google.com/docs/crashlytics/android/customize-crash-reports * - * 그 판정은 **4xx + 서버 문구** 두 조건을 모두 만족할 때만 성립하고, 나머지는 전부 기록한다. - * - * | | 문구 있음 | 문구 없음 | - * |---|---|---| - * | 4xx | 제외 | 기록 | - * | 5xx | 기록 | 기록 | - * - * 문구 유무만으로 가르지 않는 이유: 이 서버는 5xx 에도 `message` 를 싣는다 — 500 응답 body 에 내부 SQL - * 문구가 그대로 실려 온 실측(#511)이 있다. 문구만 보면 정작 잡으려던 장애가 통째로 제외된다. - * - * 반대로 status 만으로도 가를 수 없다: 문구 없는 4xx 는 서버가 안내한 거절이 아니라 이쪽이 잘못된 요청을 - * 보내고 있다는 신호(파라미터 누락·잘못된 id·만료 토큰)라, 제외하면 FE 버그가 묻힌다. 이 서버는 4xx·5xx - * 가리지 않고 문구를 싣는 것으로 관측돼(400·401·500 실측), 문구가 없다는 것 자체가 정상 경로가 아니다. - * - * 두 조건을 모두 요구하는 건 **확실할 때만 제외**하려는 것이다 — 판정이 빗나가면 기록을 더 하는 쪽으로 - * 빗나간다. 잡음은 콘솔에 보여서 나중에 좁힐 수 있지만, 제외한 건 보이지 않아 좁힐 기회조차 없다. + * 제외는 **4xx + 서버 문구** 둘 다일 때만 성립한다. 문구만 보면 5xx 장애가 통째로 빠지고(이 서버는 5xx 에도 + * `message` 를 싣는다 — 500 body 에 내부 SQL 이 실려 온 실측 #511), status 만 보면 문구 없는 4xx — + * 파라미터 누락·만료 토큰 같은 FE 버그 신호 — 가 묻힌다. 판정이 빗나갈 땐 기록을 더 하는 쪽으로 빗나가야 + * 한다: 잡음은 콘솔에 보여 나중에 좁힐 수 있지만, 제외한 건 보이지 않아 좁힐 기회조차 없다. * - * 사유 code 로 좁히지 않은 이유: 서버의 code 체계가 사용자 오류와 장애를 아직 분리하지 않는다. + * 사유 code 로 좁히지 않은 이유 — 서버의 code 체계가 사용자 오류와 장애를 아직 분리하지 않는다. * * 다른 흐름에는 쓰지 않는다. 회원가입 이메일 인증은 사용자 오류가 code 1207 하나로만 와서 호출부가 * 타입(`CoreAuthFailure.EmailVerification`)만 보고 거른다 — 문구 유무를 따질 필요가 없다. @@ -135,13 +117,22 @@ fun ErrorReporter.recordAfternoteFailure( * 화면 노출 게이트(`toErrorPayload`, DocumentUploadUiState.kt)는 이 술어를 쓰지 않는다 — 노출은 * 사유 code allowlist 로 더 좁게 가른다. 그쪽을 넓히더라도 이 판정을 따라 넓히지 말 것. */ -fun Throwable.shouldReportInReceiverFlow(): Boolean { - val isExpectedUserRejection = - this is ReceiverServerRejectionException && - status in CLIENT_ERROR_STATUS_RANGE && - !serverMessage.isNullOrBlank() - return !isExpectedUserRejection -} +fun Throwable.shouldReportInReceiverFlow(): Boolean = + when (this) { + is ReceiverFailure -> !isExpectedUserRejection() + else -> true + } + +/** + * 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 수신자 실패 유형이 늘면 여기가 컴파일 에러로 잡힌다. + * 판정을 빼먹은 새 유형은 조용히 기록 대상이 되므로(안전한 쪽), 놓쳐도 티가 나지 않는다. + */ +private fun ReceiverFailure.isExpectedUserRejection(): Boolean = + when (this) { + is ReceiverFailure.ServerRejection -> { + status in CLIENT_ERROR_STATUS_RANGE && !serverMessage.isNullOrBlank() + } + } /** 4xx = 요청을 보낸 쪽 문제. 이 대역 밖(5xx·그 외)은 서버 문구가 실려 와도 장애로 보고 기록한다. */ private val CLIENT_ERROR_STATUS_RANGE = 400..499 diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt index ddfacffc91..7c14c039b7 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt @@ -1,8 +1,6 @@ package com.afternote.feature.afternote.presentation.reporting -import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException -import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -18,7 +16,7 @@ class ReceiverFlowReportingTest { @Test fun `4xx 에 서버 문구가 실렸으면 사용자 오류라 제외한다`() { val rejection = - ReceiverEmailAuthException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", serverCode = 1902, @@ -30,7 +28,7 @@ class ReceiverFlowReportingTest { @Test fun `5xx 는 서버 문구가 실려 있어도 기록 대상이다`() { val outage = - ReceiverMasterKeyException( + ReceiverFailure.ServerRejection( status = 500, serverMessage = "서버 내부 오류: could not execute statement", serverCode = 1500, @@ -42,7 +40,7 @@ class ReceiverFlowReportingTest { @Test fun `4xx 라도 서버 문구가 없으면 기록 대상이다`() { val silentRejection = - ReceiverDeliveryVerificationException( + ReceiverFailure.ServerRejection( status = 409, serverMessage = null, serverCode = 1700, diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt index 5a21460bca..f840a23e12 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt @@ -11,9 +11,7 @@ import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.toDomain import com.afternote.feature.receiver.data.service.ReceiverAuthApiService -import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException -import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult @@ -43,7 +41,7 @@ class ReceiverAuthRepositoryImpl try { api.verifyMasterKey(ReceiverAuthVerifyRequestDto(authCode)).requireData().toDomain() } catch (e: ApiException) { - throw ReceiverMasterKeyException(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) + throw ReceiverFailure.ServerRejection(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) } } @@ -52,7 +50,7 @@ class ReceiverAuthRepositoryImpl try { api.sendEmailAuthCode(ReceiverAuthCodeEmailSendRequestDto(email)).requireStatus() } catch (e: ApiException) { - throw ReceiverEmailAuthException(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) + throw ReceiverFailure.ServerRejection(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) } } @@ -68,7 +66,7 @@ class ReceiverAuthRepositoryImpl ).requireData() .toDomain() } catch (e: ApiException) { - throw ReceiverEmailAuthException(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) + throw ReceiverFailure.ServerRejection(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) } } @@ -92,7 +90,7 @@ class ReceiverAuthRepositoryImpl ).requireData() .toDomain() } catch (e: ApiException) { - throw ReceiverDeliveryVerificationException( + throw ReceiverFailure.ServerRejection( status = e.status, serverMessage = e.serverMessage, serverCode = e.code, diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt index e3db8f0141..8a72617216 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt @@ -13,7 +13,7 @@ import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyDto import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.ReceiverMessageDto import com.afternote.feature.receiver.data.service.ReceiverAuthApiService -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -25,7 +25,7 @@ import org.junit.Test import java.io.IOException /** - * 이메일 인증 두 endpoint 의 `ApiException` → [ReceiverEmailAuthException] 도메인 예외 변환 회귀 가드 (#407). + * 이메일 인증 두 endpoint 의 `ApiException` → [ReceiverFailure.ServerRejection] 도메인 예외 변환 회귀 가드 (#407). * * presentation 은 core:network 의 ApiException 을 직접 알면 안 되므로 (layer 규약) * Impl 이 serverMessage 를 보존해 변환하는지가 계약. 에러 메시지·code 값은 @@ -55,8 +55,8 @@ class ReceiverAuthRepositoryImplEmailAuthTest { val result = runBlocking { repository.sendEmailAuthCode("none@example.com") } val exception = result.exceptionOrNull() - assertTrue(exception is ReceiverEmailAuthException) - exception as ReceiverEmailAuthException + assertTrue(exception is ReceiverFailure.ServerRejection) + exception as ReceiverFailure.ServerRejection assertEquals("등록된 수신자 이메일이 아닙니다.", exception.serverMessage) assertEquals(404, exception.status) } @@ -80,8 +80,8 @@ class ReceiverAuthRepositoryImplEmailAuthTest { val result = runBlocking { repository.verifyEmailAuthCode("a@b.com", "123456") } val exception = result.exceptionOrNull() - assertTrue(exception is ReceiverEmailAuthException) - exception as ReceiverEmailAuthException + assertTrue(exception is ReceiverFailure.ServerRejection) + exception as ReceiverFailure.ServerRejection assertEquals("인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", exception.serverMessage) assertEquals(400, exception.status) } diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt deleted file mode 100644 index 5ac4aa923e..0000000000 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.afternote.feature.receiver.domain.error - -/** - * 수신자가 사망진단서·가족관계증명서를 올려 전달 자격 심사를 신청하는 - * `submitDeliveryVerification` API 가 거절한 실패. - * - * 계약(문구·code 의 의미, 왜 흐름별로 나누는지)은 상위 [ReceiverServerRejectionException] 참고. - * - * 이 흐름의 `serverMessage` 예시 — 409 `"이미 대기 중인 인증 요청이 존재합니다."` - */ -class ReceiverDeliveryVerificationException( - status: Int, - serverMessage: String?, - serverCode: Int, -) : ReceiverServerRejectionException( - status = status, - serverCode = serverCode, - serverMessage = serverMessage, - serverMessageFallback = "delivery verification failed (serverCode=$serverCode)", - ) diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt deleted file mode 100644 index 83dbcf7fac..0000000000 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.afternote.feature.receiver.domain.error - -/** - * 수신자 본인 확인 이메일 인증(`receiver-auth/email` 계열) API 가 거절한 실패. - * - * 계약(문구·code 의 의미, 왜 흐름별로 나누는지)은 상위 [ReceiverServerRejectionException] 참고. - * - * 이 흐름의 `serverCode` 예시 — 1901 = 수신자 이메일 미등록(404 동반), - * 1902 = 인증번호 만료/미존재(400 동반), 1903 = 인증번호 불일치(400 동반). - * body 파싱 실패 시 HTTP status 폴백 값이 들어온다. - * `serverMessage` 예시 — `"등록된 수신자 이메일이 아닙니다."`, - * `"인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요."` - */ -class ReceiverEmailAuthException( - status: Int, - serverMessage: String?, - serverCode: Int, -) : ReceiverServerRejectionException( - status = status, - serverCode = serverCode, - serverMessage = serverMessage, - serverMessageFallback = "email auth failed (serverCode=$serverCode)", - ) diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt new file mode 100644 index 0000000000..a1189a33b2 --- /dev/null +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt @@ -0,0 +1,37 @@ +package com.afternote.feature.receiver.domain.error + +/** + * 수신자 흐름(본인 확인 이메일 인증·마스터 키 검증·전달 자격 심사)의 도메인 실패 루트. + * Retrofit·`BaseResponse` 같은 인프라 디테일은 Data 계층이 해석한 뒤 이 계열로 통일한다. + * + * 소비처는 이 루트로 좁힌 뒤 `when` 으로 가른다 — 실패 유형이 늘면 컴파일러가 소비처를 잡아준다. + */ +sealed class ReceiverFailure( + message: String?, +) : Exception(message) { + /** + * 서버가 응답을 내려주며 거절한 실패. + * + * 흐름별로 타입을 나누지 않는다 — 어느 endpoint 가 거절했는지는 stack trace 에 남고, 타입으로 + * 갈라 보던 프로덕션 코드가 없었다(#934 실측). 구분이 실제로 필요해지면 그때 필드를 더한다. + * + * @property status 거절 응답의 HTTP 상태 코드. 인프라 디테일을 걷어내는 이 계열에 굳이 남기는 건 + * "사용자 오류(4xx)"와 "장애(5xx)"를 가를 유일한 단서라서다 — 서버가 양쪽 모두에 [serverMessage] 를 + * 실어 보내고(실측 #511), 사유 `code` 체계도 둘을 분리하지 않는다. 텔레메트리 제외 판정과 + * 화면 노출 게이트(#651)가 이 값을 쓴다. + * @property serverCode 서버 봉투의 사유 code (BE `ErrorCode` 번호 — 1902 등). 화면 노출 게이트가 + * 표시 허용 allowlist 판정에 쓴다. 서버의 `@Valid` 바디 검증 실패는 enum code 가 아니라 + * **리터럴 400** 으로 온다(실측) — 형식 검증 문구가 allowlist 에 못 드는 건 이 때문이다. + * @property serverMessage 백엔드가 실제로 내려준 사용자 친화 message. + * **null 이면 서버가 message 미제공** — 호출처는 정적 R.string 으로 폴백한다. 클라가 만든 generic + * 문구("알 수 없는 서버 에러" 등)는 여기 들어오지 않는다. + * null 일 때 `Throwable.message` 로 쓰는 문구는 Logcat·Crashlytics 단서용이라 노출하지 않는다. + */ + class ServerRejection( + val status: Int, + val serverCode: Int, + val serverMessage: String?, + ) : ReceiverFailure( + serverMessage ?: "receiver request rejected (status=$status, serverCode=$serverCode)", + ) +} diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt deleted file mode 100644 index 4a904815fc..0000000000 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.afternote.feature.receiver.domain.error - -/** - * 마스터 키 검증(`receiver-auth/verify`) API 가 거절한 실패. - * - * 계약(문구·code 의 의미, 왜 흐름별로 나누는지)은 상위 [ReceiverServerRejectionException] 참고. - * 이 흐름의 거절은 대부분 키 오타라 서버가 안내 문구를 함께 내려준다. - */ -class ReceiverMasterKeyException( - status: Int, - serverMessage: String?, - serverCode: Int, -) : ReceiverServerRejectionException( - status = status, - serverCode = serverCode, - serverMessage = serverMessage, - serverMessageFallback = "master key verify failed (serverCode=$serverCode)", - ) diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt deleted file mode 100644 index 838bcf9494..0000000000 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.afternote.feature.receiver.domain.error - -/** - * 수신자 흐름 API 가 응답을 내려주며 거절한 실패의 공통 상위 타입. - * Retrofit·`BaseResponse` 같은 인프라 디테일은 Data 계층에서 해석한 뒤 이 계열로 통일한다. - * - * 하위 타입은 흐름별로 나눈다 — 한 화면이 여러 endpoint 를 호출할 때 어느 단계가 거절됐는지 타입만 - * 보고 갈라내기 위해서다. `sealed` 라 향후 사유 code 별 분기를 넣을 때 `when` 이 누락을 잡아준다. - * - * @property status 거절 응답의 HTTP 상태 코드. 인프라 디테일을 걷어내는 이 계열에 굳이 남기는 건 - * "사용자 오류(4xx)"와 "장애(5xx)"를 가를 유일한 단서라서다 — 서버가 양쪽 모두에 [serverMessage] 를 - * 실어 보내고(실측 #511), 사유 `code` 체계도 둘을 분리하지 않는다. 텔레메트리 제외 판정과 - * 화면 노출 게이트(#651)가 이 값을 쓴다. - * @property serverCode 서버 봉투의 사유 code (BE `ErrorCode` 번호 — 1902 등). 화면 노출 게이트가 - * 표시 허용 allowlist 판정에 쓴다. 서버의 `@Valid` 바디 검증 실패는 enum code 가 아니라 - * **리터럴 400** 으로 온다(실측) — 형식 검증 문구가 allowlist 에 못 드는 건 이 때문이다. - * @property serverMessage 백엔드가 실제로 내려준 사용자 친화 message. - * **null 이면 서버가 message 미제공** — 호출처는 정적 R.string 으로 폴백한다. 클라가 만든 generic - * 문구("알 수 없는 서버 에러" 등)는 여기 들어오지 않는다. - * @param serverMessageFallback [serverMessage] 가 null 일 때 `Throwable.message` 로 쓸 개발자용 문구 — - * Logcat·Crashlytics 에 단서를 남기기 위한 것이라 사용자에게 노출하지 않는다. 하위 타입이 서버 - * `code` 를 이 문구에도 보간해 넣는다(로그 한 줄에서 사유가 보이도록). - */ -sealed class ReceiverServerRejectionException( - val status: Int, - val serverCode: Int, - val serverMessage: String?, - serverMessageFallback: String, -) : Exception(serverMessage ?: serverMessageFallback) diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt index 64ed82a2f3..a9adb02b56 100644 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt @@ -21,7 +21,7 @@ interface ReceiverAuthRepository { * 발신자가 발급한 마스터 키([authCode])로 수신자 신원을 확인한다. * * 성공하면 이 키가 이후 요청의 `X-Auth-Code` 헤더로 재사용된다 (사실상 수신자 세션). - * 실패는 `ReceiverMasterKeyException` 으로 매핑된다. + * 실패는 `ReceiverFailure.ServerRejection` 으로 매핑된다. */ suspend fun verifyMasterKey(authCode: String): Result @@ -29,14 +29,14 @@ interface ReceiverAuthRepository { * 수신자 본인 확인 — 수신자 레코드에 등록된 [email] 로 6자리 인증번호 발송. * * 발신자가 수신자 등록 시 email 을 넣지 않았으면 서버가 거절한다 (RECEIVER_EMAIL_NOT_FOUND). - * 실패는 `ReceiverEmailAuthException` 으로 매핑되어 serverMessage 에 안내 문구가 담긴다. + * 실패는 `ReceiverFailure.ServerRejection` 으로 매핑되어 serverMessage 에 안내 문구가 담긴다. */ suspend fun sendEmailAuthCode(email: String): Result /** * 수신자 본인 확인 — [email] 로 발송된 6자리 [authCode] 검증. * - * 실패(만료/불일치 등)는 `ReceiverEmailAuthException` 으로 매핑된다. + * 실패(만료/불일치 등)는 `ReceiverFailure.ServerRejection` 으로 매핑된다. */ suspend fun verifyEmailAuthCode( email: String, diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt index 050cce6154..25fcef3575 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt @@ -2,20 +2,14 @@ package com.afternote.feature.receiver.presentation.deliveryverification import androidx.annotation.StringRes import androidx.compose.runtime.Immutable -import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException +import com.afternote.feature.receiver.domain.error.ReceiverFailure /** - * 화면이 표시할 에러 문구를 VM → UI 로 실어 나르는 상자 (payload = 운반되는 내용물). - * sealed 로 "i18n string resource" vs "서버 동적 message" 상호 배타 보장. + * 화면이 표시할 에러 문구를 VM → UI 로 실어 나른다. 리소스와 서버 동적 문구를 각각 nullable 필드로 + * 두면 둘 다 set 되는 상태가 가능해지므로, sealed 로 "하나만" 을 타입에 강제한다. * - * 두 경우를 각각 별도 nullable 필드로 두면 컨벤션 의존 (둘 다 set 되는 버그 가능). sealed 로 묶으면 - * 타입 자체가 "하나만 가능" 강제. - * - * 두 타입을 String 하나로 합칠 수 없는 이유: 리소스 ID → String 변환에는 Context 가 필요해 - * VM 에서 못 풀고(UI 의 stringResource 가 마지막에 한 번), 서버 동적 문구는 리소스가 될 수 없다. - * - * 쓰이는 곳이 열람 인증 흐름뿐인 건 서버 문구를 그대로 노출해도 되는 계약이 여기에만 있어서다. - * 같은 역할의 `core.ui.UiText.DynamicOrResource` 가 뒤늦게 들어와 중복이며, 통일은 #446 소관. + * 둘을 String 하나로 합칠 수는 없다 — 리소스 ID → String 변환에 Context 가 필요해 VM 에서 못 풀고 + * (UI 의 stringResource 가 마지막에 한 번), 서버 동적 문구는 리소스가 될 수 없다. */ sealed interface ErrorPayload { /** 클라이언트가 미리 정의한 generic 문구 (i18n 가능). 서버 message 미제공·5xx 장애 시 fallback. */ @@ -29,32 +23,43 @@ sealed interface ErrorPayload { ) : ErrorPayload } -/** - * status 만 가르고 code 를 안 보면 4xx 검증류 개발자 문구까지 화면에 실린다 — `@Valid` 실패의 - * "인증번호는 UUID 형식이어야 합니다."(리터럴 code=400, #600 실측)가 그 반례다. - */ +/** 실패를 화면 문구로 옮긴다 — 노출 가능 여부 판정은 [isUserDisplayableRejection] 이 가른다. */ internal fun Throwable.toErrorPayload( @StringRes fallbackRes: Int, ): ErrorPayload = - (this as? ReceiverServerRejectionException) - ?.takeIf { it.isUserDisplayableRejection() } - ?.serverMessage - ?.takeIf { it.isNotBlank() } + (this as? ReceiverFailure) + ?.displayTextOrNull() ?.let { ErrorPayload.Text(it) } ?: ErrorPayload.Res(fallbackRes) -/** 문구 유무는 보지 않는다 — 호출부 체인이 거른다. */ -private fun ReceiverServerRejectionException.isUserDisplayableRejection(): Boolean = +/** + * 화면에 그대로 실어도 되는 서버 문구. 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 수신자 실패 + * 유형이 늘면 여기가 컴파일 에러로 잡힌다. `else` 로 뭉개 두면 새 유형이 게이트를 거치지 않은 채 + * 흘러가 노출 규약이 조용히 빈다. + * + * null 은 "노출할 서버 문구 없음" 이고, 폴백 리소스는 [toErrorPayload] 한 곳에서만 적용한다. + */ +private fun ReceiverFailure.displayTextOrNull(): String? = + when (this) { + is ReceiverFailure.ServerRejection -> { + serverMessage?.takeIf { isUserDisplayableRejection() && it.isNotBlank() } + } + } + +/** + * status 만 보고 code 를 안 보면 4xx 검증류 개발자 문구까지 화면에 실린다 — `@Valid` 실패가 + * "인증번호는 UUID 형식이어야 합니다." 를 리터럴 code=400 으로 실어 보낸 것(#600 실측)이 그 반례다. + * 문구 유무는 여기서 보지 않는다 — 호출부 체인이 거른다. + */ +private fun ReceiverFailure.ServerRejection.isUserDisplayableRejection(): Boolean = status in DISPLAYABLE_CLIENT_ERROR_RANGE && serverCode in USER_DISPLAYABLE_SERVER_CODES /** 등재 code 라도 5xx 봉투면 장애다 — code 게이트와 독립으로 대역을 한 번 더 본다. */ private val DISPLAYABLE_CLIENT_ERROR_RANGE = 400..499 /** - * 1900 유효하지 않은 인증번호(마스터 키) · 1901 미등록 수신자 이메일 · 1902 인증번호 만료/미존재 · - * 1903 인증번호 불일치 · 2008 이미 대기 중인 인증 요청. BE `ErrorCode.java`@release 전수 대조로 - * 문구가 사용자 안내인 것만 골랐다(2026-08-01 기준) — 서버가 표시 가능 여부를 알려주지는 않으므로, - * 신규 code 는 문구를 확인한 뒤에만 등재한다. 미등재 기본값은 폴백. + * 서버는 표시 가능 여부를 알려주지 않는다 — BE `ErrorCode` 의 실제 문구가 사용자 안내인 것만 골라 + * 등재한 목록이다. 신규 code 는 문구를 확인한 뒤에만 더한다. 미등재 기본값은 폴백. */ private val USER_DISPLAYABLE_SERVER_CODES = setOf(1900, 1901, 1902, 1903, 2008) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt index 2769ea233b..8f03aa2f5f 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt @@ -7,7 +7,6 @@ import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import com.afternote.feature.afternote.presentation.reporting.shouldReportInReceiverFlow -import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -21,10 +20,8 @@ import javax.inject.Inject /** * 수신자 본인 확인 이메일 인증(designs 3·4) ViewModel — 인증번호 발송 + 코드 검증 (이슈 #215, #407). * - * [ReceiverAuthRepository.sendEmailAuthCode]·[ReceiverAuthRepository.verifyEmailAuthCode] 로 - * 실 API(`receiver-auth/email` 계열) 를 호출한다. 서버가 안내한 4xx 거절(이메일 미등록·인증번호 - * 만료/불일치 등) 의 문구는 [ReceiverServerRejectionException.serverMessage] 를 그대로 노출하고, - * 그 외(5xx 장애·인프라 실패)는 정적 리소스로 폴백 — 판정은 [toErrorPayload] 가 가른다 (#651). + * [ReceiverAuthRepository.sendEmailAuthCode]·[ReceiverAuthRepository.verifyEmailAuthCode] 로 실 API 를 + * 호출한다. 실패 문구를 서버 문구로 낼지 정적 리소스로 낼지는 [toErrorPayload] 가 가른다 (#651). * * 검증 성공 시 [IdentityVerificationRepository.markVerified] 로 캐시를 켜고 isVerified 신호 발행 → * UI 가 마스터 키(5) 단계로 이동. 이메일 인증은 신원 확인까지만 담당하며 마스터 키를 대신 획득하지 diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt index bc536de6fb..5add55c386 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt @@ -1,8 +1,6 @@ package com.afternote.feature.receiver.presentation.deliveryverification -import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException -import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import org.junit.Assert.assertEquals import org.junit.Test import java.io.IOException @@ -18,7 +16,7 @@ class ErrorPayloadTest { @Test fun `allowlist 에 등재된 4xx 사유 code 는 서버 문구를 그대로 노출한다`() { val rejection = - ReceiverEmailAuthException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", serverCode = 1902, @@ -33,7 +31,7 @@ class ErrorPayloadTest { @Test fun `allowlist 미등재 4xx 는 서버 문구가 있어도 fallback 리소스로 폴백한다`() { val validationRejection = - ReceiverMasterKeyException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호는 UUID 형식이어야 합니다.", serverCode = 400, @@ -45,7 +43,7 @@ class ErrorPayloadTest { @Test fun `5xx 는 서버 문구가 실려 있어도 fallback 리소스로 폴백한다`() { val outage = - ReceiverDeliveryVerificationException( + ReceiverFailure.ServerRejection( status = 500, serverMessage = "서버 내부 오류: could not execute statement", serverCode = 1500, @@ -57,7 +55,7 @@ class ErrorPayloadTest { @Test fun `allowlist 에 등재된 code 라도 5xx 봉투면 fallback 리소스로 폴백한다`() { val outageWithKnownCode = - ReceiverDeliveryVerificationException( + ReceiverFailure.ServerRejection( status = 500, serverMessage = "internal error while checking pending verification", serverCode = 2008, @@ -69,7 +67,7 @@ class ErrorPayloadTest { @Test fun `서버 문구 없는 4xx 는 등재 code 라도 fallback 리소스로 폴백한다`() { val silentRejection = - ReceiverMasterKeyException( + ReceiverFailure.ServerRejection( status = 404, serverMessage = null, serverCode = 1900,