diff --git a/docs/api-specification.yaml b/docs/api-specification.yaml index e75ccdb..a5f4203 100644 --- a/docs/api-specification.yaml +++ b/docs/api-specification.yaml @@ -48,7 +48,7 @@ components: - role properties: role: - $ref: '#/components/schemas/UserRole' + $ref: "#/components/schemas/UserRole" LoginResponse: type: object @@ -85,7 +85,7 @@ components: nullable: true description: 프로필 이미지 URL role: - $ref: '#/components/schemas/UserRole' + $ref: "#/components/schemas/UserRole" location: type: string nullable: true @@ -119,7 +119,7 @@ components: format: uri nullable: true role: - $ref: '#/components/schemas/UserRole' + $ref: "#/components/schemas/UserRole" UserUpdateRequest: type: object @@ -262,10 +262,10 @@ components: format: int64 description: 평가 고유 식별자 user: - $ref: '#/components/schemas/UserResponse' + $ref: "#/components/schemas/UserResponse" description: 평가를 남긴 사용자 targetType: - $ref: '#/components/schemas/RateTargetType' + $ref: "#/components/schemas/RateTargetType" targetId: type: integer format: int64 @@ -325,7 +325,7 @@ components: ratings: type: array items: - $ref: '#/components/schemas/RateResponse' + $ref: "#/components/schemas/RateResponse" securitySchemes: BearerAuth: @@ -353,20 +353,20 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserRoleUpdateRequest' + $ref: "#/components/schemas/UserRoleUpdateRequest" responses: - '200': + "200": description: 역할 선택 및 로그인 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/LoginResponse' - '401': + $ref: "#/components/schemas/LoginResponse" + "401": description: 인증 실패 (유효하지 않은 registerToken) /api/auth/logout: @@ -378,12 +378,12 @@ paths: security: - BearerAuth: [] responses: - '200': + "200": description: 로그아웃 성공 content: application/json: schema: - $ref: '#/components/schemas/ApiResponse' + $ref: "#/components/schemas/ApiResponse" /api/auth/refresh: post: @@ -393,18 +393,18 @@ paths: description: 쿠키에 담긴 Refresh Token을 사용하여 만료된 Access Token을 재발급합니다. security: [] responses: - '200': + "200": description: 토큰 재발급 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/AccessTokenResponse' - '401': + $ref: "#/components/schemas/AccessTokenResponse" + "401": description: 유효하지 않은 Refresh Token # =================== @@ -419,17 +419,17 @@ paths: security: - BearerAuth: [] responses: - '200': + "200": description: 내 정보 조회 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/UserResponse' + $ref: "#/components/schemas/UserResponse" patch: tags: - users @@ -442,19 +442,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserUpdateRequest' + $ref: "#/components/schemas/UserUpdateRequest" responses: - '200': + "200": description: 프로필 수정 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/UserResponse' + $ref: "#/components/schemas/UserResponse" delete: tags: - users @@ -463,12 +463,12 @@ paths: security: - BearerAuth: [] responses: - '200': + "200": description: 회원 탈퇴 성공 content: application/json: schema: - $ref: '#/components/schemas/ApiResponse' + $ref: "#/components/schemas/ApiResponse" # =================== # AI Chat Domain APIs @@ -488,19 +488,19 @@ paths: format: int64 description: 사용자 ID responses: - '200': + "200": description: 세션 목록 조회 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: type: array items: - $ref: '#/components/schemas/SessionsResponse' + $ref: "#/components/schemas/SessionsResponse" post: tags: @@ -516,17 +516,17 @@ paths: format: int64 description: 사용자 ID responses: - '200': + "200": description: 세션 생성 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/SessionsResponse' + $ref: "#/components/schemas/SessionsResponse" /api/aichat/sessions/{sessionId}: delete: @@ -550,13 +550,13 @@ paths: format: int64 description: 사용자 ID responses: - '200': + "200": description: 세션 삭제 완료 content: application/json: schema: - $ref: '#/components/schemas/ApiResponse' - '404': + $ref: "#/components/schemas/ApiResponse" + "404": description: 세션을 찾을 수 없음 /api/aichat/sessions/{sessionId}/messages: @@ -581,19 +581,19 @@ paths: format: int64 description: 사용자 ID responses: - '200': + "200": description: 채팅 기록 조회 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: type: array items: - $ref: '#/components/schemas/SessionMessagesResponse' + $ref: "#/components/schemas/SessionMessagesResponse" post: tags: @@ -628,17 +628,17 @@ paths: type: string description: 사용자 메시지 내용 responses: - '200': + "200": description: 메시지 전송 및 AI 응답 완료 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/AiChatResponse' + $ref: "#/components/schemas/AiChatResponse" /api/aichat/sessions/{sessionId}/title: patch: @@ -675,20 +675,20 @@ paths: maxLength: 100 description: 새로운 세션 제목 responses: - '200': + "200": description: 제목 수정 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/UpdateSessionTitleResponse' - '404': + $ref: "#/components/schemas/UpdateSessionTitleResponse" + "404": description: 세션을 찾을 수 없음 - '403': + "403": description: 권한 없음 # =================== @@ -720,13 +720,13 @@ paths: type: string description: "커서 기반 페이지네이션 포인터. '{updatedAt ISO-8601}|{roomId}' 형식이며 이전 응답의 nextCursor를 그대로 전달합니다." responses: - '200': + "200": description: 채팅방 목록 조회 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: @@ -735,7 +735,7 @@ paths: rooms: type: array items: - $ref: '#/components/schemas/ChatRoomResponse' + $ref: "#/components/schemas/ChatRoomResponse" nextCursor: type: string nullable: true @@ -754,20 +754,20 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ChatRoomStartRequest' + $ref: "#/components/schemas/ChatRoomStartRequest" responses: - '200': + "200": description: 채팅방 생성 또는 기존 방 반환 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/ChatRoomResponse' - '403': + $ref: "#/components/schemas/ChatRoomResponse" + "403": description: 인증되지 않은 사용자 또는 참여자가 아닙니다. /api/userchat/rooms/{roomId}: @@ -787,20 +787,20 @@ paths: format: int64 description: 채팅방 ID responses: - '200': + "200": description: 채팅방 조회 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/ChatRoomResponse' - '403': + $ref: "#/components/schemas/ChatRoomResponse" + "403": description: 채팅방 참여자가 아닙니다. - '404': + "404": description: 채팅방을 찾을 수 없음 delete: @@ -819,15 +819,15 @@ paths: format: int64 description: 채팅방 ID responses: - '200': + "200": description: 채팅방 삭제 완료 content: application/json: schema: - $ref: '#/components/schemas/ApiResponse' - '403': + $ref: "#/components/schemas/ApiResponse" + "403": description: 채팅방 생성자가 아닙니다. - '404': + "404": description: 채팅방을 찾을 수 없음 /api/userchat/rooms/{roomId}/messages: @@ -864,22 +864,22 @@ paths: maximum: 200 description: 최대 조회 건수 (기본 50) responses: - '200': + "200": description: 메시지 조회 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: type: array items: - $ref: '#/components/schemas/ChatMessageResponse' - '403': + $ref: "#/components/schemas/ChatMessageResponse" + "403": description: 채팅방 참여자가 아닙니다. - '404': + "404": description: 채팅방을 찾을 수 없음 post: @@ -902,22 +902,22 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ChatMessageSendRequest' + $ref: "#/components/schemas/ChatMessageSendRequest" responses: - '201': + "201": description: 메시지 전송 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/ChatMessageResponse' - '403': + $ref: "#/components/schemas/ChatMessageResponse" + "403": description: 채팅방 참여자가 아닙니다. - '404': + "404": description: 채팅방을 찾을 수 없음 # =================== @@ -944,19 +944,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RateRequest' + $ref: "#/components/schemas/RateRequest" responses: - '200': + "200": description: 평가 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/RateResponse' + $ref: "#/components/schemas/RateResponse" /api/rate/aichat/sessions/{sessionId}: put: @@ -979,19 +979,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RateRequest' + $ref: "#/components/schemas/RateRequest" responses: - '200': + "200": description: 평가 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/RateResponse' + $ref: "#/components/schemas/RateResponse" /api/rate/guides/my: get: @@ -1002,17 +1002,17 @@ paths: security: - BearerAuth: [] responses: - '200': + "200": description: 내 평가 목록 조회 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: - $ref: '#/components/schemas/GuideRatingSummaryResponse' + $ref: "#/components/schemas/GuideRatingSummaryResponse" /api/rate/admin/aichat/sessions: get: @@ -1039,20 +1039,17 @@ paths: name: sort schema: type: string - description: '정렬 기준 (예: createdAt,desc)' + description: "정렬 기준 (예: createdAt,desc)" responses: - '200': + "200": description: AI 채팅 평가 목록 조회 성공 content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiResponse' + - $ref: "#/components/schemas/ApiResponse" - type: object properties: data: type: object description: Page 형태 - - - diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt index 553b123..5d51364 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt @@ -1,6 +1,7 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.controller import com.back.koreaTravelGuide.common.ApiResponse +import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomListResponse import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomResponse import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomStartRequest import com.back.koreaTravelGuide.domain.userChat.chatroom.service.ChatRoomService @@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController @@ -20,6 +22,18 @@ import org.springframework.web.bind.annotation.RestController class ChatRoomController( private val roomService: ChatRoomService, ) { + @GetMapping + fun listRooms( + @AuthenticationPrincipal requesterId: Long?, + @RequestParam(required = false, defaultValue = "20") limit: Int, + @RequestParam(required = false) cursor: String?, + ): ResponseEntity> { + val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.") + val safeLimit = limit.coerceIn(1, 100) + val response = roomService.listRooms(authenticatedId, safeLimit, cursor) + return ResponseEntity.ok(ApiResponse(msg = "채팅방 목록 조회", data = response)) + } + // 같은 페어는 방 재사용 @PostMapping("/start") fun startChat( diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomListResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomListResponse.kt new file mode 100644 index 0000000..c96ae44 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomListResponse.kt @@ -0,0 +1,6 @@ +package com.back.koreaTravelGuide.domain.userChat.chatroom.dto + +data class ChatRoomListResponse( + val rooms: List, + val nextCursor: String?, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/repository/ChatRoomRepository.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/repository/ChatRoomRepository.kt index 1174fcd..43ba4b1 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/repository/ChatRoomRepository.kt @@ -1,8 +1,10 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.repository import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @Repository @@ -19,4 +21,23 @@ interface ChatRoomRepository : JpaRepository { guideId: Long, userId: Long, ): ChatRoom? + + @Query( + """ + select r from ChatRoom r + where (r.guideId = :memberId or r.userId = :memberId) + and ( + :cursorUpdatedAt is null + or r.updatedAt < :cursorUpdatedAt + or (r.updatedAt = :cursorUpdatedAt and r.id < :cursorRoomId) + ) + order by r.updatedAt desc, r.id desc + """, + ) + fun findPagedByMember( + @Param("memberId") memberId: Long, + @Param("cursorUpdatedAt") cursorUpdatedAt: java.time.ZonedDateTime?, + @Param("cursorRoomId") cursorRoomId: Long?, + pageable: Pageable, + ): List } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt index 82ae3f0..778045a 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt @@ -1,8 +1,11 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.service import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository +import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomListResponse +import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomResponse import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository +import org.springframework.data.domain.PageRequest import org.springframework.security.access.AccessDeniedException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -62,6 +65,47 @@ class ChatRoomService( roomRepository.deleteById(roomId) } + @Transactional(readOnly = true) + fun listRooms( + requesterId: Long, + limit: Int, + cursor: String?, + ): ChatRoomListResponse { + val (cursorUpdatedAt, cursorRoomId) = parseCursor(cursor) + val pageable = PageRequest.of(0, limit) + val rooms = roomRepository.findPagedByMember(requesterId, cursorUpdatedAt, cursorRoomId, pageable) + val roomResponses = rooms.map(ChatRoomResponse::from) + val nextCursor = + if (roomResponses.size < limit) { + null + } else { + roomResponses.lastOrNull()?.let { last -> + last.id?.let { "${last.updatedAt}|$it" } + } + } + + return ChatRoomListResponse( + rooms = roomResponses, + nextCursor = nextCursor, + ) + } + + private fun parseCursor(cursor: String?): Pair { + if (cursor.isNullOrBlank()) { + return null to null + } + + val parts = cursor.split("|") + if (parts.size != 2) { + return null to null + } + + val updatedAt = runCatching { ZonedDateTime.parse(parts[0]) }.getOrNull() + val roomId = runCatching { parts[1].toLong() }.getOrNull() + + return updatedAt to roomId + } + private fun checkParticipant( guideId: Long, userId: Long, diff --git a/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomControllerTest.kt b/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomControllerTest.kt index ec43791..2967c13 100644 --- a/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomControllerTest.kt +++ b/src/test/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomControllerTest.kt @@ -9,6 +9,7 @@ import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository import com.fasterxml.jackson.databind.ObjectMapper import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -24,6 +25,8 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional +import java.time.ZoneId +import java.time.ZonedDateTime @ActiveProfiles("test") @SpringBootTest @@ -87,6 +90,58 @@ class ChatRoomControllerTest { ) } + @Test + @DisplayName("listRooms returns paginated rooms with cursor") + fun listRoomsReturnsPaginatedRooms() { + val baseTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")) + val extraRooms = + (1..6).map { idx -> + val extraGuide = + userRepository.save( + User( + email = "guide$idx@test.com", + nickname = "guide$idx", + role = UserRole.GUIDE, + oauthProvider = "test", + oauthId = "guide$idx", + ), + ) + chatRoomRepository.save( + ChatRoom( + title = "Guide-${extraGuide.id} · User-${guest.id}", + guideId = extraGuide.id!!, + userId = guest.id!!, + updatedAt = baseTime.minusMinutes(idx.toLong()), + ), + ) + } + + val firstPage = + mockMvc.perform( + get("/api/userchat/rooms") + .header("Authorization", "Bearer $guestToken") + .param("limit", "3"), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.rooms.length()").value(3)) + .andExpect(jsonPath("$.data.rooms[0].id").value(existingRoom.id!!.toInt())) + .andReturn() + + val firstCursorNode = objectMapper.readTree(firstPage.response.contentAsString)["data"]["nextCursor"] + assertTrue(firstCursorNode != null && !firstCursorNode.isNull) + val firstCursor = firstCursorNode.asText() + + mockMvc.perform( + get("/api/userchat/rooms") + .header("Authorization", "Bearer $guestToken") + .param("limit", "3") + .param("cursor", firstCursor), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.rooms.length()").value(3)) + .andExpect(jsonPath("$.data.rooms[0].id").value(extraRooms[2].id!!.toInt())) + } + @Test @DisplayName("startChat returns existing room for participant") fun startChatReturnsExistingRoom() {