Skip to content

Commit 0fe0f65

Browse files
committed
Feat: 실시간 스터디룸 채팅 기능 구현
- WebSocket + STOMP 기반 실시간 메시지 전송 - 페이징 기반 채팅 이력 조회 API - 방 채팅 메시지 삭제 기능
1 parent 41a38ba commit 0fe0f65

File tree

18 files changed

+592
-8
lines changed

18 files changed

+592
-8
lines changed

build.gradle.kts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,36 @@ repositories {
2525
}
2626

2727
dependencies {
28-
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
28+
// Spring
2929
implementation("org.springframework.boot:spring-boot-starter-web")
3030
implementation("org.springframework.boot:spring-boot-starter-websocket")
31-
implementation("org.springframework.boot:spring-boot-starter-security")
32-
testImplementation("org.springframework.security:spring-security-test")
33-
compileOnly("org.projectlombok:lombok")
34-
developmentOnly("org.springframework.boot:spring-boot-devtools")
31+
32+
// Database & JPA
33+
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
3534
runtimeOnly("com.h2database:h2")
3635
runtimeOnly("com.mysql:mysql-connector-j")
36+
37+
// QueryDSL
38+
implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")
39+
annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta")
40+
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
41+
annotationProcessor("jakarta.annotation:jakarta.annotation-api")
42+
43+
// Security
44+
implementation("org.springframework.boot:spring-boot-starter-security")
45+
46+
// Development Tools
47+
compileOnly("org.projectlombok:lombok")
3748
annotationProcessor("org.projectlombok:lombok")
49+
developmentOnly("org.springframework.boot:spring-boot-devtools")
50+
51+
// Swagger
52+
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
53+
54+
// Test
3855
testImplementation("org.springframework.boot:spring-boot-starter-test")
56+
testImplementation("org.springframework.security:spring-security-test")
3957
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
40-
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
4158
}
4259

4360
tasks.withType<Test> {

src/main/java/com/back/domain/studyroom/entity/Room.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.back.domain.studyroom.entity;
22

33
import com.back.domain.study.entity.StudyRecord;
4+
import com.back.domain.user.entity.User;
45
import com.back.global.entity.BaseEntity;
56
import jakarta.persistence.*;
67
import lombok.Getter;
@@ -14,6 +15,7 @@
1415
@Getter
1516
public class Room extends BaseEntity {
1617
private String title;
18+
1719
private String description;
1820

1921
private boolean isPrivate;
@@ -30,6 +32,10 @@ public class Room extends BaseEntity {
3032

3133
private boolean allowScreenShare;
3234

35+
@ManyToOne(fetch = FetchType.LAZY)
36+
@JoinColumn(name = "created_by")
37+
private User createdBy;
38+
3339
@ManyToOne(fetch = FetchType.LAZY)
3440
@JoinColumn(name = "theme_id")
3541
private RoomTheme theme;

src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,11 @@ public class RoomChatMessage extends BaseEntity {
2222
private User user;
2323

2424
private String content;
25+
26+
// 채팅 메세지 생성자
27+
public RoomChatMessage(Room room, User user, String content) {
28+
this.room = room;
29+
this.user = user;
30+
this.content = content;
31+
}
2532
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.back.domain.studyroom.repository;
2+
3+
import com.back.domain.studyroom.entity.RoomChatMessage;
4+
import org.springframework.data.domain.Page;
5+
import org.springframework.data.domain.Pageable;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
9+
import org.springframework.stereotype.Repository;
10+
11+
import java.time.LocalDateTime;
12+
import java.util.List;
13+
14+
@Repository
15+
public interface RoomChatMessageRepository extends JpaRepository<RoomChatMessage, Long> {
16+
17+
// 방별 페이징된 채팅 메시지 조회 (무한 스크롤용)
18+
@Query("SELECT m FROM RoomChatMessage m " +
19+
"WHERE m.room.id = :roomId " +
20+
"ORDER BY m.createdAt DESC")
21+
Page<RoomChatMessage> findByRoomIdOrderByCreatedAtDesc(@Param("roomId") Long roomId, Pageable pageable);
22+
23+
// 특정 타임스탬프 이후의 메시지 조회 (실시간 업데이트용)
24+
@Query("SELECT m FROM RoomChatMessage m " +
25+
"WHERE m.room.id = :roomId " +
26+
"AND m.createdAt > :timestamp " +
27+
"ORDER BY m.createdAt ASC")
28+
List<RoomChatMessage> findByRoomIdAfterTimestamp(@Param("roomId") Long roomId,
29+
@Param("timestamp") LocalDateTime timestamp);
30+
31+
// 방별 최근 20개 메시지 조회
32+
List<RoomChatMessage> findTop20ByRoomIdOrderByCreatedAtDesc(Long roomId);
33+
34+
// 방별 전체 메시지 수 조회
35+
long countByRoomId(Long roomId);
36+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.back.domain.studyroom.repository;
2+
3+
import com.back.domain.studyroom.entity.Room;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Query;
6+
import org.springframework.data.repository.query.Param;
7+
import org.springframework.stereotype.Repository;
8+
9+
import java.util.List;
10+
11+
@Repository
12+
public interface RoomRepository extends JpaRepository<Room, Long> {
13+
14+
// 제목으로 방 검색 (부분 일치)
15+
List<Room> findByTitleContaining(String title);
16+
17+
// 활성화된 방 목록 조회
18+
@Query("SELECT r FROM Room r WHERE r.isActive = true")
19+
List<Room> findActiveRooms();
20+
21+
// 사용자가 생성한 방 목록 조회
22+
@Query("SELECT r FROM Room r WHERE r.createdBy.id = :createdById")
23+
List<Room> findByCreatedById(@Param("createdById") Long createdById);
24+
25+
// 방 존재 여부 확인
26+
boolean existsById(Long roomId);
27+
}

src/main/java/com/back/domain/user/entity/PrivateChatMessage.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,6 @@ public class PrivateChatMessage extends BaseEntity {
2121
private User toUser;
2222

2323
private String content;
24+
25+
private boolean isRead = false;
2426
}

src/main/java/com/back/domain/user/entity/User.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,22 @@ public class User extends BaseEntity {
8080

8181
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
8282
private List<FileAttachment> fileAttachments = new ArrayList<>();
83+
84+
// -------------------- 헬퍼 메서드 --------------------
85+
// 현재 사용자의 닉네임 조회
86+
public String getNickname() {
87+
return userProfiles.stream()
88+
.findFirst()
89+
.map(UserProfile::getNickname)
90+
.filter(nickname -> nickname != null && !nickname.trim().isEmpty())
91+
.orElse(this.username);
92+
}
93+
94+
// 현재 사용자의 프로필 이미지 URL 조회
95+
public String getProfileImageUrl() {
96+
return userProfiles.stream()
97+
.findFirst()
98+
.map(UserProfile::getProfileImageUrl)
99+
.orElse(null);
100+
}
83101
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.back.domain.user.repository;
2+
3+
import com.back.domain.user.entity.PrivateChatMessage;
4+
import com.back.domain.user.entity.User;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.Pageable;
7+
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
10+
import org.springframework.stereotype.Repository;
11+
12+
import java.time.LocalDateTime;
13+
import java.util.List;
14+
15+
@Repository
16+
public interface PrivateChatMessageRepository extends JpaRepository<PrivateChatMessage, Long> {
17+
18+
// 두 사용자 간의 페이징된 대화 메시지 조회 (무한 스크롤용)
19+
@Query("SELECT m FROM PrivateChatMessage m " +
20+
"WHERE (m.fromUser.id = :userId1 AND m.toUser.id = :userId2) " +
21+
"OR (m.fromUser.id = :userId2 AND m.toUser.id = :userId1) " +
22+
"ORDER BY m.createdAt DESC")
23+
Page<PrivateChatMessage> findConversationBetweenUsers(@Param("userId1") Long userId1,
24+
@Param("userId2") Long userId2,
25+
Pageable pageable);
26+
27+
// 두 사용자 간의 페이징된 대화 메시지 조회 (무한 스크롤용) - 최신 메시지부터
28+
@Query("SELECT m FROM PrivateChatMessage m " +
29+
"WHERE ((m.fromUser.id = :userId1 AND m.toUser.id = :userId2) " +
30+
"OR (m.fromUser.id = :userId2 AND m.toUser.id = :userId1)) " +
31+
"AND m.createdAt > :timestamp " +
32+
"ORDER BY m.createdAt ASC")
33+
List<PrivateChatMessage> findNewMessagesBetweenUsers(@Param("userId1") Long userId1,
34+
@Param("userId2") Long userId2,
35+
@Param("timestamp") LocalDateTime timestamp);
36+
37+
// 두 사용자 간의 최근 20개 메시지 조회 (초기 로드용)
38+
@Query("SELECT DISTINCT " +
39+
"CASE WHEN m.fromUser.id = :userId THEN m.toUser ELSE m.fromUser END " +
40+
"FROM PrivateChatMessage m " +
41+
"WHERE m.fromUser.id = :userId OR m.toUser.id = :userId")
42+
List<User> findConversationPartners(@Param("userId") Long userId);
43+
44+
// 두 사용자 간의 최신 메시지 조회
45+
@Query("SELECT m FROM PrivateChatMessage m " +
46+
"WHERE (m.fromUser.id = :userId1 AND m.toUser.id = :userId2) " +
47+
"OR (m.fromUser.id = :userId2 AND m.toUser.id = :userId1) " +
48+
"ORDER BY m.createdAt DESC " +
49+
"LIMIT 1")
50+
PrivateChatMessage findLatestMessageBetweenUsers(@Param("userId1") Long userId1,
51+
@Param("userId2") Long userId2);
52+
53+
// 두 사용자 간의 전체 메시지 수 조회
54+
@Query("SELECT COUNT(m) FROM PrivateChatMessage m " +
55+
"WHERE (m.fromUser.id = :userId1 AND m.toUser.id = :userId2) " +
56+
"OR (m.fromUser.id = :userId2 AND m.toUser.id = :userId1)")
57+
long countMessagesBetweenUsers(@Param("userId1") Long userId1, @Param("userId2") Long userId2);
58+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.back.domain.user.repository;
2+
3+
import com.back.domain.user.entity.User;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
@Repository
8+
public interface UserRepository extends JpaRepository<User, Long> {
9+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.back.domain.websocket.controller;
2+
3+
import com.back.domain.websocket.dto.ChatPageResponse;
4+
import com.back.domain.websocket.service.ChatService;
5+
import com.back.global.common.dto.RsData;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.format.annotation.DateTimeFormat;
8+
import org.springframework.http.HttpStatus;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.web.bind.annotation.*;
11+
12+
import java.time.LocalDateTime;
13+
import java.util.Map;
14+
15+
@RestController
16+
@RequestMapping("/api")
17+
@RequiredArgsConstructor
18+
public class ChatApiController {
19+
20+
private final ChatService chatService;
21+
22+
// 방 채팅 메시지 조회 (페이징, 특정 시간 이전 메시지)
23+
@GetMapping("/rooms/{roomId}/messages")
24+
public ResponseEntity<RsData<ChatPageResponse>> getRoomChatMessages(
25+
@PathVariable Long roomId,
26+
@RequestParam(defaultValue = "0") int page,
27+
@RequestParam(defaultValue = "50") int size,
28+
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime before,
29+
@RequestHeader("Authorization") String authorization) {
30+
31+
// size 최대값 제한 (임시: max 100)
32+
if (size > 100) {
33+
size = 100;
34+
}
35+
36+
// TODO: JWT 토큰에서 사용자 정보 추출 및 권한 확인
37+
38+
ChatPageResponse chatHistory = chatService.getRoomChatHistory(roomId, page, size, before);
39+
40+
return ResponseEntity
41+
.status(HttpStatus.OK)
42+
.body(RsData.success("채팅 기록 조회 성공", chatHistory));
43+
}
44+
45+
// 방 채팅 메시지 삭제
46+
@DeleteMapping("/rooms/{roomId}/messages/{messageId}")
47+
public ResponseEntity<RsData<Map<String, Object>>> deleteRoomMessage(
48+
@PathVariable Long roomId,
49+
@PathVariable Long messageId,
50+
@RequestHeader("Authorization") String authorization) {
51+
52+
// TODO: JWT 토큰에서 사용자 정보 추출
53+
54+
// 임시로 하드코딩 (테스트용)
55+
Long currentUserId = 1L;
56+
57+
// 메시지 삭제 로직 실행
58+
chatService.deleteRoomMessage(roomId, messageId, currentUserId);
59+
60+
Map<String, Object> responseData = Map.of(
61+
"messageId", messageId,
62+
"deletedAt", LocalDateTime.now()
63+
);
64+
65+
return ResponseEntity
66+
.status(HttpStatus.OK)
67+
.body(RsData.success("메시지 삭제 성공", responseData));
68+
}
69+
}

0 commit comments

Comments
 (0)