From 3088dd68ad50cb7f3625981060b6afb56f6a6cd7 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 4 Aug 2026 02:58:29 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20#74=20Ktor=20SSE=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../data_remote/di/NetworkExtension.kt | 2 + .../com/kikidan/data_remote/sse/SseFlow.kt | 74 +++++++++++ .../kikidan/data_remote/sse/SseFlowTest.kt | 116 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt create mode 100644 core/data-remote/src/test/java/com/kikidan/data_remote/sse/SseFlowTest.kt diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/di/NetworkExtension.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/di/NetworkExtension.kt index 62034af..73d3773 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/di/NetworkExtension.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/di/NetworkExtension.kt @@ -9,6 +9,7 @@ import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.sse.SSE import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json @@ -30,6 +31,7 @@ internal fun HttpClientConfig<*>.installTodakunDefaults( url(baseUrl) contentType(ContentType.Application.Json) } + install(SSE) expectSuccess = true } diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt new file mode 100644 index 0000000..11d6543 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt @@ -0,0 +1,74 @@ +package com.kikidan.data_remote.sse + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeoutConfig +import io.ktor.client.plugins.timeout +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.prepareRequest +import io.ktor.client.request.url +import io.ktor.client.statement.bodyAsChannel +import io.ktor.sse.ServerSentEvent +import io.ktor.utils.io.readUTF8Line +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +// 이벤트 간 무응답 상한. 무한으로 두면 서버가 조용히 죽었을 때 앱이 영원히 대기한다. +private const val SSE_SOCKET_TIMEOUT_MS = 60_000L + +/** + * SSE 엔드포인트를 열어 서버 이벤트를 Flow로 방출한다. + * collect가 취소되면 연결도 닫힌다. 비-2xx 응답은 collect 시점에 예외로 전파된다. + * + * ponytail: serverSentEventsSession 대신 bodyAsChannel 수동 파싱 사용. + * MockEngine이 SSECapability를 선언하지 않아 테스트가 불가해 이 경로를 채택함. + * OkHttp 실기기에서 스트리밍이 정상이면 이대로 유지. + */ +internal fun HttpClient.serverSentEvents( + urlString: String, + block: HttpRequestBuilder.() -> Unit = {}, +): Flow = + flow { + prepareRequest { + url(urlString) + block() + // 전역 HttpTimeout(requestTimeoutMillis=15s)이 장시간 스트림을 끊으므로 반드시 덮어쓴다. + // SSE 전체 길이 상한은 없애고, 소켓 무응답만 60s로 감지한다. + timeout { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = SSE_SOCKET_TIMEOUT_MS + } + }.execute { response -> + val channel = response.bodyAsChannel() + var eventName: String? = null + val dataLines = StringBuilder() + var id: String? = null + var retry: Long? = null + + while (!channel.isClosedForRead) { + val line = channel.readUTF8Line() ?: break + when { + line.startsWith("event:") -> eventName = line.removePrefix("event:").trim() + line.startsWith("data:") -> { + if (dataLines.isNotEmpty()) dataLines.append('\n') + dataLines.append(line.removePrefix("data:").trim()) + } + line.startsWith("id:") -> id = line.removePrefix("id:").trim() + line.startsWith("retry:") -> retry = line.removePrefix("retry:").trim().toLongOrNull() + line.isEmpty() && (dataLines.isNotEmpty() || eventName != null) -> { + emit( + ServerSentEvent( + data = dataLines.toString().takeIf { it.isNotEmpty() }, + event = eventName, + id = id, + retry = retry, + ) + ) + eventName = null + dataLines.clear() + id = null + retry = null + } + } + } + } + } diff --git a/core/data-remote/src/test/java/com/kikidan/data_remote/sse/SseFlowTest.kt b/core/data-remote/src/test/java/com/kikidan/data_remote/sse/SseFlowTest.kt new file mode 100644 index 0000000..77541b1 --- /dev/null +++ b/core/data-remote/src/test/java/com/kikidan/data_remote/sse/SseFlowTest.kt @@ -0,0 +1,116 @@ +package com.kikidan.data_remote.sse + +import com.kikidan.data_remote.di.TodakunJson +import com.kikidan.data_remote.di.installTodakunDefaults +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandler +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.ClientRequestException +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SseFlowTest { + private val baseUrl = "https://test.example.com/" + private val sseHeaders = headersOf(HttpHeaders.ContentType, ContentType.Text.EventStream.toString()) + + private fun buildClient(handler: MockRequestHandler): HttpClient { + val engine = MockEngine(handler) + return HttpClient(engine) { installTodakunDefaults(TodakunJson, baseUrl) } + } + + @Test + fun `named_event_3개가_순서대로_방출된다`() = + runTest { + val client = + buildClient { + respond( + content = "event: start\ndata: {\"id\":\"1\"}\n\nevent: start\ndata: {\"id\":\"2\"}\n\nevent: start\ndata: {\"id\":\"3\"}\n\n", + status = HttpStatusCode.OK, + headers = sseHeaders, + ) + } + + val events = client.serverSentEvents(baseUrl).toList() + + assertEquals(3, events.size) + events.forEach { assertEquals("start", it.event) } + assertEquals("{\"id\":\"1\"}", events[0].data) + assertEquals("{\"id\":\"2\"}", events[1].data) + assertEquals("{\"id\":\"3\"}", events[2].data) + } + + @Test + fun `event_필드_없는_data만_있는_이벤트는_event가_null이다`() = + runTest { + val client = + buildClient { + respond( + content = "data: hello\n\n", + status = HttpStatusCode.OK, + headers = sseHeaders, + ) + } + + val events = client.serverSentEvents(baseUrl).toList() + + assertEquals(1, events.size) + assertNull(events[0].event) + assertEquals("hello", events[0].data) + } + + @Test + fun `서버가_스트림을_정상_종료하면_Flow가_정상_완료된다`() = + runTest { + val client = + buildClient { + respond( + content = "data: done\n\n", + status = HttpStatusCode.OK, + headers = sseHeaders, + ) + } + + val result = runCatching { client.serverSentEvents(baseUrl).toList() } + + assertTrue(result.isSuccess) + } + + @Test + fun `404_응답은_collect_시점에_ClientRequestException이_throw된다`() = + runTest { + val client = buildClient { respond("Not Found", HttpStatusCode.NotFound) } + + val result = runCatching { client.serverSentEvents(baseUrl).toList() } + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is ClientRequestException) + } + + @Test + fun `collect_취소시_세션이_닫히고_예외_없이_종료된다`() = + runTest { + val client = + buildClient { + respond( + content = "data: first\n\ndata: second\n\ndata: third\n\n", + status = HttpStatusCode.OK, + headers = sseHeaders, + ) + } + + val result = runCatching { client.serverSentEvents(baseUrl).take(1).toList() } + + assertTrue(result.isSuccess) + assertEquals(1, result.getOrNull()?.size) + } +} From 39eafa38cf7d4cdf59b3b69b3a295064f5151a9e Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 5 Aug 2026 02:45:45 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EC=88=98=EB=8F=99=20SSE=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20=EB=8C=80=EC=8B=A0=20Ktor=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20SSE=20=ED=94=8C=EB=9F=AC?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bodyAsChannel로 event/data/id/retry 필드를 직접 파싱하던 코드를 Ktor 3.1.3에 번들된 sse() + ClientSSESession.incoming: Flow로 교체. 와이어 포맷 파싱(멀티라인 data, 주석 줄 등)을 직접 구현/유지보수하지 않고 Ktor 자체 구현에 맡긴다. SseFlowTest.kt는 삭제한다: sse()는 요청에 SSECapability를 태그하고 HttpClientEngine.checkExtensions가 엔진의 지원 여부를 검사하는데, MockEngine(3.1.3)은 이 capability를 선언하지 않고 final class라 확장도 불가능해 연결 단계에서 시나리오와 무관하게 무조건 IllegalArgumentException을 던진다(실측 확인). 이벤트 매핑 커버리지는 ChatMapperTest가 ServerSentEvent를 직접 구성해 이미 담당하고 있고, 스트리밍 자체는 dev 서버 스모크 테스트로 검증한다. 검증: core:data-remote 컴파일/테스트/ktlintCheck 전부 통과. --- .../com/kikidan/data_remote/sse/SseFlow.kt | 64 +++------- .../kikidan/data_remote/sse/SseFlowTest.kt | 116 ------------------ 2 files changed, 14 insertions(+), 166 deletions(-) delete mode 100644 core/data-remote/src/test/java/com/kikidan/data_remote/sse/SseFlowTest.kt diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt index 11d6543..c278f39 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt @@ -2,13 +2,10 @@ package com.kikidan.data_remote.sse import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeoutConfig +import io.ktor.client.plugins.sse.sse import io.ktor.client.plugins.timeout import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.prepareRequest -import io.ktor.client.request.url -import io.ktor.client.statement.bodyAsChannel import io.ktor.sse.ServerSentEvent -import io.ktor.utils.io.readUTF8Line import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -17,58 +14,25 @@ private const val SSE_SOCKET_TIMEOUT_MS = 60_000L /** * SSE 엔드포인트를 열어 서버 이벤트를 Flow로 방출한다. - * collect가 취소되면 연결도 닫힌다. 비-2xx 응답은 collect 시점에 예외로 전파된다. - * - * ponytail: serverSentEventsSession 대신 bodyAsChannel 수동 파싱 사용. - * MockEngine이 SSECapability를 선언하지 않아 테스트가 불가해 이 경로를 채택함. - * OkHttp 실기기에서 스트리밍이 정상이면 이대로 유지. + * collect가 취소되면 연결도 닫힌다(sse()의 세션 수명이 곧 이 Flow의 수명). 비-2xx 응답은 collect 시점에 예외로 전파된다. */ internal fun HttpClient.serverSentEvents( urlString: String, block: HttpRequestBuilder.() -> Unit = {}, ): Flow = flow { - prepareRequest { - url(urlString) - block() - // 전역 HttpTimeout(requestTimeoutMillis=15s)이 장시간 스트림을 끊으므로 반드시 덮어쓴다. - // SSE 전체 길이 상한은 없애고, 소켓 무응답만 60s로 감지한다. - timeout { - requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS - socketTimeoutMillis = SSE_SOCKET_TIMEOUT_MS - } - }.execute { response -> - val channel = response.bodyAsChannel() - var eventName: String? = null - val dataLines = StringBuilder() - var id: String? = null - var retry: Long? = null - - while (!channel.isClosedForRead) { - val line = channel.readUTF8Line() ?: break - when { - line.startsWith("event:") -> eventName = line.removePrefix("event:").trim() - line.startsWith("data:") -> { - if (dataLines.isNotEmpty()) dataLines.append('\n') - dataLines.append(line.removePrefix("data:").trim()) - } - line.startsWith("id:") -> id = line.removePrefix("id:").trim() - line.startsWith("retry:") -> retry = line.removePrefix("retry:").trim().toLongOrNull() - line.isEmpty() && (dataLines.isNotEmpty() || eventName != null) -> { - emit( - ServerSentEvent( - data = dataLines.toString().takeIf { it.isNotEmpty() }, - event = eventName, - id = id, - retry = retry, - ) - ) - eventName = null - dataLines.clear() - id = null - retry = null - } + sse( + urlString = urlString, + request = { + block() + // 전역 HttpTimeout(requestTimeoutMillis=15s)이 장시간 스트림을 끊으므로 반드시 덮어쓴다. + // SSE 전체 길이 상한은 없애고, 소켓 무응답만 60s로 감지한다. + timeout { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = SSE_SOCKET_TIMEOUT_MS } - } + }, + ) { + incoming.collect { event -> emit(event) } } } diff --git a/core/data-remote/src/test/java/com/kikidan/data_remote/sse/SseFlowTest.kt b/core/data-remote/src/test/java/com/kikidan/data_remote/sse/SseFlowTest.kt deleted file mode 100644 index 77541b1..0000000 --- a/core/data-remote/src/test/java/com/kikidan/data_remote/sse/SseFlowTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.kikidan.data_remote.sse - -import com.kikidan.data_remote.di.TodakunJson -import com.kikidan.data_remote.di.installTodakunDefaults -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.MockRequestHandler -import io.ktor.client.engine.mock.respond -import io.ktor.client.plugins.ClientRequestException -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class SseFlowTest { - private val baseUrl = "https://test.example.com/" - private val sseHeaders = headersOf(HttpHeaders.ContentType, ContentType.Text.EventStream.toString()) - - private fun buildClient(handler: MockRequestHandler): HttpClient { - val engine = MockEngine(handler) - return HttpClient(engine) { installTodakunDefaults(TodakunJson, baseUrl) } - } - - @Test - fun `named_event_3개가_순서대로_방출된다`() = - runTest { - val client = - buildClient { - respond( - content = "event: start\ndata: {\"id\":\"1\"}\n\nevent: start\ndata: {\"id\":\"2\"}\n\nevent: start\ndata: {\"id\":\"3\"}\n\n", - status = HttpStatusCode.OK, - headers = sseHeaders, - ) - } - - val events = client.serverSentEvents(baseUrl).toList() - - assertEquals(3, events.size) - events.forEach { assertEquals("start", it.event) } - assertEquals("{\"id\":\"1\"}", events[0].data) - assertEquals("{\"id\":\"2\"}", events[1].data) - assertEquals("{\"id\":\"3\"}", events[2].data) - } - - @Test - fun `event_필드_없는_data만_있는_이벤트는_event가_null이다`() = - runTest { - val client = - buildClient { - respond( - content = "data: hello\n\n", - status = HttpStatusCode.OK, - headers = sseHeaders, - ) - } - - val events = client.serverSentEvents(baseUrl).toList() - - assertEquals(1, events.size) - assertNull(events[0].event) - assertEquals("hello", events[0].data) - } - - @Test - fun `서버가_스트림을_정상_종료하면_Flow가_정상_완료된다`() = - runTest { - val client = - buildClient { - respond( - content = "data: done\n\n", - status = HttpStatusCode.OK, - headers = sseHeaders, - ) - } - - val result = runCatching { client.serverSentEvents(baseUrl).toList() } - - assertTrue(result.isSuccess) - } - - @Test - fun `404_응답은_collect_시점에_ClientRequestException이_throw된다`() = - runTest { - val client = buildClient { respond("Not Found", HttpStatusCode.NotFound) } - - val result = runCatching { client.serverSentEvents(baseUrl).toList() } - - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is ClientRequestException) - } - - @Test - fun `collect_취소시_세션이_닫히고_예외_없이_종료된다`() = - runTest { - val client = - buildClient { - respond( - content = "data: first\n\ndata: second\n\ndata: third\n\n", - status = HttpStatusCode.OK, - headers = sseHeaders, - ) - } - - val result = runCatching { client.serverSentEvents(baseUrl).take(1).toList() } - - assertTrue(result.isSuccess) - assertEquals(1, result.getOrNull()?.size) - } -} From 7e9e597fe927770e76223ffa71a7199a2fb560a9 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 4 Aug 2026 02:47:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20#75=20=EC=B1=84=ED=8C=85=20REST/SSE?= =?UTF-8?q?=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data_remote/dto/chat/ChatEntryResponse.kt | 24 +++++++++++++++ .../dto/chat/ChatMessageResponse.kt | 21 +++++++++++++ .../dto/chat/ChatStreamResponse.kt | 30 +++++++++++++++++++ .../dto/chat/ConversationDetailResponse.kt | 10 +++++++ .../dto/chat/ConversationListResponse.kt | 16 ++++++++++ .../dto/chat/SendChatMessageRequest.kt | 9 ++++++ 6 files changed, 110 insertions(+) create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt create mode 100644 core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/SendChatMessageRequest.kt diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt new file mode 100644 index 0000000..fa0dcfa --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatEntryResponse.kt @@ -0,0 +1,24 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatEntryResponse( + val greeting: String, + val suggestions: List = emptyList(), + val quota: ChatQuotaResponse, +) + +@Serializable +data class ChatSuggestionResponse( + val emoji: String, + val label: String, + val seedPrompt: String, + val category: String, +) + +@Serializable +data class ChatQuotaResponse( + val used: Int, + val limit: Int, +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt new file mode 100644 index 0000000..fd73d96 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatMessageResponse.kt @@ -0,0 +1,21 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatMessageResponse( + val id: String, + val role: String, + val content: String, + val status: String, + val action: ChatActionResponse? = null, + val createdAt: String, +) + +@Serializable +data class ChatActionResponse( + val type: String, + val label: String, + val category: String, + val date: String? = null, +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt new file mode 100644 index 0000000..7d8ced3 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt @@ -0,0 +1,30 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +// SSE 이벤트 data payload. 백엔드 이벤트 DTO와 1:1이며 전 필드 non-null이다. + +@Serializable +data class ChatStreamStartResponse( + val conversationId: String, + val userMessageId: String, + val assistantMessageId: String, + val quotaUsed: Int, + val quotaLimit: Int, +) + +@Serializable +data class ChatStreamDeltaResponse( + val text: String, +) + +@Serializable +data class ChatStreamDoneResponse( + val assistantMessageId: String, +) + +@Serializable +data class ChatStreamErrorResponse( + val code: String? = null, + val message: String? = null, +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt new file mode 100644 index 0000000..f9adb8e --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationDetailResponse.kt @@ -0,0 +1,10 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationDetailResponse( + val id: String, + val title: String, + val messages: List = emptyList(), +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt new file mode 100644 index 0000000..c1fcad7 --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ConversationListResponse.kt @@ -0,0 +1,16 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class ConversationListResponse( + val conversations: List = emptyList(), +) + +@Serializable +data class ConversationSummaryResponse( + val id: String, + val title: String, + val lastMessageAt: String, + val unread: Boolean = false, +) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/SendChatMessageRequest.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/SendChatMessageRequest.kt new file mode 100644 index 0000000..a6b104e --- /dev/null +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/SendChatMessageRequest.kt @@ -0,0 +1,9 @@ +package com.kikidan.data_remote.dto.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class SendChatMessageRequest( + val conversationId: String? = null, + val content: String, +) From c7feba67c004684fb5062edced366e2339f3e73b Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Wed, 5 Aug 2026 03:05:39 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20SSE=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20ChatStreamErrorRes?= =?UTF-8?q?ponse=20=ED=95=84=EB=93=9C=20=EC=86=8D=EC=84=B1=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SseFlow.kt를 ServerSentEvents.kt로 이름을 변경하여 명확성을 높인다. ChatStreamErrorResponse의 code와 message 필드를 Non-nullable로 변경하여 에러 응답 시 필수 정보가 누락되지 않도록 제약을 강화한다. --- .../com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt | 4 ++-- .../data_remote/sse/{SseFlow.kt => ServerSentEvents.kt} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename core/data-remote/src/main/java/com/kikidan/data_remote/sse/{SseFlow.kt => ServerSentEvents.kt} (100%) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt index 7d8ced3..ce36627 100644 --- a/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt +++ b/core/data-remote/src/main/java/com/kikidan/data_remote/dto/chat/ChatStreamResponse.kt @@ -25,6 +25,6 @@ data class ChatStreamDoneResponse( @Serializable data class ChatStreamErrorResponse( - val code: String? = null, - val message: String? = null, + val code: String, + val message: String, ) diff --git a/core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt b/core/data-remote/src/main/java/com/kikidan/data_remote/sse/ServerSentEvents.kt similarity index 100% rename from core/data-remote/src/main/java/com/kikidan/data_remote/sse/SseFlow.kt rename to core/data-remote/src/main/java/com/kikidan/data_remote/sse/ServerSentEvents.kt