diff --git a/docs/qa/assumptions.md b/docs/qa/assumptions.md index c0e0367ea7..d189213c86 100644 --- a/docs/qa/assumptions.md +++ b/docs/qa/assumptions.md @@ -258,3 +258,18 @@ Compose 테스트로 고정했다. **분리한 것** — 일기의 기록 날짜는 생성·수정 어느 쪽으로도 서버에 보낼 수 없다(양쪽 스키마에 `date` 없음, 보내도 무시됨을 실측). 날짜 선택이 표시 전용인 문제는 BE 계약 확장이나 기획 결정이 필요해 #1008 로 분리했다. + +## #1016 — 일기 본문 이미지 fileKey 변환 (해소, 추측 없음) + +한때 «전제가 실측과 어긋난다» 고 적었으나, 서버 복구 후 A/B 실측으로 **이슈 전제가 +사실임을 확인했다**. 어긋나 보였던 관측(`diaryId 41541`)은 #958 브랜치에서 나온 것이고, +그 브랜치의 에디터는 이미 `` 를 넣기 때문에 정상이었다. + +2026-08-25 실측 — 같은 일기 생성 API 에 두 형태를 보냈다. + +| 보낸 값 | 저장된 값 | CDN | +|---|---|---| +| 전체 URL (`https://cdn/...staging/...png`) | `https://cdn/https://cdn/...permanent/...png` | 403 | +| fileKey (`mindrecords/staging/...png`) | `https://cdn/...permanent/...png` | 200 | + +추측으로 진행한 항목이 아니므로 이 문서에서는 근거만 남기고 판단 보류 목록에서 뺀다. diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt index 0549131718..267c3cf8b5 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt @@ -69,6 +69,18 @@ fun String.toUploadedFileKey(): String = // substringAfter('/') 가 첫 경로 세그먼트를 잘라먹는다 — 조용히 틀린 키가 나간다. if (contains("://")) substringAfter("://").substringAfter('/') else this +/** + * 제출 직전, **이번 작성 중 업로드한** 이미지의 `src` 만 fileKey 로 바꾼다 (#549·#1016). + * + * 이미 저장돼 본문에 들어 있는 영구 URL 은 건드리지 않는다 — 서버가 그대로 통과시키고, + * 키로 바꾸면 이미 옮겨진 파일을 다시 옮기려다 실패한다. 그래서 경로 패턴으로 훑지 않고 + * 이번에 받은 URL 만 정확히 치환한다. + * + * 데일리질문과 일기가 같은 규칙을 쓴다 — 한쪽에만 있어서 일기 본문 이미지가 깨졌다 (#1016). + */ +fun String.toWireContent(uploadedImageUrls: Set): String = + uploadedImageUrls.fold(this) { content, url -> content.replace(url, url.toUploadedFileKey()) } + /** * 태그만 있고 보이는 글자가 없으면 비었다고 본다. * diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index 4bd97cba7d..366d9f060a 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -10,7 +10,7 @@ import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.util.isHtmlBlank -import com.afternote.feature.mindrecord.presentation.util.toUploadedFileKey +import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -160,16 +160,6 @@ class DailyQuestionWriteViewModel _uiState.update { it.copy(imageUploadError = null) } } - /** - * 제출 직전, **이번 작성 중 업로드한** 이미지의 `src` 만 fileKey 로 바꾼다. - * - * 이미 저장돼 본문에 들어 있는 영구 URL 은 건드리지 않는다 — 서버가 그대로 통과시키고 - * (실측 확인), 키로 바꾸면 이미 옮겨진 파일을 다시 옮기려다 실패한다. 그래서 경로 - * 패턴으로 훑지 않고 이번에 받은 URL 만 정확히 치환한다. - */ - private fun String.toWireContent(): String = - uploadedImageUrls.fold(this) { content, url -> content.replace(url, url.toUploadedFileKey()) } - /** * 저장(`isDraft=false`) / 임시저장(`isDraft=true`). * @@ -215,7 +205,7 @@ class DailyQuestionWriteViewModel id = state.draftId, payload = DailyQuestionUpdatePayload( - content = state.answer.toWireContent(), + content = state.answer.toWireContent(uploadedImageUrls), isDraft = isDraft, questionId = questionId, ), @@ -223,7 +213,7 @@ class DailyQuestionWriteViewModel } else { repository.create( DailyQuestionCreatePayload( - content = state.answer.toWireContent(), + content = state.answer.toWireContent(uploadedImageUrls), isDraft = isDraft, questionId = questionId, ), diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index acd17dcf77..f295e34c58 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -14,6 +14,7 @@ import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute +import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -91,15 +92,24 @@ class DiaryWriteViewModel } } + /** 이번 작성 중 업로드한 이미지의 원본 URL. 제출 시 fileKey 로 바꿀 대상이다 (#1016). */ + private val uploadedImageUrls = mutableSetOf() + /** - * 에디터에서 고른 이미지를 presigned URL 로 업로드하고 영구 URL 을 반환한다 (실패 시 null). - * 첫 업로드 이미지는 등록 payload 의 `imageUrl` (목록 카드 썸네일) 로도 쓴다. + * 에디터에서 고른 이미지를 presigned URL 로 업로드하고 **미리보기에 쓸 URL** 을 반환한다 + * (실패 시 null). 첫 업로드 이미지는 등록 payload 의 `imageUrl` (목록 카드 썸네일) 로도 쓴다. + * + * 저장 시 본문에 나가는 값은 이 URL 이 아니다 — 서버는 `img src` 에서 fileKey 를 기대하고 + * 전체 URL 을 받으면 호스트를 한 번 더 붙여 403 이 된다. 그래서 받은 URL 을 기억해 두고 + * 제출 직전에 키로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다 (#1016). */ suspend fun uploadImage(uriString: String): String? { _uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) } return photoUploadRepository .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) .onSuccess { url -> + // 제출 직전 fileKey 로 바꿀 대상이다 (#1016). + uploadedImageUrls += url _uiState.update { val withUrl = if (it.imageUrl == null) it.copy(imageUrl = url) else it withUrl.copy(isUploadingImage = false) @@ -133,7 +143,7 @@ class DiaryWriteViewModel payload = DiaryUpdatePayload( title = state.title, - content = state.content, + content = state.content.toWireContent(uploadedImageUrls), isDraft = isDraft, todayMood = mood, date = state.date.toString(), @@ -144,7 +154,7 @@ class DiaryWriteViewModel repository.create( DiaryCreatePayload( title = state.title, - content = state.content, + content = state.content.toWireContent(uploadedImageUrls), isDraft = isDraft, todayMood = mood, imageUrl = state.imageUrl, @@ -154,6 +164,8 @@ class DiaryWriteViewModel } result .onSuccess { + // 서버가 permanent 로 옮겼으니 이 URL 들은 더 이상 치환 대상이 아니다. + uploadedImageUrls.clear() _uiState.update { it.copy(submitState = SubmitState.Succeeded) } // 임시저장이 하나 늘었으니 툴바 숫자도 따라가야 한다 (#769). if (isDraft) loadDraftCount() diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt new file mode 100644 index 0000000000..bd54ec1867 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt @@ -0,0 +1,141 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy + +/** + * 일기 본문 이미지가 서버 계약대로 fileKey 로 나가는지 (#1016). + * + * 서버는 본문 `img src` 에서 fileKey 를 받아 staging→permanent 로 옮기고 전체 URL 로 다시 + * 쓴다. 전체 URL 을 그대로 보내면 그 앞에 호스트를 한 번 더 붙여 403 이 된다. 데일리질문에는 + * 이 변환이 있었는데(#549) 일기 경로에만 없어서, 이미지 업로드가 고쳐지는 순간(#953) + * 일기 본문이 깨진 이미지가 될 자리였다. + */ +class DiaryWriteImageKeyTest { + // submit() 이 viewModelScope(= Main)에서 돈다 — 테스트 디스패처를 깔아야 실제로 실행된다. + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `등록 시 방금 업로드한 이미지의 src 는 fileKey 로 나간다`() { + var sent: String? = null + val viewModel = viewModel(onCreate = { sent = it.content }) + + val previewUrl = runBlocking { viewModel.uploadImage("content://picked") } + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + viewModel.onMoodSelected(TodayMood.HAPPY) + viewModel.submit() + + assertEquals("

본문

", sent) + } + + @Test + fun `이미 저장된 영구 URL 은 건드리지 않는다`() { + // 서버가 이미 permanent 로 옮긴 이미지다. 키로 바꾸면 다시 옮기려다 실패한다. + var sent: String? = null + val viewModel = viewModel(onCreate = { sent = it.content }) + val storedHtml = "

이어쓰기

" + + viewModel.onTitleChanged("제목") + viewModel.onContentChanged(storedHtml) + viewModel.onMoodSelected(TodayMood.SOSO) + viewModel.submit() + + assertEquals(storedHtml, sent) + } + + private fun viewModel(onCreate: (DiaryCreatePayload) -> Unit): DiaryWriteViewModel = + DiaryWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = RecordingDiaryRepository(onCreate), + photoUploadRepository = + PhotoUploadRepository { _, _ -> + Result.success(UPLOADED_URL) + }, + userRepository = noReceiverUserRepository(), + draftLoader = MindRecordDraftLoader(RecordingDiaryRepository {}, NoDailyQuestionRepository), + ) + + private companion object { + const val UPLOADED_URL = "https://cdn.example.net/mindrecords/staging/13/a.png" + } +} + +private class RecordingDiaryRepository( + private val onCreate: (DiaryCreatePayload) -> Unit, +) : DiaryRepository { + override suspend fun getList( + yearMonth: String, + draftOnly: Boolean?, + ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + + override suspend fun create(payload: DiaryCreatePayload): Result { + onCreate(payload) + return Result.success(Unit) + } + + override suspend fun update( + id: Long, + payload: DiaryUpdatePayload, + ): Result = error("이 시나리오에서 호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("이 시나리오에서 호출되면 안 됨") +} + +private object NoDailyQuestionRepository : DailyQuestionRepository { + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ) = Result.success(emptyList()) + + override suspend fun getToday() = error("이 시나리오에서 호출되면 안 됨") + + override suspend fun create(payload: com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload) = + error("이 시나리오에서 호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload, + ) = error("이 시나리오에서 호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("이 시나리오에서 호출되면 안 됨") +} + +/** UserRepository 는 표면이 넓다 — 이 시나리오가 실제로 타는 두 호출만 답한다. */ +private fun noReceiverUserRepository(): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceiverListFlow" -> flowOf(emptyList()) + "getReceivers" -> emptyList() + else -> error("Unexpected user repository call: ${method.name}") + } + } as UserRepository