Skip to content
Merged
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
15 changes: 15 additions & 0 deletions docs/qa/assumptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,18 @@ Compose 테스트로 고정했다.
**분리한 것** — 일기의 기록 날짜는 생성·수정 어느 쪽으로도 서버에 보낼 수 없다(양쪽
스키마에 `date` 없음, 보내도 무시됨을 실측). 날짜 선택이 표시 전용인 문제는 BE 계약
확장이나 기획 결정이 필요해 #1008 로 분리했다.

## #1016 — 일기 본문 이미지 fileKey 변환 (해소, 추측 없음)

한때 «전제가 실측과 어긋난다» 고 적었으나, 서버 복구 후 A/B 실측으로 **이슈 전제가
사실임을 확인했다**. 어긋나 보였던 관측(`diaryId 41541`)은 #958 브랜치에서 나온 것이고,
그 브랜치의 에디터는 이미 `<img src="$fileKey">` 를 넣기 때문에 정상이었다.

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 |

추측으로 진행한 항목이 아니므로 이 문서에서는 근거만 남기고 판단 보류 목록에서 뺀다.
Original file line number Diff line number Diff line change
Expand Up @@ -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>): String =
uploadedImageUrls.fold(this) { content, url -> content.replace(url, url.toUploadedFileKey()) }

/**
* 태그만 있고 보이는 글자가 없으면 비었다고 본다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`).
*
Expand Down Expand Up @@ -215,15 +205,15 @@ class DailyQuestionWriteViewModel
id = state.draftId,
payload =
DailyQuestionUpdatePayload(
content = state.answer.toWireContent(),
content = state.answer.toWireContent(uploadedImageUrls),
isDraft = isDraft,
questionId = questionId,
),
)
} else {
repository.create(
DailyQuestionCreatePayload(
content = state.answer.toWireContent(),
content = state.answer.toWireContent(uploadedImageUrls),
isDraft = isDraft,
questionId = questionId,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -91,15 +92,24 @@ class DiaryWriteViewModel
}
}

/** 이번 작성 중 업로드한 이미지의 원본 URL. 제출 시 fileKey 로 바꿀 대상이다 (#1016). */
private val uploadedImageUrls = mutableSetOf<String>()

/**
* 에디터에서 고른 이미지를 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)
Expand Down Expand Up @@ -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(),
Expand All @@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

본문은 fileKey 로 바뀌는데 이 필드만 전체 URL 그대로 나간다. 같은 요청 안에서 같은 이미지가 두 형태로 실린다.

지금은 서버가 imageUrl 을 버리므로(#1024 실측 — 요청에 실어도 저장 응답이 null) 무해하고, #1124 가 이 줄을 걷어낸다. 그래서 blocking 으로 잡지 않는다.

다만 이 PR 이 #1124 보다 먼저 develop 에 들어가면 그 사이 동안 남는다. 두 건을 붙여 머지하거나, 순서가 벌어질 것 같으면 여기서 함께 걷는 편이 낫다 — 나중에 서버가 이 필드를 받기 시작하면 방금 본문에서 고친 것과 똑같은 호스트 중복이 이 자리에서 재현된다.

Expand All @@ -154,6 +164,8 @@ class DiaryWriteViewModel
}
result
.onSuccess {
// 서버가 permanent 로 옮겼으니 이 URL 들은 더 이상 치환 대상이 아니다.
uploadedImageUrls.clear()
_uiState.update { it.copy(submitState = SubmitState.Succeeded) }
// 임시저장이 하나 늘었으니 툴바 숫자도 따라가야 한다 (#769).
if (isDraft) loadDraftCount()
Expand Down
Original file line number Diff line number Diff line change
@@ -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("<p>본문</p><img src=\"$previewUrl\" />")
viewModel.onMoodSelected(TodayMood.HAPPY)
viewModel.submit()

assertEquals("<p>본문</p><img src=\"mindrecords/staging/13/a.png\" />", sent)
}

@Test
fun `이미 저장된 영구 URL 은 건드리지 않는다`() {
// 서버가 이미 permanent 로 옮긴 이미지다. 키로 바꾸면 다시 옮기려다 실패한다.
var sent: String? = null
val viewModel = viewModel(onCreate = { sent = it.content })
val storedHtml = "<p>이어쓰기</p><img src=\"https://cdn.example.net/mindrecords/permanent/13/old.png\" />"

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<DiaryList> = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null))

override suspend fun create(payload: DiaryCreatePayload): Result<Unit> {
onCreate(payload)
return Result.success(Unit)
}

override suspend fun update(
id: Long,
payload: DiaryUpdatePayload,
): Result<Unit> = error("이 시나리오에서 호출되면 안 됨")

override suspend fun delete(id: Long): Result<Unit> = error("이 시나리오에서 호출되면 안 됨")
}

private object NoDailyQuestionRepository : DailyQuestionRepository {
override suspend fun getList(
date: String?,
draftOnly: Boolean?,
) = Result.success(emptyList<com.afternote.feature.mindrecord.domain.model.DailyQuestion>())

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<Unit> = error("이 시나리오에서 호출되면 안 됨")
}

/** UserRepository 는 표면이 넓다 — 이 시나리오가 실제로 타는 두 호출만 답한다. */
private fun noReceiverUserRepository(): UserRepository =
Proxy.newProxyInstance(
UserRepository::class.java.classLoader,
arrayOf(UserRepository::class.java),
) { _, method, _ ->
when (method.name) {
"getReceiverListFlow" -> flowOf(emptyList<Any>())
"getReceivers" -> emptyList<Any>()
else -> error("Unexpected user repository call: ${method.name}")
}
} as UserRepository
Loading