diff --git a/core/network/src/main/kotlin/com/afternote/core/network/model/BaseResponse.kt b/core/network/src/main/kotlin/com/afternote/core/network/model/BaseResponse.kt index d76536f3b..f9f145a71 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/model/BaseResponse.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/model/BaseResponse.kt @@ -4,7 +4,14 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.io.IOException -/** 서버 공통 응답 봉투. 제네릭 [T] 는 `data` 필드의 페이로드 타입 — `data` 없는 엔드포인트는 `BaseResponse`. */ +/** + * 서버 공통 응답 봉투. 제네릭 [T] 는 `data` 필드의 페이로드 타입 — `data` 없는 엔드포인트는 `BaseResponse`. + * + * 서버 스키마(`ApiResponse*`)에 더 있는 `expiresIn`(액세스 토큰 잔여 수명 초 — BE 2026-06-01 도입, + * 유효 토큰으로 호출한 일부 목록 endpoint 에서 실제로 내려옴) 등 클라 미소비 필드는 선언하지 않는다 — + * null 필드는 서버 직렬화에서 생략되고, `NetworkModule.provideJson` 의 ignoreUnknownKeys 가 + * 선언 안 된 키를 무시한다. FE 가 토큰 선제 갱신을 구현하는 시점에 nullable 로 추가. + */ @Serializable data class BaseResponse( @SerialName("status") @@ -44,6 +51,10 @@ fun BaseResponse.requireData(): T { * 을 판단 — `message` 는 클라 fallback 이 섞여 사용자에게 노출하면 안 됨. * @property message IOException 호환용 디버깅 메시지 (serverMessage 있으면 그것, 없으면 클라 fallback). * 사용자 직접 노출 X — Logcat·Crashlytics 용. + * + * 헤더의 `IOException(message)` 는 부모 생성자 호출(Java 의 `super(message)`) — 인자는 주 생성자의 + * `message` 파라미터. 같은 값을 자기 프로퍼티(`String?` 인 Throwable.message 를 non-null 로 좁힘)와 + * 부모 Throwable 내부 필드 양쪽에 설정해, 디버거·Java 경로의 메시지 표시를 보존한다. */ class ApiException( val code: Int, diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAuthDto.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAuthDto.kt index 4b0cafdef..320014495 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAuthDto.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/dto/ReceiverAuthDto.kt @@ -4,6 +4,7 @@ import com.afternote.feature.afternote.data.mapper.formatDateFromServer import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl +import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult import com.afternote.feature.receiver.domain.model.ReceiverIdentity import com.afternote.feature.receiver.domain.model.SenderMessageInfo import kotlinx.serialization.SerialName @@ -22,6 +23,25 @@ data class ReceiverAuthVerifyResponse( @SerialName("relation") val relation: String, ) +@Serializable +data class ReceiverAuthCodeEmailSendRequest( + @SerialName("email") val email: String, +) + +@Serializable +data class ReceiverEmailAuthVerifyRequest( + @SerialName("email") val email: String, + @SerialName("authCode") val authCode: String, +) + +@Serializable +data class ReceiverEmailAuthVerifyResponse( + @SerialName("receiverId") val receiverId: Long, + @SerialName("receiverName") val receiverName: String, + @SerialName("senderName") val senderName: String, + @SerialName("accessCode") val accessCode: String, +) + @Serializable data class ReceiverAuthPresignedUrlRequest( @SerialName("extension") val extension: String, @@ -66,6 +86,14 @@ fun ReceiverAuthVerifyResponse.toDomain(): ReceiverIdentity = relation = relation, ) +fun ReceiverEmailAuthVerifyResponse.toDomain(): ReceiverEmailAuthResult = + ReceiverEmailAuthResult( + receiverId = receiverId, + receiverName = receiverName, + senderName = senderName, + accessCode = accessCode, + ) + fun ReceiverAuthPresignedUrlResponse.toDomain(): ReceiverAuthPresignedUrl = ReceiverAuthPresignedUrl( presignedUrl = presignedUrl, diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImpl.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImpl.kt index c1b836b9c..c0034b588 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImpl.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImpl.kt @@ -2,20 +2,33 @@ package com.afternote.feature.afternote.data.repositoryimpl.receiver import com.afternote.core.network.model.ApiException import com.afternote.core.network.model.requireData +import com.afternote.core.network.model.requireStatus import com.afternote.feature.afternote.data.dto.DeliveryVerificationRequest +import com.afternote.feature.afternote.data.dto.ReceiverAuthCodeEmailSendRequest import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlRequest import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyRequest +import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyRequest import com.afternote.feature.afternote.data.dto.toDomain import com.afternote.feature.afternote.data.service.ReceiverAuthApiService import com.afternote.feature.afternote.domain.error.ReceiverDeliverySubmitException +import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl +import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult import com.afternote.feature.receiver.domain.model.ReceiverIdentity import com.afternote.feature.receiver.domain.model.SenderMessageInfo import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import javax.inject.Inject import javax.inject.Singleton +/** + * `receiver-auth` 계열 endpoint 의 [ReceiverAuthRepository] 구현. + * + * 에러 처리 구조 — 일부 메서드는 try/catch 가 runCatching *안에* 포개져 있다: + * 안쪽 catch 가 [ApiException](인프라 타입)을 도메인 예외로 바꿔 던지면(exception translation), + * 그 새 예외는 자기를 만든 try 로 되돌아가지 않고 바깥 runCatching 이 잡아 + * `Result.failure(도메인 예외)` 로 반환된다 — 호출자에게 예외가 throw 되어 나가는 일은 없다. + */ @Singleton class ReceiverAuthRepositoryImpl @Inject @@ -27,6 +40,31 @@ class ReceiverAuthRepositoryImpl api.verify(ReceiverAuthVerifyRequest(authCode)).requireData().toDomain() } + override suspend fun sendEmailAuthCode(email: String): Result = + runCatching { + try { + api.sendEmailAuthCode(ReceiverAuthCodeEmailSendRequest(email)).requireStatus() + } catch (e: ApiException) { + throw ReceiverEmailAuthException(serverMessage = e.serverMessage, serverCode = e.code) + } + } + + override suspend fun verifyEmailAuthCode( + email: String, + authCode: String, + ): Result = + runCatching { + try { + api + .verifyEmailAuthCode( + ReceiverEmailAuthVerifyRequest(email = email, authCode = authCode), + ).requireData() + .toDomain() + } catch (e: ApiException) { + throw ReceiverEmailAuthException(serverMessage = e.serverMessage, serverCode = e.code) + } + } + override suspend fun getPresignedUrl(extension: String): Result = runCatching { api.getPresignedUrl(ReceiverAuthPresignedUrlRequest(extension)).requireData().toDomain() @@ -37,28 +75,17 @@ class ReceiverAuthRepositoryImpl familyRelationCertificateUrl: String, ): Result = runCatching { - api - .submitDeliveryVerification( - DeliveryVerificationRequest( - deathCertificateUrl = deathCertificateUrl, - familyRelationCertificateUrl = familyRelationCertificateUrl, - ), - ).requireData() - .toDomain() - }.recoverCatching { throwable -> - // ApiErrorInterceptor 가 4xx/5xx 응답을 ApiException 으로 변환 (백엔드 message 포함). - // presentation 이 core:network 의 ApiException 을 직접 알면 layer 위반이므로 - // 도메인 예외 ReceiverDeliverySubmitException 으로 변환해 노출한다. - // - // ApiException.serverMessage = 서버가 실제 보낸 message (null 가능), - // ApiException.message = 클라 fallback 섞여 있어 사용자 노출 부적합 — serverMessage 만 전달. - throw if (throwable is ApiException) { - ReceiverDeliverySubmitException( - serverMessage = throwable.serverMessage, - httpCode = throwable.code, - ) - } else { - throwable + try { + api + .submitDeliveryVerification( + DeliveryVerificationRequest( + deathCertificateUrl = deathCertificateUrl, + familyRelationCertificateUrl = familyRelationCertificateUrl, + ), + ).requireData() + .toDomain() + } catch (e: ApiException) { + throw ReceiverDeliverySubmitException(serverMessage = e.serverMessage, httpCode = e.code) } } diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAuthApiService.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAuthApiService.kt index 40d5fc6bf..39b31802c 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAuthApiService.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/service/ReceiverAuthApiService.kt @@ -3,10 +3,13 @@ package com.afternote.feature.afternote.data.service import com.afternote.core.network.model.BaseResponse import com.afternote.feature.afternote.data.dto.DeliveryVerificationRequest import com.afternote.feature.afternote.data.dto.DeliveryVerificationResponse +import com.afternote.feature.afternote.data.dto.ReceiverAuthCodeEmailSendRequest import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlRequest import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlResponse import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyRequest import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyResponse +import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyRequest +import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyResponse import com.afternote.feature.afternote.data.dto.ReceiverMessageResponse import retrofit2.http.Body import retrofit2.http.GET @@ -15,9 +18,10 @@ import retrofit2.http.POST /** * 수신자 인증 흐름 전용 API. * - * `verify` 외 모든 endpoint 는 `X-Auth-Code` 헤더가 필요하며 + * 인증 코드 취득 전 단계인 `verify`·`email/auth-code`·`email/verify` 를 제외한 모든 endpoint 는 + * `X-Auth-Code` 헤더가 필요하며 * [com.afternote.feature.afternote.data.network.ReceiverAuthInterceptor] 가 - * 저장된 인증 코드를 자동 부착한다. + * 저장된 인증 코드를 자동 부착한다 (저장된 코드가 없으면 헤더 없이 통과). */ interface ReceiverAuthApiService { @POST("receiver-auth/verify") @@ -25,6 +29,18 @@ interface ReceiverAuthApiService { @Body body: ReceiverAuthVerifyRequest, ): BaseResponse + /** 수신자 레코드에 등록된 email 로 6자리 인증번호 발송. 미등록 email 은 404 (code 1901). */ + @POST("receiver-auth/email/auth-code") + suspend fun sendEmailAuthCode( + @Body body: ReceiverAuthCodeEmailSendRequest, + ): BaseResponse + + /** 발송된 6자리 인증번호 검증. 만료/불일치는 400 (code 1902). */ + @POST("receiver-auth/email/verify") + suspend fun verifyEmailAuthCode( + @Body body: ReceiverEmailAuthVerifyRequest, + ): BaseResponse + @POST("receiver-auth/presigned-url") suspend fun getPresignedUrl( @Body body: ReceiverAuthPresignedUrlRequest, diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverAuthDtoMapperTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverAuthDtoMapperTest.kt index 55cd55172..cc10275fa 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverAuthDtoMapperTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverAuthDtoMapperTest.kt @@ -48,6 +48,22 @@ class ReceiverAuthDtoMapperTest { assertEquals("자녀", result.relation) } + @Test + fun `ReceiverEmailAuthVerifyResponse toDomain - ReceiverEmailAuthResult 매핑`() { + val result = + ReceiverEmailAuthVerifyResponse( + receiverId = 7L, + receiverName = "김수신", + senderName = "홍발신", + accessCode = "b7a3c9d1-1234-5678-9abc-def012345678", + ).toDomain() + + assertEquals(7L, result.receiverId) + assertEquals("김수신", result.receiverName) + assertEquals("홍발신", result.senderName) + assertEquals("b7a3c9d1-1234-5678-9abc-def012345678", result.accessCode) + } + @Test fun `ReceiverAuthPresignedUrlResponse toDomain - 매핑`() { val result = diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverEmailAuthContractTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverEmailAuthContractTest.kt new file mode 100644 index 000000000..f05baa460 --- /dev/null +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/dto/ReceiverEmailAuthContractTest.kt @@ -0,0 +1,50 @@ +package com.afternote.feature.afternote.data.dto + +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.model.requireData +import com.afternote.core.network.model.requireStatus +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * `POST /receiver-auth/email/auth-code`·`POST /receiver-auth/email/verify` 응답 계약 회귀 가드 (#407). + * + * 페이로드는 2026-06-11 라이브 Swagger(`afternote.kro.kr/v3/api-docs`) 스키마 기반 합성 — + * verify 성공 응답은 6자리 인증번호를 메일로 받아야 해서 자동 캡처 불가 + * (`ReceiverEmailAuthVerifyResponse` 4필드 구성·타입은 Swagger 와 BE 소스로 확정, 이슈 #407 본문). + * 프로덕션 경로(`ReceiverAuthRepositoryImpl`)와 동일하게 Json 디코드 → `requireData()`/`requireStatus()` + * → `toDomain()` 을 통과시킨다 — Json 설정은 `NetworkModule.provideJson` 과 동일 + * (ignoreUnknownKeys + coerceInputValues). + * + * 에러 응답(404 code 1901 / 400 code 1902)은 HTTP 4xx 라 `ApiErrorInterceptor` 가 가로채는 경로 — + * 그 이후의 도메인 예외 변환은 `ReceiverAuthRepositoryImplEmailAuthTest` 가 가드한다. + */ +class ReceiverEmailAuthContractTest { + private val json = + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @Test + fun `email-verify 성공 응답 - 디코드부터 accessCode 포함 도메인 모델까지 도달`() { + val payload = + """{"status":200,"code":200,"message":"성공","data":{"receiverId":3,"receiverName":"큐에이수신자","senderName":"큐에이발신자","accessCode":"123e4567-e89b-12d3-a456-426614174000"}}""" + + val result = json.decodeFromString>(payload).requireData().toDomain() + + assertEquals(3L, result.receiverId) + assertEquals("큐에이수신자", result.receiverName) + assertEquals("큐에이발신자", result.senderName) + // 백엔드가 receiver.getAuthCode() 를 그대로 반환 — 마스터 키와 동일한 UUID. + assertEquals("123e4567-e89b-12d3-a456-426614174000", result.accessCode) + } + + @Test + fun `auth-code 성공 응답(data 없음) - BaseResponse_Unit 디코드 + requireStatus 무사 통과`() { + val payload = """{"status":200,"code":200,"message":"성공","data":null}""" + + json.decodeFromString>(payload).requireStatus() + } +} diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImplEmailAuthTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImplEmailAuthTest.kt new file mode 100644 index 000000000..acd5bdc12 --- /dev/null +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/receiver/ReceiverAuthRepositoryImplEmailAuthTest.kt @@ -0,0 +1,147 @@ +package com.afternote.feature.afternote.data.repositoryimpl.receiver + +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.afternote.data.dto.DeliveryVerificationRequest +import com.afternote.feature.afternote.data.dto.DeliveryVerificationResponse +import com.afternote.feature.afternote.data.dto.ReceiverAuthCodeEmailSendRequest +import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlRequest +import com.afternote.feature.afternote.data.dto.ReceiverAuthPresignedUrlResponse +import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyRequest +import com.afternote.feature.afternote.data.dto.ReceiverAuthVerifyResponse +import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyRequest +import com.afternote.feature.afternote.data.dto.ReceiverEmailAuthVerifyResponse +import com.afternote.feature.afternote.data.dto.ReceiverMessageResponse +import com.afternote.feature.afternote.data.service.ReceiverAuthApiService +import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * 이메일 인증 두 endpoint 의 `ApiException` → [ReceiverEmailAuthException] 도메인 예외 변환 회귀 가드 (#407). + * + * presentation 은 core:network 의 ApiException 을 직접 알면 안 되므로 (layer 규약) + * Impl 이 serverMessage·code 를 보존해 변환하는지가 계약. 에러 메시지·code 값은 + * 2026-06-11 라이브 서버 실응답 캡처 — 404 `{"code":1901,"message":"등록된 수신자 이메일이 아닙니다."}`, + * 400 `{"code":1902,"message":"인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요."}`. + */ +class ReceiverAuthRepositoryImplEmailAuthTest { + @Test + fun `sendEmailAuthCode - 미등록 이메일 1901 ApiException 을 도메인 예외로 변환`() { + val repository = + ReceiverAuthRepositoryImpl( + FakeReceiverAuthApiService( + onSendEmailAuthCode = { + throw ApiException( + code = 1901, + serverMessage = "등록된 수신자 이메일이 아닙니다.", + message = "등록된 수신자 이메일이 아닙니다.", + ) + }, + ), + ) + + val result = runBlocking { repository.sendEmailAuthCode("none@example.com") } + + val exception = result.exceptionOrNull() + assertTrue(exception is ReceiverEmailAuthException) + exception as ReceiverEmailAuthException + assertEquals("등록된 수신자 이메일이 아닙니다.", exception.serverMessage) + assertEquals(1901, exception.serverCode) + } + + @Test + fun `verifyEmailAuthCode - 만료·불일치 1902 ApiException 을 도메인 예외로 변환`() { + val repository = + ReceiverAuthRepositoryImpl( + FakeReceiverAuthApiService( + onVerifyEmailAuthCode = { + throw ApiException( + code = 1902, + serverMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", + message = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", + ) + }, + ), + ) + + val result = runBlocking { repository.verifyEmailAuthCode("a@b.com", "123456") } + + val exception = result.exceptionOrNull() + assertTrue(exception is ReceiverEmailAuthException) + exception as ReceiverEmailAuthException + assertEquals("인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", exception.serverMessage) + assertEquals(1902, exception.serverCode) + } + + @Test + fun `sendEmailAuthCode - ApiException 아닌 인프라 예외는 원본 그대로 전파`() { + val original = IOException("timeout") + val repository = + ReceiverAuthRepositoryImpl( + FakeReceiverAuthApiService(onSendEmailAuthCode = { throw original }), + ) + + val result = runBlocking { repository.sendEmailAuthCode("a@b.com") } + + assertEquals(original, result.exceptionOrNull()) + } + + @Test + fun `verifyEmailAuthCode - 성공 응답을 도메인 모델로 매핑`() { + val repository = + ReceiverAuthRepositoryImpl( + FakeReceiverAuthApiService( + onVerifyEmailAuthCode = { body -> + assertEquals("a@b.com", body.email) + assertEquals("123456", body.authCode) + BaseResponse( + status = 200, + code = 200, + message = "성공", + data = + ReceiverEmailAuthVerifyResponse( + receiverId = 3L, + receiverName = "큐에이수신자", + senderName = "큐에이발신자", + accessCode = "123e4567-e89b-12d3-a456-426614174000", + ), + ) + }, + ), + ) + + val result = runBlocking { repository.verifyEmailAuthCode("a@b.com", "123456") }.getOrThrow() + + assertEquals(3L, result.receiverId) + assertEquals("123e4567-e89b-12d3-a456-426614174000", result.accessCode) + } +} + +/** 이메일 인증 두 메서드만 주입 가능한 fake — 나머지 endpoint 는 본 테스트 대상 아님. */ +private class FakeReceiverAuthApiService( + private val onSendEmailAuthCode: (ReceiverAuthCodeEmailSendRequest) -> BaseResponse = { error("unused") }, + private val onVerifyEmailAuthCode: ( + ReceiverEmailAuthVerifyRequest, + ) -> BaseResponse = { error("unused") }, +) : ReceiverAuthApiService { + override suspend fun verify(body: ReceiverAuthVerifyRequest): BaseResponse = error("unused") + + override suspend fun sendEmailAuthCode(body: ReceiverAuthCodeEmailSendRequest): BaseResponse = onSendEmailAuthCode(body) + + override suspend fun verifyEmailAuthCode(body: ReceiverEmailAuthVerifyRequest): BaseResponse = + onVerifyEmailAuthCode(body) + + override suspend fun getPresignedUrl(body: ReceiverAuthPresignedUrlRequest): BaseResponse = + error("unused") + + override suspend fun submitDeliveryVerification(body: DeliveryVerificationRequest): BaseResponse = + error("unused") + + override suspend fun getDeliveryVerificationStatus(): BaseResponse = error("unused") + + override suspend fun getSenderMessage(): BaseResponse = error("unused") +} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverEmailAuthException.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverEmailAuthException.kt new file mode 100644 index 000000000..b65c224bc --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/ReceiverEmailAuthException.kt @@ -0,0 +1,20 @@ +package com.afternote.feature.afternote.domain.error + +/** + * 수신자 본인 확인 이메일 인증(`receiver-auth/email` 계열) API 가 거절한 실패. + * + * HTTP 상태·Retrofit·BaseResponse 같은 인프라 디테일은 Data 계층에서 해석한 뒤 + * 이 타입으로 통일한다. 도메인·Presentation 은 [serverMessage] 만 받아 사용자에게 노출. + * ([ReceiverDeliverySubmitException] 과 같은 패턴 — 흐름별 예외를 분리해 호출처가 출처를 구분.) + * + * @property serverMessage 백엔드가 실제로 내려준 사용자 친화 message + * (예: `"등록된 수신자 이메일이 아닙니다."`, `"인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요."`). + * **null 이면 서버가 message 미제공** — 호출처는 도메인 fallback (정적 R.string) 으로 폴백. + * @property serverCode 서버 에러 body 의 `code` (예: 1901 = 수신자 이메일 미등록(404 동반), + * 1902 = 인증번호 만료/불일치(400 동반)). body 파싱 실패 시 HTTP status 폴백 값이 들어온다. + * 향후 "특정 code 일 때만 분기" 요구 시 사용 — message 문자열 매칭 회귀 회피. + */ +class ReceiverEmailAuthException( + val serverMessage: String?, + val serverCode: Int? = null, +) : Exception(serverMessage ?: "email auth failed (serverCode=$serverCode)") diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadUiState.kt index 4a46783fa..591bd654c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/DocumentUploadUiState.kt @@ -4,10 +4,14 @@ import androidx.annotation.StringRes import androidx.compose.runtime.Immutable /** - * UI 에 노출할 에러 — sealed 로 "i18n string resource" vs "서버 동적 message" 상호 배타 보장. + * 화면이 표시할 에러 문구를 VM → UI 로 실어 나르는 상자 (payload = 운반되는 내용물). + * sealed 로 "i18n string resource" vs "서버 동적 message" 상호 배타 보장. * * 두 경우를 각각 별도 nullable 필드로 두면 컨벤션 의존 (둘 다 set 되는 버그 가능). sealed 로 묶으면 * 타입 자체가 "하나만 가능" 강제. + * + * 두 타입을 String 하나로 합칠 수 없는 이유: 리소스 ID → String 변환에는 Context 가 필요해 + * VM 에서 못 풀고(UI 의 stringResource 가 마지막에 한 번), 서버 동적 문구는 리소스가 될 수 없다. */ sealed interface ErrorPayload { /** 클라이언트가 미리 정의한 generic 문구 (i18n 가능). 서버 message 미제공 시 fallback. */ diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityEmailVerificationStub.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityEmailVerificationStub.kt deleted file mode 100644 index 35ad662f1..000000000 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityEmailVerificationStub.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.afternote.feature.afternote.presentation.receiver.deliveryverification - -import com.afternote.feature.afternote.presentation.receiver.deliveryverification.IdentityEmailVerificationStub.Companion.STUB_CODE -import kotlinx.coroutines.delay -import javax.inject.Inject -import javax.inject.Singleton - -/** - * 본인 확인 이메일 인증(designs 3·4) 의 in-memory stub — 백엔드 `receiver-auth/email/send|verify` 미구현 (이슈 #215). - * - * 실제 백엔드가 붙기 전까지 UI 흐름만 확인할 수 있도록 임시로 다음을 흉내낸다: - * - `sendCode(email)` : 항상 성공으로 가정. 발급된 코드는 [STUB_CODE]. 약 500ms 지연. - * - `verifyCode(email, code)` : 입력 코드가 [STUB_CODE] 와 일치하면 성공. - * - * 발신자 등록 stub ([com.afternote.feature.afternote.presentation.receiver.recordsbox.SenderRegistry]) 와 - * 동일하게 백엔드 도입 시 본 클래스를 도메인/데이터 레이어 Repository 로 대체한다. - */ -@Singleton -class IdentityEmailVerificationStub - @Inject - constructor() { - suspend fun sendCode(email: String): Result { - if (!isValidEmail(email)) { - return Result.failure(IllegalArgumentException("invalid email")) - } - delay(SEND_DELAY_MS) - return Result.success(Unit) - } - - suspend fun verifyCode( - email: String, - code: String, - ): Result { - if (!isValidEmail(email)) { - return Result.failure(IllegalArgumentException("invalid email")) - } - delay(VERIFY_DELAY_MS) - return if (code.trim() == STUB_CODE) { - Result.success(Unit) - } else { - Result.failure(IllegalStateException("code mismatch")) - } - } - - private companion object { - const val STUB_CODE = "000000" - const val SEND_DELAY_MS = 500L - const val VERIFY_DELAY_MS = 300L - } - } - -private fun isValidEmail(email: String): Boolean = EMAIL_REGEX.matches(email.trim()) - -private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreen.kt index 5a7a1241e..eb9affa8b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationEmailScreen.kt @@ -38,7 +38,8 @@ import com.afternote.feature.afternote.presentation.receiver.deliveryverificatio * 디자인 3 (입력 전) 과 4 (인증번호 발송 후 안내 메시지 표시) 는 동일 화면. 발송 직후 두 입력 필드 아래 * 강조 안내 텍스트가 나타난다. * - * 백엔드 `receiver-auth/email/` 미구현이라 [IdentityEmailVerificationStub] 으로 시뮬레이션 — stub 코드는 `000000`. + * 인증번호 발송·검증은 실 API(`receiver-auth/email` 계열) — 서버 거절 안내 문구(이메일 미등록 등) 는 + * 스낵바로 노출된다 (#407). */ @Composable fun IdentityVerificationEmailScreen( @@ -66,7 +67,13 @@ fun IdentityVerificationEmailScreen( } } - val errorMessage = uiState.errorMessageRes?.let { stringResource(it) } + val errorMessage = + uiState.error?.let { err -> + when (err) { + is ErrorPayload.Res -> stringResource(err.id) + is ErrorPayload.Text -> err.message + } + } LaunchedEffect(errorMessage) { if (errorMessage != null) { snackbarHostState.showSnackbar(errorMessage) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreen.kt index 056eca6e0..3bb47e7ff 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationIntroScreen.kt @@ -20,8 +20,10 @@ import com.afternote.feature.afternote.presentation.receiver.deliveryverificatio /** * 수신자 본인 확인 안내(design 2) — 진행 인디케이터 1/3 + 안내 문구 + "인증 시작하기" CTA. * - * 발신자 상세의 "열람 신청하기" 진입 시 [IdentityVerificationGate.isVerified] 가 false 인 경우만 노출된다. - * 백엔드 `receiver-auth/email/` 미구현 단계라 [IdentityEmailVerificationStub] 으로 시뮬레이션. + * 발신자 상세의 "열람 신청하기" 진입 시 본인 확인 캐시 + * ([com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository.isVerified]) 가 + * false 인 경우만 노출된다 — 캐시 hit 시 NavGraph 가 마스터 키 단계로 바로 보낸다. + * 다음 단계의 인증번호 발송·검증은 실 API(`receiver-auth/email` 계열) 호출 (#407). */ @Composable fun IdentityVerificationIntroScreen( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationUiState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationUiState.kt index 958401dcf..3436d9214 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationUiState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationUiState.kt @@ -10,7 +10,8 @@ data class IdentityVerificationUiState( val isVerificationSent: Boolean = false, val isSendingCode: Boolean = false, val isVerifying: Boolean = false, - val errorMessageRes: Int? = null, + /** 표시할 에러 — null 이면 에러 없음. 서버 message 는 [ErrorPayload.Text], 클라 fallback 은 [ErrorPayload.Res]. */ + val error: ErrorPayload? = null, val isVerified: Boolean = false, ) { val canSubmit: Boolean diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationViewModel.kt index aec9f192c..2e421cfc4 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/deliveryverification/IdentityVerificationViewModel.kt @@ -1,9 +1,12 @@ package com.afternote.feature.afternote.presentation.receiver.deliveryverification +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.afternote.feature.afternote.domain.error.ReceiverEmailAuthException import com.afternote.feature.afternote.presentation.R import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository +import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,20 +16,27 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** - * 수신자 본인 확인 이메일 인증(designs 3·4) ViewModel — 인증번호 발송 + 코드 검증 (이슈 #215). + * 수신자 본인 확인 이메일 인증(designs 3·4) ViewModel — 인증번호 발송 + 코드 검증 (이슈 #215, #407). * - * 백엔드 미구현 단계라 [IdentityEmailVerificationStub] 으로 시뮬레이션한다. 검증 성공 시 - * [IdentityVerificationRepository.markVerified] 로 캐시를 켜고 [IdentityVerificationEvent.Verified] 발행 → - * UI 가 마스터 키(5) 단계로 이동. + * [ReceiverAuthRepository.sendEmailAuthCode]·[ReceiverAuthRepository.verifyEmailAuthCode] 로 + * 실 API(`receiver-auth/email` 계열) 를 호출한다. 서버 거절(이메일 미등록·인증번호 만료/불일치 등) 의 + * 안내 문구는 [ReceiverEmailAuthException.serverMessage] 를 그대로 노출하고, 인프라 실패는 정적 리소스로 폴백. + * + * 검증 성공 시 [IdentityVerificationRepository.markVerified] 로 캐시를 켜고 isVerified 신호 발행 → + * UI 가 마스터 키(5) 단계로 이동. 응답의 `accessCode`(마스터 키 동일 값) 활용 — 자동 채움/단계 스킵 — + * 은 디자인 결정 대기라 현재는 수신만 하고 사용하지 않는다 (#407). * * 메모리 정책상 ViewModel 은 [androidx.compose.foundation.text.input.TextFieldState] 를 보유하지 않는다. * UI 가 입력값을 [onEmailChange]·[onCodeChange] 로 흘려주고 본 VM 은 String 만 관리. + * + * 입력 trim 은 presentation(본 VM) 책임 — 사용자 실수 공백 제거는 입력 UX 보정이지 비즈니스 규칙이 + * 아니라 domain/data 로 내리지 않는다. state 에는 raw 를 두고(타이핑 중간 상태 보존) 검증·전송 시점에 적용. */ @HiltViewModel class IdentityVerificationViewModel @Inject constructor( - private val stub: IdentityEmailVerificationStub, + private val receiverAuthRepository: ReceiverAuthRepository, private val identityVerificationRepository: IdentityVerificationRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(IdentityVerificationUiState()) @@ -37,22 +47,22 @@ class IdentityVerificationViewModel it.copy( email = value, isEmailFormatValid = EMAIL_REGEX.matches(value.trim()), - errorMessageRes = null, + error = null, ) } } fun onCodeChange(value: String) { - _uiState.update { it.copy(code = value, errorMessageRes = null) } + _uiState.update { it.copy(code = value, error = null) } } fun requestVerificationCode() { val state = _uiState.value if (!state.isEmailFormatValid || state.isSendingCode) return - _uiState.update { it.copy(isSendingCode = true, errorMessageRes = null) } + _uiState.update { it.copy(isSendingCode = true, error = null) } viewModelScope.launch { - stub - .sendCode(state.email) + receiverAuthRepository + .sendEmailAuthCode(state.email.trim()) .onSuccess { _uiState.update { it.copy( @@ -60,11 +70,11 @@ class IdentityVerificationViewModel isVerificationSent = true, ) } - }.onFailure { + }.onFailure { throwable -> _uiState.update { it.copy( isSendingCode = false, - errorMessageRes = R.string.receiver_verify_email_format_invalid, + error = throwable.toErrorPayload(R.string.receiver_verify_code_send_failed), ) } } @@ -74,18 +84,18 @@ class IdentityVerificationViewModel fun verifyAndProceed() { val state = _uiState.value if (!state.canSubmit) return - _uiState.update { it.copy(isVerifying = true, errorMessageRes = null) } + _uiState.update { it.copy(isVerifying = true, error = null) } viewModelScope.launch { - stub - .verifyCode(state.email, state.code) + receiverAuthRepository + .verifyEmailAuthCode(email = state.email.trim(), authCode = state.code.trim()) .onSuccess { identityVerificationRepository.markVerified() _uiState.update { it.copy(isVerifying = false, isVerified = true) } - }.onFailure { + }.onFailure { throwable -> _uiState.update { it.copy( isVerifying = false, - errorMessageRes = R.string.receiver_verify_code_mismatch, + error = throwable.toErrorPayload(R.string.receiver_verify_code_mismatch), ) } } @@ -93,7 +103,7 @@ class IdentityVerificationViewModel } fun consumeError() { - _uiState.update { it.copy(errorMessageRes = null) } + _uiState.update { it.copy(error = null) } } fun onVerifiedConsumed() { @@ -104,3 +114,19 @@ class IdentityVerificationViewModel val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") } } + +/** + * 서버가 내려준 사용자 친화 message 가 있으면 그대로 노출, 없으면(인프라 예외·message 미제공) [fallbackRes] 폴백. + * [DocumentUploadViewModel] 의 serverMessage 우선 노출 패턴과 동일. + * + * 본문 전체가 값을 만드는 식 — `?.` 체인 어디서든 null 이 되면 `?:` 의 [ErrorPayload.Res] 로, + * 끝까지 통과하면 `let` 이 만든 [ErrorPayload.Text] 가 그대로 반환값이 된다 (객체 생성 = 반환). + */ +private fun Throwable.toErrorPayload( + @StringRes fallbackRes: Int, +): ErrorPayload = + (this as? ReceiverEmailAuthException) + ?.serverMessage + ?.takeIf { it.isNotBlank() } + ?.let { ErrorPayload.Text(it) } + ?: ErrorPayload.Res(fallbackRes) diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index 4b8aea518..1b1e53f9d 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -154,6 +154,7 @@ 인증번호가 전송되었습니다.\n수신 된 인증번호를 입력해 주세요. 이메일 형식이 올바르지 않습니다. 인증번호가 일치하지 않습니다. + 인증번호 발송에 실패했습니다. 잠시 후 다시 시도해주세요. 수신자 인증 수신자 본인 확인 diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAuthModels.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAuthModels.kt index aedc5f841..3be5ee369 100644 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAuthModels.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/model/ReceiverAuthModels.kt @@ -7,6 +7,22 @@ data class ReceiverIdentity( val relation: String, ) +/** + * 수신자 본인 확인 이메일 인증 검증(`receiver-auth/email/verify`) 성공 결과. + * + * [ReceiverIdentity] 와 비슷하지만 다른 계약 — `relation` 이 없고 [accessCode] 가 있다 (별개 endpoint). + * + * @property accessCode 이후 `X-Auth-Code` 헤더에 쓰는 UUID 접근 코드. 백엔드가 "이메일 인증 성공 + * = 마스터 키 획득" 으로 설계해 마스터 키와 동일한 값을 돌려준다. 마스터 키 입력 단계의 + * 스킵/자동 채움 여부는 디자인 결정 대기 — 결정 전까지 presentation 은 수신만 하고 사용하지 않는다 (#407). + */ +data class ReceiverEmailAuthResult( + val receiverId: Long, + val receiverName: String, + val senderName: String, + val accessCode: String, +) + data class ReceiverAuthPresignedUrl( val presignedUrl: String, val fileKey: String, 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 de42e2235..80286a251 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 @@ -2,6 +2,7 @@ package com.afternote.feature.receiver.domain.repository import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl +import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult import com.afternote.feature.receiver.domain.model.ReceiverIdentity import com.afternote.feature.receiver.domain.model.SenderMessageInfo @@ -18,6 +19,24 @@ import com.afternote.feature.receiver.domain.model.SenderMessageInfo interface ReceiverAuthRepository { suspend fun verify(authCode: String): Result + /** + * 수신자 본인 확인 — 수신자 레코드에 등록된 [email] 로 6자리 인증번호 발송. + * + * 발신자가 수신자 등록 시 email 을 넣지 않았으면 서버가 거절한다 (RECEIVER_EMAIL_NOT_FOUND). + * 실패는 `ReceiverEmailAuthException` 으로 매핑되어 serverMessage 에 안내 문구가 담긴다. + */ + suspend fun sendEmailAuthCode(email: String): Result + + /** + * 수신자 본인 확인 — [email] 로 발송된 6자리 [authCode] 검증. + * + * 실패(만료/불일치 등)는 `ReceiverEmailAuthException` 으로 매핑된다. + */ + suspend fun verifyEmailAuthCode( + email: String, + authCode: String, + ): Result + suspend fun getPresignedUrl(extension: String): Result suspend fun submitDeliveryVerification(