Skip to content

Commit 3e7b889

Browse files
committed
feat(be): Add userChat domain REST API
1 parent 02769ce commit 3e7b889

File tree

5 files changed

+140
-0
lines changed

5 files changed

+140
-0
lines changed

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.back.koreaTravelGuide.domain.userChat.chatroom.controller
22

33
import com.back.koreaTravelGuide.common.ApiResponse
4+
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomListResponse
45
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomResponse
56
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomStartRequest
67
import com.back.koreaTravelGuide.domain.userChat.chatroom.service.ChatRoomService
@@ -13,13 +14,26 @@ import org.springframework.web.bind.annotation.PathVariable
1314
import org.springframework.web.bind.annotation.PostMapping
1415
import org.springframework.web.bind.annotation.RequestBody
1516
import org.springframework.web.bind.annotation.RequestMapping
17+
import org.springframework.web.bind.annotation.RequestParam
1618
import org.springframework.web.bind.annotation.RestController
1719

1820
@RestController
1921
@RequestMapping("/api/userchat/rooms")
2022
class ChatRoomController(
2123
private val roomService: ChatRoomService,
2224
) {
25+
@GetMapping
26+
fun listRooms(
27+
@AuthenticationPrincipal requesterId: Long?,
28+
@RequestParam(required = false, defaultValue = "20") limit: Int,
29+
@RequestParam(required = false) cursor: String?,
30+
): ResponseEntity<ApiResponse<ChatRoomListResponse>> {
31+
val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.")
32+
val safeLimit = limit.coerceIn(1, 100)
33+
val response = roomService.listRooms(authenticatedId, safeLimit, cursor)
34+
return ResponseEntity.ok(ApiResponse(msg = "채팅방 목록 조회", data = response))
35+
}
36+
2337
// 같은 페어는 방 재사용
2438
@PostMapping("/start")
2539
fun startChat(
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.back.koreaTravelGuide.domain.userChat.chatroom.dto
2+
3+
data class ChatRoomListResponse(
4+
val rooms: List<ChatRoomResponse>,
5+
val nextCursor: String?,
6+
)

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/repository/ChatRoomRepository.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.back.koreaTravelGuide.domain.userChat.chatroom.repository
22

33
import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom
4+
import org.springframework.data.domain.Pageable
45
import org.springframework.data.jpa.repository.JpaRepository
56
import org.springframework.data.jpa.repository.Query
7+
import org.springframework.data.repository.query.Param
68
import org.springframework.stereotype.Repository
79

810
@Repository
@@ -19,4 +21,23 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
1921
guideId: Long,
2022
userId: Long,
2123
): ChatRoom?
24+
25+
@Query(
26+
"""
27+
select r from ChatRoom r
28+
where (r.guideId = :memberId or r.userId = :memberId)
29+
and (
30+
:cursorUpdatedAt is null
31+
or r.updatedAt < :cursorUpdatedAt
32+
or (r.updatedAt = :cursorUpdatedAt and r.id < :cursorRoomId)
33+
)
34+
order by r.updatedAt desc, r.id desc
35+
""",
36+
)
37+
fun findPagedByMember(
38+
@Param("memberId") memberId: Long,
39+
@Param("cursorUpdatedAt") cursorUpdatedAt: java.time.ZonedDateTime?,
40+
@Param("cursorRoomId") cursorRoomId: Long?,
41+
pageable: Pageable,
42+
): List<ChatRoom>
2243
}

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package com.back.koreaTravelGuide.domain.userChat.chatroom.service
22

33
import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository
4+
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomListResponse
5+
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomResponse
46
import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom
57
import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository
8+
import org.springframework.data.domain.PageRequest
69
import org.springframework.security.access.AccessDeniedException
710
import org.springframework.stereotype.Service
811
import org.springframework.transaction.annotation.Transactional
@@ -62,6 +65,47 @@ class ChatRoomService(
6265
roomRepository.deleteById(roomId)
6366
}
6467

68+
@Transactional(readOnly = true)
69+
fun listRooms(
70+
requesterId: Long,
71+
limit: Int,
72+
cursor: String?,
73+
): ChatRoomListResponse {
74+
val (cursorUpdatedAt, cursorRoomId) = parseCursor(cursor)
75+
val pageable = PageRequest.of(0, limit)
76+
val rooms = roomRepository.findPagedByMember(requesterId, cursorUpdatedAt, cursorRoomId, pageable)
77+
val roomResponses = rooms.map(ChatRoomResponse::from)
78+
val nextCursor =
79+
if (roomResponses.size < limit) {
80+
null
81+
} else {
82+
roomResponses.lastOrNull()?.let { last ->
83+
last.id?.let { "${last.updatedAt}|$it" }
84+
}
85+
}
86+
87+
return ChatRoomListResponse(
88+
rooms = roomResponses,
89+
nextCursor = nextCursor,
90+
)
91+
}
92+
93+
private fun parseCursor(cursor: String?): Pair<ZonedDateTime?, Long?> {
94+
if (cursor.isNullOrBlank()) {
95+
return null to null
96+
}
97+
98+
val parts = cursor.split("|")
99+
if (parts.size != 2) {
100+
return null to null
101+
}
102+
103+
val updatedAt = runCatching { ZonedDateTime.parse(parts[0]) }.getOrNull()
104+
val roomId = runCatching { parts[1].toLong() }.getOrNull()
105+
106+
return updatedAt to roomId
107+
}
108+
65109
private fun checkParticipant(
66110
guideId: Long,
67111
userId: Long,

src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomControllerTest.kt

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom
99
import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository
1010
import com.fasterxml.jackson.databind.ObjectMapper
1111
import org.junit.jupiter.api.Assertions.assertFalse
12+
import org.junit.jupiter.api.Assertions.assertTrue
1213
import org.junit.jupiter.api.BeforeEach
1314
import org.junit.jupiter.api.DisplayName
1415
import org.junit.jupiter.api.Test
@@ -24,6 +25,8 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
2425
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
2526
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
2627
import org.springframework.transaction.annotation.Transactional
28+
import java.time.ZoneId
29+
import java.time.ZonedDateTime
2730

2831
@ActiveProfiles("test")
2932
@SpringBootTest
@@ -87,6 +90,58 @@ class ChatRoomControllerTest {
8790
)
8891
}
8992

93+
@Test
94+
@DisplayName("listRooms returns paginated rooms with cursor")
95+
fun listRoomsReturnsPaginatedRooms() {
96+
val baseTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul"))
97+
val extraRooms =
98+
(1..6).map { idx ->
99+
val extraGuide =
100+
userRepository.save(
101+
User(
102+
email = "guide$idx@test.com",
103+
nickname = "guide$idx",
104+
role = UserRole.GUIDE,
105+
oauthProvider = "test",
106+
oauthId = "guide$idx",
107+
),
108+
)
109+
chatRoomRepository.save(
110+
ChatRoom(
111+
title = "Guide-${extraGuide.id} · User-${guest.id}",
112+
guideId = extraGuide.id!!,
113+
userId = guest.id!!,
114+
updatedAt = baseTime.minusMinutes(idx.toLong()),
115+
),
116+
)
117+
}
118+
119+
val firstPage =
120+
mockMvc.perform(
121+
get("/api/userchat/rooms")
122+
.header("Authorization", "Bearer $guestToken")
123+
.param("limit", "3"),
124+
)
125+
.andExpect(status().isOk)
126+
.andExpect(jsonPath("$.data.rooms.length()").value(3))
127+
.andExpect(jsonPath("$.data.rooms[0].id").value(existingRoom.id!!.toInt()))
128+
.andReturn()
129+
130+
val firstCursorNode = objectMapper.readTree(firstPage.response.contentAsString)["data"]["nextCursor"]
131+
assertTrue(firstCursorNode != null && !firstCursorNode.isNull)
132+
val firstCursor = firstCursorNode.asText()
133+
134+
mockMvc.perform(
135+
get("/api/userchat/rooms")
136+
.header("Authorization", "Bearer $guestToken")
137+
.param("limit", "3")
138+
.param("cursor", firstCursor),
139+
)
140+
.andExpect(status().isOk)
141+
.andExpect(jsonPath("$.data.rooms.length()").value(3))
142+
.andExpect(jsonPath("$.data.rooms[0].id").value(extraRooms[2].id!!.toInt()))
143+
}
144+
90145
@Test
91146
@DisplayName("startChat returns existing room for participant")
92147
fun startChatReturnsExistingRoom() {

0 commit comments

Comments
 (0)