Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class ReceiverAdvancedAndroidTest {
val masterKeyResults = ArrayDeque<Result<ReceiverIdentity>>()
val receiverRepository =
FakeReceiverRepository.strict().apply {
onSaveAuthCode = { authCodeState.value = it }
onSaveMasterKey = { masterKeyState.value = it }
}
val authRepository =
FakeReceiverAuthRepository.strict().apply {
Expand Down Expand Up @@ -159,9 +159,9 @@ class ReceiverAdvancedAndroidTest {
val normalizedMasterKey = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
val attached = checkNotNull(senderRegistry.findById(sender.id))
assertEquals(listOf(normalizedMasterKey), authRepository.verifiedMasterKeys)
assertEquals(listOf(normalizedMasterKey), receiverRepository.savedAuthCodes)
assertEquals(normalizedMasterKey, receiverRepository.authCodeState.value)
assertEquals(normalizedMasterKey, attached.authCode)
assertEquals(listOf(normalizedMasterKey), receiverRepository.savedMasterKeys)
assertEquals(normalizedMasterKey, receiverRepository.masterKeyState.value)
assertEquals(normalizedMasterKey, attached.masterKey)
assertEquals("이발신", attached.realSenderName)
assertEquals("가족", attached.relation)
assertEquals(1, verifiedTransitions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ fun rememberAfternoteNavActions(
* 수신자 서브그래프에 넘길 그래프 내부 [ReceiverNavActions] 구현체.
*
* 본인 확인 캐시 분기(Intro→MasterKey)는 nested 그래프 진입 후 `DeliveryVerificationFlowViewModel` 에서
* 자동 처리되므로(#220) 본 actions 는 순수 네비게이션만 수행한다. authCode 같은 Repository 사이드이펙트는
* 자동 처리되므로(#220) 본 actions 는 순수 네비게이션만 수행한다. masterKey 같은 Repository 사이드이펙트는
* 각 화면 ViewModel 에서 처리.
*/
@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ import javax.inject.Qualifier
/** Hilt 한정자: 수신 인증 코드 전용 `DataStore<Preferences>` 바인딩 (토큰용 DataStore와 구분). */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ReceiverAuthCodeDataStore
annotation class ReceiverMasterKeyDataStore
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
internal object ReceiverAuthCodeDataStoreModule {
internal object ReceiverMasterKeyDataStoreModule {
@Provides
@Singleton
@ReceiverAuthCodeDataStore
fun provideReceiverAuthCodeDataStore(registry: LocalStoreRegistry): DataStore<Preferences> =
@ReceiverMasterKeyDataStore
fun provideReceiverMasterKeyDataStore(registry: LocalStoreRegistry): DataStore<Preferences> =
// name 은 저장 파일명 계약 (#912 필수 주의). 구 SharedPreferences XML 과 같은 이름이지만
// 자동 마이그레이션하지 않는다는 기존 결정이 있다 — 마이그레이션을 붙이지 말 것.
registry.store(name = "afternote_receiver_auth", scope = StoreScope.SESSION)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ data class ReceivedRecordBoxListDto(
* 지킨다. 도메인으로 옮기는 건 지금 소비하는 것뿐이고, 나머지(`receiverName`·`relation`·
* `recordStatus`·`viewStatus`)는 목록 화면이 이 API 로 옮겨갈 때(#607) 함께 올린다.
*
* `accessCode` 는 **서버가 이 응답에서 부르는 이름**이라 DTO 에서는 그대로 받는다 — 같은 값을
* `GET /users/receivers` 는 `authCode` 로, 헤더는 `X-Auth-Code` 로 부른다(2026-08-30 실측). 앱 안에서는
* 사용자에게 보이는 말인 «마스터 키» 하나로 모으므로, 이름이 갈리는 것은 이 경계까지다 (#1554).
*
* **nullable 은 BE 실코드로 판정한 것만 둔다.** OpenAPI 에 `requiredMode` 표기가 없어 문서만으로는
* 전 필드가 optional 로 보이지만(BE#269), 실제 계약은 이렇다.
*
Expand Down Expand Up @@ -177,7 +181,7 @@ fun DeliveryVerificationDto.toDomain(): DeliveryVerification =
fun ReceivedRecordBoxDto.toDomain(): ReceivedRecordBox =
ReceivedRecordBox(
receiverId = receiverId,
accessCode = accessCode,
masterKey = accessCode,
senderName = senderName,
verificationStatus = verificationStatus?.let(DeliveryVerificationStatus::fromRaw) ?: DeliveryVerificationStatus.UNKNOWN,
requestedAt = requestedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,27 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.afternote.feature.receiver.data.di.ReceiverAuthCodeDataStore
import com.afternote.feature.receiver.data.di.ReceiverMasterKeyDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

private object ReceiverAuthCodeKeys {
val AUTH_CODE = stringPreferencesKey("receiver_auth_code")
private object ReceiverMasterKeyKeys {
val MASTER_KEY = stringPreferencesKey("receiver_auth_code")
}

/**
* 수신 인증 코드 로컬 저장 (DataStore Preferences).
* 도메인 [com.afternote.feature.receiver.domain.repository.ReceiverRepository]는 구현체에서만 이 클래스를 사용합니다.
*/
@Singleton
class ReceiverAuthCodeDataSource
class ReceiverMasterKeyDataSource
@Inject
constructor(
@param:ReceiverAuthCodeDataStore private val dataStore: DataStore<Preferences>,
@param:ReceiverMasterKeyDataStore private val dataStore: DataStore<Preferences>,
) {
val savedCodeFlow: Flow<String?> =
dataStore.data
Expand All @@ -36,16 +36,16 @@ class ReceiverAuthCodeDataSource
throw exception
}
}.map { preferences ->
preferences[ReceiverAuthCodeKeys.AUTH_CODE]?.takeIf { it.isNotBlank() }
preferences[ReceiverMasterKeyKeys.MASTER_KEY]?.takeIf { it.isNotBlank() }
}

suspend fun saveCode(code: String) {
val trimmed = code.trim()
dataStore.edit { preferences ->
if (trimmed.isEmpty()) {
preferences.remove(ReceiverAuthCodeKeys.AUTH_CODE)
preferences.remove(ReceiverMasterKeyKeys.MASTER_KEY)
} else {
preferences[ReceiverAuthCodeKeys.AUTH_CODE] = trimmed
preferences[ReceiverMasterKeyKeys.MASTER_KEY] = trimmed
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
package com.afternote.feature.receiver.data.network

import com.afternote.feature.receiver.data.local.ReceiverAuthCodeDataSource
import com.afternote.feature.receiver.data.local.ReceiverMasterKeyDataSource
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject

/**
* `/receiver-auth/` 경로로 나가는 모든 요청에 [ReceiverAuthCodeDataSource]에 저장된
* `/receiver-auth/` 경로로 나가는 모든 요청에 [ReceiverMasterKeyDataSource]에 저장된
* 인증 코드를 `X-Auth-Code` 헤더로 자동 부착한다. 코드가 없으면 헤더 없이 통과시켜
* 서버가 401을 반환하도록 둔다.
*/
class ReceiverAuthInterceptor
@Inject
constructor(
private val authCodeDataSource: ReceiverAuthCodeDataSource,
private val masterKeyDataSource: ReceiverMasterKeyDataSource,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!request.url.encodedPath.contains(RECEIVER_AUTH_PATH_SEGMENT)) {
return chain.proceed(request)
}
val code = runBlocking { authCodeDataSource.savedCodeFlow.first() }
val code = runBlocking { masterKeyDataSource.savedCodeFlow.first() }
if (code.isNullOrBlank()) {
return chain.proceed(request)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import javax.inject.Singleton
* 그래서 저장 수명을 키 발급처와 같은 프로세스 수명으로 맞춘다. #912 가 도입했던 DataStore
* (SESSION scope) 는 전역 boolean 시절의 「process death 후 유지」가 목적이었으나, 발신자별
* UUID 키에서는 그 유지가 도달 불가능해 디스크 비용만 남는다. 프로세스를 넘는 발신자 식별자
* (예: BE 발급 sender id, 또는 마스터 키 authCode 의 비가역 파생값) 가 생기면 그때 영속을 되살린다.
* (예: BE 발급 sender id, 또는 마스터 키의 비가역 파생값) 가 생기면 그때 영속을 되살린다.
*
* 구 릴리스가 디스크에 남긴 전역 `identity_verified` boolean · `identity_verified_<UUID>` 잔존값은
* 본 구현이 디스크를 아예 읽지 않으므로 구조적으로 어떤 발신자의 관문도 열 수 없다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ class ReceiverAuthRepositoryImpl
constructor(
private val api: ReceiverAuthApiService,
) : ReceiverAuthRepository {
override suspend fun verifyMasterKey(authCode: String): Result<ReceiverIdentity> =
override suspend fun verifyMasterKey(masterKey: String): Result<ReceiverIdentity> =
runCatchingCancellable {
try {
api.verifyMasterKey(ReceiverAuthVerifyRequestDto(authCode)).requireData().toDomain()
api.verifyMasterKey(ReceiverAuthVerifyRequestDto(masterKey)).requireData().toDomain()
} catch (e: ApiException) {
throw e.toReceiverServerFailure()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import androidx.paging.PagingData
import com.afternote.core.common.reporting.ErrorReporter
import com.afternote.core.common.result.runCatchingCancellable
import com.afternote.core.network.model.requireData
import com.afternote.feature.receiver.data.local.ReceiverAuthCodeDataSource
import com.afternote.feature.receiver.data.local.ReceiverMasterKeyDataSource
import com.afternote.feature.receiver.data.mapper.response.toDomain
import com.afternote.feature.receiver.data.mapper.toDomainResult
import com.afternote.feature.receiver.data.paging.ReceiverAfternotePagingSource
Expand All @@ -26,25 +26,25 @@ import javax.inject.Singleton
private const val PAGE_SIZE = 50

/**
* 인증 코드는 [ReceiverAuthCodeDataSource]에서 읽고·쓰고·지우며, REST 요청에는 ReceiverAuthInterceptor가
* 인증 코드는 [ReceiverMasterKeyDataSource]에서 읽고·쓰고·지우며, REST 요청에는 ReceiverAuthInterceptor가
* `X-Auth-Code` 헤더를 자동 부착한다. 아직 별도 기능 repository로 이관되지 않은 export 항목은
* 폴백 값을 반환한다.
*/
@Singleton
class ReceiverRepositoryImpl
@Inject
constructor(
private val authCodeDataSource: ReceiverAuthCodeDataSource,
private val masterKeyDataSource: ReceiverMasterKeyDataSource,
private val api: ReceiverAfternoteApiService,
private val receiverAuthRepository: ReceiverAuthRepository,
private val errorReporter: ErrorReporter,
) : ReceiverRepository {
override val authCodeFlow: Flow<String?> = authCodeDataSource.savedCodeFlow
override val masterKeyFlow: Flow<String?> = masterKeyDataSource.savedCodeFlow

override suspend fun currentAuthCode(): String? = authCodeFlow.first()
override suspend fun currentMasterKey(): String? = masterKeyFlow.first()

override suspend fun saveAuthCode(code: String) {
authCodeDataSource.saveCode(code)
override suspend fun saveMasterKey(code: String) {
masterKeyDataSource.saveCode(code)
}

override fun getPagedReceivedAfternotes(): Flow<PagingData<AfterNoteListItem>> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class ReceivedRecordBoxContractTest {

val box = boxes.single().toDomain()
assertEquals(4L, box.receiverId)
assertEquals("59c04a15-1f4a-4b2e-9a0c-2f4e8d7b6c31", box.accessCode)
assertEquals("59c04a15-1f4a-4b2e-9a0c-2f4e8d7b6c31", box.masterKey)
assertEquals("김혜성", box.senderName)
assertEquals(DeliveryVerificationStatus.APPROVED, box.verificationStatus)
assertEquals("2026-06-21T03:07:26", box.requestedAt)
Expand Down Expand Up @@ -110,7 +110,7 @@ class ReceivedRecordBoxContractTest {
.map { it.toDomain() }

assertEquals(2, boxes.size)
val mine = boxes.first { it.accessCode == "bbbbbbbb-0000-4000-8000-000000000002" }
val mine = boxes.first { it.masterKey == "bbbbbbbb-0000-4000-8000-000000000002" }
assertEquals("박경민", mine.senderName)
assertEquals("2026-08-01T09:00:00", mine.approvedAt)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import org.junit.Test
* → `toDomain()` 을 통과시킨다 — Json 설정은 `NetworkModule.provideJson` 과 동일
* (ignoreUnknownKeys + coerceInputValues).
*
* verify 성공 응답을 **accessCode 동봉·제거 두 형태 모두** 가드하는 이유: 서버가 마스터 키와 동일한
* `accessCode` 를 응답에서 제거할 예정이라([ReceiverEmailAuthVerifyDto] 참고), 제거 배포가 언제 나가든
* verify 성공 응답을 **masterKey 동봉·제거 두 형태 모두** 가드하는 이유: 서버가 마스터 키와 동일한
* `masterKey` 를 응답에서 제거할 예정이라([ReceiverEmailAuthVerifyDto] 참고), 제거 배포가 언제 나가든
* 파싱이 깨지지 않아야 한다 (#454).
*
* 에러 응답(404 code 1901 / 400 code 1902·1903)은 HTTP 4xx 라 Retrofit CallAdapter가 가로채는 경로 —
Expand All @@ -32,7 +32,7 @@ class ReceiverEmailAuthContractTest {
}

@Test
fun `email-verify 성공 응답(accessCode 동봉) - 잔여 키를 무시하고 도메인 모델까지 도달`() {
fun `email-verify 성공 응답(masterKey 동봉) - 잔여 키를 무시하고 도메인 모델까지 도달`() {
val payload =
"""{"status":200,"code":200,"message":"성공","data":{"receiverId":3,"receiverName":"큐에이수신자","senderName":"큐에이발신자","accessCode":"123e4567-e89b-12d3-a456-426614174000"}}"""

Expand All @@ -44,7 +44,7 @@ class ReceiverEmailAuthContractTest {
}

@Test
fun `email-verify 성공 응답(accessCode 제거 후) - 디코드 성공`() {
fun `email-verify 성공 응답(masterKey 제거 후) - 디코드 성공`() {
val payload =
"""{"status":200,"code":200,"message":"성공","data":{"receiverId":3,"receiverName":"큐에이수신자","senderName":"큐에이발신자"}}"""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,18 @@ enum class DeliveryVerificationStatus {
*
* 서버는 **마스터 키가 아니라 그 키가 가리키는 수신자의 이메일**을 권한 근거로 삼는다 — 같은 이메일로
* 등록된 기록함이 여러 발신자 것이어도 한 번에 내려온다. 그래서 특정 발신자의 칸을 고르려면
* 화면이 들고 있는 마스터 키와 [accessCode] 를 맞춰야 한다.
* 화면이 들고 있는 마스터 키와 [masterKey] 를 맞춰야 한다.
*
* 지금 이 모델을 읽는 곳은 발신자 상세의 승인일 표시 하나다(#612). 목록 화면이 이 API 로 옮겨갈 때
* (#607) `receiverName`·`relation`·`recordStatus`·`viewStatus` 를 여기에 더한다 — 서버는 이미 준다.
*
* @property accessCode 이 칸을 여는 접근 코드. 발신자가 수신자에게 건넨 마스터 키와 같은 값이다.
* @property masterKey 이 칸을 여는 접근 코드. 발신자가 수신자에게 건넨 마스터 키와 같은 값이다.
* @property requestedAt 열람 신청일. 서버 ISO-8601 raw — 표시 형식 변환은 presentation 책임.
* @property approvedAt 열람 승인일. **서버는 승인 상태일 때만 채운다** — 신청 전·심사 중·반려는 null.
*/
data class ReceivedRecordBox(
val receiverId: Long,
val accessCode: String,
val masterKey: String,
val senderName: String,
val verificationStatus: DeliveryVerificationStatus,
val requestedAt: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@ import com.afternote.feature.receiver.domain.model.SenderMessageInfo
* 기존 `ReceiverRepository` 는 인증된 수신자가 사용하는 일반 조회 (after-notes 등) 책임.
* 본 Repository 는 인증 자체 + 서류 제출/검증 + sender 메시지 조회를 담당한다.
*
* 인증 코드 저장은 `ReceiverRepository.saveAuthCode` 를 사용하며,
* 인증 코드 저장은 `ReceiverRepository.saveMasterKey` 를 사용하며,
* 저장된 코드는 `ReceiverAuthInterceptor` 가 `X-Auth-Code` 헤더로 자동 부착한다.
* (둘 다 feature:afternote 쪽이라 이 모듈에서 KDoc 링크로 참조할 수 없다 — 의존 방향이 반대.)
*/
interface ReceiverAuthRepository {
/**
* 발신자가 발급한 마스터 키([authCode])로 수신자 신원을 확인한다.
* 발신자가 발급한 마스터 키([masterKey])로 수신자 신원을 확인한다.
*
* 성공하면 이 키가 이후 요청의 `X-Auth-Code` 헤더로 재사용된다 (사실상 수신자 세션).
* 서버 거절은 `ReceiverFailure.UserRejection` 또는 `ReceiverFailure.UnexpectedServerFailure` 로 번역된다.
*/
suspend fun verifyMasterKey(authCode: String): Result<ReceiverIdentity>
suspend fun verifyMasterKey(masterKey: String): Result<ReceiverIdentity>

/**
* 수신자 본인 확인 — 수신자 레코드에 등록된 [email] 로 6자리 인증번호 발송.
Expand Down Expand Up @@ -73,7 +73,7 @@ interface ReceiverAuthRepository {
*
* 서버의 권한 근거가 키가 아니라 **그 키가 가리키는 수신자의 이메일**이라, 같은 이메일로 등록된
* 다른 발신자의 칸까지 함께 온다. 특정 발신자의 칸이 필요하면 호출부가
* [ReceivedRecordBox.accessCode] 로 골라야 한다.
* [ReceivedRecordBox.masterKey] 로 골라야 한다.
*/
suspend fun getReceivedRecordBoxes(): Result<List<ReceivedRecordBox>>
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ package com.afternote.feature.receiver.domain.repository
* 호출한다 (도메인 레이어가 안드로이드 API 에 의존하지 않도록).
*
* 업로드 + presigned URL 요청은 모두 `receiver-auth/` 인증 컨텍스트가 필요하므로, 호출 시점에
* [ReceiverRepository.saveAuthCode] 로 해당 발신자의 authCode 가 세팅돼 있어야 한다 (인터셉터가 헤더 부착).
* [ReceiverRepository.saveMasterKey] 로 해당 발신자의 masterKey 가 세팅돼 있어야 한다 (인터셉터가 헤더 부착).
*/
interface ReceiverDeliveryDocumentUploadRepository {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import kotlinx.coroutines.flow.Flow
*/
interface ReceiverRepository {
/** 저장된 인증 코드 스트림(없거나 공백만 있으면 null 방출). */
val authCodeFlow: Flow<String?>
val masterKeyFlow: Flow<String?>

/** 단발 조회; UI 스레드에서는 코루틴 안에서 호출하세요. */
suspend fun currentAuthCode(): String?
suspend fun currentMasterKey(): String?

/** 사용자가 입력·검증한 코드를 저장합니다. 제거는 로그아웃 일괄 정리(SESSION scope, #912)가 담당합니다. */
suspend fun saveAuthCode(code: String)
suspend fun saveMasterKey(code: String)

/**
* 수신 애프터노트 스트림. 서버는 페이지네이션 미지원이므로 단일 페이지로 받지만,
Expand Down
Loading
Loading