Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Long, MutableList<SseEmitter>>()

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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ interface ChatMessageRepository : JpaRepository<ChatMessage, Long> {
roomId: Long,
afterId: Long,
): List<ChatMessage>

fun deleteByRoomId(roomId: Long)
}
Original file line number Diff line number Diff line change
@@ -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<ChatMessage> = msgRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed()

fun getlistafter(
roomId: Long,
afterId: Long,
): List<ChatMessage> = 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
}
}
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<Map<String, Long>>> {
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<ApiResponse<Unit>> {
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<ApiResponse<Any>> {
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<ApiResponse<Any>> {
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatRoom, Long>
interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
// 가이드,유저 방 생성시 중복 생성 방지
@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?
}
Original file line number Diff line number Diff line change
@@ -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)
}
}