Repository navigation
fix(mindrecord): 데일리질문 이미지를 본문 img 로 저장한다 — 계약에 없는 imageUrl 제거 #954
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9c37f11
0f0e2cf
a4cd603
0a93800
aaef6f3
a715885
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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` 가 맡는다. | ||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal] 이 PR 로 본문에
#731 이 에디터에서 관측한 "대체 문자" 와 같은 문자지만 표면이 다릅니다(그건 에디터, 이건 목록 카드). 본문 img 를 정식 경로로 삼는 이 PR 에서 같이 막는 게 맞다고 봅니다. 한 줄로 끝나고, 결과까지 같은 방식으로 확인했습니다. fun String.htmlToPlainText(): String =
HtmlCompat
.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
.toString()
// ImageGetter 없이 파싱하면 img 자리에 이 문자가 남는다 — 카드 미리보기엔 노출하지 않는다.
.replace("\uFFFC", "")
.trim()
같은 함수를 |
||||||||||||||
| */ | ||||||||||||||
| 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/...`)을 코드에 | ||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [normal] fileKey 를 URL 에서 역산하는 대신, presigned 응답이 이미 주는 값을 쓸 수 있습니다.
주석의 "경로 규칙을 코드에 박지 않는다" 는 맞습니다. 다만 대신
|
||||||||||||||
| * 박지 않는다 — 서버가 디렉터리 구조를 바꿔도 따라간다. | ||||||||||||||
| * | ||||||||||||||
| * 다만 이 방식은 `fileUrl == "<스킴>://<호스트>/" + fileKey` 를 가정한다. **권위 있는 | ||||||||||||||
| * 출처는 presigned 응답의 `fileKey` 인데 `PhotoUploadRepository.upload()` 가 `fileUrl` 만 | ||||||||||||||
| * 돌려주며 버린다** — CDN 이 경로 프리픽스나 쿼리스트링을 붙이면 조용히 틀린 키가 된다. | ||||||||||||||
| * 반환을 넓히는 것은 `core:data` 범위라 별건으로 둔다 (#1017). | ||||||||||||||
| */ | ||||||||||||||
| fun String.toUploadedFileKey(): String = | ||||||||||||||
| // 스킴이 없으면 substringAfter("://") 가 원문을 그대로 돌려주고, 이어지는 | ||||||||||||||
| // substringAfter('/') 가 첫 경로 세그먼트를 잘라먹는다 — 조용히 틀린 키가 나간다. | ||||||||||||||
| if (contains("://")) substringAfter("://").substringAfter('/') else this | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * 태그만 있고 보이는 글자가 없으면 비었다고 본다. | ||||||||||||||
| * | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
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)은 직렬화가 동작하는지만 본다. 바로 위 주석 «SwaggerDailyQuestionListResponse에 없는 필드 — 기본값을 유지하는 유일한 자리» 도 필드가 없어진 지금은 사실이 아니다.같은 성질은 이 PR 이 새로 만든
DailyQuestionImageContractTest.kt:76「응답에 없는 imageUrl 키를 읽지 않아도 파싱은 멀쩡하다」 가 이름 그대로 지키고 있다. 여기서는 케이스째 지우는 편이 낫다 — 남겨 두면 다음 사람이 «imageUrl 에 아직 기본값 규칙이 걸려 있구나» 로 읽는다.