From 62a2ef6c5c3b46d412d58300d70182eaf81860cd Mon Sep 17 00:00:00 2001 From: beekeeper24 Date: Fri, 26 Sep 2025 18:04:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(be):=20userChat=20service,controller,S?= =?UTF-8?q?SE=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/userChat/UserChatSseEvents.kt | 34 ++++++++++++ .../chatmessage/service/ChatMessageService.kt | 37 +++++++++++++ .../chatroom/controller/ChatRoomController.kt | 55 +++++++++++++++++++ .../chatroom/service/ChatRoomService.kt | 20 +++++++ 4 files changed, 146 insertions(+) create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/UserChatSseEvents.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt create mode 100644 src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt 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/service/ChatMessageService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt new file mode 100644 index 0000000..fda8d57 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt @@ -0,0 +1,37 @@ +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 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..fcce087 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt @@ -0,0 +1,55 @@ +package com.back.koreaTravelGuide.domain.userChat.chatroom.controller + +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.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, +) { + // Room API + @PostMapping + fun create( + @RequestBody req: ChatRoomService.CreateRoomReq, + ) = roomSvc.create(req) + + @GetMapping("/{roomId}") + fun get( + @PathVariable roomId: Long, + ) = roomSvc.get(roomId) + + // Message API (룸 하위 리소스) + @GetMapping("/{roomId}/messages") + fun listMessages( + @PathVariable roomId: Long, + @RequestParam(required = false) after: Long?, + @RequestParam(defaultValue = "50") limit: Int, + ) = if (after == null) msgSvc.getlistbefore(roomId, limit) else msgSvc.getlistafter(roomId, after) + + @PostMapping("/{roomId}/messages") + fun sendMessage( + @PathVariable roomId: Long, + @RequestBody req: ChatMessageService.SendMessageReq, + ) = msgSvc.send(roomId, req).also { saved -> + events.publishNew(roomId, saved.id!!) + } + + // SSE 구독도 여기 포함 + @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/service/ChatRoomService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt new file mode 100644 index 0000000..28f8434 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt @@ -0,0 +1,20 @@ +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 ownerId: Long) + + @Transactional + fun create(req: CreateRoomReq): ChatRoom = + roomRepository.save(ChatRoom(title = req.title, ownerId = req.ownerId, updatedAt = Instant.now())) + + fun get(roomId: Long): ChatRoom = roomRepository.findById(roomId).orElseThrow { NoSuchElementException("room not found: $roomId") } +} From bb2efe5b68219639e89e2783279c5be4349403bd Mon Sep 17 00:00:00 2001 From: beekeeper24 Date: Sun, 28 Sep 2025 16:04:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(be)=20:=20controller,service,repositor?= =?UTF-8?q?y=20=ED=8C=80=20=EC=8A=A4=ED=83=80=EC=9D=BC=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatmessage/entity/ChatMessage.kt | 2 +- .../repository/ChatMessageRepository.kt | 2 + .../chatmessage/service/ChatMessageService.kt | 5 ++ .../chatroom/controller/ChatRoomController.kt | 50 +++++++++++++++---- .../userChat/chatroom/entity/ChatRoom.kt | 6 ++- .../chatroom/repository/ChatRoomRepository.kt | 16 +++++- .../chatroom/service/ChatRoomService.kt | 30 +++++++++-- 7 files changed, 94 insertions(+), 17 deletions(-) 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 index fda8d57..26f73a0 100644 --- 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 @@ -23,6 +23,11 @@ class ChatMessageService( afterId: Long, ): List = msgRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId) + @Transactional + fun deleteByRoom(roomId: Long) { + msgRepository.deleteByRoomId(roomId) + } + @Transactional fun send( roomId: Long, 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 fcce087..432b365 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,9 +1,12 @@ 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 @@ -13,6 +16,7 @@ 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( @@ -20,34 +24,60 @@ class ChatRoomController( private val msgSvc: ChatMessageService, private val events: UserChatSseEvents, ) { - // Room API - @PostMapping - fun create( - @RequestBody req: ChatRoomService.CreateRoomReq, - ) = roomSvc.create(req) + 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, - ) = roomSvc.get(roomId) + ) = ResponseEntity.ok(ApiResponse(msg = "채팅방 조회", data = roomSvc.get(roomId))) - // Message API (룸 하위 리소스) @GetMapping("/{roomId}/messages") fun listMessages( @PathVariable roomId: Long, @RequestParam(required = false) after: Long?, @RequestParam(defaultValue = "50") limit: Int, - ) = if (after == null) msgSvc.getlistbefore(roomId, limit) else msgSvc.getlistafter(roomId, after) + ): 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, - ) = msgSvc.send(roomId, req).also { saved -> + ): ResponseEntity> { + val saved = msgSvc.send(roomId, req) events.publishNew(roomId, saved.id!!) + return ResponseEntity.status(201).body(ApiResponse(msg = "메시지 전송", data = saved)) } - // SSE 구독도 여기 포함 + // SSE는 스트림이여서 ApiResponse로 감싸지 않았음 + // WebSocket,Stomp 적용되면 바로 삭제 예정 @GetMapping("/{roomId}/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) fun subscribe( @PathVariable roomId: Long, 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 index 28f8434..75a0965 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 @@ -10,11 +10,35 @@ import java.time.Instant class ChatRoomService( private val roomRepository: ChatRoomRepository, ) { - data class CreateRoomReq(val title: String, val ownerId: Long) + data class CreateRoomReq(val title: String, val guideId: Long, val userId: Long) @Transactional - fun create(req: CreateRoomReq): ChatRoom = - roomRepository.save(ChatRoom(title = req.title, ownerId = req.ownerId, updatedAt = Instant.now())) + 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) + } }