Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
67600ad
fix(afternote): 수정 PATCH 가 안 건드린 필드를 싣지 않게 한다 (#1617)
OnlyReadOneBook Aug 30, 2026
c335530
docs(afternote): baseline 주석의 상호 참조를 심볼 이름으로 바로잡는다 (#1617)
OnlyReadOneBook Aug 30, 2026
ceff01f
fix(afternote): 부분 PATCH 판정을 서버가 반영하는 슬롯 단위로 내린다 (#1617)
OnlyReadOneBook Aug 30, 2026
a8b59f4
docs(afternote): 생성 전용이 된 MemorialWritePayload 의 KDoc 을 바로잡는다 (#1617)
OnlyReadOneBook Aug 30, 2026
891ebd0
fix(afternote): 수정 전용 플레이리스트 DTO 이름을 Request 규약에 맞춘다 (#1617)
OnlyReadOneBook Aug 30, 2026
b1793b4
test(afternote): 프로세스 사망 복원 테스트의 스냅샷 키를 v4 로 맞춘다 (#1617)
OnlyReadOneBook Sep 2, 2026
d084be5
Merge remote-tracking branch 'origin/develop' into fix/1617-dirty-fie…
OnlyReadOneBook Sep 3, 2026
88073fd
merge: 상세·임시저장 모델 위에 부분 PATCH를 연결 (#1617)
OnlyReadOneBook Sep 8, 2026
09a4aa7
merge: 부분 PATCH를 최신 Nav3와 미디어 삭제 계약에 통합 (#1617)
OnlyReadOneBook Sep 8, 2026
d939c2f
Merge commit '04f2571fe0d9fb02716d91893ebbec935d01ba85' into HEAD
OnlyReadOneBook Sep 8, 2026
01c4be0
Merge commit 'a3dd0a3fd90ba1b0d9c4efa6eff060dab8715126' into HEAD
OnlyReadOneBook Sep 8, 2026
edb5953
chore: 옛 guard check-run 을 걷어내려 HEAD 를 갱신한다 (#1983)
OnlyReadOneBook Sep 11, 2026
0dfcb0f
Merge remote-tracking branch 'origin/feat/808-draft-resume' into fix/…
OnlyReadOneBook Sep 11, 2026
8ea5a94
Merge remote-tracking branch 'origin/feat/1762-draft-resume' into fix…
OnlyReadOneBook Sep 13, 2026
7981646
Merge remote-tracking branch 'origin/develop' into fix/1617-dirty-fie…
OnlyReadOneBook Sep 19, 2026
a2dd618
Merge remote-tracking branch 'origin/develop' into HEAD
OnlyReadOneBook Sep 20, 2026
c757588
test: 복원 프리필 회귀 검증의 현재 스냅샷 키 사용 (#1617)
OnlyReadOneBook Sep 20, 2026
9b7e19d
refactor(afternote): 수정 입력 모델 이름을 Input으로 정리 (#1617)
OnlyReadOneBook Sep 25, 2026
3aa2856
refactor(afternote): 마지막 말씀 DTO 변환 목적을 함수명에 명시 (#1617)
OnlyReadOneBook Sep 25, 2026
58bdf0f
refactor(afternote): 수신자 참조 입력 모델 이름 정리 (#1617)
OnlyReadOneBook Sep 25, 2026
78f7f90
fix(afternote): 기준 스냅샷 오류 문구 명확화 (#1617)
OnlyReadOneBook Sep 25, 2026
6ae7539
refactor(afternote): 수정 입력의 수신자를 id 목록으로 받고 wire 포장은 data 에서 끝낸다 (#1617)
OnlyReadOneBook Sep 25, 2026
11d6459
fix(afternote): 요청 매퍼의 파일 안 toDto 헬퍼를 private 으로 줄인다 (#1617)
OnlyReadOneBook Sep 25, 2026
bd12e59
refactor(afternote): 수정 페이로드의 미디어 URL 인자 이름을 타입과 맞춘다 (#1617)
OnlyReadOneBook Sep 25, 2026
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.afternote.feature.afternote.data.dto

import com.afternote.feature.afternote.domain.model.author.FieldPatch
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
Expand Down Expand Up @@ -39,15 +40,32 @@ data class AfternoteCreateAccountRequestDto(
@SerialName("isDraft") val isDraft: Boolean = false,
)

/**
* 애프터노트 수정(PATCH) 요청 바디 — **부분 갱신이다.**
*
* `null` 인 슬롯은 `encodeDefaults = false` 덕분에 **키째 빠지고**, 서버는 그것을 「기존 값 유지」로
* 읽는다 (Afternote-BE `bbff47c` · BE#200·#201). 그러므로 여기서 기본값 `null` 은 편의가 아니라
* 계약이다 — 사용자가 만지지 않은 필드를 이 DTO 에 채우면 그 순간 낡은 값이 남의 변경을 덮는다
* (#1617).
*
* [title] 에도 기본값을 둔다. 제목은 늘 폼에 떠 있어 「안 바꿨다」를 말할 수단이 필요하다 —
* 서버도 `title` 생략을 계약으로 못박아 뒀다(`updateAfternote_OmitCategoryAndTitle_Success`).
*
* [category] 만 늘 실린다. 값을 바꾸는 필드가 아니라 **대상 확인용 단언**이라 lost update 축에
* 참여하지 않는다 — 서버는 저장값과 다르면 400 을 내고 같으면 아무것도 바꾸지 않는다.
*
* 빈 컬렉션은 생략이 아니라 **삭제**다. `[]` 를 실으면 서버가 관계를 통째로 갈아 끼운다
* (`actions`·`leaveMessage`·`receivers`, 그리고 `playlist.songs` — #1599).
*/
@Serializable
data class AfternoteUpdateRequestDto(
@SerialName("category") val category: String,
@SerialName("title") val title: String,
@SerialName("title") val title: String? = null,
@SerialName("actions") val processingMethods: List<String>? = null,
@SerialName("leaveMessage") val leaveMessage: List<LeaveMessageBlockDto>? = null,
@SerialName("credentials") val credentials: AfternoteCredentialsDto? = null,
@SerialName("receivers") val receivers: List<AfternoteReceiverRefDto>? = null,
@SerialName("playlist") val memorial: AfternotePlaylistRequestDto? = null,
@SerialName("playlist") val memorial: AfternotePlaylistPatchRequestDto? = null,
/**
* 임시저장 여부. **생략하면 서버가 저장값을 그대로 유지한다**(BE `AfternoteValidator.validatePublishRequirements`) —
* 그래서 «건드리지 않음» 이 null 이고, 발행 전환은 `false` 를 명시해야 일어난다.
Expand Down Expand Up @@ -113,6 +131,39 @@ data class AfternotePlaylistRequestDto(
@SerialName("memorialVideo") val memorialVideo: AfternoteMemorialVideoDto?,
)

/**
* 추억 노트 플레이리스트 **수정(PATCH) 전용** 바디 — 만진 슬롯만 말한다 (#1617).
*
* [AfternotePlaylistRequestDto] 와 나눠 둔 이유가 계약 그 자체다. 생성은 「이 노트가 가져야 할
* 상태」를 통째로 말해야 해서 슬롯에 기본값을 두지 않는다(그래서 항상 직렬화된다). 수정은 반대다 —
* **만지지 않은 슬롯은 키째 빠져야** 서버가 기존 값을 유지한다.
*
* 한 DTO 로 겸하면 둘 중 하나가 반드시 깨진다. 실제로 그랬다: 수정에서 생성용 DTO 를 쓰는 동안,
* 곡만 바꾼 저장이 `{"memorialPhotoUrl":null,"memorialVideo":null}` 을 함께 실어 **그 사이 다른
* 기기가 올린 영정사진·추모 영상을 지웠다.** 기본값이 없는 슬롯은 `encodeDefaults = false` 로도
* 생략되지 않고, `null` 이면 곧 삭제 지시이기 때문이다.
*
* 슬롯별 표현이 다른 것은 서버가 그렇게 읽기 때문이다:
* - [memorialPhotoUrl]·[memorialVideo] — `PlaylistRequestDeserializer` 가 `node.has(...)` 로 키
* 유무를 보고 `AfternotePlaylist.update` 가 그 플래그로 유지/삭제를 가른다. 3상태가 필요해
* [FieldPatch] 를 쓴다. 기본값 [FieldPatch.Unchanged] 가 곧 「키 생략」이다.
* - [songs] — `PlaylistRelationStrategy.update` 가 `songs != null` 일 때만 `items` 를 통째로 갈아
* 끼운다. `null`(안 건드림)과 `[]`(전부 삭제)로 두 뜻이 다 나오므로 [FieldPatch] 가 필요 없다.
* - `atmosphere`·`memorialAudioUrl` — FE 화면에 없는 값이라 삭제를 지시할 자격이 없다. 슬롯 자체를
* 두지 않아 영영 생략된다.
*/
@Serializable
data class AfternotePlaylistPatchRequestDto(
@SerialName("memorialPhotoUrl")
@Serializable(with = FieldPatchSerializer::class)
val memorialPhotoUrl: FieldPatch<String?> = FieldPatch.Unchanged,
@SerialName("songs")
val songs: List<AfternoteSongDto>? = null,
@SerialName("memorialVideo")
@Serializable(with = FieldPatchSerializer::class)
val memorialVideo: FieldPatch<AfternoteMemorialVideoDto?> = FieldPatch.Unchanged,
)

@Serializable
data class AfternoteSongDto(
@SerialName("title") val title: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.afternote.feature.afternote.data.dto

import com.afternote.feature.afternote.domain.model.author.FieldPatch
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

/**
* [FieldPatch] 슬롯을 wire 로 옮기는 직렬화기 (#1617).
*
* **「키를 뺀다」는 이 직렬화기가 하는 일이 아니다.** kotlinx 는 `encodeDefaults = false` 에서
* *선언된 기본값과 같은* 필드만 생략하므로, 생략은 DTO 가 슬롯의 기본값을 [FieldPatch.Unchanged] 로
* 두는 것으로 성립한다. 이 직렬화기는 나머지 둘만 맡는다 — [FieldPatch.Set] 의 값을 그대로 쓰고,
* 그 값이 `null` 이면 JSON `null` 을 남겨 서버가 삭제로 읽게 한다.
*
* 그래서 [FieldPatch.Unchanged] 가 여기까지 오면 계약이 깨진 것이다: DTO 슬롯의 기본값이
* [FieldPatch.Unchanged] 가 아니거나 `encodeDefaults` 가 켜졌다는 뜻이고, 둘 다 「안 건드림」을
* 「삭제」로 바꿔 버린다. 조용히 null 을 쓰지 않고 즉시 실패시킨다.
*/
class FieldPatchSerializer<T>(
private val valueSerializer: KSerializer<T>,
) : KSerializer<FieldPatch<T>> {
override val descriptor: SerialDescriptor = valueSerializer.descriptor

override fun serialize(
encoder: Encoder,
value: FieldPatch<T>,
) {
when (value) {
is FieldPatch.Unchanged -> {
error(
"FieldPatch.Unchanged 는 직렬화 대상이 아니다 — 키가 생략됐어야 한다. " +
"DTO 슬롯의 기본값과 Json 의 encodeDefaults 설정을 확인할 것.",
)
}

is FieldPatch.Set -> {
encoder.encodeSerializableValue(valueSerializer, value.value)
}
}
}

/** 요청 전용 타입이라 역직렬화는 쓰이지 않지만, 계약상 값이 있으면 [FieldPatch.Set] 이다. */
override fun deserialize(decoder: Decoder): FieldPatch<T> = FieldPatch.Set(decoder.decodeSerializableValue(valueSerializer))
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,63 @@ import com.afternote.feature.afternote.data.dto.AfternoteCreateGalleryRequestDto
import com.afternote.feature.afternote.data.dto.AfternoteCreatePlaylistRequestDto
import com.afternote.feature.afternote.data.dto.AfternoteCredentialsDto
import com.afternote.feature.afternote.data.dto.AfternoteMemorialVideoDto
import com.afternote.feature.afternote.data.dto.AfternotePlaylistPatchRequestDto
import com.afternote.feature.afternote.data.dto.AfternotePlaylistRequestDto
import com.afternote.feature.afternote.data.dto.AfternoteReceiverRefDto
import com.afternote.feature.afternote.data.dto.AfternoteSongDto
import com.afternote.feature.afternote.data.dto.AfternoteUpdateRequestDto
import com.afternote.feature.afternote.domain.AfternoteType
import com.afternote.feature.afternote.domain.model.author.AfternoteAccountCredentials
import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload
import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload
import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload
import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload
import com.afternote.feature.afternote.domain.model.author.FieldPatch
import com.afternote.feature.afternote.domain.model.author.MemorialPatchInput
import com.afternote.feature.afternote.domain.model.author.MemorialSongPayload
import com.afternote.feature.afternote.domain.model.author.MemorialVideoPayload
import com.afternote.feature.afternote.domain.model.author.MemorialWritePayload
import com.afternote.feature.afternote.domain.model.author.ReceiverRefPayload
import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput

/**
* 도메인 → 요청 wire. 요청 바디 루트는 `toRequest`, 그 안에 실리는 부분 DTO 는 `toDto` 다.
*
* 수정 페이로드는 **슬롯을 하나도 접지 않고 그대로 옮긴다** (#1617). 어떤 필드를 실을지는 이미
* `AfternoteEditorFormMapper` 가 프리필 원본과 비교해 정했다 — 여기서 `ifEmpty { null }` 류로 한 번
* 더 손대면 「전부 삭제」가 「안 건드림」으로 조용히 바뀐다.
*/

fun AfternoteUpdatePayload.toRequest() =
fun UpdateAfternoteInput.toRequest() =
AfternoteUpdateRequestDto(
category = type.toAuthoringServerCategory(),
title = title,
processingMethods = processingMethods,
leaveMessage = leaveMessageBlocks.toDto(),
leaveMessage = leaveMessageBlocks.toDtoForPatch(),
credentials = credentials?.toDto(),
receivers = receivers?.map { it.toDto() },
memorial = memorial?.toDto(),
receivers = receiverIds?.toReceiverRefDtos(),
memorial = memorial?.toPatchDto(),
isDraft = isDraft,
)

/**
* 플레이리스트 수정 슬롯 → wire. 슬롯의 [FieldPatch] 를 **그대로** 옮긴다 (#1617).
*
* [FieldPatch.Unchanged] 를 여기서 `null` 이나 기존 값으로 바꿔치기하면 안 된다 — 전자는 삭제로,
* 후자는 남의 변경을 되돌리는 덮어쓰기로 나간다. 「말하지 않음」은 끝까지 말하지 않는 것으로만
* 표현된다.
*/
fun MemorialPatchInput.toPatchDto() =
AfternotePlaylistPatchRequestDto(
memorialPhotoUrl = memorialPhotoUrl,
songs = songs?.map { it.toDto() },
memorialVideo = memorialVideo.map { it?.toDto() },
)

/** 슬롯을 열어 값만 바꾸고 「안 건드림」은 그대로 통과시킨다. */
private inline fun <T, R> FieldPatch<T>.map(transform: (T) -> R): FieldPatch<R> =
when (this) {
is FieldPatch.Unchanged -> FieldPatch.Unchanged
is FieldPatch.Set -> FieldPatch.Set(transform(value))
}

/** 문자열 변환은 data 경계에서 끝낸다 — 화면·domain 은 [AfternoteType] 만 다룬다. */
private fun AfternoteType.toAuthoringServerCategory(): String =
when (this) {
Expand All @@ -51,9 +77,9 @@ fun CreateAccountPayload.toSocialRequest() =
category = "SOCIAL",
title = title,
processingMethods = processingMethods,
leaveMessage = leaveMessageBlocks.toDto(),
leaveMessage = leaveMessageBlocks.toDtoForCreate(),
credentials = credentials?.toDto(),
receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) },
receivers = receiverIds.toReceiverRefDtos(),
isDraft = isDraft,
)

Expand All @@ -62,9 +88,9 @@ fun CreateAccountPayload.toBusinessRequest() =
category = "BUSINESS",
title = title,
processingMethods = processingMethods,
leaveMessage = leaveMessageBlocks.toDto(),
leaveMessage = leaveMessageBlocks.toDtoForCreate(),
credentials = credentials?.toDto(),
receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) },
receivers = receiverIds.toReceiverRefDtos(),
isDraft = isDraft,
)

Expand All @@ -73,8 +99,8 @@ fun CreateGalleryPayload.toRequest() =
category = "GALLERY",
title = title,
processingMethods = processingMethods,
leaveMessage = leaveMessageBlocks.toDto(),
receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) },
leaveMessage = leaveMessageBlocks.toDtoForCreate(),
receivers = receiverIds.toReceiverRefDtos(),
isDraft = isDraft,
)

Expand All @@ -83,12 +109,12 @@ fun CreateMemorialPayload.toRequest() =
category = "PLAYLIST",
title = title,
memorial = memorial.toDto(),
leaveMessage = leaveMessageBlocks.toDto(),
receivers = receiverIds.map { AfternoteReceiverRefDto(receiverId = it) },
leaveMessage = leaveMessageBlocks.toDtoForCreate(),
receivers = receiverIds.toReceiverRefDtos(),
isDraft = isDraft,
)

fun MemorialWritePayload.toDto() =
private fun MemorialWritePayload.toDto() =
AfternotePlaylistRequestDto(
memorialPhotoUrl = memorialPhotoUrl,
songs = songs.map { it.toDto() },
Expand All @@ -102,19 +128,20 @@ private fun MemorialSongPayload.toDto() =
coverUrl = coverUrl,
)

fun MemorialVideoPayload.toDto() =
private fun MemorialVideoPayload.toDto() =
AfternoteMemorialVideoDto(
videoUrl = videoUrl,
thumbnailUrl = thumbnailUrl,
)

fun AfternoteAccountCredentials.toDto() =
private fun AfternoteAccountCredentials.toDto() =
AfternoteCredentialsDto(
id = id,
password = password,
)

fun ReceiverRefPayload.toDto() =
AfternoteReceiverRefDto(
receiverId = receiverId,
)
/**
* 도메인은 수신자를 id 로만 말한다 — wire 의 `{receiverId}` 객체로 감싸는 것은 여기서 끝낸다.
* 순서·중복은 받은 그대로 둔다.
*/
private fun List<Long>.toReceiverRefDtos() = map { AfternoteReceiverRefDto(receiverId = it) }
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ fun List<LeaveMessageBlockDto>?.toLeaveMessageBlocks(): List<LeaveMessageBlock>
LeaveMessageBlock(title = dto.title, body = body)
}.orEmpty()

/** 도메인 → 요청. 남길 말씀이 없으면 빈 배열 대신 필드 자체를 빼도록 null 로 접는다. */
fun List<LeaveMessageBlock>.toDto(): List<LeaveMessageBlockDto>? =
/** 도메인 → 생성 요청. 남길 말씀이 없으면 빈 배열 대신 필드 자체를 빼도록 null 로 접는다. */
internal fun List<LeaveMessageBlock>.toDtoForCreate(): List<LeaveMessageBlockDto>? =
map { LeaveMessageBlockDto(title = it.title, body = it.body) }
.takeIf { it.isNotEmpty() }

/**
* 도메인 → 수정 요청. **[toDtoForCreate] 처럼 빈 배열을 null 로 접지 않는다** (#1617).
*
* 수정은 부분 갱신이라 `null`(안 건드림 → 키 생략)과 `[]`(전부 삭제)가 서로 다른 지시다.
* 접어 버리면 마지막 말씀을 전부 지운 저장이 「유지」로 흡수돼 서버에 반영되지 않는다.
*/
internal fun List<LeaveMessageBlock>?.toDtoForPatch(): List<LeaveMessageBlockDto>? =
this?.map { LeaveMessageBlockDto(title = it.title, body = it.body) }
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import com.afternote.feature.afternote.data.paging.AfternotePagingSource
import com.afternote.feature.afternote.data.service.AfternoteApiService
import com.afternote.feature.afternote.domain.AfternoteType
import com.afternote.feature.afternote.domain.error.AfternoteFailure
import com.afternote.feature.afternote.domain.model.author.AfternoteUpdatePayload
import com.afternote.feature.afternote.domain.model.author.CreateAccountPayload
import com.afternote.feature.afternote.domain.model.author.CreateGalleryPayload
import com.afternote.feature.afternote.domain.model.author.CreateMemorialPayload
import com.afternote.feature.afternote.domain.model.author.Detail
import com.afternote.feature.afternote.domain.model.author.DraftDetail
import com.afternote.feature.afternote.domain.model.author.ListItem
import com.afternote.feature.afternote.domain.model.author.UpdateAfternoteInput
import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -93,7 +93,7 @@ class AfternoteRepositoryImpl

override suspend fun update(
id: Long,
payload: AfternoteUpdatePayload,
payload: UpdateAfternoteInput,
): Result<Long> =
runCatchingCancellable {
api
Expand Down
Loading
Loading