Skip to content

Commit 3f670c8

Browse files
beekeeper24정동하
authored andcommitted
feat(be): userChat 도메인 임시 기능 구현 (#41)
* feat(be): userChat service,controller,SSE 추가 * feat(be) : controller,service,repository 팀 스타일에 맞게 수정
1 parent a16c757 commit 3f670c8

File tree

8 files changed

+227
-4
lines changed

8 files changed

+227
-4
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.back.koreaTravelGuide.domain.userChat
2+
3+
import org.springframework.stereotype.Component
4+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
5+
import java.util.concurrent.ConcurrentHashMap
6+
7+
// Websocket,Stomp 사용 전 임시로 만들었음
8+
// 테스트 후 제거 예정
9+
10+
@Component
11+
class UserChatSseEvents {
12+
private val emitters = ConcurrentHashMap<Long, MutableList<SseEmitter>>()
13+
14+
fun subscribe(roomId: Long): SseEmitter {
15+
val emitter = SseEmitter(0L)
16+
emitters.computeIfAbsent(roomId) { mutableListOf() }.add(emitter)
17+
emitter.onCompletion { emitters[roomId]?.remove(emitter) }
18+
emitter.onTimeout { emitter.complete() }
19+
return emitter
20+
}
21+
22+
fun publishNew(
23+
roomId: Long,
24+
lastMessageId: Long,
25+
) {
26+
emitters[roomId]?.toList()?.forEach {
27+
try {
28+
it.send(SseEmitter.event().name("NEW").data(lastMessageId))
29+
} catch (_: Exception) {
30+
it.complete()
31+
}
32+
}
33+
}
34+
}

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/entity/ChatMessage.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ data class ChatMessage(
2121
val roomId: Long,
2222
@Column(name = "sender_id", nullable = false)
2323
val senderId: Long,
24-
@Column(columnDefinition = "text", nullable = false)
24+
@Column(nullable = false, columnDefinition = "text")
2525
val content: String,
2626
@Column(name = "created_at", nullable = false)
2727
val createdAt: Instant = Instant.now(),

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/repository/ChatMessageRepository.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ interface ChatMessageRepository : JpaRepository<ChatMessage, Long> {
1212
roomId: Long,
1313
afterId: Long,
1414
): List<ChatMessage>
15+
16+
fun deleteByRoomId(roomId: Long)
1517
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.back.koreaTravelGuide.domain.userChat.chatmessage.service
2+
3+
import com.back.koreaTravelGuide.domain.userChat.chatmessage.entity.ChatMessage
4+
import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository
5+
import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository
6+
import org.springframework.stereotype.Service
7+
import org.springframework.transaction.annotation.Transactional
8+
9+
@Service
10+
class ChatMessageService(
11+
private val msgRepository: ChatMessageRepository,
12+
private val roomRepository: ChatRoomRepository,
13+
) {
14+
data class SendMessageReq(val senderId: Long, val content: String)
15+
16+
fun getlistbefore(
17+
roomId: Long,
18+
limit: Int,
19+
): List<ChatMessage> = msgRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed()
20+
21+
fun getlistafter(
22+
roomId: Long,
23+
afterId: Long,
24+
): List<ChatMessage> = msgRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId)
25+
26+
@Transactional
27+
fun deleteByRoom(roomId: Long) {
28+
msgRepository.deleteByRoomId(roomId)
29+
}
30+
31+
@Transactional
32+
fun send(
33+
roomId: Long,
34+
req: SendMessageReq,
35+
): ChatMessage {
36+
val saved = msgRepository.save(ChatMessage(roomId = roomId, senderId = req.senderId, content = req.content))
37+
roomRepository.findById(roomId).ifPresent {
38+
roomRepository.save(it.copy(updatedAt = saved.createdAt, lastMessageId = saved.id))
39+
}
40+
return saved
41+
}
42+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.back.koreaTravelGuide.domain.userChat.chatroom.controller
2+
3+
import com.back.koreaTravelGuide.common.ApiResponse
4+
import com.back.koreaTravelGuide.domain.userChat.UserChatSseEvents
5+
import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService
6+
import com.back.koreaTravelGuide.domain.userChat.chatroom.service.ChatRoomService
7+
import org.springframework.http.MediaType
8+
import org.springframework.http.ResponseEntity
9+
import org.springframework.web.bind.annotation.DeleteMapping
10+
import org.springframework.web.bind.annotation.GetMapping
11+
import org.springframework.web.bind.annotation.PathVariable
12+
import org.springframework.web.bind.annotation.PostMapping
13+
import org.springframework.web.bind.annotation.RequestBody
14+
import org.springframework.web.bind.annotation.RequestMapping
15+
import org.springframework.web.bind.annotation.RequestParam
16+
import org.springframework.web.bind.annotation.RestController
17+
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
18+
19+
// 컨트롤러는 임시로 강사님 스타일 따라서 통합해놓았음. 추후 리팩토링 예정
20+
@RestController
21+
@RequestMapping("/api/userchat/rooms")
22+
class ChatRoomController(
23+
private val roomSvc: ChatRoomService,
24+
private val msgSvc: ChatMessageService,
25+
private val events: UserChatSseEvents,
26+
) {
27+
data class StartChatReq(val guideId: Long, val userId: Long)
28+
29+
data class DeleteChatReq(val userId: Long)
30+
31+
// MVP: 같은 페어는 방 재사용
32+
@PostMapping("/start")
33+
fun startChat(
34+
@RequestBody req: StartChatReq,
35+
): ResponseEntity<ApiResponse<Map<String, Long>>> {
36+
val roomId = roomSvc.exceptOneToOneRoom(req.guideId, req.userId).id!!
37+
return ResponseEntity.ok(ApiResponse(msg = "채팅방 시작", data = mapOf("roomId" to roomId)))
38+
}
39+
40+
@DeleteMapping("/{roomId}")
41+
fun deleteRoom(
42+
@PathVariable roomId: Long,
43+
@RequestBody req: DeleteChatReq,
44+
): ResponseEntity<ApiResponse<Unit>> {
45+
roomSvc.deleteByOwner(roomId, req.userId)
46+
return ResponseEntity.ok(ApiResponse("채팅방 삭제 완료"))
47+
}
48+
49+
@GetMapping("/{roomId}")
50+
fun get(
51+
@PathVariable roomId: Long,
52+
) = ResponseEntity.ok(ApiResponse(msg = "채팅방 조회", data = roomSvc.get(roomId)))
53+
54+
@GetMapping("/{roomId}/messages")
55+
fun listMessages(
56+
@PathVariable roomId: Long,
57+
@RequestParam(required = false) after: Long?,
58+
@RequestParam(defaultValue = "50") limit: Int,
59+
): ResponseEntity<ApiResponse<Any>> {
60+
val messages =
61+
if (after == null) {
62+
msgSvc.getlistbefore(roomId, limit)
63+
} else {
64+
msgSvc.getlistafter(roomId, after)
65+
}
66+
return ResponseEntity.ok(ApiResponse(msg = "메시지 조회", data = messages))
67+
}
68+
69+
@PostMapping("/{roomId}/messages")
70+
fun sendMessage(
71+
@PathVariable roomId: Long,
72+
@RequestBody req: ChatMessageService.SendMessageReq,
73+
): ResponseEntity<ApiResponse<Any>> {
74+
val saved = msgSvc.send(roomId, req)
75+
events.publishNew(roomId, saved.id!!)
76+
return ResponseEntity.status(201).body(ApiResponse(msg = "메시지 전송", data = saved))
77+
}
78+
79+
// SSE는 스트림이여서 ApiResponse로 감싸지 않았음
80+
// WebSocket,Stomp 적용되면 바로 삭제 예정
81+
@GetMapping("/{roomId}/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
82+
fun subscribe(
83+
@PathVariable roomId: Long,
84+
): SseEmitter = events.subscribe(roomId)
85+
}

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/entity/ChatRoom.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ data class ChatRoom(
1919
val id: Long? = null,
2020
@Column(nullable = false)
2121
val title: String,
22-
@Column(name = "owner_id", nullable = false)
23-
val ownerId: Long,
22+
@Column(name = "guide_id", nullable = false)
23+
val guideId: Long,
24+
@Column(name = "user_id", nullable = false)
25+
val userId: Long,
2426
@Column(name = "updated_at", nullable = false)
2527
val updatedAt: Instant = Instant.now(),
2628
@Column(name = "last_message_id")

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,21 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.repository
22

33
import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom
44
import org.springframework.data.jpa.repository.JpaRepository
5+
import org.springframework.data.jpa.repository.Query
56
import org.springframework.stereotype.Repository
67

78
@Repository
8-
interface ChatRoomRepository : JpaRepository<ChatRoom, Long>
9+
interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
10+
// 가이드,유저 방 생성시 중복 생성 방지
11+
@Query(
12+
"""
13+
select r from ChatRoom r
14+
where (r.guideId = :guideId and r.userId = :userId)
15+
or (r.guideId = :userId and r.userId = :guideId)
16+
""",
17+
)
18+
fun findOneToOneRoom(
19+
guideId: Long,
20+
userId: Long,
21+
): ChatRoom?
22+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.back.koreaTravelGuide.domain.userChat.chatroom.service
2+
3+
import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom
4+
import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository
5+
import org.springframework.stereotype.Service
6+
import org.springframework.transaction.annotation.Transactional
7+
import java.time.Instant
8+
9+
@Service
10+
class ChatRoomService(
11+
private val roomRepository: ChatRoomRepository,
12+
) {
13+
data class CreateRoomReq(val title: String, val guideId: Long, val userId: Long)
14+
15+
@Transactional
16+
fun exceptOneToOneRoom(
17+
guideId: Long,
18+
userId: Long,
19+
): ChatRoom {
20+
// 1) 기존 방 재사용
21+
roomRepository.findOneToOneRoom(guideId, userId)?.let { return it }
22+
23+
// 2) 없으면 생성 (동시요청은 DB 유니크 인덱스로 가드)
24+
val title = "Guide-$guideId · User-$userId"
25+
return roomRepository.save(
26+
ChatRoom(title = title, guideId = guideId, userId = userId, updatedAt = Instant.now()),
27+
)
28+
}
29+
30+
fun get(roomId: Long): ChatRoom = roomRepository.findById(roomId).orElseThrow { NoSuchElementException("room not found: $roomId") }
31+
32+
@Transactional
33+
fun deleteByOwner(
34+
roomId: Long,
35+
requesterId: Long,
36+
) {
37+
val room = get(roomId)
38+
if (room.userId != requesterId) {
39+
// 예외처리 임시
40+
throw IllegalArgumentException("채팅방 생성자만 삭제할 수 있습니다.")
41+
}
42+
roomRepository.deleteById(roomId)
43+
}
44+
}

0 commit comments

Comments
 (0)