diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/UserChatSseEvents.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/UserChatSseEvents.kt new file mode 100644 index 0000000..4fb9ac3 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/UserChatSseEvents.kt @@ -0,0 +1,34 @@ +package com.back.koreaTravelGuide.domain.userChat + +import org.springframework.stereotype.Component +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.util.concurrent.ConcurrentHashMap + +// Websocket,Stomp 사용 전 임시로 만들었음 +// 테스트 후 제거 예정 + +@Component +class UserChatSseEvents { + private val emitters = ConcurrentHashMap>() + + fun subscribe(roomId: Long): SseEmitter { + val emitter = SseEmitter(0L) + emitters.computeIfAbsent(roomId) { mutableListOf() }.add(emitter) + emitter.onCompletion { emitters[roomId]?.remove(emitter) } + emitter.onTimeout { emitter.complete() } + return emitter + } + + fun publishNew( + roomId: Long, + lastMessageId: Long, + ) { + emitters[roomId]?.toList()?.forEach { + try { + it.send(SseEmitter.event().name("NEW").data(lastMessageId)) + } catch (_: Exception) { + it.complete() + } + } + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt index ccd0e28..991554a 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt @@ -21,7 +21,7 @@ data class ChatMessage( val roomId: Long, @Column(name = "sender_id", nullable = false) val senderId: Long, - @Column(columnDefinition = "text", nullable = false) + @Column(nullable = false, columnDefinition = "text") val content: String, @Column(name = "created_at", nullable = false) val createdAt: Instant = Instant.now(), diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/repository/ChatMessageRepository.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/repository/ChatMessageRepository.kt index 651ed17..b32d0b1 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/repository/ChatMessageRepository.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/repository/ChatMessageRepository.kt @@ -12,4 +12,6 @@ interface ChatMessageRepository : JpaRepository { roomId: Long, afterId: Long, ): List + + fun deleteByRoomId(roomId: Long) } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt new file mode 100644 index 0000000..26f73a0 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt @@ -0,0 +1,42 @@ +package com.back.koreaTravelGuide.domain.userChat.chatmessage.service + +import com.back.koreaTravelGuide.domain.userChat.chatmessage.entity.ChatMessage +import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository +import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ChatMessageService( + private val msgRepository: ChatMessageRepository, + private val roomRepository: ChatRoomRepository, +) { + data class SendMessageReq(val senderId: Long, val content: String) + + fun getlistbefore( + roomId: Long, + limit: Int, + ): List = msgRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed() + + fun getlistafter( + roomId: Long, + afterId: Long, + ): List = msgRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId) + + @Transactional + fun deleteByRoom(roomId: Long) { + msgRepository.deleteByRoomId(roomId) + } + + @Transactional + fun send( + roomId: Long, + req: SendMessageReq, + ): ChatMessage { + val saved = msgRepository.save(ChatMessage(roomId = roomId, senderId = req.senderId, content = req.content)) + roomRepository.findById(roomId).ifPresent { + roomRepository.save(it.copy(updatedAt = saved.createdAt, lastMessageId = saved.id)) + } + return saved + } +} 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 new file mode 100644 index 0000000..432b365 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt @@ -0,0 +1,85 @@ +package com.back.koreaTravelGuide.domain.userChat.chatroom.controller + +import com.back.koreaTravelGuide.common.ApiResponse +import com.back.koreaTravelGuide.domain.userChat.UserChatSseEvents +import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService +import com.back.koreaTravelGuide.domain.userChat.chatroom.service.ChatRoomService +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +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 +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter + +// 컨트롤러는 임시로 강사님 스타일 따라서 통합해놓았음. 추후 리팩토링 예정 +@RestController +@RequestMapping("/api/userchat/rooms") +class ChatRoomController( + private val roomSvc: ChatRoomService, + private val msgSvc: ChatMessageService, + private val events: UserChatSseEvents, +) { + data class StartChatReq(val guideId: Long, val userId: Long) + + data class DeleteChatReq(val userId: Long) + + // MVP: 같은 페어는 방 재사용 + @PostMapping("/start") + fun startChat( + @RequestBody req: StartChatReq, + ): ResponseEntity>> { + val roomId = roomSvc.exceptOneToOneRoom(req.guideId, req.userId).id!! + return ResponseEntity.ok(ApiResponse(msg = "채팅방 시작", data = mapOf("roomId" to roomId))) + } + + @DeleteMapping("/{roomId}") + fun deleteRoom( + @PathVariable roomId: Long, + @RequestBody req: DeleteChatReq, + ): ResponseEntity> { + roomSvc.deleteByOwner(roomId, req.userId) + return ResponseEntity.ok(ApiResponse("채팅방 삭제 완료")) + } + + @GetMapping("/{roomId}") + fun get( + @PathVariable roomId: Long, + ) = ResponseEntity.ok(ApiResponse(msg = "채팅방 조회", data = roomSvc.get(roomId))) + + @GetMapping("/{roomId}/messages") + fun listMessages( + @PathVariable roomId: Long, + @RequestParam(required = false) after: Long?, + @RequestParam(defaultValue = "50") limit: Int, + ): ResponseEntity> { + val messages = + if (after == null) { + msgSvc.getlistbefore(roomId, limit) + } else { + msgSvc.getlistafter(roomId, after) + } + return ResponseEntity.ok(ApiResponse(msg = "메시지 조회", data = messages)) + } + + @PostMapping("/{roomId}/messages") + fun sendMessage( + @PathVariable roomId: Long, + @RequestBody req: ChatMessageService.SendMessageReq, + ): ResponseEntity> { + val saved = msgSvc.send(roomId, req) + events.publishNew(roomId, saved.id!!) + return ResponseEntity.status(201).body(ApiResponse(msg = "메시지 전송", data = saved)) + } + + // SSE는 스트림이여서 ApiResponse로 감싸지 않았음 + // WebSocket,Stomp 적용되면 바로 삭제 예정 + @GetMapping("/{roomId}/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) + fun subscribe( + @PathVariable roomId: Long, + ): SseEmitter = events.subscribe(roomId) +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt index 9a581c5..09fa508 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt @@ -19,8 +19,10 @@ data class ChatRoom( val id: Long? = null, @Column(nullable = false) val title: String, - @Column(name = "owner_id", nullable = false) - val ownerId: Long, + @Column(name = "guide_id", nullable = false) + val guideId: Long, + @Column(name = "user_id", nullable = false) + val userId: Long, @Column(name = "updated_at", nullable = false) val updatedAt: Instant = Instant.now(), @Column(name = "last_message_id") 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 57287e7..1174fcd 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 @@ -2,7 +2,21 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.repository import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository -interface ChatRoomRepository : JpaRepository +interface ChatRoomRepository : JpaRepository { + // 가이드,유저 방 생성시 중복 생성 방지 + @Query( + """ + select r from ChatRoom r + where (r.guideId = :guideId and r.userId = :userId) + or (r.guideId = :userId and r.userId = :guideId) + """, + ) + fun findOneToOneRoom( + guideId: Long, + userId: Long, + ): ChatRoom? +} 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 new file mode 100644 index 0000000..75a0965 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt @@ -0,0 +1,44 @@ +package com.back.koreaTravelGuide.domain.userChat.chatroom.service + +import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom +import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Instant + +@Service +class ChatRoomService( + private val roomRepository: ChatRoomRepository, +) { + data class CreateRoomReq(val title: String, val guideId: Long, val userId: Long) + + @Transactional + fun exceptOneToOneRoom( + guideId: Long, + userId: Long, + ): ChatRoom { + // 1) 기존 방 재사용 + roomRepository.findOneToOneRoom(guideId, userId)?.let { return it } + + // 2) 없으면 생성 (동시요청은 DB 유니크 인덱스로 가드) + val title = "Guide-$guideId · User-$userId" + return roomRepository.save( + ChatRoom(title = title, guideId = guideId, userId = userId, updatedAt = Instant.now()), + ) + } + + fun get(roomId: Long): ChatRoom = roomRepository.findById(roomId).orElseThrow { NoSuchElementException("room not found: $roomId") } + + @Transactional + fun deleteByOwner( + roomId: Long, + requesterId: Long, + ) { + val room = get(roomId) + if (room.userId != requesterId) { + // 예외처리 임시 + throw IllegalArgumentException("채팅방 생성자만 삭제할 수 있습니다.") + } + roomRepository.deleteById(roomId) + } +}