Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,6 @@ private class CompletionDailyQuestionRepository(
title = today.content,
content = payload.content,
createdAt = "2026-08-22",
imageUrl = payload.imageUrl,
isDraft = payload.isDraft,
)
today = today.copy(isAnswered = !payload.isDraft, isDraft = payload.isDraft)
Expand All @@ -817,7 +816,6 @@ private class CompletionDailyQuestionRepository(
if (answer.dailyQuestionId == id) {
answer.copy(
content = payload.content ?: answer.content,
imageUrl = payload.imageUrl ?: answer.imageUrl,
isDraft = payload.isDraft ?: answer.isDraft,
)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,26 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames

/**
* `POST /daily-questions` 요청 (Swagger `DailyQuestionAnswerRequest`).
*
* **`imageUrl` 필드는 없다.** 요청·응답 계약 어디에도 없고, 보내도 서버가 무시한다
* (2026-08-23 실측). 본문 이미지는 [content] HTML 의 `img` 태그로 담긴다 (#549).
*/
@Serializable
data class DailyQuestionCreateRequestDto(
@SerialName("content") val content: String,
@SerialName("isDraft") val isDraft: Boolean,
@SerialName("questionId") val questionId: Long,
// 응답/요청 계약에 없는 필드라 타입은 nullable 로 두되 기본값은 두지 않는다.
// 매퍼가 항상 명시적으로 넘기므로 생략이 조용히 통과할 자리가 없다 (#789).
@SerialName("imageUrl") val imageUrl: String?,
)

/**
* `PATCH /daily-questions/{id}` 요청. 선택 필드라 nullable 은 유지하되 기본값은 두지 않는다 —
* 모든 생성 인자를 명시해야 "보내지 않음" 과 "명시적 null" 이 호출부 코드에서 구분된다 (#789).
*/
/** `PATCH /daily-questions/{id}` 요청. [DailyQuestionCreateRequestDto] 와 같은 이유로 `imageUrl` 이 없다. */
@Serializable
data class DailyQuestionUpdateRequestDto(
@SerialName("content") val content: String?,
@SerialName("isDraft") val isDraft: Boolean?,
@SerialName("date") val date: String?,
@SerialName("questionId") val questionId: Long?,
@SerialName("imageUrl") val imageUrl: String?,
@SerialName("content") val content: String? = null,
@SerialName("isDraft") val isDraft: Boolean? = null,
@SerialName("date") val date: String? = null,
@SerialName("questionId") val questionId: Long? = null,
)

@Serializable
Expand All @@ -41,9 +40,9 @@ data class DailyQuestionListItemDto(
@SerialName("title") val title: String,
@SerialName("content") val content: String,
@SerialName("createdAt") val createdAt: String,
// Swagger `DailyQuestionListResponse` 응답 계약에 없는 필드 — 서버가 주기 시작하면 쓰이고,
// 아니면 계속 null. 계약에 없으므로 여기서만 기본값을 유지한다 (#789).
@SerialName("imageUrl") val imageUrl: String? = null,
// `imageUrl` 은 응답 계약에 없고 실서버도 키 자체를 내려주지 않는다 — 항상 null 인
// 필드를 읽느라 목록 카드 썸네일이 영영 뜨지 않았다. 썸네일은 표시 단계에서
// `content` 의 첫 img 로 뽑는다 (#549).
// Swagger `DailyQuestionListResponse` 및 실서버 응답 모두 와이어 키는 `isDraft`.
// 과거 QA logcat 에서 `draft` 로 관측된 적이 있어 대체 키도 함께 허용한다.
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ fun DailyQuestionListItemDto.toDomain(): DailyQuestion =
title = title,
content = content,
createdAt = createdAt,
imageUrl = imageUrl,
isDraft = isDraft,
)

Expand All @@ -33,7 +32,6 @@ fun DailyQuestionCreatePayload.toRequest(): DailyQuestionCreateRequestDto =
content = content,
isDraft = isDraft,
questionId = questionId,
imageUrl = imageUrl,
)

fun DailyQuestionUpdatePayload.toRequest(): DailyQuestionUpdateRequestDto =
Expand All @@ -42,5 +40,4 @@ fun DailyQuestionUpdatePayload.toRequest(): DailyQuestionUpdateRequestDto =
isDraft = isDraft,
date = date,
questionId = questionId,
imageUrl = imageUrl,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.afternote.feature.mindrecord.data.dto

import com.afternote.core.network.model.BaseResponse
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test

/**
* 데일리질문 이미지 계약 가드 (#549).
*
* Swagger 전체에서 이미지 필드는 `UserResponse.profileImageUrl` 과
* `MusicSearchItemDto.albumImageUrl` 뿐이고, 데일리질문의 요청·응답 스키마 어디에도
* `imageUrl` 이 없다. 대신 `content` 설명이 허용 태그로 `img[src|alt|width|height|style]`
* 을 명시한다 — 본문 이미지는 `content` HTML 에 담긴다.
*
* 실서버 왕복 실측(2026-08-23)에서도 `imageUrl` 은 무시되고 `<img src>` 는 그대로 살아남았다.
*/
class DailyQuestionImageContractTest {
private val json =
Json {
ignoreUnknownKeys = true
coerceInputValues = true
}

@Test
fun `등록 요청에 imageUrl 을 싣지 않는다`() {
// 계약에 없는 필드라 보내 봐야 서버가 버린다. 필드를 두면 "저장됐다" 는 착각만 남는다.
val encoded =
json.encodeToString(
DailyQuestionCreateRequestDto.serializer(),
DailyQuestionCreateRequestDto(content = "<p>본문</p>", isDraft = false, questionId = 1L),
)

assertFalse(encoded.contains("imageUrl"))
}

@Test
fun `수정 요청에도 imageUrl 을 싣지 않는다`() {
val encoded =
json.encodeToString(
DailyQuestionUpdateRequestDto.serializer(),
DailyQuestionUpdateRequestDto(content = "<p>본문</p>", isDraft = true),
)

assertFalse(encoded.contains("imageUrl"))
}

@Test
fun `본문의 img 태그는 응답에서 그대로 돌아온다`() {
// 실서버 왕복 실측 형태 — 이미지가 남는 유일한 경로다.
val body =
"""
{ "status": 200, "code": 200,
"data": [{ "userDailyQuestionId": 26, "title": "질문", "isDraft": false,
"createdAt": "2026.08.23 일",
"content": "<p>사진과 함께</p><p><img src=\"https://cdn/probe.png\" alt=\"사진\" /></p>" }] }
""".trimIndent()

val item =
json
.decodeFromString(
BaseResponse.serializer(ListSerializer(DailyQuestionListItemDto.serializer())),
body,
).data!!
.single()

assertEquals(
"<p>사진과 함께</p><p><img src=\"https://cdn/probe.png\" alt=\"사진\" /></p>",
item.content,
)
}

@Test
fun `응답에 없는 imageUrl 키를 읽지 않아도 파싱은 멀쩡하다`() {
// 실서버 응답에는 이 키 자체가 없다 — 읽어 봐야 항상 null 이라 썸네일이 영영 안 떴다.
val body =
"""
{ "status": 200, "code": 200,
"data": [{ "userDailyQuestionId": 1, "title": "질문", "content": "<p>본문</p>",
"createdAt": "2026.08.23 일", "isDraft": false }] }
""".trimIndent()

val item =
json
.decodeFromString(
BaseResponse.serializer(ListSerializer(DailyQuestionListItemDto.serializer())),
body,
).data!!
.single()

assertEquals(1L, item.dailyQuestionId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ class MindRecordDtoContractTest {

val decoded = json.decodeFromString(BaseResponse.serializer(ListSerializerOf), body)

assertNull(decoded.data!!.single().imageUrl)
// imageUrl 은 계약에 없어 DTO 에서 걷었다 — 썸네일은 본문 img 태그에서 뽑는다 (#549).
assertEquals("t", decoded.data!!.single().title)

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.

[nit] 이 테스트는 이제 아무것도 지키지 않는다.

imageUrl 이 DTO 에서 사라졌으니 «imageUrl 키가 없는 응답» 은 그냥 평범한 응답이고, 남은 assertEquals("t", title) 은 직렬화가 동작하는지만 본다. 바로 위 주석 «Swagger DailyQuestionListResponse 에 없는 필드 — 기본값을 유지하는 유일한 자리» 도 필드가 없어진 지금은 사실이 아니다.

같은 성질은 이 PR 이 새로 만든 DailyQuestionImageContractTest.kt:76 「응답에 없는 imageUrl 키를 읽지 않아도 파싱은 멀쩡하다」 가 이름 그대로 지키고 있다. 여기서는 케이스째 지우는 편이 낫다 — 남겨 두면 다음 사람이 «imageUrl 에 아직 기본값 규칙이 걸려 있구나» 로 읽는다.

}

// ---------- 수신자 목록 ----------
Expand Down Expand Up @@ -140,7 +141,6 @@ class MindRecordDtoContractTest {
isDraft = false,
date = null,
questionId = null,
imageUrl = null,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ data class DailyQuestion(
val title: String,
val content: String,
val createdAt: String,
val imageUrl: String? = null,
val isDraft: Boolean = false,
)

Expand All @@ -22,13 +21,11 @@ data class DailyQuestionCreatePayload(
val content: String,
val isDraft: Boolean,
val questionId: Long,
val imageUrl: String? = null,
)

data class DailyQuestionUpdatePayload(
val content: String? = null,
val isDraft: Boolean? = null,
val date: String? = null,
val questionId: Long? = null,
val imageUrl: String? = null,
)
1 change: 1 addition & 0 deletions feature/mindrecord/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
// 매퍼가 android.util.Log 를 타 JVM 단위 테스트로는 돌지 않는다 (#751).
// 부분 성공에서 어떤 문구가 나가는지도 컴포지션을 태워야 확인된다 (#725).
// 수신자 기록 본문 시트도 JVM 에서 실제로 렌더해 확인한다 (#618).
// 카드 미리보기의 대체 문자 제거도 HtmlCompat 파싱 결과라 실제 파서로 확인한다 (#549).
testImplementation(libs.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.afternote.feature.mindrecord.domain.model.Diary
import com.afternote.feature.mindrecord.domain.model.TodayMood
import com.afternote.feature.mindrecord.presentation.model.DailyDiary
import com.afternote.feature.mindrecord.presentation.model.DailyQuestion
import com.afternote.feature.mindrecord.presentation.util.firstHtmlImageSrcOrNull
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import com.afternote.feature.mindrecord.domain.model.DailyQuestion as DailyQuestionDomain
Expand Down Expand Up @@ -38,7 +39,8 @@ fun DailyQuestionDomain.toUi(): DailyQuestion? {
title = title,
date = resolvedDate,
content = content,
imageUrl = imageUrl,
// 서버 계약에 `imageUrl` 이 없다 — 썸네일은 본문 HTML 의 첫 img 에서 뽑는다 (#549).
imageUrl = content.firstHtmlImageSrcOrNull(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,62 @@ fun String.htmlToPlainText(): String =
HtmlCompat
.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
.toString()
// ImageGetter 없이 파싱하면 img 자리에 U+FFFC 가 남는다. 본문에 img 를 정식으로
// 넣기 시작하면(#549) 카드 미리보기 둘째 줄이 통째로 `` 가 된다 — 공백이 아니라
// trim() 으로는 안 지워진다.
.replace("\uFFFC", "")
.trim()

/**
* 본문 HTML 의 **첫 `img[src]`** 를 목록 카드 썸네일로 뽑는다 (없으면 null).
*
* 데일리질문·일기의 요청/응답 계약 어디에도 `imageUrl` 필드가 없다 (#549). 서버 설계는
* 본문 이미지를 `content` HTML 의 `img` 태그로 담는 것이고, `content` 설명이 허용 태그로
* `img[src|alt|width|height|style]` 을 명시한다. 실서버에서도 `<img src>` 는 저장·재조회에
* 그대로 살아남는다 (2026-08-23 실측).
*
* 그래서 썸네일의 출처는 별도 필드가 아니라 본문이다. 정규식으로 훑는 이유는 이 값이
* **표시용 한 장**이라, 파서를 들이기보다 실패해도 썸네일이 안 뜨는 정도로 끝나는 편이
* 낫기 때문이다 — 본문 렌더링은 여전히 에디터/`htmlToPlainText` 가 맡는다.

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.

[normal] 이 PR 로 본문에 <img> 가 실제로 남기 시작하면 목록 카드 미리보기 텍스트에  가 박힙니다.

htmlToPlainText 는 HtmlCompat.fromHtml 에 ImageGetter 를 주지 않는데, 그러면 <img> 자리에 \uFFFC(OBJECT REPLACEMENT CHARACTER)가 들어갑니다. 이 함수와 같은 호출을 Robolectric(SDK 35)로 실측했습니다.

본문 현재 미리보기
<p>본문</p><p><img … width="0" height="0"/></p> 본문\n
<img …/> (이미지만) 

.trim() 은 \uFFFC 를 못 지웁니다(공백이 아님). 그리고 본문에 img 가 있으면 firstHtmlImageSrcOrNull() 이 non-null 이라 카드가 항상 ThumbnailCardContent 로 가는데, 거기 maxLines = 2 미리보기의 둘째 줄이 통째로  가 됩니다 — 시안 2757:16129 의 "본문 2줄" 자리입니다.

#731 이 에디터에서 관측한 "대체 문자" 와 같은 문자지만 표면이 다릅니다(그건 에디터, 이건 목록 카드). 본문 img 를 정식 경로로 삼는 이 PR 에서 같이 막는 게 맞다고 봅니다.

한 줄로 끝나고, 결과까지 같은 방식으로 확인했습니다.

fun String.htmlToPlainText(): String =
    HtmlCompat
        .fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
        .toString()
        // ImageGetter 없이 파싱하면 img 자리에 이 문자가 남는다 — 카드 미리보기엔 노출하지 않는다.
        .replace("\uFFFC", "")
        .trim()
본문 적용 후
<p>본문</p><p><img …/></p> 본문
<img …/> (빈 문자열 → 미리보기 없음)

같은 함수를 DiaryCard·DiaryComponent·DraftListScreen·MemoryDetailOverlay 도 쓰므로 일기 쪽도 함께 덮입니다.

*/
fun String.firstHtmlImageSrcOrNull(): String? =
HTML_IMG_SRC
.find(this)
?.groupValues
?.get(1)
?.trim()
?.takeIf { it.isNotEmpty() }

// `src` 가 img 태그의 몇 번째 속성이든 잡히도록 태그 안을 훑는다. 따옴표는 " 와 ' 둘 다 허용.
private val HTML_IMG_SRC = Regex("""<img\b[^>]*?\bsrc\s*=\s*["']([^"']*)["']""", RegexOption.IGNORE_CASE)

/**
* 업로드 직후 받은 파일 URL 을 **서버가 본문에서 기대하는 형태**(fileKey) 로 바꾼다.
*
* `POST /files/presigned-url` 은 `fileKey`(`mindrecords/staging/13/uuid.png`) 와
* `fileUrl`(그 앞에 CDN 호스트가 붙은 전체 URL) 을 함께 준다. 저장 시 서버는 본문의
* `img src` 를 훑어 **staging 파일을 permanent 로 옮기고 전체 URL 로 재작성**하는데,
* 이때 기대하는 입력이 fileKey 다. 전체 URL 을 그대로 넣으면 그 앞에 호스트를 한 번 더
* 붙여 버린다 (실서버 실측 2026-08-23, #549).
*
* ```
* 보낸 값: https://cdn.example.net/mindrecords/staging/13/a.png
* 저장된 값: https://cdn.example.net/https://cdn.example.net/mindrecords/permanent/13/a.png → 403
* ```
*
* 스킴과 호스트만 떼면 fileKey 가 되므로 경로 규칙(`mindrecords/staging/...`)을 코드에

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.

[normal] fileKey 를 URL 에서 역산하는 대신, presigned 응답이 이미 주는 값을 쓸 수 있습니다.

PresignedUrlDto 는 fileKey 와 fileUrl 을 둘 다 내려주는데(core/network/dto/PresignedUrlDto.kt), PhotoUploadRepositoryImpl.upload() 가 presigned.fileUrl 만 돌려주면서 권위 있는 fileKey 를 버립니다. 그래서 여기서 문자열로 되살리는 구조가 됐습니다.

주석의 "경로 규칙을 코드에 박지 않는다" 는 맞습니다. 다만 대신 fileUrl == "<스킴>://<호스트>/" + fileKey 라는 다른 가정이 들어옵니다. CDN 이 경로 프리픽스를 붙이거나(https://cdn/bucket/mindrecords/…) 쿼리스트링이 붙는 순간 조용히 틀린 키가 나가고, 증상은 이 PR 이 고친 것과 똑같은 "저장은 됐는데 이미지가 안 뜬다" 입니다.

upload() 반환을 (fileUrl, fileKey) 로 넓히면 추측이 사라집니다. core:data 를 건드리는 범위 확대라 이 PR 에서 꼭 해야 한다고 보진 않습니다 — 후속 이슈로 남기고 여기 주석에 "권위 있는 출처는 presigned 응답의 fileKey 지만 upload() 가 버린다" 는 근거를 한 줄 달아 두는 선택도 괜찮습니다.

* 박지 않는다 — 서버가 디렉터리 구조를 바꿔도 따라간다.
*
* 다만 이 방식은 `fileUrl == "<스킴>://<호스트>/" + fileKey` 를 가정한다. **권위 있는
* 출처는 presigned 응답의 `fileKey` 인데 `PhotoUploadRepository.upload()` 가 `fileUrl` 만
* 돌려주며 버린다** — CDN 이 경로 프리픽스나 쿼리스트링을 붙이면 조용히 틀린 키가 된다.
* 반환을 넓히는 것은 `core:data` 범위라 별건으로 둔다 (#1017).
*/
fun String.toUploadedFileKey(): String =
// 스킴이 없으면 substringAfter("://") 가 원문을 그대로 돌려주고, 이어지는
// substringAfter('/') 가 첫 경로 세그먼트를 잘라먹는다 — 조용히 틀린 키가 나간다.
if (contains("://")) substringAfter("://").substringAfter('/') else this

/**
* 태그만 있고 보이는 글자가 없으면 비었다고 본다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ data class DailyQuestionWriteUiState(
*/
val draftLoaded: Boolean = false,
val answer: String = "",
val imageUrl: String? = null,
val isQuestionLoading: Boolean = true,
val questionLoadError: UiText? = null,
/** 이어쓸 임시저장 본문을 불러오는 중 (#923). */
Expand Down
Loading
Loading