From f66b3589f357fee5b596079dc82f5d538ca6f392 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 25 Aug 2026 08:18:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(mindrecord):=20=EC=9D=BC=EA=B8=B0=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=8F=84=20fil?= =?UTF-8?q?eKey=20=EB=A1=9C=20=EB=B0=94=EA=BF=94=20=EB=B3=B4=EB=82=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버는 본문 img src 에서 fileKey 를 받아 staging→permanent 로 옮기고 전체 URL 로 다시 쓴다. 전체 URL 을 그대로 보내면 그 앞에 호스트를 한 번 더 붙여 403 이 된다. 데일리질문에는 이 변환이 있었는데(#549) 일기 경로에만 없었다. 두 화면이 같은 규칙을 쓰도록 치환을 HtmlText 의 toWireContent(uploadedImageUrls) 로 올리고 데일리질문 쪽 사본을 지운다. 이미 저장된 영구 URL 은 건드리지 않는다 — 이번 작성에서 받은 URL 만 정확히 치환한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../mindrecord/presentation/util/HtmlText.kt | 12 ++ .../viewmodel/DailyQuestionWriteViewModel.kt | 16 +- .../viewmodel/DiaryWriteViewModel.kt | 19 ++- .../viewmodel/DiaryWriteImageKeyTest.kt | 141 ++++++++++++++++++ 4 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt 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 7d95b43c4f..e0ad8eb6c9 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 @@ -145,16 +145,6 @@ class DailyQuestionWriteViewModel .onSuccess { url -> uploadedImageUrls += url } .getOrNull() - /** - * 제출 직전, **이번 작성 중 업로드한** 이미지의 `src` 만 fileKey 로 바꾼다. - * - * 이미 저장돼 본문에 들어 있는 영구 URL 은 건드리지 않는다 — 서버가 그대로 통과시키고 - * (실측 확인), 키로 바꾸면 이미 옮겨진 파일을 다시 옮기려다 실패한다. 그래서 경로 - * 패턴으로 훑지 않고 이번에 받은 URL 만 정확히 치환한다. - */ - private fun String.toWireContent(): String = - uploadedImageUrls.fold(this) { content, url -> content.replace(url, url.toUploadedFileKey()) } - /** * 저장(`isDraft=false`) / 임시저장(`isDraft=true`). * @@ -194,7 +184,7 @@ class DailyQuestionWriteViewModel id = state.draftId, payload = DailyQuestionUpdatePayload( - content = state.answer.toWireContent(), + content = state.answer.toWireContent(uploadedImageUrls), isDraft = isDraft, questionId = questionId, ), @@ -202,7 +192,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 d6293dac3a..2a6d547648 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 @@ -88,14 +89,22 @@ 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? = photoUploadRepository .upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY) .onSuccess { url -> + uploadedImageUrls += url _uiState.update { if (it.imageUrl == null) it.copy(imageUrl = url) else it } }.getOrNull() @@ -113,7 +122,7 @@ class DiaryWriteViewModel payload = DiaryUpdatePayload( title = state.title, - content = state.content, + content = state.content.toWireContent(uploadedImageUrls), isDraft = isDraft, todayMood = mood, date = state.date.toString(), @@ -124,7 +133,7 @@ class DiaryWriteViewModel repository.create( DiaryCreatePayload( title = state.title, - content = state.content, + content = state.content.toWireContent(uploadedImageUrls), isDraft = isDraft, todayMood = mood, imageUrl = state.imageUrl, @@ -134,6 +143,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 From d49aa5eea550730a939b9b6da09d08a8f86b7aa0 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 25 Aug 2026 08:22:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(qa):=20#1016=20=EC=A0=84=EC=A0=9C?= =?UTF-8?q?=EC=99=80=20=EC=96=B4=EA=B8=8B=EB=82=9C=20=EC=8B=A4=EC=B8=A1?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- docs/qa/assumptions.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/qa/assumptions.md b/docs/qa/assumptions.md index c0e0367ea7..056d4dce36 100644 --- a/docs/qa/assumptions.md +++ b/docs/qa/assumptions.md @@ -258,3 +258,14 @@ Compose 테스트로 고정했다. **분리한 것** — 일기의 기록 날짜는 생성·수정 어느 쪽으로도 서버에 보낼 수 없다(양쪽 스키마에 `date` 없음, 보내도 무시됨을 실측). 날짜 선택이 표시 전용인 문제는 BE 계약 확장이나 기획 결정이 필요해 #1008 로 분리했다. + +## #1016 — 일기 본문 이미지 fileKey 변환 (검증 보류) + +**추측으로 진행한 항목이 아니라, 전제가 실측과 어긋나 보류한 항목이다.** + +- 이슈 전제: 일기 본문에 전체 URL 을 보내면 서버가 호스트를 한 번 더 붙여 403. +- 반대 실측(2026-08-24, #958 브랜치): 전체 staging URL 을 보냈는데 서버가 permanent 로 + 옮기고 URL 을 다시 써서 정상 저장했다 (`diaryId 41541`). +- dev 서버 점검 시간대(03~12 KST 추정)라 (a) 전체 URL (b) fileKey 두 형태 비교 실측을 + 못 했다. 서버 복구 후 비교하고 PR 을 올린다. +- 구현·단위 가드는 이 브랜치에 있다 (수정 전 실패 확인 완료).