feat(chat-room): ChatRepository 도입 및 TalkApi에 입장/메시지 조회 API 추가

- Repository 패턴 구현: 로컬 DB(Room) + 네트워크(TalkApi) 통합
- enterChatRoom, loadMoreMessages, clearAllMessagesOnLogout 제공
- TalkApi에 /enter, /messages 엔드포인트 추가
- Entity↔도메인 매퍼 추가
- Koin 모듈에 ChatRepository 바인딩
This commit is contained in:
2025-08-13 17:30:04 +09:00
parent 725c4335e1
commit 6388895e6e
6 changed files with 163 additions and 2 deletions

View File

@@ -3,11 +3,15 @@ package kr.co.vividnext.sodalive.chat.talk
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomRequest
import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomResponse
import kr.co.vividnext.sodalive.chat.talk.room.ChatMessagesResponse
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomEnterResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface TalkApi {
@GET("/api/chat/room/list")
@@ -20,4 +24,20 @@ interface TalkApi {
@Header("Authorization") authHeader: String,
@Body request: CreateChatRoomRequest
): Single<ApiResponse<CreateChatRoomResponse>>
// 통합 채팅방 입장 API
@GET("/api/chat/rooms/{roomId}/enter")
fun enterChatRoom(
@Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long
): Single<ApiResponse<ChatRoomEnterResponse>>
// 점진적 메시지 로딩 API
@GET("/api/chat/rooms/{roomId}/messages")
fun getChatRoomMessages(
@Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long,
@Query("cursor") cursor: Long?,
@Query("limit") limit: Int = 20
): Single<ApiResponse<ChatMessagesResponse>>
}

View File

@@ -0,0 +1,47 @@
/*
* 보이스온 - 채팅 메시지 Entity/도메인 매퍼
*/
package kr.co.vividnext.sodalive.chat.talk.room
import androidx.annotation.Keep
import kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageEntity
@Keep
fun ChatMessageEntity.toDomain(): ChatMessage {
return ChatMessage(
messageId = messageId,
message = message,
profileImageUrl = profileImageUrl,
mine = mine,
createdAt = createdAt,
status = status,
localId = localId,
isGrouped = false
)
}
@Keep
fun ChatMessage.toEntity(roomId: Long): ChatMessageEntity {
return ChatMessageEntity(
messageId = messageId,
roomId = roomId,
message = message,
profileImageUrl = profileImageUrl,
mine = mine,
createdAt = createdAt,
status = status,
localId = localId
)
}
@Keep
fun ServerChatMessage.toEntity(roomId: Long): ChatMessageEntity {
return ChatMessageEntity(
messageId = messageId,
roomId = roomId,
message = message,
profileImageUrl = profileImageUrl,
mine = mine,
createdAt = createdAt
)
}

View File

@@ -1,5 +1,5 @@
/*
* 보이스온 - 점진적 메시지 로딩 응답 모델
* 보이스온 - 점진적 메시지 로딩 응답 DTO
*/
package kr.co.vividnext.sodalive.chat.talk.room

View File

@@ -0,0 +1,90 @@
/*
* 보이스온 - 채팅방 ChatRepository (Repository 패턴)
* - 로컬 DB(Room) + 네트워크(TalkApi) 통합 관리
* - 통합 채팅방 입장 API 호출 및 로컬 동기화
* - 점진적 메시지 로딩
* - 로그아웃 시 로컬 메시지 삭제
*/
package kr.co.vividnext.sodalive.chat.talk.room
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageDao
import kr.co.vividnext.sodalive.common.ApiResponse
import java.util.concurrent.Callable
class ChatRepository(
private val chatDao: ChatMessageDao,
private val talkApi: TalkApi
) {
/**
* 로컬에서 최근 20개 메시지 조회
*/
fun getRecentMessagesFromLocal(roomId: Long): Single<List<ChatMessage>> {
return Single.fromCallable(Callable {
runCatching {
val entities = kotlinx.coroutines.runBlocking { chatDao.getRecentMessages(roomId) }
entities.map { it.toDomain() }
}.getOrDefault(emptyList())
}).subscribeOn(Schedulers.io())
}
/**
* 통합 채팅방 입장: 서버 캐릭터 정보 + 최신 메시지 수신 후 로컬 DB 업데이트
* - 로컬 데이터가 없더라도 서버 응답을 기준으로 동기화
*/
fun enterChatRoom(token: String, roomId: Long): Single<ChatRoomEnterResponse> {
return talkApi.enterChatRoom(authHeader = token, roomId = roomId)
.subscribeOn(Schedulers.io())
.flatMap { response ->
val data = ensureSuccess(response)
// 로컬 DB 동기화
val entities = data.messages.map { it.toEntity(roomId) }
Single.fromCallable {
kotlinx.coroutines.runBlocking { chatDao.insertMessages(entities) }
data
}.subscribeOn(Schedulers.io())
}
}
/**
* 점진적 메시지 로딩: 커서 기반으로 이전 메시지를 조회
*/
fun loadMoreMessages(token: String, roomId: Long, cursor: Long?): Single<ChatMessagesResponse> {
return talkApi.getChatRoomMessages(authHeader = token, roomId = roomId, cursor = cursor, limit = 20)
.subscribeOn(Schedulers.io())
.flatMap { response ->
val data = ensureMessagesSuccess(response)
val entities = data.messages.map { it.toEntity(roomId) }
Single.fromCallable {
kotlinx.coroutines.runBlocking { chatDao.insertMessages(entities) }
data
}.subscribeOn(Schedulers.io())
}
}
/**
* 로그아웃 시 모든 로컬 메시지 삭제
*/
fun clearAllMessagesOnLogout(): Completable {
return Completable.fromAction {
kotlinx.coroutines.runBlocking { chatDao.deleteAllMessages() }
}.subscribeOn(Schedulers.io())
}
private fun <T> ensureSuccess(response: ApiResponse<T>): T {
if (!response.success || response.data == null) {
throw IllegalStateException(response.message ?: "API response error")
}
return response.data
}
private fun ensureMessagesSuccess(response: ApiResponse<ChatMessagesResponse>): ChatMessagesResponse {
if (!response.success || response.data == null) {
throw IllegalStateException(response.message ?: "API response error")
}
return response.data
}
}

View File

@@ -1,5 +1,5 @@
/*
* 보이스온 - 통합 채팅방 입장 응답 모델
* 보이스온 - 통합 채팅방 입장 응답 DTO
*/
package kr.co.vividnext.sodalive.chat.talk.room

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.chat.talk.room
import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageDatabase
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
@@ -10,4 +11,7 @@ val chatTalkRoomModule = module {
// DAO
single { get<ChatMessageDatabase>().chatMessageDao() }
// Repository
single { ChatRepository(chatDao = get(), talkApi = get<TalkApi>()) }
}